feat(discovery): add DiscoveryRankingEngine with 6-level deterministic heuristic (D029)

Implement the spec's ranking and recommendation heuristic:
1. Highest unique discovered node count
2. Highest neighbor-report diversity (direct + mesh)
3. Highest non-duplicate packet count
4. Best median link quality (SNR first, then RSSI)
5. Greatest best known distance
6. Lowest failure/reconnect penalty
Presets tied after all 6 criteria share the same rank with isTied=true.
Includes RankingScoreBreakdown for transparent per-criterion scoring.
11 unit tests covering each criterion as tiebreaker, full ties,
edge cases (empty/single preset, no nodes, failed presets).
Validated: spotlessApply, allTests, kmpSmokeCompile
This commit is contained in:
James Rich
2026-05-07 17:55:38 -05:00
parent 5cb9ea8102
commit 5b98b852e9
3 changed files with 596 additions and 1 deletions

View File

@@ -0,0 +1,197 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.feature.discovery.scan
import org.koin.core.annotation.Single
import org.meshtastic.core.database.entity.DiscoveredNodeEntity
import org.meshtastic.core.database.entity.DiscoveryPresetResultEntity
/** Input bundle for ranking: a preset result together with its discovered nodes. */
data class PresetRankingInput(
val presetResult: DiscoveryPresetResultEntity,
val discoveredNodes: List<DiscoveredNodeEntity>,
)
/** Per-criterion score breakdown for a ranked preset. */
data class RankingScoreBreakdown(
/** Criterion 1: unique discovered node count. */
val uniqueNodeCount: Int,
/** Criterion 2: neighbor-report diversity (direct + mesh neighbor count). */
val neighborDiversity: Int,
/** Criterion 3: non-duplicate packet count (numPacketsRx - numRxDupe). */
val nonDupePacketCount: Int,
/** Criterion 4a: median SNR across discovered nodes. */
val medianSnr: Float,
/** Criterion 4b: median RSSI across discovered nodes (tiebreak within criterion 4). */
val medianRssi: Int,
/** Criterion 5: best known distance to a valid-position node (metres). */
val bestKnownDistance: Double,
/** Criterion 6: failure/reconnect penalty (packet failure rate). */
val failurePenalty: Double,
)
/** Output ranking for a single preset. */
data class PresetRanking(
/** 1-based rank (1 = best). Tied presets share the same rank. */
val rank: Int,
val presetResult: DiscoveryPresetResultEntity,
val scoreBreakdown: RankingScoreBreakdown,
/** True when this preset tied with at least one other after all 6 criteria. */
val isTied: Boolean,
)
/**
* Deterministic 6-level heuristic ranking engine for discovery preset results.
*
* The ranking order (best-first) is:
* 1. Highest unique discovered node count
* 2. Highest neighbor-report diversity (direct + mesh neighbor mentions)
* 3. Highest non-duplicate packet count
* 4. Best median link quality (median SNR first, then median RSSI)
* 5. Greatest best-known distance to a valid-position node
* 6. Lowest failure / reconnect penalty
*
* If two presets still tie after all heuristics they are labelled as tied.
*/
@Single
class DiscoveryRankingEngine {
/**
* Rank the given preset inputs best-to-worst using the 6-level heuristic.
*
* @return sorted list of [PresetRanking] (index 0 = best). Empty input yields empty output.
*/
fun rank(inputs: List<PresetRankingInput>): List<PresetRanking> {
if (inputs.isEmpty()) return emptyList()
val scored = inputs.map { it.toScored() }
val sorted = scored.sortedWith(RANKING_COMPARATOR)
return assignRanks(sorted)
}
// ---- internal helpers ----
private data class ScoredPreset(val presetResult: DiscoveryPresetResultEntity, val breakdown: RankingScoreBreakdown)
private fun PresetRankingInput.toScored(): ScoredPreset {
val pr = presetResult
val nodes = discoveredNodes
val snrValues = nodes.map { it.snr }.sorted()
val rssiValues = nodes.map { it.rssi }.sorted()
return ScoredPreset(
presetResult = pr,
breakdown =
RankingScoreBreakdown(
uniqueNodeCount = pr.uniqueNodes,
neighborDiversity = pr.directNeighborCount + pr.meshNeighborCount,
nonDupePacketCount = (pr.numPacketsRx - pr.numRxDupe).coerceAtLeast(0),
medianSnr = median(snrValues) { it },
medianRssi = medianInt(rssiValues),
bestKnownDistance = nodes.mapNotNull { it.distanceFromUser }.maxOrNull() ?: 0.0,
failurePenalty = pr.packetFailureRate,
),
)
}
private fun assignRanks(sorted: List<ScoredPreset>): List<PresetRanking> {
if (sorted.isEmpty()) return emptyList()
// Detect tie groups: consecutive entries that compare as 0.
val tieFlags = BooleanArray(sorted.size)
for (i in 0 until sorted.size - 1) {
if (RANKING_COMPARATOR.compare(sorted[i], sorted[i + 1]) == 0) {
tieFlags[i] = true
tieFlags[i + 1] = true
}
}
val result = mutableListOf<PresetRanking>()
var currentRank = 1
for (i in sorted.indices) {
if (i > 0 && RANKING_COMPARATOR.compare(sorted[i - 1], sorted[i]) != 0) {
currentRank = i + 1
}
result +=
PresetRanking(
rank = currentRank,
presetResult = sorted[i].presetResult,
scoreBreakdown = sorted[i].breakdown,
isTied = tieFlags[i],
)
}
return result
}
companion object {
/**
* Comparator implementing the 6-level heuristic (best-first ordering). "Higher is better" criteria use
* descending compare (b vs a). "Lower is better" criteria (penalty) use ascending compare (a vs b).
*/
private val RANKING_COMPARATOR =
Comparator<ScoredPreset> { a, b ->
// 1. Highest unique node count
var cmp = b.breakdown.uniqueNodeCount.compareTo(a.breakdown.uniqueNodeCount)
if (cmp != 0) return@Comparator cmp
// 2. Highest neighbor-report diversity
cmp = b.breakdown.neighborDiversity.compareTo(a.breakdown.neighborDiversity)
if (cmp != 0) return@Comparator cmp
// 3. Highest non-duplicate packet count
cmp = b.breakdown.nonDupePacketCount.compareTo(a.breakdown.nonDupePacketCount)
if (cmp != 0) return@Comparator cmp
// 4. Best median link quality: SNR first, then RSSI
cmp = b.breakdown.medianSnr.compareTo(a.breakdown.medianSnr)
if (cmp != 0) return@Comparator cmp
cmp = b.breakdown.medianRssi.compareTo(a.breakdown.medianRssi)
if (cmp != 0) return@Comparator cmp
// 5. Greatest best-known distance
cmp = b.breakdown.bestKnownDistance.compareTo(a.breakdown.bestKnownDistance)
if (cmp != 0) return@Comparator cmp
// 6. Lowest failure/reconnect penalty
a.breakdown.failurePenalty.compareTo(b.breakdown.failurePenalty)
}
/** Compute the median of a sorted float-convertible list. Returns 0 for empty. */
internal fun <T> median(sorted: List<T>, toFloat: (T) -> Float): Float {
if (sorted.isEmpty()) return 0f
val mid = sorted.size / 2
return if (sorted.size % 2 == 0) {
(toFloat(sorted[mid - 1]) + toFloat(sorted[mid])) / 2f
} else {
toFloat(sorted[mid])
}
}
/** Compute the median of a sorted Int list. Returns 0 for empty. */
private fun medianInt(sorted: List<Int>): Int {
if (sorted.isEmpty()) return 0
val mid = sorted.size / 2
return if (sorted.size % 2 == 0) {
(sorted[mid - 1] + sorted[mid]) / 2
} else {
sorted[mid]
}
}
}
}

