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 <noreply@anthropic.com>
pull/60/head
Strycher 1 month ago
parent 5687916192
commit 5227507810

@ -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() {

@ -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));
});
});
}
Loading…
Cancel
Save

Powered by TurnKey Linux.