1use std::sync::Arc;
8use std::time::Duration;
9
10use web_time::Instant;
11
12use henad_compute::entry::{ModelEntry, ModelState};
13use henad_compute::fault::{BUILDING, Fault, FaultKind, STEPPING, catching};
14use henad_core::action::{RefusedActions, Schedule, Scheduled};
15use henad_core::explore::measure::{MeasurePlan, Sampler};
16use henad_core::explore::outcome::{PlannedRun, RunOutcome, RunStatus, StopReason};
17use henad_core::export::StatsWriteError;
18use henad_core::model::SimState;
19
20use crate::exec::RunRequest;
21
22#[cfg(not(target_arch = "wasm32"))]
24const GPU_REFUSAL: &str = "a GPU model cannot run on a CPU lane";
25
26#[cfg(target_arch = "wasm32")]
28const GPU_REFUSAL: &str = "a GPU sweep needs a native build";
29
30#[derive(Debug)]
32pub enum CursorState {
33 Running,
35 Finished(RunOutcome),
37}
38
39pub struct RunCursor {
44 run: PlannedRun,
45 run_key: u64,
46 phase: Phase,
47}
48
49impl std::fmt::Debug for RunCursor {
51 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52 f.debug_struct("RunCursor")
53 .field("run", &self.run)
54 .field("run_key", &self.run_key)
55 .finish_non_exhaustive()
56 }
57}
58
59enum Phase {
60 Live(Box<LiveRun>),
61 BuildFailed(Box<RunOutcome>),
63 Spent,
65}
66
67struct LiveRun {
69 state: Box<dyn SimState>,
70 timeline: Timeline,
71 schedule: Schedule,
72 build_ms: f64,
73 wall: Duration,
75 timeout: Option<Duration>,
77}
78
79struct Timeline {
81 sampler: Sampler,
82 tick: u64,
84 next_sample: Option<u64>,
86 population: u64,
88 fired_start_actions: bool,
90 refusals: Vec<String>,
92}
93
94#[derive(Debug, Clone)]
96pub(crate) struct RunFailure {
97 pub(crate) status: RunStatus,
98 pub(crate) note: String,
99}
100
101impl From<Fault> for RunFailure {
102 fn from(fault: Fault) -> Self {
103 let status = match fault.kind {
104 FaultKind::Panic { .. } => RunStatus::Panicked,
105 FaultKind::Refused(_) => RunStatus::Refused,
106 _ => RunStatus::GpuError,
108 };
109 Self {
110 status,
111 note: fault.to_string(),
112 }
113 }
114}
115
116impl From<StatsWriteError> for RunFailure {
117 fn from(error: StatsWriteError) -> Self {
118 Self {
119 status: RunStatus::ShapeError,
120 note: error.to_string(),
121 }
122 }
123}
124
125pub(crate) enum RunEnd {
127 Stopped(StopReason),
129 Failed(RunFailure),
130 TimedOut,
132}
133
134pub(crate) struct OutcomeParts {
136 pub(crate) sampler: Sampler,
137 pub(crate) ticks: u64,
139 pub(crate) population: u64,
141 pub(crate) build_ms: f64,
142 pub(crate) wall: Duration,
145 pub(crate) timeout: Option<Duration>,
146 pub(crate) refusals: Vec<String>,
148}
149
150pub(crate) fn run_outcome(run: PlannedRun, run_key: u64, end: RunEnd, parts: OutcomeParts) -> RunOutcome {
155 let ticks = parts.ticks;
156 let measured = parts.sampler.finish();
157 let (status, stop_reason, note) = match (end, measured.non_finite) {
158 (RunEnd::Failed(failure), _) => (failure.status, StopReason::Fault, Some(failure.note)),
159 (RunEnd::TimedOut, _) => {
160 let seconds = parts.timeout.unwrap_or_default().as_secs_f64();
161 let note = format!("timed out after {seconds} s at tick {ticks}");
162 (RunStatus::TimedOut, StopReason::Timeout, Some(note))
163 }
164 (RunEnd::Stopped(reason), Some(sample)) => (RunStatus::NonFinite, reason, Some(sample.to_string())),
165 (RunEnd::Stopped(reason), None) => (RunStatus::Ok, reason, None),
166 };
167 let mut notes = parts.refusals;
168 notes.extend(note);
169 RunOutcome {
170 run,
171 run_key,
172 status,
173 stop_reason,
174 ticks,
175 population: parts.population,
176 build_ms: parts.build_ms,
177 wall_ms: milliseconds(parts.wall),
178 reducers: measured.reducers,
179 series: measured.series,
180 note: (!notes.is_empty()).then(|| notes.join("; ")),
181 }
182}
183
184pub(crate) fn failed_build_outcome(
186 run: PlannedRun,
187 run_key: u64,
188 measure: &Arc<MeasurePlan>,
189 build_ms: f64,
190 failure: RunFailure,
191) -> RunOutcome {
192 let measured = Sampler::new(Arc::clone(measure)).finish();
193 RunOutcome {
194 run,
195 run_key,
196 status: failure.status,
197 stop_reason: StopReason::Fault,
198 ticks: 0,
199 population: 0,
200 build_ms,
201 wall_ms: 0.0,
202 reducers: measured.reducers,
203 series: measured.series,
204 note: Some(failure.note),
205 }
206}
207
208pub(crate) fn refusal_note(action: &Scheduled) -> String {
210 format!("model refused action '{}' at tick {}", action.id, action.tick)
211}
212
213impl RunCursor {
214 pub fn new(
219 entry: &ModelEntry,
220 measure: &Arc<MeasurePlan>,
221 request: &RunRequest<'_>,
222 timeout: Option<Duration>,
223 ) -> Self {
224 let started = Instant::now();
225 let built = if entry.gpu_needs().is_some() {
226 Err(Fault::refused(BUILDING, GPU_REFUSAL))
227 } else {
228 entry
229 .build(request.params, Some(request.run.seed), None)
230 .and_then(cpu_state)
231 };
232 let build_ms = milliseconds(started.elapsed());
233 let phase = match built {
234 Ok(state) => Phase::Live(Box::new(LiveRun {
235 state,
236 timeline: Timeline {
237 sampler: Sampler::new(Arc::clone(measure)),
238 tick: 0,
239 next_sample: Some(measure.first_sample()),
240 population: 0,
241 fired_start_actions: false,
242 refusals: Vec::new(),
243 },
244 schedule: request.schedule.clone(),
245 build_ms,
246 wall: Duration::ZERO,
247 timeout,
248 })),
249 Err(fault) => Phase::BuildFailed(Box::new(failed_build_outcome(
250 request.run,
251 request.run_key,
252 measure,
253 build_ms,
254 fault.into(),
255 ))),
256 };
257 Self {
258 run: request.run,
259 run_key: request.run_key,
260 phase,
261 }
262 }
263
264 pub fn tick(&self) -> u64 {
266 match &self.phase {
267 Phase::Live(live) => live.timeline.tick,
268 Phase::BuildFailed(_) | Phase::Spent => 0,
269 }
270 }
271
272 pub fn advance(&mut self, max_steps: u64) -> CursorState {
281 match std::mem::replace(&mut self.phase, Phase::Spent) {
282 Phase::Live(mut live) => match live.advance(max_steps) {
283 None => {
284 self.phase = Phase::Live(live);
285 CursorState::Running
286 }
287 Some(end) => CursorState::Finished(live.finish(self.run, self.run_key, end)),
288 },
289 Phase::BuildFailed(outcome) => CursorState::Finished(*outcome),
290 Phase::Spent => panic!("run {} already finished", self.run.run_id),
291 }
292 }
293}
294
295impl LiveRun {
296 fn advance(&mut self, max_steps: u64) -> Option<RunEnd> {
298 let started = Instant::now();
299 let (timeline, schedule, state) = (&mut self.timeline, &self.schedule, &mut self.state);
300 let driven = catching(STEPPING, || timeline.drive(&mut **state, schedule, max_steps));
301 self.wall += started.elapsed();
302 match driven {
303 Ok(Ok(None)) if self.timeout.is_some_and(|timeout| self.wall >= timeout) => Some(RunEnd::TimedOut),
304 Ok(Ok(None)) => None,
305 Ok(Ok(Some(reason))) => Some(RunEnd::Stopped(reason)),
306 Ok(Err(failure)) => Some(RunEnd::Failed(failure)),
307 Err(fault) => Some(RunEnd::Failed(fault.into())),
308 }
309 }
310
311 fn finish(self, run: PlannedRun, run_key: u64, end: RunEnd) -> RunOutcome {
312 let ticks = match &end {
313 RunEnd::Stopped(_) | RunEnd::TimedOut => self.timeline.tick,
314 RunEnd::Failed(_) => self.state.tick(),
315 };
316 let parts = OutcomeParts {
317 sampler: self.timeline.sampler,
318 ticks,
319 population: self.timeline.population,
320 build_ms: self.build_ms,
321 wall: self.wall,
322 timeout: self.timeout,
323 refusals: self.timeline.refusals,
324 };
325 run_outcome(run, run_key, end, parts)
326 }
327}
328
329impl Timeline {
330 fn drive(
334 &mut self,
335 state: &mut dyn SimState,
336 schedule: &Schedule,
337 max_steps: u64,
338 ) -> Result<Option<StopReason>, RunFailure> {
339 if !self.fired_start_actions {
340 self.fired_start_actions = true;
341 note_refused(schedule.run_due(state), &mut self.refusals);
342 }
343 let mut budget = max_steps;
344 loop {
345 let Some(next_sample) = self.next_sample else {
346 return Ok(Some(StopReason::Steps));
347 };
348 if self.tick == next_sample {
349 state.prepare_view();
351 let stats = state.stats();
352 self.population = state.population();
353 let stops = self.sampler.push(self.tick, &stats)?;
354 if stops {
355 self.next_sample = None;
356 return Ok(Some(StopReason::Condition));
357 }
358 self.next_sample = self.sampler.plan().next_sample(self.tick);
359 } else if budget == 0 {
360 return Ok(None);
361 } else {
362 let count = (next_sample - self.tick).min(budget);
363 step_by(state, count, schedule, &mut self.refusals);
364 self.tick += count;
365 budget -= count;
366 }
367 }
368 }
369}
370
371fn step_by(state: &mut dyn SimState, count: u64, schedule: &Schedule, refusals: &mut Vec<String>) {
374 if schedule.is_empty() {
375 for _ in 0..count {
376 state.step();
377 }
378 } else {
379 for _ in 0..count {
380 state.step();
381 note_refused(schedule.run_due(state), refusals);
382 }
383 }
384}
385
386fn note_refused(refused: RefusedActions<'_>, refusals: &mut Vec<String>) {
388 refusals.extend(refused.into_iter().map(refusal_note));
389}
390
391fn cpu_state(state: ModelState) -> Result<Box<dyn SimState>, Fault> {
393 match state {
394 ModelState::Cpu(state) => Ok(state),
395 ModelState::Gpu(_) => Err(Fault::refused(BUILDING, GPU_REFUSAL)),
396 }
397}
398
399pub(crate) fn milliseconds(duration: Duration) -> f64 {
400 duration.as_secs_f64() * 1000.0
401}
402
403#[cfg(test)]
404mod tests {
405 use std::sync::Arc;
406
407 use henad_compute::entry::{ModelEntry, ModelState, register_grid_model};
408 use henad_compute::fault::install_panic_hook;
409 use henad_core::action::Schedule;
410 use henad_core::explore::measure::MeasurePlan;
411 use henad_core::explore::outcome::{PlannedRun, RunStatus, StopReason};
412 use henad_core::explore::spec::{MeasureSettings, RunSettings};
413 use henad_core::export::StatColumns;
414 use henad_core::model::SimState;
415 use henad_core::params::ParamValue;
416 use henad_models::example_models;
417
418 use super::{CursorState, RunCursor};
419 use crate::exec::RunRequest;
420 use crate::tests::broken::DividesByParam;
421
422 const SEED: u64 = 11;
423
424 fn entry(id: &str) -> ModelEntry {
425 example_models().get(id).cloned().expect("the model is registered")
426 }
427
428 fn cpu_state(entry: &ModelEntry, params: &[ParamValue]) -> Box<dyn SimState> {
429 match entry.build(params, Some(SEED), None) {
430 Ok(ModelState::Cpu(state)) => state,
431 _ => panic!("{} builds on the CPU", entry.id()),
432 }
433 }
434
435 fn params(entry: &ModelEntry, width: u32) -> Vec<ParamValue> {
436 let mut params: Vec<ParamValue> = entry
437 .param_descriptors()
438 .iter()
439 .map(|descriptor| descriptor.kind.default_value())
440 .collect();
441 params[0] = ParamValue::U32(width);
442 params[1] = ParamValue::U32(width);
443 params
444 }
445
446 fn measure(entry: &ModelEntry, params: &[ParamValue], warmup: u64, steps: u64, stats_every: u64) -> MeasurePlan {
447 let mut probe = cpu_state(entry, params);
448 probe.prepare_view();
449 let run = RunSettings {
450 steps,
451 warmup,
452 ..RunSettings::default()
453 };
454 let settings = MeasureSettings {
455 stats_every,
456 series_every: stats_every,
457 ..MeasureSettings::default()
458 };
459 MeasurePlan::new(&run, &settings, StatColumns::plan(&probe.stats())).expect("the columns bind")
460 }
461
462 fn request(params: &[ParamValue]) -> RunRequest<'_> {
463 RunRequest {
464 run: PlannedRun {
465 run_id: 3,
466 config_id: 1,
467 rep: 0,
468 seed: SEED,
469 },
470 run_key: 99,
471 params,
472 schedule: Schedule::from_entries(Vec::new()),
473 }
474 }
475
476 fn drive(cursor: &mut RunCursor, slice: u64) -> henad_core::explore::outcome::RunOutcome {
478 loop {
479 if let CursorState::Finished(outcome) = cursor.advance(slice) {
480 return outcome;
481 }
482 }
483 }
484
485 #[test]
486 fn a_cursor_samples_what_a_hand_stepped_run_gives() {
487 let entry = entry("sir");
488 let params = params(&entry, 24);
489 let plan = Arc::new(measure(&entry, ¶ms, 2, 20, 6));
490 let mut cursor = RunCursor::new(&entry, &plan, &request(¶ms), None);
491 let outcome = drive(&mut cursor, 5);
492 assert_eq!(outcome.status, RunStatus::Ok);
493 assert_eq!(outcome.stop_reason, StopReason::Steps);
494 assert_eq!((outcome.ticks, outcome.run.run_id, outcome.run_key), (22, 3, 99));
495 assert_eq!(outcome.series.ticks(), [2, 8, 14, 20, 22]);
496
497 let mut state = cpu_state(&entry, ¶ms);
498 let mut row = Vec::new();
499 for (i, &tick) in outcome.series.ticks().iter().enumerate() {
500 while state.tick() < tick {
501 state.step();
502 }
503 state.prepare_view();
504 plan.columns()
505 .extract(tick, &state.stats(), &mut row)
506 .expect("the layout holds");
507 assert_eq!(outcome.series.row(i), row.as_slice(), "tick {tick}");
508 }
509 assert_eq!(outcome.population, state.population());
510 }
511
512 #[test]
513 fn the_slice_size_does_not_change_the_outcome() {
514 let entry = entry("game_of_life");
515 let params = params(&entry, 16);
516 let plan = Arc::new(measure(&entry, ¶ms, 0, 30, 4));
517 let outcomes: Vec<_> = [1, 3, 1000]
518 .into_iter()
519 .map(|slice| {
520 let mut cursor = RunCursor::new(&entry, &plan, &request(¶ms), None);
521 let outcome = drive(&mut cursor, slice);
522 (outcome.reducers, outcome.series, outcome.ticks)
523 })
524 .collect();
525 assert_eq!(outcomes[0], outcomes[1]);
526 assert_eq!(outcomes[0], outcomes[2]);
527 }
528
529 #[test]
530 fn a_failed_build_finishes_on_the_first_advance() {
531 install_panic_hook();
532 let entry = register_grid_model::<DividesByParam>();
533 let good = params(&entry, 8);
534 let plan = Arc::new(measure(&entry, &good, 0, 10, 5));
535 let mut broken = good.clone();
536 broken[3] = ParamValue::U32(0);
537 let mut cursor = RunCursor::new(&entry, &plan, &request(&broken), None);
538 let CursorState::Finished(outcome) = cursor.advance(0) else {
539 panic!("a failed build has nothing to step");
540 };
541 assert_eq!(outcome.status, RunStatus::Panicked);
542 assert_eq!((outcome.stop_reason, outcome.ticks), (StopReason::Fault, 0));
543 assert!(outcome.series.is_empty());
544 assert!(outcome.reducers.iter().all(Option::is_none));
545 let note = outcome.note.unwrap_or_default();
546 assert!(note.starts_with("while building the model"), "{note}");
547 assert!(note.contains("broken.rs:"), "{note}");
548 }
549
550 #[test]
551 fn a_panic_mid_run_keeps_the_samples_before_it() {
552 install_panic_hook();
553 let entry = register_grid_model::<DividesByParam>();
554 let good = params(&entry, 8);
555 let plan = Arc::new(measure(&entry, &good, 0, 10, 5));
556 let mut broken = good.clone();
557 broken[2] = ParamValue::U32(0);
558 let mut cursor = RunCursor::new(&entry, &plan, &request(&broken), None);
559 let outcome = drive(&mut cursor, 100);
560 assert_eq!(outcome.status, RunStatus::Panicked);
561 assert_eq!((outcome.stop_reason, outcome.ticks), (StopReason::Fault, 0));
562 assert_eq!(
563 outcome.series.ticks(),
564 [0],
565 "the sample at tick 0 came before the first step"
566 );
567 assert_eq!(outcome.reducers[0], Some(1.0), "Cells:final");
568 let note = outcome.note.unwrap_or_default();
569 assert!(note.starts_with("while stepping the simulation"), "{note}");
570 assert!(note.contains("broken.rs:"), "{note}");
571
572 let mut cursor = RunCursor::new(&entry, &plan, &request(&good), None);
573 assert_eq!(
574 drive(&mut cursor, 100).status,
575 RunStatus::Ok,
576 "a divisor of 1 runs clean"
577 );
578 }
579}