From c62008dee7324b160da2658da5cbfdfaec0da7d1 Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Mon, 19 Oct 2026 03:58:12 +0800 Subject: [PATCH] perf(search): assign remaining Snap terms when bounding deck payoff The power bound of a member prefix took the smaller of a per-character and a per-column relaxation, so a single Snap column could serve several slots. Add an assignment bound that keeps the remaining member terms per character but assigns the Snap terms over the five slots on distinct columns, with placed slots keeping their own member's row. It is never above the column relaxation, so it only tightens the bound, and it is used only while the Snap pool is narrow enough for the matching work to pay off. A payoff bound that only ties the K-th's payoff now re-checks the K-th's power with that assignment bound before pruning, so a tie no completion can reach falls to the canonical key. A bounded payoff (score steps) no longer re-ranks the domain after a shortfall: the first shortfall makes the retained ranking optimistic, so the joint traversal takes over and re-ranks against the incumbents already evaluated. This removes the round limit and the per-layout shortfall map. Co-Authored-By: Claude Opus 5.5 --- .../ournotes-search/src/search/deck_payoff.rs | 85 +++++++++- .../src/search/deck_payoff_search.rs | 156 ++++++++---------- .../ournotes-search/src/search/team_power.rs | 131 ++++++++++++++- .../ournotes-search/src/search/telemetry.rs | 4 +- .../tests/score_steps_bounds.rs | 6 +- .../tests/skip_points_bounds.rs | 39 +++++ docs/search.md | 56 ++++--- 7 files changed, 354 insertions(+), 123 deletions(-) diff --git a/crates/ournotes-search/src/search/deck_payoff.rs b/crates/ournotes-search/src/search/deck_payoff.rs index 1e973b70..6a3afa7d 100644 --- a/crates/ournotes-search/src/search/deck_payoff.rs +++ b/crates/ournotes-search/src/search/deck_payoff.rs @@ -8,11 +8,12 @@ //! (a declared multiplayer room ranks its total, which is nondecreasing in the local score). Every order of a deck //! scores at most its power times the joint envelope's global coefficient, so the step of that cap bounds the payoff //! by the deck's bonus and power alone ([`ScoreSteps`]); the search then ranks by this bound and evaluates the decks -//! it ranks first, re-ranking those that pay less. +//! it ranks first, handing over to the joint traversal as soon as one pays less. //! //! Members branch in `SLOTS` order; a member prefix bounds its completions by the best remaining bonuses and the -//! power bound of [`TeamPowerBounds`]. A complete member layout has one Snap-independent power constant, and its Snap -//! bindings are ranked exactly by [`DeckPayoffBounds::frontier`]. +//! power bound of [`TeamPowerBounds`], which assigns the remaining slots' Snap terms where that is tighter. A complete +//! member layout has one Snap-independent power constant, and its Snap bindings are ranked exactly by +//! [`DeckPayoffBounds::frontier`]. //! //! A common terminal upper bound can also order the domain by power and canonical identity. Its proposals require //! terminal evaluation; the upper-only mode does not assign that bound as a deck's actual payoff. @@ -477,7 +478,9 @@ impl DeckPayoffBounds { } /// Bound every completion of the members in `SLOTS[..depth]` (no Snap is placed yet): (payoff, power), or None - /// when the prefix has no legal completion. Each kind's bonus is bounded on its own. + /// when the prefix has no legal completion. Each kind's bonus is bounded on its own. The power is the smaller of + /// the column relaxation and, where it applies, an assignment of the remaining slots' Snap terms; both bound + /// every completion, so the payoff curve read at it stays a bound. pub(crate) fn upper( &self, pool: &Pool, @@ -485,11 +488,10 @@ impl DeckPayoffBounds { p: &PhysicalDeck, depth: usize, ) -> Result, Error> { - let (Some(power), Some((available, required))) = - (self.power.upper(pool, domain, p, depth), candidates(pool, domain, p, depth)) - else { + let Some((available, required)) = candidates(pool, domain, p, depth) else { return Ok(None); }; + let power = self.power.refined_upper(pool, domain, p, depth, &available, &required); let mut bonus = [0i64; KINDS]; let mut rows = HashMap::::new(); for (kind, bonus) in self.kinds.iter().zip(&mut bonus) { @@ -850,6 +852,75 @@ mod tests { } } + /// Both power bounds of a member prefix are at least the exact power of every completion beneath it, and the bound + /// the search uses is the smaller of the two, so it never exceeds the column relaxation. + #[test] + fn assignment_power_bounds_every_completion_and_never_exceeds_the_relaxation() { + let data = fixture(); + let master = data.master(); + let owned = roster(&mut Rng::new(739), &master); + let input: ContextInput = serde_json::from_value(json!({ + "powerSnapshot":{"eventIds":[7],"capturedJstTicks":50}, + "resultClock":{"execution":"skip","serverNowJstTicks":99}, + "eventPayoff":{"consumedCount":0,"localEvents":[{"eventId":7,"points":0,"challengePoints":30,"added":[]}], + "eventWindows":[{"eventId":7,"startJstTicks":90,"endJstTicks":100}]} + })) + .unwrap(); + let context = input.resolve(&master, Scenario::Free(10), Some(1004), &[]).unwrap(); + let pool = context.pool(&master, &owned).unwrap(); + let chart = short_chart(&mut Rng::new(3), 4, false).0; + let request = SearchRequest { + objective: Objective::SkipScore { score_id: 1004, chart }.in_scenario(context.clone()), + k: 3, + constraints: Constraints { leader: Some(1), ..Default::default() }, + time_limit: None, + }; + let domain = CandidateDomain::build(&pool, &request.constraints).unwrap(); + let bound = DeckPayoffBounds::compile( + &pool, + &request, + &domain, + &Metric::ClientEventPoints { event_id: 7 }, + input.event_payoff.as_ref(), + None, + ) + .unwrap(); + // The best exact power over the completions of each member prefix. Only the canonical layout is a team the + // search visits: its four non-leaders take slots 0, 1, 3, 4 in ascending ID order, so every prefix of it is a + // prefix the member order and the ID floor of the search admit. + let mut best = HashMap::<(usize, [usize; 5]), i64>::new(); + visit_physical_decks(&pool, &request.constraints, |deck| { + if canonical(&pool, &deck) != deck { + return Ok(true); + } + let power = i64::from(crate::search::evaluate(&pool, &deck.as_deck(), &request.objective)?.0); + for depth in 0..=5 { + let mut prefix = PhysicalDeck { members: [0; 5], snaps: [None; 5] }; + for &slot in &SLOTS[..depth] { + prefix.members[slot] = deck.members[slot]; + } + let held = best.entry((depth, prefix.members)).or_insert(i64::MIN); + *held = (*held).max(power); + } + Ok(true) + }) + .unwrap(); + assert!(best.len() > 5); + for (&(depth, members), &power) in &best { + let prefix = PhysicalDeck { members, snaps: [None; 5] }; + let (available, required) = candidates(&pool, &domain, &prefix, depth).expect("legal prefix"); + let relaxed = bound.power.upper(&pool, &domain, &prefix, depth).expect("legal prefix"); + assert!(relaxed >= power, "depth {depth}: the relaxation is below a completion"); + let refined = bound.power.refined_upper(&pool, &domain, &prefix, depth, &available, &required); + assert!(refined >= power, "depth {depth}: the bound in use is below a completion"); + assert!(refined <= relaxed, "depth {depth}: the bound in use is above the relaxation"); + match bound.power.assigned_upper(&pool, &domain, &prefix, depth) { + Some(assigned) => assert!(assigned >= power, "depth {depth}: the assignment is below a completion"), + None => assert_eq!(depth, 5, "a member prefix still to fill has an assignment bound"), + } + } + } + fn combined(levels: &[&[(&Metric, u32)]]) -> Metric { use crate::types::{MetricLevel, MetricTerm}; Metric::Combined { diff --git a/crates/ournotes-search/src/search/deck_payoff_search.rs b/crates/ournotes-search/src/search/deck_payoff_search.rs index b0c39539..fea53b73 100644 --- a/crates/ournotes-search/src/search/deck_payoff_search.rs +++ b/crates/ournotes-search/src/search/deck_payoff_search.rs @@ -3,20 +3,15 @@ //! Members branch in `SLOTS` order with ascending nonleader IDs, so each canonical member layout is visited once, and //! a complete layout offers its exact frontier of Snap bindings. The ranking (payoff, then power, then canonical IDs) //! needs no evaluation: the search keeps it alone and evaluates only the decks it ranks first, in rank order. A -//! deck-determined payoff must be reproduced exactly. A bounded payoff (score steps) may fall short: such a deck is -//! ranked again by what it paid, and the ranking repeats until its first K decks are all evaluated. The joint -//! traversal takes over after `ROUNDS` rankings, or when a certified evaluation falls short. +//! deck-determined payoff must be reproduced exactly. A bounded payoff (score steps) may fall short: after that first +//! shortfall the joint traversal takes over, re-ranking the whole domain against the evaluated incumbents. use super::*; use crate::domain::CandidateDomain; use crate::search::deck_payoff::DeckPayoffBounds; use crate::search::team_power::SLOTS; -use std::collections::HashMap; type Key = ([i64; 5], [Option; 5]); -/// Rankings of a bounded payoff before the joint traversal takes over. -const ROUNDS: usize = 8; - /// A ranked team: payoff numerator over the engine's order mass (its bound until evaluated), power, canonical key. struct Ranked { payoff: i128, @@ -32,9 +27,6 @@ impl Ranked { } } -/// Evaluated decks that paid less than their bound, by member layout, with their payoff numerators. -type Below = HashMap<[usize; 5], Vec<(PhysicalDeck, i128)>>; - /// A complete power-ranked prefix settles Maximum only when every member attains the common cap. /// If fewer than K legal teams exist, evaluating every returned team exhausts the domain instead. pub(super) fn solve_upper_only( @@ -50,7 +42,7 @@ pub(super) fn solve_upper_only( (e.rec.tracked, e.rec.bounded) = (true, true); e.note_open(Some(cap)); let mut top = Vec::new(); - visit(0, &mut p, domain, bounds, &Below::new(), 1, &mut top, e)?; + visit(0, &mut p, domain, bounds, 1, &mut top, e)?; if e.stop.is_some() { return stopped(e, Some(cap)); } @@ -76,6 +68,10 @@ pub(super) fn solve_upper_only( } /// Ok(false) when the joint traversal is to take over (only with `joint_follows`). +/// +/// One ranking, then the first `K` decks are evaluated in rank order. A deck that pays less than its bound makes the +/// ranking optimistic, so nothing below it can be settled without exact values: the joint traversal takes over and +/// re-ranks the whole domain against the incumbents already evaluated. pub(super) fn solve( domain: &CandidateDomain, bounds: &DeckPayoffBounds, @@ -88,82 +84,66 @@ pub(super) fn solve( e.rec.frontier.clear(); (e.rec.tracked, e.rec.bounded) = (true, true); e.note_open(root); - let mut below = Below::new(); - let mut evaluated = HashSet::new(); - for round in 1..=ROUNDS { - let setup = e.tel.environment.bounds.deck_payoff.get_or_insert_with(Default::default); - setup.rounds = round; - let mut top = Vec::new(); - visit(0, &mut p, domain, bounds, &below, scale, &mut top, e)?; - if e.stop.is_some() { + e.tel.environment.bounds.deck_payoff.get_or_insert_with(Default::default).rounds = 1; + let mut top = Vec::new(); + visit(0, &mut p, domain, bounds, scale, &mut top, e)?; + if e.stop.is_some() { + return stopped(e, root); + } + for row in top { + if !e.consider(row.deck)? { return stopped(e, root); } - let mut fell = false; - for row in top { - if !evaluated.insert(row.deck) { - continue; - } - if !e.consider(row.deck)? { - return stopped(e, root); - } - let Some(offered) = e.offered.take().or_else(|| e.recorded(&row.deck)) else { - // This known deck is outside Top-K, but its optimistic rank may still precede an unseen winner. - // No retained exact payoff means a bounded ranking cannot close on this row. The full joint - // traversal retains the incumbents and proves every remaining candidate against them. - if bounds.bounded_only() { - if joint_follows { - return hand_over(e); - } - return Err(Error::Domain("unsettled deck payoff without a joint traversal".into())); + let Some(offered) = e.offered.take().or_else(|| e.recorded(&row.deck)) else { + // This known deck is outside Top-K, but its optimistic rank may still precede an unseen winner. No + // retained exact payoff means a bounded ranking cannot close on this row. The joint traversal retains + // the incumbents and proves every remaining candidate against them; an exact payoff cannot move below + // the K decks already retained. + if bounds.bounded_only() { + if joint_follows { + return hand_over(e); } - // A deck-determined payoff is exact: K evaluated decks rank ahead of this exact ranking entry. - below.entry(row.deck.members).or_default().push((row.deck, i128::MIN)); - continue; - }; - let mismatch = || { - Error::Domain(format!( - "deck payoff bound predicted payoff {}/{scale} and power {} for {:?}, the evaluation differs", - row.payoff, row.power, row.key - )) - }; - if i64::from(offered.power) != row.power { - return Err(mismatch()); - } - if let (Some(cap), Some(score)) = (bounds.score_cap(row.power), offered.score) - && i128::from(score) > cap - { - return Err(Error::Domain(format!("score {score} above the score cap {cap} of {:?}", row.key))); + return Err(Error::Domain("unsettled deck payoff without a joint traversal".into())); } - // The evaluated payoff fraction against the bound numerator over the aggregation's scale, - // and its exact numerator on that same scale when representable. - let paid = offered.payoff.and_then(|(numerator, denominator)| { - let denominator = i128::try_from(denominator).ok()?; - let scaled = numerator.checked_mul(scale)?; - let ranked = (scaled % denominator == 0).then(|| scaled / denominator); - Some((scaled, row.payoff.checked_mul(denominator)?, ranked)) - }); - match paid { - Some((paid, bound, _)) if paid == bound => {} - Some((paid, bound, Some(ranked))) if paid < bound && bounds.bounded_only() && e.certified.is_none() => { - below.entry(row.deck.members).or_default().push((row.deck, ranked)); - e.tel.environment.bounds.deck_payoff.get_or_insert_with(Default::default).shortfalls += 1; - fell = true; + continue; + }; + let mismatch = || { + Error::Domain(format!( + "deck payoff bound predicted payoff {}/{scale} and power {} for {:?}, the evaluation differs", + row.payoff, row.power, row.key + )) + }; + if i64::from(offered.power) != row.power { + return Err(mismatch()); + } + if let (Some(cap), Some(score)) = (bounds.score_cap(row.power), offered.score) + && i128::from(score) > cap + { + return Err(Error::Domain(format!("score {score} above the score cap {cap} of {:?}", row.key))); + } + // The evaluated payoff fraction against the bound numerator over the aggregation's scale. + let paid = offered.payoff.and_then(|(numerator, denominator)| { + let denominator = i128::try_from(denominator).ok()?; + Some((numerator.checked_mul(scale)?, row.payoff.checked_mul(denominator)?, denominator)) + }); + match paid { + Some((paid, bound, _)) if paid == bound => {} + Some((paid, bound, _)) if paid < bound && bounds.bounded_only() && e.certified.is_none() => { + // The ranking was optimistic for this deck, so no later ranking can settle the Top-K without exact + // values. The joint traversal re-ranks the whole domain against the incumbents evaluated so far. + e.tel.environment.bounds.deck_payoff.get_or_insert_with(Default::default).shortfalls += 1; + if joint_follows { + return hand_over(e); } - Some((paid, bound, _)) if paid > bound => return Err(mismatch()), - // A certified payoff below its bound, or not settled: the joint traversal ranks it. - _ if bounds.bounded_only() && joint_follows => return hand_over(e), - _ => return Err(mismatch()), + return Err(Error::Domain("bounded deck payoff without a joint traversal".into())); } + Some((paid, bound, _)) if paid > bound => return Err(mismatch()), + // A certified payoff below its bound, or not settled: the joint traversal ranks it. + _ if bounds.bounded_only() && joint_follows => return hand_over(e), + _ => return Err(mismatch()), } - if !fell { - return Ok(true); - } - } - if joint_follows { - hand_over(e) - } else { - Err(Error::Domain("bounded deck payoff without a joint traversal".into())) } + Ok(true) } /// The root cap covers every unvisited branch and every ranked team not yet evaluated. @@ -178,13 +158,11 @@ fn hand_over(e: &mut Engine<'_, '_>) -> Result { Ok(false) } -#[allow(clippy::too_many_arguments)] fn visit( depth: usize, p: &mut PhysicalDeck, domain: &CandidateDomain, bounds: &DeckPayoffBounds, - below: &Below, scale: i128, top: &mut Vec, e: &mut Engine<'_, '_>, @@ -204,6 +182,14 @@ fn visit( let payoff = payoff * scale; if top.len() == k { let kth = &top[k - 1]; + // A payoff bound that only ties the K-th's leaves the power to decide. Assigning the Snap terms of the + // remaining slots, rather than bounding each column alone, can then rule out every completion that could + // reach it, and the tie falls to the canonical key. + let power = if payoff == kth.payoff { + bounds.power.assigned_upper(e.pool, domain, p, depth).map_or(power, |assigned| power.min(assigned)) + } else { + power + }; // Equal payoff and power keep every completion whose canonical key may still come first. if (payoff, power) < (kth.payoff, kth.power) || ((payoff, power) == (kth.payoff, kth.power) @@ -216,10 +202,8 @@ fn visit( if depth == 5 { let bare = PhysicalDeck { members: p.members, snaps: [None; 5] }; let base = e.pool.deck_power(&bare.as_deck(), e.song, e.event)?.power(); - // Decks that paid less only move down: the layout's first K stay within its first K plus their count. - let paid = below.get(&p.members).map_or(&[][..], Vec::as_slice); - for (bound, power, deck) in bounds.frontier(domain, p, i64::from(base), k + paid.len())? { - let payoff = paid.iter().find(|(d, _)| *d == deck).map_or(bound * scale, |&(_, numerator)| numerator); + for (bound, power, deck) in bounds.frontier(domain, p, i64::from(base), k)? { + let payoff = bound * scale; let key = (deck.members.map(|m| e.pool.members[m].id), deck.snaps.map(|s| s.map(|s| e.pool.snaps[s].id))); let row = Ranked { payoff, power, key, deck }; let at = top.partition_point(|t| t.ahead_of(&row)); @@ -249,7 +233,7 @@ fn visit( } p.members[slot] = member; e.rec.frontier.set(depth, index, bounds.members.len()); - if !visit(depth + 1, p, domain, bounds, below, scale, top, e)? { + if !visit(depth + 1, p, domain, bounds, scale, top, e)? { return Ok(false); } } diff --git a/crates/ournotes-search/src/search/team_power.rs b/crates/ournotes-search/src/search/team_power.rs index b84af9ab..f5ceee7a 100644 --- a/crates/ournotes-search/src/search/team_power.rs +++ b/crates/ournotes-search/src/search/team_power.rs @@ -7,6 +7,10 @@ use std::collections::{HashMap, HashSet}; pub(crate) const SLOTS: [usize; 5] = [2, 0, 1, 3, 4]; +/// 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. +const ASSIGNED_SNAPS: usize = 128; + pub(crate) fn applies(objective: &Objective, metric: &Metric) -> bool { matches!( (objective.inner(), metric), @@ -180,8 +184,25 @@ impl TeamPowerBounds { .collect() } + /// An upper bound of the power of every completion of `SLOTS[..depth]`, from two relaxations: each remaining + /// slot takes the largest `a[m] + lead[m] + wmax[m]` of its character (a team's members have distinct + /// characters), or the largest member terms plus the five largest column terms (its Snaps occupy distinct + /// columns). A placed slot keeps its own Snap terms in both. pub(crate) fn upper(&self, pool: &Pool, domain: &CandidateDomain, p: &PhysicalDeck, depth: usize) -> Option { let (available, required) = candidates(pool, domain, p, depth)?; + Some(self.relaxed_upper(pool, domain, p, depth, &available, &required)) + } + + /// [`TeamPowerBounds::upper`] of a prefix whose candidates are already known. + fn relaxed_upper( + &self, + pool: &Pool, + domain: &CandidateDomain, + p: &PhysicalDeck, + depth: usize, + available: &[usize], + required: &HashSet, + ) -> i64 { let remaining = 5 - depth; let leaders = if depth > 0 { vec![p.members[2]] @@ -200,7 +221,7 @@ impl TeamPowerBounds { } let mut base = HashMap::::new(); let mut paired = HashMap::::new(); - for &m in &available { + for &m in available { let c = pool.members[m].character_id; let b = self.a[m] + self.lead[profile][m]; base.entry(c).and_modify(|v| *v = (*v).max(b)).or_insert(b); @@ -209,7 +230,7 @@ impl TeamPowerBounds { } let paired = fixed_base + SLOTS[..depth].iter().map(|&s| self.row_max[p.members[s]]).sum::() - + sum_rows(&paired, &required, remaining); + + sum_rows(&paired, required, remaining); let mut resources: Vec<_> = (0..domain.snaps().len()) .map(|column| { SLOTS[..depth] @@ -223,12 +244,116 @@ impl TeamPowerBounds { }) .collect(); resources.sort_unstable_by(|a, b| b.cmp(a)); - let resource = fixed_base + sum_rows(&base, &required, remaining) + resources.iter().take(5).sum::(); + let resource = fixed_base + sum_rows(&base, required, remaining) + resources.iter().take(5).sum::(); maximum = maximum.max(paired.min(resource)); } + maximum + } + + /// [`TeamPowerBounds::upper`] lowered by [`TeamPowerBounds::assigned_upper`] where the Snap pool is narrow enough + /// for the matching work to pay off, for a prefix whose candidates are already known: both bound every + /// completion of `SLOTS[..depth]`, so their lower one does too, and it is never above the relaxation. + pub(crate) fn refined_upper( + &self, + pool: &Pool, + domain: &CandidateDomain, + p: &PhysicalDeck, + depth: usize, + available: &[usize], + required: &HashSet, + ) -> i64 { + let relaxed = self.relaxed_upper(pool, domain, p, depth, available, required); + if domain.snaps().len() > ASSIGNED_SNAPS { + return relaxed; + } + self.assigned_with(pool, domain, p, depth, available, required) + .map_or(relaxed, |assigned| relaxed.min(assigned)) + } + + /// An upper bound of the power of every completion of `SLOTS[..depth]` that keeps the remaining member terms + /// per character and assigns the Snap terms to the five slots. A placed slot keeps its own member's row of `w`; + /// a remaining slot may take the largest term of any available member in a column. Every completion is one such + /// assignment with slots on distinct columns, and none of these sums more. `None` once every member is placed, + /// where the caller's power is already exact. + pub(crate) fn assigned_upper( + &self, + pool: &Pool, + domain: &CandidateDomain, + p: &PhysicalDeck, + depth: usize, + ) -> Option { + let (available, required) = candidates(pool, domain, p, depth)?; + self.assigned_with(pool, domain, p, depth, &available, &required) + } + + /// [`TeamPowerBounds::assigned_upper`] of a prefix whose candidates are already known. + fn assigned_with( + &self, + pool: &Pool, + domain: &CandidateDomain, + p: &PhysicalDeck, + depth: usize, + available: &[usize], + required: &HashSet, + ) -> Option { + let remaining = 5 - depth; + if remaining == 0 { + return None; + } + let snap = self.assigned_snaps(domain.snaps().len(), available, p, depth); + let leaders = if depth > 0 { + vec![p.members[2]] + } else { + domain.leader().map_or_else(|| domain.members().to_vec(), |m| vec![m]) + }; + let profiles: HashSet<_> = leaders.iter().map(|&m| self.profile[m]).collect(); + let mut maximum = 0; + for profile in profiles { + let fixed_base: i64 = + SLOTS[..depth].iter().map(|&s| self.a[p.members[s]] + self.lead[profile][p.members[s]]).sum(); + let mut base = HashMap::::new(); + for &m in available { + let c = pool.members[m].character_id; + let b = self.a[m] + self.lead[profile][m]; + base.entry(c).and_modify(|v| *v = (*v).max(b)).or_insert(b); + } + maximum = maximum.max(fixed_base + sum_rows(&base, required, remaining) + snap); + } Some(maximum) } + /// The largest total Snap term of the five slots when a placed slot keeps its own row of `w` and a remaining + /// slot may take any available member's term in a column, on distinct columns. + /// + /// Every completion is one such assignment: its placed slots take their own member's term at their own column, + /// and each of its remaining slots takes at most the largest available member's term there. Distinct Snaps mean + /// distinct columns, and an unpaired slot takes the zero of "no Snap". So the largest assignment is an upper + /// bound of every completion's Snap terms. + /// + /// Only the five best columns of each row are kept. Four other slots occupy at most four columns, so a row on a + /// column outside its five best always has one of them free; moving to that column keeps the sum and the + /// distinctness. An assignment of the kept columns is therefore as good as any other. + fn assigned_snaps(&self, snaps: usize, available: &[usize], p: &PhysicalDeck, depth: usize) -> i64 { + let columns: Vec = + (0..snaps).map(|column| available.iter().map(|&m| self.w[m][column]).max().unwrap_or(0).max(0)).collect(); + let rows: [&[i64]; 5] = std::array::from_fn(|slot| { + if SLOTS[..depth].contains(&slot) { self.w[p.members[slot]].as_slice() } else { columns.as_slice() } + }); + let mut kept = Vec::::new(); + for row in rows { + let mut top: Vec<(i64, usize)> = row.iter().copied().enumerate().map(|(at, v)| (v, at)).collect(); + top.sort_unstable_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1))); + for &(_, column) in top.iter().take(5) { + kept.push(column); + } + } + kept.sort_unstable(); + kept.dedup(); + let weights: [Vec; 5] = + std::array::from_fn(|slot| kept.iter().map(|&column| rows[slot][column]).collect()); + super::matching::best_assignment(weights.each_ref().map(|row| row.as_slice())).0 + } + pub(crate) fn least_key( &self, pool: &Pool, diff --git a/crates/ournotes-search/src/search/telemetry.rs b/crates/ournotes-search/src/search/telemetry.rs index 438f9b81..8f64d2a9 100644 --- a/crates/ournotes-search/src/search/telemetry.rs +++ b/crates/ournotes-search/src/search/telemetry.rs @@ -282,9 +282,9 @@ pub struct DeckPayoffSetup { pub score_cap: Option, /// Why a played Live's payoff steps are not ranked by deck. pub refusal: Option, - /// Rankings run; each later one ranks again after evaluated decks paid less than their bound. + /// Rankings run: one, unless the deck payoff is not ranked at all. pub rounds: usize, - /// Evaluated decks that paid less than their bound. + /// Evaluated decks that paid less than their bound; the first one hands the search to the joint traversal. pub shortfalls: usize, /// The joint traversal took the search over. pub handed_over: bool, diff --git a/crates/ournotes-search/tests/score_steps_bounds.rs b/crates/ournotes-search/tests/score_steps_bounds.rs index 3cd647a5..9ea3823c 100644 --- a/crates/ournotes-search/tests/score_steps_bounds.rs +++ b/crates/ournotes-search/tests/score_steps_bounds.rs @@ -177,9 +177,11 @@ fn score_steps_rank_by_deck_and_keep_the_exhaustive_top_k() { let high = i32::try_from(cap + 1).unwrap(); let synth = fixture(middle, high); - // The most powerful decks bound the target at one; those with a lower order fall back and the ranking settles. + // The most powerful decks bound the target at one; those with a lower order pay less than their bound, and the + // joint traversal re-ranks the whole domain against them after that first shortfall. let (rounds, shortfalls, handed_over) = compare(&synth, Metric::ScoreAtLeast { threshold: middle }); - assert!(rounds > 1 && shortfalls > 0 && !handed_over, "{rounds} {shortfalls} {handed_over}"); + assert_eq!(rounds, 1, "{rounds} {shortfalls} {handed_over}"); + assert!(shortfalls > 0 && handed_over, "{rounds} {shortfalls} {handed_over}"); for metric in [points, Metric::ClientChallengePoints { event_id: EVENT }] { compare(&synth, metric); } diff --git a/crates/ournotes-search/tests/skip_points_bounds.rs b/crates/ournotes-search/tests/skip_points_bounds.rs index cb24d1f4..79f6bb4c 100644 --- a/crates/ournotes-search/tests/skip_points_bounds.rs +++ b/crates/ournotes-search/tests/skip_points_bounds.rs @@ -288,6 +288,45 @@ fn normal_and_challenge_skip_preserve_canonical_k5_and_k31() { check(&data, Scenario::Free(10), false, true); } +/// Challenge-point earnings read no card bonus, so every deck of the fixture pays the same and every member prefix +/// ties the K-th's payoff: the power bound alone decides, and the tighter assignment of the remaining Snap terms must +/// keep every team that outranks the K-th by power. +#[test] +fn equal_payoffs_are_ordered_by_power_and_keep_the_top_k() { + let data = fixture(); + let master = data.master(); + let mut owned = roster(&mut Rng::new(739), &master); + owned.members.reverse(); + owned.snaps.reverse(); + let inputs = input(); + let context = inputs.resolve(&master, Scenario::Free(10), Some(1004), &[]).unwrap(); + let pool = context.pool(&master, &owned).unwrap(); + let payoff = inputs.event_payoff.as_ref().unwrap(); + let metric = Metric::ClientChallengePoints { event_id: 7 }; + let expected = oracle(&pool, &request(&master, context.clone(), 31), &context, payoff, true); + let payoffs: HashSet<_> = + expected.iter().map(|row| row.expected_payoff.as_ref().expect("exact payoff").numerator.clone()).collect(); + assert_eq!(payoffs.len(), 1, "every team of the fixture pays the same"); + assert!(expected[..5].windows(2).all(|pair| pair[0].power >= pair[1].power)); + for k in [1, 5, 31] { + let out = search::solve_physical( + &pool, + &request(&master, context.clone(), k), + &metric, + Some(payoff), + &Limits { time_limit_ms: None, max_candidates: None, cache_entries: 0 }, + &Strategy::BranchAndBound, + None, + &SimulationInput::default(), + ) + .unwrap(); + assert_eq!(out.completion, Completion::Complete); + assert_eq!(out.optimality, Optimality::Proven); + assert_eq!(out.results, expected[..k]); + assert!(out.telemetry.joint.modules["deckPayoff"].checks > 0); + } +} + #[test] fn configured_c_reward_stays_fixed_when_native_scores_cross_rank_thresholds() { for scenario in [Scenario::Free(10), Scenario::Challenge(70)] { diff --git a/docs/search.md b/docs/search.md index 39dc60e1..66a096a7 100644 --- a/docs/search.md +++ b/docs/search.md @@ -764,6 +764,14 @@ At an unassigned leader the maximum over possible leader profiles is also safe. bound uses an exact maximum-weight Snap assignment and the bounded leader constant. Complex leader effects can make that constant loose; they do not change the Snap weights or the upper-bound direction. +A prefix bound also assigns the Snap terms instead of summing column maxima, for a pool narrow enough that the +matching work pays off. Its rows are the members already placed, bound by their own member's `W`, and the remaining +slots, bound by the largest term of any available member in a column. Every completion's binding is one such +assignment with distinct columns, so the assignment total dominates all of them, and it is never above +`U_resource`, which lets one column serve several slots. Each row keeps only its five best columns: four other slots +occupy at most four columns, so a slot on a column outside its five best always has one of them free and moving there +preserves both the total and distinctness. + **Fixed-layout assignment lemma.** For a complete member layout `M`, exact native power has the form `P(M,s) = P(M,None) + sum_i W(M_i,s_i)`. The first term is independent of the Snap binding, including with complex leader effects. Scan Snap resources once in public-ID order. After a scan position, a partial binding is described @@ -797,6 +805,7 @@ upper bound. Those two cases have different completion rules. | `floor(reward * (B + 10000) * rate / 10000)` | Exact Skip event points at the configured result rank. | | `reward * rate` | Exact Skip challenge-point earnings; no card bonus is applied. | | `floor(count * (B + 10000) * rate / 10000)` | Exact Skip items at the configured result grade, or zero when that grade rewards a different resource. `B` contains EventItem effects. | +| Skip score at the deck's power | Exact Skip score over the request's chart; no bonus is read. Its monotonicity over the compiled power interval is proved before a ranking reads it. | | Score-step function at a score cap | Upper bound for each played order's score-dependent payoff. | The compiler resolves the requested result route, active events, reward rows and effect event before selecting a @@ -807,9 +816,15 @@ products over this interval. In particular, a zero final rate does not excuse ov `reward * (B + 10000)` product. These checks establish monotonicity of the integer arithmetic actually evaluated. At a member prefix, the fixed bonuses plus required-character maxima and the best remaining character values, -together with the five largest Snap bonuses, give `B_upper`. The team power bound gives `P_upper`. Every completion -has `B <= B_upper` and `P <= P_upper`, so `F(B_upper,P_upper)` bounds its payoff even if those two maxima cannot -occur in one team. This is a relaxation; no assumption of positive correlation between bonus and power is needed. +together with the five largest Snap bonuses, give `B_upper`. The team power bound gives `P_upper`: the smaller of +the per-character and per-column relaxations, lowered by an exact assignment of the Snap terms over the five slots +when the Snap pool is narrow enough for the work to pay off. Each placed slot keeps its own member's row of `w`, and +each remaining slot may take any available member's term in a column, on distinct columns. A completion is one such +assignment, so the assignment total also bounds it; it is never above the column relaxation, which lets one Snap +column serve several slots. Every completion has `B <= B_upper` and `P <= P_upper`, so `F(B_upper,P_upper)` bounds +its payoff even if those two maxima cannot occur in one team. This is a relaxation; no assumption of positive +correlation between bonus and power is needed. A `Curve::Skip` term reads no bonus and is nondecreasing in the +power, so the same `P_upper` bounds its score just as it bounds a scored order's step. For played score-step payoffs, [the joint compiler](../crates/ournotes-search/src/search/joint.rs) supplies a per-outcome score cap `C(P) = ceil(P * global * (1 + eps))`. A reward table may decrease at a higher score: the bound @@ -858,34 +873,29 @@ This frontier can keep many mutually undominated bonus/power tradeoffs in one ma `K`, unlike the power-only assignment DP. The compilation checks on `K * max(1, number_of_Snaps)` and table sizes are algorithm-selection limits; they do not prove a wall-clock bound or a bound of `K` states per payoff mask. -### Evaluation and reranking of payoff bounds +### Evaluation of payoff bounds [The payoff traversal](../crates/ournotes-search/src/search/deck_payoff_search.rs) ranks all teams by their compiled payoff, exact power and canonical IDs, then evaluates the first `K`. Use common payoff mass `D = 1` for Skip and `D = 120` for played Live. An unevaluated team has optimistic numerator `D * F(B,P)`; an evaluated shortfall is replaced by its exact numerator over that same mass. Numerator/denominator comparisons are checked explicitly. Replacing a bound by a smaller exact value only moves that team down. A payoff above its bound or a power mismatch -is an error and cannot certify a ranking. - -**Reranking frontier lemma.** Suppose `f` bindings of one member layout have been moved down after evaluation. -Any binding outside that layout's original first `K + f` has at least `K + f` bindings ahead of it; at most `f` -of those can move down. At least `K` unchanged bindings still precede it. Therefore recomputing the original -`K + f` frontier, applying the recorded decreases and merging the layouts preserves the new global Top-K. -The count is per member layout, as are the frontier and recorded shortfalls. +is an error and cannot certify a ranking. A payoff bound that only ties the `K`-th's is compared with the K-th's +power using the assignment bound of the remaining slots, so a tie that no completion can reach is pruned; an +equality of both keeps every completion whose canonical key may still precede the K-th. -**Termination certificate.** In a round, every current ranking value is an upper bound on its team's true value, -while already replaced values are exact. If all first `K` teams have settled values equal to their current ranking -values and no new shortfall occurs, every other team's optimistic rank is behind those exact ranks. Its true rank -can only move down, so the retained exact Top-K is globally correct. When the domain has fewer than `K` teams, all -of them must be accounted for. This argument includes power and canonical-ID ties, not only primary-payoff values. +**Termination certificate.** If all first `K` teams have settled values equal to their current ranking values and no +shortfall occurs, every other team's optimistic rank is behind those exact ranks. Its true rank can only move down, +so the retained exact Top-K is globally correct. When the domain has fewer than `K` teams, all of them must be +accounted for. This argument includes power and canonical-ID ties, not only primary-payoff values. A known evaluated team whose exact payoff is no longer retained cannot simply be skipped as an unsettled optimistic -entry. For a bounded payoff that situation hands the domain to the joint traversal. A certified lottery evaluation -that falls below the bound, a value that cannot be settled on the common mass, or continued shortfalls after eight -ranking rounds also hands over. The continuation retains evaluated incumbents and searches the full legal domain -with its own admissible bounds. The round limit changes the algorithm; it never converts an unresolved ranking -into `Complete`. A time or candidate stop retains the root payoff cap for all unresolved work, including ranked -teams not yet evaluated, and reports an incomplete search. +entry. For a bounded payoff that situation hands the domain to the joint traversal, as does a certified lottery +evaluation below the bound, a value that cannot be settled on the common mass, and the first settled shortfall: its +retained bound was optimistic, so no further ranking of the same bounds can certify the Top-K. The continuation +retains evaluated incumbents and searches the full legal domain with its own admissible bounds. Handing over changes +the algorithm; it never converts an unresolved ranking into `Complete`. A time or candidate stop retains the root +payoff cap for all unresolved work, including ranked teams not yet evaluated, and reports an incomplete search. ## Uniform member-order search @@ -1409,7 +1419,7 @@ the weighted sum of those curves with the folded weights. On a Skip the event-po terms are determined by the deck, the sum is the exact folded payoff, and the ranking is the result. On a played Live a term has a curve when its payoff steps with the local score (a score target, event points or items by score tier, Challenge points); the sum is an upper bound of every order's folded payoff, and -[evaluation and reranking](#evaluation-and-reranking-of-payoff-bounds) settles it, handing over to the traversal +[evaluation of payoff bounds](#evaluation-of-payoff-bounds) settles it, handing over to the traversal with the bound of the sections above under the same conditions as a single metric. A played Live with a term without score steps, such as a raw score, uses that traversal from the start. A Skip with a term the deck does not determine uses complete legal team enumeration.