pluralistic_rs/
lib.rs

1use rand::random;
2use rand::seq::SliceRandom;
3use std::collections::HashMap;
4
5#[derive(Debug, Clone)]
6pub struct Contribution {
7    pub recipient: String,
8    pub amount: f64,
9    pub sender: String,
10}
11
12trait Random {
13    fn rnd() -> Self;
14}
15
16impl Random for Contribution {
17    fn rnd() -> Self {
18        let names = ["Alice", "Bob", "Thomas", "Ben", "Jason", "Mary"];
19        let rand_names = names
20            .choose_multiple(&mut rand::thread_rng(), 2)
21            .collect::<Vec<&&str>>();
22        let amount: f64 = random();
23        Contribution {
24            sender: format!("0x{}", rand_names[0]),
25            recipient: format!("0x{}", rand_names[1]),
26            amount,
27        }
28    }
29}
30
31#[derive(Debug)]
32pub struct Matcha {
33    recipient: String,
34    matcha: f64,
35}
36
37#[derive(Default)]
38pub enum MatchingCapStrategy {
39    #[default]
40    Cap,
41    Redistribute,
42}
43
44#[derive(Default)]
45pub struct LinearQfOptions {
46    pub matching_cap_amount: Option<f64>,
47    pub matching_cap_strategy: MatchingCapStrategy,
48    pub upscale: bool,
49}
50
51pub fn calculate_linear_qf(
52    contributions: Vec<Contribution>,
53    matching_pot: f64,
54    options: LinearQfOptions,
55) -> Vec<Matcha> {
56    let mut total_match = 0f64;
57    let mut has_saturated = false;
58    let mut contributions_by_recipient: HashMap<String, HashMap<String, Contribution>> =
59        HashMap::new();
60
61    let mut distributions: Vec<Matcha> = vec![];
62
63    // pivot the contributions by recipient
64    for contribution in contributions {
65        if !contributions_by_recipient.contains_key(&contribution.recipient) {
66            contributions_by_recipient.insert(contribution.recipient.clone(), HashMap::new());
67        }
68
69        if let Some(existing_contribution_map) =
70            contributions_by_recipient.get_mut(&contribution.recipient)
71        {
72            if let Some(existing_contribution) =
73                existing_contribution_map.get_mut(&contribution.sender)
74            {
75                existing_contribution.amount += contribution.amount;
76            } else {
77                existing_contribution_map.insert(contribution.sender.clone(), contribution);
78            }
79        }
80    }
81
82    for details in contributions_by_recipient {
83        let mut sum_of_sqrt_contrib = 0f64;
84        let mut sum_of_contrib = 0f64;
85        for contribution in details.1.values() {
86            sum_of_sqrt_contrib += contribution.amount.powf(0.5);
87            sum_of_contrib += contribution.amount;
88        }
89
90        let matcha = sum_of_sqrt_contrib.powf(2f64) - sum_of_contrib;
91
92        distributions.push(Matcha {
93            recipient: details.0,
94            matcha,
95        });
96
97        total_match += matcha;
98    }
99
100    if total_match > matching_pot {
101        has_saturated = true;
102    }
103
104    if has_saturated {
105        for distribution in &mut distributions {
106            distribution.matcha = (distribution.matcha * matching_pot) / total_match;
107        }
108    }
109
110    if options.upscale && total_match < matching_pot {
111        let upscale_factor = matching_pot / total_match;
112        for distribution in &mut distributions {
113            distribution.matcha *= upscale_factor;
114        }
115    }
116
117    // Apply matching cap and redistribute if necessary
118    if let Some(cap) = options.matching_cap_amount {
119        let mut overflow_total = 0f64;
120        let mut eligible_for_redistribution = 0usize;
121
122        // First pass to apply cap and calculate overflow
123        for matcha in &mut distributions {
124            if matcha.matcha > cap {
125                overflow_total += matcha.matcha - cap;
126                matcha.matcha = cap;
127            } else {
128                eligible_for_redistribution += 1;
129            }
130        }
131
132        // Redistribution logic
133        if matches!(
134            options.matching_cap_strategy,
135            MatchingCapStrategy::Redistribute
136        ) && overflow_total > 0f64
137        {
138            let redistribution_amount = overflow_total / eligible_for_redistribution as f64;
139            for matcha in &mut distributions {
140                if matcha.matcha < cap {
141                    matcha.matcha += redistribution_amount;
142                    // Ensure not to exceed the cap after redistribution
143                    if matcha.matcha > cap {
144                        matcha.matcha = cap;
145                    }
146                }
147            }
148        }
149    }
150
151    distributions
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use arr_macro::arr;
158    use rand::*;
159
160    fn generate_contributions() -> Vec<Contribution> {
161        arr![Contribution::rnd(); 10].to_vec()
162    }
163
164    #[test]
165    fn test_upscale_to_pot() {
166        let contributions = arr![Contribution::rnd(); 50].to_vec();
167        let matching_pot = 1000.0;
168        let options = LinearQfOptions {
169            upscale: true,
170            ..Default::default()
171        };
172
173        let distributions = calculate_linear_qf(contributions, matching_pot, options);
174
175        let total_distributed: f64 = distributions.iter().map(|d| d.matcha).sum();
176        assert!(total_distributed.floor() <= matching_pot + f64::EPSILON * 10.0);
177    }
178
179    #[test]
180    fn test_add() {
181        let mut rng = rand::thread_rng();
182
183        let a_contribs = arr![Contribution {
184                recipient: "A".into(),
185                amount: 200f64,
186                sender: rng.gen::<char>().into(),
187            }; 5]
188        .to_vec();
189        let b_contribs = arr![Contribution {
190                recipient: "B".into(),
191                amount: 500f64,
192                sender: rng.gen::<char>().into(),
193            }; 2]
194        .to_vec();
195        let c_contribs = arr![Contribution {
196                recipient: "C".into(),
197                amount: 50f64,
198                sender: rng.gen::<char>().into(),
199            }; 20]
200        .to_vec();
201        let contributions = vec![a_contribs, b_contribs, c_contribs]
202            .into_iter()
203            .flatten()
204            .collect::<Vec<Contribution>>();
205        calculate_linear_qf(contributions, 10_000f64, LinearQfOptions::default());
206    }
207
208    #[test]
209    fn test_redistribution_strategy() {
210        let mut rng = rand::thread_rng();
211
212        let a_contribs = arr![Contribution {
213                recipient: "A".into(),
214                amount: 200f64,
215                sender: rng.gen::<char>().into(),
216            }; 5]
217        .to_vec();
218        let b_contribs = arr![Contribution {
219                recipient: "B".into(),
220                amount: 500f64,
221                sender: rng.gen::<char>().into(),
222            }; 2]
223        .to_vec();
224        let c_contribs = arr![Contribution {
225                recipient: "C".into(),
226                amount: 50f64,
227                sender: rng.gen::<char>().into(),
228            }; 20]
229        .to_vec();
230        let contributions = vec![a_contribs, b_contribs, c_contribs]
231            .into_iter()
232            .flatten()
233            .collect::<Vec<Contribution>>();
234
235        let matching_pot = 10000.0;
236        let cap = 10.0; // Set a cap that would force redistribution
237
238        let options = LinearQfOptions {
239            matching_cap_amount: Some(cap),
240            matching_cap_strategy: MatchingCapStrategy::Redistribute,
241            upscale: false, // Upscaling not relevant for this test
242        };
243
244        let distributions = calculate_linear_qf(contributions, matching_pot, options);
245
246        // Verify that none of the distributions exceed the cap
247        assert!(
248            distributions.iter().all(|d| d.matcha <= cap),
249            "All distributions must be within the cap."
250        );
251
252        // Calculate total distributed amount to ensure it's within the matching pot
253        let total_distributed: f64 = distributions.iter().map(|d| d.matcha).sum();
254
255        // The total distributed amount should be less than or equal to the matching pot
256        assert!(
257            total_distributed <= matching_pot,
258            "Total distributed amount must not exceed the matching pot."
259        );
260    }
261}