diff --git a/src/lib.rs b/src/lib.rs index 250949d..5413552 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -39,6 +39,7 @@ //! [up]: UserPreferences pub mod user_preferences; +pub mod util; mod weighted_table; use std::fmt; diff --git a/src/user_preferences/error.rs b/src/user_preferences/error.rs index cd01c2f..e5eea25 100644 --- a/src/user_preferences/error.rs +++ b/src/user_preferences/error.rs @@ -62,7 +62,7 @@ pub enum ParseError { /// The prefstring assumes the existance of more pronouns than exist /// /// Often a symptom of a malformed prefstring, this error occurs when the prefstring - /// is correctly formatted, but when a [`pronouns_today::WeightedTable`] is formed + /// is correctly formatted, but when a [`crate::WeightedTable`] is formed /// from it, the prefstring refers to a pronoun with an index greater than is in the /// list. For example, setting the weight of pronoun set number 20 when there's only /// 15 pronoun sets known by the instance. @@ -70,6 +70,14 @@ pub enum ParseError { /// This can be caused by reducing the number of available pronouns after a prefstring /// has been generated, or by trying to parse a lucky malformed prefstring. PrefstringExceedsPronounCount, + + /// Attempted to roll or generate an empty table + /// + /// Whatever you're doing, you would have ended up with an empty + /// [`crate::WeightedTable`], either by evaluating a prefstring that doesn't match any + /// pronouns, or by creating an invalid weighted table. Either way, you got this + /// error instead. + EmptyWeightedTable, } impl fmt::Display for ParseError { @@ -108,6 +116,9 @@ impl fmt::Display for ParseError { ParseError::PrefstringExceedsPronounCount => { write!(f, "The user preferences refers to pronouns beyond those that are known") } + ParseError::EmptyWeightedTable => { + write!(f, "Attempted to create or roll an empty roll table") + } } } diff --git a/src/user_preferences/mod.rs b/src/user_preferences/mod.rs index d20c45c..e176ac2 100644 --- a/src/user_preferences/mod.rs +++ b/src/user_preferences/mod.rs @@ -8,7 +8,7 @@ pub mod v0; mod error; pub use error::ParseError; -use crate::{InstanceSettings, WeightedTable}; +use crate::{InstanceSettings, Pronoun, WeightedTable}; use data_encoding::BASE32_NOPAD; @@ -39,7 +39,7 @@ pub trait Preference { /// crucial step to randomly selecting a pronoun set based on a user's preferences, as /// any selection is done by using a [`WeightedTable`]. All preference versions must /// implement this method. - fn into_weighted_table<'a>(&self, settings: &'a InstanceSettings) -> Result, ParseError>; + fn into_weighted_table<'a>(&self, settings: &'a InstanceSettings) -> Result, ParseError>; /// Parse a given prefstring, after it's extraction from base64 /// @@ -78,7 +78,7 @@ pub trait Preference { } impl Preference for UserPreferences { - fn into_weighted_table<'a>(&self, settings: &'a InstanceSettings) -> Result, ParseError> { + fn into_weighted_table<'a>(&self, settings: &'a InstanceSettings) -> Result, ParseError> { match self { UserPreferences::V0(pref) => pref, }.into_weighted_table(settings) diff --git a/src/user_preferences/v0.rs b/src/user_preferences/v0.rs index d2751b7..25b499a 100644 --- a/src/user_preferences/v0.rs +++ b/src/user_preferences/v0.rs @@ -2,6 +2,7 @@ use crate::{ InstanceSettings, + Pronoun, user_preferences::{Preference, ParseError}, WeightedTable, }; @@ -23,7 +24,7 @@ pub struct UserPreferencesV0 { } impl Preference for UserPreferencesV0 { - fn into_weighted_table<'a>(&self, settings: &'a InstanceSettings) -> Result, ParseError> { + fn into_weighted_table<'a>(&self, settings: &'a InstanceSettings) -> Result, ParseError> { let mut remaining_pronouns = settings.pronoun_list.iter(); let mut weighted_pronouns = HashMap::with_capacity(settings.pronoun_list.len()); @@ -74,7 +75,7 @@ impl Preference for UserPreferencesV0 { ); } - Ok(WeightedTable(weighted_pronouns)) + WeightedTable::new(weighted_pronouns) } /// ``` @@ -168,7 +169,7 @@ impl From for Command { #[cfg(test)] mod tests { - use crate::{InstanceSettings, WeightedTable}; + use crate::{InstanceSettings, Pronoun, WeightedTable}; use crate::user_preferences::{Preference, ParseError}; use crate::user_preferences::v0::{UserPreferencesV0, Command}; @@ -186,10 +187,11 @@ mod tests { } } - fn check_table(actual: WeightedTable, expected: Vec<(&str, u8)>) { - let actual_simplified = actual.0.into_iter() - .filter(|(pronoun, weight)| *weight > 0) - .map(|(pronoun, weight)| (pronoun.subject_pronoun.as_str(), weight)) + fn check_table(actual: WeightedTable<&Pronoun>, expected: Vec<(&str, u8)>) { + let actual_simplified = actual.weights() + .into_iter() + .filter(|(pronoun, weight)| **weight > 0) + .map(|(pronoun, weight)| (pronoun.subject_pronoun.as_str(), *weight)) .collect::>(); let expected_owned = expected.into_iter() .map(|(pronoun, weight)| (pronoun, weight)) diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..2e93d75 --- /dev/null +++ b/src/util.rs @@ -0,0 +1,20 @@ +pub fn pcg64(state: &mut u128) -> u64 { + let mut x = *state; + pcg64_iterstate(state); + let count = (x >> (128 - 6)) as u32; // Highest 6 bits + x ^= x >> 35; + ((x >> 58) as u64).rotate_right(count) +} + +fn pcg64_iterstate(state: &mut u128) { + const MULTIPLIER: u128 = 0xde92a69f6e2f9f25fd0d90f576075fbd; + const INCREMENT: u128 = 621; + *state = state.wrapping_mul(MULTIPLIER).wrapping_add(INCREMENT); +} + +pub fn pcg64_seed(state: &mut u128, bytes: &[u8]) { + for byte in bytes { + *state = (*state ^ *byte as u128).rotate_right(8); + } + pcg64_iterstate(state); +} diff --git a/src/weighted_table.rs b/src/weighted_table.rs index a1b0353..fec5740 100644 --- a/src/weighted_table.rs +++ b/src/weighted_table.rs @@ -1,4 +1,7 @@ -use crate::Pronoun; +use crate::{ + user_preferences::ParseError, + util::{pcg64, pcg64_seed}, +}; use std::collections::HashMap; @@ -22,14 +25,61 @@ pub const COVID_EPOCH: Date = match Date::from_calendar_date(2020, Month::Januar /// pronoun. Additional methods are provided to perform this random selection on a weighted list, /// using as a seed both an arbitrary string of bytes and a Date. #[derive(Clone, PartialEq, Eq, Debug)] -pub struct WeightedTable<'a>(pub HashMap<&'a Pronoun, u8>); +pub struct WeightedTable(HashMap); -impl<'a> WeightedTable<'a> { +impl WeightedTable { + + /// Attempt to generate a new table + /// + /// Accepts an iterator of tuples. Each tupple represents one outcome. The left + /// element of the tupple is the value that outcome produces (the table loot, if you + /// will), and the right element of the tuple is the weight associated with that + /// outcome. + /// + /// Performs a safety check to prevent creating an empty roll table. Raises a parse + /// error if the safety check fails. The original tabel can be accessed as a + /// [`HashMap`] via the [`WeightedTable::weights()`] method + pub fn new(table: impl IntoIterator) -> Result { + let table: HashMap = table.into_iter() + .filter(|(_, weight)| *weight > 0) + .collect(); + + if table.is_empty() { + Err(ParseError::EmptyWeightedTable) + } else { + Ok(WeightedTable(table)) + } + } + + /// Access the orginal table of weights underlying this table + /// + /// Returns a [`HashMap`] mapping pronouns to their respective weights. + pub fn weights(&self) -> &HashMap { + &self.0 + } + + /// Produces a table of cumulative weights for the roll table + /// + /// Each entry has the weight of itself as well as all of the previous entries. This + /// is provided in a [`Vec`] ordered from lowest weight to highest. This table can be + /// used to efficiently perform a custom roll. + /// + /// Two values are returned: The table itself, and the highest value in the table + /// (that is, the sum of all of the weights) + pub fn rollable_table(&self) -> (Vec<(u64, Loot)>, u64) { + let mut cumulative_weights: Vec<(u64, Loot)> = Vec::with_capacity(self.0.len()); + let mut weight_so_far = 0; + for (pronoun, weight) in &self.0 { + weight_so_far += *weight as u64; + cumulative_weights.push((weight_so_far, *pronoun)); + } + (cumulative_weights, weight_so_far) + } /// A shorthand for calling [`WeightedTable::select_on_date()`] with today's date /// /// The date is generated for the system's time and timezone - pub fn select_today(&self, seed: &[u8]) -> &Pronoun { + pub fn select_today(&self, seed: &[u8]) -> Loot { self.select_on_date( seed, OffsetDateTime::now_local() @@ -41,7 +91,7 @@ impl<'a> WeightedTable<'a> { /// Randomly select a pronoun set for a given date and name. /// /// Is a wrapper for calling [`WeightedTable::select`] with the given date mixed into the seed. - pub fn select_on_date(&self, seed: &[u8], date: Date) -> &Pronoun { + pub fn select_on_date(&self, seed: &[u8], date: Date) -> Loot { let mut new_seed: Vec = Vec::with_capacity(seed.len() + 4); new_seed.extend( ( @@ -59,8 +109,18 @@ impl<'a> WeightedTable<'a> { /// This function is *pure*, and any randomness is produced internally using PRNG seeded with /// the given date and seed. That is to say, for any given seed, this table must always /// produce the same pronoun set. - pub fn select(&self, seed: &[u8]) -> &Pronoun { - todo!() + pub fn select(&self, seed: &[u8]) -> Loot { + let (rollable_table, sum_weights) = self.rollable_table(); + + let mut generator: u128 = 131213121312; + pcg64_seed(&mut generator, seed); + let random = pcg64(&mut generator) % sum_weights; + + rollable_table.iter() + .filter(|(weight, _)| random < *weight) + .next() + .expect("A table was generated with zero entries. This should be impossible.") + .1 } }