From 845ebc583d7f2578a22ca3b14a954b731aa945c8 Mon Sep 17 00:00:00 2001 From: Emil Koutanov Date: Sun, 24 Dec 2023 06:54:52 +1100 Subject: [PATCH] Initial work on StackVec --- brumby-soccer/benches/cri_interval.rs | 4 +- brumby-soccer/src/bin/soc_prices.rs | 14 +- brumby-soccer/src/domain.rs | 6 - brumby-soccer/src/domain/error.rs | 12 +- .../src/domain/error/head_to_head.rs | 41 ++- brumby-soccer/src/domain/error/total_goals.rs | 33 +- brumby-soccer/src/interval.rs | 28 +- brumby-soccer/src/model.rs | 65 ++-- brumby/src/lib.rs | 3 +- brumby/src/stack_vec.rs | 319 ++++++++++++++++++ 10 files changed, 455 insertions(+), 70 deletions(-) create mode 100644 brumby/src/stack_vec.rs diff --git a/brumby-soccer/benches/cri_interval.rs b/brumby-soccer/benches/cri_interval.rs index f8d7a84..6b85e1c 100644 --- a/brumby-soccer/benches/cri_interval.rs +++ b/brumby-soccer/benches/cri_interval.rs @@ -33,8 +33,8 @@ fn criterion_benchmark(c: &mut Criterion) { b.iter(|| run(18, u16::MAX)); }); - c.bench_function("cri_interval_90_min_1e-6", |b| { - b.iter(|| run(90, u16::MAX)); + c.bench_function("cri_interval_36_min_1e-6", |b| { + b.iter(|| run(36, u16::MAX)); }); c.bench_function("cri_interval_90_min_1e-6_max_8_goals", |b| { diff --git a/brumby-soccer/src/bin/soc_prices.rs b/brumby-soccer/src/bin/soc_prices.rs index cd6caf2..3a10931 100644 --- a/brumby-soccer/src/bin/soc_prices.rs +++ b/brumby-soccer/src/bin/soc_prices.rs @@ -15,6 +15,7 @@ use tracing::{debug, info}; use brumby::hash_lookup::HashLookup; use brumby::market::{Market, OverroundMethod, PriceBounds}; use brumby::tables; +use brumby::timed::Timed; use brumby_soccer::data::{download_by_id, ContestSummary, SoccerFeedId}; use brumby_soccer::domain::{Offer, OfferType, OutcomeType}; use brumby_soccer::fit::{ErrorType, FittingErrors}; @@ -26,12 +27,10 @@ use brumby_soccer::{fit, model, print}; const OVERROUND_METHOD: OverroundMethod = OverroundMethod::OddsRatio; const SINGLE_PRICE_BOUNDS: PriceBounds = 1.01..=301.0; -const FIRST_GOALSCORER_BOOKSUM: f64 = 1.5; const INTERVALS: u8 = 18; const INCREMENTAL_OVERROUND: f64 = 0.01; // const MAX_TOTAL_GOALS_HALF: u16 = 4; const MAX_TOTAL_GOALS: u16 = 8; -const GOALSCORER_MIN_PROB: f64 = 0.0; // const ERROR_TYPE: ErrorType = ErrorType::SquaredRelative; #[derive(Debug, clap::Parser, Clone)] @@ -142,7 +141,16 @@ async fn main() -> Result<(), Box> { overround: offer.market.overround.clone(), }) .collect::>(); - model.derive(&stubs, &SINGLE_PRICE_BOUNDS)?; + + let Timed { + value: cache_stats, + elapsed, + } = model.derive(&stubs, &SINGLE_PRICE_BOUNDS)?; + debug!( + "derivation took {elapsed:?} for {} offers ({} outcomes), {cache_stats:?}", + stubs.len(), + stubs.iter().map(|stub| stub.outcomes.len()).sum::() + ); { let table = Table::default().with_rows({ diff --git a/brumby-soccer/src/domain.rs b/brumby-soccer/src/domain.rs index 53945c2..b8c8716 100644 --- a/brumby-soccer/src/domain.rs +++ b/brumby-soccer/src/domain.rs @@ -112,12 +112,6 @@ impl OutcomeType { } } -// #[derive(Debug, Error)] -// pub enum OfferSubsetError { -// #[error("no outcomes remaining")] -// NoOutcomesRemaining -// } - #[derive(Debug)] pub struct Offer { pub offer_type: OfferType, diff --git a/brumby-soccer/src/domain/error.rs b/brumby-soccer/src/domain/error.rs index 103d1df..fec615d 100644 --- a/brumby-soccer/src/domain/error.rs +++ b/brumby-soccer/src/domain/error.rs @@ -35,7 +35,7 @@ impl Offer { &self.market.probs, &self.offer_type, )?; - self.offer_type.validate(&self.outcomes)?; + self.offer_type.validate_outcomes(&self.outcomes)?; match self.offer_type { OfferType::TotalGoals(_, _) => total_goals::validate_probs(&self.offer_type, &self.market.probs), OfferType::HeadToHead(_) => head_to_head::validate_probs(&self.offer_type, &self.market.probs), @@ -80,13 +80,21 @@ pub enum InvalidOutcome { } impl OfferType { - pub fn validate(&self, outcomes: &HashLookup) -> Result<(), InvalidOutcome> { + pub fn validate_outcomes(&self, outcomes: &HashLookup) -> Result<(), InvalidOutcome> { match self { OfferType::TotalGoals(_, _) => total_goals::validate_outcomes(self, outcomes), OfferType::HeadToHead(_) => head_to_head::validate_outcomes(self, outcomes), _ => Ok(()), } } + + pub fn validate_outcome(&self, outcome: &OutcomeType) -> Result<(), InvalidOutcome> { + match self { + OfferType::TotalGoals(_, _) => total_goals::validate_outcome(self, outcome), + OfferType::HeadToHead(_) => head_to_head::validate_outcome(self, outcome), + _ => Ok(()), + } + } } #[derive(Debug, Error)] diff --git a/brumby-soccer/src/domain/error/head_to_head.rs b/brumby-soccer/src/domain/error/head_to_head.rs index 0bcfb4e..8d5af04 100644 --- a/brumby-soccer/src/domain/error/head_to_head.rs +++ b/brumby-soccer/src/domain/error/head_to_head.rs @@ -1,34 +1,39 @@ use brumby::hash_lookup::HashLookup; use crate::domain::{error, OfferType, OutcomeType, Side}; -use crate::domain::error::{InvalidOffer, InvalidOutcome}; +use crate::domain::error::{ExtraneousOutcome, InvalidOffer, InvalidOutcome}; -pub fn validate_outcomes( +pub(crate) fn validate_outcomes( offer_type: &OfferType, outcomes: &HashLookup, ) -> Result<(), InvalidOutcome> { - match offer_type { - OfferType::HeadToHead(_) => { - error::OutcomesCompleteAssertion { - outcomes: &valid_outcomes(), - } - .check(outcomes, offer_type)?; - Ok(()) - } - _ => unreachable!(), + error::OutcomesCompleteAssertion { + outcomes: &valid_outcomes(), } + .check(outcomes, offer_type)?; + Ok(()) } -pub fn validate_probs(offer_type: &OfferType, probs: &[f64]) -> Result<(), InvalidOffer> { - match offer_type { - OfferType::HeadToHead(_) => { - error::BooksumAssertion::with_default_tolerance(1.0..=1.0).check(probs, offer_type)?; - Ok(()) - } - _ => unreachable!(), +pub(crate) fn validate_outcome( + offer_type: &OfferType, + outcome: &OutcomeType, +) -> Result<(), InvalidOutcome> { + let valid_outcomes = valid_outcomes(); + if valid_outcomes.contains(outcome) { + Ok(()) + } else { + Err(InvalidOutcome::ExtraneousOutcome(ExtraneousOutcome { + outcome_type: outcome.clone(), + offer_type: offer_type.clone(), + })) } } +pub(crate) fn validate_probs(offer_type: &OfferType, probs: &[f64]) -> Result<(), InvalidOffer> { + error::BooksumAssertion::with_default_tolerance(1.0..=1.0).check(probs, offer_type)?; + Ok(()) +} + fn valid_outcomes() -> [OutcomeType; 3] { [ OutcomeType::Win(Side::Home), diff --git a/brumby-soccer/src/domain/error/total_goals.rs b/brumby-soccer/src/domain/error/total_goals.rs index a8ca723..c1611bc 100644 --- a/brumby-soccer/src/domain/error/total_goals.rs +++ b/brumby-soccer/src/domain/error/total_goals.rs @@ -1,9 +1,9 @@ use brumby::hash_lookup::HashLookup; -use crate::domain::error::{InvalidOffer, InvalidOutcome}; use crate::domain::{error, OfferType, OutcomeType, Over}; +use crate::domain::error::{ExtraneousOutcome, InvalidOffer, InvalidOutcome}; -pub fn validate_outcomes( +pub(crate) fn validate_outcomes( offer_type: &OfferType, outcomes: &HashLookup, ) -> Result<(), InvalidOutcome> { @@ -19,7 +19,27 @@ pub fn validate_outcomes( } } -pub fn validate_probs(offer_type: &OfferType, probs: &[f64]) -> Result<(), InvalidOffer> { +pub(crate) fn validate_outcome( + offer_type: &OfferType, + outcome: &OutcomeType, +) -> Result<(), InvalidOutcome> { + match offer_type { + OfferType::TotalGoals(_, over) => { + let valid_outcomes = valid_outcomes(over); + if valid_outcomes.contains(outcome) { + Ok(()) + } else { + Err(InvalidOutcome::ExtraneousOutcome(ExtraneousOutcome { + outcome_type: outcome.clone(), + offer_type: offer_type.clone(), + })) + } + } + _ => unreachable!(), + } +} + +pub(crate) fn validate_probs(offer_type: &OfferType, probs: &[f64]) -> Result<(), InvalidOffer> { match offer_type { OfferType::TotalGoals(_, _) => { error::BooksumAssertion::with_default_tolerance(1.0..=1.0).check(probs, offer_type)?; @@ -29,13 +49,6 @@ pub fn validate_probs(offer_type: &OfferType, probs: &[f64]) -> Result<(), Inval } } -// pub fn create_outcomes(offer_type: &OfferType) -> [OutcomeType; 2] { -// match offer_type { -// OfferType::TotalGoals(_, over) => _create_outcomes(over), -// _ => unreachable!(), -// } -// } - fn valid_outcomes(over: &Over) -> [OutcomeType; 2] { [ OutcomeType::Over(over.0), OutcomeType::Under(over.0 + 1), diff --git a/brumby-soccer/src/interval.rs b/brumby-soccer/src/interval.rs index 8ca709f..bbc9ef1 100644 --- a/brumby-soccer/src/interval.rs +++ b/brumby-soccer/src/interval.rs @@ -1,4 +1,4 @@ -use std::ops::Range; +use std::ops::{Add, AddAssign, Range}; use bincode::Encode; use rustc_hash::FxHashMap; @@ -133,6 +133,32 @@ impl Default for Expansions { } } +// impl Add for Expansions { +// type Output = Expansions; +// +// fn add(self, rhs: Self) -> Self::Output { +// Self { +// ht_score: self.ht_score || rhs.ht_score, +// ft_score: self.ft_score || rhs.ft_score, +// max_player_goals: u8::max(self.max_player_goals, rhs.max_player_goals), +// player_split_goal_stats: self.player_split_goal_stats || rhs.player_split_goal_stats, +// max_player_assists: u8::max(self.max_player_assists, rhs.max_player_assists), +// first_goalscorer: self.first_goalscorer || rhs.first_goalscorer, +// } +// } +// } + +impl AddAssign for Expansions { + fn add_assign(&mut self, rhs: Self) { + self.ht_score |= rhs.ht_score; + self.ft_score |= rhs.ft_score; + self.max_player_goals = u8::max(self.max_player_goals, rhs.max_player_goals); + self.player_split_goal_stats |= rhs.player_split_goal_stats; + self.max_player_assists = u8::max(self.max_player_assists, rhs.max_player_assists); + self.first_goalscorer |= rhs.first_goalscorer; + } +} + #[derive(Debug, Clone, Encode)] pub struct PruneThresholds { pub max_total_goals: u16, diff --git a/brumby-soccer/src/model.rs b/brumby-soccer/src/model.rs index c1027e0..8e8917b 100644 --- a/brumby-soccer/src/model.rs +++ b/brumby-soccer/src/model.rs @@ -13,6 +13,7 @@ use brumby::capture::Capture; use brumby::hash_lookup::HashLookup; use brumby::market::{Market, Overround, PriceBounds}; use brumby::probs::SliceExt; +use brumby::timed::Timed; use crate::domain::error::{InvalidOffer, InvalidOutcome, MissingOutcome, UnvalidatedOffer}; use crate::domain::{Offer, OfferCategory, OfferType, OutcomeType, Over, Period, Player}; @@ -178,33 +179,26 @@ impl Model { &mut self, stubs: &[Stub], price_bounds: &PriceBounds, - ) -> Result<(), DerivationError> { - let start = Instant::now(); - let mut cache = FxHashMap::default(); - let mut cache_stats = CacheStats::default(); - for stub in stubs { - debug!( - "deriving {:?} ({} outcomes)", - stub.offer_type, - stub.outcomes.len() - ); - stub.offer_type.validate(&stub.outcomes)?; - let start = Instant::now(); - let (offer, derive_cache_stats) = self.derive_offer(stub, price_bounds, &mut cache)?; - debug!("... took {:?}, {derive_cache_stats:?}", start.elapsed()); - cache_stats += derive_cache_stats; - self.offers.insert(offer.offer_type.clone(), offer); - } - let elapsed = start.elapsed(); - debug!( - "derivation took {elapsed:?} for {} offers ({} outcomes), {cache_stats:?}", - self.offers.len(), - self.offers - .values() - .map(|offer| offer.outcomes.len()) - .sum::() - ); - Ok(()) + ) -> Result, DerivationError> { + Timed::result(|| { + let mut cache = FxHashMap::default(); + let mut cache_stats = CacheStats::default(); + for stub in stubs { + debug!( + "deriving {:?} ({} outcomes)", + stub.offer_type, + stub.outcomes.len() + ); + stub.offer_type.validate_outcomes(&stub.outcomes)?; + let start = Instant::now(); + let (offer, derive_cache_stats) = + self.derive_offer(stub, price_bounds, &mut cache)?; + debug!("... took {:?}, {derive_cache_stats:?}", start.elapsed()); + cache_stats += derive_cache_stats; + self.offers.insert(offer.offer_type.clone(), offer); + } + Ok(cache_stats) + }) } pub fn offers(&self) -> &FxHashMap { @@ -313,6 +307,23 @@ impl Model { Ok((offer, cache_stats)) } + pub fn derive_price(selections: &[(OfferType, OutcomeType)]) -> Result<(), ()> { + todo!() + } + + fn collect_requirements( + offer_type: &OfferType, + outcome: &OutcomeType, + agg_reqs: &mut Expansions, + agg_player_probs: &mut FxHashMap, + ) -> Result<(), DerivationError> { + offer_type.validate_outcome(outcome)?; + let reqs = requirements(offer_type); + + // *agg_reqs = *agg_reqs + reqs; + todo!() + } + fn ensure_team_requirements(&self, reqs: &Expansions) -> Result<(), UnmetRequirement> { if reqs.requires_team_goal_probs() { self.require_team_goal_probs()?; diff --git a/brumby/src/lib.rs b/brumby/src/lib.rs index b2cbebf..f5b0fe0 100644 --- a/brumby/src/lib.rs +++ b/brumby/src/lib.rs @@ -21,8 +21,9 @@ pub mod opt; pub mod poisson; pub mod probs; pub mod tables; -pub mod selection; pub mod timed; +pub mod selection; +pub mod stack_vec; #[cfg(test)] pub(crate) mod testing; diff --git a/brumby/src/stack_vec.rs b/brumby/src/stack_vec.rs new file mode 100644 index 0000000..fca75a9 --- /dev/null +++ b/brumby/src/stack_vec.rs @@ -0,0 +1,319 @@ +use std::fmt::{Debug, Formatter}; +use std::ops::{Index, IndexMut}; +use thiserror::Error; + +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct StackVec { + len: usize, + array: [Option; C] +} +impl StackVec { + pub fn len(&self) -> usize { + self.len + } + + pub fn is_empty(&self) -> bool { + self.len == 0 + } + + pub fn push(&mut self, value: T) { + self.array[self.len] = Some(value); + self.len += 1; + } + + pub fn iter(&self) -> Iter { + Iter { + sv: self, + pos: 0, + } + } + + pub fn clear(&mut self) { + self.array.fill_with(|| None); + self.len = 0; + } + + pub fn capacity(&self) -> usize { + C + } +} + +impl Debug for StackVec { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "[")?; + let mut iter = self.iter(); + if let Some(item) = iter.next() { + write!(f, "{item:?}")?; + } + for item in iter { + write!(f, ", {item:?}")?; + } + write!(f, "]") + } +} + +#[derive(Debug, Error, PartialEq, Eq)] +#[error("source array len {source_len} exceeds target len {target_len}")] +pub struct ArrayOverflow { + source_len: usize, + target_len: usize, +} + +impl TryFrom<[T; B]> for StackVec { + type Error = ArrayOverflow; + + fn try_from(source: [T; B]) -> Result { + if B > C { + return Err(ArrayOverflow { source_len: B, target_len: C }); + } + + let mut array: [Option; C] = std::array::from_fn(|_| None); + for (index, item) in source.into_iter().enumerate() { + array[index] = Some(item); + } + Ok(Self { + len: B, + array, + }) + } +} + +pub fn sv(source: J) -> StackVec where J: IntoIterator { + let mut sv = StackVec::default(); + for item in source { + sv.push(item); + } + sv +} + +impl Default for StackVec { + fn default() -> Self { + Self { + len: 0, + array: std::array::from_fn(|_| None) + } + } +} + +impl Index for StackVec { + type Output = T; + + fn index(&self, index: usize) -> &Self::Output { + if index >= self.len { + panic!("index out of bounds: the len is {} but the index is {index}", self.len); + } + self.array[index].as_ref().unwrap() + } +} + +impl IndexMut for StackVec { + fn index_mut(&mut self, index: usize) -> &mut Self::Output { + if index >= self.len { + panic!("index out of bounds: the len is {} but the index is {index}", self.len); + } + self.array[index].as_mut().unwrap() + } +} + +pub struct Iter<'a, T, const C: usize> { + sv: &'a StackVec, + pos: usize +} + +impl<'a, T, const C: usize> Iterator for Iter<'a, T, C> { + type Item = &'a T; + + fn next(&mut self) -> Option { + if self.pos < self.sv.len { + let next = self.sv.array[self.pos].as_ref(); + self.pos += 1; + next + } else { + None + } + } +} + +impl<'a, T, const C: usize> IntoIterator for &'a StackVec { + type Item = &'a T; + type IntoIter = Iter<'a, T, C>; + + fn into_iter(self) -> Self::IntoIter { + self.iter() + } +} + +pub struct IntoIter { + sv: StackVec, + pos: usize +} + +impl Iterator for IntoIter { + type Item = T; + + fn next(&mut self) -> Option { + if self.pos < self.sv.len { + let next = self.sv.array[self.pos].take(); + self.pos += 1; + next + } else { + None + } + } +} + +impl IntoIterator for StackVec { + type Item = T; + type IntoIter = IntoIter; + + fn into_iter(self) -> Self::IntoIter { + IntoIter { + sv: self, + pos: 0, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn init() { + let sv = StackVec::<(), 4>::default(); + assert_eq!(4, sv.capacity()); + assert!(sv.is_empty()); + assert_eq!(0, sv.len()); + assert_eq!(None, sv.iter().next()); + assert_eq!(None, sv.into_iter().next()); + } + + #[test] + fn debug() { + { + let sv = StackVec::<(), 0>::default(); + assert_eq!("[]", format!("{sv:?}")); + } + { + let sv: StackVec<_, 2> = sv(["zero"]); + assert_eq!(r#"["zero"]"#, format!("{sv:?}")); + } + { + let sv: StackVec<_, 2> = sv(["zero", "one"]); + assert_eq!(r#"["zero", "one"]"#, format!("{sv:?}")); + } + { + let sv: StackVec<_, 3> = sv(["zero", "one", "two"]); + assert_eq!(r#"["zero", "one", "two"]"#, format!("{sv:?}")); + } + } + + #[test] + fn push_within_capacity() { + let mut sv = StackVec::<_, 4>::default(); + sv.push("zero"); + assert!(!sv.is_empty()); + assert_eq!(1, sv.len()); + sv.push("one"); + sv.push("two"); + sv.push("three"); + assert_eq!(4, sv.len()); + } + + #[test] + #[should_panic(expected = "index out of bounds: the len is 2 but the index is 2")] + fn push_with_overflow() { + let mut sv = StackVec::<_, 2>::default(); + sv.push("zero"); + sv.push("one"); + sv.push("two"); + } + + #[test] + fn iter() { + let mut sv = StackVec::<_, 2>::default(); + sv.push("zero"); + sv.push("one"); + let mut iter = sv.iter(); + assert_eq!(Some(&"zero"), iter.next()); + assert_eq!(Some(&"one"), iter.next()); + assert_eq!(None, iter.next()); + assert_eq!(None, iter.next()); + } + + #[test] + fn iter_ref() { + let mut sv = StackVec::<_, 2>::default(); + sv.push("zero"); + sv.push("one"); + let mut vec = Vec::with_capacity(2); + for &item in &sv { + vec.push(item); + } + assert_eq!(vec!["zero", "one"], vec); + } + + #[test] + fn into_iter() { + let mut sv = StackVec::<_, 2>::default(); + sv.push("zero"); + sv.push("one"); + assert_eq!(vec!["zero", "one"], sv.into_iter().collect::>()); + } + + #[test] + fn from_array() { + let sv = StackVec::<_, 2>::try_from(["zero", "one"]).unwrap(); + assert_eq!(2, sv.len()); + assert_eq!(vec!["zero", "one"], sv.into_iter().collect::>()); + } + + #[test] + fn from_array_overflow() { + let result = StackVec::<_, 2>::try_from(["zero", "one", "two"]); + assert_eq!(ArrayOverflow { source_len: 3, target_len: 2 }, result.unwrap_err()); + } + + #[test] + fn index() { + let sv: StackVec<_, 2> = sv(["zero", "one"]); + assert_eq!("zero", sv[0]); + assert_eq!("one", sv[1]); + } + + #[test] + #[should_panic(expected = "index out of bounds: the len is 2 but the index is 2")] + fn index_overflow() { + let sv: StackVec<_, 2> = sv(["0", "1"]); + let _ = sv[2]; + } + + #[test] + fn index_mut() { + let mut sv: StackVec<_, 2> = sv(["0", "1"]); + sv[0] = "zero"; + sv[1] = "one"; + assert_eq!(vec!["zero", "one"], sv.into_iter().collect::>()); + } + + #[test] + #[should_panic(expected = "index out of bounds: the len is 2 but the index is 2")] + fn index_mut_overflow() { + let mut sv: StackVec<_, 2> = sv(["0", "1"]); + sv[2] = "two"; + } + + #[test] + fn clear() { + let mut sv: StackVec<_, 2> = sv(["0", "1"]); + sv.clear(); + assert!(sv.is_empty()); + assert_eq!(Vec::<&str>::new(), sv.into_iter().collect::>()); + } + + #[test] + #[should_panic(expected = "the len is 2 but the index is 2")] + fn sv_overflow() { + let _: StackVec<_, 2> = sv(["0", "1", "2"]); + } +} \ No newline at end of file