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 handler.install_target_metadata(&mut sink);
263
264 let seed_prefix = seed_prefix(seed_base, &assignment.chunk_id);
266 tracing::debug!(
269 chunk_id = %assignment.chunk_id,
270 seed_base,
271 seed_prefix,
272 "chunk seed derived",
273 );
274
275 let mut completed: u32 = 0;
276 for i in 0..max_iters {
278 wowlab_types::bench_metrics::measure_reset(|| handler.reset());
279 queue.clear();
280 let state = SimState::new(i, encounter_end, seed_prefix);
283
284 let mut engine = SimEngine::new(
285 state,
286 SimEngineRefs {
287 telemetry: &mut accumulator,
288 handler: handler.as_mut(),
289 queue: &mut queue,
290 sink: &mut sink,
291 },
292 );
293
294 engine
295 .run()
296 .in_application_stage(ApplicationStage::ChunkExecution)?;
297 completed = i + 1;
298
299 let is_check_tick = completed.is_multiple_of(CHECK_INTERVAL) || completed == max_iters;
300
301 if !is_check_tick {
302 continue;
303 }
304
305 let stats = accumulator.running_stats();
306 let rel_stderr = stats
307 .as_ref()
308 .and_then(|s| relative_stderr(s.mean_dps, s.std_dps, completed));
309
310 let should_stop = match assignment.target_error {
311 Some(te) if completed >= min_iters => {
312 matches!(rel_stderr, Some(re) if re < te)
313 }
314 _ => false,
315 };
316
317 let mut tick = ProgressTick::new(assignment.chunk_id.clone(), completed, max_iters);
319
320 tick.running_mean_dps = stats.as_ref().map(|s| s.mean_dps);
321 tick.running_std_dps = stats.as_ref().map(|s| s.std_dps);
322 tick.running_min_dps = stats.as_ref().map(|s| s.min_dps);
323 tick.running_max_dps = stats.as_ref().map(|s| s.max_dps);
324 tick.relative_stderr = rel_stderr;
325 enrich_progress_tick(&mut tick, observers.timer);
326 observers.progress.on_tick(tick);
327
328 if should_stop {
329 break;
330 }
331 }
332
333 if capture_representative {
334 capture_representative_with_handler(
335 &mut accumulator,
336 encounter_end,
337 handler.as_mut(),
338 &mut queue,
339 &mut sink,
340 )?;
341 }
342
343 observers.progress.on_complete(ProgressDone {
344 chunk_id: assignment.chunk_id.clone(),
345 iterations_completed: completed,
346 });
347
348 Ok(accumulator)
349}
350
351fn capture_representative_with_handler(
352 accumulator: &mut TelemetryAccumulator,
353 encounter_end: SimTime,
354 handler: &mut dyn wowlab_engine_ports::SpecHandler,
355 queue: &mut EventQueue,
356 sink: &mut TelemetrySink,
357) -> Result<(), ApplicationError> {
358 let Some(representative) = accumulator.representative_iteration() else {
359 return Ok(());
360 };
361
362 wowlab_types::bench_metrics::measure_reset(|| handler.reset());
363 queue.clear();
364
365 SimEngine::new(
366 SimState::new(
367 representative.iteration,
368 encounter_end,
369 representative.seed_prefix,
370 ),
371 SimEngineRefs {
372 telemetry: accumulator,
373 handler,
374 queue,
375 sink,
376 },
377 )
378 .capture_representative()
379 .in_application_stage(ApplicationStage::ChunkExecution)
380}
381
382pub(crate) fn capture_exact_representative(
383 accumulator: &mut TelemetryAccumulator,
384 duration_s: f64,
385 handler_factory: impl Fn() -> Result<ResolvedHandler, ApplicationError>,
386) -> Result<(), ApplicationError> {
387 if accumulator.representative_iteration().is_none() {
388 return Ok(());
389 }
390
391 let encounter_end = encounter_end_time(duration_s)?;
392 let mut handler = handler_factory()?.into_inner();
393 let mut queue = EventQueue::new();
394 let mut sink = TelemetrySink::new();
395
396 handler.install_target_metadata(&mut sink);
397
398 capture_representative_with_handler(
399 accumulator,
400 encounter_end,
401 handler.as_mut(),
402 &mut queue,
403 &mut sink,
404 )
405}
406
407#[cfg(test)]
408mod tests {
409 use std::error::Error as _;
410
411 use googletest::prelude::*;
412 use rstest::rstest;
413
414 use super::*;
415
416 struct FixedProgressTimer(Duration);
417
418 #[gtest]
419 fn simulation_run_failure_retains_ports_source_and_exact_display() -> Result<()> {
420 let error = EngineError::from(wowlab_engine_ports::SimRunError::event_budget_exceeded(
421 500_001,
422 ));
423
424 verify_that!(
425 error.to_string(),
426 eq(
427 "simulation runtime error: event budget exceeded: simulator processed 500001 events without converging"
428 )
429 )?;
430 let source = error
431 .source()
432 .and_then(std::error::Error::source)
433 .and_then(|source| source.downcast_ref::<wowlab_engine_ports::SimRunError>())
434 .or_fail()?;
435
436 verify_that!(source.event_count(), eq(Some(500_001)))?;
437
438 Ok(())
439 }
440
441 impl ProgressTimer for FixedProgressTimer {
442 fn elapsed(&self) -> Duration {
443 self.0
444 }
445 }
446
447 #[gtest]
448 fn progress_tick_enrichment_uses_injected_elapsed_time() -> Result<()> {
449 let mut tick = ProgressTick::new("test".to_string(), 100, 200);
450
451 enrich_progress_tick(&mut tick, &FixedProgressTimer(Duration::from_millis(1_250)));
452
453 verify_that!(tick.elapsed_ms, some(eq(1_250)))?;
454
455 verify_that!(tick.throughput_sps, some(near(80.0, 1e-12)))
456 }
457
458 #[gtest]
459 fn zero_elapsed_time_omits_undefined_throughput() -> Result<()> {
460 let mut tick = ProgressTick::new("test".to_string(), 100, 200);
461
462 enrich_progress_tick(&mut tick, &FixedProgressTimer(Duration::ZERO));
463
464 verify_that!(tick.elapsed_ms, some(eq(0)))?;
465
466 verify_that!(tick.throughput_sps, none())
467 }
468
469 #[gtest]
470 #[rstest]
471 #[case::whole_second(10.0, 10_000)]
472 #[case::fractional(1.5, 1_500)]
473 #[case::zero(0.0, 0)]
474 fn encounter_end_time_cases(#[case] duration_s: f64, #[case] expected_ms: u32) -> Result<()> {
475 verify_that!(
476 encounter_end_time(duration_s).map(|time| time.as_millis()),
477 ok(eq(&expected_ms))
478 )
479 }
480
481 #[gtest]
482 fn encounter_end_time_overflow_is_chunk_validation_err() -> Result<()> {
483 let result = encounter_end_time(5_000_000.0);
484
485 verify_that!(
486 result.as_ref().err().or_fail()?.engine_error(),
487 some(predicate(EngineError::is_chunk_validation))
488 )?;
489
490 verify_that!(
491 result.err().or_fail()?.to_string(),
492 contains_substring("overflows the millisecond clock")
493 )
494 }
495
496 #[gtest]
497 #[rstest]
498 #[case::n_below_two_none(100.0, 10.0, 1, None)]
499 #[case::known_value(1000.0, 100.0, 100, Some(0.01))]
500 #[case::mean_zero_none(0.0, 10.0, 10, None)]
501 #[case::mean_negative_none(-5.0, 10.0, 10, None)]
502 #[case::mean_nan_none(f64::NAN, 10.0, 10, None)]
503 #[case::std_infinite_none(100.0, f64::INFINITY, 10, None)]
504 #[case::n_two_boundary_some(100.0, 0.0, 2, Some(0.0))]
505 fn relative_stderr_cases(
506 #[case] mean_dps: f64,
507 #[case] std_dps: f64,
508 #[case] n: u32,
509 #[case] expected: Option<f64>,
510 ) -> Result<()> {
511 let actual = relative_stderr(mean_dps, std_dps, n);
512
513 if let Some(e) = expected {
514 verify_that!(actual, some(near(e, 1e-12)))
515 } else {
516 verify_that!(actual, none())
517 }
518 }
519}