droprate/
lib.rs

1//! The `droprate` crate aims to be a drop-in solution for picking options from
2//! a weighted list of possibilities.
3//! 
4//! While naive random number generation to pick from some list of traits is
5//! pretty easy to implement as-needed, it doesn't take long to run into scenarios
6//! where this solution provides suboptimal results.
7//! 
8//! In a card game, you may want to simulate a deck of cards being shuffled, which
9//! means that the odds of each card becomes zero once it's been pulled from the
10//! deck.
11
12use std::collections::HashMap;
13
14extern crate rand;
15
16pub trait ProbabilityTable<T> {
17    /// Add an option to the random table with the assigned weight value.
18    /// 
19    /// You can chain multiple `push(...)` calls together to save a little space.
20    /// 
21    /// The weight is stored as an `f64` value, so you don't _need_ the specify
22    /// the odds in pure-integer ratios. Also, while you could use numbers to look
23    /// like a "precent chance", it's important to remember that each items weight
24    /// calculates odds against the entire table, and so if you add "more than 100%"
25    /// then you will end up with each outcome's odds being less than you put in.
26    /// 
27    /// In other words, it's best to think of this like a recipe with ratios:
28    /// 
29    /// * 2 parts Option A
30    /// * 5 parts Option B
31    /// * 1 part Option C
32    /// * 12 parts Option D
33    /// * etc.
34    /// 
35    /// # Examples
36    /// 
37    /// ```
38    /// use droprate::{RandomTable, ProbabilityTable};
39    /// 
40    /// let mut table = RandomTable::<&'static str>::new();
41    /// table.push("First option", 1f64)  // 1/4 = 25% chance
42    ///      .push("Second option", 1f64) // 1/4 = 25% chance
43    ///      .push("Third option", 2f64); // 2/4 = 50% chance
44    /// 
45    /// assert_eq!(3, table.count());
46    /// ```
47    fn push(&mut self, ident: T, weight: f64) -> &mut ProbabilityTable<T>;
48
49    /// Get the number of possible items in the table.
50    /// 
51    /// # Examples
52    /// 
53    /// ```
54    /// use droprate::{RandomTable, ProbabilityTable};
55    /// 
56    /// let mut table = RandomTable::<&'static str>::new();
57    /// table.push("First option", 1f64);
58    /// assert_eq!(1, table.count());
59    /// 
60    /// table.push("Second option", 1f64)
61    ///      .push("Third option", 2f64);
62    /// assert_eq!(3, table.count());
63    /// ```
64    fn count(&self) -> usize;
65
66    /// Get a vector of all of the options in the list. There is no guarantee
67    /// over the order in which items are returned.
68    /// 
69    /// # Examples
70    /// 
71    /// ```
72    /// use droprate::{RandomTable, ProbabilityTable};
73    /// 
74    /// let mut table = RandomTable::<&'static str>::new();
75    /// table.push("A", 1f64)
76    ///      .push("B", 1f64);
77    /// 
78    /// assert_eq!(2, table.keys().len());
79    /// ```
80    fn keys(&self) -> Vec<T>;
81
82    /// Choose an option from the list of options, using the supplied weights
83    /// to map the odds. The actual odds for each trial may differ when using
84    /// tables other than [`RandomTable`] (e.g., [`FairlyRandomTable`] tracks
85    /// results from previous trials in order to deliver a more evenly distributed
86    /// set of results than one would fine from a more "true" random generator)    
87    /// 
88    /// # Errors
89    /// 
90    /// If no options have been specified (or all options added were give a weight
91    /// less than or equal to zero), this will return an error that the generated
92    /// value was outside the range of the table.
93    /// 
94    /// # Examples
95    /// 
96    /// ```
97    /// use droprate::{RandomTable, ProbabilityTable};
98    /// 
99    /// let mut table = RandomTable::<&'static str>::new();
100    /// assert_eq!(true, table.random().is_err());
101    /// 
102    /// table.push("A", 1f64)
103    ///      .push("B", 1f64);
104    /// 
105    /// assert_eq!(false, table.random().is_err());
106    /// ```
107    fn random(&mut self) -> Result<T, String>;
108}
109
110/// `RandomTable` represents a table of options and their relative weights. The
111/// odds of any option being selected is `option's weight / all options' weights`.
112/// This is a typical implementation of random tables in software and games as
113/// each trial runs independent of other trials which may have happened in the
114/// past. As such, it is perfectly valid for an option with 50% odds to be
115/// selected many times in a row. (It's an outcome which becomes statistically
116/// less likely to have happened, but nevertheless if you have 5 in a row, the
117/// next trial is still a 50% chance.)
118/// 
119/// This kind of random is useful, but it's also hard for users to understand
120/// and can often lead to outcomes which (in games, at least) feel unfair.
121pub struct RandomTable<T> {
122    pub(crate) table: HashMap<T, f64>,
123    pub(crate) total: f64,
124}
125
126/// `FairlyRandomTable` aims to create results which a human might create when
127/// asked to create a random sequence from a weighted table. It is human nature
128/// to generate a more evenly-distributed list of random values because we are
129/// aware of the the history of options chosen.
130/// 
131/// Calling this a "random" table strains the definition -- it is certainly going
132/// to give you "mixed up" results, but ultimately they will be far more
133/// predictable. That said, they will _also_ feel much more "fair" to users who
134/// experience them.
135/// 
136/// For example: Given a result with a 1-in-10 odds ratio, there is still a 30%
137/// chance that you won't get that result within 10 trials. There is a 12% chance
138/// that you won't even see the result within 20 trials. This is where players
139/// start to complain that the devs hate them.
140/// 
141/// With `FairlyRandomTable`, every trial which doesn't give a certain result
142/// increases the probability of that result on the next trial (proportional to
143/// its initial probability) until it is selected, which decreases its probability
144/// dramatically (however it's not impossible to get multiple results in a row --
145/// in fact, allowing for multiple results in a row of even unlikely options is
146/// a design goal; you just won't seem them as frequently).
147pub struct FairlyRandomTable<T> {
148    pub(crate) base: RandomTable<T>,
149    pub(crate) table: HashMap<T, f64>,
150    pub(crate) total: f64,
151}
152
153// RandomTable
154impl<T: std::cmp::Eq + std::hash::Hash> RandomTable<T> {
155    /// Create a new instance of `RandomTable` with no options.
156    pub fn new() -> RandomTable<T> {
157        RandomTable {
158            table: HashMap::new(),
159            total: 0f64,
160        }
161    }
162
163    /// Create a new `RandomTable` from a [`HashMap`].
164    /// 
165    /// # Examples
166    /// 
167    /// ```
168    /// use droprate::{RandomTable, ProbabilityTable};
169    /// use std::collections::HashMap;
170    /// 
171    /// let map: HashMap<&'static str, f64> =
172    ///     [("A", 1f64),
173    ///     ("B", 1f64),
174    ///     ("C", 3f64)]
175    ///     .iter().cloned().collect();
176    /// 
177    /// let mut table = RandomTable::<&'static str>::from_map(map);
178    /// 
179    /// assert_eq!(3, table.count());
180    /// ```
181    /// 
182    /// ```
183    /// use droprate::{RandomTable, ProbabilityTable};
184    /// use std::collections::HashMap;
185    /// 
186    /// let mut map = HashMap::new();
187    /// map.insert("A", 1f64);
188    /// map.insert("B", 1f64);
189    /// map.insert("C", 3f64);
190    /// 
191    /// let mut table = RandomTable::<&'static str>::from_map(map);
192    /// 
193    /// assert_eq!(3, table.count());
194    /// ```
195    pub fn from_map(in_table: HashMap<T, f64>) -> RandomTable<T> {
196        let mut total = 0f64;
197        for entry in &in_table {
198            total += entry.1
199        }
200
201        RandomTable {
202            table: in_table,
203            total: total,
204        }
205    }
206}
207
208impl<T: std::cmp::Eq + std::hash::Hash + Clone> ProbabilityTable<T> for RandomTable<T> {
209    fn push(&mut self, ident: T, weight: f64) -> &mut ProbabilityTable<T> {
210        self.table.insert(ident, weight);
211        self.total += weight;
212        self
213    }
214
215    fn count(&self) -> usize {
216        self.table.len()
217    }
218
219    fn keys(&self) -> Vec<T> {
220        self.table.keys().cloned().collect()
221    }
222
223    fn random(&mut self) -> Result<T, String> {
224        let r = rand::random::<f64>() * self.total;
225        let mut comp = r;
226        for pair in &self.table {
227            if *pair.1 > comp {
228                return Ok(pair.0.clone());
229            }
230            comp -= pair.1;
231        }
232
233        Err("Generated random outside of possible range".to_owned())
234    }
235}
236
237//
238// FairlyRandomTable
239//
240impl<T: std::cmp::Eq + std::hash::Hash + Clone> FairlyRandomTable<T> {
241    /// Create a new instance of `FairlyRandomTable` with no options.
242    pub fn new() -> FairlyRandomTable<T> {
243        FairlyRandomTable {
244            base: RandomTable::new(),
245            table: HashMap::new(),
246            total: 0f64,
247        }
248    }
249
250    /// Create a new `FairlyRandomTable` from a [`HashMap`].
251    /// 
252    /// # Examples
253    /// 
254    /// ```
255    /// use droprate::{FairlyRandomTable, ProbabilityTable};
256    /// use std::collections::HashMap;
257    /// 
258    /// let map: HashMap<&'static str, f64> =
259    ///     [("A", 1f64),
260    ///     ("B", 1f64),
261    ///     ("C", 3f64)]
262    ///     .iter().cloned().collect();
263    /// 
264    /// let mut table = FairlyRandomTable::<&'static str>::from_map(map);
265    /// 
266    /// assert_eq!(3, table.count());
267    /// ```
268    /// 
269    /// ```
270    /// use droprate::{FairlyRandomTable, ProbabilityTable};
271    /// use std::collections::HashMap;
272    /// 
273    /// let mut map = HashMap::new();
274    /// map.insert("A", 1f64);
275    /// map.insert("B", 1f64);
276    /// map.insert("C", 3f64);
277    /// 
278    /// let mut table = FairlyRandomTable::<&'static str>::from_map(map);
279    /// 
280    /// assert_eq!(3, table.count());
281    /// ```
282    pub fn from_map(in_table: HashMap<T, f64>) -> FairlyRandomTable<T> {
283        let mut total = 0f64;
284        for entry in &in_table {
285            total += entry.1
286        }
287
288        FairlyRandomTable {
289            base: RandomTable::from_map(in_table.clone()),
290            table: in_table,
291            total: total,
292        }
293    }
294
295    /// Run a trial from this as though it were a [`RandomTable`]. The table's
296    /// results memory will not be affected, and as such future results from
297    /// calling `random()` will not account for this trial.
298    pub fn pure_random(&self) -> Result<T, String> {
299        let r = rand::random::<f64>() * self.total;
300        let mut comp = r;
301        for pair in &self.base.table {
302            if *pair.1 > comp {
303                return Ok(pair.0.clone());
304            }
305            comp -= pair.1;
306        }
307
308        Err("Generated random outside of possible range".to_owned())
309    }
310
311    fn redistribute_weights(&mut self, amount: f64) {
312        let keys = self.table.keys().cloned().collect::<Vec<T>>();
313
314        for key in keys {
315            let original = match self.base.table.get(&key) {
316                Some(val) => *val,
317                None => continue,
318            };
319
320            let local = match self.table.get_mut(&key) {
321                Some(val) => val,
322                None => continue,
323            };
324
325            let ratio = original / self.base.total;
326
327            *local += amount * ratio;
328        }
329    }
330}
331
332impl<T: std::cmp::Eq + std::hash::Hash + Clone> ProbabilityTable<T> for FairlyRandomTable<T> {
333    fn push(&mut self, ident: T, weight: f64) -> &mut ProbabilityTable<T> {
334        self.base.push(ident.clone(), weight);
335        self.table.insert(ident, weight);
336        self.total += weight;
337        self
338    }
339
340    fn count(&self) -> usize {
341        self.table.len()
342    }
343
344    fn keys(&self) -> Vec<T> {
345        self.table.keys().cloned().collect()
346    }
347
348    fn random(&mut self) -> Result<T, String> {
349        let r = rand::random::<f64>() * self.total;
350        let mut comp = r;
351
352        let keys = self.table.keys().cloned();
353        let mut match_pair: Option<(T, f64)> = None;
354
355        for key in keys {
356            let val = self.table.get(&key);
357            if let Some(val) = val {
358                if *val > comp {
359                    match_pair = Some((key.clone(), *val));
360                    break;
361                }
362                comp -= *val;
363            }
364        }
365
366        match match_pair {
367            Some(pair) => {
368                self.table.entry(pair.0.clone()).and_modify(|e| *e = 0f64);
369                self.redistribute_weights(pair.1);
370                return Ok(pair.0.clone());
371            }
372            None => {}
373        }
374
375        Err("Generated random outside of possible range".to_owned())
376    }
377}