From 5227507810cf2a3c0e487adc66a9260cb4c8978b Mon Sep 17 00:00:00 2001 From: Strycher Date: Sat, 20 Jun 2026 02:56:52 -0400 Subject: [PATCH] fix(#25): SNR-first repeater ranking (recency as a sub-dB tie-breaker) DirectRepeater.ranking added recencyScore (0..1.8M) to the SNR term (1000/dB), so recency dominated (~1 dB == 1 second), contradicting the comment that recency only breaks ties. Scale recency to a 0-999 bonus so SNR (1000/dB) is primary and recency only decides sub-dB ties. Offset SNR by its floor (+32; SX126x SNR spans -32..+31.75 dB) so every live ranking stays >= 0, above the -1 stale sentinel; otherwise the scaled-down recency leaves live rankings negative and a stale repeater would outrank a live one. Adds DirectRepeater.ranking tests: SNR-first, recency tie-break, stale=-1, live-always-outranks-stale. Co-Authored-By: Claude Opus 4.8 --- lib/connector/meshcore_connector.dart | 13 ++-- .../direct_repeater_ranking_test.dart | 60 +++++++++++++++++++ 2 files changed, 69 insertions(+), 4 deletions(-) create mode 100644 test/connector/direct_repeater_ranking_test.dart diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 6791901..60ebe95 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -66,15 +66,20 @@ class DirectRepeater { int get ranking { if (isStale()) { - return -1; // Stale repeaters get lowest rank + return -1; // Stale repeaters sort last; every live ranking below is >= 0. } - // Higher SNR gets higher rank and recency within maxAgeMinutes breaks ties. + // SNR-first: each dB of SNR is worth 1000 rank points; recency only breaks + // ties between repeaters within ~1 dB of each other (a 0-999 bonus). SX126x + // SNR is an int8 x 0.25 dB value spanning -32.0 .. +31.75 dB, so the +32 + // offset keeps every live ranking >= 0 (above the -1 stale sentinel) while + // preserving SNR order. final ageMs = DateTime.now().millisecondsSinceEpoch - lastUpdated.millisecondsSinceEpoch; final maxAgeMs = maxAgeMinutes * 60 * 1000; - final recencyScore = (maxAgeMs - ageMs).clamp(0, maxAgeMs); - return ((snr - 31.75) * 1000).round() + recencyScore; + final recencyFraction = ((maxAgeMs - ageMs) / maxAgeMs).clamp(0.0, 1.0); + final recencyTieBreak = (recencyFraction * 999).round(); + return ((snr + 32) * 1000).round() + recencyTieBreak; } bool isStale() { diff --git a/test/connector/direct_repeater_ranking_test.dart b/test/connector/direct_repeater_ranking_test.dart new file mode 100644 index 0000000..caaec69 --- /dev/null +++ b/test/connector/direct_repeater_ranking_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/connector/meshcore_connector.dart'; + +void main() { + group('DirectRepeater.ranking (SNR-first)', () { + test('a stronger signal outranks a more-recent but weaker one', () { + // 5 dB better SNR, but heard 24 min earlier — signal margin must win. + final strongOld = DirectRepeater( + pubkeyFirstByte: 1, + snr: 10.0, + lastUpdated: DateTime.now().subtract(const Duration(minutes: 25)), + ); + final weakRecent = DirectRepeater( + pubkeyFirstByte: 2, + snr: 5.0, + lastUpdated: DateTime.now().subtract(const Duration(minutes: 1)), + ); + expect(strongOld.ranking, greaterThan(weakRecent.ranking)); + }); + + test('recency breaks ties between equal-SNR repeaters', () { + final older = DirectRepeater( + pubkeyFirstByte: 1, + snr: 10.0, + lastUpdated: DateTime.now().subtract(const Duration(minutes: 20)), + ); + final newer = DirectRepeater( + pubkeyFirstByte: 2, + snr: 10.0, + lastUpdated: DateTime.now().subtract(const Duration(minutes: 1)), + ); + expect(newer.ranking, greaterThan(older.ranking)); + }); + + test('a stale repeater ranks -1', () { + final stale = DirectRepeater( + pubkeyFirstByte: 1, + snr: 30.0, + lastUpdated: DateTime.now().subtract(const Duration(minutes: 31)), + ); + expect(stale.ranking, -1); + }); + + test('a live repeater always outranks a stale one', () { + // Weak but live vs strong but stale — the live one must win, so the + // stale sentinel (-1) has to sit below every live ranking. + final weakLive = DirectRepeater( + pubkeyFirstByte: 1, + snr: -30.0, + lastUpdated: DateTime.now().subtract(const Duration(minutes: 1)), + ); + final strongStale = DirectRepeater( + pubkeyFirstByte: 2, + snr: 30.0, + lastUpdated: DateTime.now().subtract(const Duration(minutes: 31)), + ); + expect(weakLive.ranking, greaterThan(strongStale.ranking)); + }); + }); +}