1#![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 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
357pub(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}