diff --git a/crates/ournotes-search/src/search/bonus.rs b/crates/ournotes-search/src/search/bonus.rs new file mode 100644 index 0000000..2c69b02 --- /dev/null +++ b/crates/ournotes-search/src/search/bonus.rs @@ -0,0 +1,169 @@ +//! The event-bonus arithmetic the payoff bounds share: audited native products and card-bonus sums, and the largest +//! bonus a team can reach. Every value is a nonnegative Int32 unless a function says otherwise; the audits refuse a +//! computation whose native intermediate would leave that range, so a bound never wraps into a smaller number. +use ournotes_sim::{ + Error, + event::{self, EventCard}, + master::EventEffectRow, +}; +use std::collections::{HashMap, HashSet}; + +/// The refusal of a payoff computation whose native intermediates would leave the supported range. +pub(crate) fn unavailable(message: &str) -> Error { + Error::Unsupported(format!("deck payoff bound unavailable: {message}")) +} + +pub(super) fn nonnegative_i32(value: i64) -> Result { + if (0..=i64::from(i32::MAX)).contains(&value) { + Ok(value) + } else { + Err(unavailable("negative or wrapping native Int32 value")) + } +} + +/// Audit the native order `value * (bonus + 10000) * rate / 10000`. +/// The first product must fit even when the final rate is zero. +pub(super) fn point_product(value: i64, bonus: i64, rate: i64) -> Result { + nonnegative_i32(value)?; + nonnegative_i32(bonus)?; + nonnegative_i32(rate)?; + let base = nonnegative_i32(bonus.checked_add(10000).ok_or_else(|| unavailable("bonus overflow"))?)?; + let first = nonnegative_i32(value.checked_mul(base).ok_or_else(|| unavailable("first product overflow"))?)?; + let last = nonnegative_i32(first.checked_mul(rate).ok_or_else(|| unavailable("second product overflow"))?)?; + Ok(i128::from(last / 10000)) +} + +/// Challenge-point earnings have no event bonus or 10000-scale intermediate. +pub(super) fn challenge_product(value: i64, rate: i64) -> Result { + nonnegative_i32(value)?; + nonnegative_i32(rate)?; + let product = value.checked_mul(rate).ok_or_else(|| unavailable("CP product overflow"))?; + Ok(i128::from(nonnegative_i32(product)?)) +} + +/// Reproduce the native card's sum of `bonus_type` effects only after proving every cast/addition safe. +pub(super) fn card_bonus(effects: &[&EventEffectRow], card: EventCard<'_>, bonus_type: i64) -> Result { + let mut sum = 0i64; + for effect in effects.iter().filter(|e| e.event_bonus_type == bonus_type) { + let (hit, rank) = match card { + EventCard::Member(m) => (event::is_member_target(effect, m)?, m.rank), + EventCard::Snap(s) => (event::is_snap_target(effect, s), s.rank), + }; + if hit { + let value = nonnegative_i32(event::rank_effect_value(effect, rank)?)?; + sum = nonnegative_i32(sum + value)?; + } + } + Ok(sum) +} + +/// The largest event bonus of any team of five `members` (character, bonus) and up to five of the `snap` bonuses: +/// the five largest per-character maxima of the member bonuses plus the five largest Snap bonuses. A team's members +/// have distinct characters and its Snaps are distinct, so no team sums more. Every bonus is nonnegative. +pub(super) fn largest_team_bonus(members: impl IntoIterator, snap: &[i64]) -> i64 { + let mut rows = HashMap::::new(); + for (character, bonus) in members { + let best = rows.entry(character).or_insert(0); + *best = (*best).max(bonus); + } + let top = |mut values: Vec| { + values.sort_unstable_by(|a, b| b.cmp(a)); + values.into_iter().take(5).sum::() + }; + top(rows.into_values().collect()) + top(snap.to_vec()) +} + +/// The largest sum of `count` completed characters' bonuses, each a row keyed by character: the required characters +/// plus the largest optional rows. Callers pass the rows of the characters a prefix can still use. +pub(super) fn sum_rows(rows: &HashMap, required: &HashSet, count: usize) -> i64 { + let fixed: i64 = required.iter().map(|c| rows[c]).sum(); + let mut optional: Vec<_> = rows.iter().filter(|(c, _)| !required.contains(c)).map(|(_, &v)| v).collect(); + optional.sort_unstable_by(|a, b| b.cmp(a)); + fixed + optional.iter().take(count - required.len()).sum::() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn native_intermediate_wrapping_is_refused_even_at_zero_rate() { + assert!(point_product(i64::from(i32::MAX), 0, 0).is_err()); + assert!(point_product(1, i64::from(i32::MAX), 0).is_err()); + assert!(point_product(1, 0, i64::from(i32::MAX)).is_err()); + assert!(point_product(-1, 0, 1).is_err()); + assert_eq!(point_product(100, 20000, 0).unwrap(), 0); + assert_eq!(challenge_product(i64::from(i32::MAX), 1).unwrap(), i128::from(i32::MAX)); + assert_eq!(challenge_product(i64::from(i32::MAX), 0).unwrap(), 0); + assert!(challenge_product(i64::from(i32::MAX), 2).is_err()); + } + + #[test] + fn effect_casts_and_each_addition_must_fit_before_native_wrapping() { + let member = + event::EventMember { id: 1, character_id: 1, band_id: Some(1), card_type: 1, tags: vec![], rank: 1 }; + let mut effect = EventEffectRow { + event_bonus_type: event::EVENT_POINT, + resource_type_constraint: 2, + rank1_effect_value: i64::from(i32::MAX), + ..Default::default() + }; + let card = EventCard::Member(&member); + assert_eq!(card_bonus(&[&effect], card, event::EVENT_POINT).unwrap(), i64::from(i32::MAX)); + assert_eq!(card_bonus(&[&effect], card, event::EVENT_ITEM).unwrap(), 0, "another bonus type is not read"); + assert!(card_bonus(&[&effect, &effect], card, event::EVENT_POINT).is_err()); + effect.rank1_effect_value = 1i64 << 32; + assert!(card_bonus(&[&effect], card, event::EVENT_POINT).is_err()); + effect.rank1_effect_value = -1; + assert!(card_bonus(&[&effect], card, event::EVENT_POINT).is_err()); + } + + /// Every team of five distinct-character members and at most five distinct Snaps, by enumeration. + fn enumerated(members: &[(i64, i64)], snaps: &[i64]) -> Option { + let n = members.len(); + let mut best_members = None; + for mask in 0u32..1 << n { + if mask.count_ones() != 5 { + continue; + } + let chosen: Vec<_> = (0..n).filter(|&i| mask & (1 << i) != 0).map(|i| members[i]).collect(); + let mut characters: Vec<_> = chosen.iter().map(|&(c, _)| c).collect(); + characters.sort_unstable(); + characters.dedup(); + if characters.len() == 5 { + let sum = chosen.iter().map(|&(_, b)| b).sum::(); + best_members = Some(best_members.map_or(sum, |b: i64| b.max(sum))); + } + } + let mut best_snaps = 0; + for mask in 0u32..1 << snaps.len() { + if mask.count_ones() <= 5 { + let sum = (0..snaps.len()).filter(|&j| mask & (1 << j) != 0).map(|j| snaps[j]).sum::(); + best_snaps = best_snaps.max(sum); + } + } + best_members.map(|m| m + best_snaps) + } + + #[test] + fn largest_team_bonus_equals_the_best_enumerated_team() { + let mut x = 0x2545_f491_4f6c_dd1du64; + let mut next = |n: u64| { + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + x % n + }; + let mut checked = 0; + for _ in 0..400 { + let n = 5 + next(8) as usize; + let characters = 3 + next(6) as i64; + let members: Vec<_> = (0..n).map(|_| (next(characters as u64) as i64, next(4) as i64 * 2500)).collect(); + let snaps: Vec<_> = (0..next(9)).map(|_| next(5) as i64 * 700).collect(); + let Some(expected) = enumerated(&members, &snaps) else { continue }; + assert_eq!(largest_team_bonus(members.iter().copied(), &snaps), expected, "{members:?} {snaps:?}"); + checked += 1; + } + assert!(checked > 100); + } +} diff --git a/crates/ournotes-search/src/search/caps.rs b/crates/ournotes-search/src/search/caps.rs new file mode 100644 index 0000000..bfc7dd9 --- /dev/null +++ b/crates/ournotes-search/src/search/caps.rs @@ -0,0 +1,130 @@ +//! Largest payoff each term of a metric can pay, used to fold a combined metric and to bound its terms. + +use super::{Pool, SearchRequest}; +use crate::domain::CandidateDomain; +use crate::search::bonus::{card_bonus, point_product}; +use crate::types::Metric; +use ournotes_sim::Error; +use ournotes_sim::scenario::EventPayoffInput; + +/// At least the payoff of one terminal result of any team of the domain under a term of a combined metric: one for +/// a score target met or missed, the threshold of a capped score, the largest row of an event reward table, else +/// the largest Int32. +pub(crate) fn term_cap( + pool: &Pool, + request: &SearchRequest, + domain: &CandidateDomain, + metric: &Metric, + input: Option<&EventPayoffInput>, +) -> i128 { + match metric { + Metric::ScoreAtLeast { .. } | Metric::ScoreAndLifeAtLeast { .. } => 1, + Metric::CappedScore { threshold } => i128::from(*threshold), + _ => event_cap(pool, request, domain, metric, input).unwrap_or(i128::from(i32::MAX)), + } +} + +/// The largest row of an event metric's reward table on the request's result route, played or skipped, at the +/// route's rate and the largest event bonus of a team of the domain. None for another metric and for a product +/// outside Int32. +fn event_cap( + pool: &Pool, + request: &SearchRequest, + domain: &CandidateDomain, + metric: &Metric, + input: Option<&EventPayoffInput>, +) -> Option { + use ournotes_sim::event::{self, EventCard, EventResultRoute}; + + let event_id = metric.event()?; + let master = pool.master; + let q = request.objective.context()?.event_request(master, input?, event_id).ok()?; + let ev = master.event(event_id)?; + let challenge = + matches!(q.route, EventResultRoute::ChallengePlayed { .. } | EventResultRoute::ChallengeSkip { .. }); + let consumed = i64::from(q.consumed_count); + let rates = + if challenge { event::challenge_point_bonus(master, consumed) } else { event::boost_bonus(master, consumed) } + .ok()?; + let (value, rate, effect) = match *metric { + Metric::ClientEventPoints { .. } => { + let (rows, group) = if challenge { + (&master.challenge_live_event_points, ev.challenge_live_event_point_group) + } else { + (&master.live_event_points, ev.live_event_point_group) + }; + ( + rows.iter().filter(|row| row.group == group).map(|row| row.value).max(), + rates[4], + Some(event::EVENT_POINT), + ) + } + Metric::ClientChallengePoints { .. } => { + (master.live_challenge_points.iter().map(|row| row.value).max(), rates[4], None) + } + Metric::RankedEventItems { resource_type, resource_id, .. } => { + let (rows, group) = if challenge { + (&master.challenge_live_event_rewards, ev.challenge_live_event_reward_group) + } else { + (&master.live_event_rewards, ev.live_event_reward_group) + }; + let counts = rows + .iter() + .filter(|row| { + row.event_group == group && row.resource_type == resource_type && row.resource_id == resource_id + }) + .map(|row| row.resource_count); + (counts.max(), rates[0], Some(event::EVENT_ITEM)) + } + _ => return None, + }; + let bonus = match effect { + Some(kind) => { + let effects = event::event_effects(master, event_id); + let members = domain + .members() + .iter() + .map(|&m| { + let card = ournotes_sim::bonus::event_member(master, &pool.members[m]); + Ok((pool.members[m].character_id, card_bonus(&effects, EventCard::Member(&card), kind)?)) + }) + .collect::, Error>>() + .ok()?; + let snaps = domain + .snaps() + .iter() + .map(|&s| card_bonus(&effects, EventCard::Snap(&ournotes_sim::bonus::event_snap(&pool.snaps[s])), kind)) + .collect::, Error>>() + .ok()?; + crate::search::bonus::largest_team_bonus(members, &snaps) + } + None => 0, + }; + point_product(value.unwrap_or(0).max(0), bonus, rate).ok() +} + +/// Whether no negative local score has a native result: the local score -1 has no rank (a solo score below every +/// threshold is a game error), or its room total takes NONE and `paid` holds for no NONE reward. Ranks walk the +/// thresholds a score or total reaches, so every smaller score takes no higher rank, and NONE is the least. Settlement +/// then reports a Game error for every negative score, and every team with a result scores at least zero in every +/// play, whatever its factor margin. False when the request maps no local score to a total. +pub(crate) fn negative_scores_unpaid( + pool: &Pool, + context: &ournotes_sim::scenario::ResolvedContext, + input: &EventPayoffInput, + paid: impl Fn(i64) -> bool, +) -> bool { + use ournotes_sim::event; + use ournotes_sim::scenario::Scenario; + let Some(music) = pool.master.live_music(context.resolved.live_music_id) else { return false }; + let group = music.live_score_rank_group; + if !matches!(context.scenario, Scenario::Battle(_) | Scenario::Arena(_)) { + return event::score_rank(pool.master, group, -1).is_err(); + } + input.multiplayer_score_policy.as_ref().and_then(|policy| policy.total_and_count(-1).ok()).is_some_and( + |(total, players)| { + event::battle_score_rank(pool.master, group, i64::from(total), players) == event::RANK_NONE + && !paid(event::RANK_NONE) + }, + ) +} diff --git a/crates/ournotes-search/src/search/certified_payoff.rs b/crates/ournotes-search/src/search/certified_payoff.rs index 06a39dc..92896a9 100644 --- a/crates/ournotes-search/src/search/certified_payoff.rs +++ b/crates/ournotes-search/src/search/certified_payoff.rs @@ -8,7 +8,7 @@ use super::{Pool, SearchRequest}; use crate::types::Metric; use ournotes_sim::Error; use ournotes_sim::event; -use ournotes_sim::scenario::{EventPayoffInput, MultiplayerScorePolicy, ResolvedContext, Scenario, item_payoff}; +use ournotes_sim::scenario::{EventPayoffInput, MultiplayerScorePolicy, ResolvedContext, Scenario}; fn invalid(message: &str) -> Error { Error::Domain(format!("certified payoff: {message}")) @@ -65,15 +65,8 @@ pub(crate) fn payoff_map( pool.check_deck(&deck)?; let cuts = rank_cuts(pool, context, input, support)?; let value_at = |score| match *metric { - Metric::ClientEventPoints { event_id } => { - Ok(i128::from(context.preview_event_points(pool, &deck, input, event_id, score)?.points_for(event_id))) - } - Metric::ClientChallengePoints { event_id } => Ok(i128::from( - context.preview_event_points(pool, &deck, input, event_id, score)?.challenge_points_for(event_id), - )), - Metric::RankedEventItems { event_id, resource_type, resource_id } => { - let preview = context.preview_event_items(pool, &deck, input, event_id, score)?; - item_payoff(&preview, event_id, resource_type, resource_id) + Metric::ClientEventPoints { .. } | Metric::ClientChallengePoints { .. } | Metric::RankedEventItems { .. } => { + crate::search::reward::payoff_of(pool, request, metric, Some(input), physical, score, power, None) } _ => Err(invalid("non-event metric reached native settlement")), }; @@ -225,7 +218,7 @@ mod tests { use super::*; use crate::search::{Constraints, Objective, PlayInput}; use ournotes_sim::live::model::JudgementStream; - use ournotes_sim::scenario::ContextInput; + use ournotes_sim::scenario::{ContextInput, item_payoff}; use serde_json::{Value, json}; fn fixture() -> Synth { diff --git a/crates/ournotes-search/src/search/curve.rs b/crates/ournotes-search/src/search/curve.rs new file mode 100644 index 0000000..f564958 --- /dev/null +++ b/crates/ournotes-search/src/search/curve.rs @@ -0,0 +1,59 @@ +//! The shape of a score-dependent payoff: how far one order's payoff steps with its score, and the score cap the +//! step is read at. +use crate::search::bonus::unavailable; +use crate::search::reward::points_payoff; +use ournotes_sim::Error; + +/// One step of a played Live's per-order payoff, from its lowest score on. +#[derive(Clone, Copy, Debug)] +pub(crate) enum Step { + /// Event or challenge points `multiplier * (bonus + 10000) / 10000`, the multiplier being the rate times the + /// largest reward value at or below the step's score (challenge points read no card bonus). + Points(i64), + /// A score target: reached (1) or not (0). + Target(bool), +} + +/// The per-order payoff of a played Live steps with the order's score. Every order of a deck with power `p` +/// scores at most `ceil(p * global * (1 + eps))` (the global coefficient of the joint envelope with its margin, see +/// `JointBounds::score_steps`), so the step of that cap bounds each of its orders. +#[derive(Clone, Debug)] +pub(crate) struct ScoreSteps { + global: f64, + eps: f64, + /// (lowest score, step) by ascending score, each step at least the previous one. + steps: Vec<(i128, Step)>, +} + +impl ScoreSteps { + pub(crate) fn new(global: f64, eps: f64, steps: Vec<(i128, Step)>) -> Self { + Self { global, eps, steps } + } + + /// The score cap of every order of a deck with power at most `power`. + pub(crate) fn score_cap(&self, power: i64) -> i128 { + ((power as f64) * self.global * (1.0 + self.eps)).ceil() as i128 + } + + pub(crate) fn value(&self, bonus: i64, power: i64) -> Result { + let cap = self.score_cap(power); + let at = self.steps.partition_point(|&(score, _)| score <= cap); + // The joint bound's compilation proved the native products nonwrapping up to the largest bonus. + match at.checked_sub(1).map(|i| self.steps[i].1) { + None => Err(unavailable("score cap below the lowest step")), + Some(Step::Points(multiplier)) => Ok(points_payoff(bonus, multiplier)), + Some(Step::Target(reached)) => Ok(i128::from(reached)), + } + } + + /// The largest multiplier of any step; it only orders the members a search visits. + pub(crate) fn largest_step(&self) -> f64 { + self.steps.iter().fold(0.0, |best, &(_, step)| match step { + Step::Points(multiplier) => best.max(multiplier as f64), + Step::Target(_) => best, + }) + } +} + +/// The steps of one term of a combined metric, with its weight and its member and Snap bonuses. +pub(crate) type TermSteps<'a> = (i128, &'a [i64], &'a [i64], ScoreSteps); diff --git a/crates/ournotes-search/src/search/deck_payoff.rs b/crates/ournotes-search/src/search/deck_payoff.rs index 6a3afa7..7f8041a 100644 --- a/crates/ournotes-search/src/search/deck_payoff.rs +++ b/crates/ournotes-search/src/search/deck_payoff.rs @@ -19,110 +19,21 @@ //! terminal evaluation; the upper-only mode does not assign that bound as a deck's actual payoff. use super::{ Objective, Pool, SearchRequest, + bonus::{card_bonus, challenge_product, largest_team_bonus, nonnegative_i32, point_product, sum_rows}, expectation::PhysicalDeck, - team_power::{SLOTS, TeamPowerBounds, candidates, largest_team_bonus, sum_rows}, + team_power::{SLOTS, TeamPowerBounds, candidates}, }; +use crate::search::bonus::unavailable; +use crate::search::curve::{ScoreSteps, TermSteps}; use crate::{domain::CandidateDomain, types::Metric}; use ournotes_sim::{ Error, event::{self, EventCard, EventResultRoute}, live::skip::SkipEvaluator, - master::EventEffectRow, scenario::EventPayoffInput, }; use std::collections::HashMap; -fn unavailable(message: &str) -> Error { - Error::Unsupported(format!("deck payoff bound unavailable: {message}")) -} - -fn nonnegative_i32(value: i64) -> Result { - if (0..=i64::from(i32::MAX)).contains(&value) { - Ok(value) - } else { - Err(unavailable("negative or wrapping native Int32 value")) - } -} - -/// Audit the native order `value * (bonus + 10000) * rate / 10000`. -/// The first product must fit even when the final rate is zero. -pub(super) fn point_product(value: i64, bonus: i64, rate: i64) -> Result { - nonnegative_i32(value)?; - nonnegative_i32(bonus)?; - nonnegative_i32(rate)?; - let base = nonnegative_i32(bonus.checked_add(10000).ok_or_else(|| unavailable("bonus overflow"))?)?; - let first = nonnegative_i32(value.checked_mul(base).ok_or_else(|| unavailable("first product overflow"))?)?; - let last = nonnegative_i32(first.checked_mul(rate).ok_or_else(|| unavailable("second product overflow"))?)?; - Ok(i128::from(last / 10000)) -} - -/// Challenge-point earnings have no event bonus or 10000-scale intermediate. -fn challenge_product(value: i64, rate: i64) -> Result { - nonnegative_i32(value)?; - nonnegative_i32(rate)?; - let product = value.checked_mul(rate).ok_or_else(|| unavailable("CP product overflow"))?; - Ok(i128::from(nonnegative_i32(product)?)) -} - -/// Reproduce the native card's sum of `bonus_type` effects only after proving every cast/addition safe. -pub(super) fn card_bonus(effects: &[&EventEffectRow], card: EventCard<'_>, bonus_type: i64) -> Result { - let mut sum = 0i64; - for effect in effects.iter().filter(|e| e.event_bonus_type == bonus_type) { - let (hit, rank) = match card { - EventCard::Member(m) => (event::is_member_target(effect, m)?, m.rank), - EventCard::Snap(s) => (event::is_snap_target(effect, s), s.rank), - }; - if hit { - let value = nonnegative_i32(event::rank_effect_value(effect, rank)?)?; - sum = nonnegative_i32(sum + value)?; - } - } - Ok(sum) -} - -/// One step of a played Live's per-order payoff, from its lowest score on. -#[derive(Clone, Copy, Debug)] -pub(crate) enum Step { - /// Event or challenge points `multiplier * (bonus + 10000) / 10000`, the multiplier being the rate times the - /// largest reward value at or below the step's score (challenge points read no card bonus). - Points(i64), - /// A score target: reached (1) or not (0). - Target(bool), -} - -/// The per-order payoff of a played Live steps with the order's score. Every order of a deck with power `p` -/// scores at most `ceil(p * global * (1 + eps))` (the global coefficient of the joint envelope with its margin, see -/// `JointBounds::score_steps`), so the step of that cap bounds each of its orders. -#[derive(Clone, Debug)] -pub(crate) struct ScoreSteps { - global: f64, - eps: f64, - /// (lowest score, step) by ascending score, each step at least the previous one. - steps: Vec<(i128, Step)>, -} - -impl ScoreSteps { - pub(crate) fn new(global: f64, eps: f64, steps: Vec<(i128, Step)>) -> Self { - Self { global, eps, steps } - } - - /// The score cap of every order of a deck with power at most `power`. - pub(crate) fn score_cap(&self, power: i64) -> i128 { - ((power as f64) * self.global * (1.0 + self.eps)).ceil() as i128 - } - - fn value(&self, bonus: i64, power: i64) -> Result { - let cap = self.score_cap(power); - let at = self.steps.partition_point(|&(score, _)| score <= cap); - // The joint bound's compilation proved the native products nonwrapping up to the largest bonus. - match at.checked_sub(1).map(|i| self.steps[i].1) { - None => Err(unavailable("score cap below the lowest step")), - Some(Step::Points(multiplier)) => Ok(i128::from(multiplier) * i128::from(bonus + 10000) / 10000), - Some(Step::Target(reached)) => Ok(i128::from(reached)), - } - } -} - /// The payoff of a deck as a function of its event bonus (10000 = 100 %) and power, nondecreasing in both on the /// compiled domain. #[derive(Clone, Debug)] @@ -171,10 +82,7 @@ impl Curve { f64::from(u8::from(*counted)) * *count as f64 * *rate as f64 / 10000.0 } Self::Constant(_) | Self::UpperOnly(_) | Self::Skip => 0.0, - Self::Steps(steps) => steps.steps.iter().fold(0.0, |best, &(_, step)| match step { - Step::Points(multiplier) => best.max(multiplier as f64), - Step::Target(_) => best, - }), + Self::Steps(steps) => steps.largest_step(), } } } @@ -301,9 +209,6 @@ struct Term { curve: Curve, } -/// A term's weight, its event bonus per member and per Snap column, and its steps with the score. -pub(crate) type TermSteps<'a> = (i128, &'a [i64], &'a [i64], ScoreSteps); - /// Owned arrays only. The payoff unit is one deck payoff; the engine scales it to its order mass. The payoff is the /// weighted sum of its terms (one term of weight one for a single metric), each nondecreasing in its own event bonus /// and in the power. @@ -647,38 +552,6 @@ mod tests { use serde_json::json; use std::collections::HashSet; - #[test] - fn native_intermediate_wrapping_is_refused_even_at_zero_rate() { - assert!(point_product(i64::from(i32::MAX), 0, 0).is_err()); - assert!(point_product(1, i64::from(i32::MAX), 0).is_err()); - assert!(point_product(1, 0, i64::from(i32::MAX)).is_err()); - assert!(point_product(-1, 0, 1).is_err()); - assert_eq!(point_product(100, 20000, 0).unwrap(), 0); - assert_eq!(challenge_product(i64::from(i32::MAX), 1).unwrap(), i128::from(i32::MAX)); - assert_eq!(challenge_product(i64::from(i32::MAX), 0).unwrap(), 0); - assert!(challenge_product(i64::from(i32::MAX), 2).is_err()); - } - - #[test] - fn effect_casts_and_each_addition_must_fit_before_native_wrapping() { - let member = - event::EventMember { id: 1, character_id: 1, band_id: Some(1), card_type: 1, tags: vec![], rank: 1 }; - let mut effect = EventEffectRow { - event_bonus_type: event::EVENT_POINT, - resource_type_constraint: 2, - rank1_effect_value: i64::from(i32::MAX), - ..Default::default() - }; - let card = EventCard::Member(&member); - assert_eq!(card_bonus(&[&effect], card, event::EVENT_POINT).unwrap(), i64::from(i32::MAX)); - assert_eq!(card_bonus(&[&effect], card, event::EVENT_ITEM).unwrap(), 0, "another bonus type is not read"); - assert!(card_bonus(&[&effect, &effect], card, event::EVENT_POINT).is_err()); - effect.rank1_effect_value = 1i64 << 32; - assert!(card_bonus(&[&effect], card, event::EVENT_POINT).is_err()); - effect.rank1_effect_value = -1; - assert!(card_bonus(&[&effect], card, event::EVENT_POINT).is_err()); - } - /// Seven one-character members and three Snaps. Event 7 has point effects and item effects on different cards, /// and normal and Challenge item rewards of resource 4/88 next to another resource. fn fixture() -> Synth { @@ -773,7 +646,7 @@ mod tests { let domain = CandidateDomain::build(&pool, &request.constraints).unwrap(); let payoff = input.event_payoff.as_ref().unwrap(); let folded = crate::search::levels::fold(&metric, |term| { - crate::search::joint::term_cap(&pool, &request, &domain, term, Some(payoff)) + crate::search::caps::term_cap(&pool, &request, &domain, term, Some(payoff)) }) .unwrap(); let metric = folded.unwrap_or(metric); diff --git a/crates/ournotes-search/src/search/joint.rs b/crates/ournotes-search/src/search/joint.rs index 51bcb9f..d4702cb 100644 --- a/crates/ournotes-search/src/search/joint.rs +++ b/crates/ournotes-search/src/search/joint.rs @@ -46,20 +46,19 @@ mod relax_tables; mod resource; mod split_tables; mod tail; +pub(crate) use crate::search::caps::term_cap; pub(crate) use bonus::BonusScratch; use compile::row_spread; use law_clusters::LawPart; pub(crate) use law_clusters::{LawClusters, LawTables, MAX_CLUSTERS, NO_KIND, pack}; pub(crate) use order_steps::OrderSteps; pub(crate) use orders::TeamCaps; -pub(crate) use point_bound::term_cap; use point_bound::{PointBound, ScoreTarget}; use relax::order_gain_bound; use tail::TailTables; pub(crate) use tail::{PAIR_BLOCK, TailState}; -/// Leader first, then the other physical slots. Performance order is never a decision. -pub(crate) const SLOTS: [usize; 5] = [2, 0, 1, 3, 4]; +pub(crate) use super::slots::SLOTS; /// A bound module: an upper bound of the payoff numerator, over the performance-order masses `orders`, of every /// completion of a partial team. `p` holds the members of `SLOTS[..depth]` (the leader first) and, with diff --git a/crates/ournotes-search/src/search/joint/compile.rs b/crates/ournotes-search/src/search/joint/compile.rs index 7cd15cc..b30f51d 100644 --- a/crates/ournotes-search/src/search/joint/compile.rs +++ b/crates/ournotes-search/src/search/joint/compile.rs @@ -192,7 +192,7 @@ impl JointBounds { request.objective.context().ok_or_else(|| unavailable("items need resolved context"))?; let route = context.event_request(pool.master, input, *event_id)?.route; let nonnegative = nonnegative_scores - || point_bound::negative_scores_unpaid(pool, context, input, |rank| { + || crate::search::caps::negative_scores_unpaid(pool, context, input, |rank| { ournotes_sim::event::ranked_event_reward(pool.master, *event_id, &route, rank).is_ok() }); let support = (if nonnegative { 0 } else { i32::MIN }, cap.clamp(0.0, i32::MAX as f64) as i32); diff --git a/crates/ournotes-search/src/search/joint/point_bound.rs b/crates/ournotes-search/src/search/joint/point_bound.rs index 7399b27..52f4368 100644 --- a/crates/ournotes-search/src/search/joint/point_bound.rs +++ b/crates/ournotes-search/src/search/joint/point_bound.rs @@ -44,103 +44,6 @@ const RANKED_CAPS: f64 = (1u64 << 38) as f64; /// of 120 of them saturates. const RANKED_REWARDS: i128 = 1 << 80; -/// At least the payoff of one terminal result of any team of the domain under a term of a combined metric: one for -/// a score target met or missed, the threshold of a capped score, the largest row of an event reward table, else -/// the largest Int32. -pub(crate) fn term_cap( - pool: &Pool, - request: &SearchRequest, - domain: &CandidateDomain, - metric: &Metric, - input: Option<&EventPayoffInput>, -) -> i128 { - match metric { - Metric::ScoreAtLeast { .. } | Metric::ScoreAndLifeAtLeast { .. } => 1, - Metric::CappedScore { threshold } => i128::from(*threshold), - _ => event_cap(pool, request, domain, metric, input).unwrap_or(i128::from(i32::MAX)), - } -} - -/// The largest row of an event metric's reward table on the request's result route, played or skipped, at the -/// route's rate and the largest event bonus of a team of the domain. None for another metric and for a product -/// outside Int32. -fn event_cap( - pool: &Pool, - request: &SearchRequest, - domain: &CandidateDomain, - metric: &Metric, - input: Option<&EventPayoffInput>, -) -> Option { - use crate::search::deck_payoff::{card_bonus, point_product}; - use ournotes_sim::event::{self, EventCard, EventResultRoute}; - - let event_id = metric.event()?; - let master = pool.master; - let q = request.objective.context()?.event_request(master, input?, event_id).ok()?; - let ev = master.event(event_id)?; - let challenge = - matches!(q.route, EventResultRoute::ChallengePlayed { .. } | EventResultRoute::ChallengeSkip { .. }); - let consumed = i64::from(q.consumed_count); - let rates = - if challenge { event::challenge_point_bonus(master, consumed) } else { event::boost_bonus(master, consumed) } - .ok()?; - let (value, rate, effect) = match *metric { - Metric::ClientEventPoints { .. } => { - let (rows, group) = if challenge { - (&master.challenge_live_event_points, ev.challenge_live_event_point_group) - } else { - (&master.live_event_points, ev.live_event_point_group) - }; - ( - rows.iter().filter(|row| row.group == group).map(|row| row.value).max(), - rates[4], - Some(event::EVENT_POINT), - ) - } - Metric::ClientChallengePoints { .. } => { - (master.live_challenge_points.iter().map(|row| row.value).max(), rates[4], None) - } - Metric::RankedEventItems { resource_type, resource_id, .. } => { - let (rows, group) = if challenge { - (&master.challenge_live_event_rewards, ev.challenge_live_event_reward_group) - } else { - (&master.live_event_rewards, ev.live_event_reward_group) - }; - let counts = rows - .iter() - .filter(|row| { - row.event_group == group && row.resource_type == resource_type && row.resource_id == resource_id - }) - .map(|row| row.resource_count); - (counts.max(), rates[0], Some(event::EVENT_ITEM)) - } - _ => return None, - }; - let bonus = match effect { - Some(kind) => { - let effects = event::event_effects(master, event_id); - let members = domain - .members() - .iter() - .map(|&m| { - let card = ournotes_sim::bonus::event_member(master, &pool.members[m]); - Ok((pool.members[m].character_id, card_bonus(&effects, EventCard::Member(&card), kind)?)) - }) - .collect::, Error>>() - .ok()?; - let snaps = domain - .snaps() - .iter() - .map(|&s| card_bonus(&effects, EventCard::Snap(&ournotes_sim::bonus::event_snap(&pool.snaps[s])), kind)) - .collect::, Error>>() - .ok()?; - crate::search::team_power::largest_team_bonus(members, &snaps) - } - None => 0, - }; - point_product(value.unwrap_or(0).max(0), bonus, rate).ok() -} - impl PointBound { pub(super) fn score_target(members: usize, snaps: usize, target: ScoreTarget) -> Self { Self::plain( @@ -249,7 +152,7 @@ impl PointBound { /// The point payoff of an order of a deck with this event bonus at a score cap without a score target: the step /// multiplier at the cap. fn tier_payoff(&self, bonus: i64, score_cap: i128) -> i128 { - ((bonus + 10000) * self.multiplier_at(score_cap) / 10000) as i128 + crate::search::reward::points_payoff(bonus, self.multiplier_at(score_cap)) } /// The bound of a combined metric's payoff from its terms' weights and bounds. The first term whose payoff @@ -276,7 +179,7 @@ impl PointBound { let others: Vec<_> = parts .into_iter() .map(|(weight, part)| { - let bonus = crate::search::team_power::largest_team_bonus( + let bonus = crate::search::bonus::largest_team_bonus( domain.members().iter().map(|&m| (pool.members[m].character_id, part.member[m])), &part.snap, ); @@ -320,7 +223,7 @@ impl PointBound { fn maximum_reward(&self, bonus: i64, others: &[i64]) -> i128 { let own = match self.target { Some(_) => i128::from(self.multiplier), - None => ((bonus + 10000) * self.multiplier / 10000) as i128, + None => crate::search::reward::points_payoff(bonus, self.multiplier), }; self.others.iter().enumerate().fold(self.weight.saturating_mul(own), |sum, (i, (weight, largest, part))| { let bonus = others.get(i).copied().unwrap_or(*largest); @@ -575,7 +478,7 @@ impl PointBound { .expect("PT values in the nonwrapping domain") .min(self.own_order_payoff(bonus, max_cap)) } - _ => ((bonus + 10000) * self.multiplier / 10000) as i128, + _ => crate::search::reward::points_payoff(bonus, self.multiplier), } } @@ -614,7 +517,7 @@ impl PointBound { target: (i64, i64, i64), support: (i32, i32), ) -> Result { - use crate::search::deck_payoff::{card_bonus, point_product}; + use crate::search::bonus::{card_bonus, point_product}; use ournotes_sim::event::{self, EventCard, EventResultRoute}; let (event_id, resource_type, resource_id) = target; @@ -661,7 +564,7 @@ impl PointBound { ) }) .collect::, _>>()?; - let maximum_bonus = crate::search::team_power::largest_team_bonus( + let maximum_bonus = crate::search::bonus::largest_team_bonus( domain.members().iter().map(|&m| (pool.members[m].character_id, member[m])), &snap, ); @@ -707,7 +610,9 @@ impl PointBound { let q = context.event_request(pool.master, input, event_id)?; let route = point_route::PointRoute::compile(pool.master, &q, event_id, challenge_points)?; let nonnegative_scores = nonnegative_scores - || negative_scores_unpaid(pool, context, input, |rank| route.value_at_rank(pool.master, rank).is_ok()); + || crate::search::caps::negative_scores_unpaid(pool, context, input, |rank| { + route.value_at_rank(pool.master, rank).is_ok() + }); if !nonnegative_scores && route.value_at_rank(pool.master, event::RANK_NONE).is_err() { return Err(unavailable("signed score domain requires a defined NONE payoff")); } @@ -799,7 +704,7 @@ impl PointBound { if !(0..=i32::MAX as i64).contains(&rate) || values.iter().any(|v| !(0..=i32::MAX as i64).contains(v)) { return Err(unavailable("PT rate/value outside nonwrapping domain")); } - let maximum_bonus = crate::search::team_power::largest_team_bonus( + let maximum_bonus = crate::search::bonus::largest_team_bonus( domain.members().iter().map(|&m| (pool.members[m].character_id, member[m])), &snap, ); @@ -829,32 +734,6 @@ impl PointBound { } } -/// Whether no negative local score has a native result: the local score -1 has no rank (a solo score below every -/// threshold is a game error), or its room total takes NONE and `paid` holds for no NONE reward. Ranks walk the -/// thresholds a score or total reaches, so every smaller score takes no higher rank, and NONE is the least. Settlement -/// then reports a Game error for every negative score, and every team with a result scores at least zero in every -/// play, whatever its factor margin. False when the request maps no local score to a total. -pub(super) fn negative_scores_unpaid( - pool: &Pool, - context: &ournotes_sim::scenario::ResolvedContext, - input: &EventPayoffInput, - paid: impl Fn(i64) -> bool, -) -> bool { - use ournotes_sim::event; - use ournotes_sim::scenario::Scenario; - let Some(music) = pool.master.live_music(context.resolved.live_music_id) else { return false }; - let group = music.live_score_rank_group; - if !matches!(context.scenario, Scenario::Battle(_) | Scenario::Arena(_)) { - return event::score_rank(pool.master, group, -1).is_err(); - } - input.multiplayer_score_policy.as_ref().and_then(|policy| policy.total_and_count(-1).ok()).is_some_and( - |(total, players)| { - event::battle_score_rank(pool.master, group, i64::from(total), players) == event::RANK_NONE - && !paid(event::RANK_NONE) - }, - ) -} - pub(super) fn signed_network_reward_values( master: &ournotes_sim::master::Master, route: &point_route::PointRoute, diff --git a/crates/ournotes-search/src/search/joint/points.rs b/crates/ournotes-search/src/search/joint/points.rs index d84790a..ffb6335 100644 --- a/crates/ournotes-search/src/search/joint/points.rs +++ b/crates/ournotes-search/src/search/joint/points.rs @@ -6,13 +6,13 @@ impl JointBounds { /// The per-order payoff steps of a score target or of points stepped by the local score, with this envelope's /// global score cap (`payoff_cap_from` with every gain at the global coefficient); None for score payoffs, capped /// scores, multiplayer rewards without score tiers and combined metrics. - pub(crate) fn score_steps(&self) -> Option { + pub(crate) fn score_steps(&self) -> Option { self.steps_of(self.points.as_ref().filter(|pt| !pt.is_combined())?) } /// The steps of every term of a combined metric with the term's weight and event bonuses; None when a term has /// no steps. - pub(crate) fn term_steps(&self) -> Option>> { + pub(crate) fn term_steps(&self) -> Option>> { let pt = self.points.as_ref()?; std::iter::once((pt.weight, pt)) .chain(pt.others.iter().map(|(weight, _, part)| (*weight, part))) @@ -21,8 +21,8 @@ impl JointBounds { } /// The steps of one term's own payoff; None for ranking values ([`PointBound::ranked`]), which step nowhere. - fn steps_of(&self, pt: &PointBound) -> Option { - use crate::search::deck_payoff::Step; + fn steps_of(&self, pt: &PointBound) -> Option { + use crate::search::curve::Step; if pt.ranked { return None; } @@ -43,7 +43,7 @@ impl JointBounds { .collect() } }; - Some(crate::search::deck_payoff::ScoreSteps::new(self.global, self.eps, steps)) + Some(crate::search::curve::ScoreSteps::new(self.global, self.eps, steps)) } /// Every deck containing member m earns at most this many PT in every performance order: under each term of the diff --git a/crates/ournotes-search/src/search/mod.rs b/crates/ournotes-search/src/search/mod.rs index 2764f81..c0a7639 100644 --- a/crates/ournotes-search/src/search/mod.rs +++ b/crates/ournotes-search/src/search/mod.rs @@ -21,7 +21,10 @@ pub use physical::{ StepBudget, evaluate_declared_context, score_summary, solve_physical, solve_physical_with_aggregation, }; +pub(crate) mod bonus; pub(crate) mod budget; +pub(crate) mod caps; +pub(crate) mod curve; pub(crate) mod deck_payoff; #[cfg(feature = "search-diagnostics")] pub mod diagnostics; @@ -30,6 +33,8 @@ pub(crate) mod levels; mod live; mod matching; mod power; +pub(crate) mod reward; +mod slots; mod snaps; mod tables; pub(crate) mod team_power; diff --git a/crates/ournotes-search/src/search/physical.rs b/crates/ournotes-search/src/search/physical.rs index e4efbf9..5c01af5 100644 --- a/crates/ournotes-search/src/search/physical.rs +++ b/crates/ournotes-search/src/search/physical.rs @@ -50,10 +50,10 @@ mod team_power_search; mod team_scores; #[path = "warm.rs"] mod warm; +pub(crate) use crate::search::reward::{arithmetic, payoff_of}; use engine::{Engine, Offered}; use entry::{Entry, compare}; use enumeration::{ProposalRandom, members_rec, random_deck}; -pub(crate) use evaluation::{arithmetic, payoff_of}; pub use evaluation::{evaluate_declared_context, score_summary}; use joint_search::{joint_rec, joint_regimes, joint_root_upper, node_upper, ordered_root, traverse}; pub(crate) use progress::ProgressHook; diff --git a/crates/ournotes-search/src/search/physical/evaluation.rs b/crates/ournotes-search/src/search/physical/evaluation.rs index 95debc2..30bbc19 100644 --- a/crates/ournotes-search/src/search/physical/evaluation.rs +++ b/crates/ournotes-search/src/search/physical/evaluation.rs @@ -1,83 +1,7 @@ //! Payoffs and score summaries of evaluated decks, and the evaluation of a declared context. use super::*; - -/// The payoff of one outcome of a deck under the metric. -#[allow(clippy::too_many_arguments)] -pub(crate) fn payoff_of( - pool: &Pool, - request: &SearchRequest, - metric: &Metric, - event_input: Option<&EventPayoffInput>, - p: &PhysicalDeck, - score: i32, - power: i32, - final_life: Option, -) -> Result { - match *metric { - Metric::Power => Ok(power as i128), - Metric::Score => Ok(score as i128), - Metric::ScoreAtLeast { threshold } => Ok(i128::from(score >= threshold)), - Metric::CappedScore { threshold } => Ok(score.min(threshold) as i128), - Metric::ScoreAndLifeAtLeast { threshold, min_final_life } => Ok(i128::from( - score >= threshold - && final_life.ok_or_else(|| Error::Input("terminal life requires played Live".into()))? - >= min_final_life, - )), - Metric::ClientEventPoints { event_id } => Ok(request - .objective - .context() - .expect("validated context") - .preview_event_points(pool, &p.as_deck(), event_input.expect("validated event input"), event_id, score)? - .points_for(event_id) as i128), - Metric::ClientChallengePoints { event_id } => Ok(request - .objective - .context() - .expect("validated context") - .preview_event_points(pool, &p.as_deck(), event_input.expect("validated event input"), event_id, score)? - .challenge_points_for(event_id) as i128), - Metric::RankedEventItems { event_id, resource_type, resource_id } => { - let items = request.objective.context().expect("validated context").preview_event_items( - pool, - &p.as_deck(), - event_input.expect("validated event input"), - event_id, - score, - )?; - ournotes_sim::scenario::item_payoff(&items, event_id, resource_type, resource_id) - } - Metric::Combined { ref levels } => { - // The folded payoff (`levels.rs`): from the last level up, each level's weighted sum times its - // multiplier. The levels after the first stay nonnegative and within their caps. - let mut folded = 0i128; - for (at, level) in levels.iter().enumerate().rev() { - let own = level.terms.iter().try_fold(0i128, |sum, term| { - payoff_of(pool, request, &term.metric, event_input, p, score, power, final_life)? - .checked_mul(i128::from(term.weight)) - .and_then(|value| sum.checked_add(value)) - .ok_or_else(arithmetic) - })?; - if levels.len() > 1 && level.scale.multiplier == 0 { - return Err(Error::Input("the levels of a combined metric are folded by the search".into())); - } - folded = own - .checked_mul(level.scale.multiplier.max(1)) - .and_then(|value| value.checked_add(folded)) - .ok_or_else(arithmetic)?; - if at > 0 && (own < 0 || folded > level.scale.cap) { - return Err(Error::Unsupported( - "a later level of the combined metric pays outside its nonnegative payoff range".into(), - )); - } - } - Ok(folded) - } - } -} - -pub(crate) fn arithmetic() -> Error { - Error::Domain("finite-law checked exact arithmetic overflow".into()) -} +use crate::search::reward::arithmetic; /// A score distribution summary preserves integer masses and does not use f64 ranks. /// Quantile p is the smallest score with cumulative mass >= ceil(p * total mass). diff --git a/crates/ournotes-search/src/search/reward.rs b/crates/ournotes-search/src/search/reward.rs new file mode 100644 index 0000000..10381f2 --- /dev/null +++ b/crates/ournotes-search/src/search/reward.rs @@ -0,0 +1,100 @@ +//! The payoff of one terminal outcome of a deck, and the arithmetic that must fit for it to be exact. +use super::expectation::PhysicalDeck; +use super::{Pool, SearchRequest}; +use crate::types::Metric; +use ournotes_sim::Error; +use ournotes_sim::scenario::EventPayoffInput; + +/// The payoff of one outcome of a deck under the metric. +#[allow(clippy::too_many_arguments)] +pub(crate) fn payoff_of( + pool: &Pool, + request: &SearchRequest, + metric: &Metric, + event_input: Option<&EventPayoffInput>, + p: &PhysicalDeck, + score: i32, + power: i32, + final_life: Option, +) -> Result { + match *metric { + Metric::Power => Ok(power as i128), + Metric::Score + | Metric::ScoreAtLeast { .. } + | Metric::CappedScore { .. } + | Metric::ScoreAndLifeAtLeast { .. } => score_payoff(metric, score, final_life), + Metric::ClientEventPoints { event_id } => Ok(request + .objective + .context() + .expect("validated context") + .preview_event_points(pool, &p.as_deck(), event_input.expect("validated event input"), event_id, score)? + .points_for(event_id) as i128), + Metric::ClientChallengePoints { event_id } => Ok(request + .objective + .context() + .expect("validated context") + .preview_event_points(pool, &p.as_deck(), event_input.expect("validated event input"), event_id, score)? + .challenge_points_for(event_id) as i128), + Metric::RankedEventItems { event_id, resource_type, resource_id } => { + let items = request.objective.context().expect("validated context").preview_event_items( + pool, + &p.as_deck(), + event_input.expect("validated event input"), + event_id, + score, + )?; + ournotes_sim::scenario::item_payoff(&items, event_id, resource_type, resource_id) + } + Metric::Combined { ref levels } => { + // The folded payoff (`levels.rs`): from the last level up, each level's weighted sum times its + // multiplier. The levels after the first stay nonnegative and within their caps. + let mut folded = 0i128; + for (at, level) in levels.iter().enumerate().rev() { + let own = level.terms.iter().try_fold(0i128, |sum, term| { + payoff_of(pool, request, &term.metric, event_input, p, score, power, final_life)? + .checked_mul(i128::from(term.weight)) + .and_then(|value| sum.checked_add(value)) + .ok_or_else(arithmetic) + })?; + if levels.len() > 1 && level.scale.multiplier == 0 { + return Err(Error::Input("the levels of a combined metric are folded by the search".into())); + } + folded = own + .checked_mul(level.scale.multiplier.max(1)) + .and_then(|value| value.checked_add(folded)) + .ok_or_else(arithmetic)?; + if at > 0 && (own < 0 || folded > level.scale.cap) { + return Err(Error::Unsupported( + "a later level of the combined metric pays outside its nonnegative payoff range".into(), + )); + } + } + Ok(folded) + } + } +} + +/// The payoff of a score, capped score or score target at one terminal score. +pub(crate) fn score_payoff(metric: &Metric, score: i32, final_life: Option) -> Result { + match *metric { + Metric::Score => Ok(score as i128), + Metric::ScoreAtLeast { threshold } => Ok(i128::from(score >= threshold)), + Metric::CappedScore { threshold } => Ok(score.min(threshold) as i128), + Metric::ScoreAndLifeAtLeast { threshold, min_final_life } => Ok(i128::from( + score >= threshold + && final_life.ok_or_else(|| Error::Input("terminal life requires played Live".into()))? + >= min_final_life, + )), + _ => Err(Error::Unsupported("not a score payoff".into())), + } +} + +/// The points a step multiplier pays at one event bonus, `(bonus + 10000) * multiplier / 10000`, in the same order +/// a played Live pays it. Callers certify that the native intermediates stay inside the supported range. +pub(crate) fn points_payoff(bonus: i64, multiplier: i64) -> i128 { + i128::from(multiplier) * i128::from(bonus + 10000) / 10000 +} + +pub(crate) fn arithmetic() -> Error { + Error::Domain("finite-law checked exact arithmetic overflow".into()) +} diff --git a/crates/ournotes-search/src/search/slots.rs b/crates/ournotes-search/src/search/slots.rs new file mode 100644 index 0000000..508f484 --- /dev/null +++ b/crates/ournotes-search/src/search/slots.rs @@ -0,0 +1,4 @@ +//! The physical slots of a team in search order: the leader first, then the four other members. + +/// Leader first, then the other physical slots. Performance order is never a decision. +pub(crate) const SLOTS: [usize; 5] = [2, 0, 1, 3, 4]; diff --git a/crates/ournotes-search/src/search/team_power.rs b/crates/ournotes-search/src/search/team_power.rs index f5ceee7..14ab533 100644 --- a/crates/ournotes-search/src/search/team_power.rs +++ b/crates/ournotes-search/src/search/team_power.rs @@ -1,11 +1,13 @@ //! Native power and monotone Skip-score bounds over leader/member-Snap teams. //! No event payoff, Skip result-rank parameter, or played-live setup is needed. -use super::{Objective, Pool, SearchRequest, budget::SearchBudget, expectation::PhysicalDeck, tables::Tables}; +use super::{ + Objective, Pool, SearchRequest, bonus::sum_rows, budget::SearchBudget, expectation::PhysicalDeck, tables::Tables, +}; use crate::{clock::Instant, domain::CandidateDomain, types::Metric}; use ournotes_sim::{Error, live::skip::SkipEvaluator}; use std::collections::{HashMap, HashSet}; -pub(crate) const SLOTS: [usize; 5] = [2, 0, 1, 3, 4]; +pub(crate) use super::slots::SLOTS; /// Snap columns up to which [`TeamPowerBounds::refined_upper`] also assigns the Snap terms. The matching work grows /// with the column count, so a wider pool keeps the cheaper column relaxation. @@ -57,29 +59,6 @@ pub(crate) fn candidates( Some((available, required.iter().map(|&m| pool.members[m].character_id).collect())) } -/// The largest event bonus of any team of five `members` (character, bonus) and up to five of the `snap` bonuses: -/// the five largest per-character maxima of the member bonuses plus the five largest Snap bonuses. A team's members -/// have distinct characters and its Snaps are distinct, so no team sums more. Every bonus is nonnegative. -pub(crate) fn largest_team_bonus(members: impl IntoIterator, snap: &[i64]) -> i64 { - let mut rows = HashMap::::new(); - for (character, bonus) in members { - let best = rows.entry(character).or_insert(0); - *best = (*best).max(bonus); - } - let top = |mut values: Vec| { - values.sort_unstable_by(|a, b| b.cmp(a)); - values.into_iter().take(5).sum::() - }; - top(rows.into_values().collect()) + top(snap.to_vec()) -} - -pub(crate) fn sum_rows(rows: &HashMap, required: &HashSet, count: usize) -> i64 { - let fixed: i64 = required.iter().map(|c| rows[c]).sum(); - let mut optional: Vec<_> = rows.iter().filter(|(c, _)| !required.contains(c)).map(|(_, &v)| v).collect(); - optional.sort_unstable_by(|a, b| b.cmp(a)); - fixed + optional.iter().take(count - required.len()).sum::() -} - pub(crate) struct TeamPowerBounds { pub(crate) members: Vec, a: Vec, @@ -440,57 +419,3 @@ impl TeamPowerBounds { seeds } } - -#[cfg(test)] -mod tests { - use super::largest_team_bonus; - - /// Every team of five distinct-character members and at most five distinct Snaps, by enumeration. - fn enumerated(members: &[(i64, i64)], snaps: &[i64]) -> Option { - let n = members.len(); - let mut best_members = None; - for mask in 0u32..1 << n { - if mask.count_ones() != 5 { - continue; - } - let chosen: Vec<_> = (0..n).filter(|&i| mask & (1 << i) != 0).map(|i| members[i]).collect(); - let mut characters: Vec<_> = chosen.iter().map(|&(c, _)| c).collect(); - characters.sort_unstable(); - characters.dedup(); - if characters.len() == 5 { - let sum = chosen.iter().map(|&(_, b)| b).sum::(); - best_members = Some(best_members.map_or(sum, |b: i64| b.max(sum))); - } - } - let mut best_snaps = 0; - for mask in 0u32..1 << snaps.len() { - if mask.count_ones() <= 5 { - let sum = (0..snaps.len()).filter(|&j| mask & (1 << j) != 0).map(|j| snaps[j]).sum::(); - best_snaps = best_snaps.max(sum); - } - } - best_members.map(|m| m + best_snaps) - } - - #[test] - fn largest_team_bonus_equals_the_best_enumerated_team() { - let mut x = 0x2545_f491_4f6c_dd1du64; - let mut next = |n: u64| { - x ^= x << 13; - x ^= x >> 7; - x ^= x << 17; - x % n - }; - let mut checked = 0; - for _ in 0..400 { - let n = 5 + next(8) as usize; - let characters = 3 + next(6) as i64; - let members: Vec<_> = (0..n).map(|_| (next(characters as u64) as i64, next(4) as i64 * 2500)).collect(); - let snaps: Vec<_> = (0..next(9)).map(|_| next(5) as i64 * 700).collect(); - let Some(expected) = enumerated(&members, &snaps) else { continue }; - assert_eq!(largest_team_bonus(members.iter().copied(), &snaps), expected, "{members:?} {snaps:?}"); - checked += 1; - } - assert!(checked > 100); - } -}