Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 78 additions & 7 deletions crates/ournotes-search/src/search/deck_payoff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -477,19 +478,20 @@ 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,
domain: &CandidateDomain,
p: &PhysicalDeck,
depth: usize,
) -> Result<Option<(i128, i64)>, 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::<i64, i64>::new();
for (kind, bonus) in self.kinds.iter().zip(&mut bonus) {
Expand Down Expand Up @@ -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 {
Expand Down
156 changes: 70 additions & 86 deletions crates/ournotes-search/src/search/deck_payoff_search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<i64>; 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,
Expand All @@ -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(
Expand All @@ -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));
}
Expand All @@ -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,
Expand All @@ -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.
Expand All @@ -178,13 +158,11 @@ fn hand_over(e: &mut Engine<'_, '_>) -> Result<bool, Error> {
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<Ranked>,
e: &mut Engine<'_, '_>,
Expand All @@ -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)
Expand All @@ -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));
Expand Down Expand Up @@ -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);
}
}
Expand Down
Loading
Loading