wowlab_sentinel/scheduler/
planning.rs1use prost::Message;
4use roaring::RoaringBitmap;
5use wowlab_common::sim::sentinel_config::{self, SentinelConfig};
6use wowlab_types::proto::TournamentPayload;
7
8use super::{
9 repository::PendingJob,
10 runtime::{JobRuntime, JobRuntimeInit, Phase},
11 runtime_factorial::{FactorialRuntime, factorial_slots_from_payload},
12};
13
14const DEFAULT_TARGET_CHUNK_ITERATIONS: i32 = 50_000;
15const STAT_WEIGHTS_STATS: usize = 4;
16const DEFAULT_PHASE_0_KEEP: u32 = 25;
17const DEFAULT_PHASE_1_KEEP: u32 = 20;
18const DEFAULT_PHASE_0_ITERS: u32 = 2_000;
19const DEFAULT_PHASE_1_ITERS: u32 = 10_000;
20const DEFAULT_PHASE_2_ITERS: u32 = 100_000;
21
22pub(super) struct PlannedJob {
23 pub job: PendingJob,
24 pub config: SentinelConfig,
25}
26
27pub(super) struct JobPlan {
28 pub jobs: Vec<PlannedJob>,
29 pub invalid_configs: usize,
30}
31
32pub(super) fn plan_jobs(jobs: Vec<PendingJob>) -> JobPlan {
33 let mut planned = Vec::with_capacity(jobs.len());
34 let mut invalid_configs = 0usize;
35
36 for job in jobs {
37 match sentinel_config::parse_sentinel_config(&job.sentinel_config) {
38 Ok(config) => planned.push(PlannedJob { job, config }),
39 Err(_) => invalid_configs += 1,
40 }
41 }
42
43 JobPlan {
44 jobs: planned,
45 invalid_configs,
46 }
47}
48
49pub(super) fn order_for_dispatch(jobs: &mut [PlannedJob]) {
50 jobs.sort_by_key(|planned| std::cmp::Reverse(job_priority(&planned.job)));
51}
52
53fn job_priority(job: &PendingJob) -> i64 {
54 job.meta
55 .get("priority")
56 .and_then(serde_json::Value::as_i64)
57 .unwrap_or(0)
58}
59
60pub(super) fn build_runtime(job: &PendingJob, config: &SentinelConfig) -> JobRuntime {
61 let payload_bytes = build_tournament_payload_bytes(config).unwrap_or_default();
62 let init = JobRuntimeInit {
63 job_id: job.id,
64 user_id: job.user_id,
65 base_sim_config: job.sim_config.clone(),
66 sentinel_config: job.sentinel_config.clone(),
67 payload_bytes: payload_bytes.clone(),
68 priority: config.priority,
69 };
70
71 match config.strategy.as_str() {
72 "tournament" => tournament_runtime(init, config, &payload_bytes),
73 "stat_weights" => JobRuntime::stat_weights(init, &stat_weights_runs(config)),
74 _ => JobRuntime::single(init, &single_chunk_iterations(config)),
75 }
76}
77
78fn tournament_runtime(
79 init: JobRuntimeInit,
80 config: &SentinelConfig,
81 payload_bytes: &[u8],
82) -> JobRuntime {
83 let phases = tournament_phases(config);
84 let payload = if payload_bytes.is_empty() {
85 None
86 } else {
87 TournamentPayload::decode(payload_bytes).ok()
88 };
89
90 if let (Some(factorial_config), Some(payload_ref)) =
91 (config.factorial.as_ref(), payload.as_ref())
92 {
93 let slots = factorial_slots_from_payload(payload_ref);
94 let factorial = FactorialRuntime::new(
95 slots,
96 factorial_config.clone(),
97 config.screening.clone().unwrap_or_default(),
98 );
99
100 JobRuntime::tournament_with_factorial(init, phases, factorial)
101 } else {
102 let total = total_permutations_for(payload.as_ref());
103 let mut to_test = RoaringBitmap::new();
104
105 to_test.insert_range(0..total);
106
107 JobRuntime::tournament(init, phases, to_test)
108 }
109}
110
111fn build_tournament_payload_bytes(config: &SentinelConfig) -> Option<Vec<u8>> {
112 let tournament = config.tournament.as_ref()?;
113
114 if tournament.slot_candidates.is_empty() {
115 return None;
116 }
117
118 let payload = TournamentPayload {
119 slot_candidates: tournament
120 .slot_candidates
121 .iter()
122 .map(|slot| wowlab_types::proto::SlotCandidates {
123 slot: slot.slot.clone(),
124 items: slot
125 .items
126 .iter()
127 .map(|item| wowlab_types::proto::CandidateItem {
128 item_id: item.item_id,
129 bonus_ids: item.bonus_ids.clone(),
130 enchant_id: item.enchant_id,
131 gem_ids: item.gem_ids.clone(),
132 })
133 .collect(),
134 })
135 .collect(),
136 };
137
138 Some(payload.encode_to_vec())
139}
140
141fn tournament_phases(config: &SentinelConfig) -> Vec<Phase> {
142 let configured = config
143 .tournament
144 .as_ref()
145 .map(|tournament| &tournament.phases)
146 .filter(|phases| !phases.is_empty());
147
148 match configured {
149 Some(phases) => phases
150 .iter()
151 .map(|phase| Phase {
152 iterations: phase.iterations,
153 keep_fraction_x100: phase.keep_fraction_x100,
154 })
155 .collect(),
156 None => vec![
157 Phase {
158 iterations: DEFAULT_PHASE_0_ITERS,
159 keep_fraction_x100: DEFAULT_PHASE_0_KEEP,
160 },
161 Phase {
162 iterations: DEFAULT_PHASE_1_ITERS,
163 keep_fraction_x100: DEFAULT_PHASE_1_KEEP,
164 },
165 Phase {
166 iterations: DEFAULT_PHASE_2_ITERS,
167 keep_fraction_x100: 0,
168 },
169 ],
170 }
171}
172
173fn total_permutations_for(payload: Option<&TournamentPayload>) -> u32 {
174 const DEFAULT_PERMUTATION_COUNT: u32 = 100;
175 let Some(payload) = payload else {
176 return DEFAULT_PERMUTATION_COUNT;
177 };
178 let total = payload
179 .slot_candidates
180 .iter()
181 .map(|slot| u64::try_from(slot.items.len().max(1)).unwrap_or(u64::MAX))
182 .fold(1u64, u64::saturating_mul)
183 .max(1)
184 .min(u64::from(u32::MAX));
185
186 u32::try_from(total).unwrap_or(u32::MAX)
187}
188
189fn single_chunk_iterations(config: &SentinelConfig) -> Vec<u32> {
190 let total = config.iterations.max(0);
191 let base_chunks =
192 (total + DEFAULT_TARGET_CHUNK_ITERATIONS - 1) / DEFAULT_TARGET_CHUNK_ITERATIONS;
193 let effective = if config.max_chunks > 0 {
194 base_chunks.clamp(1, config.max_chunks)
195 } else {
196 base_chunks.max(1)
197 };
198 let chunk_size = (total + effective - 1) / effective.max(1);
199
200 (0..effective)
201 .map(|index| {
202 u32::try_from(chunk_size.min(total - index * chunk_size).max(0)).unwrap_or_default()
203 })
204 .collect()
205}
206
207fn stat_weights_runs(config: &SentinelConfig) -> Vec<(u64, Vec<u32>)> {
208 let total = config.iterations.max(0);
209 let runs = i32::try_from(1 + STAT_WEIGHTS_STATS).unwrap_or(i32::MAX);
210 let base_chunks =
211 (total + DEFAULT_TARGET_CHUNK_ITERATIONS - 1) / DEFAULT_TARGET_CHUNK_ITERATIONS;
212 let chunks_per_run = if config.max_chunks > 0 {
213 base_chunks.clamp(1, (config.max_chunks / runs).max(1))
214 } else {
215 base_chunks.max(1)
216 };
217 let chunk_size = (total + chunks_per_run - 1) / chunks_per_run.max(1);
218 let chunk_iters: Vec<u32> = (0..chunks_per_run)
219 .map(|chunk| {
220 u32::try_from(chunk_size.min(total - chunk * chunk_size).max(0)).unwrap_or_default()
221 })
222 .collect();
223
224 (0..u64::try_from(runs).unwrap_or_default())
225 .map(|tag| (tag, chunk_iters.clone()))
226 .collect()
227}
228
229#[cfg(test)]
230mod tests;