From 0c4eeec7d1924bdcf52283e40fa89ab9c1db0458 Mon Sep 17 00:00:00 2001 From: dborup Date: Mon, 5 Oct 2026 13:38:57 +0000 Subject: [PATCH 1/2] test(analytics): reproduce random order of tied analytics list entries (#256) Analytics lists built by ranging over a map and sorted on one key put entries with equal values in a different order on each recompute, and in lists capped at N it was random which tied entries made the cut. Each new test computes a list 50 times and requires one fully specified order: Hash Stats adopters (multiByteNodes) and topHops; channels, topSenders and channelTimeline; RF payloadTypes and snrByType; topology topRepeaters, topPairs, observers, perObserverReach rings, multiObsNodes and bestPathList; distance topHops (dedupeHopsByPair); subpaths (rankSubpaths). All 15 assertions are red on master (30 of 30 runs). Co-Authored-By: Claude Opus 5.5 --- cmd/server/analytics_tie_order_256_test.go | 336 +++++++++++++++++++++ 1 file changed, 336 insertions(+) create mode 100644 cmd/server/analytics_tie_order_256_test.go diff --git a/cmd/server/analytics_tie_order_256_test.go b/cmd/server/analytics_tie_order_256_test.go new file mode 100644 index 000000000..25d44967d --- /dev/null +++ b/cmd/server/analytics_tie_order_256_test.go @@ -0,0 +1,336 @@ +package main + +import ( + "encoding/json" + "fmt" + "reflect" + "sort" + "strings" + "testing" + "time" +) + +// Issue #256: analytics lists are built by ranging over a map and were sorted +// on one key only. Go map iteration order is random, so entries with equal +// values came out in a different order on each recompute, and in lists capped +// at N it was random which tied entries made the cut. These tests compute each +// list many times and require the same, fully specified order every time. + +const tieOrderRuns = 50 + +// newTieOrderStore builds an in-memory store over packets with an empty node +// cache, so hops stay unresolved and no DB is needed. +func newTieOrderStore(packets []*StoreTx) *PacketStore { + s := newChannelTestStore(packets) + s.nodeCache = []nodeInfo{} + s.nodePM = buildPrefixMap(nil) + s.nodeCacheTime = time.Now() + return s +} + +// assertSameEveryRun calls compute tieOrderRuns times and fails unless every +// result equals want. +func assertSameEveryRun(t *testing.T, name string, want interface{}, compute func() interface{}) { + t.Helper() + for run := 0; run < tieOrderRuns; run++ { + if got := compute(); !reflect.DeepEqual(got, want) { + t.Errorf("%s, run %d:\n got %v\n want %v", name, run, got, want) + return + } + } +} + +// mapField projects one field of each entry of a []map[string]interface{}. +func mapField(list interface{}, field string) []string { + out := []string{} + for _, e := range list.([]map[string]interface{}) { + out = append(out, fmt.Sprint(e[field])) + } + return out +} + +func makeHashSizeAdvert(pubkey, name, firstSeen string) *StoreTx { + pt := PayloadADVERT + d, _ := json.Marshal(map[string]interface{}{"pubKey": pubkey, "name": name}) + return &StoreTx{ + // Header 0x11: flood route, ADVERT. Path byte 0x41: 2-byte hashes, 1 hop. + RawHex: "1141aabbccdd", + Hash: "adv-" + pubkey + "-" + firstSeen, + FirstSeen: firstSeen, + PayloadType: &pt, + DecodedJSON: string(d), + } +} + +func TestHashSizesMultiByteNodesTieOrder_256(t *testing.T) { + // pubkey → adverts. Six nodes tie on 3, inserted in an order that is not + // the expected one. + counts := []struct { + pk string + n int + }{ + {"f6000000", 3}, {"a1000000", 3}, {"d4000000", 3}, {"0b000000", 5}, + {"c3000000", 3}, {"b2000000", 3}, {"e5000000", 3}, {"99000000", 1}, + } + var packets []*StoreTx + for _, c := range counts { + for i := 0; i < c.n; i++ { + packets = append(packets, makeHashSizeAdvert(c.pk, "N-"+c.pk, fmt.Sprintf("2026-05-01T12:%02d:00Z", i))) + } + } + s := newTieOrderStore(packets) + + want := []string{"0b000000", "a1000000", "b2000000", "c3000000", "d4000000", "e5000000", "f6000000", "99000000"} + assertSameEveryRun(t, "multiByteNodes pubkeys", want, func() interface{} { + return mapField(s.computeAnalyticsHashSizes("", "")["multiByteNodes"], "pubkey") + }) +} + +func TestHashSizesTopHopsTieOrder_256(t *testing.T) { + // 60 distinct 2-byte hops, each seen twice, plus one seen three times. + // The list is capped at 50, so the tie also decides which hops are listed. + var packets []*StoreTx + var hops []string + for i := 0; i < 60; i++ { + hops = append(hops, fmt.Sprintf("%04x", (i*37)%256+0x1000)) + } + add := func(hop string) { + pt := PayloadTXT_MSG + packets = append(packets, &StoreTx{ + RawHex: "0941aabb", Hash: fmt.Sprintf("h%d", len(packets)), + FirstSeen: "2026-05-01T12:00:00Z", PayloadType: &pt, + PathJSON: `["` + hop + `"]`, + }) + } + for _, h := range hops { + add(h) + add(h) + } + add("ffff") + add("ffff") + add("ffff") + s := newTieOrderStore(packets) + + sorted := append([]string(nil), hops...) + sort.Strings(sorted) + want := append([]string{"ffff"}, sorted[:49]...) + assertSameEveryRun(t, "topHops hex", want, func() interface{} { + return mapField(s.computeAnalyticsHashSizes("", "")["topHops"], "hex") + }) +} + +func TestChannelAnalyticsTieOrder_256(t *testing.T) { + var packets []*StoreTx + add := func(chHash int, channel, sender, firstSeen string) { + tx := makeGrpTxWithStatus(chHash, channel, "hi", sender, "decrypted") + tx.ID = len(packets) + 1 + tx.FirstSeen = firstSeen + packets = append(packets, tx) + } + // Six channels with 2 messages each, one with 4. Each message has its own + // sender (20 senders with 1 message, capped at 15), plus one sender with 3. + sender := 0 + nextSender := func() string { sender++; return fmt.Sprintf("s%02d", (sender*7)%23) } + for _, ch := range []int{60, 10, 50, 20, 40, 30} { + for i := 0; i < 2; i++ { + add(ch, fmt.Sprintf("#c%d", ch), nextSender(), "2026-05-01T12:00:00Z") + } + } + for i := 0; i < 4; i++ { + add(70, "#c70", "zz-top", "2026-05-01T13:00:00Z") + } + for i := 0; i < 8; i++ { + add(80, "#c80", nextSender(), "2026-05-01T14:00:00Z") + } + s := newTieOrderStore(packets) + s.byPayloadType[5] = packets + compute := func() map[string]interface{} { return s.computeAnalyticsChannels("", "", TimeWindow{}) } + + // channels: messages desc, then hash asc. + assertSameEveryRun(t, "channels hash", []string{"80", "70", "10", "20", "30", "40", "50", "60"}, func() interface{} { + return mapField(compute()["channels"], "hash") + }) + + // topSenders: count desc, then name asc, capped at 15. + var names []string + for k := range senderSet(packets) { + if k != "zz-top" { + names = append(names, k) + } + } + sort.Strings(names) + wantSenders := append([]string{"zz-top"}, names[:14]...) + assertSameEveryRun(t, "topSenders name", wantSenders, func() interface{} { + return mapField(compute()["topSenders"], "name") + }) + + // channelTimeline: hour asc, then channel asc. + wantTL := []string{ + "2026-05-01T12|#c10", "2026-05-01T12|#c20", "2026-05-01T12|#c30", + "2026-05-01T12|#c40", "2026-05-01T12|#c50", "2026-05-01T12|#c60", + "2026-05-01T13|#c70", "2026-05-01T14|#c80", + } + assertSameEveryRun(t, "channelTimeline hour|channel", wantTL, func() interface{} { + out := []string{} + for _, e := range compute()["channelTimeline"].([]map[string]interface{}) { + out = append(out, e["hour"].(string)+"|"+e["channel"].(string)) + } + return out + }) +} + +func senderSet(packets []*StoreTx) map[string]bool { + out := map[string]bool{} + for _, tx := range packets { + var d struct { + Sender string `json:"sender"` + } + json.Unmarshal([]byte(tx.DecodedJSON), &d) + out[d.Sender] = true + } + return out +} + +func TestRFAnalyticsTieOrder_256(t *testing.T) { + var packets []*StoreTx + snr := 5.0 + // Payload types 0..6 each with 2 packets (one observation each), type 8 + // with 3. + add := func(ptype int) { + pt := ptype + id := len(packets) + 1 + packets = append(packets, &StoreTx{ + ID: id, Hash: fmt.Sprintf("rf%d", id), RawHex: "0000", + FirstSeen: "2026-05-01T12:00:00Z", PayloadType: &pt, + Observations: []*StoreObs{{ID: id, ObserverID: "obs1", SNR: &snr, Timestamp: "2026-05-01T12:00:00Z"}}, + }) + } + for _, p := range []int{6, 2, 4, 0, 5, 1, 3} { + add(p) + add(p) + } + add(8) + add(8) + add(8) + s := newTieOrderStore(packets) + compute := func() map[string]interface{} { return s.computeAnalyticsRF("", "", TimeWindow{}) } + + assertSameEveryRun(t, "payloadTypes type", []int{8, 0, 1, 2, 3, 4, 5, 6}, func() interface{} { + v := reflect.ValueOf(compute()["payloadTypes"]) + out := []int{} + for i := 0; i < v.Len(); i++ { + out = append(out, int(v.Index(i).FieldByName("Type").Int())) + } + return out + }) + + // snrByType: count desc, then type name asc. + var tied []string + for _, p := range []int{0, 1, 2, 3, 4, 5, 6} { + tied = append(tied, payloadTypeNames[p]) + } + sort.Strings(tied) + wantSNR := append([]string{payloadTypeNames[8]}, tied...) + assertSameEveryRun(t, "snrByType name", wantSNR, func() interface{} { + v := reflect.ValueOf(compute()["snrByType"]) + out := []string{} + for i := 0; i < v.Len(); i++ { + out = append(out, v.Index(i).FieldByName("Name").String()) + } + return out + }) +} + +func TestTopologyAnalyticsTieOrder_256(t *testing.T) { + var packets []*StoreTx + add := func(obsID, path string) { + id := len(packets) + 1 + packets = append(packets, &StoreTx{ + ID: id, Hash: fmt.Sprintf("tp%d", id), FirstSeen: "2026-05-01T12:00:00Z", + ObserverID: obsID, ObserverName: "Name-" + obsID, PathJSON: path, + }) + } + // Six observers each hear the same six 2-hop paths once, so every hop, + // every pair, every ring entry and every cross-observer entry ties. + observers := []string{"obs-f", "obs-a", "obs-d", "obs-c", "obs-e", "obs-b"} + paths := []string{`["f1","e1"]`, `["a1","b1"]`, `["d1","c1"]`} + for _, o := range observers { + for _, p := range paths { + add(o, p) + } + } + s := newTieOrderStore(packets) + compute := func() map[string]interface{} { return s.computeAnalyticsTopology("", "", TimeWindow{}) } + + assertSameEveryRun(t, "topRepeaters hop", []string{"a1", "b1", "c1", "d1", "e1", "f1"}, func() interface{} { + return mapField(compute()["topRepeaters"], "hop") + }) + assertSameEveryRun(t, "topPairs hopA|hopB", []string{"a1|b1", "c1|d1", "e1|f1"}, func() interface{} { + out := []string{} + for _, e := range compute()["topPairs"].([]map[string]interface{}) { + out = append(out, e["hopA"].(string)+"|"+e["hopB"].(string)) + } + return out + }) + // observers had no sort at all; the first one is the default tab. + assertSameEveryRun(t, "observers id", []string{"obs-a", "obs-b", "obs-c", "obs-d", "obs-e", "obs-f"}, func() interface{} { + return mapField(compute()["observers"], "id") + }) + assertSameEveryRun(t, "perObserverReach obs-a rings", []string{"1:b1,c1,e1", "2:a1,d1,f1"}, func() interface{} { + reach := compute()["perObserverReach"].(map[string]interface{})["obs-a"].(map[string]interface{}) + out := []string{} + for _, r := range reach["rings"].([]map[string]interface{}) { + out = append(out, fmt.Sprintf("%d:%s", r["hops"], strings.Join(mapField(r["nodes"], "hop"), ","))) + } + return out + }) + wantObs := strings.Join([]string{"obs-a", "obs-b", "obs-c", "obs-d", "obs-e", "obs-f"}, ",") + assertSameEveryRun(t, "multiObsNodes hop:observers", []string{ + "a1:" + wantObs, "b1:" + wantObs, "c1:" + wantObs, "d1:" + wantObs, "e1:" + wantObs, "f1:" + wantObs, + }, func() interface{} { + out := []string{} + for _, e := range compute()["multiObsNodes"].([]map[string]interface{}) { + out = append(out, e["hop"].(string)+":"+strings.Join(mapField(e["observers"], "observer_id"), ",")) + } + return out + }) + // bestPathList: minDist asc, then hop asc; among observers tied on + // minDist the lowest observer id is reported. + assertSameEveryRun(t, "bestPathList hop@observer", []string{ + "b1@obs-a", "c1@obs-a", "e1@obs-a", "a1@obs-a", "d1@obs-a", "f1@obs-a", + }, func() interface{} { + out := []string{} + for _, e := range compute()["bestPathList"].([]map[string]interface{}) { + out = append(out, e["hop"].(string)+"@"+e["observer_id"].(string)) + } + return out + }) +} + +func TestDistanceTopHopsTieOrder_256(t *testing.T) { + // Six pairs at exactly the same distance, one farther, one nearer. + var hops []distHopRecord + for _, p := range [][2]string{{"F", "f"}, {"A", "a"}, {"D", "d"}, {"C", "c"}, {"E", "e"}, {"B", "b"}} { + hops = append(hops, distHopRecord{FromPk: p[0], ToPk: p[1], Dist: 42, Hash: "h" + p[0]}) + } + hops = append(hops, distHopRecord{FromPk: "Z", ToPk: "z", Dist: 99, Hash: "hZ"}) + hops = append(hops, distHopRecord{FromPk: "Y", ToPk: "y", Dist: 1, Hash: "hY"}) + + // limit 5 also checks which tied pairs make the cut. + assertSameEveryRun(t, "distance topHops fromPk", []string{"Z", "A", "B", "C", "D"}, func() interface{} { + return mapField(dedupeHopsByPair(hops, 5), "fromPk") + }) +} + +func TestRankSubpathsTieOrder_256(t *testing.T) { + counts := map[string]*subpathAccum{} + for _, p := range []string{"F → G", "A → B", "D → E", "C → D", "E → F", "B → C"} { + counts[p] = &subpathAccum{count: 4, raw: strings.ToLower(strings.ReplaceAll(p, " → ", ","))} + } + counts["X → Y"] = &subpathAccum{count: 9, raw: "x,y"} + s := newTieOrderStore(nil) + + assertSameEveryRun(t, "subpaths path", []string{"X → Y", "A → B", "B → C", "C → D"}, func() interface{} { + return mapField(s.rankSubpaths(counts, 100, 4)["subpaths"], "path") + }) +} From 4688f5bfa0d36713be31aecc6f32a56e35e92f92 Mon Sep 17 00:00:00 2001 From: dborup Date: Mon, 5 Oct 2026 13:38:57 +0000 Subject: [PATCH 2/2] fix(analytics): break ties on the map key in analytics list sorts (#256) computeAnalyticsHashSizes sorted multiByteNodes on packets only, so the Hash Stats adopters table's default ("server order") reshuffled tied rows on every analytics recompute, which made the #226 E2E flaky. Sort by packets descending, then pubkey ascending. The same map-iteration tie problem existed in the other analytics lists the frontend shows in server order or caps at N; each now breaks ties on its unique key (hop, pair, channel hash, sender, payload type, type name, observer id, path). The topology observers list had no sort at all and its first entry is the default observer tab; it is now ordered by id. Among observers tied on minDist, bestPathList now reports the lowest observer id. Only the order of equal values changes. The sorts stay O(n log n) and run in the cached analytics recompute, not per request. Co-Authored-By: Claude Opus 5.5 --- cmd/server/store.go | 121 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 104 insertions(+), 17 deletions(-) diff --git a/cmd/server/store.go b/cmd/server/store.go index 2e4a945b8..ca7e617f5 100644 --- a/cmd/server/store.go +++ b/cmd/server/store.go @@ -670,7 +670,14 @@ func dedupeHopsByPair(hops []distHopRecord, limit int) []map[string]interface{} 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 }) + // #256: pairMap iteration order is random; break distance ties on the + // pair key so the order (and which tied pairs make the limit) is stable. + sort.Slice(pairs, func(i, j int) bool { + if pairs[i].agg.best.Dist != pairs[j].agg.best.Dist { + return pairs[i].agg.best.Dist > pairs[j].agg.best.Dist + } + return pairs[i].key < pairs[j].key + }) result := make([]map[string]interface{}, 0, min(limit, len(pairs))) for i, pe := range pairs { if i >= limit { @@ -6667,8 +6674,14 @@ func (s *PacketStore) computeAnalyticsChannels(region, area string, window TimeW "lastActivity": c.LastActivity, "encrypted": c.Encrypted, }) } + // #256: channelMap iteration order is random; ties break on the channel + // hash (the map key) so equal counts keep one order across recomputes. sort.Slice(channelList, func(i, j int) bool { - return channelList[i]["messages"].(int) > channelList[j]["messages"].(int) + mi, mj := channelList[i]["messages"].(int), channelList[j]["messages"].(int) + if mi != mj { + return mi > mj + } + return channelList[i]["hash"].(string) < channelList[j]["hash"].(string) }) // Top senders @@ -6680,7 +6693,12 @@ func (s *PacketStore) computeAnalyticsChannels(region, area string, window TimeW for n, c := range senderCounts { senderList = append(senderList, senderEntry{n, c}) } - sort.Slice(senderList, func(i, j int) bool { return senderList[i].count > senderList[j].count }) + sort.Slice(senderList, func(i, j int) bool { + if senderList[i].count != senderList[j].count { + return senderList[i].count > senderList[j].count + } + return senderList[i].name < senderList[j].name + }) topSenders := make([]map[string]interface{}, 0) for i, e := range senderList { if i >= 15 { @@ -6701,7 +6719,12 @@ func (s *PacketStore) computeAnalyticsChannels(region, area string, window TimeW tlList = append(tlList, tlEntry{parts[0], parts[1], count}) } } - sort.Slice(tlList, func(i, j int) bool { return tlList[i].hour < tlList[j].hour }) + sort.Slice(tlList, func(i, j int) bool { + if tlList[i].hour != tlList[j].hour { + return tlList[i].hour < tlList[j].hour + } + return tlList[i].channel < tlList[j].channel + }) channelTimeline := make([]map[string]interface{}, 0, len(tlList)) for _, e := range tlList { channelTimeline = append(channelTimeline, map[string]interface{}{ @@ -7138,7 +7161,13 @@ func (s *PacketStore) computeAnalyticsRF(region, area string, window TimeWindow) } payloadTypes = append(payloadTypes, ptEntry{Type: t, Name: name, Count: c}) } - sort.Slice(payloadTypes, func(i, j int) bool { return payloadTypes[i].Count > payloadTypes[j].Count }) + // #256: built from maps; ties break on the map key for a stable order. + sort.Slice(payloadTypes, func(i, j int) bool { + if payloadTypes[i].Count != payloadTypes[j].Count { + return payloadTypes[i].Count > payloadTypes[j].Count + } + return payloadTypes[i].Type < payloadTypes[j].Type + }) // SNR by type type snrTypeEntry struct { @@ -7160,7 +7189,12 @@ func (s *PacketStore) computeAnalyticsRF(region, area string, window TimeWindow) Min: minF64(d.vals), Max: maxF64(d.vals), }) } - sort.Slice(snrByTypeArr, func(i, j int) bool { return snrByTypeArr[i].Count > snrByTypeArr[j].Count }) + sort.Slice(snrByTypeArr, func(i, j int) bool { + if snrByTypeArr[i].Count != snrByTypeArr[j].Count { + return snrByTypeArr[i].Count > snrByTypeArr[j].Count + } + return snrByTypeArr[i].Name < snrByTypeArr[j].Name + }) // Signal over time type sigTimeEntry struct { @@ -8172,7 +8206,17 @@ func (s *PacketStore) computeAnalyticsTopology(region, area string, window TimeW for h, c := range hopFreq { freqList = append(freqList, freqEntry{h, c}) } - sort.Slice(freqList, func(i, j int) bool { return freqList[i].count > freqList[j].count }) + // #256: hopFreq/pairFreq iteration order is random; ties break on the + // hop (pair) key so the order and the top-N cut are stable. + byCountThenHop := func(list []freqEntry) func(i, j int) bool { + return func(i, j int) bool { + if list[i].count != list[j].count { + return list[i].count > list[j].count + } + return list[i].hop < list[j].hop + } + } + sort.Slice(freqList, byCountThenHop(freqList)) topRepeaters := make([]map[string]interface{}, 0) for i, e := range freqList { if i >= 20 { @@ -8192,7 +8236,7 @@ func (s *PacketStore) computeAnalyticsTopology(region, area string, window TimeW for p, c := range pairFreq { pairList = append(pairList, freqEntry{p, c}) } - sort.Slice(pairList, func(i, j int) bool { return pairList[i].count > pairList[j].count }) + sort.Slice(pairList, byCountThenHop(pairList)) topPairs := make([]map[string]interface{}, 0) for i, e := range pairList { if i >= 15 { @@ -8243,6 +8287,11 @@ func (s *PacketStore) computeAnalyticsTopology(region, area string, window TimeW } observers = append(observers, map[string]interface{}{"id": id, "name": n}) } + // #256: built from a map with no sort; the first entry is the frontend's + // default observer tab, so give it a stable order. + sort.Slice(observers, func(i, j int) bool { + return observers[i]["id"].(string) < observers[j]["id"].(string) + }) // Per-observer reachability perObserverReach := map[string]interface{}{} @@ -8274,7 +8323,11 @@ func (s *PacketStore) computeAnalyticsTopology(region, area string, window TimeW rings := make([]map[string]interface{}, 0) for dist, nodeList := range byDist { sort.Slice(nodeList, func(i, j int) bool { - return nodeList[i]["count"].(int) > nodeList[j]["count"].(int) + ci, cj := nodeList[i]["count"].(int), nodeList[j]["count"].(int) + if ci != cj { + return ci > cj + } + return nodeList[i]["hop"].(string) < nodeList[j]["hop"].(string) }) rings = append(rings, map[string]interface{}{"hops": dist, "nodes": nodeList}) } @@ -8300,7 +8353,10 @@ func (s *PacketStore) computeAnalyticsTopology(region, area string, window TimeW "observer_id": obsID, "observer_name": obsName, "minDist": data.minDist, "count": data.count, }) - if bp, ok := bestPath[hop]; !ok || data.minDist < bp["minDist"].(int) { + // #256: perObserver iteration order is random; among observers + // tied on minDist report the lowest observer id. + if bp, ok := bestPath[hop]; !ok || data.minDist < bp["minDist"].(int) || + (data.minDist == bp["minDist"].(int) && obsID < bp["observer_id"].(string)) { bestPath[hop] = map[string]interface{}{ "minDist": data.minDist, "observer_id": obsID, "observer_name": obsName, } @@ -8315,7 +8371,11 @@ func (s *PacketStore) computeAnalyticsTopology(region, area string, window TimeW continue } sort.Slice(obs, func(i, j int) bool { - return obs[i]["minDist"].(int) < obs[j]["minDist"].(int) + di, dj := obs[i]["minDist"].(int), obs[j]["minDist"].(int) + if di != dj { + return di < dj + } + return obs[i]["observer_id"].(string) < obs[j]["observer_id"].(string) }) r := resolveHop(hop) entry := map[string]interface{}{ @@ -8328,8 +8388,12 @@ func (s *PacketStore) computeAnalyticsTopology(region, area string, window TimeW multiObsNodes = append(multiObsNodes, entry) } sort.Slice(multiObsNodes, func(i, j int) bool { - return len(multiObsNodes[i]["observers"].([]map[string]interface{})) > - len(multiObsNodes[j]["observers"].([]map[string]interface{})) + ni := len(multiObsNodes[i]["observers"].([]map[string]interface{})) + nj := len(multiObsNodes[j]["observers"].([]map[string]interface{})) + if ni != nj { + return ni > nj + } + return multiObsNodes[i]["hop"].(string) < multiObsNodes[j]["hop"].(string) }) if len(multiObsNodes) > 50 { multiObsNodes = multiObsNodes[:50] @@ -8351,7 +8415,11 @@ func (s *PacketStore) computeAnalyticsTopology(region, area string, window TimeW bestPathList = append(bestPathList, entry) } sort.Slice(bestPathList, func(i, j int) bool { - return bestPathList[i]["minDist"].(int) < bestPathList[j]["minDist"].(int) + di, dj := bestPathList[i]["minDist"].(int), bestPathList[j]["minDist"].(int) + if di != dj { + return di < dj + } + return bestPathList[i]["hop"].(string) < bestPathList[j]["hop"].(string) }) if len(bestPathList) > 50 { bestPathList = bestPathList[:50] @@ -9054,8 +9122,14 @@ func (s *PacketStore) computeAnalyticsHashSizes(region, area string) map[string] for hex, data := range uniqueHops { hopList = append(hopList, hopEntry{hex, data}) } + // #256: uniqueHops iteration order is random; ties break on the hop hex + // so the order and the top-50 cut are stable. sort.Slice(hopList, func(i, j int) bool { - return hopList[i].data["count"].(int) > hopList[j].data["count"].(int) + ci, cj := hopList[i].data["count"].(int), hopList[j].data["count"].(int) + if ci != cj { + return ci > cj + } + return hopList[i].hex < hopList[j].hex }) topHops := make([]map[string]interface{}, 0) for i, e := range hopList { @@ -9088,8 +9162,14 @@ func (s *PacketStore) computeAnalyticsHashSizes(region, area string) map[string] }) } } + // #256: byNode iteration order is random; ties break on pubkey so the + // adopters table's default ("server order") is the same on every recompute. sort.Slice(multiByteNodes, func(i, j int) bool { - return multiByteNodes[i]["packets"].(int) > multiByteNodes[j]["packets"].(int) + pi, pj := multiByteNodes[i]["packets"].(int), multiByteNodes[j]["packets"].(int) + if pi != pj { + return pi > pj + } + return multiByteNodes[i]["pubkey"].(string) < multiByteNodes[j]["pubkey"].(string) }) // Distribution by repeaters: count unique REPEATER nodes per hash size @@ -10975,7 +11055,14 @@ func (s *PacketStore) rankSubpaths(counts map[string]*subpathAccum, totalPaths, for path, data := range counts { ranked = append(ranked, subpathEntry{path, data.count, data.raw}) } - sort.Slice(ranked, func(i, j int) bool { return ranked[i].count > ranked[j].count }) + // #256: counts iteration order is random; ties break on the path so the + // order and the limit cut are stable. + sort.Slice(ranked, func(i, j int) bool { + if ranked[i].count != ranked[j].count { + return ranked[i].count > ranked[j].count + } + return ranked[i].path < ranked[j].path + }) if len(ranked) > limit { ranked = ranked[:limit] }