mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-05-18 19:56:34 -04:00
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:
@@ -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]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user