1use std::time::Duration;
4
5use wowlab_common::time::Instant;
6use wowlab_engine_ports::{
7 ChunkAssignment, ChunkReport, DecisionTraceSink, EngineError, ProgressDone, ProgressSink,
8 ProgressStart, ProgressTick, SimState,
9};
10use wowlab_engine_rng::seed_prefix;
11use wowlab_engine_sim::{EventQueue, SimEngine, SimEngineRefs, TelemetryAccumulator};
12use wowlab_engine_telemetry::TelemetrySink;
13use wowlab_types::{constants::MS_PER_SECOND, sim::SimTime};
14
15use crate::{ApplicationError, ApplicationStage, ResolvedHandler, error::EngineResultExt as _};
16const CHECK_INTERVAL: u32 = 100;
17
18const MIN_SAMPLES_FOR_STDERR: u32 = 2;
19
20#[derive(Clone, Copy, Debug)]
21pub struct ChunkRun<'a> {
22 pub assignment: &'a ChunkAssignment,
23 pub duration_s: f64,
24 pub seed_base: u64,
25}
26
27trait ProgressTimer {
28 fn elapsed(&self) -> Duration;
29}
30
31struct SystemProgressTimer {
32 started_at: Instant,
33}
34
35impl SystemProgressTimer {
36 fn start() -> Self {
37 Self {
38 started_at: Instant::now(),
40 }
41 }
42}
43
44impl ProgressTimer for SystemProgressTimer {
45 fn elapsed(&self) -> Duration {
46 self.started_at.elapsed()
47 }
48}
49
50struct ExecutionObservers<'a> {
51 progress: &'a dyn ProgressSink,
52 timer: &'a dyn ProgressTimer,
53 trace_extras: TraceExtras,
54}
55
56fn enrich_progress_tick(tick: &mut ProgressTick, timer: &dyn ProgressTimer) {
57 let elapsed = timer.elapsed();
58 let elapsed_s = elapsed.as_secs_f64();
59
60 tick.elapsed_ms = Some(u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX));
61 tick.throughput_sps =
62 (elapsed_s > 0.0).then(|| f64::from(tick.iterations_completed) / elapsed_s);
63}
64
65#[derive(Clone, Copy, Debug, Eq, PartialEq)]
66pub(crate) enum TraceExtras {
67 On,
68 Off,
69}
70
71#[derive(Clone, Copy, Debug, Eq, PartialEq)]
72struct ExecutionOptions {
73 trace_extras: TraceExtras,
74 capture_representative: bool,
75}
76
77fn accumulator_to_report(
78 accumulator: TelemetryAccumulator,
79 assignment: &ChunkAssignment,
80) -> ChunkReport {
81 let telemetry_bytes = accumulator.encode(assignment.chunk_index, assignment.permutation_index);
82
83 ChunkReport {
84 job_id: assignment.job_id.clone(),
85 chunk_id: assignment.chunk_id.clone(),
86 chunk_index: assignment.chunk_index,
87 telemetry_bytes,
88 }
89}
90
91fn encounter_end_time(duration_s: f64) -> Result<SimTime, ApplicationError> {
92 let millis = u32::try_from(wowlab_types::numeric::f64_to_u64_saturating_trunc(
93 duration_s * MS_PER_SECOND,
94 ))
95 .map_err(|e| {
96 EngineError::chunk_validation(format!(
97 "duration_s {duration_s} overflows the millisecond clock: {e}"
98 ))
99 })
100 .in_application_stage(ApplicationStage::ChunkExecution)?;
101
102 Ok(SimTime::from_millis(millis))
103}
104
105fn relative_stderr(mean_dps: f64, std_dps: f64, n: u32) -> Option<f64> {
106 if n < MIN_SAMPLES_FOR_STDERR
107 || !mean_dps.is_finite()
108 || mean_dps <= 0.0
109 || !std_dps.is_finite()
110 {
111 return None;
112 }
113
114 let stderr = std_dps / (f64::from(n)).sqrt();
115
116 Some(stderr / mean_dps)
117}
118
119pub fn run_chunk(
121 run: ChunkRun<'_>,
122 progress: &dyn ProgressSink,
123 handler_factory: impl Fn() -> Result<ResolvedHandler, ApplicationError>,
124) -> Result<ChunkReport, ApplicationError> {
125 let accumulator = run_chunk_into_accumulator_impl(
126 run,
127 progress,
128 ExecutionOptions {
129 trace_extras: TraceExtras::Off,
130 capture_representative: true,
131 },
132 handler_factory,
133 )?;
134
135 Ok(accumulator_to_report(accumulator, run.assignment))
136}
137
138pub(crate) fn run_chunk_with_trace<T>(
140 run: ChunkRun<'_>,
141 progress: &dyn ProgressSink,
142 trace_sink: T,
143 handler_factory: impl Fn() -> Result<ResolvedHandler, ApplicationError>,
144) -> Result<ChunkReport, ApplicationError>
145where
146 T: DecisionTraceSink + 'static,
147{
148 let trace_sink: std::sync::Arc<dyn DecisionTraceSink> = std::sync::Arc::new(trace_sink);
149 let accumulator = run_chunk_into_accumulator_impl(
150 run,
151 progress,
152 ExecutionOptions {
153 trace_extras: TraceExtras::On,
154 capture_representative: true,
155 },
156 move || {
157 let mut handler = handler_factory()?;
158
159 handler
160 .handler_mut()
161 .attach_decision_trace(std::sync::Arc::clone(&trace_sink));
162
163 Ok(handler)
164 },
165 )?;
166
167 Ok(accumulator_to_report(accumulator, run.assignment))
168}
169
170pub fn run_chunk_into_accumulator(
172 run: ChunkRun<'_>,
173 progress: &dyn ProgressSink,
174 handler_factory: impl Fn() -> Result<ResolvedHandler, ApplicationError>,
175) -> Result<TelemetryAccumulator, ApplicationError> {
176 run_chunk_into_accumulator_impl(
177 run,
178 progress,
179 ExecutionOptions {
180 trace_extras: TraceExtras::Off,
181 capture_representative: true,
182 },
183 handler_factory,
184 )
185}
186
187pub(crate) fn run_chunk_into_accumulator_for_merge(
188 run: ChunkRun<'_>,
189 progress: &dyn ProgressSink,
190 handler_factory: impl Fn() -> Result<ResolvedHandler, ApplicationError>,
191) -> Result<TelemetryAccumulator, ApplicationError> {
192 run_chunk_into_accumulator_impl(
193 run,
194 progress,
195 ExecutionOptions {
196 trace_extras: TraceExtras::Off,
197 capture_representative: false,
198 },
199 handler_factory,
200 )
201}
202
203fn run_chunk_into_accumulator_impl(
204 run: ChunkRun<'_>,
205 progress: &dyn ProgressSink,
206 options: ExecutionOptions,
207 handler_factory: impl Fn() -> Result<ResolvedHandler, ApplicationError>,
208) -> Result<TelemetryAccumulator, ApplicationError> {
209 let timer = SystemProgressTimer::start();
210
211 run_chunk_into_accumulator_with_timer(
212 run,
213 &ExecutionObservers {
214 progress,
215 timer: &timer,
216 trace_extras: options.trace_extras,
217 },
218 options.capture_representative,
219 &handler_factory,
220 )
221}
222
223fn run_chunk_into_accumulator_with_timer(
224 run: ChunkRun<'_>,
225 observers: &ExecutionObservers<'_>,
226 capture_representative: bool,
227 handler_factory: &impl Fn() -> Result<ResolvedHandler, ApplicationError>,
228) -> Result<TelemetryAccumulator, ApplicationError> {
229 let ChunkRun {
230 assignment,
231 duration_s,
232 seed_base,
233 } = run;
234 let encounter_end = encounter_end_time(duration_s)?;
235
236 let max_iters = if assignment.target_error.is_some() {
237 assignment
238 .max_iterations
239 .unwrap_or(assignment.iterations)
240 .max(1)
241 } else {
242 assignment.iterations
243 };
244 let min_iters = assignment.min_iterations.unwrap_or(1).max(1);
245
246 observers.progress.on_start(ProgressStart {
247 job_id: assignment.job_id.clone(),
248 chunk_id: assignment.chunk_id.clone(),
249 iterations: max_iters,
250 });
251
252 let mut accumulator = TelemetryAccumulator::new();
253
254 if observers.trace_extras == TraceExtras::On {
255 accumulator.enable_trace_extras();
256 }
257
258 let mut handler = handler_factory()?.into_inner();
259 let mut queue = EventQueue::new();
260 let mut sink = TelemetrySink::new();
261
262 let seed_prefix = seed_prefix(seed_base, &assignment.chunk_id);
264 tracing::debug!(
267 chunk_id = %assignment.chunk_id,
268 seed_base,
269 seed_prefix,
270 "chunk seed derived",
271 );
272
273 let mut completed: u32 = 0;
274 for i in 0..max_iters {
276 handler.reset();
277 queue.clear();
278 let state = SimState::new(i, encounter_end, seed_prefix);
281
282 let mut engine = SimEngine::new(
283 state,
284 SimEngineRefs {
285 telemetry: &mut accumulator,
286 handler: handler.as_mut(),
287 queue: &mut queue,
288 sink: &mut sink,
289 },
290 );
291
292 engine
293 .run()
294 .in_application_stage(ApplicationStage::ChunkExecution)?;
295 completed = i + 1;
296
297 let is_check_tick = completed % CHECK_INTERVAL == 0 || completed == max_iters;
298
299 if !is_check_tick {
300 continue;
301 }
302
303 let stats = accumulator.running_stats();
304 let rel_stderr = stats
305 .as_ref()
306 .and_then(|s| relative_stderr(s.mean_dps, s.std_dps, completed));
307
308 let should_stop = match assignment.target_error {
309 Some(te) if completed >= min_iters => {
310 matches!(rel_stderr, Some(re) if re < te)
311 }
312 _ => false,
313 };
314
315 let mut tick = ProgressTick::new(assignment.chunk_id.clone(), completed, max_iters);
317
318 tick.running_mean_dps = stats.as_ref().map(|s| s.mean_dps);
319 tick.running_std_dps = stats.as_ref().map(|s| s.std_dps);
320 tick.running_min_dps = stats.as_ref().map(|s| s.min_dps);
321 tick.running_max_dps = stats.as_ref().map(|s| s.max_dps);
322 tick.relative_stderr = rel_stderr;
323 enrich_progress_tick(&mut tick, observers.timer);
324 observers.progress.on_tick(tick);
325
326 if should_stop {
327 break;
328 }
329 }
330
331 if capture_representative {
332 capture_representative_with_handler(
333 &mut accumulator,
334 encounter_end,
335 handler.as_mut(),
336 &mut queue,
337 &mut sink,
338 )?;
339 }
340
341 observers.progress.on_complete(ProgressDone {
342 chunk_id: assignment.chunk_id.clone(),
343 iterations_completed: completed,
344 });
345
346 Ok(accumulator)
347}
348
349fn capture_representative_with_handler(
350 accumulator: &mut TelemetryAccumulator,
351 encounter_end: SimTime,
352 handler: &mut dyn wowlab_engine_ports::SpecHandler,
353 queue: &mut EventQueue,
354 sink: &mut TelemetrySink,
355) -> Result<(), ApplicationError> {
356 let Some(representative) = accumulator.representative_iteration() else {
357 return Ok(());
358 };
359
360 handler.reset();
361 queue.clear();
362
363 SimEngine::new(
364 SimState::new(
365 representative.iteration,
366 encounter_end,
367 representative.seed_prefix,
368 ),
369 SimEngineRefs {
370 telemetry: accumulator,
371 handler,
372 queue,
373 sink,
374 },
375 )
376 .capture_representative()
377 .in_application_stage(ApplicationStage::ChunkExecution)
378}
379
380pub(crate) fn capture_exact_representative(
381 accumulator: &mut TelemetryAccumulator,
382 duration_s: f64,
383 handler_factory: impl Fn() -> Result<ResolvedHandler, ApplicationError>,
384) -> Result<(), ApplicationError> {
385 if accumulator.representative_iteration().is_none() {
386 return Ok(());
387 }
388
389 let encounter_end = encounter_end_time(duration_s)?;
390 let mut handler = handler_factory()?.into_inner();
391 let mut queue = EventQueue::new();
392 let mut sink = TelemetrySink::new();
393
394 capture_representative_with_handler(
395 accumulator,
396 encounter_end,
397 handler.as_mut(),
398 &mut queue,
399 &mut sink,
400 )
401}
402
403#[cfg(test)]
404mod tests {
405 use std::error::Error as _;
406
407 use googletest::prelude::*;
408 use rstest::rstest;
409
410 use super::*;
411
412 struct FixedProgressTimer(Duration);
413
414 #[gtest]
415 fn simulation_run_failure_retains_ports_source_and_exact_display() -> Result<()> {
416 let error = EngineError::from(wowlab_engine_ports::SimRunError::event_budget_exceeded(
417 500_001,
418 ));
419
420 verify_that!(
421 error.to_string(),
422 eq(
423 "simulation runtime error: event budget exceeded: simulator processed 500001 events without converging"
424 )
425 )?;
426 let source = error
427 .source()
428 .and_then(std::error::Error::source)
429 .and_then(|source| source.downcast_ref::<wowlab_engine_ports::SimRunError>())
430 .or_fail()?;
431
432 verify_that!(source.event_count(), eq(Some(500_001)))?;
433
434 Ok(())
435 }
436
437 impl ProgressTimer for FixedProgressTimer {
438 fn elapsed(&self) -> Duration {
439 self.0
440 }
441 }
442
443 #[gtest]
444 fn progress_tick_enrichment_uses_injected_elapsed_time() -> Result<()> {
445 let mut tick = ProgressTick::new("test".to_string(), 100, 200);
446
447 enrich_progress_tick(&mut tick, &FixedProgressTimer(Duration::from_millis(1_250)));
448
449 verify_that!(tick.elapsed_ms, some(eq(1_250)))?;
450
451 verify_that!(tick.throughput_sps, some(near(80.0, 1e-12)))
452 }
453
454 #[gtest]
455 fn zero_elapsed_time_omits_undefined_throughput() -> Result<()> {
456 let mut tick = ProgressTick::new("test".to_string(), 100, 200);
457
458 enrich_progress_tick(&mut tick, &FixedProgressTimer(Duration::ZERO));
459
460 verify_that!(tick.elapsed_ms, some(eq(0)))?;
461
462 verify_that!(tick.throughput_sps, none())
463 }
464
465 #[gtest]
466 #[rstest]
467 #[case::whole_second(10.0, 10_000)]
468 #[case::fractional(1.5, 1_500)]
469 #[case::zero(0.0, 0)]
470 fn encounter_end_time_cases(#[case] duration_s: f64, #[case] expected_ms: u32) -> Result<()> {
471 verify_that!(
472 encounter_end_time(duration_s).map(|time| time.as_millis()),
473 ok(eq(&expected_ms))
474 )
475 }
476
477 #[gtest]
478 fn encounter_end_time_overflow_is_chunk_validation_err() -> Result<()> {
479 let result = encounter_end_time(5_000_000.0);
480
481 verify_that!(
482 result.as_ref().err().or_fail()?.engine_error(),
483 some(predicate(EngineError::is_chunk_validation))
484 )?;
485
486 verify_that!(
487 result.err().or_fail()?.to_string(),
488 contains_substring("overflows the millisecond clock")
489 )
490 }
491
492 #[gtest]
493 #[rstest]
494 #[case::n_below_two_none(100.0, 10.0, 1, None)]
495 #[case::known_value(1000.0, 100.0, 100, Some(0.01))]
496 #[case::mean_zero_none(0.0, 10.0, 10, None)]
497 #[case::mean_negative_none(-5.0, 10.0, 10, None)]
498 #[case::mean_nan_none(f64::NAN, 10.0, 10, None)]
499 #[case::std_infinite_none(100.0, f64::INFINITY, 10, None)]
500 #[case::n_two_boundary_some(100.0, 0.0, 2, Some(0.0))]
501 fn relative_stderr_cases(
502 #[case] mean_dps: f64,
503 #[case] std_dps: f64,
504 #[case] n: u32,
505 #[case] expected: Option<f64>,
506 ) -> Result<()> {
507 let actual = relative_stderr(mean_dps, std_dps, n);
508
509 if let Some(e) = expected {
510 verify_that!(actual, some(near(e, 1e-12)))
511 } else {
512 verify_that!(actual, none())
513 }
514 }
515}