bevy_society 0.2.0

Living societies for Bevy: personalities, moods, needs, memories, relationships, opinions, rumors, reputation and factions; utility-AI daily lives; Rhai triggers; dialogue through bevy_speech_bubbles.
Documentation
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
//! Directed ties between persons: affinity, trust, respect, familiarity and labels (plan D4).
//!
//! `Ties` is a component rather than Bevy relationships because the edges are weighted, directed
//! and many-to-many. Entries stay sorted by the target's bits so lookups are binary searches and
//! every iteration is deterministic.

use bevy::prelude::*;
use bevy_emerge_rng::DetRng;
use serde::Deserialize;

use crate::core::require_core;
use crate::defs::SocietyDefs;
use crate::groups::{Memberships, shared_faction_bias};
use crate::model::{balance, recall};
use crate::{
    Perception, PersonIndex, SocietyClock, SocietyRng, SocietySettings, SocietySystems, Tuning,
};

/// The label a tie carries, recomputed each tick from affinity and familiarity (plan D4).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Reflect, Deserialize)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub enum TieLabel {
    /// Barely known: familiarity below 0.1.
    #[default]
    Stranger,
    /// Known but neither friend nor rival.
    Acquaintance,
    /// Affinity at least 0.4 and familiarity at least 0.3.
    Friend,
    /// Affinity at most -0.4 and familiarity at least 0.2.
    Rival,
}

impl TieLabel {
    /// Classifies a tie. Friend is checked first, then Rival, then Stranger; anything else is an
    /// Acquaintance.
    ///
    /// The four thresholds are design defaults (no source paper gives them); they sit on two
    /// mechanisms the literature does supply: familiarity from mere exposure (Zajonc 1968,
    /// DOI 10.1037/h0025848) and affinity from similarity (Byrne 1961, DOI 10.1037/h0044721).
    pub fn classify(affinity: f32, familiarity: f32, tuning: &crate::Tuning) -> Self {
        Self::classify_with_trust(affinity, familiarity, f32::MAX, tuning)
    }

    /// [`classify`](Self::classify) with the tie's trust, which `Friend` additionally requires.
    ///
    /// **Trust is what separates friends from everyone else**: measured on the village, friend-ties
    /// average trust 0.480 against 0.068 for the rest — sevenfold — while familiarity does not separate
    /// them at all (0.907 against 0.908). The `friend_affinity` bar alone therefore never distinguished
    /// anything; a clamped affinity plus this gate does.
    pub fn classify_with_trust(
        affinity: f32,
        familiarity: f32,
        trust: f32,
        tuning: &crate::Tuning,
    ) -> Self {
        if affinity >= tuning.friend_affinity
            && familiarity >= tuning.friend_familiarity
            && trust >= tuning.friend_trust
        {
            Self::Friend
        } else if affinity <= -tuning.rival_affinity && familiarity >= tuning.rival_familiarity {
            Self::Rival
        } else if familiarity < tuning.stranger_familiarity {
            Self::Stranger
        } else {
            Self::Acquaintance
        }
    }

    /// Lowercase name used by scripts (`"stranger" | "acquaintance" | "friend" | "rival"`).
    pub fn script_name(self) -> &'static str {
        match self {
            Self::Stranger => "stranger",
            Self::Acquaintance => "acquaintance",
            Self::Friend => "friend",
            Self::Rival => "rival",
        }
    }
}

/// One directed edge from the owning person to `target` (plan D4).
#[derive(Debug, Clone, Copy, PartialEq, Reflect)]
pub struct Tie {
    /// The person this edge points at.
    pub target: Entity,
    /// Liking in `[-1, 1]`.
    pub affinity: f32,
    /// Reliance in `[0, 1]`; 0.5 for a new acquaintance.
    pub trust: f32,
    /// Conferred esteem in `[-1, 1]`.
    pub respect: f32,
    /// Familiarity peak in `[0, 1]`, decayed by power-law forgetting when read.
    pub familiarity_peak: f32,
    /// Game-day of the last contact.
    pub last_contact_day: f32,
    /// The label as of the last refresh.
    pub label: TieLabel,
}

