From d1e3fcfaf399e1a93c2ef7fb0df56088c4a1878a Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Mon, 19 Oct 2026 02:16:07 +0800 Subject: [PATCH] perf(search): rank Skip score terms of combined metrics by deck A Skip Live has one score, a closed form of the deck's power over the request's chart. When the power tables prove that score nondecreasing over the compiled power interval, the deck-payoff bounds read it as a power curve, so combined Skip metrics that include the score (alone or weighted with event points or items) are ranked by deck like the other Skip payoffs. Without that proof the request keeps its other route. Co-Authored-By: Claude Opus 5.5 --- .../ournotes-search/src/search/deck_payoff.rs | 71 +++++-- .../ournotes-search/src/search/team_power.rs | 6 + .../tests/fixtures/combined_metric.rs | 9 +- .../tests/skip_score_bounds.rs | 186 ++++++++++++++++++ 4 files changed, 260 insertions(+), 12 deletions(-) create mode 100644 crates/ournotes-search/tests/skip_score_bounds.rs diff --git a/crates/ournotes-search/src/search/deck_payoff.rs b/crates/ournotes-search/src/search/deck_payoff.rs index 1db36beb..1e973b70 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 feec76ad..b84af9ab 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 08ad384b..8404262b 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 00000000..bebfc6c2 --- /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}"); + } +}