summaryrefslogtreecommitdiffstats
path: root/src/swarm/topology.rs
blob: 2c6b8674ad3ee7b617815593c26896e6dda9b1dc (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
//! Which members a node syncs with: its neighbours.
//!
//! In a full mesh every change reaches every node from every other node (Automerge
//! decides per pair, so nobody knows that a third peer already passed it on), so the
//! copies of one write grow with N². Above `k` members, each node keeps only a few
//! neighbours, and changes travel over several hops.
//!
//! Every node computes the graph from the member list and which members it can reach,
//! with no coordination:
//! * a ring ordered by key hash: each member's nearest reachable member in both
//!   directions, which keeps the reachable members connected;
//! * plus, for each member, the `k - 2` reachable others with the lowest pair score (a
//!   hash of both keys, so both ends agree on it), which keeps paths short.
//!
//! A link exists if either end chose it, so links are symmetric and a node has at least
//! `k` neighbours. With at most `k + 1` reachable members the graph is the full mesh:
//! small swarms behave exactly as before. A member joining, leaving or going offline
//! changes only links near it.
//!
//! Members on several transports (Mycelium, libp2p) get such a graph per transport,
//! among the members on it (as their addresses in the member list say), and a node's
//! neighbours are the union of its neighbours on each transport it is on
//! (`neighbours_across`). Each of those graphs is connected (its ring), and a member on
//! two transports is in both: as long as members on several transports link the
//! transports, the swarm stays connected, and such a member has about `k` neighbours per
//! transport. With one transport, it is the one graph as before.

use std::collections::{BTreeMap, BTreeSet};

use sha2::{Digest, Sha256};
use tracing::info;

use crate::locks::RwLockExt;
use crate::state::AppState;
use crate::swarm::members;

/// This node's current neighbours, and the member list they were computed from.
#[derive(Debug, Default)]
pub(crate) struct NeighbourView {
    members: Vec<String>,
    k: usize,
    unreachable: BTreeSet<String>,
    stable: BTreeSet<String>,
    /// The transports (address schemes) of each member, and ours.
    transports: BTreeMap<String, BTreeSet<String>>,
    local_transports: BTreeSet<String>,
    neighbours: BTreeSet<String>,
    /// Every reachable member is a neighbour.
    full_mesh: bool,
}

/// Whether the neighbours are computed from the member list yet (before, everyone
/// counts as a neighbour).
pub(crate) fn view_known(state: &AppState) -> bool {
    state.neighbours.read_or_recover().is_some()
}

/// Whether every reachable member is our neighbour (also while the view is unknown).
pub(crate) fn is_full_mesh(state: &AppState) -> bool {
    state
        .neighbours
        .read_or_recover()
        .as_ref()
        .is_none_or(|view| view.full_mesh)
}

/// The current view in numbers, once known.
#[derive(Debug, Clone, Copy)]
pub(crate) struct Summary {
    /// Registered members, us included.
    pub(crate) members: usize,
    /// Members other than us not currently judged unreachable.
    pub(crate) reachable: usize,
    pub(crate) stable: usize,
    pub(crate) neighbours: usize,
    pub(crate) stable_neighbours: usize,
    pub(crate) full_mesh: bool,
}

pub(crate) fn summary(state: &AppState) -> Option<Summary> {
    let local = state.local_id();
    state
        .neighbours
        .read_or_recover()
        .as_ref()
        .map(|view| Summary {
            members: view.members.len(),
            reachable: view
                .members
                .iter()
                .filter(|m| m.as_str() != local && !view.unreachable.contains(*m))
                .count(),
            stable: view.stable.len(),
            neighbours: view.neighbours.len(),
            stable_neighbours: view
                .neighbours
                .iter()
                .filter(|m| view.stable.contains(*m))
                .count(),
            full_mesh: view.full_mesh,
        })
}

/// Whether we initiate contact with `peer`: HELLOs, keepalives, dormancy checks, sync
/// openings. Everyone is a neighbour until the view is known (while joining, or before
/// any member registered), so joining works as in a full mesh. We always *answer*
/// anyone who contacts us.
pub(crate) fn is_neighbour(state: &AppState, peer: &str) -> bool {
    match &*state.neighbours.read_or_recover() {
        Some(view) => view.neighbours.contains(peer),
        None => true,
    }
}

/// How the neighbours changed.
pub(crate) struct NeighbourChange {
    pub(crate) added: Vec<String>,
    pub(crate) removed: Vec<String>,
}

/// Recomputes the neighbours whenever something they depend on may have changed
/// (`AppState::topology_changed`): the member list (`swarm::sync::run_change_publisher`),
/// a peer becoming unreachable or reachable again (`swarm::peer`), and every 5 s as a
/// fallback. A burst of notifications coalesces into one recomputation.
pub(crate) async fn run_worker(state: AppState) {
    loop {
        state.topology_changed.notified().await;
        if let Some(change) = refresh(&state).await {
            crate::swarm::peer::apply_neighbour_change(&state, change).await;
        }
    }
}

/// Whether a change at `path` may change the neighbours: one in the member list, or the
/// root (the whole document replaced, as after an epoch switch).
pub(crate) fn affects_neighbours(path: &[String]) -> bool {
    path.is_empty() || path.starts_with(&members::members_dir())
}

/// Recomputes the neighbours once ready, if the member list, the reachable members or
/// `k` changed.
pub(crate) async fn refresh(state: &AppState) -> Option<NeighbourChange> {
    if !state.is_ready().await {
        return None;
    }
    let (current, stable, transports) = {
        let replica = state.doc.read().await;
        members::learn_addresses(state, &replica.doc);
        let current = members::members(&replica.doc);
        let stable: BTreeSet<String> = current
            .iter()
            .filter(|m| members::tier(&replica.doc, m) == members::Tier::Stable)
            .cloned()
            .collect();
        let transports: BTreeMap<String, BTreeSet<String>> = current
            .iter()
            .map(|m| (m.clone(), schemes(&members::addresses(&replica.doc, m))))
            .collect();
        (current, stable, transports)
    };
    let local_transports: BTreeSet<String> = state.transport.transports().into_iter().collect();
    let k = state.neighbours_k;
    // Those that don't answer, and those on none of our transports.
    let mut unreachable = BTreeSet::new();
    for member in &current {
        if member == state.local_id() {
            continue;
        }
        if !state.transport.can_reach(member) {
            unreachable.insert(member.clone());
        } else if let Some(peer_state) = state.get_peer_state(member).await
            && peer_state.lock().await.is_unreachable()
        {
            unreachable.insert(member.clone());
        }
    }
    let previous = {
        let view = state.neighbours.read_or_recover();
        if view.as_ref().is_some_and(|view| {
            view.members == current
                && view.k == k
                && view.unreachable == unreachable
                && view.stable == stable
                && view.transports == transports
                && view.local_transports == local_transports
        }) {
            return None;
        }
        view.as_ref().map(|view| view.neighbours.clone())
    };

    // Before the first view everyone known counted as a neighbour.
    let previous = match previous {
        Some(previous) => previous,
        None => state.peer_states.read().await.keys().cloned().collect(),
    };
    let computed = neighbours_across(
        &transports,
        state.local_id(),
        &local_transports,
        k,
        &|m| unreachable.contains(m),
        &|m| stable.contains(m),
    );
    let change = NeighbourChange {
        added: computed
            .iter()
            .filter(|m| !previous.contains(*m))
            .cloned()
            .collect(),
        removed: previous
            .iter()
            .filter(|m| !computed.contains(*m))
            .cloned()
            .collect(),
    };
    info!(
        members = current.len(),
        unreachable = unreachable.len(),
        stable = stable.len(),
        k,
        neighbours = computed.len(),
        added = change.added.len(),
        removed = change.removed.len(),
        "Neighbours computed from the member list"
    );
    let reachable_others = current
        .iter()
        .filter(|m| m.as_str() != state.local_id() && !unreachable.contains(*m))
        .count();
    *state.neighbours.write_or_recover() = Some(NeighbourView {
        full_mesh: computed.len() >= reachable_others,
        members: current,
        k,
        unreachable,
        stable,
        transports,
        local_transports,
        neighbours: computed,
    });
    Some(change)
}

const RING_LABEL: &[u8] = b"continuum/topology-ring/1";
const PAIR_LABEL: &[u8] = b"continuum/topology-pair/2";

/// How many members may prefer the same stable member, as a multiple of `k`. Without a
/// bound, 20 stable members among 2000 get about 500 neighbours each: every
/// announcement in the swarm lands on them, and the backbone saturates first. Members
/// that find a stable member full prefer the next one, then go by pair score, so the
/// graph stays connected and symmetric.
const STABLE_FANIN_FACTOR: usize = 4;

/// The transports `addresses` are on: their schemes (`mycelium` of `mycelium:<key>`);
/// the empty one for an address without (a transport whose identities are addresses).
pub(crate) fn schemes(addresses: &[String]) -> BTreeSet<String> {
    addresses
        .iter()
        .map(|address| {
            address
                .split_once(':')
                .map_or("", |(scheme, _)| scheme)
                .to_owned()
        })
        .collect()
}

/// The neighbours of `local` among `members` (by the transports each is on), on each of
/// `local_transports`: on each, its neighbours among the members on it
/// (`neighbours`), all together.
pub(crate) fn neighbours_across(
    members: &BTreeMap<String, BTreeSet<String>>,
    local: &str,
    local_transports: &BTreeSet<String>,
    k: usize,
    unreachable: &dyn Fn(&str) -> bool,
    stable: &dyn Fn(&str) -> bool,
) -> BTreeSet<String> {
    let mut all = BTreeSet::new();
    for transport in local_transports {
        let on_it: Vec<String> = members
            .iter()
            .filter(|(member, transports)| *member != local && transports.contains(transport))
            .map(|(member, _)| member.clone())
            .collect();
        all.extend(neighbours(&on_it, local, k, unreachable, stable));
    }
    all
}

/// The neighbours of `local` among `members` (which may or may not contain `local`),
/// skipping members for which `unreachable` holds.
pub(crate) fn neighbours(
    members: &[String],
    local: &str,
    k: usize,
    unreachable: &dyn Fn(&str) -> bool,
    stable: &dyn Fn(&str) -> bool,
) -> BTreeSet<String> {
    let mut all: Vec<String> = members.to_vec();
    all.push(local.to_owned());
    graph(&all, k, &|m: &str| m != local && unreachable(m), stable)
        .remove(local)
        .unwrap_or_default()
}

/// Every reachable member's neighbours. Hashes each member once and scores pairs by
/// mixing the two hashes (`pair_score`), so the N² scores are a few nanoseconds each:
/// tens of milliseconds for thousands of members. `stable` members (`swarm::stability`)
/// are preferred for half of each member's scored slots, each by at most
/// `STABLE_FANIN_FACTOR * k` members.
///
/// Each reachable member walks the ring (by key hash) both ways to the first reachable
/// member, which gives a ring over the reachable members, and adds the `k - 2`
/// reachable members with the lowest pair score. A link exists if either end chose it.
/// With at most `k + 1` reachable members, the reachable members form a full mesh.
///
/// Each node judges reachability by itself, so nodes may briefly disagree; that only
/// adds links (whoever chose a link initiates, the other answers), it never cuts one.
/// The preference budget is spent in key order, which every node shares, so nodes that
/// agree on reachability compute the same links.
pub(crate) fn graph(
    members: &[String],
    k: usize,
    unreachable: &dyn Fn(&str) -> bool,
    stable: &dyn Fn(&str) -> bool,
) -> BTreeMap<String, BTreeSet<String>> {
    let k = k.max(2);
    let all: BTreeSet<&str> = members.iter().map(String::as_str).collect();
    let live: BTreeSet<&str> = all.iter().copied().filter(|m| !unreachable(m)).collect();
    let mut links: BTreeMap<String, BTreeSet<String>> = live
        .iter()
        .map(|m| ((*m).to_owned(), BTreeSet::new()))
        .collect();

    let mut link = |a: &str, b: &str| {
        if a != b {
            links.get_mut(a).expect("live member").insert(b.to_owned());
            links.get_mut(b).expect("live member").insert(a.to_owned());
        }
    };

    if live.len() <= k + 1 {
        for a in &live {
            for b in &live {
                link(a, b);
            }
        }
        return links;
    }

    // The ring over all members, so its order doesn't change when one goes offline.
    let mut ring: Vec<([u8; 32], &str)> =
        all.iter().map(|m| (hash(RING_LABEL, &[*m]), *m)).collect();
    ring.sort_unstable();
    let n = ring.len();
    let position: BTreeMap<&str, usize> =
        ring.iter().enumerate().map(|(i, (_, m))| (*m, i)).collect();
    let first_live = |from: usize, step: usize| {
        (1..n)
            .map(|d| ring[(from + d * step) % n].1)
            .find(|m| live.contains(m))
            .expect("more than k + 1 live members")
    };

    // Live members in key order, with their pair keys; and those of them that are stable.
    let keyed: Vec<(&str, u64)> = live.iter().map(|m| (*m, pair_key(m))).collect();
    let stable_keyed: Vec<(&str, u64)> = keyed.iter().copied().filter(|(m, _)| stable(m)).collect();

    // How many members have preferred each stable member so far (`keyed` is in key
    // order, the same on every node).
    let budget = STABLE_FANIN_FACTOR * k;
    let mut preferred_by: BTreeMap<&str, usize> = BTreeMap::new();
    // Half the slots (rounded up) prefer stable members, so long-running nodes form a
    // backbone; the rest go by pair score alone, keeping paths short. A stable member
    // enough others prefer already is skipped, so no hub grows with the swarm.
    let stable_slots = (k - 2).div_ceil(2);

    for &(member, key) in &keyed {
        let at = position[member];
        let next = first_live(at, 1);
        let prev = first_live(at, n - 1);
        link(member, next);
        link(member, prev);
        let skip = [member, next, prev];
        // The stable candidates in score order (there are few).
        let mut stable_candidates = scored(&stable_keyed, key, skip);
        stable_candidates.sort_unstable();
        let preferred: Vec<&str> = stable_candidates
            .iter()
            .map(|(_, other)| *other)
            .filter(|other| preferred_by.get(other).copied().unwrap_or(0) < budget)
            .take(stable_slots)
            .collect();
        for other in preferred.iter().copied() {
            *preferred_by.entry(other).or_default() += 1;
        }
        // The rest: the lowest scores among the others that aren't preferred, which are
        // among the `need + preferred` lowest overall. Selecting those is linear; only
        // they are sorted.
        let need = k - 2 - preferred.len();
        let mut candidates = scored(&keyed, key, skip);
        let lowest = (need + preferred.len()).min(candidates.len());
        if lowest < candidates.len() {
            candidates.select_nth_unstable(lowest);
            candidates.truncate(lowest);
        }
        candidates.sort_unstable();
        let rest = candidates
            .iter()
            .map(|(_, other)| *other)
            .filter(|other| !preferred.contains(other))
            .take(need);
        for partner in preferred.iter().copied().chain(rest) {
            link(member, partner);
        }
    }
    links
}

/// `others` but those in `skip`, scored as pairs with the member whose key is `key`.
fn scored<'a>(others: &[(&'a str, u64)], key: u64, skip: [&str; 3]) -> Vec<(u64, &'a str)> {
    others
        .iter()
        .filter(|(other, _)| !skip.contains(other))
        .map(|&(other, other_key)| (mix_pair(key, other_key), other))
        .collect()
}