impl Tie {
    /// A fresh tie with the plan's defaults: neutral affinity and respect, trust 0.5, no familiarity.
    pub fn new(target: Entity) -> Self {
        Self {
            target,
            affinity: 0.0,
            trust: 0.5,
            respect: 0.0,
            familiarity_peak: 0.0,
            last_contact_day: 0.0,
            label: TieLabel::Stranger,
        }
    }

    /// Adds `delta` to affinity, **clamped to `±Tuning::affinity_ceiling`**.
    ///
    /// The one place affinity moves. Routing every writer through it is what makes the clamp a property
    /// of the model rather than of whichever call site remembered: a new affinity effect cannot
    /// accidentally escape the ceiling, and `grep -n 'affinity =' src` finds no direct write outside
    /// [`Tie::new`], this function, and the two places that set a tie's *initial* value.
    pub fn add_affinity(&mut self, delta: f32, tuning: &Tuning) {
        let ceiling = tuning.affinity_ceiling.max(0.0);
        self.affinity = (self.affinity + delta).clamp(-ceiling, ceiling);
    }

    /// Familiarity at `now_days`, decayed from the peak by power-law forgetting (Anderson &
    /// Schooler 1991, DOI 10.1111/j.1467-9280.1991.tb00174.x; Murre & Dros 2015, DOI
    /// 10.1371/journal.pone.0120644).
    pub fn familiarity(&self, now_days: f32, tuning: &Tuning) -> f32 {
        recall::familiarity(
            self.familiarity_peak,
            now_days - self.last_contact_day,
            tuning.familiarity_tau_days,
            tuning.familiarity_decay_exp,
        )
    }

    /// Records one contact over `dt_days`. Familiarity is first read at `now_days`, then raised by
    /// mere exposure; affinity is nudged toward the target (Zajonc 1968, DOI 10.1037/h0025848).
    pub fn expose(&mut self, now_days: f32, dt_days: f32, tuning: &Tuning) {
        let f = self.familiarity(now_days, tuning);
        self.familiarity_peak = recall::exposed_peak(f, tuning.exposure_per_day, dt_days);
        self.last_contact_day = now_days;
        // Exposure saturates on `(1 − |affinity|)` and now also honours the ceiling, through the one
        // writer. Before the ceiling, this term is what carried a village to affinity 0.999.
        let step = tuning.mere_exposure_per_day * dt_days * (1.0 - self.affinity.abs());
        self.add_affinity(step, tuning);
    }

    /// Relaxes affinity toward zero by `affinity_relax_per_day · dt_days`, never crossing zero.
    ///
    /// Goes through [`add_affinity`](Self::add_affinity) like every other writer, so a ceiling lowered
    /// under an existing tie is respected on the next tick rather than only on the next write.
    pub fn relax(&mut self, dt_days: f32, tuning: &Tuning) {
        let relaxed =
            balance::relax_toward_zero(self.affinity, tuning.affinity_relax_per_day * dt_days);
        self.add_affinity(relaxed - self.affinity, tuning);
    }

    /// Recomputes the label from the current affinity and familiarity. Returns `Some((old, new))`
    /// only when it changed, so the caller can write one `TieLabelChanged` per transition.
    pub fn refresh_label(
        &mut self,
        now_days: f32,
        tuning: &Tuning,
    ) -> Option<(TieLabel, TieLabel)> {
        let next = TieLabel::classify_with_trust(
            self.affinity,
            self.familiarity(now_days, tuning),
            self.trust,
            tuning,
        );
        if next == self.label {
            None
        } else {
            let old = self.label;
            self.label = next;
            Some((old, next))
        }
    }
}

