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}