From 47cf0f2bc9d0ea7732abfaa46a12c9d1030453dc Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Sun, 18 Oct 2026 20:28:22 +0800 Subject: [PATCH 1/3] perf(search): bound combined-metric terms by each team's own bonuses A composition's bound under a combined metric now caps every term after the first at the event bonus its members and Snaps can reach under that term, instead of the term's largest bonus of any team. Searches that rank teams by challenge points or score first, with event points or items as later terms, prove their optimum with far fewer nodes. The joint search also opens leaders best-first by the bound of their subtree, and when a combined metric's first level reads no event bonus it orders member and Snap pairs by power first, so a strong incumbent arrives earlier. The traversed domain and the results are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../ournotes-search/src/search/composition.rs | 34 ++-- .../src/search/joint/compile.rs | 15 +- .../src/search/joint/composition.rs | 145 +++++++++++++++++- 3 files changed, 174 insertions(+), 20 deletions(-) diff --git a/crates/ournotes-search/src/search/composition.rs b/crates/ournotes-search/src/search/composition.rs index 8c34c64d..38e1efb2 100644 --- a/crates/ournotes-search/src/search/composition.rs +++ b/crates/ournotes-search/src/search/composition.rs @@ -26,20 +26,32 @@ pub(super) fn solve( .iter() .any(|&m| m != leader && pool.members[m].character_id == pool.members[leader].character_id) }; - // The bound of every leader's subtree, then their suffix maxima: the bound of the leaders still open. - let mut open = vec![None; candidates.len() + 1]; - for (index, &leader) in candidates.iter().enumerate().rev() { - open[index] = open[index + 1]; - if legal(leader) { + // Leaders open best-first by their subtree bound (the member order breaks ties); each leader's subtree takes + // every member as a nonleader, so the visiting order of leaders leaves the traversed domain unchanged. + let mut caps = Vec::with_capacity(candidates.len()); + for &leader in &candidates { + caps.push(if legal(leader) { p.members[2] = leader; - let cap = members_upper(&p, 1, domain, bounds, orders, e)?; - open[index] = Some(open[index].map_or(cap, |u: i128| u.max(cap))); - } + Some(members_upper(&p, 1, domain, bounds, orders, e)?) + } else { + None + }); + } + let mut ranked: Vec = (0..candidates.len()).collect(); + ranked.sort_by_key(|&i| std::cmp::Reverse(caps[i])); + let leaders: Vec = ranked.iter().map(|&i| candidates[i]).collect(); + // The suffix maxima of the leaders' bounds: the bound of the leaders still open. + let mut open: Vec> = vec![None; leaders.len() + 1]; + for (index, &i) in ranked.iter().enumerate().rev() { + open[index] = match (open[index + 1], caps[i]) { + (Some(u), Some(cap)) => Some(u.max(cap)), + (u, cap) => u.or(cap), + }; } - for (index, &leader) in candidates.iter().enumerate() { + for (index, &leader) in leaders.iter().enumerate() { e.note_open(open[index]); if e.expired() { - unexplored_members(0, index, &mut p, &candidates, domain, bounds, orders, e)?; + unexplored_members(0, index, &mut p, &leaders, domain, bounds, orders, e)?; return Ok(false); } if !legal(leader) { @@ -48,7 +60,7 @@ pub(super) fn solve( p.members[2] = leader; e.rec.frontier.set(0, index, candidates.len()); if !members(1, 0, &mut p, &candidates, domain, bounds, orders, e)? { - unexplored_members(0, index + 1, &mut p, &candidates, domain, bounds, orders, e)?; + unexplored_members(0, index + 1, &mut p, &leaders, domain, bounds, orders, e)?; return Ok(false); } } diff --git a/crates/ournotes-search/src/search/joint/compile.rs b/crates/ournotes-search/src/search/joint/compile.rs index b7448f80..b5213196 100644 --- a/crates/ournotes-search/src/search/joint/compile.rs +++ b/crates/ournotes-search/src/search/joint/compile.rs @@ -227,12 +227,18 @@ impl JointBounds { // Per-note caps of complete teams: one per performance order, and the cutoff tables of the simulations. let fine = Some(envelope.into_joint_fine()); // This estimate only orders branches. It never removes a pair or claims a native order. + // A combined metric whose first level reads no event bonus is decided by the score first: the term that reads + // the bonuses only orders the pairs whose power is equal. + let power_first = points.as_ref().is_some_and(|pt| pt.is_combined() && pt.weight < metric.primary_unit()); let priority = |(m, s): (usize, usize)| { let power = t.a[m] + t.lead.iter().map(|r| r[m]).max().unwrap_or(0) + if s == 0 { 0 } else { t.w[m][s - 1] }; match &points { - Some(pt) => (pt.member[m] + if s == 0 { 0 } else { pt.snap[s - 1] }) as f64 * 1e12 + power as f64, - None => power as f64 * (a0 / 5.0 + gains[m][s].iter().copied().fold(0.0, f64::max)), + Some(pt) => { + let bonus = (pt.member[m] + if s == 0 { 0 } else { pt.snap[s - 1] }) as f64; + if power_first { (power as f64, bonus) } else { (bonus * 1e12 + power as f64, 0.0) } + } + None => (power as f64 * (a0 / 5.0 + gains[m][s].iter().copied().fold(0.0, f64::max)), 0.0), } }; // Each pair's estimate is taken once, before the sort; the stable sort and its comparison are unchanged. @@ -243,7 +249,10 @@ impl JointBounds { .map(|c| (priority(c), c)) .collect(); keyed.sort_by(|&(pa, a), &(pb, b)| { - pb.total_cmp(&pa).then_with(|| pool.members[a.0].id.cmp(&pool.members[b.0].id)).then(a.1.cmp(&b.1)) + pb.0.total_cmp(&pa.0) + .then_with(|| pb.1.total_cmp(&pa.1)) + .then_with(|| pool.members[a.0].id.cmp(&pool.members[b.0].id)) + .then(a.1.cmp(&b.1)) }); let choices: Vec<_> = keyed.into_iter().map(|(_, c)| c).collect(); let scale = diff --git a/crates/ournotes-search/src/search/joint/composition.rs b/crates/ournotes-search/src/search/joint/composition.rs index 7dd97c09..b2c3062f 100644 --- a/crates/ournotes-search/src/search/joint/composition.rs +++ b/crates/ournotes-search/src/search/joint/composition.rs @@ -1,5 +1,6 @@ //! Envelopes for member compositions before physical layouts and Snap bindings. use super::*; +use crate::types::MAX_METRIC_TERMS; #[derive(Clone, Copy, Default)] struct Envelope { @@ -8,6 +9,11 @@ struct Envelope { /// At least the spread of the gain sum (see `JointBounds::spread`). spread: f64, bonus: i64, + /// At least the event bonus under each other term of a combined metric's point bound, in the terms' order, of + /// every deck the envelope covers. Only the first `n_others` entries are read; a term past them reads its largest + /// bonus of a team. The composition tables carry none. + others: [i64; MAX_METRIC_TERMS], + n_others: u8, weighted: [f64; 3], } impl Envelope { @@ -16,6 +22,10 @@ impl Envelope { self.gain = self.gain.max(r.gain); self.spread = self.spread.max(r.spread); self.bonus = self.bonus.max(r.bonus); + self.n_others = self.n_others.max(r.n_others); + for i in 0..usize::from(self.n_others) { + self.others[i] = self.others[i].max(r.others[i]); + } for i in 0..3 { self.weighted[i] = self.weighted[i].max(r.weighted[i]); } @@ -25,6 +35,10 @@ impl Envelope { self.gain = add_up(self.gain, r.gain); self.spread = add_up(self.spread, r.spread); self.bonus += r.bonus; + self.n_others = self.n_others.max(r.n_others); + for i in 0..usize::from(self.n_others) { + self.others[i] += r.others[i]; + } for i in 0..3 { self.weighted[i] = add_up(self.weighted[i], r.weighted[i]); } @@ -39,7 +53,7 @@ pub(super) struct CompositionTables { layers: Vec<[Layer; 5]>, } fn add_remaining(total: &mut Envelope, rows: &[Envelope], take: usize) { - let top_int = |f: fn(&Envelope) -> i64| { + let top_int = |f: &dyn Fn(&Envelope) -> i64| { let mut v: Vec<_> = rows.iter().map(f).collect(); v.sort_unstable_by(|a, b| b.cmp(a)); v.into_iter().take(take).sum::() @@ -49,8 +63,13 @@ fn add_remaining(total: &mut Envelope, rows: &[Envelope], take: usize) { v.sort_by(|a, b| b.total_cmp(a)); v.into_iter().take(take).fold(0.0, add_up) }; - total.power += top_int(|r| r.power); - total.bonus += top_int(|r| r.bonus); + total.power += top_int(&|r| r.power); + total.bonus += top_int(&|r| r.bonus); + let n_others = rows.iter().map(|r| r.n_others).fold(total.n_others, u8::max); + for i in 0..usize::from(n_others) { + total.others[i] += top_int(&|r| r.others[i]); + } + total.n_others = n_others; total.gain = add_up(total.gain, top_float(&|r| r.gain)); total.spread = add_up(total.spread, top_float(&|r| r.spread)); for i in 0..3 { @@ -89,6 +108,8 @@ impl CompositionTables { gain, spread: b.spread[m][choice], bonus, + others: [0; MAX_METRIC_TERMS], + n_others: 0, weighted: b.correlation_scales.map(|r| add_up(power as f64, (r * gain).next_up())), }); } @@ -235,6 +256,9 @@ impl JointBounds { let characters: ournotes_sim::num::FxHashSet<_> = SLOTS[..member_depth].iter().map(|&s| pool.members[p.members[s]].character_id).collect(); let used: ournotes_sim::num::FxHashSet<_> = SLOTS[..snap_depth].iter().filter_map(|&s| p.snaps[s]).collect(); + // The other terms of a combined metric, in their order, whose event bonuses the envelope bounds per slot. + let other_terms: &[(i128, i64, PointBound)] = self.points.as_ref().map_or(&[][..], |pt| pt.others.as_slice()); + let n_others = other_terms.len().min(MAX_METRIC_TERMS) as u8; let row = |m: usize, choice: usize, slot: Option| { let power = self.a[m] + self.lead[profile][m] + if choice == 0 { 0 } else { self.w[m][choice - 1] }; let gain = if let Some(slot) = slot { @@ -244,11 +268,15 @@ impl JointBounds { }; let bonus = self.points.as_ref().map_or(0, |pt| pt.member[m] + if choice == 0 { 0 } else { pt.snap[choice - 1] }); + let others = + std::array::from_fn(|i| other_terms.get(i).map_or(0, |(_, _, part)| part.pair_bonus(m, choice))); Envelope { power, gain, spread: self.spread[m][choice], bonus, + others, + n_others, weighted: self.correlation_scales.map(|r| add_up(power as f64, (r * gain).next_up())), } }; @@ -283,6 +311,12 @@ impl JointBounds { } } add_remaining(&mut total, &remaining.into_values().collect::>(), 5 - member_depth); + // A team's event bonus under a term is at most the term's largest bonus of a team. + if let Some(pt) = &self.points { + for (i, (_, largest, _)) in pt.others.iter().enumerate().take(MAX_METRIC_TERMS) { + total.others[i] = total.others[i].min(*largest); + } + } self.composition_cap(total, power_cap) } fn composition_cap(&self, total: Envelope, power_cap: Option) -> (i128, i64) { @@ -297,9 +331,14 @@ impl JointBounds { ); } ( - self.points - .as_ref() - .map_or(score, |pt| pt.mean_payoff(total.bonus, score, cap(add_up(total.gain, total.spread)))), + self.points.as_ref().map_or(score, |pt| { + pt.mean_payoff_with( + total.bonus, + &total.others[..usize::from(total.n_others)], + score, + cap(add_up(total.gain, total.spread)), + ) + }), power, ) } @@ -439,3 +478,97 @@ impl JointBounds { choices } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Bounds whose score caps are zero, holding `points` as the point bound. + fn bounds(points: PointBound) -> JointBounds { + JointBounds { + a: vec![0; 5], + w: vec![Vec::new(); 5], + lead: vec![vec![0; 5]], + profile: vec![0; 5], + gains: vec![vec![[0.0; 5]; 1]; 5], + order_gains: vec![vec![[0.0; 5]; 1]; 5], + spread: vec![vec![0.0; 1]; 5], + spread_top: [0.0; 6], + column: [0.0; 5], + a0: 0.0, + global: 1.0, + eps: 0.0, + points: Some(points), + min_final_life: None, + fine: None, + correlation_scales: [1.0; 3], + tails: None, + composition: None, + modules: Vec::new(), + gekisou: false, + class_search: false, + class_resource_caps: false, + prefix_resource: None, + prefix_character: None, + rules: None, + relax_tables: None, + forced_tables: Default::default(), + step_pairs: None, + choices: Vec::new(), + carrier_levels: None, + carrier_split: None, + laws: Vec::new(), + law_clusters: None, + part: None, + order_potentials: None, + order_offset: 0.0, + root_keys: None, + } + } + + /// A bounded payoff of weight 2 plus one other term of weight 3 and largest bonus 1500. + fn combined() -> PointBound { + let mut own = PointBound::plain(vec![0; 5], Vec::new(), 100, None, None, None); + own.weight = 2; + own.others = + vec![(3, 1_500, PointBound::plain(vec![100, 200, 300, 400, 500], Vec::new(), 100, None, None, None))]; + own + } + + #[test] + fn a_fixed_composition_caps_each_other_term_at_the_teams_own_bonus() { + let pt = combined(); + let bound = bounds(pt.clone()); + let (own, other) = (700, 900); + let mut total = Envelope { bonus: own, n_others: 1, ..Envelope::default() }; + total.others[0] = other; + let (tight, _) = bound.composition_cap(total, None); + let (loose, _) = bound.composition_cap(Envelope { bonus: own, ..Envelope::default() }, None); + assert_eq!(tight, pt.mean_payoff_with(own, &[other], 0, 0)); + assert_eq!(loose, pt.mean_payoff(own, 0, 0)); + assert!(tight < loose); + } + + #[test] + fn other_term_bonuses_merge_sum_and_take_the_top_slots() { + let mut a = Envelope { n_others: 2, ..Envelope::default() }; + a.others[0] = 7; + a.others[1] = 5; + let mut b = Envelope { n_others: 2, ..Envelope::default() }; + b.others[0] = 9; + b.others[1] = 2; + a.merge(b); + let mut total = Envelope::default(); + total.add(a); + assert_eq!(&total.others[..2], &[9, 5]); + let mut rows = [Envelope::default(); 3]; + for (row, values) in rows.iter_mut().zip([[1, 5], [4, 0], [2, 3]]) { + row.n_others = 2; + row.others[0] = values[0]; + row.others[1] = values[1]; + } + add_remaining(&mut total, &rows, 2); + assert_eq!(&total.others[..2], &[9 + 4 + 2, 5 + 5 + 3]); + assert_eq!(total.n_others, 2); + } +} From 21ebd74d65e283bb745d9a7916effd8e236286ca Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Sun, 18 Oct 2026 20:37:45 +0800 Subject: [PATCH 2/3] style(search): keep the plain mean payoff for tests and diagnostics The search reads every bound through mean_payoff_with; the plain form remains for the tests and the diagnostics build. Co-Authored-By: Claude Opus 5.5 --- crates/ournotes-search/src/search/joint/point_bound.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ournotes-search/src/search/joint/point_bound.rs b/crates/ournotes-search/src/search/joint/point_bound.rs index c8a01929..7399b278 100644 --- a/crates/ournotes-search/src/search/joint/point_bound.rs +++ b/crates/ournotes-search/src/search/joint/point_bound.rs @@ -528,6 +528,7 @@ impl PointBound { /// is constant. A combined payoff sums the bounds of its terms, each a bound of the term's mean. A /// [`PointBound::ranked`] bound reads expected scores as scores and adds the mean expected score's cap, so it /// bounds the mean ranking value. + #[cfg(any(test, feature = "search-diagnostics"))] pub(super) fn mean_payoff(&self, bonus: i64, score_cap: i128, max_cap: i128) -> i128 { self.rank(self.mean_reward(bonus, score_cap, max_cap), score_cap) } From c619f24f71890dbd184f6429e2553ad0cbb65af5 Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Sun, 18 Oct 2026 20:59:03 +0800 Subject: [PATCH 3/3] perf(search): keep the leaders in member order Opening leaders best-first by their subtree bound visits no fewer nodes than the member order on combined metrics, so the joint search keeps the member order. Co-Authored-By: Claude Opus 5.5 --- .../ournotes-search/src/search/composition.rs | 34 ++++++------------- 1 file changed, 11 insertions(+), 23 deletions(-) diff --git a/crates/ournotes-search/src/search/composition.rs b/crates/ournotes-search/src/search/composition.rs index 38e1efb2..8c34c64d 100644 --- a/crates/ournotes-search/src/search/composition.rs +++ b/crates/ournotes-search/src/search/composition.rs @@ -26,32 +26,20 @@ pub(super) fn solve( .iter() .any(|&m| m != leader && pool.members[m].character_id == pool.members[leader].character_id) }; - // Leaders open best-first by their subtree bound (the member order breaks ties); each leader's subtree takes - // every member as a nonleader, so the visiting order of leaders leaves the traversed domain unchanged. - let mut caps = Vec::with_capacity(candidates.len()); - for &leader in &candidates { - caps.push(if legal(leader) { + // The bound of every leader's subtree, then their suffix maxima: the bound of the leaders still open. + let mut open = vec![None; candidates.len() + 1]; + for (index, &leader) in candidates.iter().enumerate().rev() { + open[index] = open[index + 1]; + if legal(leader) { p.members[2] = leader; - Some(members_upper(&p, 1, domain, bounds, orders, e)?) - } else { - None - }); - } - let mut ranked: Vec = (0..candidates.len()).collect(); - ranked.sort_by_key(|&i| std::cmp::Reverse(caps[i])); - let leaders: Vec = ranked.iter().map(|&i| candidates[i]).collect(); - // The suffix maxima of the leaders' bounds: the bound of the leaders still open. - let mut open: Vec> = vec![None; leaders.len() + 1]; - for (index, &i) in ranked.iter().enumerate().rev() { - open[index] = match (open[index + 1], caps[i]) { - (Some(u), Some(cap)) => Some(u.max(cap)), - (u, cap) => u.or(cap), - }; + let cap = members_upper(&p, 1, domain, bounds, orders, e)?; + open[index] = Some(open[index].map_or(cap, |u: i128| u.max(cap))); + } } - for (index, &leader) in leaders.iter().enumerate() { + for (index, &leader) in candidates.iter().enumerate() { e.note_open(open[index]); if e.expired() { - unexplored_members(0, index, &mut p, &leaders, domain, bounds, orders, e)?; + unexplored_members(0, index, &mut p, &candidates, domain, bounds, orders, e)?; return Ok(false); } if !legal(leader) { @@ -60,7 +48,7 @@ pub(super) fn solve( p.members[2] = leader; e.rec.frontier.set(0, index, candidates.len()); if !members(1, 0, &mut p, &candidates, domain, bounds, orders, e)? { - unexplored_members(0, index + 1, &mut p, &leaders, domain, bounds, orders, e)?; + unexplored_members(0, index + 1, &mut p, &candidates, domain, bounds, orders, e)?; return Ok(false); } }