Skip to main content

wowlab_sentinel/scheduler/
planning.rs

1//! Scheduler job validation, ordering, and runtime construction.
2
3use 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;