Skip to main content

quoracle/
quorum_system.rs

1//! Read-write quorum systems and strategies.
2//!
3//! A [`QuorumSystem`] pairs a read expression with a write expression such
4//! that every read quorum intersects every write quorum. A [`Strategy`] is a
5//! probability distribution over read quorums and over write quorums; it
6//! determines per-node load, capacity, network cost, and latency.
7
8use crate::distribution::{self, Canonical, Distribution};
9use crate::error::{Error, Result};
10use crate::expr::{minimize, Element, Expr, Node};
11use good_lp::{
12    default_solver, variable, Expression, ProblemVariables, Solution,
13    SolverModel, Variable,
14};
15use hashbrown::{HashMap, HashSet};
16use itertools::Itertools;
17use rand::seq::IndexedRandom;
18use std::collections::BTreeMap;
19use std::time::Duration;
20
21/// Optimization objective for strategy computation.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum Objective {
24    /// Minimize the load of the busiest node (maximizes capacity).
25    Load,
26    /// Minimize the expected number of nodes contacted per operation.
27    Network,
28    /// Minimize the expected operation latency.
29    Latency,
30}
31
32/// Optional upper bounds for strategy optimization.
33///
34/// The bound matching the objective being optimized must be `None`.
35#[derive(Debug, Clone, Copy, Default)]
36pub struct StrategyLimits {
37    /// Maximum load (see [`Strategy::load`]).
38    pub load: Option<f64>,
39    /// Maximum network load (see [`Strategy::network_load`]).
40    pub network: Option<f64>,
41    /// Maximum latency (see [`Strategy::latency`]).
42    pub latency: Option<Duration>,
43}
44
45impl StrategyLimits {
46    /// Reject a limit on the metric being optimized.
47    pub(crate) fn check(&self, objective: Objective) -> Result<()> {
48        let conflict = match objective {
49            Objective::Load => self.load.is_some(),
50            Objective::Network => self.network.is_some(),
51            Objective::Latency => self.latency.is_some(),
52        };
53        if conflict {
54            return Err(Error::InvalidQuorumSystem(format!(
55                "a {objective:?} limit cannot be set when optimizing for \
56                 {objective:?}"
57            )));
58        }
59        Ok(())
60    }
61}
62
63type Quorums<T> = Vec<HashSet<T>>;
64
65/// A quorum as a sorted, de-duplicated list of node identifiers.
66pub type Quorum<T> = Vec<T>;
67
68fn to_quorum<T: Element>(set: &HashSet<T>) -> Quorum<T> {
69    let mut vec: Vec<T> = set.iter().cloned().collect();
70    vec.sort();
71    vec
72}
73
74fn to_set<T: Element>(quorum: &[T]) -> HashSet<T> {
75    quorum.iter().cloned().collect()
76}
77
78#[expect(clippy::cast_precision_loss)]
79fn len_f64(n: usize) -> f64 {
80    n as f64
81}
82
83/// A read-write quorum system.
84#[derive(Debug, Clone)]
85pub struct QuorumSystem<T: Element> {
86    reads: Expr<T>,
87    writes: Expr<T>,
88    x_to_node: HashMap<T, Node<T>>,
89}
90
91impl<T: Element> QuorumSystem<T> {
92    /// Build a quorum system from reads only; writes are the dual.
93    pub fn from_reads(reads: Expr<T>) -> Self {
94        let writes = reads.dual();
95        Self::build(reads, writes)
96    }
97
98    /// Build a quorum system from writes only; reads are the dual.
99    pub fn from_writes(writes: Expr<T>) -> Self {
100        let reads = writes.dual();
101        Self::build(reads, writes)
102    }
103
104    /// Build a quorum system from both read and write expressions.
105    ///
106    /// # Errors
107    ///
108    /// Returns [`Error::NonOverlappingQuorums`] unless every read quorum
109    /// intersects every write quorum.
110    pub fn new(reads: Expr<T>, writes: Expr<T>) -> Result<Self> {
111        let optimal_writes = reads.dual();
112        if !writes.quorums().all(|wq| optimal_writes.is_quorum(&wq)) {
113            return Err(Error::NonOverlappingQuorums);
114        }
115        Ok(Self::build(reads, writes))
116    }
117
118    fn build(reads: Expr<T>, writes: Expr<T>) -> Self {
119        let mut x_to_node = HashMap::new();
120        for node in reads.nodes().into_iter().chain(writes.nodes()) {
121            x_to_node.entry(node.x().clone()).or_insert(node);
122        }
123        Self { reads, writes, x_to_node }
124    }
125
126    /// The read expression.
127    #[must_use]
128    pub fn reads(&self) -> &Expr<T> {
129        &self.reads
130    }
131
132    /// The write expression.
133    #[must_use]
134    pub fn writes(&self) -> &Expr<T> {
135        &self.writes
136    }
137
138    /// Iterate over all read quorums.
139    pub fn read_quorums(&self) -> Box<dyn Iterator<Item = HashSet<T>> + '_> {
140        self.reads.quorums()
141    }
142
143    /// Iterate over all write quorums.
144    pub fn write_quorums(&self) -> Box<dyn Iterator<Item = HashSet<T>> + '_> {
145        self.writes.quorums()
146    }
147
148    /// Whether `xs` contains a read quorum.
149    #[must_use]
150    pub fn is_read_quorum(&self, xs: &HashSet<T>) -> bool {
151        self.reads.is_quorum(xs)
152    }
153
154    /// Whether `xs` contains a write quorum.
155    #[must_use]
156    pub fn is_write_quorum(&self, xs: &HashSet<T>) -> bool {
157        self.writes.is_quorum(xs)
158    }
159
160    /// Look up a node by its identifier.
161    ///
162    /// # Errors
163    ///
164    /// Returns [`Error::InvalidQuorumSystem`] if `x` is not in the system.
165    pub fn node(&self, x: &T) -> Result<&Node<T>> {
166        self.x_to_node.get(x).ok_or_else(|| {
167            Error::InvalidQuorumSystem(format!(
168                "element {x} not found in quorum system"
169            ))
170        })
171    }
172
173    /// All nodes in the system.
174    #[must_use]
175    pub fn nodes(&self) -> HashSet<Node<T>> {
176        self.x_to_node.values().cloned().collect()
177    }
178
179    /// All node identifiers in the system.
180    #[must_use]
181    pub fn elements(&self) -> HashSet<T> {
182        self.x_to_node.keys().cloned().collect()
183    }
184
185    /// The resilience of the system: the minimum of read and write
186    /// resilience.
187    #[must_use]
188    pub fn resilience(&self) -> usize {
189        self.read_resilience().min(self.write_resilience())
190    }
191
192    /// The resilience of the read expression.
193    #[must_use]
194    pub fn read_resilience(&self) -> usize {
195        self.reads.resilience()
196    }
197
198    /// The resilience of the write expression.
199    #[must_use]
200    pub fn write_resilience(&self) -> usize {
201        self.writes.resilience()
202    }
203
204    /// Whether both read and write expressions are duplicate-free.
205    #[must_use]
206    pub fn dup_free(&self) -> bool {
207        self.reads.dup_free() && self.writes.dup_free()
208    }
209
210    /// Read and write quorum candidates for `f`-resilient strategies.
211    fn candidate_quorums(&self, f: usize) -> Result<(Quorums<T>, Quorums<T>)> {
212        if f == 0 {
213            return Ok((
214                minimize(self.read_quorums().collect()),
215                minimize(self.write_quorums().collect()),
216            ));
217        }
218        let mut xs: Vec<T> = self.elements().into_iter().collect();
219        xs.sort();
220        let rq = f_resilient_quorums(f, &xs, &self.reads);
221        let wq = f_resilient_quorums(f, &xs, &self.writes);
222        if rq.is_empty() || wq.is_empty() {
223            return Err(Error::NoStrategyFound);
224        }
225        Ok((rq, wq))
226    }
227
228    /// A strategy that picks uniformly among the minimal `f`-resilient
229    /// quorums.
230    ///
231    /// # Errors
232    ///
233    /// Returns [`Error::NoStrategyFound`] if there are no `f`-resilient read
234    /// or write quorums.
235    pub fn uniform_strategy(&self, f: usize) -> Result<Strategy<T>> {
236        let (rq, wq) = self.candidate_quorums(f)?;
237        let uniform = |qs: Vec<HashSet<T>>| -> BTreeMap<Quorum<T>, f64> {
238            let p = 1.0 / len_f64(qs.len());
239            qs.iter().map(|q| (to_quorum(q), p)).collect()
240        };
241        Ok(Strategy::new(self, uniform(rq), uniform(wq)))
242    }
243
244    /// Build a strategy from explicit quorum weights.
245    ///
246    /// Each key is a list of node identifiers (order and duplicates do not
247    /// matter); weights for the same set are added together, and weights
248    /// are normalized to sum to 1. Zero-weight quorums are dropped.
249    ///
250    /// # Errors
251    ///
252    /// Returns [`Error::InvalidQuorumSystem`] if a key is not a read (or
253    /// write) quorum, a weight is negative or not finite, or all weights are
254    /// zero.
255    pub fn make_strategy(
256        &self,
257        sigma_r: BTreeMap<Quorum<T>, f64>,
258        sigma_w: BTreeMap<Quorum<T>, f64>,
259    ) -> Result<Strategy<T>> {
260        let r = normalize("sigma_r", sigma_r, |q| self.is_read_quorum(q))?;
261        let w = normalize("sigma_w", sigma_w, |q| self.is_write_quorum(q))?;
262        Ok(Strategy::new(self, r, w))
263    }
264
265    /// Compute the optimal strategy by linear programming.
266    ///
267    /// Minimizes `objective` subject to `limits`, considering only
268    /// `f`-resilient quorums (quorums that still contain a quorum after any
269    /// `f` of their nodes fail). Exactly one of `read_fraction` and
270    /// `write_fraction` must be `Some`.
271    ///
272    /// # Errors
273    ///
274    /// - [`Error::InvalidQuorumSystem`] if the limit matching `objective`
275    ///   is set.
276    /// - [`Error::InvalidDistribution`] for a bad read/write fraction.
277    /// - [`Error::NoStrategyFound`] if the limits cannot be met or there
278    ///   are no `f`-resilient quorums.
279    /// - [`Error::LpError`] if the solver fails for another reason.
280    pub fn strategy(
281        &self,
282        objective: Objective,
283        read_fraction: Option<&Distribution>,
284        write_fraction: Option<&Distribution>,
285        limits: &StrategyLimits,
286        f: usize,
287    ) -> Result<Strategy<T>> {
288        limits.check(objective)?;
289        let d = distribution::canonicalize_rw(read_fraction, write_fraction)?;
290        let (rq, wq) = self.candidate_quorums(f)?;
291        self.lp_optimal_strategy(&rq, &wq, &d, objective, limits)
292    }
293
294    /// Latency of a quorum: the time until enough of its nodes (fastest
295    /// first) have responded to form a quorum of `expr`.
296    fn quorum_latency(&self, quorum: &HashSet<T>, expr: &Expr<T>) -> Duration {
297        quorum_latency(&self.x_to_node, quorum, expr)
298    }
299
300    fn lp_optimal_strategy(
301        &self,
302        read_quorums: &[HashSet<T>],
303        write_quorums: &[HashSet<T>],
304        read_fraction: &Canonical,
305        objective: Objective,
306        limits: &StrategyLimits,
307    ) -> Result<Strategy<T>> {
308        let mut vars = ProblemVariables::new();
309        let r: Vec<Variable> = read_quorums
310            .iter()
311            .map(|_| vars.add(variable().min(0.0).max(1.0)))
312            .collect();
313        let w: Vec<Variable> = write_quorums
314            .iter()
315            .map(|_| vars.add(variable().min(0.0).max(1.0)))
316            .collect();
317        // One load variable per read fraction: the max node load at that fr.
318        let loads: Vec<(f64, f64, Variable)> = read_fraction
319            .iter()
320            .map(|(fr, &p)| (fr.0, p, vars.add(variable().min(0.0))))
321            .collect();
322
323        let avg_fr: f64 = read_fraction.iter().map(|(k, &p)| k.0 * p).sum();
324        let weighted = |coeffs: &[f64], vs: &[Variable]| -> Expression {
325            coeffs.iter().zip(vs).map(|(&c, &v)| c * v).sum()
326        };
327        let rq_sizes: Vec<f64> =
328            read_quorums.iter().map(|q| len_f64(q.len())).collect();
329        let wq_sizes: Vec<f64> =
330            write_quorums.iter().map(|q| len_f64(q.len())).collect();
331        let network = avg_fr * weighted(&rq_sizes, &r)
332            + (1.0 - avg_fr) * weighted(&wq_sizes, &w);
333        let latency = {
334            let rl: Vec<f64> = read_quorums
335                .iter()
336                .map(|q| self.quorum_latency(q, &self.reads).as_secs_f64())
337                .collect();
338            let wl: Vec<f64> = write_quorums
339                .iter()
340                .map(|q| self.quorum_latency(q, &self.writes).as_secs_f64())
341                .collect();
342            avg_fr * weighted(&rl, &r) + (1.0 - avg_fr) * weighted(&wl, &w)
343        };
344        let load: Expression = loads.iter().map(|&(_, p, l)| p * l).sum();
345
346        let objective_expr = match objective {
347            Objective::Load => load.clone(),
348            Objective::Network => network.clone(),
349            Objective::Latency => latency.clone(),
350        };
351        let mut problem = vars.minimise(objective_expr).using(default_solver);
352        let r_sum: Expression = r.iter().copied().sum();
353        let w_sum: Expression = w.iter().copied().sum();
354        problem = problem.with(r_sum.eq(1.0)).with(w_sum.eq(1.0));
355
356        // Per-node load at each read fraction is at most that fraction's
357        // load variable.
358        let mut x_to_r: HashMap<&T, Vec<Variable>> = HashMap::new();
359        for (q, &v) in read_quorums.iter().zip(&r) {
360            for x in q {
361                x_to_r.entry(x).or_default().push(v);
362            }
363        }
364        let mut x_to_w: HashMap<&T, Vec<Variable>> = HashMap::new();
365        for (q, &v) in write_quorums.iter().zip(&w) {
366            for x in q {
367                x_to_w.entry(x).or_default().push(v);
368            }
369        }
370        for &(fr, _, l) in &loads {
371            for node in self.x_to_node.values() {
372                let sum = |m: &HashMap<&T, Vec<Variable>>| -> Expression {
373                    m.get(node.x()).into_iter().flatten().copied().sum()
374                };
375                let node_load = (fr / node.read_capacity()) * sum(&x_to_r)
376                    + ((1.0 - fr) / node.write_capacity()) * sum(&x_to_w);
377                problem = problem.with(node_load.leq(l));
378            }
379        }
380
381        if let Some(limit) = limits.load {
382            problem = problem.with(load.leq(limit));
383        }
384        if let Some(limit) = limits.network {
385            problem = problem.with(network.leq(limit));
386        }
387        if let Some(limit) = limits.latency {
388            problem = problem.with(latency.leq(limit.as_secs_f64()));
389        }
390
391        let solution = problem.solve()?;
392        let pick = |qs: &[HashSet<T>], vs: &[Variable]| {
393            qs.iter()
394                .zip(vs)
395                .map(|(q, &v)| (to_quorum(q), solution.value(v)))
396                .filter(|&(_, p)| p > 1e-10)
397                .collect::<BTreeMap<_, _>>()
398        };
399        let sigma_r = pick(read_quorums, &r);
400        let sigma_w = pick(write_quorums, &w);
401        // LP solutions satisfy sum == 1 only up to solver tolerance.
402        let renorm = |m: BTreeMap<Quorum<T>, f64>| {
403            let total: f64 = m.values().sum();
404            m.into_iter().map(|(q, p)| (q, p / total)).collect()
405        };
406        Ok(Strategy::new(self, renorm(sigma_r), renorm(sigma_w)))
407    }
408}
409
410/// Validate, merge, and normalize user-supplied quorum weights.
411fn normalize<T: Element>(
412    name: &str,
413    sigma: BTreeMap<Quorum<T>, f64>,
414    is_quorum: impl Fn(&HashSet<T>) -> bool,
415) -> Result<BTreeMap<Quorum<T>, f64>> {
416    let invalid =
417        |msg: &str| Error::InvalidQuorumSystem(format!("{name} {msg}"));
418    let mut merged: BTreeMap<Quorum<T>, f64> = BTreeMap::new();
419    for (q, weight) in sigma {
420        if !weight.is_finite() || weight < 0.0 {
421            return Err(invalid("has negative or non-finite weights"));
422        }
423        let set = to_set(&q);
424        if !is_quorum(&set) {
425            return Err(invalid(&format!(
426                "has non-quorum {:?}",
427                to_quorum(&set)
428            )));
429        }
430        *merged.entry(to_quorum(&set)).or_default() += weight;
431    }
432    let total: f64 = merged.values().sum();
433    if !(total.is_finite() && total > 0.0) {
434        return Err(invalid("must have a positive total weight"));
435    }
436    Ok(merged
437        .into_iter()
438        .filter(|&(_, w)| w > 0.0)
439        .map(|(q, w)| (q, w / total))
440        .collect())
441}
442
443/// All minimal sets over `xs` that remain quorums of `expr` after any `f`
444/// of their elements fail.
445fn f_resilient_quorums<T: Element>(
446    f: usize,
447    xs: &[T],
448    expr: &Expr<T>,
449) -> Vec<HashSet<T>> {
450    let mut results = Vec::new();
451    f_resilient_helper(f, xs, expr, &mut HashSet::new(), 0, &mut results);
452    minimize(results)
453}
454
455fn f_resilient_helper<T: Element>(
456    f: usize,
457    xs: &[T],
458    expr: &Expr<T>,
459    current: &mut HashSet<T>,
460    start: usize,
461    results: &mut Vec<HashSet<T>>,
462) {
463    let resilient =
464        current.iter().combinations(f.min(current.len())).all(|failed| {
465            let alive: HashSet<T> = current
466                .iter()
467                .filter(|x| !failed.contains(x))
468                .cloned()
469                .collect();
470            expr.is_quorum(&alive)
471        });
472    if resilient {
473        results.push(current.clone());
474        return;
475    }
476    for j in start..xs.len() {
477        current.insert(xs[j].clone());
478        f_resilient_helper(f, xs, expr, current, j + 1, results);
479        current.remove(&xs[j]);
480    }
481}
482
483fn quorum_latency<T: Element>(
484    x_to_node: &HashMap<T, Node<T>>,
485    quorum: &HashSet<T>,
486    expr: &Expr<T>,
487) -> Duration {
488    let mut nodes: Vec<&Node<T>> =
489        quorum.iter().filter_map(|x| x_to_node.get(x)).collect();
490    nodes.sort_by_key(|n| n.latency());
491    let mut seen = HashSet::new();
492    for node in nodes {
493        seen.insert(node.x().clone());
494        if expr.is_quorum(&seen) {
495            return node.latency();
496        }
497    }
498    // Unreachable for quorums of `expr`; strategies only contain quorums.
499    Duration::ZERO
500}
501
502impl<T: Element> std::fmt::Display for QuorumSystem<T> {
503    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
504        write!(f, "QuorumSystem(reads={}, writes={})", self.reads, self.writes)
505    }
506}
507
508/// A probability distribution over read quorums and over write quorums.
509///
510/// Built by [`QuorumSystem::strategy`], [`QuorumSystem::uniform_strategy`],
511/// or [`QuorumSystem::make_strategy`]; probabilities always sum to 1.
512#[derive(Debug, Clone)]
513pub struct Strategy<T: Element> {
514    sigma_r: BTreeMap<Quorum<T>, f64>,
515    sigma_w: BTreeMap<Quorum<T>, f64>,
516    /// Per-node: (node, P(node in read quorum), P(node in write quorum)).
517    node_probs: Vec<(Node<T>, f64, f64)>,
518    qs: QuorumSystem<T>,
519}
520
521impl<T: Element> Strategy<T> {
522    fn new(
523        qs: &QuorumSystem<T>,
524        sigma_r: BTreeMap<Quorum<T>, f64>,
525        sigma_w: BTreeMap<Quorum<T>, f64>,
526    ) -> Self {
527        let mut x_read: HashMap<T, f64> = HashMap::new();
528        for (q, &p) in &sigma_r {
529            for x in q {
530                *x_read.entry_ref(x).or_default() += p;
531            }
532        }
533        let mut x_write: HashMap<T, f64> = HashMap::new();
534        for (q, &p) in &sigma_w {
535            for x in q {
536                *x_write.entry_ref(x).or_default() += p;
537            }
538        }
539        let mut node_probs: Vec<(Node<T>, f64, f64)> = qs
540            .x_to_node
541            .values()
542            .map(|n| {
543                let rp = x_read.get(n.x()).copied().unwrap_or(0.0);
544                let wp = x_write.get(n.x()).copied().unwrap_or(0.0);
545                (n.clone(), rp, wp)
546            })
547            .collect();
548        node_probs.sort_by(|a, b| a.0.cmp(&b.0));
549        Self { sigma_r, sigma_w, node_probs, qs: qs.clone() }
550    }
551
552    /// The quorum system this strategy is for.
553    #[must_use]
554    pub fn quorum_system(&self) -> &QuorumSystem<T> {
555        &self.qs
556    }
557
558    /// Read quorum probabilities (they sum to 1).
559    #[must_use]
560    pub fn sigma_r(&self) -> &BTreeMap<Quorum<T>, f64> {
561        &self.sigma_r
562    }
563
564    /// Write quorum probabilities (they sum to 1).
565    #[must_use]
566    pub fn sigma_w(&self) -> &BTreeMap<Quorum<T>, f64> {
567        &self.sigma_w
568    }
569
570    /// Sample a read quorum according to the strategy.
571    #[must_use]
572    pub fn get_read_quorum(&self) -> HashSet<T> {
573        sample_quorum(&self.sigma_r)
574    }
575
576    /// Sample a write quorum according to the strategy.
577    #[must_use]
578    pub fn get_write_quorum(&self) -> HashSet<T> {
579        sample_quorum(&self.sigma_w)
580    }
581
582    /// Expected load of the busiest node, in fractions of its capacity
583    /// per operation. Capacity is `1 / load`.
584    ///
585    /// # Errors
586    ///
587    /// Returns an error if the distribution is invalid.
588    pub fn load(
589        &self,
590        read_fraction: Option<&Distribution>,
591        write_fraction: Option<&Distribution>,
592    ) -> Result<f64> {
593        let d = distribution::canonicalize_rw(read_fraction, write_fraction)?;
594        Ok(d.iter().map(|(fr, &p)| p * self.load_at(fr.0)).sum())
595    }
596
597    /// Expected capacity: operations per unit time the system can serve
598    /// before its busiest node saturates (`1 / load` at each fraction).
599    ///
600    /// # Errors
601    ///
602    /// Returns an error if the distribution is invalid.
603    pub fn capacity(
604        &self,
605        read_fraction: Option<&Distribution>,
606        write_fraction: Option<&Distribution>,
607    ) -> Result<f64> {
608        let d = distribution::canonicalize_rw(read_fraction, write_fraction)?;
609        Ok(d.iter().map(|(fr, &p)| p / self.load_at(fr.0)).sum())
610    }
611
612    /// Expected number of nodes contacted per operation.
613    ///
614    /// # Errors
615    ///
616    /// Returns an error if the distribution is invalid.
617    pub fn network_load(
618        &self,
619        read_fraction: Option<&Distribution>,
620        write_fraction: Option<&Distribution>,
621    ) -> Result<f64> {
622        let fr = avg_read_fraction(read_fraction, write_fraction)?;
623        let expected_size = |sigma: &BTreeMap<Quorum<T>, f64>| -> f64 {
624            sigma.iter().map(|(q, &p)| p * len_f64(q.len())).sum()
625        };
626        Ok(fr * expected_size(&self.sigma_r)
627            + (1.0 - fr) * expected_size(&self.sigma_w))
628    }
629
630    /// Expected operation latency: for each quorum, the time until its
631    /// fastest nodes form a quorum, weighted by the strategy.
632    ///
633    /// # Errors
634    ///
635    /// Returns an error if the distribution is invalid.
636    pub fn latency(
637        &self,
638        read_fraction: Option<&Distribution>,
639        write_fraction: Option<&Distribution>,
640    ) -> Result<Duration> {
641        let fr = avg_read_fraction(read_fraction, write_fraction)?;
642        let expected = |sigma: &BTreeMap<Quorum<T>, f64>, e: &Expr<T>| -> f64 {
643            sigma
644                .iter()
645                .map(|(q, &p)| {
646                    p * self.qs.quorum_latency(&to_set(q), e).as_secs_f64()
647                })
648                .sum()
649        };
650        let secs = fr * expected(&self.sigma_r, &self.qs.reads)
651            + (1.0 - fr) * expected(&self.sigma_w, &self.qs.writes);
652        Ok(Duration::from_secs_f64(secs))
653    }
654
655    fn probs(&self, node: &Node<T>) -> (f64, f64) {
656        self.node_probs
657            .binary_search_by(|(n, _, _)| n.cmp(node))
658            .map_or((0.0, 0.0), |i| {
659                (self.node_probs[i].1, self.node_probs[i].2)
660            })
661    }
662
663    /// Expected load on `node`.
664    ///
665    /// Capacities come from the quorum system's copy of the node, so
666    /// passing `Node::new(id)` works. Nodes not in the system have load 0.
667    ///
668    /// # Errors
669    ///
670    /// Returns an error if the distribution is invalid.
671    pub fn node_load(
672        &self,
673        node: &Node<T>,
674        read_fraction: Option<&Distribution>,
675        write_fraction: Option<&Distribution>,
676    ) -> Result<f64> {
677        let d = distribution::canonicalize_rw(read_fraction, write_fraction)?;
678        let node = self.qs.x_to_node.get(node.x()).unwrap_or(node);
679        Ok(d.iter().map(|(fr, &p)| p * self.node_load_at(node, fr.0)).sum())
680    }
681
682    /// Utilization of `node`: its load relative to the busiest node.
683    ///
684    /// # Errors
685    ///
686    /// Returns an error if the distribution is invalid.
687    pub fn node_utilization(
688        &self,
689        node: &Node<T>,
690        read_fraction: Option<&Distribution>,
691        write_fraction: Option<&Distribution>,
692    ) -> Result<f64> {
693        let d = distribution::canonicalize_rw(read_fraction, write_fraction)?;
694        let node = self.qs.x_to_node.get(node.x()).unwrap_or(node);
695        Ok(d.iter()
696            .map(|(fr, &p)| {
697                p * self.node_load_at(node, fr.0) / self.load_at(fr.0)
698            })
699            .sum())
700    }
701
702    /// Requests per unit time handled by `node` when the system runs at
703    /// full capacity.
704    ///
705    /// # Errors
706    ///
707    /// Returns an error if the distribution is invalid.
708    pub fn node_throughput(
709        &self,
710        node: &Node<T>,
711        read_fraction: Option<&Distribution>,
712        write_fraction: Option<&Distribution>,
713    ) -> Result<f64> {
714        let d = distribution::canonicalize_rw(read_fraction, write_fraction)?;
715        let (rp, wp) = self.probs(node);
716        Ok(d.iter()
717            .map(|(fr, &p)| {
718                let cap = 1.0 / self.load_at(fr.0);
719                p * cap * (fr.0 * rp + (1.0 - fr.0) * wp)
720            })
721            .sum())
722    }
723
724    fn load_at(&self, fr: f64) -> f64 {
725        self.node_probs
726            .iter()
727            .map(|(n, rp, wp)| node_load(n, *rp, *wp, fr))
728            .fold(0.0_f64, f64::max)
729    }
730
731    fn node_load_at(&self, node: &Node<T>, fr: f64) -> f64 {
732        let (rp, wp) = self.probs(node);
733        node_load(node, rp, wp, fr)
734    }
735}
736
737fn node_load<T: Element>(node: &Node<T>, rp: f64, wp: f64, fr: f64) -> f64 {
738    fr * rp / node.read_capacity() + (1.0 - fr) * wp / node.write_capacity()
739}
740
741fn avg_read_fraction(
742    read_fraction: Option<&Distribution>,
743    write_fraction: Option<&Distribution>,
744) -> Result<f64> {
745    let d = distribution::canonicalize_rw(read_fraction, write_fraction)?;
746    Ok(d.iter().map(|(k, &p)| k.0 * p).sum())
747}
748
749impl<T: Element> std::fmt::Display for Strategy<T> {
750    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
751        let show = |sigma: &BTreeMap<Quorum<T>, f64>| -> String {
752            sigma
753                .iter()
754                .map(|(q, p)| format!("{{{}}}: {p:.4}", q.iter().join(", ")))
755                .join(", ")
756        };
757        write!(
758            f,
759            "Strategy(reads=[{}], writes=[{}])",
760            show(&self.sigma_r),
761            show(&self.sigma_w)
762        )
763    }
764}
765
766/// Sample a quorum. Strategies are never empty and weights are positive,
767/// so this always returns a quorum.
768fn sample_quorum<T: Element>(sigma: &BTreeMap<Quorum<T>, f64>) -> HashSet<T> {
769    let entries: Vec<(&Quorum<T>, &f64)> = sigma.iter().collect();
770    entries
771        .choose_weighted(&mut rand::rng(), |(_, &w)| w)
772        .map(|(q, _)| to_set(q))
773        .unwrap_or_default()
774}
775
776#[cfg(test)]
777#[expect(clippy::expect_used, clippy::unwrap_used)]
778mod tests {
779    use super::*;
780
781    fn n(x: &str) -> Expr<String> {
782        Expr::Node(Node::new(x.to_string()))
783    }
784
785    fn node(x: &str) -> Node<String> {
786        Node::new(x.to_string())
787    }
788
789    fn node_with(x: &str, rc: f64, wc: f64, lat: u64) -> Node<String> {
790        Node::new(x.to_string())
791            .with_read_write_capacity(rc, wc)
792            .expect("valid capacity")
793            .with_latency(Duration::from_secs(lat))
794    }
795
796    fn set(items: &[&str]) -> HashSet<String> {
797        items.iter().map(|s| (*s).to_string()).collect()
798    }
799
800    fn quorum(items: &[&str]) -> Quorum<String> {
801        let mut v: Vec<String> =
802            items.iter().map(|s| (*s).to_string()).collect();
803        v.sort();
804        v
805    }
806
807    fn quorum_set(
808        qs: impl Iterator<Item = HashSet<String>>,
809    ) -> HashSet<Vec<String>> {
810        qs.map(|q| {
811            let mut v: Vec<String> = q.into_iter().collect();
812            v.sort();
813            v
814        })
815        .collect()
816    }
817
818    // -- Constructor tests --
819
820    #[test]
821    fn from_reads_generates_dual_writes() {
822        let qs = QuorumSystem::from_reads(n("a") + n("b"));
823        let r = quorum_set(qs.read_quorums());
824        let w = quorum_set(qs.write_quorums());
825        assert!(r.contains(&vec!["a".to_string()]));
826        assert!(r.contains(&vec!["b".to_string()]));
827        assert!(w.contains(&vec!["a".to_string(), "b".to_string()]));
828    }
829
830    #[test]
831    fn from_writes_generates_dual_reads() {
832        let qs = QuorumSystem::from_writes(n("a") + n("b"));
833        let r = quorum_set(qs.read_quorums());
834        let w = quorum_set(qs.write_quorums());
835        assert!(w.contains(&vec!["a".to_string()]));
836        assert!(w.contains(&vec!["b".to_string()]));
837        assert!(r.contains(&vec!["a".to_string(), "b".to_string()]));
838    }
839
840    #[test]
841    fn new_with_valid_overlap() {
842        let qs = QuorumSystem::new(n("a") + n("b"), n("a") * n("b") * n("c"));
843        assert!(qs.is_ok());
844    }
845
846    #[test]
847    fn new_with_no_overlap_fails() {
848        let qs = QuorumSystem::new(n("a") + n("b"), n("a"));
849        assert_eq!(qs.unwrap_err(), Error::NonOverlappingQuorums);
850    }
851
852    // -- Basic methods --
853
854    #[test]
855    fn elements_returns_all() {
856        let qs = QuorumSystem::from_reads(n("a") + n("b"));
857        let elems = qs.elements();
858        assert!(elems.contains("a"));
859        assert!(elems.contains("b"));
860    }
861
862    #[test]
863    fn nodes_returns_all() {
864        let qs = QuorumSystem::from_reads(n("a") + n("b"));
865        let nodes = qs.nodes();
866        assert_eq!(nodes.len(), 2);
867    }
868
869    #[test]
870    fn is_read_quorum_and_is_write_quorum() {
871        let qs = QuorumSystem::from_reads(n("a") + n("b"));
872        assert!(qs.is_read_quorum(&set(&["a"])));
873        assert!(qs.is_read_quorum(&set(&["b"])));
874        assert!(!qs.is_read_quorum(&set(&["c"])));
875        assert!(qs.is_write_quorum(&set(&["a", "b"])));
876        assert!(!qs.is_write_quorum(&set(&["a"])));
877    }
878
879    // -- Resilience --
880
881    #[test]
882    fn resilience_simple() {
883        let qs = QuorumSystem::from_reads(n("a") + n("b"));
884        assert_eq!(qs.resilience(), 0);
885        assert_eq!(qs.read_resilience(), 1);
886        assert_eq!(qs.write_resilience(), 0);
887    }
888
889    #[test]
890    fn dup_free_check() {
891        let qs = QuorumSystem::from_reads(n("a") + n("b"));
892        assert!(qs.dup_free());
893    }
894
895    // -- uniform_strategy --
896
897    #[test]
898    fn uniform_strategy_single_node() {
899        let qs = QuorumSystem::from_reads(n("a"));
900        let sigma = qs.uniform_strategy(0).expect("ok");
901        assert_eq!(sigma.sigma_r().len(), 1);
902        assert!((sigma.sigma_r()[&quorum(&["a"])] - 1.0).abs() < f64::EPSILON);
903        assert_eq!(sigma.sigma_w().len(), 1);
904        assert!((sigma.sigma_w()[&quorum(&["a"])] - 1.0).abs() < f64::EPSILON);
905    }
906
907    #[test]
908    fn uniform_strategy_two_nodes() {
909        let qs = QuorumSystem::from_reads(n("a") + n("b"));
910        let sigma = qs.uniform_strategy(0).expect("ok");
911        assert_eq!(sigma.sigma_r().len(), 2);
912        assert!((sigma.sigma_r()[&quorum(&["a"])] - 0.5).abs() < 1e-10);
913        assert!((sigma.sigma_r()[&quorum(&["b"])] - 0.5).abs() < 1e-10);
914        assert_eq!(sigma.sigma_w().len(), 1);
915        assert!(
916            (sigma.sigma_w()[&quorum(&["a", "b"])] - 1.0).abs() < f64::EPSILON
917        );
918    }
919
920    #[test]
921    fn uniform_strategy_grid() {
922        let qs = QuorumSystem::from_reads(n("a") * n("b") + n("c") * n("d"));
923        let sigma = qs.uniform_strategy(0).expect("ok");
924        assert_eq!(sigma.sigma_r().len(), 2);
925        assert!((sigma.sigma_r()[&quorum(&["a", "b"])] - 0.5).abs() < 1e-10);
926        assert!((sigma.sigma_r()[&quorum(&["c", "d"])] - 0.5).abs() < 1e-10);
927        assert_eq!(sigma.sigma_w().len(), 4);
928        for wq in &[
929            quorum(&["a", "c"]),
930            quorum(&["a", "d"]),
931            quorum(&["b", "c"]),
932            quorum(&["b", "d"]),
933        ] {
934            assert!((sigma.sigma_w()[wq] - 0.25).abs() < 1e-10);
935        }
936    }
937
938    #[test]
939    fn uniform_strategy_minimizes() {
940        // a + a*b should reduce to just {a}
941        let qs = QuorumSystem::from_reads(n("a") + n("a") * n("b"));
942        let sigma = qs.uniform_strategy(0).expect("ok");
943        assert_eq!(sigma.sigma_r().len(), 1);
944        assert!((sigma.sigma_r()[&quorum(&["a"])] - 1.0).abs() < f64::EPSILON);
945    }
946
947    // -- make_strategy --
948
949    #[test]
950    fn make_strategy_normalizes() {
951        let qs = QuorumSystem::from_reads(n("a") * n("b") + n("c") * n("d"));
952        let mut sigma_r = BTreeMap::new();
953        sigma_r.insert(quorum(&["a", "b"]), 25.0);
954        sigma_r.insert(quorum(&["c", "d"]), 75.0);
955        let mut sigma_w = BTreeMap::new();
956        sigma_w.insert(quorum(&["a", "c"]), 1.0);
957        sigma_w.insert(quorum(&["a", "d"]), 1.0);
958        sigma_w.insert(quorum(&["b", "c"]), 1.0);
959        sigma_w.insert(quorum(&["b", "d"]), 1.0);
960
961        let sigma = qs.make_strategy(sigma_r, sigma_w).expect("ok");
962        assert!((sigma.sigma_r()[&quorum(&["a", "b"])] - 0.25).abs() < 1e-10);
963        assert!((sigma.sigma_r()[&quorum(&["c", "d"])] - 0.75).abs() < 1e-10);
964        for wq in &[
965            quorum(&["a", "c"]),
966            quorum(&["a", "d"]),
967            quorum(&["b", "c"]),
968            quorum(&["b", "d"]),
969        ] {
970            assert!((sigma.sigma_w()[wq] - 0.25).abs() < 1e-10);
971        }
972    }
973
974    #[test]
975    fn make_strategy_negative_weights_fail() {
976        let qs = QuorumSystem::from_reads(n("a") * n("b") + n("c") * n("d"));
977        let mut sigma_r = BTreeMap::new();
978        sigma_r.insert(quorum(&["a", "b"]), -1.0);
979        sigma_r.insert(quorum(&["c", "d"]), 1.0);
980        let mut sigma_w = BTreeMap::new();
981        sigma_w.insert(quorum(&["a", "c"]), 1.0);
982
983        assert!(qs.make_strategy(sigma_r, sigma_w).is_err());
984    }
985
986    #[test]
987    fn make_strategy_non_quorum_fails() {
988        let qs = QuorumSystem::from_reads(n("a") * n("b") + n("c") * n("d"));
989        let mut sigma_r = BTreeMap::new();
990        sigma_r.insert(quorum(&["a"]), 1.0); // not a read quorum
991        sigma_r.insert(quorum(&["c", "d"]), 1.0);
992        let mut sigma_w = BTreeMap::new();
993        sigma_w.insert(quorum(&["a", "c"]), 1.0);
994
995        assert!(qs.make_strategy(sigma_r, sigma_w).is_err());
996    }
997
998    // -- Strategy load/capacity tests --
999
1000    #[test]
1001    fn strategy_load_and_capacity() {
1002        let a = node_with("a", 50.0, 10.0, 1);
1003        let b = node_with("b", 60.0, 20.0, 2);
1004        let c = node_with("c", 70.0, 30.0, 3);
1005        let d = node_with("d", 80.0, 40.0, 4);
1006
1007        let reads = Expr::Node(a.clone()) * Expr::Node(b.clone())
1008            + Expr::Node(c.clone()) * Expr::Node(d.clone());
1009        let qs = QuorumSystem::from_reads(reads);
1010
1011        let mut sigma_r = BTreeMap::new();
1012        sigma_r.insert(quorum(&["a", "b"]), 0.75);
1013        sigma_r.insert(quorum(&["c", "d"]), 0.25);
1014
1015        let mut sigma_w = BTreeMap::new();
1016        sigma_w.insert(quorum(&["a", "c"]), 0.1);
1017        sigma_w.insert(quorum(&["a", "d"]), 0.2);
1018        sigma_w.insert(quorum(&["b", "c"]), 0.3);
1019        sigma_w.insert(quorum(&["b", "d"]), 0.4);
1020
1021        let sigma = qs.make_strategy(sigma_r, sigma_w).expect("ok");
1022
1023        let fr08 = Distribution::fixed(0.8).expect("ok");
1024
1025        // node loads at fr=0.8
1026        let la = 0.8 / 50.0 * 0.75 + 0.2 / 10.0 * (0.1 + 0.2);
1027        let lb = 0.8 / 60.0 * 0.75 + 0.2 / 20.0 * (0.3 + 0.4);
1028        let lc = 0.8 / 70.0 * 0.25 + 0.2 / 30.0 * (0.1 + 0.3);
1029        let ld = 0.8 / 80.0 * 0.25 + 0.2 / 40.0 * (0.2 + 0.4);
1030
1031        let load_08 = [la, lb, lc, ld].iter().copied().fold(0.0_f64, f64::max);
1032
1033        let got_load = sigma.load(Some(&fr08), None).expect("ok");
1034        assert!(
1035            (got_load - load_08).abs() < 1e-10,
1036            "load mismatch: {got_load} vs {load_08}"
1037        );
1038
1039        let got_cap = sigma.capacity(Some(&fr08), None).expect("ok");
1040        let expected_cap = 1.0 / load_08;
1041        assert!(
1042            (got_cap - expected_cap).abs() < 1e-10,
1043            "capacity mismatch: {got_cap} vs {expected_cap}"
1044        );
1045
1046        // Check node_load for node a
1047        let got_node_load = sigma.node_load(&a, Some(&fr08), None).expect("ok");
1048        assert!(
1049            (got_node_load - la).abs() < 1e-10,
1050            "node load mismatch: {got_node_load} vs {la}"
1051        );
1052    }
1053
1054    #[test]
1055    fn strategy_network_load() {
1056        let a = node("a");
1057        let b = node("b");
1058        let c = node("c");
1059        let d = node("d");
1060        let e_node = node("e");
1061
1062        let reads = Expr::Node(a) * Expr::Node(b)
1063            + Expr::Node(c) * Expr::Node(d) * Expr::Node(e_node);
1064        let qs = QuorumSystem::from_reads(reads);
1065
1066        let mut sigma_r = BTreeMap::new();
1067        sigma_r.insert(quorum(&["a", "b"]), 75.0);
1068        sigma_r.insert(quorum(&["c", "d", "e"]), 25.0);
1069
1070        let mut sigma_w = BTreeMap::new();
1071        sigma_w.insert(quorum(&["a", "c"]), 5.0);
1072        sigma_w.insert(quorum(&["a", "d"]), 10.0);
1073        sigma_w.insert(quorum(&["a", "e"]), 15.0);
1074        sigma_w.insert(quorum(&["b", "c"]), 20.0);
1075        sigma_w.insert(quorum(&["b", "d"]), 25.0);
1076        sigma_w.insert(quorum(&["b", "e"]), 25.0);
1077
1078        let sigma = qs.make_strategy(sigma_r, sigma_w).expect("ok");
1079        let fr08 = Distribution::fixed(0.8).expect("ok");
1080
1081        let expected = 0.8 * 0.75 * 2.0 + 0.8 * 0.25 * 3.0 + 0.2 * 2.0;
1082        let got = sigma.network_load(Some(&fr08), None).expect("ok");
1083        assert!(
1084            (got - expected).abs() < 1e-10,
1085            "network load mismatch: {got} vs {expected}"
1086        );
1087    }
1088
1089    #[test]
1090    #[expect(clippy::many_single_char_names)]
1091    fn strategy_latency() {
1092        let a = node_with("a", 1.0, 1.0, 1);
1093        let b = node_with("b", 1.0, 1.0, 2);
1094        let c = node_with("c", 1.0, 1.0, 3);
1095        let d = node_with("d", 1.0, 1.0, 4);
1096        let e = node_with("e", 1.0, 1.0, 5);
1097
1098        let reads = Expr::Node(a) * Expr::Node(b.clone())
1099            + Expr::Node(c.clone())
1100                * Expr::Node(d.clone())
1101                * Expr::Node(e.clone());
1102        let qs = QuorumSystem::from_reads(reads);
1103
1104        let mut sigma_r = BTreeMap::new();
1105        sigma_r.insert(quorum(&["a", "b"]), 10.0);
1106        sigma_r.insert(quorum(&["a", "b", "c"]), 20.0);
1107        sigma_r.insert(quorum(&["c", "d", "e"]), 30.0);
1108        sigma_r.insert(quorum(&["c", "d", "e", "a"]), 40.0);
1109
1110        let mut sigma_w = BTreeMap::new();
1111        sigma_w.insert(quorum(&["a", "c"]), 5.0);
1112        sigma_w.insert(quorum(&["a", "d"]), 10.0);
1113        sigma_w.insert(quorum(&["a", "e"]), 15.0);
1114        sigma_w.insert(quorum(&["b", "c"]), 20.0);
1115        sigma_w.insert(quorum(&["b", "d"]), 25.0);
1116        sigma_w.insert(quorum(&["b", "e"]), 25.0);
1117
1118        let sigma = qs.make_strategy(sigma_r, sigma_w).expect("ok");
1119        let fr08 = Distribution::fixed(0.8).expect("ok");
1120
1121        let expected_secs = 0.8 * 0.10 * 2.0
1122            + 0.8 * 0.20 * 2.0
1123            + 0.8 * 0.30 * 5.0
1124            + 0.8 * 0.40 * 5.0
1125            + 0.2 * 0.05 * 3.0
1126            + 0.2 * 0.10 * 4.0
1127            + 0.2 * 0.15 * 5.0
1128            + 0.2 * 0.20 * 3.0
1129            + 0.2 * 0.25 * 4.0
1130            + 0.2 * 0.25 * 5.0;
1131
1132        let got = sigma.latency(Some(&fr08), None).expect("ok").as_secs_f64();
1133        assert!(
1134            (got - expected_secs).abs() < 1e-10,
1135            "latency mismatch: {got} vs {expected_secs}"
1136        );
1137    }
1138
1139    // -- minimize --
1140
1141    #[test]
1142    fn minimize_removes_supersets() {
1143        let sets = vec![
1144            set(&["a"]),
1145            set(&["a", "b"]),
1146            set(&["c"]),
1147            set(&["a", "b", "c"]),
1148        ];
1149        let result = minimize(sets);
1150        assert_eq!(result.len(), 2);
1151        assert!(result.contains(&set(&["a"])));
1152        assert!(result.contains(&set(&["c"])));
1153    }
1154
1155    // -- Display --
1156
1157    #[test]
1158    fn quorum_system_display() {
1159        let qs = QuorumSystem::from_reads(n("a") + n("b"));
1160        let s = format!("{qs}");
1161        assert!(s.contains("QuorumSystem"));
1162    }
1163
1164    // -- Parity with the Python reference (tests/test_quorum_system.py) --
1165
1166    fn s(secs: u64) -> Duration {
1167        Duration::from_secs(secs)
1168    }
1169
1170    fn fixed(fr: f64) -> Distribution {
1171        Distribution::fixed(fr).expect("valid")
1172    }
1173
1174    fn opt(
1175        qs: &QuorumSystem<String>,
1176        objective: Objective,
1177        fr: f64,
1178        limits: StrategyLimits,
1179        f: usize,
1180    ) -> Result<Strategy<String>> {
1181        qs.strategy(objective, Some(&fixed(fr)), None, &limits, f)
1182    }
1183
1184    fn close(a: f64, b: f64) -> bool {
1185        (a - b).abs() < 1e-6
1186    }
1187
1188    fn python_grid() -> QuorumSystem<String> {
1189        let a = node_with("a", 2.0, 1.0, 1);
1190        let b = node_with("b", 2.0, 1.0, 2);
1191        let c = node_with("c", 2.0, 1.0, 3);
1192        let d = node_with("d", 2.0, 1.0, 4);
1193        QuorumSystem::from_reads(
1194            Expr::Node(a) * Expr::Node(b) + Expr::Node(c) * Expr::Node(d),
1195        )
1196    }
1197
1198    #[test]
1199    fn python_parity_load_optimized() {
1200        let qs = python_grid();
1201        let none = StrategyLimits::default();
1202        let net2 = StrategyLimits { network: Some(2.0), ..none };
1203        let lat4 = StrategyLimits { latency: Some(s(4)), ..none };
1204        for limits in [none, net2, lat4] {
1205            for (fr, load) in [(1.0, 0.25), (0.0, 0.5)] {
1206                let sigma = opt(&qs, Objective::Load, fr, limits, 0).unwrap();
1207                let d = fixed(fr);
1208                assert!(close(sigma.load(Some(&d), None).unwrap(), load));
1209                let cap = sigma.capacity(Some(&d), None).unwrap();
1210                assert!(close(cap, 1.0 / load));
1211            }
1212        }
1213    }
1214
1215    #[test]
1216    fn python_parity_network_and_latency_optimized() {
1217        let qs = python_grid();
1218        let none = StrategyLimits::default();
1219        for (fr, limits) in [
1220            (1.0, none),
1221            (0.0, none),
1222            (1.0, StrategyLimits { load: Some(0.25), ..none }),
1223            (0.0, StrategyLimits { load: Some(0.5), ..none }),
1224            (1.0, StrategyLimits { latency: Some(s(2)), ..none }),
1225            (0.0, StrategyLimits { latency: Some(s(3)), ..none }),
1226        ] {
1227            let sigma = opt(&qs, Objective::Network, fr, limits, 0).unwrap();
1228            let net = sigma.network_load(Some(&fixed(fr)), None).unwrap();
1229            assert!(close(net, 2.0), "fr={fr} {limits:?}: {net}");
1230        }
1231        for (fr, lat, limits) in [
1232            (1.0, 2, none),
1233            (0.0, 3, none),
1234            (1.0, 2, StrategyLimits { load: Some(1.0), ..none }),
1235            (0.0, 3, StrategyLimits { load: Some(1.0), ..none }),
1236            (1.0, 2, StrategyLimits { network: Some(2.0), ..none }),
1237            (0.0, 3, StrategyLimits { network: Some(2.0), ..none }),
1238        ] {
1239            let sigma = opt(&qs, Objective::Latency, fr, limits, 0).unwrap();
1240            let got = sigma.latency(Some(&fixed(fr)), None).unwrap();
1241            assert!(close(got.as_secs_f64(), s(lat).as_secs_f64()));
1242        }
1243    }
1244
1245    #[test]
1246    fn python_parity_one_resilient() {
1247        let qs = python_grid();
1248        let none = StrategyLimits::default();
1249        for (fr, load) in [(1.0, 0.5), (0.0, 1.0)] {
1250            let sigma = opt(&qs, Objective::Load, fr, none, 1).unwrap();
1251            assert!(close(sigma.load(Some(&fixed(fr)), None).unwrap(), load));
1252        }
1253        for fr in [1.0, 0.0] {
1254            let sigma = opt(&qs, Objective::Network, fr, none, 1).unwrap();
1255            let net = sigma.network_load(Some(&fixed(fr)), None).unwrap();
1256            assert!(close(net, 4.0));
1257        }
1258        for (fr, lat) in [(1.0, 2.0), (0.0, 3.0)] {
1259            let sigma = opt(&qs, Objective::Latency, fr, none, 1).unwrap();
1260            let got = sigma.latency(Some(&fixed(fr)), None).unwrap();
1261            assert!(close(got.as_secs_f64(), lat));
1262        }
1263    }
1264
1265    #[test]
1266    fn python_parity_illegal_and_unsatisfiable() {
1267        let qs = python_grid();
1268        let none = StrategyLimits::default();
1269        for (objective, limits) in [
1270            (Objective::Load, StrategyLimits { load: Some(1.0), ..none }),
1271            (Objective::Network, StrategyLimits { network: Some(2.0), ..none }),
1272            (
1273                Objective::Latency,
1274                StrategyLimits { latency: Some(s(5)), ..none },
1275            ),
1276        ] {
1277            assert!(matches!(
1278                opt(&qs, objective, 0.1, limits, 0),
1279                Err(Error::InvalidQuorumSystem(_))
1280            ));
1281        }
1282        for (objective, fr, limits) in [
1283            (
1284                Objective::Load,
1285                0.0,
1286                StrategyLimits { network: Some(1.5), ..none },
1287            ),
1288            (
1289                Objective::Load,
1290                0.0,
1291                StrategyLimits { latency: Some(s(1)), ..none },
1292            ),
1293            (
1294                Objective::Network,
1295                1.0,
1296                StrategyLimits {
1297                    load: Some(0.25),
1298                    latency: Some(s(2)),
1299                    ..none
1300                },
1301            ),
1302        ] {
1303            assert_eq!(
1304                opt(&qs, objective, fr, limits, 0).unwrap_err(),
1305                Error::NoStrategyFound
1306            );
1307        }
1308    }
1309
1310    #[test]
1311    fn python_parity_uniform_one_resilient() {
1312        // From test_uniform_strategy: reads = a*b + c*d + e*f, f = 1.
1313        let qs = QuorumSystem::from_reads(
1314            n("a") * n("b") + n("c") * n("d") + n("e") * n("f"),
1315        );
1316        let sigma = qs.uniform_strategy(1).unwrap();
1317        let want_r = [
1318            quorum(&["a", "b", "c", "d"]),
1319            quorum(&["a", "b", "e", "f"]),
1320            quorum(&["c", "d", "e", "f"]),
1321        ];
1322        assert_eq!(sigma.sigma_r().len(), want_r.len());
1323        for q in &want_r {
1324            assert!(close(sigma.sigma_r()[q], 1.0 / 3.0));
1325        }
1326        // Same as Python: the only 1-resilient write quorum is all nodes.
1327        assert_eq!(sigma.sigma_w().len(), 1);
1328        assert!(close(
1329            sigma.sigma_w()[&quorum(&["a", "b", "c", "d", "e", "f"])],
1330            1.0
1331        ));
1332    }
1333
1334    // -- Regression tests for 2.0 fixes --
1335
1336    #[test]
1337    fn f_resilient_impossible_is_no_strategy() {
1338        let qs = QuorumSystem::from_reads(n("a") * n("b"));
1339        assert_eq!(qs.uniform_strategy(1).unwrap_err(), Error::NoStrategyFound);
1340        assert_eq!(
1341            opt(&qs, Objective::Load, 0.5, StrategyLimits::default(), 1)
1342                .unwrap_err(),
1343            Error::NoStrategyFound
1344        );
1345    }
1346
1347    #[test]
1348    fn make_strategy_merges_unsorted_and_duplicate_keys() {
1349        let qs = QuorumSystem::from_reads(n("a") + n("b"));
1350        let mut r = BTreeMap::new();
1351        r.insert(vec!["a".to_string(), "a".to_string()], 1.0);
1352        r.insert(quorum(&["a"]), 1.0);
1353        r.insert(quorum(&["b"]), 2.0);
1354        r.insert(quorum(&["b", "a"]).into_iter().rev().collect(), 0.0);
1355        let mut w = BTreeMap::new();
1356        w.insert(vec!["b".to_string(), "a".to_string()], 1.0);
1357        let sigma = qs.make_strategy(r, w).unwrap();
1358        assert_eq!(sigma.sigma_r().len(), 2);
1359        assert!(close(sigma.sigma_r()[&quorum(&["a"])], 0.5));
1360        let load_a = sigma.node_load(&node("a"), Some(&fixed(1.0)), None);
1361        assert!(close(load_a.unwrap(), 0.5));
1362        assert_eq!(sigma.sigma_w().keys().next(), Some(&quorum(&["a", "b"])));
1363    }
1364
1365    #[test]
1366    fn make_strategy_rejects_bad_weights() {
1367        let qs = QuorumSystem::from_reads(n("a") + n("b"));
1368        let one = |q: &[&str], w: f64| {
1369            let mut m = BTreeMap::new();
1370            m.insert(quorum(q), w);
1371            m
1372        };
1373        let w = || one(&["a", "b"], 1.0);
1374        assert!(qs.make_strategy(BTreeMap::new(), w()).is_err());
1375        assert!(qs.make_strategy(one(&["a"], 0.0), w()).is_err());
1376        assert!(qs.make_strategy(one(&["a"], f64::NAN), w()).is_err());
1377        assert!(qs.make_strategy(one(&["a"], f64::INFINITY), w()).is_err());
1378        assert!(qs.make_strategy(one(&["a"], 1.0), one(&["a"], 1.0)).is_err());
1379        assert!(qs
1380            .make_strategy(one(&["a"], 1.0), one(&["a", "b"], -1.0))
1381            .is_err());
1382    }
1383
1384    #[test]
1385    fn node_metrics_use_system_capacities() {
1386        let a = node_with("a", 2.0, 2.0, 1);
1387        let qs = QuorumSystem::from_reads(Expr::Node(a) + n("b"));
1388        let sigma = qs.uniform_strategy(0).unwrap();
1389        let fr = fixed(1.0);
1390        // Pass a bare Node: capacity 2 must still be used for "a".
1391        let la = sigma.node_load(&node("a"), Some(&fr), None).unwrap();
1392        assert!(close(la, 0.25));
1393        let lb = sigma.node_load(&node("b"), Some(&fr), None).unwrap();
1394        assert!(close(lb, 0.5));
1395        let ua = sigma.node_utilization(&node("a"), Some(&fr), None).unwrap();
1396        assert!(close(ua, 0.5));
1397        let tb = sigma.node_throughput(&node("b"), Some(&fr), None).unwrap();
1398        assert!(close(tb, 1.0));
1399        assert!(close(
1400            sigma.node_load(&node("zz"), Some(&fr), None).unwrap(),
1401            0.0
1402        ));
1403        assert!(sigma.load(None, None).is_err());
1404        assert!(sigma.capacity(Some(&fr), Some(&fr)).is_err());
1405        assert!(sigma.network_load(None, None).is_err());
1406        assert!(sigma.latency(None, None).is_err());
1407        assert!(sigma.node_load(&node("a"), None, None).is_err());
1408        assert!(sigma.node_utilization(&node("a"), None, None).is_err());
1409        assert!(sigma.node_throughput(&node("a"), None, None).is_err());
1410    }
1411
1412    #[test]
1413    fn accessors_and_sampling() {
1414        let qs = QuorumSystem::from_reads(n("a") * n("b") + n("c"));
1415        assert_eq!(qs.reads().to_string(), "((a * b) + c)");
1416        assert_eq!(qs.writes().to_string(), "((a + b) * c)");
1417        assert_eq!(qs.node(&"a".to_string()).unwrap().x(), "a");
1418        assert!(qs.node(&"zz".to_string()).is_err());
1419        let sigma = qs.uniform_strategy(0).unwrap();
1420        assert_eq!(sigma.quorum_system().elements(), qs.elements());
1421        for _ in 0..20 {
1422            assert!(qs.is_read_quorum(&sigma.get_read_quorum()));
1423            assert!(qs.is_write_quorum(&sigma.get_write_quorum()));
1424        }
1425        let shown = sigma.to_string();
1426        assert!(shown.contains("{a, b}: 0.5000"), "{shown}");
1427    }
1428
1429    #[test]
1430    fn strategy_with_write_fraction_and_weighted() {
1431        let qs = QuorumSystem::from_reads(n("a") + n("b") + n("c"));
1432        let none = StrategyLimits::default();
1433        let w = fixed(0.25);
1434        let by_w =
1435            qs.strategy(Objective::Load, None, Some(&w), &none, 0).unwrap();
1436        let by_r = opt(&qs, Objective::Load, 0.75, none, 0).unwrap();
1437        let l1 = by_w.load(None, Some(&w)).unwrap();
1438        let l2 = by_r.load(Some(&fixed(0.75)), None).unwrap();
1439        assert!(close(l1, l2));
1440        let d = Distribution::weighted(&[(0.0, 1.0), (1.0, 1.0)]).unwrap();
1441        let sigma =
1442            qs.strategy(Objective::Load, Some(&d), None, &none, 0).unwrap();
1443        assert!(sigma.load(Some(&d), None).unwrap() > 0.0);
1444        assert!(qs.strategy(Objective::Load, None, None, &none, 0).is_err());
1445    }
1446}