Skip to main content

wowlab_sentinel/scheduler/
runtime_factorial.rs

1//! Factorial-screening stage for the in-process tournament runtime.
2
3#![expect(
4    clippy::cast_possible_truncation,
5    reason = "factorial slot and iteration counts are bounded by the u32 protobuf job contract"
6)]
7
8use roaring::RoaringBitmap;
9use wowlab_common::{
10    RuntimeWorkFidelity, RuntimeWorkItem, RuntimeWorkItemKind, RuntimeWorkResult,
11    sim::sentinel_config::{FactorialConfig, ScreeningConfig},
12};
13
14use crate::strategy::factorial::{
15    FactorialItem, FactorialSlot, InteractionEstimate, MainEffectEstimate, WorkUnitKind,
16    decode_interaction_tag, decode_main_effect_tag, encode_interaction_tag, encode_main_effect_tag,
17    reconstruct_model, run_screening, select_items_for_interaction, tag_kind,
18};
19
20#[derive(Clone, Copy, Debug, Eq, PartialEq)]
21enum Stage {
22    Main,
23    Interaction,
24    Done,
25}
26
27#[derive(Clone, Copy, Debug)]
28struct MainSample {
29    slot_index: u32,
30    item_idx: u32,
31    sum_num_x10: u64,
32    iters: u64,
33}
34
35#[derive(Clone, Copy, Debug)]
36struct InteractionSample {
37    slot_a: u32,
38    item_a: u32,
39    slot_b: u32,
40    item_b: u32,
41    sum_num_x10: u64,
42    iters: u64,
43}
44
45#[derive(Debug)]
46pub struct FactorialRuntime {
47    stage: Stage,
48    slots: Vec<FactorialSlot>,
49    config: FactorialConfig,
50    screening: ScreeningConfig,
51    queue: std::collections::VecDeque<RuntimeWorkItem>,
52    stage_total: u32,
53    stage_completed: u32,
54    baseline_num_x10: u64,
55    baseline_iters: u64,
56    main_samples: Vec<MainSample>,
57    interaction_samples: Vec<InteractionSample>,
58}
59
60impl FactorialRuntime {
61    pub fn new(
62        slots: Vec<FactorialSlot>,
63        config: FactorialConfig,
64        screening: ScreeningConfig,
65    ) -> Self {
66        let mut rt = Self {
67            stage: Stage::Main,
68            slots,
69            config,
70            screening,
71            queue: std::collections::VecDeque::new(),
72            stage_total: 0,
73            stage_completed: 0,
74            baseline_num_x10: 0,
75            baseline_iters: 0,
76            main_samples: Vec::new(),
77            interaction_samples: Vec::new(),
78        };
79
80        rt.queue_main_effects();
81
82        rt
83    }
84
85    pub fn total_permutations(&self) -> u64 {
86        self.slots
87            .iter()
88            .map(|s| s.items.len().max(1) as u64)
89            .fold(1u64, u64::saturating_mul)
90            .max(1)
91    }
92
93    pub fn is_screening_ready(&self) -> bool {
94        self.stage == Stage::Done
95    }
96
97    pub fn next_iterations(&self) -> u32 {
98        self.queue.front().map_or(0, |w| w.iterations)
99    }
100
101    pub fn next_work(&mut self, max_items: usize, next_item_id: &mut u64) -> Vec<RuntimeWorkItem> {
102        let mut items = Vec::new();
103
104        while items.len() < max_items {
105            let Some(mut item) = self.queue.pop_front() else {
106                break;
107            };
108
109            item.item_id = *next_item_id;
110            *next_item_id += 1;
111            items.push(item);
112        }
113
114        items
115    }
116
117    pub fn ingest(&mut self, results: &[RuntimeWorkResult]) {
118        for r in results {
119            self.route(r);
120            self.stage_completed += 1;
121        }
122
123        if self.stage_completed >= self.stage_total && self.queue.is_empty() {
124            self.advance_stage();
125        }
126    }
127
128    pub fn take_survivors(&self) -> RoaringBitmap {
129        if self.stage != Stage::Done {
130            return RoaringBitmap::new();
131        }
132
133        let grand_mean = self
134            .baseline_num_x10
135            .checked_div(self.baseline_iters)
136            .map_or(0, |v| v as i32);
137        let main_effects = self.materialize_main_effects(grand_mean);
138        let interactions = self.materialize_interactions();
139        let model = reconstruct_model(grand_mean, &main_effects, &interactions);
140        let result = run_screening(&model, &self.slots, Some(&self.screening));
141
142        let mut survivors = RoaringBitmap::new();
143
144        for &s in &result.survivors {
145            if let Ok(idx) = u32::try_from(s) {
146                survivors.insert(idx);
147            }
148        }
149
150        survivors
151    }
152
153    fn route(&mut self, r: &RuntimeWorkResult) {
154        let sum = u64::from(r.mean_dps_x10) * u64::from(r.iterations);
155        let iters = u64::from(r.iterations);
156
157        match tag_kind(r.tag) {
158            WorkUnitKind::MainEffect => {
159                let (slot_idx, item_idx) = decode_main_effect_tag(r.tag);
160
161                if slot_idx == 0 && item_idx == 0 {
162                    self.baseline_num_x10 += sum;
163                    self.baseline_iters += iters;
164                } else if let Some(existing) = self
165                    .main_samples
166                    .iter_mut()
167                    .find(|m| m.slot_index == slot_idx && m.item_idx == item_idx)
168                {
169                    existing.sum_num_x10 += sum;
170                    existing.iters += iters;
171                } else {
172                    self.main_samples.push(MainSample {
173                        slot_index: slot_idx,
174                        item_idx,
175                        sum_num_x10: sum,
176                        iters,
177                    });
178                }
179            }
180            WorkUnitKind::Interaction => {
181                let (slot_a, item_a, slot_b, item_b) = decode_interaction_tag(r.tag);
182
183                if let Some(existing) = self.interaction_samples.iter_mut().find(|it| {
184                    it.slot_a == slot_a
185                        && it.item_a == item_a
186                        && it.slot_b == slot_b
187                        && it.item_b == item_b
188                }) {
189                    existing.sum_num_x10 += sum;
190                    existing.iters += iters;
191                } else {
192                    self.interaction_samples.push(InteractionSample {
193                        slot_a,
194                        item_a,
195                        slot_b,
196                        item_b,
197                        sum_num_x10: sum,
198                        iters,
199                    });
200                }
201            }
202            WorkUnitKind::Tournament => {}
203        }
204    }
205
206    fn advance_stage(&mut self) {
207        match self.stage {
208            Stage::Main => {
209                self.stage = Stage::Interaction;
210                self.queue_interactions();
211
212                if self.queue.is_empty() {
213                    self.stage = Stage::Done;
214                }
215            }
216            Stage::Interaction => {
217                self.stage = Stage::Done;
218            }
219            Stage::Done => {}
220        }
221    }
222
223    fn queue_main_effects(&mut self) {
224        let mut queue = std::collections::VecDeque::new();
225        let iterations = self.config.main_iters;
226        let mut seed: u64 = 0;
227
228        queue.push_back(RuntimeWorkItem {
229            item_id: 0,
230            kind: RuntimeWorkItemKind::FactorialMain {
231                slot_index: 0,
232                item_idx: 0,
233            },
234            tag: encode_main_effect_tag(0, 0),
235            iterations,
236            seed_offset: seed,
237            fidelity: RuntimeWorkFidelity::DpsOnly,
238        });
239        seed += u64::from(iterations);
240
241        for slot in &self.slots {
242            for item_idx in 1..slot.items.len() as u32 {
243                queue.push_back(RuntimeWorkItem {
244                    item_id: 0,
245                    kind: RuntimeWorkItemKind::FactorialMain {
246                        slot_index: slot.slot_index,
247                        item_idx,
248                    },
249                    tag: encode_main_effect_tag(slot.slot_index, item_idx),
250                    iterations,
251                    seed_offset: seed,
252                    fidelity: RuntimeWorkFidelity::DpsOnly,
253                });
254                seed += u64::from(iterations);
255            }
256        }
257
258        self.stage_total = queue.len() as u32;
259        self.stage_completed = 0;
260        self.queue = queue;
261    }
262
263    // #t(fn: rust_unchecked_indexing) i, j bounded by slots.len(); selected built from slots
264    fn queue_interactions(&mut self) {
265        let grand_mean = self
266            .baseline_num_x10
267            .checked_div(self.baseline_iters)
268            .map_or(0, |v| v as i32);
269        let main_effects = self.materialize_main_effects(grand_mean);
270        let selected = select_items_for_interaction(
271            &self.slots,
272            &main_effects,
273            self.config.top_k_per_slot as usize,
274        );
275
276        let mut queue = std::collections::VecDeque::new();
277        let iterations = self.config.interaction_iters;
278        let mut seed: u64 = 0;
279
280        for i in 0..self.slots.len() {
281            for j in (i + 1)..self.slots.len() {
282                let items_i = &selected[i];
283                let items_j = &selected[j];
284
285                if items_i.len() <= 1 || items_j.len() <= 1 {
286                    continue;
287                }
288
289                for &item_i in items_i {
290                    for &item_j in items_j {
291                        if item_i == 0 && item_j == 0 {
292                            continue;
293                        }
294
295                        queue.push_back(RuntimeWorkItem {
296                            item_id: 0,
297                            kind: RuntimeWorkItemKind::FactorialInteraction {
298                                slot_a: self.slots[i].slot_index,
299                                item_a: item_i,
300                                slot_b: self.slots[j].slot_index,
301                                item_b: item_j,
302                            },
303                            tag: encode_interaction_tag(
304                                self.slots[i].slot_index,
305                                item_i,
306                                self.slots[j].slot_index,
307                                item_j,
308                            ),
309                            iterations,
310                            seed_offset: seed,
311                            fidelity: RuntimeWorkFidelity::DpsOnly,
312                        });
313                        seed += u64::from(iterations);
314                    }
315                }
316            }
317        }
318
319        self.stage_total = queue.len() as u32;
320        self.stage_completed = 0;
321        self.queue = queue;
322    }
323
324    fn materialize_main_effects(&self, baseline_mean: i32) -> Vec<MainEffectEstimate> {
325        self.main_samples
326            .iter()
327            .map(|m| {
328                let mean = m.sum_num_x10.checked_div(m.iters).map_or(0, |v| v as i32);
329
330                MainEffectEstimate {
331                    slot_index: m.slot_index,
332                    item_idx: m.item_idx,
333                    effect_dps_x10: mean - baseline_mean,
334                }
335            })
336            .collect()
337    }
338
339    fn materialize_interactions(&self) -> Vec<InteractionEstimate> {
340        self.interaction_samples
341            .iter()
342            .map(|it| {
343                let mean = it.sum_num_x10.checked_div(it.iters).map_or(0, |v| v as i32);
344
345                InteractionEstimate {
346                    slot_a: it.slot_a,
347                    item_idx_a: it.item_a,
348                    slot_b: it.slot_b,
349                    item_idx_b: it.item_b,
350                    interaction_dps_x10: mean,
351                }
352            })
353            .collect()
354    }
355}
356
357/// Items with non-empty `gem_ids` count as having an effect.
358pub(super) fn factorial_slots_from_payload(
359    payload: &wowlab_types::proto::TournamentPayload,
360) -> Vec<FactorialSlot> {
361    payload
362        .slot_candidates
363        .iter()
364        .enumerate()
365        .map(|(slot_index, sc)| FactorialSlot {
366            slot_index: slot_index as u32,
367            items: sc
368                .items
369                .iter()
370                .map(|it| FactorialItem::new(!it.gem_ids.is_empty()))
371                .collect(),
372        })
373        .collect()
374}
375
376#[cfg(test)]
377mod tests {
378    use googletest::prelude::*;
379
380    use super::*;
381
382    fn slot(slot_index: u32, n: usize) -> FactorialSlot {
383        FactorialSlot {
384            slot_index,
385            items: (0..n).map(|_i| FactorialItem::new(false)).collect(),
386        }
387    }
388
389    fn config() -> FactorialConfig {
390        FactorialConfig {
391            main_iters: 1_000,
392            interaction_iters: 1_000,
393            top_k_per_slot: 4,
394        }
395    }
396
397    #[gtest]
398    fn main_then_interaction_then_screen_yields_survivors() -> Result<()> {
399        let slots = vec![slot(0, 3), slot(1, 3)];
400        let mut rt = FactorialRuntime::new(slots, config(), ScreeningConfig::default());
401
402        verify_eq!(rt.total_permutations(), 9)?;
403
404        let mut next_id = 0u64;
405        let mut guard = 0;
406
407        loop {
408            guard += 1;
409            verify_true!(guard < 1_000)?;
410            let items = rt.next_work(64, &mut next_id);
411
412            if items.is_empty() {
413                if rt.is_screening_ready() {
414                    break;
415                }
416
417                continue;
418            }
419
420            let results: Vec<RuntimeWorkResult> = items
421                .iter()
422                .map(|w| {
423                    let bonus = match w.kind {
424                        RuntimeWorkItemKind::FactorialMain { item_idx, .. } => item_idx * 100,
425                        _ => 0,
426                    };
427
428                    RuntimeWorkResult {
429                        item_id: w.item_id,
430                        tag: w.tag,
431                        iterations: w.iterations,
432                        mean_dps_x10: 10_000 + bonus,
433                        m2_dps_bits: 0,
434                        telemetry_pb: None,
435                    }
436                })
437                .collect();
438
439            rt.ingest(&results);
440        }
441
442        verify_true!(rt.is_screening_ready())?;
443        let survivors = rt.take_survivors();
444
445        verify_true!(!survivors.is_empty())?;
446        verify_true!(survivors.contains(8))?;
447
448        Ok(())
449    }
450
451    #[gtest]
452    fn slots_with_no_extra_items_skip_interactions() -> Result<()> {
453        let slots = vec![slot(0, 1), slot(1, 1)];
454        let mut rt = FactorialRuntime::new(slots, config(), ScreeningConfig::default());
455        let mut next_id = 0u64;
456        let items = rt.next_work(64, &mut next_id);
457
458        verify_eq!(items.len(), 1)?;
459        let results: Vec<RuntimeWorkResult> = items
460            .iter()
461            .map(|w| RuntimeWorkResult {
462                item_id: w.item_id,
463                tag: w.tag,
464                iterations: w.iterations,
465                mean_dps_x10: 10_000,
466                m2_dps_bits: 0,
467                telemetry_pb: None,
468            })
469            .collect();
470
471        rt.ingest(&results);
472        verify_true!(rt.is_screening_ready())?;
473        verify_eq!(rt.take_survivors().len(), 1)?;
474
475        Ok(())
476    }
477}