From be403e5a4b74ed0a3625a6d19092fa6a9015859e Mon Sep 17 00:00:00 2001 From: steve Date: Thu, 25 Jun 2026 22:16:19 +0000 Subject: [PATCH] feat: configurable radius for !path hash-collision disambiguation --- Bot_Usage.md | 17 +++++---- commands/path.py | 38 +++++++++++++-------- mcbot.conf.example | 13 +++++++ mcbot.py | 16 ++++++++- tests/test_path_resolve.py | 70 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 133 insertions(+), 21 deletions(-) diff --git a/Bot_Usage.md b/Bot_Usage.md index 1d5a0b3..ce3bd24 100644 --- a/Bot_Usage.md +++ b/Bot_Usage.md @@ -889,12 +889,17 @@ in one reply. Arguments: message → `direct (no path)`. - Reliability note: path hashes can collide (several repeaters share the same leading byte(s)), so results are most reliable on 2- and 3-byte paths. Hops - resolve only against repeater/room contacts. When a hash matches more than - one repeater, the hop is placed only if a candidate is clearly the closest - fit to a known anchor (the bot's own location, the sender, or an - unambiguous neighbouring hop); otherwise it is left unlocated rather than - risk using a far repeater that merely shares the hash. Setting the bot's own - location on the radio improves this disambiguation. + resolve only against repeater/room contacts. A hop matching a single repeater + is always trusted. When a hash matches more than one repeater, the hop is + placed only if the nearest located candidate lies within + `[bot] path_collision_radius_miles` (default 150) of a known anchor — the + bot's own location, the sender, or an unambiguous neighbouring hop — + otherwise it is left unlocated rather than risk a far repeater that merely + shares the hash (e.g. one pulled in by tropospheric ducting). Set the radius + to `0` to disable that bound (collisions then resolve only when ≥2 candidates + are located). Any one anchor enables this — the bot's own location, the + sender, or an unambiguously-located neighbouring hop — so setting the bot's + own location on the radio helps but isn't required. - 10s per-user cooldown; no auth; works in DM and any allowed channel. - Makes a da.gd shortener call per invocation (when ≥2 hops are located). diff --git a/commands/path.py b/commands/path.py index 351deb4..b5b88f9 100644 --- a/commands/path.py +++ b/commands/path.py @@ -97,11 +97,15 @@ async def _resolve_hops(ctx, hops): - only repeater/room contacts are candidates (clients never repeat); - a hop whose hash matches a single contact is trusted, even if distant (a unique hash is unambiguous); - - a colliding hop is placed only when >= 2 of its candidates carry a - location and one is the clear nearest to a trusted anchor (the bot's - own location, the sender, or an already-unambiguous hop). A lone - located candidate among a collision is ambiguous and left unlocated, - so a wrong distance is never shown. + - a colliding hop is resolved to the located candidate nearest a trusted + anchor (the bot's own location, the sender, or an already-unambiguous + hop), but ONLY if that candidate is within cfg.path_collision_radius_miles + of the anchor — a single-hop RF sanity bound. This keeps a correctly- + located local hop even when it's the only located candidate, while + rejecting a far repeater that merely shares the hash (tropo ducting). + Radius 0 disables the bound: collisions then resolve only when >= 2 + candidates are located (the conservative fallback). With no anchors at + all, collisions stay unlocated. """ per_hop = [] for h in hops: @@ -131,16 +135,22 @@ async def _resolve_hops(ctx, hops): anchors.append((located[0][0], located[0][1])) # pass 2: collisions — choose the located candidate nearest a trusted - # anchor, but only when at least two candidates are located (otherwise the - # right one can't be told apart from the impostor → leave unlocated). + # anchor, accepting it only within the configured radius (a one-hop RF + # sanity bound). radius 0 falls back to "place only when >= 2 located". + radius = ctx.bot.cfg.path_collision_radius_miles + + def nearest_anchor_mi(p): + return min(_haversine(p[0], p[1], a[0], a[1], "mi") for a in anchors) + for i, (n, located) in enumerate(per_hop): - if n > 1 and len(located) >= 2 and anchors: - resolved[i] = min( - located, - key=lambda p: min( - _haversine(p[0], p[1], a[0], a[1], "mi") for a in anchors - ), - ) + if n <= 1 or not located or not anchors: + continue + best = min(located, key=nearest_anchor_mi) + if radius > 0: + if nearest_anchor_mi(best) <= radius: + resolved[i] = best + elif len(located) >= 2: + resolved[i] = best return resolved diff --git a/mcbot.conf.example b/mcbot.conf.example index ac1cac5..11e01b2 100644 --- a/mcbot.conf.example +++ b/mcbot.conf.example @@ -85,6 +85,19 @@ enabled = true ; start, then runtime-managed via '!adm command retry N' or the web ; Manage->Commands page (persists across restarts). ;channel_retry_max = 2 +; !path collision disambiguation radius, in MILES. When a path-hop hash matches +; more than one repeater, a candidate is accepted only if it lies within this +; distance of a trusted anchor (the bot's own location, the message sender, or +; an unambiguous neighbouring hop). This rejects a far repeater that merely +; shares the hash (e.g. one pulled in by tropospheric ducting) while keeping a +; correctly-located local hop — even when it is the only located candidate. +; Applies ONLY to hash collisions; a hop matching a single repeater is always +; trusted. Set 0 to disable the bound (collisions then resolve only when >= 2 +; candidates are located). Works whenever the path has at least one anchor — the +; bot's own location, the message sender, or an unambiguously-located +; neighbouring hop — so the bot's own location helps but isn't required. +; Default: 150. +;path_collision_radius_miles = 150 ; Path to the exported radio private key (0600). Default: .privkey ;privkey_path = ./mcbot.privkey ; Comma-separated full pubkeys bootstrapped into the 'owner' group on every diff --git a/mcbot.py b/mcbot.py index 4fba730..da784f9 100755 --- a/mcbot.py +++ b/mcbot.py @@ -415,6 +415,17 @@ class Config: # bot_meta, then the DB value is authoritative and runtime-managed (via # '!adm command retry N' and the web Manage->Commands page). channel_retry_max: int = 2 + # !path collision disambiguation: when a path-hop hash matches more than one + # repeater, a candidate is accepted only if it lies within this many miles of + # a trusted anchor (the bot's own location, the sender, or an unambiguous + # neighbouring hop). This is a single-hop RF sanity bound that rejects a far + # repeater sharing the hash (e.g. a tropospheric-ducting node) while keeping + # a correctly-located local hop. Applies ONLY to collisions; unique hops are + # always trusted. 0 disables it (collisions then resolve only when >= 2 + # candidates are located). Takes effect whenever the path has at least one + # anchor (any of the three above); the bot's own location helps but is not + # required if a neighbouring hop resolves unambiguously. + path_collision_radius_miles: float = 150.0 debug: bool = False # idx -> (name, 16-byte secret) channels: dict[int, tuple[str, bytes]] = field(default_factory=dict) @@ -480,7 +491,7 @@ def target_desc(self) -> str: "channel_logging": {"channels"}, "logging": {"logs_dir", "log_level"}, "bot": {"commands_dir", "enabled", "repeat_tracking", "repeat_timeout", - "channel_retry_max", + "channel_retry_max", "path_collision_radius_miles", "privkey_path", "dm_max_attempts", "dm_flood_after", "dm_max_flood_attempts", "radio_evict_enabled", "radio_evict_headroom", "radio_evict_max_per_run", @@ -583,6 +594,9 @@ def load_config(args) -> Config: cfg.channel_retry_max = min(5, max(0, parser["bot"].getint( "channel_retry_max", cfg.channel_retry_max ))) + cfg.path_collision_radius_miles = max(0.0, parser["bot"].getfloat( + "path_collision_radius_miles", cfg.path_collision_radius_miles + )) pk = parser["bot"].get("privkey_path", "") if pk: cfg.privkey_path = Path(pk) diff --git a/tests/test_path_resolve.py b/tests/test_path_resolve.py index dffba2c..d522fbd 100644 --- a/tests/test_path_resolve.py +++ b/tests/test_path_resolve.py @@ -149,6 +149,72 @@ async def test_sender_anchor_used_when_no_bot_location(): bot.db.close() +async def test_collision_lone_local_within_radius_accepted(): + print("test_collision_lone_local_within_radius_accepted") + bot = make_bot() + await set_bot_location(bot, 32.0, -96.0) + # collision where the only located candidate is the correct LOCAL hop (a + # few miles from the bot); a geoless repeater shares the hash. Within the + # radius it must be kept (the regression the radius restores). + await add_contact(bot, pk("abcd0"), name="local", lat=32.05, lon=-96.05) + await add_contact(bot, pk("abcd1"), name="noloc", lat=None, lon=None) + res = await pathcmd._resolve_hops(ctx_for(bot), ["abcd"]) + check(res[0] is not None and res[0][2] == "local", + f"lone local hop within radius is kept (got {res[0]})") + bot.db.close() + + +async def test_collision_radius_gates_acceptance(): + print("test_collision_radius_gates_acceptance") + bot = make_bot() + await set_bot_location(bot, 32.0, -96.0) + # lone located candidate ~69 mi north of the bot, colliding with a geoless one + await add_contact(bot, pk("abcd0"), name="hop", lat=33.0, lon=-96.0) + await add_contact(bot, pk("abcd1"), name="noloc", lat=None, lon=None) + + bot.cfg.path_collision_radius_miles = 150 + res = await pathcmd._resolve_hops(ctx_for(bot), ["abcd"]) + check(res[0] is not None and res[0][2] == "hop", "within 150mi radius -> accepted") + + bot.cfg.path_collision_radius_miles = 50 + res = await pathcmd._resolve_hops(ctx_for(bot), ["abcd"]) + check(res == [None], "beyond 50mi radius -> rejected") + + bot.cfg.path_collision_radius_miles = 0 + res = await pathcmd._resolve_hops(ctx_for(bot), ["abcd"]) + check(res == [None], "radius 0 -> lone candidate never trusted (conservative)") + bot.db.close() + + +async def test_neighbour_hop_anchors_without_bot_or_sender(): + print("test_neighbour_hop_anchors_without_bot_or_sender") + bot = make_bot() # no bot location, no sender + # hop 1 matches a single located repeater -> resolves and becomes an anchor + await add_contact(bot, pk("aaaa"), name="anchorhop", lat=32.0, lon=-96.0) + # hop 2 collides: a nearby located hop + a geoless one. The only anchor is + # the neighbouring hop, which must be enough to keep the local candidate. + await add_contact(bot, pk("abcd0"), name="near", lat=32.1, lon=-96.1) + await add_contact(bot, pk("abcd1"), name="noloc", lat=None, lon=None) + res = await pathcmd._resolve_hops(ctx_for(bot), ["aaaa", "abcd"]) + check(res[0] is not None and res[0][2] == "anchorhop", "unambiguous hop resolved") + check(res[1] is not None and res[1][2] == "near", + "neighbouring located hop anchors the collision (no bot/sender loc)") + bot.db.close() + + +async def test_radius_zero_still_disambiguates_two_located(): + print("test_radius_zero_still_disambiguates_two_located") + bot = make_bot() + await set_bot_location(bot, 32.0, -96.0) + await add_contact(bot, pk("abcd0"), name="near", lat=32.1, lon=-96.1) + await add_contact(bot, pk("abcd1"), name="far", lat=30.0, lon=-92.0) + bot.cfg.path_collision_radius_miles = 0 + res = await pathcmd._resolve_hops(ctx_for(bot), ["abcd"]) + check(res[0] is not None and res[0][2] == "near", + "radius 0 still picks nearest when >=2 located") + bot.db.close() + + async def main(): for t in ( test_collision_only_far_has_geo_is_unlocated, @@ -157,6 +223,10 @@ async def main(): test_non_repeater_excluded, test_unique_geoless_hop_is_unlocated, test_sender_anchor_used_when_no_bot_location, + test_collision_lone_local_within_radius_accepted, + test_collision_radius_gates_acceptance, + test_neighbour_hop_anchors_without_bot_or_sender, + test_radius_zero_still_disambiguates_two_located, ): await t() print()