/// Symmetric: the same for (a, b) and (b, a).
pub(crate) fn pair_score(a: &str, b: &str) -> u64 {
    mix_pair(pair_key(a), pair_key(b))
}

/// What a member contributes to its pair scores: computed once per member.
fn pair_key(member: &str) -> u64 {
    let digest = hash(PAIR_LABEL, &[member]);
    u64::from_be_bytes(digest[..8].try_into().expect("8 bytes"))
}

/// The score of a pair from its members' keys, symmetric: SplitMix64's finalizer over
/// the lower key and the mixed higher one. Keys are uniform (SHA-256), so for each member
/// the others' scores are in an order unrelated to anyone else's.
fn mix_pair(a: u64, b: u64) -> u64 {
    let (low, high) = if a <= b { (a, b) } else { (b, a) };
    mix(low ^ mix(high))
}

fn mix(mut z: u64) -> u64 {
    z = z.wrapping_add(0x9e37_79b9_7f4a_7c15);
    z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
    z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
    z ^ (z >> 31)
}

fn hash(label: &[u8], fields: &[&str]) -> [u8; 32] {
    let mut hasher = Sha256::new();
    for field in [label]
        .into_iter()
        .chain(fields.iter().map(|f| f.as_bytes()))
    {
        hasher.update((field.len() as u32).to_be_bytes());
        hasher.update(field);
    }
    hasher.finalize().into()
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::VecDeque;

    fn members(n: usize) -> Vec<String> {
        (0..n).map(|i| format!("{:064x}", i * 7919 + 13)).collect()
    }

    fn all_reachable(_: &str) -> bool {
        false
    }

    fn none_stable(_: &str) -> bool {
        false
    }

    fn graph(members: &[String], k: usize) -> BTreeMap<String, BTreeSet<String>> {
        super::graph(members, k, &all_reachable, &none_stable)
    }

    fn connected(g: &BTreeMap<String, BTreeSet<String>>) -> bool {
        let Some(start) = g.keys().next() else {
            return true;
        };
        let mut seen = BTreeSet::from([start.clone()]);
        let mut queue = VecDeque::from([start.clone()]);
        while let Some(m) = queue.pop_front() {
            for next in &g[&m] {
                if seen.insert(next.clone()) {
                    queue.push_back(next.clone());
                }
            }
        }
        seen.len() == g.len()
    }

    fn edges(graph: &BTreeMap<String, BTreeSet<String>>) -> BTreeSet<(String, String)> {
        graph
            .iter()
            .flat_map(|(a, ns)| {
                ns.iter().map(move |b| {
                    if a < b {
                        (a.clone(), b.clone())
                    } else {
                        (b.clone(), a.clone())
                    }
                })
            })
            .collect()
    }

    fn hex(bytes: [u8; 32]) -> String {
        bytes.iter().map(|byte| format!("{byte:02x}")).collect()
    }

    /// Over random swarms (size, `k`, who is reachable, who is stable): links only
    /// between reachable members, never to oneself, symmetric; a full mesh while small,
    /// otherwise at least `k` links each and connected; independent of the order members
    /// are listed in; and each member's own view (`neighbours`) agrees with the graph.
    #[test]
    fn random_swarms() {
        crate::testrand::for_seeds(300, |rng| {
            let n = rng.between(1, 60);
            let k = rng.between(2, 14);
            let mut all: Vec<String> = (0..n).map(|_| hex(rng.bytes32())).collect();
            let unreachable: BTreeSet<String> =
                all.iter().filter(|_| rng.chance(0.3)).cloned().collect();
            let stable: BTreeSet<String> =
                all.iter().filter(|_| rng.chance(0.3)).cloned().collect();
            let is_unreachable = |m: &str| unreachable.contains(m);
            let is_stable = |m: &str| stable.contains(m);

            let g = super::graph(&all, k, &is_unreachable, &is_stable);
            // Owned: `all` is shuffled below.
            let live: BTreeSet<String> = all
                .iter()
                .filter(|m| !unreachable.contains(*m))
                .cloned()
                .collect();
            assert_eq!(g.keys().cloned().collect::<BTreeSet<_>>(), live);
            for (a, ns) in &g {
                assert!(!ns.contains(a), "{a} links to itself");
                for b in ns {
                    assert!(live.contains(b), "{a} links to unreachable {b}");
                    assert!(g[b].contains(a), "{a} -> {b} but not back");
                }
            }
            if live.len() <= k + 1 {
                assert!(
                    g.values().all(|ns| ns.len() + 1 == live.len()),
                    "not a full mesh"
                );
            } else {
                assert!(
                    g.values().all(|ns| ns.len() >= k),
                    "a member with fewer than k links"
                );
                assert!(connected(&g), "not connected");
            }

            rng.shuffle(&mut all);
            assert_eq!(super::graph(&all, k, &is_unreachable, &is_stable), g);

            if let Some(local) = live.iter().nth(rng.below(live.len())) {
                let others: Vec<String> = all.iter().filter(|m| *m != local).cloned().collect();
                assert_eq!(
                    neighbours(&others, local, k, &is_unreachable, &is_stable),
                    g[local]
                );
            }
        });
    }

    #[test]
    fn small_swarms_are_a_full_mesh() {
        let ms = members(13);
        for m in &ms {
            assert_eq!(
                neighbours(&ms, m, 12, &all_reachable, &none_stable).len(),
                12
            );
        }
    }

    #[test]
    fn links_are_symmetric_and_degree_at_least_k() {
        let ms = members(250);
        let g = graph(&ms, 8);
        for (a, ns) in &g {
            assert!(ns.len() >= 8, "{a} has {} neighbours", ns.len());
            assert!(!ns.contains(a));
            for b in ns {
                assert!(g[b].contains(a), "{a} -> {b} not symmetric");
            }
        }
    }

    #[test]
    fn graph_is_connected() {
        for (n, k) in [(250, 8), (250, 2), (60, 3)] {
            assert!(connected(&graph(&members(n), k)), "n={n} k={k}");
        }
    }

    #[test]
    fn one_member_joining_changes_few_links() {
        let ms = members(200);
        let before = edges(&graph(&ms, 8));
        let mut more = ms.clone();
        more.push(format!("{:064x}", 999_999));
        let after = edges(&graph(&more, 8));
        let changed = before.symmetric_difference(&after).count();
        assert!(changed <= 4 * 8, "{changed} links changed");
    }

    #[test]
    fn reachable_members_stay_connected_when_most_are_offline() {
        let ms = members(100);
        // Every tenth member is online.
        let online: BTreeSet<String> = ms.iter().step_by(10).cloned().collect();
        let offline = |m: &str| !online.contains(m);
        let g = super::graph(&ms, 12, &offline, &none_stable);
        assert_eq!(g.len(), 10);
        // Ten reachable members and k = 12: a full mesh among them.
        assert!(g.values().all(|ns| ns.len() == 9));

        let online: BTreeSet<String> = ms.iter().step_by(4).cloned().collect();
        let offline = |m: &str| !online.contains(m);
        let g = super::graph(&ms, 8, &offline, &none_stable);
        assert_eq!(g.len(), 25);
        assert!(connected(&g));
        assert!(
            g.values()
                .all(|ns| ns.len() >= 8 && ns.iter().all(|n| online.contains(n)))
        );
    }

    #[test]
    fn stable_members_become_hubs_with_bounded_degree() {
        let ms = members(250);
        let stable: BTreeSet<String> = ms.iter().step_by(12).take(20).cloned().collect();
        let is_stable = |m: &str| stable.contains(m);
        let g = super::graph(&ms, 12, &all_reachable, &is_stable);
        assert!(connected(&g));

        let degree = |pred: &dyn Fn(&String) -> bool| -> (usize, f64) {
            let degrees: Vec<usize> = g
                .iter()
                .filter(|(m, _)| pred(m))
                .map(|(_, ns)| ns.len())
                .collect();
            let max = *degrees.iter().max().unwrap();
            (
                max,
                degrees.iter().sum::<usize>() as f64 / degrees.len() as f64,
            )
        };
        let (stable_max, stable_mean) = degree(&|m| stable.contains(m));
        let (_, transient_mean) = degree(&|m| !stable.contains(m));
        // 230 transient members put about 5 of their 10 scored slots on 20 stable ones.
        assert!(
            stable_mean > 3.0 * transient_mean,
            "{stable_mean} vs {transient_mean}"
        );
        assert!(
            stable_max <= 100,
            "a stable member has {stable_max} neighbours"
        );
    }

    /// A hub's degree in a swarm of `n` with `stable_count` stable members stays within
    /// the bound.
    fn hub_degree_is_bounded(n: usize, stable_count: usize) {
        // Few stable members among many: without the fan-in budget each would be chosen
        // by most of the swarm (about 130 neighbours with 10 of 250). With it, a hub has
        // at most the budget plus the links others choose by pair score and the ring.
        let k = 12;
        let ms = members(n);
        let stable: BTreeSet<String> = ms.iter().step_by(n / stable_count).cloned().collect();
        let is_stable = |m: &str| stable.contains(m);
        let g = super::graph(&ms, k, &all_reachable, &is_stable);
        assert!(connected(&g), "n={n} stable={stable_count}");
        let hub_max = g
            .iter()
            .filter(|(m, _)| stable.contains(*m))
            .map(|(_, ns)| ns.len())
            .max()
            .unwrap();
        assert!(
            hub_max <= 8 * k,
            "n={n} stable={stable_count}: a stable member has {hub_max} neighbours"
        );
    }

    /// Two transports with members on one or the other, and two members on both: every
    /// link is over a shared transport, and the two members on both connect the rest.
    #[test]
    fn members_on_two_transports_bridge_the_others() {
        let k = 8;
        let ms = members(201);
        let on = |names: &[&str]| -> BTreeSet<String> {
            names.iter().map(|name| (*name).to_owned()).collect()
        };
        let transports: BTreeMap<String, BTreeSet<String>> = ms
            .iter()
            .enumerate()
            .map(|(i, m)| {
                let transports = match i {
                    0 | 1 => on(&["a", "b"]),
                    i if i % 2 == 0 => on(&["a"]),
                    _ => on(&["b"]),
                };
                (m.clone(), transports)
            })
            .collect();
        let no_stable = |_: &str| false;
        let mut graph: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
        for (m, own) in &transports {
            for n in neighbours_across(&transports, m, own, k, &all_reachable, &no_stable) {
                assert!(
                    !transports[&n].is_disjoint(own),
                    "{m} and {n} share no transport"
                );
                graph.entry(m.clone()).or_default().insert(n.clone());
                graph.entry(n).or_default().insert(m.clone());
            }
        }
        assert_eq!(graph.len(), ms.len());
        assert!(connected(&graph));
        let bridge = &ms[0];
        assert!(graph[bridge].iter().any(|n| !transports[n].contains("b")));
        assert!(graph[bridge].iter().any(|n| !transports[n].contains("a")));

        // One transport: the one graph, as without transports.
        let one: BTreeMap<String, BTreeSet<String>> =
            ms.iter().map(|m| (m.clone(), on(&[""]))).collect();
        for m in &ms[..5] {
            assert_eq!(
                neighbours_across(&one, m, &on(&[""]), k, &all_reachable, &no_stable),
                neighbours(&ms, m, k, &all_reachable, &no_stable)
            );
        }
    }

    #[test]
    fn schemes_of_addresses() {
        let addresses =
            |all: &[&str]| -> Vec<String> { all.iter().map(|a| (*a).to_owned()).collect() };
        assert_eq!(
            schemes(&addresses(&[
                "mycelium:ab",
                "libp2p:/ip4/1.2.3.4/tcp/1",
                "libp2p:/ip6/::1/tcp/1"
            ])),
            BTreeSet::from(["libp2p".to_owned(), "mycelium".to_owned()])
        );
        assert_eq!(schemes(&addresses(&["a"])), BTreeSet::from([String::new()]));
        assert!(schemes(&[]).is_empty());
    }

    #[test]
    fn hub_degree_is_bounded_however_few_stable_members() {
        for (n, stable_count) in [(250, 10), (400, 5)] {
            hub_degree_is_bounded(n, stable_count);
        }
    }

    /// The same at the sizes the bound was designed for; slow in debug builds, so run
    /// on request (`cargo test --release -- --ignored`).
    #[test]
    #[ignore]
    fn hub_degree_is_bounded_in_large_swarms() {
        for (n, stable_count) in [(1000, 10), (1000, 20), (2000, 20)] {
            hub_degree_is_bounded(n, stable_count);
        }
    }
}