Skip to main content

wowlab_engine_application/
chunk_executor.rs

1//! Chunk execution loop. `target_error` is a fraction (0.05 = 5%), not a percent.
2
3use 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            // #t(rust_ambient_syscall) isolated system-clock adapter for the injected progress timer
39            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
119/// Run N iterations and return a `ChunkReport`.
120pub 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
138/// Trace-runner variant of [`run_chunk`] that attaches a decision sink and trace extras.
139pub(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
170/// Like [`run_chunk`] but yields the raw `TelemetryAccumulator` for merging.
171pub 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    // docref:start chunk-seed-derivation
265    let seed_prefix = seed_prefix(seed_base, &assignment.chunk_id);
266    // docref:end chunk-seed-derivation
267
268    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    // docref:start orchestration-iteration-reset
277    for i in 0..max_iters {
278        wowlab_types::bench_metrics::measure_reset(|| handler.reset());
279        queue.clear();
280        // docref:end orchestration-iteration-reset
281
282        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        // #t(block: rust_clone_in_loop) ProgressTick requires owned String; only cloned every CHECK_INTERVAL iterations
318        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}