View File

@@ -0,0 +1,398 @@
/*
* Copyright (c) 2025-2026 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
@file:Suppress("MagicNumber")
package org.meshtastic.feature.discovery
import org.meshtastic.core.database.entity.DiscoveredNodeEntity
import org.meshtastic.core.database.entity.DiscoveryPresetResultEntity
import org.meshtastic.feature.discovery.scan.DiscoveryRankingEngine
import org.meshtastic.feature.discovery.scan.PresetRankingInput
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class DiscoveryRankingEngineTest {
private val engine = DiscoveryRankingEngine()
// ---- Helpers ----
private fun preset(
id: Long = 1,
sessionId: Long = 100,
name: String = "LongFast",
uniqueNodes: Int = 0,
directNeighborCount: Int = 0,
meshNeighborCount: Int = 0,
numPacketsRx: Int = 0,
numRxDupe: Int = 0,
packetFailureRate: Double = 0.0,
) = DiscoveryPresetResultEntity(
id = id,
sessionId = sessionId,
presetName = name,
uniqueNodes = uniqueNodes,
directNeighborCount = directNeighborCount,
meshNeighborCount = meshNeighborCount,
numPacketsRx = numPacketsRx,
numRxDupe = numRxDupe,
packetFailureRate = packetFailureRate,
)
private fun node(
presetResultId: Long = 1,
nodeNum: Long = 1,
snr: Float = 0f,
rssi: Int = 0,
distanceFromUser: Double? = null,
) = DiscoveredNodeEntity(
presetResultId = presetResultId,
nodeNum = nodeNum,
snr = snr,
rssi = rssi,
distanceFromUser = distanceFromUser,
)
private fun input(preset: DiscoveryPresetResultEntity, nodes: List<DiscoveredNodeEntity> = emptyList()) =
PresetRankingInput(preset, nodes)
// ---- Tests ----
@Test
fun emptyInputReturnsEmptyOutput() {
val result = engine.rank(emptyList())
assertTrue(result.isEmpty())
}
@Test
fun singlePresetAlwaysRank1NotTied() {
val p = preset(uniqueNodes = 5)
val result = engine.rank(listOf(input(p)))
assertEquals(1, result.size)
assertEquals(1, result[0].rank)
assertFalse(result[0].isTied)
assertEquals(5, result[0].scoreBreakdown.uniqueNodeCount)
}
@Test
fun criterion1UniqueNodeCountDecides() {
val winner = preset(id = 1, name = "LongFast", uniqueNodes = 10)
val loser = preset(id = 2, name = "ShortFast", uniqueNodes = 3)
val result = engine.rank(listOf(input(loser), input(winner)))
assertEquals(2, result.size)
assertEquals("LongFast", result[0].presetResult.presetName)
assertEquals(1, result[0].rank)
assertEquals("ShortFast", result[1].presetResult.presetName)
assertEquals(2, result[1].rank)
assertFalse(result[0].isTied)
assertFalse(result[1].isTied)
}
@Test
fun criterion2NeighborDiversityBreaksTie() {
val a = preset(id = 1, name = "A", uniqueNodes = 5, directNeighborCount = 3, meshNeighborCount = 4)
val b = preset(id = 2, name = "B", uniqueNodes = 5, directNeighborCount = 1, meshNeighborCount = 2)
val result = engine.rank(listOf(input(b), input(a)))
assertEquals("A", result[0].presetResult.presetName, "Higher neighbor diversity wins")
assertEquals(7, result[0].scoreBreakdown.neighborDiversity)
assertEquals(3, result[1].scoreBreakdown.neighborDiversity)
}
@Test
fun criterion3NonDupePacketCountBreaksTie() {
val a =
preset(
id = 1,
name = "A",
uniqueNodes = 5,
directNeighborCount = 3,
meshNeighborCount = 2,
numPacketsRx = 100,
numRxDupe = 10,
)
val b =
preset(
id = 2,
name = "B",
uniqueNodes = 5,
directNeighborCount = 3,
meshNeighborCount = 2,
numPacketsRx = 80,
numRxDupe = 5,
)
val result = engine.rank(listOf(input(b), input(a)))
assertEquals("A", result[0].presetResult.presetName, "Higher non-dupe packet count wins")
assertEquals(90, result[0].scoreBreakdown.nonDupePacketCount)
assertEquals(75, result[1].scoreBreakdown.nonDupePacketCount)
}
@Test
fun criterion4MedianSnrBreaksTie() {
val pA =
preset(
id = 1,
name = "A",
uniqueNodes = 5,
directNeighborCount = 3,
meshNeighborCount = 2,
numPacketsRx = 50,
)
val pB =
preset(
id = 2,
name = "B",
uniqueNodes = 5,
directNeighborCount = 3,
meshNeighborCount = 2,
numPacketsRx = 50,
)
val nodesA =
listOf(
node(presetResultId = 1, nodeNum = 1, snr = 10f),
node(presetResultId = 1, nodeNum = 2, snr = 8f),
node(presetResultId = 1, nodeNum = 3, snr = 12f),
)
val nodesB =
listOf(
node(presetResultId = 2, nodeNum = 4, snr = 2f),
node(presetResultId = 2, nodeNum = 5, snr = 4f),
node(presetResultId = 2, nodeNum = 6, snr = 3f),
)
val result = engine.rank(listOf(input(pB, nodesB), input(pA, nodesA)))
assertEquals("A", result[0].presetResult.presetName, "Higher median SNR wins")
assertEquals(10f, result[0].scoreBreakdown.medianSnr)
assertEquals(3f, result[1].scoreBreakdown.medianSnr)
}
@Test
fun criterion4MedianRssiBreaksTieOnSnr() {
val pA =
preset(
id = 1,
name = "A",
uniqueNodes = 5,
directNeighborCount = 3,
meshNeighborCount = 2,
numPacketsRx = 50,
)
val pB =
preset(
id = 2,
name = "B",
uniqueNodes = 5,
directNeighborCount = 3,
meshNeighborCount = 2,
numPacketsRx = 50,
)
val nodesA =
listOf(
node(presetResultId = 1, nodeNum = 1, snr = 5f, rssi = -60),
node(presetResultId = 1, nodeNum = 2, snr = 5f, rssi = -50),
node(presetResultId = 1, nodeNum = 3, snr = 5f, rssi = -55),
)
val nodesB =
listOf(
node(presetResultId = 2, nodeNum = 4, snr = 5f, rssi = -90),
node(presetResultId = 2, nodeNum = 5, snr = 5f, rssi = -80),
node(presetResultId = 2, nodeNum = 6, snr = 5f, rssi = -85),
)
val result = engine.rank(listOf(input(pB, nodesB), input(pA, nodesA)))
assertEquals("A", result[0].presetResult.presetName, "Higher median RSSI wins when SNR ties")
}
@Test
fun criterion5BestKnownDistanceBreaksTie() {
val pA =
preset(
id = 1,
name = "A",
uniqueNodes = 5,
directNeighborCount = 3,
meshNeighborCount = 2,
numPacketsRx = 50,
)
val pB =
preset(
id = 2,
name = "B",
uniqueNodes = 5,
directNeighborCount = 3,
meshNeighborCount = 2,
numPacketsRx = 50,
)
val nodesA =
listOf(
node(presetResultId = 1, nodeNum = 1, snr = 5f, rssi = -70, distanceFromUser = 5000.0),
node(presetResultId = 1, nodeNum = 2, snr = 5f, rssi = -70, distanceFromUser = 3000.0),
)
val nodesB =
listOf(
node(presetResultId = 2, nodeNum = 3, snr = 5f, rssi = -70, distanceFromUser = 1000.0),
node(presetResultId = 2, nodeNum = 4, snr = 5f, rssi = -70, distanceFromUser = 500.0),
)
val result = engine.rank(listOf(input(pB, nodesB), input(pA, nodesA)))
assertEquals("A", result[0].presetResult.presetName, "Greater best-known distance wins")
assertEquals(5000.0, result[0].scoreBreakdown.bestKnownDistance)
assertEquals(1000.0, result[1].scoreBreakdown.bestKnownDistance)
}
@Test
fun criterion6LowestFailurePenaltyBreaksTie() {
val pA =
preset(
id = 1,
name = "A",
uniqueNodes = 5,
directNeighborCount = 3,
meshNeighborCount = 2,
numPacketsRx = 50,
packetFailureRate = 0.05,
)
val pB =
preset(
id = 2,
name = "B",
uniqueNodes = 5,
directNeighborCount = 3,
meshNeighborCount = 2,
numPacketsRx = 50,
packetFailureRate = 0.20,
)
val nodesA = listOf(node(presetResultId = 1, nodeNum = 1, snr = 5f, rssi = -70))
val nodesB = listOf(node(presetResultId = 2, nodeNum = 2, snr = 5f, rssi = -70))
val result = engine.rank(listOf(input(pB, nodesB), input(pA, nodesA)))
assertEquals("A", result[0].presetResult.presetName, "Lower failure rate wins")
assertEquals(0.05, result[0].scoreBreakdown.failurePenalty)
}
@Test
fun allCriteriaTiedMarkedAsTied() {
val pA =
preset(
id = 1,
name = "A",
uniqueNodes = 5,
directNeighborCount = 3,
meshNeighborCount = 2,
numPacketsRx = 50,
packetFailureRate = 0.1,
)
val pB =
preset(
id = 2,
name = "B",
uniqueNodes = 5,
directNeighborCount = 3,
meshNeighborCount = 2,
numPacketsRx = 50,
packetFailureRate = 0.1,
)
val nodesA = listOf(node(presetResultId = 1, nodeNum = 1, snr = 5f, rssi = -70, distanceFromUser = 1000.0))
val nodesB = listOf(node(presetResultId = 2, nodeNum = 2, snr = 5f, rssi = -70, distanceFromUser = 1000.0))
val result = engine.rank(listOf(input(pA, nodesA), input(pB, nodesB)))
assertEquals(2, result.size)
assertEquals(1, result[0].rank)
assertEquals(1, result[1].rank, "Tied presets share the same rank")
assertTrue(result[0].isTied)
assertTrue(result[1].isTied)
}
@Test
fun threePresetsWithOneFailedStillRanked() {
val good =
preset(
id = 1,
name = "LongFast",
uniqueNodes = 10,
directNeighborCount = 5,
meshNeighborCount = 3,
numPacketsRx = 100,
packetFailureRate = 0.02,
)
val mediocre =
preset(
id = 2,
name = "MedFast",
uniqueNodes = 5,
directNeighborCount = 2,
meshNeighborCount = 1,
numPacketsRx = 50,
packetFailureRate = 0.10,
)
val failed =
preset(
id = 3,
name = "ShortFast",
uniqueNodes = 0,
directNeighborCount = 0,
meshNeighborCount = 0,
numPacketsRx = 5,
packetFailureRate = 0.9,
)
val result = engine.rank(listOf(input(failed), input(mediocre), input(good)))
assertEquals(3, result.size)
assertEquals("LongFast", result[0].presetResult.presetName)
assertEquals(1, result[0].rank)
assertEquals("MedFast", result[1].presetResult.presetName)
assertEquals(2, result[1].rank)
assertEquals("ShortFast", result[2].presetResult.presetName)
assertEquals(3, result[2].rank)
assertFalse(result[0].isTied)
assertFalse(result[2].isTied)
}
@Test
fun noNodesProducesZeroMediansAndDistance() {
val p = preset(uniqueNodes = 3, numPacketsRx = 20)
val result = engine.rank(listOf(input(p, emptyList())))
assertEquals(0f, result[0].scoreBreakdown.medianSnr)
assertEquals(0, result[0].scoreBreakdown.medianRssi)
assertEquals(0.0, result[0].scoreBreakdown.bestKnownDistance)
}
@Test
fun nodesWithoutDistanceYieldZeroBestDistance() {
val p = preset(id = 1, uniqueNodes = 2)
val nodes =
listOf(
node(presetResultId = 1, nodeNum = 1, snr = 5f, distanceFromUser = null),
node(presetResultId = 1, nodeNum = 2, snr = 3f, distanceFromUser = null),
)
val result = engine.rank(listOf(input(p, nodes)))
assertEquals(0.0, result[0].scoreBreakdown.bestKnownDistance)
}
@Test
fun negativeDupeCountClampedToZero() {
val p = preset(numPacketsRx = 5, numRxDupe = 10) // more dupes than rx — shouldn't go negative
val result = engine.rank(listOf(input(p)))
assertEquals(0, result[0].scoreBreakdown.nonDupePacketCount)
}
}

View File

@@ -74,7 +74,7 @@
## Phase 6 — Summary / analysis (per-preset metrics, charts)
- [ ] **D029** [P] Implement `DiscoveryRankingEngine` deterministic heuristic in `commonMain`.
- [X] **D029** [P] Implement `DiscoveryRankingEngine` deterministic heuristic in `commonMain`.
- [ ] **D030** [P] Build summary presentation models for overview cards, comparison table, and tie explanations.
- [ ] **D031** [P] Implement `DiscoverySummaryScreen` with per-preset ranking, warnings, and partial-session handling.
- [ ] **D032** Add tests for ranking ties, failed presets, and deterministic fallback output.