/// A person's outgoing ties, sorted by `target.to_bits()` with at most one entry per target.
#[derive(Component, Debug, Clone, Default, PartialEq, Reflect)]
pub struct Ties(pub Vec<Tie>);

impl Ties {
    /// Binary-search result for `target`: `Ok(index)` when present, `Err(insert_at)` otherwise.
    fn search(&self, target: Entity) -> Result<usize, usize> {
        let bits = target.to_bits();
        self.0.binary_search_by_key(&bits, |t| t.target.to_bits())
    }

    /// The tie to `target`, if any.
    pub fn get(&self, target: Entity) -> Option<&Tie> {
        self.search(target).ok().map(|i| &self.0[i])
    }

    /// Mutable access to the tie to `target`, if any.
    pub fn get_mut(&mut self, target: Entity) -> Option<&mut Tie> {
        match self.search(target) {
            Ok(i) => Some(&mut self.0[i]),
            Err(_) => None,
        }
    }

    /// The tie to `target`, inserted with [`Tie::new`] in sorted position when absent.
    pub fn entry(&mut self, target: Entity) -> &mut Tie {
        self.entry_with(target, 0.0)
    }

    /// The tie to `target`. When absent it is inserted with [`Tie::new`] and the given affinity,
    /// in sorted position; an existing tie keeps its affinity. D7 uses this to start co-members at
    /// their faction's `ingroup_bias`.
    pub fn entry_with(&mut self, target: Entity, affinity: f32) -> &mut Tie {
        let index = match self.search(target) {
            Ok(i) => i,
            Err(i) => {
                let mut tie = Tie::new(target);
                // **An initial value, not a delta**, and one of the two direct writes left in the crate
                // (the other is `Tie::new`). `entry_with` has no `Tuning` to clamp against; the caller
                // sets a faction's `ingroup_bias`, which is already in `[-1, 1]`, and the next
                // `add_affinity` brings it under the ceiling.
                tie.affinity = affinity;
                self.0.insert(i, tie);
                i
            }
        };
        &mut self.0[index]
    }

    /// Removes and returns the tie to `target`, if any.
    pub fn remove(&mut self, target: Entity) -> Option<Tie> {
        match self.search(target) {
            Ok(i) => Some(self.0.remove(i)),
            Err(_) => None,
        }
    }

    /// Number of ties.
    pub fn len(&self) -> usize {
        self.0.len()
    }

    /// Whether there are no ties.
    pub fn is_empty(&self) -> bool {
        self.0.is_empty()
    }

    /// Drops the least-familiar ties until at most `max_ties` remain (Dunbar 1992, DOI
    /// 10.1016/0047-2484(92)90081-J). Ties of equal familiarity go first by lower target bits, so
    /// the result is deterministic.
    pub fn trim(&mut self, now_days: f32, tuning: &Tuning, max_ties: usize) {
        self.trim_keeping(now_days, tuning, max_ties, |_| false);
    }

