diff --git a/crates/ournotes-search/src/search/deck_payoff.rs b/crates/ournotes-search/src/search/deck_payoff.rs index 1e973b7..6a3afa7 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 b0c3953..fea53b7 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 b84af9a..f5ceee7 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 438f9b8..8f64d2a 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 3cd647a..9ea3823 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 cb24d1f..79f6bb4 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 39dc60e..66a096a 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.