diff --git a/crates/ournotes-search/src/search/deck_payoff.rs b/crates/ournotes-search/src/search/deck_payoff.rs index 1db36be..1e973b7 100644 --- a/crates/ournotes-search/src/search/deck_payoff.rs +++ b/crates/ournotes-search/src/search/deck_payoff.rs @@ -1,6 +1,8 @@ -//! Payoffs the deck alone determines: Skip event points, challenge points and ranked items at the configured result -//! rank. Each is a nondecreasing function of one additive deck sum, the event bonus of its members and Snaps, so the -//! canonical team Top-K needs no Live play: payoff, power, then canonical member and Snap IDs rank every team exactly. +//! Payoffs the deck alone determines: the Skip score, Skip event points, challenge points and ranked items at the +//! configured result rank. Each is a nondecreasing function of the deck's power or of one additive deck sum, the +//! event bonus of its members and Snaps, so the canonical team Top-K needs no Live play: payoff, power, then +//! canonical member and Snap IDs rank every team exactly. The Skip score is a closed form of the power alone; its +//! monotonicity over the compiled power interval is proved by [`TeamPowerBounds`] before a ranking reads it. //! //! A played Live's event points, challenge points, ranked items and score targets step with the local score instead //! (a declared multiplayer room ranks its total, which is nondecreasing in the local score). Every order of a deck @@ -23,6 +25,7 @@ use crate::{domain::CandidateDomain, types::Metric}; use ournotes_sim::{ Error, event::{self, EventCard, EventResultRoute}, + live::skip::SkipEvaluator, master::EventEffectRow, scenario::EventPayoffInput, }; @@ -133,10 +136,13 @@ enum Curve { UpperOnly(i128), /// A bound of each order of a played Live: the step its score cap reaches. Steps(ScoreSteps), + /// The Skip score at the deck's power: a closed form of the power over the request's own chart. Its + /// monotonicity over the compiled power interval is certified by [`TeamPowerBounds`] before the bound is used. + Skip, } impl Curve { - fn value(&self, bonus: i64, power: i64) -> Result { + fn value(&self, bonus: i64, power: i64, skip: Option<&SkipEvaluator>) -> Result { match self { Self::Points { reward, rate } => point_product(*reward, bonus, *rate), Self::Items { count, rate, counted } => { @@ -145,6 +151,14 @@ impl Curve { } Self::Constant(value) | Self::UpperOnly(value) => Ok(*value), Self::Steps(steps) => steps.value(bonus, power), + Self::Skip => { + let skip = skip.ok_or_else(|| unavailable("Skip score monotonicity is not certified"))?; + let power = i32::try_from(power) + .ok() + .filter(|power| *power >= 0) + .ok_or_else(|| unavailable("Skip power outside the certified interval"))?; + Ok(i128::from(skip.score(power).0)) + } } } @@ -155,7 +169,7 @@ impl Curve { Self::Items { count, rate, counted } => { f64::from(u8::from(*counted)) * *count as f64 * *rate as f64 / 10000.0 } - Self::Constant(_) | Self::UpperOnly(_) => 0.0, + Self::Constant(_) | Self::UpperOnly(_) | Self::Skip => 0.0, Self::Steps(steps) => steps.steps.iter().fold(0.0, |best, &(_, step)| match step { Step::Points(multiplier) => best.max(multiplier as f64), Step::Target(_) => best, @@ -189,6 +203,11 @@ fn curve( _ => Err(unavailable("score steps of a payoff that does not step with the score")), }; } + // A skipped Live has one score: a closed form of the deck's power over the request's chart. It reads no event + // bonus and needs no event payoff input. + if matches!(request.objective.inner(), Objective::SkipScore { .. }) && matches!(*metric, Metric::Score) { + return Ok((event::EVENT_POINT, None, Curve::Skip)); + } let input = input.ok_or_else(|| unavailable("missing event payoff input"))?; let master = pool.master; let context = request.objective.context().ok_or_else(|| unavailable("missing resolved context"))?; @@ -293,6 +312,8 @@ pub(crate) struct DeckPayoffBounds { pub(crate) members: Vec, kinds: Vec, terms: Vec, + /// The Skip score of the request's own chart, present exactly when a [`Curve::Skip`] term reads it. + skip: Option, } impl DeckPayoffBounds { @@ -318,6 +339,7 @@ impl DeckPayoffBounds { members, kinds: vec![kind], terms: vec![Term { weight: 1, kind: 0, curve: Curve::UpperOnly(cap) }], + skip: None, }) } @@ -387,9 +409,16 @@ impl DeckPayoffBounds { domain: &CandidateDomain, parts: Vec<(i128, Vec, Vec, Curve)>, ) -> Result { - // The power tables certify nonnegative, nonwrapping powers (and a monotone Skip score) for every legal - // deck. They protect native evaluation; without score steps power only breaks payoff ties. + // The power tables certify nonnegative, nonwrapping powers for every legal deck, and for a Skip objective + // they also prove the Skip score nondecreasing over the whole compiled power interval and hold its evaluator. + // They protect native evaluation; without score steps power only breaks payoff ties. let power = TeamPowerBounds::tables(pool, request, domain)?; + // Without that proof no Skip term is ranked by deck, and the request keeps its other route. + let skip = if parts.iter().any(|part| matches!(part.3, Curve::Skip)) { + Some(power.monotone_skip().ok_or_else(|| unavailable("Skip score monotonicity is not certified"))?) + } else { + None + }; let mut kinds = Vec::::new(); let mut terms = Vec::with_capacity(parts.len()); // How much the payoff gains from each kind's bonus, at least. @@ -420,7 +449,7 @@ impl DeckPayoffBounds { return Err(unavailable("too many distinct event bonuses")); } let mut members = power.members.clone(); - let bound = Self { power, members: Vec::new(), kinds, terms }; + let bound = Self { power, members: Vec::new(), kinds, terms, skip }; let root = PhysicalDeck { members: [0; 5], snaps: [None; 5] }; let maxima: Vec = bound.kinds.iter().map(|kind| kind.maximum).collect(); bound @@ -439,7 +468,7 @@ impl DeckPayoffBounds { for term in &self.terms { sum = term .curve - .value(bonus[term.kind], power)? + .value(bonus[term.kind], power, self.skip.as_ref())? .checked_mul(term.weight) .and_then(|value| sum.checked_add(value)) .ok_or_else(|| unavailable("weighted payoff overflow"))?; @@ -506,6 +535,10 @@ impl DeckPayoffBounds { /// is dropped once `k` bindings of its mask with at least its bonus of every kind beat its power and Snap IDs: /// every common extension keeps them ahead (the payoff is nondecreasing in each bonus and in the power), so no /// dropped binding is among the layout's first `k`. + /// + /// A [`Curve::Skip`] term reads no bonus: the payoff of a binding is its own power's score, so the rows are + /// ranked by the payoff at `base + power`. With one bonus-only kind the `N == 1` shortcut still holds on power, + /// since a higher power scores at least as much. pub(crate) fn frontier( &self, domain: &CandidateDomain, @@ -743,11 +776,12 @@ mod tests { .unwrap(); let metric = folded.unwrap_or(metric); let bound = DeckPayoffBounds::compile(&pool, &request, &domain, &metric, Some(payoff), None).unwrap(); - // Point effects, item effects and no effect (challenge points) are three different bonuses of the fixture. + // Point effects and item effects are two different bonuses of the fixture; challenge points and the Skip + // score read no bonus at all and share the third, all-zero one. let read = |test: fn(&Metric) -> bool| usize::from(metric.any(test)); let kinds = read(|term| matches!(term, Metric::ClientEventPoints { .. })) + read(|term| matches!(term, Metric::RankedEventItems { .. })) - + read(|term| matches!(term, Metric::ClientChallengePoints { .. })); + + usize::from(metric.any(|term| matches!(term, Metric::ClientChallengePoints { .. } | Metric::Score))); assert_eq!(bound.kinds.len(), kinds); let mut layouts = HashMap::new(); let mut teams = 0; @@ -761,6 +795,7 @@ mod tests { let score = score.expect("Skip score"); let native = |term: &Metric| -> Result { Ok(match *term { + Metric::Score => i128::from(score), Metric::ClientEventPoints { event_id } => i128::from( context .preview_event_points(&pool, &deck.as_deck(), payoff, event_id, score)? @@ -839,6 +874,20 @@ mod tests { check_exact(Scenario::Free(10), Metric::ClientChallengePoints { event_id: 7 }); } + #[test] + fn combined_skip_score_terms_are_bounded_and_ranked_exactly() { + let points = Metric::ClientEventPoints { event_id: 7 }; + let items = Metric::RankedEventItems { event_id: 7, resource_type: 4, resource_id: 88 }; + for scenario in [Scenario::Free(10), Scenario::Challenge(70)] { + check_exact(scenario, combined(&[&[(&Metric::Score, 1), (&points, 1)]])); + check_exact(scenario, combined(&[&[(&Metric::Score, 1), (&points, 1000)]])); + check_exact(scenario, combined(&[&[(&points, 1), (&Metric::Score, 1)]])); + check_exact(scenario, combined(&[&[(&Metric::Score, 1)], &[(&points, 1)]])); + check_exact(scenario, combined(&[&[(&points, 2)], &[(&Metric::Score, 1)], &[(&items, 1)]])); + } + check_exact(Scenario::Free(10), combined(&[&[(&Metric::Score, 3)]])); + } + #[test] fn combined_skip_payoffs_are_ranked_exactly_over_the_bonus_of_each_term() { let points = Metric::ClientEventPoints { event_id: 7 }; diff --git a/crates/ournotes-search/src/search/team_power.rs b/crates/ournotes-search/src/search/team_power.rs index feec76a..b84af9a 100644 --- a/crates/ournotes-search/src/search/team_power.rs +++ b/crates/ournotes-search/src/search/team_power.rs @@ -146,6 +146,12 @@ impl TeamPowerBounds { Ok(Self { members, a: t.a, w: t.w, lead: t.lead, profile: t.profile_of, row_max: t.wmax, maximum, skip }) } + /// The Skip score evaluator whose monotonicity [`TeamPowerBounds::tables`] proved over `0..=maximum`, or `None` + /// when the request has no Skip score. + pub(crate) fn monotone_skip(&self) -> Option { + self.skip.clone() + } + /// On the certified power interval, every supported Skip utility is /// nondecreasing. Utility then power then IDs has exactly power then IDs order. pub(crate) fn primary_upper(&self, power: i64, metric: &Metric) -> Result { diff --git a/crates/ournotes-search/tests/fixtures/combined_metric.rs b/crates/ournotes-search/tests/fixtures/combined_metric.rs index 08ad384..8404262 100644 --- a/crates/ournotes-search/tests/fixtures/combined_metric.rs +++ b/crates/ournotes-search/tests/fixtures/combined_metric.rs @@ -222,7 +222,11 @@ fn assert_ranking( // Terms the deck alone determines on a Skip, or that step with the local score of a played Live, are ranked // by deck. On the fixture's played Lives the event points, the items and the score target have such steps. let ranked = |term: usize| { - if skip { [POINTS, CHALLENGE, ITEMS].contains(&term) } else { [POINTS, ITEMS, AT_LEAST].contains(&term) } + if skip { + [POINTS, CHALLENGE, ITEMS, SCORE].contains(&term) + } else { + [POINTS, ITEMS, AT_LEAST].contains(&term) + } }; if bounded && levels.iter().flat_map(|terms| terms.iter()).all(|&(term, _)| ranked(term)) { let setup = actual @@ -235,6 +239,9 @@ fn assert_ranking( assert!(setup.refusal.is_none(), "{label}: {:?}", setup.refusal); assert!(setup.rounds > 0, "{label}"); assert!(actual.telemetry.environment.bounds.compiled, "{label}"); + if skip { + assert!(actual.telemetry.joint.modules["deckPayoff"].checks > 0, "{label}"); + } } assert_eq!(actual.results.len(), expected.len().min(5), "{label}"); for (row, (team, sums)) in actual.results.iter().zip(&expected) { diff --git a/crates/ournotes-search/tests/skip_score_bounds.rs b/crates/ournotes-search/tests/skip_score_bounds.rs new file mode 100644 index 0000000..bebfc6c --- /dev/null +++ b/crates/ournotes-search/tests/skip_score_bounds.rs @@ -0,0 +1,186 @@ +//! Skip combined metrics that read the Skip score: the deck payoff bound ranks the canonical teams and the +//! branch-and-bound answer equals full exhaustive play. A score whose monotone power interval cannot be certified is +//! refused, and the request keeps the exhaustive traversal. +#[path = "../../ournotes-sim/tests/common/mod.rs"] +mod common; + +use common::{Rng, Synth, roster, set_column, synth}; +use ournotes_search::search::{Completion, Constraints, Objective, SearchRequest, solve_physical_with_aggregation}; +use ournotes_search::types::{ + Aggregation, Limits, Metric, MetricLevel, MetricTerm, Optimality, RecommendationOutcome, SimulationInput, Strategy, +}; +use ournotes_sim::live::skip::{Chart, ChartNote}; +use ournotes_sim::master::Master; +use ournotes_sim::scenario::{ContextInput, ResolvedContext, Scenario}; +use serde_json::json; + +const EVENT: i64 = 7; +const SCORE_ID: i64 = 1004; + +/// Six members and two Snaps. Member 6 is weak but carries the largest point bonus, so the payoff order is not the +/// power order. `adjustment` is the configured note score adjustment factor. +fn fixture(adjustment: &str) -> Synth { + let mut data = synth(&mut Rng::new(735), 6, 2); + set_column(&mut data, "MasterMemberCard", &mut |row| { + let id = row["_id"].as_i64().unwrap(); + row["_characterID"] = json!(id); + row["_leaderSkillID"] = json!(4); + if id == 6 { + for field in ["_performancePowerMax", "_technicPowerMax", "_visualPowerMax"] { + row[field] = json!(100); + } + } + }); + set_column(&mut data, "MasterLiveMusic", &mut |row| row["_liveScoreRankGroup"] = json!(1)); + set_column(&mut data, "MasterLiveSettings", &mut |row| { + if row["_key"] == "note_score_adjustment_factor" { + row["_value"] = json!(adjustment); + } + }); + data.tables.extend([ + ("MasterChallengeMusic".into(), json!([{"_id":70,"_eventId":EVENT,"_liveMusicId":10}])), + ("MasterEvent".into(), json!([{"_id":EVENT,"_liveEventPointGroup":1,"_challengeLiveEventPointGroup":2}])), + ( + "MasterEventEffect".into(), + json!([ + {"_id":1,"_eventId":EVENT,"_eventBonusType":0,"_resourceTypeConstraint":2,"_memberCardId":6, + "_rank1EffectValue":5000,"_rank2EffectValue":5000,"_rank3EffectValue":5000, + "_rank4EffectValue":5000,"_rank5EffectValue":5000}, + {"_id":2,"_eventId":EVENT,"_eventBonusType":0,"_resourceTypeConstraint":3,"_supportCardId":1, + "_rank1EffectValue":1000,"_rank2EffectValue":1000,"_rank3EffectValue":1000, + "_rank4EffectValue":1000,"_rank5EffectValue":1000} + ]), + ), + ("MasterLiveScoreRank".into(), json!([{"_id":1,"_group":1,"_liveScoreRank":2,"_requiredScore":0}])), + ("MasterLiveEventPoint".into(), json!([{"_id":1,"_group":1,"_scoreRank":2,"_value":100}])), + ("MasterChallengeLiveEventPoint".into(), json!([{"_id":1,"_group":2,"_scoreRank":2,"_value":300}])), + ( + "MasterLiveChallengePoint".into(), + json!([ + {"_id":1,"_scoreRank":2,"_value":5}, + {"_id":2,"_scoreRank":3,"_value":5}, + {"_id":3,"_scoreRank":4,"_value":5} + ]), + ), + ]); + data +} + +fn input() -> ContextInput { + serde_json::from_value(json!({ + "powerSnapshot":{"eventIds":[EVENT],"capturedJstTicks":50}, + "resultClock":{"execution":"skip","serverNowJstTicks":99}, + "eventPayoff":{"consumedCount":0,"localEvents":[{"eventId":EVENT,"points":0,"challengePoints":30,"added":[]}], + "eventWindows":[{"eventId":EVENT,"startJstTicks":90,"endJstTicks":100}]} + })) + .unwrap() +} + +fn request(master: &Master, context: ResolvedContext, k: usize) -> SearchRequest { + // Eight ordinary notes: the Skip score reads all of them, so the chart's power response is dense. + let chart = Chart { + converted_note_count: 8, + last_timing_note_ms: 800, + notes: (1..=8).map(|id| ChartNote { id, time_ms: id * 100, note_type: 1 }).collect(), + skill_events: Vec::new(), + }; + assert!(master.live_music_score(SCORE_ID).is_some()); + SearchRequest { + objective: Objective::SkipScore { score_id: SCORE_ID, chart }.in_scenario(context), + k, + constraints: Constraints { leader: Some(1), ..Default::default() }, + time_limit: None, + } +} + +fn run(data: &Synth, metric: &Metric, strategy: Strategy, aggregation: Aggregation) -> RecommendationOutcome { + let master = data.master(); + let owned = roster(&mut Rng::new(739), &master); + let input = input(); + let context = input.resolve(&master, Scenario::Free(10), Some(SCORE_ID), &[]).unwrap(); + let pool = context.pool(&master, &owned).unwrap(); + solve_physical_with_aggregation( + &pool, + &request(&master, context.clone(), 5), + metric, + input.event_payoff.as_ref(), + &Limits { time_limit_ms: None, max_candidates: None, cache_entries: 0 }, + &strategy, + None, + &SimulationInput::default(), + aggregation, + ) + .unwrap() +} + +fn points() -> Metric { + Metric::ClientEventPoints { event_id: EVENT } +} + +fn combined(levels: &[&[(Metric, u32)]]) -> Metric { + Metric::Combined { + levels: levels + .iter() + .map(|terms| { + let terms = terms + .iter() + .map(|(metric, weight)| MetricTerm { metric: metric.clone(), weight: *weight }) + .collect(); + MetricLevel::new(terms) + }) + .collect(), + } +} + +/// Branch-and-bound ranks the canonical teams with the deck payoff bound, and its whole answer equals the +/// exhaustive reference. +fn assert_deck_payoff_ranking(data: &Synth, metric: &Metric, aggregation: Aggregation) { + let bounded = run(data, metric, Strategy::BranchAndBound, aggregation); + let exhaustive = run(data, metric, Strategy::Exhaustive, aggregation); + for out in [&bounded, &exhaustive] { + assert_eq!(out.completion, Completion::Complete, "{metric:?} {aggregation:?}"); + assert_eq!(out.optimality, Optimality::Proven, "{metric:?} {aggregation:?}"); + } + let bounds = &bounded.telemetry.environment.bounds; + assert!(bounds.compiled && bounds.fallback.is_none(), "{:?}", bounds.fallback); + let setup = bounds.deck_payoff.as_ref().expect("a deck payoff bound"); + assert!(setup.refusal.is_none(), "{:?}", setup.refusal); + assert!(bounded.telemetry.joint.modules["deckPayoff"].checks > 0, "{metric:?} {aggregation:?}"); + assert_eq!(bounded.results.len(), 5, "{metric:?} {aggregation:?}"); + assert_eq!(bounded.results, exhaustive.results, "{metric:?} {aggregation:?}"); +} + +#[test] +fn skip_combined_score_requests_are_ranked_by_deck_payoff() { + let data = fixture("2.5"); + for aggregation in [Aggregation::Expected, Aggregation::Maximum] { + // One weighted level, then the same terms split over two levels. + assert_deck_payoff_ranking(&data, &combined(&[&[(Metric::Score, 1), (points(), 1)]]), aggregation); + assert_deck_payoff_ranking(&data, &combined(&[&[(Metric::Score, 1), (points(), 1000)]]), aggregation); + assert_deck_payoff_ranking(&data, &combined(&[&[(points(), 1), (Metric::Score, 1)]]), aggregation); + assert_deck_payoff_ranking(&data, &combined(&[&[(Metric::Score, 1)], &[(points(), 1)]]), aggregation); + assert_deck_payoff_ranking(&data, &combined(&[&[(points(), 2)], &[(Metric::Score, 1)]]), aggregation); + assert_deck_payoff_ranking(&data, &combined(&[&[(Metric::Score, 2)], &[(points(), 3)]]), aggregation); + } +} + +#[test] +fn a_skip_score_without_a_provable_monotone_interval_falls_back() { + // This note score adjustment factor sends the power-to-score chain past the binary32 and Int32 ranges at the top + // of the compiled power interval, so the Skip score cannot be certified nondecreasing there. No deck payoff bound + // is built and the request keeps the exhaustive traversal. + let data = fixture("3e38"); + let metric = combined(&[&[(Metric::Score, 1), (points(), 1)]]); + for aggregation in [Aggregation::Expected, Aggregation::Maximum] { + let bounded = run(&data, &metric, Strategy::BranchAndBound, aggregation); + let exhaustive = run(&data, &metric, Strategy::Exhaustive, aggregation); + assert_eq!(bounded.completion, Completion::Complete, "{aggregation:?}"); + assert_eq!(bounded.optimality, Optimality::Proven, "{aggregation:?}"); + assert_eq!(bounded.results, exhaustive.results, "{aggregation:?}"); + let bounds = &bounded.telemetry.environment.bounds; + assert!(!bounds.compiled && bounds.fallback.is_none(), "{aggregation:?}"); + let setup = bounds.deck_payoff.as_ref().expect("a deck payoff refusal"); + let refusal = setup.refusal.as_deref().expect("a refusal reason"); + assert!(refusal.contains("score chain"), "{refusal}"); + } +}