    /// [`trim`](Self::trim), except a tie for which `frozen(target)` holds is **excluded from the count
    /// and never removed**.
    ///
    /// That is what a dead person's ties need: a town that has lost half its people must not forget them
    /// because the Dunbar cap only counted the living. A cap below the frozen count keeps every frozen
    /// tie and trims the living ones to nothing, which is the honest answer — the frozen set is not the
    /// caller's to trade away.
    pub fn trim_keeping(
        &mut self,
        now_days: f32,
        tuning: &Tuning,
        max_ties: usize,
        frozen: impl Fn(Entity) -> bool,
    ) {
        let live = self.0.iter().filter(|t| !frozen(t.target)).count();
        if live <= max_ties {
            return;
        }
        let excess = live - max_ties;
        let mut ranked: Vec<(f32, u64)> = self
            .0
            .iter()
            .filter(|t| !frozen(t.target))
            .map(|t| (t.familiarity(now_days, tuning), t.target.to_bits()))
            .collect();
        ranked.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)));
        let doomed: Vec<u64> = ranked.iter().take(excess).map(|&(_, bits)| bits).collect();
        self.0
            .retain(|t| frozen(t.target) || !doomed.contains(&t.target.to_bits()));
    }

    /// The `close_ties` targets with the highest `max(0, affinity) · familiarity`, best first.
    /// Ties of equal score go by lower target bits (Zhou et al. 2005, DOI
    /// 10.1098/rspb.2004.2970).
    pub fn close_targets(&self, now_days: f32, tuning: &Tuning, close_ties: usize) -> Vec<Entity> {
        let mut scored: Vec<(f32, u64, Entity)> = self
            .0
            .iter()
            .map(|t| {
                let score = t.affinity.max(0.0) * t.familiarity(now_days, tuning);
                (score, t.target.to_bits(), t.target)
            })
            .collect();
        scored.sort_by(|a, b| b.0.total_cmp(&a.0).then(a.1.cmp(&b.1)));
        scored
            .into_iter()
            .take(close_ties)
            .map(|(_, _, e)| e)
            .collect()
    }
}

/// Written once per tie whose label changed (plan D4). `from` owns the tie, `to` is its target.
#[derive(Message, Debug, Clone, Copy, PartialEq, Eq)]
pub struct TieLabelChanged {
    /// The person who owns the tie.
    pub from: Entity,
    /// The person the tie points at.
    pub to: Entity,
    /// The label before the change.
    pub old: TieLabel,
    /// The label after the change.
    pub new: TieLabel,
}

/// Records one contact from `ties`' owner toward `target`. `shared_bias` is the faction
/// `ingroup_bias` for co-members, used only when the tie is new (plan D4/D6).
pub(crate) fn expose_toward(
    ties: &mut Ties,
    target: Entity,
    shared_bias: Option<f32>,
    now_days: f32,
    dt_days: f32,
    tuning: &Tuning,
) {
    let tie = match shared_bias {
        Some(bias) => ties.entry_with(target, bias),
        None => ties.entry(target),
    };
    tie.expose(now_days, dt_days, tuning);
}

/// Triadic closure for one direction (Kossinets & Watts 2006, DOI 10.1126/science.1116869): when
/// the owner meets someone who knows `target`, familiarity toward `target` rises to
/// `f + introduction_familiarity · (1 − f)`, where `f` is the current familiarity.
pub(crate) fn introduce_toward(ties: &mut Ties, target: Entity, now_days: f32, tuning: &Tuning) {
    let tie = ties.entry(target);
    let f = tie.familiarity(now_days, tuning);
    tie.familiarity_peak = (f + tuning.introduction_familiarity * (1.0 - f)).clamp(0.0, 1.0);
    tie.last_contact_day = now_days;
}

/// Records every contact pair in `perception.contact` once per direction (plan D4). Mere exposure
/// (Zajonc 1968, DOI 10.1037/h0025848) applies to both sides of each pair.
fn expose_contacts(
    clock: Res<SocietyClock>,
    settings: Res<SocietySettings>,
    perception: Res<Perception>,
    defs: Res<SocietyDefs>,
    memberships: Query<&Memberships>,
    mut ties: Query<&mut Ties>,
) {
    let tuning = &settings.tuning;
    let now = clock.now_days as f32;
    let dt = clock.dt_days;
    for (i, &entity) in perception.persons.iter().enumerate() {
        let Ok(mine) = memberships.get(entity) else {
            continue;
        };
        for &j in &perception.contact[i] {
            let Some(&other) = perception.persons.get(j as usize) else {
                continue;
            };
            let bias = match memberships.get(other) {
                Ok(theirs) => shared_faction_bias(mine, theirs, &defs),
                Err(_) => None,
            };
            if let Ok(mut owned) = ties.get_mut(entity) {
                expose_toward(&mut owned, other, bias, now, dt, tuning);
            }
        }
    }
}

