wowlab_sentinel/scheduler/
runtime_aggregate.rs1#![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 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}