Skip to main content

wowlab_sentinel/scheduler/
runtime_aggregate.rs

1//! In-process runtime state for base-config jobs (single and stat-weights).
2
3#![expect(
4    clippy::cast_possible_truncation,
5    clippy::cast_precision_loss,
6    clippy::cast_sign_loss,
7    reason = "aggregate sample counts are bounded by job limits and statistical interpolation intentionally uses f64"
8)]
9
10use std::collections::VecDeque;
11
12use prost::Message;
13use wowlab_analytics as merge;
14use wowlab_common::{RuntimeWorkFidelity, RuntimeWorkItem, RuntimeWorkItemKind, RuntimeWorkResult};
15use wowlab_types::proto::{self, JobResult, JobTimeline};
16
17use crate::strategy::{FinalOutput, StrategyError, encode_and_check};
18
19const RESULT_BUDGET: usize = 32_768;
20
21#[derive(Clone, Copy, Debug)]
22pub(super) struct PlannedChunk {
23    iterations: u32,
24    seed_offset: u64,
25    tag: u64,
26    full: bool,
27}
28
29#[derive(Clone, Debug)]
30struct AggregateRun {
31    tag: u64,
32    chunks_total: u32,
33    completed: u32,
34    merge: proto::RunningAggregateStateV1,
35}
36
37#[derive(Debug)]
38pub(super) struct SingleRuntime {
39    queue: VecDeque<PlannedChunk>,
40    run: AggregateRun,
41}
42
43impl SingleRuntime {
44    pub(super) fn new(chunk_iterations: &[u32]) -> Self {
45        let chunks_total = chunk_iterations.len() as u32;
46        let mut queue = VecDeque::new();
47        let mut seed = 0u64;
48
49        for (i, &iters) in chunk_iterations.iter().enumerate() {
50            queue.push_back(PlannedChunk {
51                iterations: iters,
52                seed_offset: seed,
53                tag: i as u64,
54                full: true,
55            });
56            seed += u64::from(iters);
57        }
58
59        Self {
60            queue,
61            run: AggregateRun {
62                tag: 0,
63                chunks_total,
64                completed: 0,
65                merge: merge::new_running_state(chunks_total),
66            },
67        }
68    }
69
70    pub(super) fn next_work(
71        &mut self,
72        max_items: usize,
73        next_item_id: &mut u64,
74    ) -> Vec<RuntimeWorkItem> {
75        next_from_queue(&mut self.queue, max_items, next_item_id)
76    }
77
78    pub(super) fn ingest(&mut self, results: &[RuntimeWorkResult]) -> Result<(), StrategyError> {
79        let mut merged = self.run.merge.clone();
80
81        for r in results {
82            let telemetry = r
83                .telemetry_pb
84                .as_ref()
85                .ok_or_else(StrategyError::full_telemetry_required)?;
86            let chunk = proto::ChunkTelemetry::decode(telemetry.as_slice())?;
87
88            merge::merge_chunk(&mut merged, &chunk)?;
89        }
90
91        self.run.merge = merged;
92        self.run.completed += results.len() as u32;
93
94        Ok(())
95    }
96
97    pub(super) fn is_complete(&self) -> bool {
98        self.run.completed >= self.run.chunks_total
99    }
100
101    pub(super) fn has_pending(&self) -> bool {
102        !self.queue.is_empty()
103    }
104
105    pub(super) fn peek_shape(&self) -> (u32, bool) {
106        peek_queue(&self.queue)
107    }
108
109    pub(super) fn finalize(&self) -> Result<FinalOutput, StrategyError> {
110        finalize_single("single", &self.run.merge)
111    }
112}
113
114#[derive(Debug)]
115pub(super) struct StatWeightsRuntime {
116    queue: VecDeque<PlannedChunk>,
117    runs: Vec<AggregateRun>,
118    runs_completed: u32,
119}
120
121impl StatWeightsRuntime {
122    /// Build from `(tag, chunk_iterations)` runs; tag 0 is the full-fidelity baseline.
123    pub(super) fn new(runs: &[(u64, Vec<u32>)]) -> Self {
124        let mut queue = VecDeque::new();
125        let mut run_states = Vec::with_capacity(runs.len());
126
127        for (tag, chunk_iters) in runs {
128            let mut seed = 0u64;
129
130            for &iters in chunk_iters {
131                queue.push_back(PlannedChunk {
132                    iterations: iters,
133                    seed_offset: seed,
134                    tag: *tag,
135                    full: *tag == 0,
136                });
137                seed += u64::from(iters);
138            }
139
140            run_states.push(AggregateRun {
141                tag: *tag,
142                chunks_total: chunk_iters.len() as u32,
143                completed: 0,
144                merge: merge::new_running_state(chunk_iters.len() as u32),
145            });
146        }
147
148        Self {
149            queue,
150            runs: run_states,
151            runs_completed: 0,
152        }
153    }
154
155    pub(super) fn next_work(
156        &mut self,
157        max_items: usize,
158        next_item_id: &mut u64,
159    ) -> Vec<RuntimeWorkItem> {
160        next_from_queue(&mut self.queue, max_items, next_item_id)
161    }
162
163    pub(super) fn ingest(&mut self, results: &[RuntimeWorkResult]) -> Result<(), StrategyError> {
164        let mut runs = self.runs.clone();
165        let mut runs_completed = self.runs_completed;
166
167        for r in results {
168            let run = runs
169                .iter_mut()
170                .find(|run| run.tag == r.tag)
171                .ok_or_else(|| StrategyError::unknown_tag(r.tag))?;
172
173            if let Some(telemetry) = &r.telemetry_pb {
174                let chunk = proto::ChunkTelemetry::decode(telemetry.as_slice())?;
175
176                merge::merge_chunk(&mut run.merge, &chunk)?;
177            } else {
178                run.merge.chunks_completed += 1;
179                run.merge.iterations_total += u64::from(r.iterations);
180                let n = run.merge.iterations_total.max(1);
181                let old_mean = run.merge.weighted_mean_num_x10 as f64;
182                let new_mean = old_mean
183                    + (f64::from(r.mean_dps_x10) - old_mean) * f64::from(r.iterations) / n as f64;
184
185                run.merge.weighted_mean_num_x10 = new_mean as u64;
186            }
187
188            run.completed += 1;
189
190            if run.completed == run.chunks_total {
191                runs_completed += 1;
192            }
193        }
194
195        self.runs = runs;
196        self.runs_completed = runs_completed;
197
198        Ok(())
199    }
200
201    pub(super) fn is_complete(&self) -> bool {
202        self.runs_completed >= self.runs.len() as u32
203    }
204
205    pub(super) fn has_pending(&self) -> bool {
206        !self.queue.is_empty()
207    }
208
209    pub(super) fn peek_shape(&self) -> (u32, bool) {
210        peek_queue(&self.queue)
211    }
212
213    pub(super) fn finalize(&self) -> Result<FinalOutput, StrategyError> {
214        let baseline = self
215            .runs
216            .iter()
217            .find(|r| r.tag == 0)
218            .ok_or_else(StrategyError::missing_baseline)?;
219
220        finalize_single("stat_weights", &baseline.merge)
221    }
222}
223
224fn next_from_queue(
225    queue: &mut VecDeque<PlannedChunk>,
226    max_items: usize,
227    next_item_id: &mut u64,
228) -> Vec<RuntimeWorkItem> {
229    let mut items = Vec::new();
230
231    while items.len() < max_items {
232        let Some(chunk) = queue.pop_front() else {
233            break;
234        };
235        let item_id = *next_item_id;
236
237        *next_item_id += 1;
238        items.push(work_item_from_chunk(item_id, &chunk));
239    }
240
241    items
242}
243
244fn peek_queue(queue: &VecDeque<PlannedChunk>) -> (u32, bool) {
245    queue.front().map_or((0, false), |c| (c.iterations, c.full))
246}
247
248const fn work_item_from_chunk(item_id: u64, chunk: &PlannedChunk) -> RuntimeWorkItem {
249    RuntimeWorkItem {
250        item_id,
251        kind: RuntimeWorkItemKind::Base,
252        tag: chunk.tag,
253        iterations: chunk.iterations,
254        seed_offset: chunk.seed_offset,
255        fidelity: if chunk.full {
256            RuntimeWorkFidelity::Full
257        } else {
258            RuntimeWorkFidelity::DpsOnly
259        },
260    }
261}
262
263fn finalize_single(
264    strategy: &str,
265    merge_state: &proto::RunningAggregateStateV1,
266) -> Result<FinalOutput, StrategyError> {
267    let result_view = merge::emit_result_snapshot(merge_state);
268    let timeline_view = merge::emit_timeline_snapshot(merge_state);
269    let result_pb = encode_and_check(
270        &JobResult {
271            strategy: strategy.into(),
272            total_iterations: merge_state.iterations_total,
273            duration_ms: 0,
274            result: Some(proto::job_result::Result::Single(proto::SingleResult {
275                result: Some(result_view),
276            })),
277        },
278        RESULT_BUDGET,
279    )?;
280    let timeline_pb = JobTimeline {
281        strategy: strategy.into(),
282        timeline: Some(proto::job_timeline::Timeline::Single(
283            proto::SingleTimeline {
284                timeline: Some(timeline_view),
285            },
286        )),
287    }
288    .encode_to_vec();
289
290    Ok(FinalOutput {
291        result_pb,
292        timeline_pb: Some(timeline_pb),
293    })
294}