/// Relaxes every affinity toward zero (plan D4), in sorted person order.
fn relax_affinity(
    clock: Res<SocietyClock>,
    settings: Res<SocietySettings>,
    index: Res<PersonIndex>,
    deceased: Query<(), With<crate::Deceased>>,
    mut ties: Query<&mut Ties>,
) {
    let tuning = &settings.tuning;
    for &(_, entity) in &index.sorted {
        // **A tie to or from the dead is frozen**: it stays at what it was when they died. That is what
        // lets the living grieve someone the social graph still remembers, and it is why a dead person's
        // own ties stop moving too — nothing decides for them.
        if deceased.get(entity).is_ok() {
            continue;
        }
        if let Ok(mut owned) = ties.get_mut(entity) {
            for tie in owned.0.iter_mut() {
                if deceased.get(tie.target).is_ok() {
                    continue;
                }
                tie.relax(clock.dt_days, tuning);
            }
        }
    }
}

/// Structural balance (Heider 1946, DOI 10.1080/00223980.1946.9917275; Cartwright & Harary 1956,
/// DOI 10.1037/h0046049). Each sample draws a person A and two of A's ties B and C; when B→C
/// exists, `aff(A→C) += balance_rate · dt · aff(A→B) · aff(B→C)`. Failed draws are skipped.
fn balance_triads(
    clock: Res<SocietyClock>,
    settings: Res<SocietySettings>,
    index: Res<PersonIndex>,
    deceased: Query<(), With<crate::Deceased>>,
    mut rng: ResMut<SocietyRng>,
    mut ties: Query<&mut Ties>,
) {
    let n = index.sorted.len();
    if n == 0 {
        return;
    }
    let tuning = &settings.tuning;
    let dt = clock.dt_days;
    for _ in 0..tuning.balance_samples_per_tick {
        let a = index.sorted[rng.0.below(n)].1;
        let picked = {
            let Ok(a_ties) = ties.get(a) else {
                continue;
            };
            let len = a_ties.len();
            if len < 2 {
                continue;
            }
            let b = a_ties.0[rng.0.below(len)];
            let c = a_ties.0[rng.0.below(len)];
            if b.target == c.target {
                continue;
            }
            let Ok(b_ties) = ties.get(b.target) else {
                continue;
            };
            let Some(bc) = b_ties.get(c.target) else {
                continue;
            };
            (b.affinity, c.target, bc.affinity)
        };
        let (ab, c_target, bc) = picked;
        let delta = balance::triad_delta(tuning.balance_rate_per_day, dt, ab, bc);
        // A frozen tie takes no balance step, in either direction: the dead person's relations are fixed
        // at death, and so is everyone's feeling toward them.
        if deceased.get(a).is_ok() || deceased.get(c_target).is_ok() {
            continue;
        }
        if let Ok(mut owned) = ties.get_mut(a)
            && let Some(ac) = owned.get_mut(c_target)
        {
            ac.add_affinity(delta, tuning);
        }
    }
}

/// Drops each person's least-familiar ties beyond `max_ties` (plan D4, Dunbar 1992).
fn trim_ties(
    clock: Res<SocietyClock>,
    settings: Res<SocietySettings>,
    index: Res<PersonIndex>,
    deceased: Query<(), With<crate::Deceased>>,
    mut ties: Query<&mut Ties>,
) {
    let now = clock.now_days as f32;
    for &(_, entity) in &index.sorted {
        if let Ok(mut owned) = ties.get_mut(entity) {
            // A frozen tie is excluded from the count and never removed, so a person who has lost many
            // people still keeps every one of them.
            owned.trim_keeping(now, &settings.tuning, settings.max_ties, |target| {
                deceased.get(entity).is_ok() || deceased.get(target).is_ok()
            });
        }
    }
}

