From 85637a68d30b60ef1f9f2ad9d4f8493841692102 Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Mon, 19 Oct 2026 08:20:38 +0800 Subject: [PATCH 1/6] refactor(search): share the event-bonus arithmetic of the payoff bounds Move the audited native products (point_product, challenge_product), the card-bonus sum (card_bonus) and the two team-bonus maxima (largest_team_bonus, sum_rows) into search::bonus, together with their unit tests. The payoff bounds of the joint traversal and of the deck payoff now read the same functions instead of one reaching into the other. Each function keeps its body, error messages and visibility. Co-Authored-By: Claude Opus 5.5 --- crates/ournotes-search/src/search/bonus.rs | 168 ++++++++++++++++++ .../ournotes-search/src/search/deck_payoff.rs | 80 +-------- .../src/search/joint/point_bound.rs | 12 +- crates/ournotes-search/src/search/mod.rs | 1 + .../ournotes-search/src/search/team_power.rs | 81 +-------- 5 files changed, 180 insertions(+), 162 deletions(-) create mode 100644 crates/ournotes-search/src/search/bonus.rs diff --git a/crates/ournotes-search/src/search/bonus.rs b/crates/ournotes-search/src/search/bonus.rs new file mode 100644 index 0000000..a2d80e7 --- /dev/null +++ b/crates/ournotes-search/src/search/bonus.rs @@ -0,0 +1,168 @@ +//! 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}; + +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/deck_payoff.rs b/crates/ournotes-search/src/search/deck_payoff.rs index 6a3afa7..d7a2b0d 100644 --- a/crates/ournotes-search/src/search/deck_payoff.rs +++ b/crates/ournotes-search/src/search/deck_payoff.rs @@ -19,15 +19,15 @@ //! 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::{domain::CandidateDomain, types::Metric}; use ournotes_sim::{ Error, event::{self, EventCard, EventResultRoute}, live::skip::SkipEvaluator, - master::EventEffectRow, scenario::EventPayoffInput, }; use std::collections::HashMap; @@ -36,50 +36,6 @@ 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 { @@ -647,38 +603,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 { diff --git a/crates/ournotes-search/src/search/joint/point_bound.rs b/crates/ournotes-search/src/search/joint/point_bound.rs index 7399b27..4087942 100644 --- a/crates/ournotes-search/src/search/joint/point_bound.rs +++ b/crates/ournotes-search/src/search/joint/point_bound.rs @@ -71,7 +71,7 @@ fn event_cap( metric: &Metric, input: Option<&EventPayoffInput>, ) -> Option { - 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 = metric.event()?; @@ -134,7 +134,7 @@ fn event_cap( .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) + crate::search::bonus::largest_team_bonus(members, &snaps) } None => 0, }; @@ -276,7 +276,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, ); @@ -614,7 +614,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 +661,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, ); @@ -799,7 +799,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, ); diff --git a/crates/ournotes-search/src/search/mod.rs b/crates/ournotes-search/src/search/mod.rs index 2764f81..9e56048 100644 --- a/crates/ournotes-search/src/search/mod.rs +++ b/crates/ournotes-search/src/search/mod.rs @@ -21,6 +21,7 @@ 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 deck_payoff; #[cfg(feature = "search-diagnostics")] diff --git a/crates/ournotes-search/src/search/team_power.rs b/crates/ournotes-search/src/search/team_power.rs index f5ceee7..7369495 100644 --- a/crates/ournotes-search/src/search/team_power.rs +++ b/crates/ournotes-search/src/search/team_power.rs @@ -1,6 +1,8 @@ //! 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}; @@ -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); - } -} From 3d6ded96d102bc2af0ce8f0f04d8dff34998fd1e Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Mon, 19 Oct 2026 08:21:28 +0800 Subject: [PATCH 2/6] refactor(search): give the physical slot order one definition The joint traversal and the power bound each declared their own SLOTS table. Keep one constant in search::slots and re-export it from both modules, so every path that names SLOTS keeps working and the slot order has a single source. Co-Authored-By: Claude Opus 5.5 --- crates/ournotes-search/src/search/joint.rs | 3 +-- crates/ournotes-search/src/search/mod.rs | 1 + crates/ournotes-search/src/search/slots.rs | 4 ++++ crates/ournotes-search/src/search/team_power.rs | 2 +- 4 files changed, 7 insertions(+), 3 deletions(-) create mode 100644 crates/ournotes-search/src/search/slots.rs diff --git a/crates/ournotes-search/src/search/joint.rs b/crates/ournotes-search/src/search/joint.rs index 51bcb9f..5c8a174 100644 --- a/crates/ournotes-search/src/search/joint.rs +++ b/crates/ournotes-search/src/search/joint.rs @@ -58,8 +58,7 @@ 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/mod.rs b/crates/ournotes-search/src/search/mod.rs index 9e56048..8816326 100644 --- a/crates/ournotes-search/src/search/mod.rs +++ b/crates/ournotes-search/src/search/mod.rs @@ -31,6 +31,7 @@ pub(crate) mod levels; mod live; mod matching; mod power; +mod slots; mod snaps; mod tables; pub(crate) mod team_power; 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 7369495..14ab533 100644 --- a/crates/ournotes-search/src/search/team_power.rs +++ b/crates/ournotes-search/src/search/team_power.rs @@ -7,7 +7,7 @@ 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. From 6d645d7df0873987c68f5b1745f304a2ebaa767f Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Mon, 19 Oct 2026 08:22:38 +0800 Subject: [PATCH 3/6] refactor(search): separate metric payoff caps from the point bound Move the largest term payoff of a metric (term_cap), the largest reward row of an event table and the negative-score check into search::caps. The combined-level folding, the deck payoff and the joint compilation read them without depending on the point bound, which keeps only the per-order bound it computes. Values, error text and visibility are unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/ournotes-search/src/search/caps.rs | 130 ++++++++++++++++++ crates/ournotes-search/src/search/joint.rs | 2 +- .../src/search/joint/compile.rs | 2 +- .../src/search/joint/point_bound.rs | 127 +---------------- crates/ournotes-search/src/search/mod.rs | 1 + 5 files changed, 136 insertions(+), 126 deletions(-) create mode 100644 crates/ournotes-search/src/search/caps.rs 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/joint.rs b/crates/ournotes-search/src/search/joint.rs index 5c8a174..d4702cb 100644 --- a/crates/ournotes-search/src/search/joint.rs +++ b/crates/ournotes-search/src/search/joint.rs @@ -46,13 +46,13 @@ 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; 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 4087942..6cd7d0d 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::bonus::{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::bonus::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( @@ -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")); } @@ -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/mod.rs b/crates/ournotes-search/src/search/mod.rs index 8816326..6b8791d 100644 --- a/crates/ournotes-search/src/search/mod.rs +++ b/crates/ournotes-search/src/search/mod.rs @@ -23,6 +23,7 @@ pub use physical::{ pub(crate) mod bonus; pub(crate) mod budget; +pub(crate) mod caps; pub(crate) mod deck_payoff; #[cfg(feature = "search-diagnostics")] pub mod diagnostics; From cef9917ec6a711b25c1747908b05141ccfd6f7c6 Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Mon, 19 Oct 2026 08:25:07 +0800 Subject: [PATCH 4/6] refactor(search): move the score-step payoff shape into its own module Step, ScoreSteps and the TermSteps alias describe how an order's payoff steps with its score, which the joint traversal and the deck payoff both consume. They move to search::curve unchanged, apart from a largest_step accessor that replaces the deck payoff's direct read of the private step list. The payoff arithmetic of each step is the same text as before. Co-Authored-By: Claude Opus 5.5 --- crates/ournotes-search/src/search/bonus.rs | 3 +- crates/ournotes-search/src/search/curve.rs | 58 +++++++++++++++++++ .../ournotes-search/src/search/deck_payoff.rs | 57 +----------------- .../src/search/joint/points.rs | 10 ++-- crates/ournotes-search/src/search/mod.rs | 1 + 5 files changed, 69 insertions(+), 60 deletions(-) create mode 100644 crates/ournotes-search/src/search/curve.rs diff --git a/crates/ournotes-search/src/search/bonus.rs b/crates/ournotes-search/src/search/bonus.rs index a2d80e7..2c69b02 100644 --- a/crates/ournotes-search/src/search/bonus.rs +++ b/crates/ournotes-search/src/search/bonus.rs @@ -8,7 +8,8 @@ use ournotes_sim::{ }; use std::collections::{HashMap, HashSet}; -fn unavailable(message: &str) -> Error { +/// 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}")) } diff --git a/crates/ournotes-search/src/search/curve.rs b/crates/ournotes-search/src/search/curve.rs new file mode 100644 index 0000000..dae6ec7 --- /dev/null +++ b/crates/ournotes-search/src/search/curve.rs @@ -0,0 +1,58 @@ +//! 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 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(i128::from(multiplier) * i128::from(bonus + 10000) / 10000), + 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 d7a2b0d..05a3ca2 100644 --- a/crates/ournotes-search/src/search/deck_payoff.rs +++ b/crates/ournotes-search/src/search/deck_payoff.rs @@ -23,6 +23,8 @@ use super::{ expectation::PhysicalDeck, 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, @@ -32,53 +34,6 @@ use ournotes_sim::{ }; use std::collections::HashMap; -fn unavailable(message: &str) -> Error { - Error::Unsupported(format!("deck payoff bound unavailable: {message}")) -} - -/// 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)] @@ -127,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(), } } } @@ -257,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. 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 6b8791d..4d21f58 100644 --- a/crates/ournotes-search/src/search/mod.rs +++ b/crates/ournotes-search/src/search/mod.rs @@ -24,6 +24,7 @@ pub use physical::{ 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; From a990e46d6695a142a8140e34ec3e0e023e3d5be6 Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Mon, 19 Oct 2026 08:26:32 +0800 Subject: [PATCH 5/6] refactor(search): make the terminal payoff its own module payoff_of and the arithmetic error it reports describe one terminal outcome, not the evaluation driver. Move them to search::reward, keep the re-export from the physical module so every caller resolves as before, and add narrow score and points entry points for the payoff bounds that read those forms without a deck. Co-Authored-By: Claude Opus 5.5 --- crates/ournotes-search/src/search/mod.rs | 1 + crates/ournotes-search/src/search/physical.rs | 2 +- .../src/search/physical/evaluation.rs | 78 +------------- crates/ournotes-search/src/search/reward.rs | 100 ++++++++++++++++++ 4 files changed, 103 insertions(+), 78 deletions(-) create mode 100644 crates/ournotes-search/src/search/reward.rs diff --git a/crates/ournotes-search/src/search/mod.rs b/crates/ournotes-search/src/search/mod.rs index 4d21f58..c0a7639 100644 --- a/crates/ournotes-search/src/search/mod.rs +++ b/crates/ournotes-search/src/search/mod.rs @@ -33,6 +33,7 @@ pub(crate) mod levels; mod live; mod matching; mod power; +pub(crate) mod reward; mod slots; mod snaps; mod tables; 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()) +} From a5d9200c2633bb702e8a9ef0b7f6734d11289c56 Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Mon, 19 Oct 2026 08:29:25 +0800 Subject: [PATCH 6/6] refactor(search): read the payoff arithmetic from one place The score-step bound, the point bound and the certified native steps each re-derived the payoff of one order. They now call search::reward: the step's Points value and the point bound's products go through points_payoff, and the certified steps read payoff_of at each cut instead of reproducing the event preview. The ordering weight of a certified rank stays in the certified search, which is a ranking lattice rather than a payoff. Co-Authored-By: Claude Opus 5.5 --- .../src/search/certified_payoff.rs | 15 ++++----------- crates/ournotes-search/src/search/curve.rs | 3 ++- crates/ournotes-search/src/search/deck_payoff.rs | 2 +- .../src/search/joint/point_bound.rs | 6 +++--- 4 files changed, 10 insertions(+), 16 deletions(-) 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 index dae6ec7..f564958 100644 --- a/crates/ournotes-search/src/search/curve.rs +++ b/crates/ournotes-search/src/search/curve.rs @@ -1,6 +1,7 @@ //! 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. @@ -40,7 +41,7 @@ impl ScoreSteps { // 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::Points(multiplier)) => Ok(points_payoff(bonus, multiplier)), Some(Step::Target(reached)) => Ok(i128::from(reached)), } } diff --git a/crates/ournotes-search/src/search/deck_payoff.rs b/crates/ournotes-search/src/search/deck_payoff.rs index 05a3ca2..7f8041a 100644 --- a/crates/ournotes-search/src/search/deck_payoff.rs +++ b/crates/ournotes-search/src/search/deck_payoff.rs @@ -646,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/point_bound.rs b/crates/ournotes-search/src/search/joint/point_bound.rs index 6cd7d0d..52f4368 100644 --- a/crates/ournotes-search/src/search/joint/point_bound.rs +++ b/crates/ournotes-search/src/search/joint/point_bound.rs @@ -152,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 @@ -223,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); @@ -478,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), } }