1use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque};
15
16use serde::{Deserialize, Serialize};
17
18use petgraph::algo::kosaraju_scc;
19use petgraph::graph::{DiGraph, NodeIndex};
20
21use crate::config::ConsensusSection;
22use crate::ops::Ballot;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "lowercase")]
28pub enum TrustSource {
29 Default,
31 Configured,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(rename_all = "lowercase")]
38pub enum Settling {
39 Agreed,
41 Split,
44 Oscillating,
48 Anchored,
51}
52
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
55pub struct AgentLimit {
56 pub agent: String,
58 pub voted: String,
60 pub limit: Vec<f64>,
63 pub power: Option<f64>,
67 pub susceptibility: f64,
69}
70
71#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
73pub struct Outcome {
74 pub choices: Vec<String>,
76 pub agents: Vec<AgentLimit>,
78 pub settling: Settling,
80 pub consensus: Option<Vec<f64>>,
82 pub factions: Vec<Vec<String>>,
84 pub rounds: usize,
86 pub budget_reached: bool,
89 pub trust: TrustSource,
91 pub susceptibility: f64,
95 pub spread: f64,
98}
99
100impl Outcome {
101 #[must_use]
104 pub fn leader(&self) -> Option<(&str, f64)> {
105 let consensus = self.consensus.as_ref()?;
106 let mut ranked: Vec<(usize, f64)> = consensus.iter().copied().enumerate().collect();
107 ranked.sort_by(|a, b| b.1.total_cmp(&a.1));
108 let (top, share) = *ranked.first()?;
109 if ranked.len() > 1 && (ranked[1].1 - share).abs() < TIE_EPS {
110 return None;
111 }
112 Some((self.choices[top].as_str(), share))
113 }
114}
115
116impl Outcome {
117 #[must_use]
119 pub fn settled(&self) -> bool {
120 self.settling == Settling::Agreed && self.leader().is_some()
121 }
122}
123
124const TIE_EPS: f64 = 1e-6;
126
127#[must_use]
130pub fn settle(ballots: &[Ballot], cfg: &ConsensusSection) -> Outcome {
131 let agents: Vec<&Ballot> = {
132 let mut sorted: Vec<&Ballot> = ballots.iter().collect();
133 sorted.sort_by(|a, b| a.agent.cmp(&b.agent));
134 sorted
135 };
136 let choices: Vec<String> = agents
137 .iter()
138 .map(|b| b.choice.clone())
139 .collect::<BTreeSet<_>>()
140 .into_iter()
141 .collect();
142 let n = agents.len();
143 let m = choices.len();
144 if n == 0 {
145 return Outcome {
146 choices,
147 agents: Vec::new(),
148 settling: Settling::Agreed,
149 consensus: None,
150 factions: Vec::new(),
151 rounds: 0,
152 budget_reached: false,
153 trust: TrustSource::Default,
154 susceptibility: cfg.susceptibility,
155 spread: 0.0,
156 };
157 }
158
159 let names: Vec<&str> = agents.iter().map(|b| b.agent.as_str()).collect();
160 let (weights, trust) = influence(&names, cfg);
161 let pull: Vec<f64> = names
162 .iter()
163 .map(|name| {
164 cfg.susceptibility_of
165 .get(*name)
166 .copied()
167 .unwrap_or(cfg.susceptibility)
168 })
169 .collect();
170
171 let mut opinion = vec![vec![0.0f64; m]; n];
173 for (i, ballot) in agents.iter().enumerate() {
174 if let Some(at) = choices.iter().position(|c| *c == ballot.choice) {
175 opinion[i][at] = 1.0;
176 }
177 }
178
179 let anchored = pull.iter().any(|value| *value < 1.0);
182 let settling = if anchored {
183 Settling::Anchored
184 } else {
185 match structure(&weights) {
186 Structure::Convergent => Settling::Agreed,
187 Structure::Split => Settling::Split,
188 Structure::Periodic => Settling::Oscillating,
189 }
190 };
191 let start = opinion.clone();
192 let rounds = iterate(&mut opinion, &weights, &start, &pull, cfg, settling);
193 let consensus = (settling == Settling::Agreed).then(|| opinion[0].clone());
194 let power = (settling == Settling::Agreed).then(|| social_power(&weights, cfg));
198 let factions = match settling {
199 Settling::Split => group_by_limit(&names, &opinion, cfg.tolerance),
200 Settling::Agreed | Settling::Oscillating | Settling::Anchored => Vec::new(),
202 };
203 let spread = spread(&opinion, m);
204
205 Outcome {
206 choices,
207 agents: agents
208 .iter()
209 .enumerate()
210 .map(|(i, ballot)| AgentLimit {
211 agent: ballot.agent.clone(),
212 voted: ballot.choice.clone(),
213 limit: opinion[i].clone(),
214 power: power.as_ref().map(|p| p[i]),
215 susceptibility: pull[i],
216 })
217 .collect(),
218 settling,
219 consensus,
220 factions,
221 rounds,
222 budget_reached: settling != Settling::Oscillating && rounds >= cfg.max_iterations,
223 trust,
224 susceptibility: cfg.susceptibility,
225 spread,
226 }
227}
228
229pub fn of_issue(layout: &crate::config::Layout, id: &str) -> crate::error::Result<Outcome> {
236 of_issue_with(layout, id, &[])
237}
238
239pub fn of_issue_with(
245 layout: &crate::config::Layout,
246 id: &str,
247 rows: &[(String, String, f64)],
248) -> crate::error::Result<Outcome> {
249 of_issue_anchored(layout, id, rows, &[])
250}
251
252pub fn of_issue_anchored(
261 layout: &crate::config::Layout,
262 id: &str,
263 rows: &[(String, String, f64)],
264 anchors: &[(String, f64)],
265) -> crate::error::Result<Outcome> {
266 let ballots = crate::ops::ballots(layout, id)?;
267 let mut cfg = crate::config::VissueConfig::load(layout)?.consensus;
268 for (from, to, weight) in rows {
269 cfg.trust
270 .entry(from.clone())
271 .or_default()
272 .insert(to.clone(), *weight);
273 }
274 for (agent, s) in anchors {
275 cfg.susceptibility_of.insert(agent.clone(), *s);
276 }
277 Ok(settle(&ballots, &cfg))
278}
279
280pub fn anchor_rows(raw: &str) -> crate::error::Result<Vec<(String, f64)>> {
286 let bad = |what: &str| crate::error::Error::from(anyhow::anyhow!("susceptibility-of: {what}"));
287 let value: serde_json::Value =
288 serde_json::from_str(raw).map_err(|e| bad(&format!("not JSON: {e}")))?;
289 let map = value
290 .as_object()
291 .ok_or_else(|| bad("expected an object of agent to number"))?;
292 map.iter()
293 .map(|(agent, v)| match v.as_f64() {
294 Some(s) if (0.0..=1.0).contains(&s) => Ok((agent.clone(), s)),
295 _ => Err(bad(&format!("{agent}: a number in [0, 1]"))),
296 })
297 .collect()
298}
299
300pub fn trust_rows(raw: &str) -> crate::error::Result<Vec<(String, String, f64)>> {
307 let bad = |what: &str| crate::error::Error::from(anyhow::anyhow!("trust: {what}"));
308 let value: serde_json::Value =
309 serde_json::from_str(raw).map_err(|e| bad(&format!("not JSON: {e}")))?;
310 let rows = value.as_array().ok_or_else(|| bad("expected an array"))?;
311 rows.iter()
312 .map(|row| {
313 let (from, to, weight) = if let Some(items) = row.as_array() {
314 match items.as_slice() {
315 [f, t, w] => (f.as_str(), t.as_str(), w.as_f64()),
316 _ => return Err(bad("a tuple is [from, to, weight]")),
317 }
318 } else {
319 (
320 row.get("from").and_then(serde_json::Value::as_str),
321 row.get("to").and_then(serde_json::Value::as_str),
322 row.get("weight").and_then(serde_json::Value::as_f64),
323 )
324 };
325 match (from, to, weight) {
326 (Some(f), Some(t), Some(w)) if w > 0.0 && !f.is_empty() && !t.is_empty() => {
327 Ok((f.to_string(), t.to_string(), w))
328 }
329 _ => Err(bad("a row needs from, to and a positive weight")),
330 }
331 })
332 .collect()
333}
334
335pub fn of_plan(
343 layout: &crate::config::Layout,
344 plan: &str,
345) -> crate::error::Result<crate::views::PlanConsensus> {
346 use crate::views::{ChildConsensus, PlanConsensus};
347
348 let recs = crate::catalog::load_recs(layout)?;
349 let service = crate::catalog::CatalogService::from_recs(&recs);
350 let parent = service.detail(plan)?;
351 let cfg = crate::config::VissueConfig::load(layout)?.consensus;
352
353 let mut children = Vec::new();
354 for hit in service.children(plan)? {
355 let ballots = crate::ops::ballots(layout, &hit.id)?;
356 let outcome = (!ballots.is_empty()).then(|| settle(&ballots, &cfg));
357 children.push(ChildConsensus {
358 id: hit.id,
359 state: hit.state,
360 title: hit.title,
361 ballots: ballots.len(),
362 settling: outcome.as_ref().map(|o| o.settling),
363 holds: outcome.as_ref().and_then(|o| {
364 o.leader()
365 .map(|(choice, share)| (choice.to_string(), share))
366 }),
367 });
368 }
369
370 Ok(PlanConsensus {
371 plan: parent.id,
372 title: parent.title,
373 children,
374 })
375}
376
377fn influence(names: &[&str], cfg: &ConsensusSection) -> (Vec<Vec<f64>>, TrustSource) {
382 let n = names.len();
383 let mut weights = vec![vec![0.0f64; n]; n];
384 let mut source = TrustSource::Default;
385 for (i, name) in names.iter().enumerate() {
386 let row = &mut weights[i];
387 let configured = cfg.trust.get(*name).map(|spec| {
388 let mut named = 0usize;
389 for (j, other) in names.iter().enumerate() {
390 if let Some(w) = spec.get(*other)
391 && *w > 0.0
392 {
393 row[j] = *w;
394 named += 1;
395 }
396 }
397 (named > 0, spec.contains_key(*name))
398 });
399 match configured {
400 Some((true, names_itself)) => {
401 source = TrustSource::Configured;
402 if names_itself {
403 normalise(row, 1.0);
404 } else {
405 normalise(row, 1.0 - cfg.self_weight);
406 row[i] += cfg.self_weight;
407 }
408 }
409 _ => {
410 if n == 1 {
411 row[i] = 1.0;
412 } else {
413 let share = (1.0 - cfg.self_weight) / ((n - 1) as f64);
414 for weight in row.iter_mut() {
415 *weight = share;
416 }
417 row[i] = cfg.self_weight;
418 }
419 }
420 }
421 }
422 (weights, source)
423}
424
425fn normalise(row: &mut [f64], total: f64) {
428 let sum: f64 = row.iter().sum();
429 if sum <= 0.0 {
430 return;
431 }
432 for weight in row.iter_mut() {
433 *weight *= total / sum;
434 }
435}
436
437#[derive(Debug, Clone, Copy, PartialEq, Eq)]
440enum Structure {
441 Convergent,
443 Split,
445 Periodic,
447}
448
449fn structure(weights: &[Vec<f64>]) -> Structure {
452 let n = weights.len();
453 let mut graph = DiGraph::<usize, ()>::with_capacity(n, n);
454 let nodes: Vec<NodeIndex> = (0..n).map(|i| graph.add_node(i)).collect();
455 for (i, row) in weights.iter().enumerate() {
456 for (j, weight) in row.iter().enumerate() {
457 if *weight > 0.0 {
458 graph.add_edge(nodes[i], nodes[j], ());
459 }
460 }
461 }
462 let components = kosaraju_scc(&graph);
463 let mut component_of = vec![0usize; n];
464 for (at, component) in components.iter().enumerate() {
465 for node in component {
466 component_of[graph[*node]] = at;
467 }
468 }
469 let closed: Vec<usize> = components
470 .iter()
471 .enumerate()
472 .filter(|(at, component)| {
473 !component.iter().any(|node| {
474 graph
475 .neighbors(*node)
476 .any(|to| component_of[graph[to]] != *at)
477 })
478 })
479 .map(|(at, _)| at)
480 .collect();
481 let [only] = closed[..] else {
484 return Structure::Split;
485 };
486 if period(&graph, &components[only], &component_of, only) == 1 {
487 Structure::Convergent
488 } else {
489 Structure::Periodic
490 }
491}
492
493fn period(
496 graph: &DiGraph<usize, ()>,
497 component: &[NodeIndex],
498 component_of: &[usize],
499 at: usize,
500) -> usize {
501 let Some(&root) = component.first() else {
502 return 1;
503 };
504 let mut level: HashMap<NodeIndex, i64> = HashMap::from([(root, 0)]);
505 let mut queue = VecDeque::from([root]);
506 while let Some(node) = queue.pop_front() {
507 let depth = level[&node];
508 for to in graph.neighbors(node) {
509 if component_of[graph[to]] != at || level.contains_key(&to) {
510 continue;
511 }
512 level.insert(to, depth + 1);
513 queue.push_back(to);
514 }
515 }
516 let mut divisor = 0i64;
517 for node in component {
518 let Some(&depth) = level.get(node) else {
519 continue;
520 };
521 for to in graph.neighbors(*node) {
522 if component_of[graph[to]] != at {
523 continue;
524 }
525 if let Some(&other) = level.get(&to) {
526 divisor = gcd(divisor, depth + 1 - other);
527 }
528 }
529 }
530 let period = divisor.unsigned_abs() as usize;
531 period.max(1)
532}
533
534fn gcd(a: i64, b: i64) -> i64 {
535 let (mut a, mut b) = (a.abs(), b.abs());
536 while b != 0 {
537 let t = b;
538 b = a % b;
539 a = t;
540 }
541 a
542}
543
544fn iterate(
548 opinion: &mut Vec<Vec<f64>>,
549 weights: &[Vec<f64>],
550 start: &[Vec<f64>],
551 pull: &[f64],
552 cfg: &ConsensusSection,
553 settling: Settling,
554) -> usize {
555 if settling == Settling::Oscillating {
556 return 0;
557 }
558 let n = opinion.len();
559 let m = opinion.first().map_or(0, Vec::len);
560 for round in 0..cfg.max_iterations {
561 if settling == Settling::Agreed && spread(opinion, m) < cfg.tolerance {
562 return round;
563 }
564 let mut next = vec![vec![0.0f64; m]; n];
565 let mut step = 0.0f64;
566 for i in 0..n {
567 for c in 0..m {
568 let mut acc = 0.0;
569 for (j, row) in opinion.iter().enumerate() {
570 acc += weights[i][j] * row[c];
571 }
572 let value = pull[i] * acc + (1.0 - pull[i]) * start[i][c];
573 next[i][c] = value;
574 step = step.max((value - opinion[i][c]).abs());
575 }
576 }
577 *opinion = next;
578 if matches!(settling, Settling::Split | Settling::Anchored) && step < cfg.tolerance {
579 return round + 1;
580 }
581 }
582 cfg.max_iterations
583}
584
585fn spread(opinion: &[Vec<f64>], m: usize) -> f64 {
587 let mut worst = 0.0f64;
588 for c in 0..m {
589 let mut low = f64::INFINITY;
590 let mut high = f64::NEG_INFINITY;
591 for row in opinion {
592 low = low.min(row[c]);
593 high = high.max(row[c]);
594 }
595 worst = worst.max(high - low);
596 }
597 worst
598}
599
600fn social_power(weights: &[Vec<f64>], cfg: &ConsensusSection) -> Vec<f64> {
603 let n = weights.len();
604 let mut power = vec![1.0 / (n as f64); n];
605 for _ in 0..cfg.max_iterations {
606 let mut next = vec![0.0f64; n];
607 for j in 0..n {
608 for (i, row) in weights.iter().enumerate() {
609 next[j] += power[i] * row[j];
610 }
611 }
612 let step = next
613 .iter()
614 .zip(&power)
615 .map(|(a, b)| (a - b).abs())
616 .fold(0.0f64, f64::max);
617 power = next;
618 if step < cfg.tolerance {
619 break;
620 }
621 }
622 let sum: f64 = power.iter().sum();
623 if sum > 0.0 {
624 for weight in &mut power {
625 *weight /= sum;
626 }
627 }
628 power
629}
630
631fn group_by_limit(names: &[&str], opinion: &[Vec<f64>], tolerance: f64) -> Vec<Vec<String>> {
633 let mut groups: Vec<(Vec<f64>, Vec<String>)> = Vec::new();
634 for (i, name) in names.iter().enumerate() {
635 let row = &opinion[i];
636 match groups.iter_mut().find(|(seen, _)| {
637 seen.iter()
638 .zip(row)
639 .all(|(a, b)| (a - b).abs() < tolerance.max(TIE_EPS))
640 }) {
641 Some((_, members)) => members.push((*name).to_string()),
642 None => groups.push((row.clone(), vec![(*name).to_string()])),
643 }
644 }
645 groups.into_iter().map(|(_, members)| members).collect()
646}
647
648#[must_use]
650pub fn tally(ballots: &[Ballot]) -> BTreeMap<String, Vec<String>> {
651 let mut counts: BTreeMap<String, Vec<String>> = BTreeMap::new();
652 for ballot in ballots {
653 counts
654 .entry(ballot.choice.clone())
655 .or_default()
656 .push(ballot.agent.clone());
657 }
658 counts
659}
660
661#[cfg(test)]
662mod tests {
663 use super::*;
664
665 #[test]
668 fn anchor_rows_take_an_object_in_range() {
669 let rows = anchor_rows(r#"{"reviewer": 0.3, "author": 1}"#).expect("parses");
670 assert_eq!(
671 rows,
672 vec![("author".to_string(), 1.0), ("reviewer".to_string(), 0.3)]
673 );
674 assert!(anchor_rows(r#"{"reviewer": 1.5}"#).is_err());
675 assert!(anchor_rows(r#"[["reviewer", 0.3]]"#).is_err());
676 }
677
678 fn ballot(agent: &str, choice: &str) -> Ballot {
679 Ballot {
680 agent: agent.to_string(),
681 choice: choice.to_string(),
682 stamp: "[2026-09-07 Mon]".to_string(),
683 }
684 }
685
686 fn share(outcome: &Outcome, choice: &str) -> f64 {
687 let at = outcome
688 .choices
689 .iter()
690 .position(|c| c == choice)
691 .expect("choice");
692 outcome.consensus.as_ref().expect("consensus")[at]
693 }
694
695 #[test]
698 fn trust_rows_take_tuples_or_objects() {
699 let rows = trust_rows(r#"[["a","b",2.0],{"from":"b","to":"a","weight":1}]"#).unwrap();
700 assert_eq!(
701 rows,
702 vec![("a".into(), "b".into(), 2.0), ("b".into(), "a".into(), 1.0)]
703 );
704 assert!(trust_rows("{}").is_err());
705 assert!(trust_rows(r#"[["a","b"]]"#).is_err());
706 assert!(trust_rows(r#"[["a","b",0]]"#).is_err());
707 assert!(trust_rows("not json").is_err());
708 }
709
710 #[test]
712 fn without_configuration_the_consensus_is_the_tally_as_a_fraction() {
713 let cfg = ConsensusSection::default();
714 let ballots = [
715 ballot("alice", "ship"),
716 ballot("bob", "ship"),
717 ballot("carol", "hold"),
718 ];
719 let outcome = settle(&ballots, &cfg);
720 assert_eq!(outcome.settling, Settling::Agreed);
721 assert!((share(&outcome, "ship") - 2.0 / 3.0).abs() < 1e-6);
722 assert!((share(&outcome, "hold") - 1.0 / 3.0).abs() < 1e-6);
723 assert_eq!(outcome.leader().map(|(c, _)| c), Some("ship"));
724 for row in &outcome.agents {
725 assert!((row.power.expect("power") - 1.0 / 3.0).abs() < 1e-6);
726 }
727 }
728
729 #[test]
733 fn trust_can_move_the_group_off_the_plurality() {
734 let mut cfg = ConsensusSection::default();
735 cfg.trust.insert(
736 "alice".to_string(),
737 BTreeMap::from([("carol".to_string(), 1.0)]),
738 );
739 cfg.trust.insert(
740 "bob".to_string(),
741 BTreeMap::from([("carol".to_string(), 1.0)]),
742 );
743 cfg.trust.insert(
744 "carol".to_string(),
745 BTreeMap::from([("carol".to_string(), 4.0), ("alice".to_string(), 1.0)]),
746 );
747 let ballots = [
748 ballot("alice", "ship"),
749 ballot("bob", "ship"),
750 ballot("carol", "hold"),
751 ];
752 let outcome = settle(&ballots, &cfg);
753 assert_eq!(outcome.settling, Settling::Agreed);
754 assert_eq!(
755 outcome.leader().map(|(c, _)| c),
756 Some("hold"),
757 "the plurality is ship; the group listens to carol: {outcome:?}"
758 );
759 let power = |who: &str| {
760 outcome
761 .agents
762 .iter()
763 .find(|a| a.agent == who)
764 .expect("agent")
765 .power
766 .expect("power")
767 };
768 assert!(power("carol") > power("alice"), "{outcome:?}");
769 assert!(power("bob") < 1e-6, "{outcome:?}");
772 }
773
774 #[test]
778 fn the_consensus_is_the_ballots_weighted_by_social_power() {
779 let mut cfg = ConsensusSection::default();
780 cfg.trust.insert(
781 "alice".to_string(),
782 BTreeMap::from([("bob".to_string(), 3.0), ("carol".to_string(), 1.0)]),
783 );
784 cfg.trust.insert(
785 "bob".to_string(),
786 BTreeMap::from([("carol".to_string(), 1.0)]),
787 );
788 let ballots = [
789 ballot("alice", "ship"),
790 ballot("bob", "hold"),
791 ballot("carol", "hold"),
792 ];
793 let outcome = settle(&ballots, &cfg);
794 assert_eq!(outcome.settling, Settling::Agreed);
795 for (at, choice) in outcome.choices.iter().enumerate() {
796 let weighted: f64 = outcome
797 .agents
798 .iter()
799 .map(|a| {
800 let vote = f64::from(u8::from(a.voted == *choice));
801 a.power.expect("power") * vote
802 })
803 .sum();
804 assert!(
805 (weighted - outcome.consensus.as_ref().expect("consensus")[at]).abs() < 1e-6,
806 "{choice}: {weighted} vs {outcome:?}"
807 );
808 }
809 }
810
811 #[test]
815 fn two_closed_groups_do_not_reach_a_consensus() {
816 let mut cfg = ConsensusSection::default();
817 for (who, whom) in [
818 ("alice", "bob"),
819 ("bob", "alice"),
820 ("carol", "dave"),
821 ("dave", "carol"),
822 ] {
823 cfg.trust
824 .insert(who.to_string(), BTreeMap::from([(whom.to_string(), 1.0)]));
825 }
826 let ballots = [
827 ballot("alice", "ship"),
828 ballot("bob", "ship"),
829 ballot("carol", "hold"),
830 ballot("dave", "hold"),
831 ];
832 let outcome = settle(&ballots, &cfg);
833 assert_eq!(outcome.settling, Settling::Split, "{outcome:?}");
834 assert!(outcome.consensus.is_none());
835 assert!(outcome.leader().is_none());
836 assert_eq!(outcome.factions.len(), 2, "{:?}", outcome.factions);
837 assert_eq!(
838 outcome.factions[0],
839 vec!["alice".to_string(), "bob".to_string()]
840 );
841 }
842
843 #[test]
847 fn a_periodic_trust_graph_is_reported_as_oscillating() {
848 let mut cfg = ConsensusSection {
849 self_weight: 0.0,
850 max_iterations: 50,
851 ..ConsensusSection::default()
852 };
853 cfg.trust.insert(
854 "alice".to_string(),
855 BTreeMap::from([("bob".to_string(), 1.0)]),
856 );
857 cfg.trust.insert(
858 "bob".to_string(),
859 BTreeMap::from([("alice".to_string(), 1.0)]),
860 );
861 let ballots = [ballot("alice", "ship"), ballot("bob", "hold")];
862 let outcome = settle(&ballots, &cfg);
863 assert_eq!(outcome.settling, Settling::Oscillating, "{outcome:?}");
864 assert!(outcome.consensus.is_none());
865 }
866
867 #[test]
871 fn weight_on_an_agent_that_did_not_vote_is_dropped() {
872 let ballots = [ballot("alice", "ship"), ballot("bob", "hold")];
873 let mut with_absentee = ConsensusSection::default();
874 with_absentee.trust.insert(
875 "alice".to_string(),
876 BTreeMap::from([("absent".to_string(), 9.0), ("bob".to_string(), 1.0)]),
877 );
878 let mut without = ConsensusSection::default();
879 without.trust.insert(
880 "alice".to_string(),
881 BTreeMap::from([("bob".to_string(), 1.0)]),
882 );
883
884 assert_eq!(
885 settle(&ballots, &with_absentee).agents,
886 settle(&ballots, &without).agents,
887 "nine parts trust in an agent that did not vote changed the answer"
888 );
889 }
890
891 #[test]
893 fn a_slowly_mixing_group_still_reaches_a_consensus() {
894 let cfg = ConsensusSection {
895 self_weight: 0.99,
896 max_iterations: 40,
897 ..ConsensusSection::default()
898 };
899 let ballots = [
900 ballot("alice", "ship"),
901 ballot("bob", "ship"),
902 ballot("carol", "hold"),
903 ];
904 let outcome = settle(&ballots, &cfg);
905 assert_eq!(outcome.settling, Settling::Agreed, "{outcome:?}");
906 assert!(
907 outcome.budget_reached,
908 "40 rounds cannot settle this one, and the report has to say so"
909 );
910 }
911
912 #[test]
916 fn an_anchor_leaves_the_minority_still_holding_its_position() {
917 let ballots = [
918 ballot("alice", "ship"),
919 ballot("bob", "ship"),
920 ballot("carol", "hold"),
921 ];
922 let unanchored = settle(&ballots, &ConsensusSection::default());
923 assert_eq!(unanchored.settling, Settling::Agreed);
924 assert!(unanchored.spread < 1e-6, "{unanchored:?}");
925
926 let anchored = settle(
927 &ballots,
928 &ConsensusSection {
929 susceptibility: 0.6,
930 ..ConsensusSection::default()
931 },
932 );
933 assert_eq!(anchored.settling, Settling::Anchored, "{anchored:?}");
934 assert!(anchored.consensus.is_none(), "no one position to report");
935 assert!(
936 anchored.spread > 0.1,
937 "the disagreement is the result: {anchored:?}"
938 );
939 let hold = anchored
940 .choices
941 .iter()
942 .position(|c| c == "hold")
943 .expect("hold");
944 let carol = anchored
945 .agents
946 .iter()
947 .find(|a| a.agent == "carol")
948 .expect("carol");
949 let alice = anchored
950 .agents
951 .iter()
952 .find(|a| a.agent == "alice")
953 .expect("alice");
954 assert!(
955 carol.limit[hold] > alice.limit[hold],
956 "carol voted hold and stays nearer it: {anchored:?}"
957 );
958 }
959
960 #[test]
964 fn the_anchored_limit_solves_the_friedkin_johnsen_equation() {
965 let mut cfg = ConsensusSection {
966 susceptibility: 0.7,
967 ..ConsensusSection::default()
968 };
969 cfg.susceptibility_of.insert("carol".to_string(), 0.25);
972 cfg.trust.insert(
973 "alice".to_string(),
974 BTreeMap::from([("carol".to_string(), 2.0), ("bob".to_string(), 1.0)]),
975 );
976 let ballots = [
977 ballot("alice", "ship"),
978 ballot("bob", "ship"),
979 ballot("carol", "hold"),
980 ];
981 let outcome = settle(&ballots, &cfg);
982 assert_eq!(outcome.settling, Settling::Anchored);
983
984 let names: Vec<&str> = outcome.agents.iter().map(|a| a.agent.as_str()).collect();
985 let (weights, _) = influence(&names, &cfg);
986 for (i, row) in outcome.agents.iter().enumerate() {
987 for (c, choice) in outcome.choices.iter().enumerate() {
988 let neighbours: f64 = outcome
989 .agents
990 .iter()
991 .enumerate()
992 .map(|(j, other)| weights[i][j] * other.limit[c])
993 .sum();
994 let own = f64::from(u8::from(row.voted == *choice));
995 let pull = row.susceptibility;
996 let want = pull * neighbours + (1.0 - pull) * own;
997 assert!(
998 (want - row.limit[c]).abs() < 1e-6,
999 "{} on {choice}: {want} vs {}",
1000 row.agent,
1001 row.limit[c]
1002 );
1003 }
1004 }
1005 }
1006
1007 #[test]
1009 fn a_named_agent_carries_its_own_susceptibility() {
1010 let mut cfg = ConsensusSection {
1011 susceptibility: 0.9,
1012 ..ConsensusSection::default()
1013 };
1014 cfg.susceptibility_of.insert("maintainer".to_string(), 0.1);
1015 let ballots = [
1016 ballot("maintainer", "hold"),
1017 ballot("newcomer", "ship"),
1018 ballot("other", "ship"),
1019 ];
1020 let outcome = settle(&ballots, &cfg);
1021 assert_eq!(outcome.settling, Settling::Anchored);
1022
1023 let of = |who: &str| {
1024 outcome
1025 .agents
1026 .iter()
1027 .find(|a| a.agent == who)
1028 .expect("agent")
1029 };
1030 assert!((of("maintainer").susceptibility - 0.1).abs() < f64::EPSILON);
1031 assert!((of("newcomer").susceptibility - 0.9).abs() < f64::EPSILON);
1032
1033 let hold = outcome
1035 .choices
1036 .iter()
1037 .position(|c| c == "hold")
1038 .expect("hold");
1039 assert!(
1040 of("maintainer").limit[hold] > of("newcomer").limit[hold],
1041 "{outcome:?}"
1042 );
1043 }
1044
1045 #[test]
1049 fn an_agent_at_zero_never_leaves_its_ballot() {
1050 let mut cfg = ConsensusSection::default();
1051 cfg.susceptibility_of.insert("rock".to_string(), 0.0);
1052 let ballots = [
1053 ballot("rock", "hold"),
1054 ballot("a", "ship"),
1055 ballot("b", "ship"),
1056 ];
1057 let outcome = settle(&ballots, &cfg);
1058 let hold = outcome
1059 .choices
1060 .iter()
1061 .position(|c| c == "hold")
1062 .expect("hold");
1063 let rock = outcome
1064 .agents
1065 .iter()
1066 .find(|a| a.agent == "rock")
1067 .expect("rock");
1068 assert!(
1069 (rock.limit[hold] - 1.0).abs() < 1e-9,
1070 "it voted hold and never moved: {outcome:?}"
1071 );
1072 let a = outcome.agents.iter().find(|x| x.agent == "a").expect("a");
1074 assert!(a.limit[hold] > 0.0, "{outcome:?}");
1075 }
1076
1077 #[test]
1080 fn naming_nobody_is_the_scalar_case() {
1081 let ballots = [ballot("a", "ship"), ballot("b", "hold")];
1082 let scalar = ConsensusSection {
1083 susceptibility: 0.5,
1084 ..ConsensusSection::default()
1085 };
1086 let mut spelled_out = scalar.clone();
1087 for who in ["a", "b"] {
1088 spelled_out.susceptibility_of.insert(who.to_string(), 0.5);
1089 }
1090 assert_eq!(
1091 settle(&ballots, &scalar).agents,
1092 settle(&ballots, &spelled_out).agents
1093 );
1094 }
1095
1096 #[test]
1100 fn an_anchor_removes_the_periodic_case() {
1101 let mut cfg = ConsensusSection {
1102 self_weight: 0.0,
1103 susceptibility: 0.9,
1104 max_iterations: 500,
1105 ..ConsensusSection::default()
1106 };
1107 cfg.trust.insert(
1108 "alice".to_string(),
1109 BTreeMap::from([("bob".to_string(), 1.0)]),
1110 );
1111 cfg.trust.insert(
1112 "bob".to_string(),
1113 BTreeMap::from([("alice".to_string(), 1.0)]),
1114 );
1115 let ballots = [ballot("alice", "ship"), ballot("bob", "hold")];
1116
1117 let unanchored = settle(
1118 &ballots,
1119 &ConsensusSection {
1120 susceptibility: 1.0,
1121 ..cfg.clone()
1122 },
1123 );
1124 assert_eq!(unanchored.settling, Settling::Oscillating);
1125
1126 let anchored = settle(&ballots, &cfg);
1127 assert_eq!(anchored.settling, Settling::Anchored, "{anchored:?}");
1128 assert!(
1129 !anchored.budget_reached,
1130 "a contraction settles well inside the budget: {anchored:?}"
1131 );
1132 }
1133
1134 #[test]
1137 fn full_susceptibility_is_the_unanchored_model() {
1138 let ballots = [
1139 ballot("alice", "ship"),
1140 ballot("bob", "hold"),
1141 ballot("carol", "ship"),
1142 ];
1143 let default = settle(&ballots, &ConsensusSection::default());
1144 let explicit = settle(
1145 &ballots,
1146 &ConsensusSection {
1147 susceptibility: 1.0,
1148 ..ConsensusSection::default()
1149 },
1150 );
1151 assert_eq!(default.settling, explicit.settling);
1152 assert_eq!(default.agents, explicit.agents);
1153 assert_eq!(default.consensus, explicit.consensus);
1154 }
1155
1156 #[test]
1159 fn an_exact_tie_has_no_leader() {
1160 let cfg = ConsensusSection::default();
1161 let ballots = [ballot("alice", "ship"), ballot("bob", "hold")];
1162 let outcome = settle(&ballots, &cfg);
1163 assert_eq!(outcome.settling, Settling::Agreed);
1164 assert!(outcome.leader().is_none(), "{outcome:?}");
1165 }
1166
1167 #[test]
1170 fn a_single_ballot_settles_on_itself() {
1171 let cfg = ConsensusSection::default();
1172 let outcome = settle(&[ballot("alice", "ship")], &cfg);
1173 assert_eq!(outcome.settling, Settling::Agreed);
1174 assert_eq!(outcome.agents.len(), 1);
1175 assert!((share(&outcome, "ship") - 1.0).abs() < 1e-9);
1176 assert!((outcome.agents[0].power.unwrap() - 1.0).abs() < 1e-9);
1177 }
1178
1179 #[test]
1182 fn no_ballots_leaves_no_consensus_to_report() {
1183 let outcome = settle(&[], &ConsensusSection::default());
1184 assert!(outcome.agents.is_empty());
1185 assert!(outcome.consensus.is_none());
1186 assert!(outcome.leader().is_none());
1187 }
1188
1189 #[test]
1193 fn every_influence_row_is_stochastic() {
1194 let mut cfg = ConsensusSection::default();
1195 cfg.trust.insert(
1196 "alice".to_string(),
1197 BTreeMap::from([("bob".to_string(), 7.5), ("carol".to_string(), 0.25)]),
1198 );
1199 cfg.trust.insert(
1200 "bob".to_string(),
1201 BTreeMap::from([("bob".to_string(), 4.0), ("alice".to_string(), 1.0)]),
1202 );
1203 let (weights, source) = influence(&["alice", "bob", "carol"], &cfg);
1204 assert_eq!(source, TrustSource::Configured);
1205 for row in &weights {
1206 let sum: f64 = row.iter().sum();
1207 assert!((sum - 1.0).abs() < 1e-12, "{row:?} sums to {sum}");
1208 }
1209 assert!((weights[1][1] - 0.8).abs() < 1e-12, "{:?}", weights[1]);
1212 assert!((weights[0][0] - cfg.self_weight).abs() < 1e-12);
1214 }
1215}