/// Recomputes every tie's label and writes one [`TieLabelChanged`] per transition (plan D4).
pub(crate) fn refresh_labels(
    clock: Res<SocietyClock>,
    settings: Res<SocietySettings>,
    index: Res<PersonIndex>,
    deceased: Query<(), With<crate::Deceased>>,
    mut ties: Query<&mut Ties>,
    mut changed: MessageWriter<TieLabelChanged>,
) {
    let now = clock.now_days as f32;
    for &(_, entity) in &index.sorted {
        // A dead person's labels stay as they were at death, and so does the label of a tie pointing at
        // them: the living still think of them as the friend they lost.
        if deceased.get(entity).is_ok() {
            continue;
        }
        let Ok(mut owned) = ties.get_mut(entity) else {
            continue;
        };
        for tie in owned.0.iter_mut() {
            if deceased.get(tie.target).is_ok() {
                continue;
            }
            if let Some((old, new)) = tie.refresh_label(now, &settings.tuning) {
                changed.write(TieLabelChanged {
                    from: entity,
                    to: tie.target,
                    old,
                    new,
                });
            }
        }
    }
}

/// Plugin for the directed-tie model (plan D4). Runs in `SocietySystems::Relate`, after
/// `SocietyCorePlugin`.
pub struct RelationshipsPlugin;

impl Plugin for RelationshipsPlugin {
    fn build(&self, app: &mut App) {
        app.register_type::<TieLabel>()
            .register_type::<Tie>()
            .register_type::<Ties>()
            .add_message::<TieLabelChanged>()
            .add_systems(
                FixedUpdate,
                (
                    expose_contacts,
                    relax_affinity,
                    balance_triads,
                    trim_ties,
                    refresh_labels,
                )
                    .chain()
                    .in_set(SocietySystems::Relate),
            );
    }

    fn finish(&self, app: &mut App) {
        require_core(app, "RelationshipsPlugin");
    }
}

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

    fn e(bits: u64) -> Entity {
        Entity::from_bits(bits)
    }

    #[test]
    fn labels_follow_the_d4_thresholds() {
        let tuning = Tuning::default();
        assert_eq!(TieLabel::classify(0.5, 0.5, &tuning), TieLabel::Friend);
        assert_eq!(TieLabel::classify(-0.5, 0.5, &tuning), TieLabel::Rival);
        assert_eq!(TieLabel::classify(0.0, 0.05, &tuning), TieLabel::Stranger);
        assert_eq!(
            TieLabel::classify(0.2, 0.5, &tuning),
            TieLabel::Acquaintance
        );
        assert_eq!(
            TieLabel::classify(0.5, 0.2, &tuning),
            TieLabel::Acquaintance
        );
    }

    #[test]
    fn entries_stay_sorted_by_target_bits() {
        let mut ties = Ties::default();
        for bits in [30_u64, 10, 20] {
            ties.entry(e(bits));
        }
        let order: Vec<u64> = ties.0.iter().map(|t| t.target.to_bits()).collect();
        assert_eq!(order, vec![10, 20, 30]);
    }

    #[test]
    fn trim_drops_least_familiar_first() {
        let tuning = Tuning::default();
        let mut ties = Ties::default();
        ties.entry(e(1)).familiarity_peak = 0.9;
        ties.entry(e(2)).familiarity_peak = 0.1;
        ties.entry(e(3)).familiarity_peak = 0.5;
        ties.trim(0.0, &tuning, 2);
        let kept: Vec<u64> = ties.0.iter().map(|t| t.target.to_bits()).collect();
        assert_eq!(kept, vec![1, 3]);
    }

    #[test]
    fn label_change_is_reported_once() {
        let tuning = Tuning::default();
        let mut tie = Tie::new(e(1));
        tie.affinity = 0.5;
        tie.familiarity_peak = 0.5;
        assert_eq!(
            tie.refresh_label(0.0, &tuning),
            Some((TieLabel::Stranger, TieLabel::Friend))
        );
        assert_eq!(tie.refresh_label(0.0, &tuning), None);
    }
}