From a371d35bfd4d9eb8c96f0c1e8a2195515ea6d664 Mon Sep 17 00:00:00 2001 From: Kpa-clawbot Date: Tue, 21 Apr 2026 09:09:39 -0700 Subject: [PATCH] feat(#847): dedupe Top Longest Hops by pair + add obs count and SNR cues (#848) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem The "Top 20 Longest Hops" RF analytics card shows the same repeater pair filling most slots because the query sorts raw hop records by distance with no pair deduplication. A single long link observed 12+ times dominates the leaderboard. ## Fix Dedupe by unordered `(pk1, pk2)` pair. Per pair, keep the max-distance record and compute reliability metrics: | Column | Description | |--------|-------------| | **Obs** | Total observations of this link | | **Best SNR** | Maximum SNR seen (dB) | | **Median SNR** | Median SNR across all observations (dB) | Tooltip on each row shows the timestamp of the best observation. ### Before | # | From | To | Distance | Type | SNR | Packet | |---|------|----|----------|------|-----|--------| | 1 | NodeX | NodeY | 200 mi | R↔R | 5 dB | abc… | | 2 | NodeX | NodeY | 199 mi | R↔R | 6 dB | def… | | 3 | NodeX | NodeY | 198 mi | R↔R | 4 dB | ghi… | ### After | # | From | To | Distance | Type | Obs | Best SNR | Median SNR | Packet | |---|------|----|----------|------|-----|----------|------------|--------| | 1 | NodeX | NodeY | 200 mi | R↔R | 12 | 8.0 dB | 5.2 dB | abc… | | 2 | NodeA | NodeB | 150 mi | C↔R | 3 | 6.5 dB | 6.5 dB | jkl… | ## Changes - **`cmd/server/store.go`**: Group `filteredHops` by unordered pair key, accumulate obs count / best SNR / median SNR per group, sort by max distance, take top 20 - **`cmd/server/types.go`**: Update `DistanceHop` struct — replace `SNR` with `BestSnr`, `MedianSnr`, add `ObsCount` - **`public/analytics.js`**: Replace single SNR column with Obs, Best SNR, Median SNR; add row tooltip with best observation timestamp - **`cmd/server/store_tophops_test.go`**: 3 unit tests — basic dedupe, reverse-pair merge, nil SNR edge case ## Test Coverage - `TestDedupeTopHopsByPair`: 5 records on pair (A,B) + 1 on (C,D) → 2 results, correct obsCount/dist/bestSnr/medianSnr - `TestDedupeTopHopsReversePairMerges`: (B,A) and (A,B) merge into one entry - `TestDedupeTopHopsNilSNR`: all-nil SNR records → bestSnr and medianSnr both nil - Existing `TestAnalyticsRFEndpoint` and `TestAnalyticsRFWithRegion` still pass Closes #847 --------- Co-authored-by: you --- cmd/server/store.go | 92 ++++++++++++++++---- cmd/server/store_tophops_test.go | 116 +++++++++++++++++++++++++ cmd/server/testdata/golden/shapes.json | 14 ++- cmd/server/types.go | 4 +- public/analytics.js | 9 +- 5 files changed, 212 insertions(+), 23 deletions(-) create mode 100644 cmd/server/store_tophops_test.go diff --git a/cmd/server/store.go b/cmd/server/store.go index b2ffec40..2df6de40 100644 --- a/cmd/server/store.go +++ b/cmd/server/store.go @@ -247,6 +247,82 @@ type distHopRecord struct { tx *StoreTx } +// dedupeHopsByPair groups hops by unordered node-pair, keeps the max-distance +// record per pair, and computes obsCount / bestSnr / medianSnr. limit caps the +// number of returned entries (sorted by distance descending). +func dedupeHopsByPair(hops []distHopRecord, limit int) []map[string]interface{} { + type pairAgg struct { + best *distHopRecord + obsCount int + maxSNR *float64 + snrs []float64 + } + pairMap := make(map[string]*pairAgg) + for i := range hops { + h := &hops[i] + pk1, pk2 := h.FromPk, h.ToPk + if pk1 > pk2 { + pk1, pk2 = pk2, pk1 + } + key := pk1 + "|" + pk2 + agg, ok := pairMap[key] + if !ok { + agg = &pairAgg{} + pairMap[key] = agg + } + agg.obsCount++ + if h.SNR != nil { + agg.snrs = append(agg.snrs, *h.SNR) + if agg.maxSNR == nil || *h.SNR > *agg.maxSNR { + v := *h.SNR + agg.maxSNR = &v + } + } + if agg.best == nil || h.Dist > agg.best.Dist { + agg.best = h + } + } + type pairEntry struct { + key string + agg *pairAgg + } + pairs := make([]pairEntry, 0, len(pairMap)) + for k, v := range pairMap { + pairs = append(pairs, pairEntry{k, v}) + } + sort.Slice(pairs, func(i, j int) bool { return pairs[i].agg.best.Dist > pairs[j].agg.best.Dist }) + result := make([]map[string]interface{}, 0, min(limit, len(pairs))) + for i, pe := range pairs { + if i >= limit { + break + } + h := pe.agg.best + var medianSNR *float64 + if len(pe.agg.snrs) > 0 { + sorted := make([]float64, len(pe.agg.snrs)) + copy(sorted, pe.agg.snrs) + sort.Float64s(sorted) + mid := len(sorted) / 2 + if len(sorted)%2 == 0 { + v := (sorted[mid-1] + sorted[mid]) / 2 + medianSNR = &v + } else { + v := sorted[mid] + medianSNR = &v + } + } + result = append(result, map[string]interface{}{ + "fromName": h.FromName, "fromPk": h.FromPk, + "toName": h.ToName, "toPk": h.ToPk, + "dist": h.Dist, "type": h.Type, + "bestSnr": floatPtrOrNil(pe.agg.maxSNR), "medianSnr": floatPtrOrNil(medianSNR), + "obsCount": pe.agg.obsCount, + "hash": h.Hash, "timestamp": h.Timestamp, + }) + } + return result +} + type distPathRecord struct { Hash string TotalDist float64 @@ -5114,21 +5190,7 @@ func (s *PacketStore) computeAnalyticsDistance(region string) map[string]interfa } } - // Sort and pick top hops - sort.Slice(filteredHops, func(i, j int) bool { return filteredHops[i].Dist > filteredHops[j].Dist }) - topHops := make([]map[string]interface{}, 0) - for i := range filteredHops { - if i >= 50 { - break - } - h := &filteredHops[i] - topHops = append(topHops, map[string]interface{}{ - "fromName": h.FromName, "fromPk": h.FromPk, - "toName": h.ToName, "toPk": h.ToPk, - "dist": h.Dist, "type": h.Type, - "snr": floatPtrOrNil(h.SNR), "hash": h.Hash, "timestamp": h.Timestamp, - }) - } + topHops := dedupeHopsByPair(filteredHops, 20) // Sort and pick top paths sort.Slice(filteredPaths, func(i, j int) bool { return filteredPaths[i].TotalDist > filteredPaths[j].TotalDist }) diff --git a/cmd/server/store_tophops_test.go b/cmd/server/store_tophops_test.go new file mode 100644 index 00000000..6fbbd141 --- /dev/null +++ b/cmd/server/store_tophops_test.go @@ -0,0 +1,116 @@ +package main + +import ( + "testing" +) + +func f64(v float64) *float64 { return &v } + +func TestDedupeTopHopsByPair(t *testing.T) { + hops := []distHopRecord{ + {FromPk: "AAA", ToPk: "BBB", FromName: "A", ToName: "B", Dist: 100, Type: "R↔R", SNR: f64(5.0), Hash: "h1", Timestamp: "t1"}, + {FromPk: "AAA", ToPk: "BBB", FromName: "A", ToName: "B", Dist: 90, Type: "R↔R", SNR: f64(8.0), Hash: "h2", Timestamp: "t2"}, + {FromPk: "BBB", ToPk: "AAA", FromName: "B", ToName: "A", Dist: 80, Type: "R↔R", SNR: f64(3.0), Hash: "h3", Timestamp: "t3"}, + {FromPk: "AAA", ToPk: "BBB", FromName: "A", ToName: "B", Dist: 70, Type: "R↔R", SNR: f64(6.0), Hash: "h4", Timestamp: "t4"}, + {FromPk: "AAA", ToPk: "BBB", FromName: "A", ToName: "B", Dist: 60, Type: "R↔R", SNR: f64(4.0), Hash: "h5", Timestamp: "t5"}, + {FromPk: "CCC", ToPk: "DDD", FromName: "C", ToName: "D", Dist: 50, Type: "C↔R", SNR: f64(7.0), Hash: "h6", Timestamp: "t6"}, + } + + result := dedupeHopsByPair(hops, 20) + + if len(result) != 2 { + t.Fatalf("expected 2 entries, got %d", len(result)) + } + + // First entry: A↔B pair, max distance = 100, obsCount = 5 + ab := result[0] + if ab["dist"].(float64) != 100 { + t.Errorf("expected dist 100, got %v", ab["dist"]) + } + if ab["obsCount"].(int) != 5 { + t.Errorf("expected obsCount 5, got %v", ab["obsCount"]) + } + if ab["hash"].(string) != "h1" { + t.Errorf("expected hash h1 (from max-dist record), got %v", ab["hash"]) + } + if ab["bestSnr"].(float64) != 8.0 { + t.Errorf("expected bestSnr 8.0, got %v", ab["bestSnr"]) + } + // medianSnr of [3,4,5,6,8] = 5.0 + if ab["medianSnr"].(float64) != 5.0 { + t.Errorf("expected medianSnr 5.0, got %v", ab["medianSnr"]) + } + + // Second entry: C↔D pair + cd := result[1] + if cd["dist"].(float64) != 50 { + t.Errorf("expected dist 50, got %v", cd["dist"]) + } + if cd["obsCount"].(int) != 1 { + t.Errorf("expected obsCount 1, got %v", cd["obsCount"]) + } +} + +func TestDedupeTopHopsReversePairMerges(t *testing.T) { + hops := []distHopRecord{ + {FromPk: "BBB", ToPk: "AAA", FromName: "B", ToName: "A", Dist: 50, Type: "R↔R", Hash: "h1"}, + {FromPk: "AAA", ToPk: "BBB", FromName: "A", ToName: "B", Dist: 80, Type: "R↔R", Hash: "h2"}, + } + result := dedupeHopsByPair(hops, 20) + if len(result) != 1 { + t.Fatalf("expected 1 entry, got %d", len(result)) + } + if result[0]["obsCount"].(int) != 2 { + t.Errorf("expected obsCount 2, got %v", result[0]["obsCount"]) + } + if result[0]["dist"].(float64) != 80 { + t.Errorf("expected dist 80, got %v", result[0]["dist"]) + } +} + +func TestDedupeTopHopsNilSNR(t *testing.T) { + hops := []distHopRecord{ + {FromPk: "AAA", ToPk: "BBB", FromName: "A", ToName: "B", Dist: 100, Type: "R↔R", SNR: nil, Hash: "h1"}, + {FromPk: "AAA", ToPk: "BBB", FromName: "A", ToName: "B", Dist: 90, Type: "R↔R", SNR: nil, Hash: "h2"}, + } + result := dedupeHopsByPair(hops, 20) + if len(result) != 1 { + t.Fatalf("expected 1 entry, got %d", len(result)) + } + if result[0]["bestSnr"] != nil { + t.Errorf("expected bestSnr nil, got %v", result[0]["bestSnr"]) + } + if result[0]["medianSnr"] != nil { + t.Errorf("expected medianSnr nil, got %v", result[0]["medianSnr"]) + } +} + +func TestDedupeTopHopsLimit(t *testing.T) { + // Generate 25 unique pairs, verify limit=20 caps output + hops := make([]distHopRecord, 25) + for i := range hops { + hops[i] = distHopRecord{ + FromPk: "A", ToPk: string(rune('a' + i)), + Dist: float64(i), Type: "R↔R", Hash: "h", + } + } + result := dedupeHopsByPair(hops, 20) + if len(result) != 20 { + t.Errorf("expected 20 entries, got %d", len(result)) + } +} + +func TestDedupeTopHopsEvenMedian(t *testing.T) { + // Even count: median = avg of two middle values + hops := []distHopRecord{ + {FromPk: "A", ToPk: "B", Dist: 10, Type: "R↔R", SNR: f64(2.0), Hash: "h1"}, + {FromPk: "A", ToPk: "B", Dist: 20, Type: "R↔R", SNR: f64(4.0), Hash: "h2"}, + {FromPk: "A", ToPk: "B", Dist: 30, Type: "R↔R", SNR: f64(6.0), Hash: "h3"}, + {FromPk: "A", ToPk: "B", Dist: 40, Type: "R↔R", SNR: f64(8.0), Hash: "h4"}, + } + result := dedupeHopsByPair(hops, 20) + // sorted SNR: [2,4,6,8], median = (4+6)/2 = 5.0 + if result[0]["medianSnr"].(float64) != 5.0 { + t.Errorf("expected medianSnr 5.0, got %v", result[0]["medianSnr"]) + } +} diff --git a/cmd/server/testdata/golden/shapes.json b/cmd/server/testdata/golden/shapes.json index d715e89a..4556ddd5 100644 --- a/cmd/server/testdata/golden/shapes.json +++ b/cmd/server/testdata/golden/shapes.json @@ -42,14 +42,20 @@ "type": { "type": "string" }, - "snr": { - "type": "number" - }, "hash": { "type": "string" }, "timestamp": { "type": "string" + }, + "bestSnr": { + "type": "number" + }, + "medianSnr": { + "type": "number" + }, + "obsCount": { + "type": "number" } } } @@ -1580,4 +1586,4 @@ } } } -} \ No newline at end of file +} diff --git a/cmd/server/types.go b/cmd/server/types.go index 76ea0392..fc4cbd1d 100644 --- a/cmd/server/types.go +++ b/cmd/server/types.go @@ -678,7 +678,9 @@ type DistanceHop struct { ToPk string `json:"toPk"` Dist float64 `json:"dist"` Type string `json:"type"` - SNR interface{} `json:"snr"` + BestSnr interface{} `json:"bestSnr"` + MedianSnr interface{} `json:"medianSnr"` + ObsCount int `json:"obsCount"` Hash string `json:"hash"` Timestamp string `json:"timestamp"` } diff --git a/public/analytics.js b/public/analytics.js index 9eb080d0..27e8fd4d 100644 --- a/public/analytics.js +++ b/public/analytics.js @@ -1937,15 +1937,18 @@ } // Top hops leaderboard - html += `

🏆 Top 20 Longest Hops

`; + html += `

🏆 Top 20 Longest Hops

#FromToDistance (${distUnitLabel})TypeSNRPacket
`; const top20 = data.topHops.slice(0, 20); top20.forEach((h, i) => { const fromLink = h.fromPk ? `${esc(h.fromName)}` : esc(h.fromName || '?'); const toLink = h.toPk ? `${esc(h.toName)}` : esc(h.toName || '?'); - const snr = h.snr != null ? h.snr + ' dB' : '—'; + const bestSnr = h.bestSnr != null ? Number(h.bestSnr).toFixed(1) + ' dB' : '—'; + const medianSnr = h.medianSnr != null ? Number(h.medianSnr).toFixed(1) + ' dB' : '—'; + const obs = h.obsCount != null ? h.obsCount : 1; const pktLink = h.hash ? `${esc(h.hash.slice(0, 12))}…` : '—'; const mapBtn = h.fromPk && h.toPk ? `` : ''; - html += ``; + const tsTitle = h.timestamp ? `Best observation: ${h.timestamp}` : ''; + html += ``; }); html += `
#FromToDistance (${distUnitLabel})TypeObsBest SNRMedian SNRPacket
${i+1}${fromLink}${toLink}${formatDistance(h.dist)}${esc(h.type)}${snr}${pktLink}${mapBtn}
${i+1}${fromLink}${toLink}${formatDistance(h.dist)}${esc(h.type)}${obs}${bestSnr}${medianSnr}${pktLink}${mapBtn}
`;