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    // docref:start chunk-seed-derivation
263    let seed_prefix = seed_prefix(seed_base, &assignment.chunk_id);
264    // docref:end chunk-seed-derivation
265
266    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    // docref:start orchestration-iteration-reset
275    for i in 0..max_iters {
276        handler.reset();
277        queue.clear();
278        // docref:end orchestration-iteration-reset
279
280        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        // #t(block: rust_clone_in_loop) ProgressTick requires owned String; only cloned every CHECK_INTERVAL iterations
316        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}