Skip to main content

henad_app/
state.rs

1//! State of the app's panels. `DockArea::show_inside` borrows it as its `TabViewer` beside the dock.
2
3use std::sync::Arc;
4
5use egui::TextureHandle;
6use henad_compute::cpu::sim_thread::{SimCommand, SimThread, WakeFn};
7use henad_compute::entry::{ModelEntry, ModelLookupError, ModelSet, ModelState};
8use henad_compute::fault::{BUILDING, Fault, catching};
9use henad_compute::simulation::{RunSetup, SetupError};
10use henad_compute::snapshot::Snapshot;
11use henad_core::action::Schedule;
12use henad_core::explore::replay::Replay;
13use henad_core::params::ParamValue;
14use henad_core::view::StatsHistory;
15use henad_explore::output::memory::SweepFiles;
16
17use crate::options::{AppOpening, Product};
18use crate::sim_runner::SimRunner;
19use crate::ui::agent_layer::AgentLayer;
20use crate::ui::dock::Tab;
21use crate::ui::edge_layer::EdgeStyle;
22use crate::ui::export::Recording;
23use crate::ui::export::image::PendingCapture;
24use crate::ui::files::open::spawn_open;
25use crate::ui::files::save::{spawn_save, spawn_save_files};
26use crate::ui::files::{OpenOutcome, OpenTarget, SaveOutcome, SaveResult, SaveTarget};
27use crate::ui::results::ResultsPanel;
28use crate::ui::sweep::SweepPanel;
29use henad_compute::runtime_info::RuntimeInfo;
30
31use henad_compute::gpu::GpuContext;
32use henad_compute::gpu::fault::catching_on;
33use henad_compute::gpu::sim_thread::{GpuBatchSettings, GpuSimThread};
34use henad_compute::gpu::timing::{DEFAULT_BATCH_SIZE, DEFAULT_TARGET_MS};
35
36/// Exponential moving average smoothing factor (0..1, higher = more responsive).
37const EMA_ALPHA: f64 = 0.1;
38
39/// Default number of snapshots the chart history keeps before it starts dropping the oldest snapshots.
40pub const DEFAULT_HISTORY_LEN: usize = 10_000;
41
42/// Default time in milliseconds that a snapshot can spend on a network's layout.
43const DEFAULT_LAYOUT_BUDGET_MS: f32 = 4.0;
44
45/// Status shown while a save is pending, followed by the file name.
46#[cfg(not(target_arch = "wasm32"))]
47const SAVE_PENDING: &str = "Select location to save";
48
49/// Status shown while a download is pending, followed by the file name.
50#[cfg(target_arch = "wasm32")]
51const SAVE_PENDING: &str = "Downloading";
52
53/// Status of the downloads of several files. A browser can hold back every download after the first.
54const DOWNLOADS_STARTED: &str = "Downloads started. Press Save results again if your browser blocked any.";
55
56/// Per-frame timing breakdown, smoothed with EMA.
57#[derive(Default)]
58pub struct FrameTimings {
59    pub render_ms: f64,
60    pub ui_ms: f64,
61    /// Raw viewport cost this frame, folded into the EMAs once the frame is over.
62    pub frame_render_ms: f64,
63}
64
65impl FrameTimings {
66    pub fn update_ema(smoothed: &mut f64, sample_ms: f64) {
67        *smoothed += EMA_ALPHA * (sample_ms - *smoothed);
68    }
69}
70
71pub struct AppState {
72    /// Kept so a freshly built sim thread can be given a repaint waker.
73    egui_ctx: egui::Context,
74    /// Every model the host offers, including GPU models this machine cannot run.
75    pub models: ModelSet,
76    /// Name, build, links and command line of the app the host ships.
77    pub product: Product,
78    /// Opening that the app could not open, shown in the Model panel until a model is selected.
79    pub opening_refusal: Option<OpeningRefusal>,
80    /// Id of the model the panels show, `None` when no offered model runs on this machine or the opening was rejected.
81    pub selected_model: Option<String>,
82    pub param_values: Vec<ParamValue>,
83    /// Id of the model the live simulation was built from.
84    pub loaded_model: Option<String>,
85    pub pending_reload: Vec<bool>,
86    /// Seed of the next build, `None` for the model's default seed.
87    pub seed: Option<u64>,
88    /// Text of the Seed field, including text that does not parse.
89    pub seed_text: String,
90    /// Values the loaded model runs with, those it was built with and any live edit since. Meaningful only while
91    /// `loaded_model` is set.
92    pub loaded_values: Vec<ParamValue>,
93    /// Seed the loaded model was built with. Meaningful only while `loaded_model` is set.
94    pub loaded_seed: Option<u64>,
95    /// Actions the next build runs at their ticks.
96    pub schedule: Schedule,
97    /// Schedule the loaded model was built with.
98    pub loaded_schedule: Schedule,
99    // Entry row of the scheduled-actions list.
100    pub schedule_action_input: usize,
101    pub schedule_tick_input: u64,
102    /// Sweep run the loaded model replays.
103    pub opened_run: Option<OpenedRun>,
104    /// Tick a pending [`SimCommand::RunTo`] stops at.
105    pub run_to_target: Option<u64>,
106    pub run_to_input: u64,
107    /// Tab brought to the front once the dock has drawn.
108    pub focus_request: Option<Tab>,
109    pub sim_thread: Option<SimRunner>,
110    pub snapshot: Option<Snapshot>,
111    pub sim_running: bool,
112    pub grid_texture: Option<TextureHandle>,
113    /// Separate from `grid_texture`, so density mode does not overwrite a composite model's field.
114    pub density_texture: Option<TextureHandle>,
115    pub density_max: f32,
116    pub point_render_mode: PointRenderMode,
117    // Edge toggles from the Viewport toolbar, for network models.
118    pub show_edges: bool,
119    pub edge_arrows: bool,
120    /// Agent renderer, built on first use and kept across model switches. Its pipeline is tied to no model.
121    pub agent_layer: Option<AgentLayer>,
122    /// Serial of the snapshot whose data the viewport last copied to the GPU.
123    pub last_rendered_serial: Option<u64>,
124    pub rendering_enabled: bool,
125    pub target_tps: f64,
126    pub uncapped: bool,
127    pub ticks_per_snapshot: u32,
128    // Spring layout settings for network models, sent to each model when it is built.
129    pub layout_on: bool,
130    pub layout_budget_ms: f32,
131    pub layout_while_paused: bool,
132    pub stats_history: Option<StatsHistory>,
133    /// Number of samples the chart history keeps, `None` to keep every sample so a whole run can be exported.
134    pub history_capacity: Option<usize>,
135    /// Position of the History length slider, kept while Unlimited is ticked so unticking restores it.
136    pub history_len: usize,
137    /// Host, adapter and device limits, collected once at startup.
138    pub runtime: RuntimeInfo,
139    /// Device and queue for rendering, present wherever the app runs. The renderer and the live
140    /// simulation report their errors into `faults`. A GPU sweep runs on its own device, and
141    /// its errors stay with the sweep. `gpu_ctx` below is a different thing, and gates GPU models.
142    pub render_ctx: GpuContext,
143    /// The fault being shown, cleared when the user dismisses the modal.
144    pub fault: Option<ShownFault>,
145    pub about_open: bool,
146    /// Note shown in the Performance tab when the browser's thread pool failed to start.
147    pub thread_pool_note: Option<String>,
148    /// About window's image, set when the window first opens. It holds `None` for an icon that is not a PNG.
149    pub logo_texture: std::cell::OnceCell<Option<TextureHandle>>,
150    pub timings: FrameTimings,
151    /// Device and queue a live GPU model builds on, `None` where the adapter cannot run compute shaders. The GPU
152    /// models are then hidden.
153    pub gpu_ctx: Option<GpuContext>,
154    /// A viewport capture waiting on the GPU.
155    pub capture: Option<PendingCapture>,
156    pub recording: Recording,
157    /// The last export's result, shown in the Export tab.
158    pub export_status: Option<String>,
159    /// Channel the save dialogs send their outcomes back on, from their own thread or task.
160    saves: (flume::Sender<SaveOutcome>, flume::Receiver<SaveOutcome>),
161    /// Channel the open dialogs send their outcomes back on, from their own thread or task.
162    opens: (flume::Sender<OpenOutcome>, flume::Receiver<OpenOutcome>),
163    pub sweep: SweepPanel,
164    pub results: ResultsPanel,
165    // GPU batching controls
166    pub gpu_adaptive: bool,
167    pub gpu_target_ms: f64,
168    pub gpu_batch_size: u32,
169}
170
171/// Fault the modal shows, with the model it stopped.
172#[derive(Debug)]
173pub struct ShownFault {
174    pub fault: Fault,
175    /// Name of the model the fault stopped, `None` when no model was building or loaded.
176    pub model: Option<String>,
177}
178
179/// Opening that the app could not open, as shown in the Model panel.
180#[derive(Debug, Clone, PartialEq, Eq)]
181pub struct OpeningRefusal {
182    /// Line naming what was not opened, as in "Run not opened".
183    pub lead: &'static str,
184    /// Reason, as in "This build does not include model 'x'".
185    pub reason: String,
186}
187
188/// Tick an opened run or setup is stepped to.
189#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190#[non_exhaustive]
191pub enum OpenAt {
192    /// Tick 0, paused.
193    Start,
194    /// A tick reached by stepping uncapped from tick 0.
195    Tick(u64),
196}
197
198/// Sweep run the loaded model was built from.
199#[derive(Debug, Clone, PartialEq)]
200pub struct OpenedRun {
201    pub replay: Replay,
202    /// Whether a live edit, an action or a build from other values has left the run's trajectory.
203    pub modified: bool,
204}
205
206impl OpenedRun {
207    /// Returns whether a build from `params`, `seed` and `schedule` steps through the run tick for tick.
208    pub fn is_built_by(&self, params: &[ParamValue], seed: Option<u64>, schedule: &Schedule) -> bool {
209        params == self.replay.params.as_slice() && seed == Some(self.replay.seed) && *schedule == self.replay.schedule
210    }
211}
212
213/// Draw style for an agent population in the viewport.
214#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
215pub enum PointRenderMode {
216    #[default]
217    Agents,
218    /// Density map of the agents. Cheaper past roughly a million agents, and readable where sprites would overlap
219    /// into a mass.
220    Density,
221}
222
223impl AppState {
224    /// Returns the state of an app over `models`, with the first model that runs on this machine selected, or
225    /// with nothing selected when no model runs on this machine.
226    ///
227    /// `gpu_ctx` is `None` on an adapter without compute. GPU models are then hidden from the Model panel, and
228    /// rendering is unaffected.
229    pub fn new(
230        egui_ctx: egui::Context,
231        models: ModelSet,
232        product: Product,
233        render_ctx: GpuContext,
234        gpu_ctx: Option<GpuContext>,
235        runtime: RuntimeInfo,
236    ) -> Self {
237        let first = models.runnable(gpu_ctx.as_ref()).next();
238        let selected_model = first.map(|entry| entry.id().to_owned());
239        let param_values: Vec<ParamValue> = first.map(default_values).unwrap_or_default();
240        let mut sweep = SweepPanel::default();
241        sweep.cli_command.clone_from(&product.cli_command);
242
243        Self {
244            egui_ctx,
245            pending_reload: vec![false; param_values.len()],
246            models,
247            product,
248            opening_refusal: None,
249            selected_model,
250            param_values,
251            loaded_model: None,
252            seed: None,
253            seed_text: String::new(),
254            loaded_values: Vec::new(),
255            loaded_seed: None,
256            schedule: Schedule::default(),
257            loaded_schedule: Schedule::default(),
258            schedule_action_input: 0,
259            schedule_tick_input: 0,
260            opened_run: None,
261            run_to_target: None,
262            run_to_input: 0,
263            focus_request: None,
264            sim_thread: None,
265            snapshot: None,
266            sim_running: false,
267            grid_texture: None,
268            density_texture: None,
269            density_max: 4.0,
270            point_render_mode: PointRenderMode::default(),
271            show_edges: true,
272            edge_arrows: false,
273            agent_layer: None,
274            last_rendered_serial: None,
275            rendering_enabled: true,
276            target_tps: 30.0,
277            uncapped: false,
278            ticks_per_snapshot: 1,
279            layout_on: true,
280            layout_budget_ms: DEFAULT_LAYOUT_BUDGET_MS,
281            layout_while_paused: false,
282            stats_history: None,
283            history_capacity: Some(DEFAULT_HISTORY_LEN),
284            history_len: DEFAULT_HISTORY_LEN,
285            runtime,
286            render_ctx,
287            fault: None,
288            about_open: false,
289            thread_pool_note: None,
290            logo_texture: std::cell::OnceCell::new(),
291            timings: FrameTimings::default(),
292            gpu_ctx,
293            capture: None,
294            recording: Recording::Off,
295            export_status: None,
296            saves: flume::unbounded(),
297            opens: flume::unbounded(),
298            sweep,
299            results: ResultsPanel::default(),
300            gpu_adaptive: true,
301            gpu_target_ms: DEFAULT_TARGET_MS,
302            gpu_batch_size: DEFAULT_BATCH_SIZE,
303        }
304    }
305
306    /// Tears down the live simulation and builds the selected model from the fields the next build reads.
307    ///
308    /// Does nothing when [`Self::build_setup`] rejects the fields. Build is disabled then, with the reason
309    /// [`setup_message`] returns.
310    pub fn reset_simulation(&mut self) {
311        let setup = match self.build_setup() {
312            Some(Ok(setup)) => Some(setup),
313            Some(Err(error)) => {
314                log::warn!("Build refused: {}", setup_message(&error));
315                return;
316            }
317            None => None,
318        };
319        self.settle_opened_run();
320        self.stop_recording();
321        // Dropping the sim thread releases a GPU model's buffers and pipelines. A paint callback still in flight this
322        // frame holds its own `Arc` to the display, and keeps the texture alive.
323        self.sim_thread = None;
324        drop(self.render_ctx.faults.take());
325        self.snapshot = None;
326        self.last_rendered_serial = None;
327        self.grid_texture = None;
328        self.density_texture = None;
329        self.density_max = 4.0;
330        self.ticks_per_snapshot = 1;
331        self.loaded_model = None;
332        self.export_status = None;
333        self.run_to_target = None;
334        // The next model may have no agents at all.
335        if let Some(layer) = &mut self.agent_layer {
336            layer.clear();
337        }
338
339        let Some(setup) = setup else {
340            return;
341        };
342
343        let stats_history = StatsHistory::new(setup.entry().stat_descriptors().to_vec(), self.history_capacity);
344
345        match self.build_runner(&setup, &self.repaint_waker()) {
346            Ok(mut runner) => {
347                if !self.schedule.is_empty() {
348                    runner.send(SimCommand::SetSchedule(self.schedule.clone()));
349                }
350                self.sim_thread = Some(runner);
351            }
352            Err(fault) => {
353                self.report_fault(fault);
354                return;
355            }
356        }
357
358        self.stats_history = Some(stats_history);
359        self.sim_running = false;
360        self.loaded_model.clone_from(&self.selected_model);
361        self.pending_reload = vec![false; self.param_values.len()];
362        self.loaded_values.clone_from(&self.param_values);
363        self.loaded_seed = self.seed;
364        self.loaded_schedule = self.schedule.clone();
365    }
366
367    /// Clears the opened run before a build of another model, or sets its `modified` to whether the build's values
368    /// differ from the run's.
369    ///
370    /// A rebuild from the run's own values clears the mark that [`Self::mark_opened_run_modified`] sets.
371    fn settle_opened_run(&mut self) {
372        let Some(run) = &mut self.opened_run else {
373            return;
374        };
375        if self.selected_model.as_deref() != Some(run.replay.model.as_str()) {
376            self.opened_run = None;
377            return;
378        }
379        run.modified = !run.is_built_by(&self.param_values, self.seed, &self.schedule);
380    }
381
382    /// Opens `opening`, the results, run or setup that the host requested.
383    ///
384    /// A run or a setup this machine cannot open leaves the app with nothing selected, and the Model panel shows the
385    /// reason.
386    pub fn open(&mut self, opening: AppOpening) {
387        let opened = match opening {
388            #[cfg(not(target_arch = "wasm32"))]
389            AppOpening::Results(folder) => {
390                crate::ui::results::open_folder(self, folder);
391                self.focus_request = Some(Tab::Results);
392                Ok(())
393            }
394            AppOpening::Run { replay, open_at } => self.open_run(replay, open_at).map_err(|reason| OpeningRefusal {
395                lead: "Run not opened",
396                reason,
397            }),
398            AppOpening::Setup { setup, open_at } => self.open_setup(&setup, open_at).map_err(|reason| OpeningRefusal {
399                lead: "Model not opened",
400                reason,
401            }),
402        };
403        if let Err(refusal) = opened {
404            log::warn!("{}: {}", refusal.lead, refusal.reason);
405            self.selected_model = None;
406            self.param_values.clear();
407            self.pending_reload.clear();
408            self.schedule = Schedule::default();
409            self.opening_refusal = Some(refusal);
410            self.focus_request = Some(Tab::Model);
411        }
412    }
413
414    /// Builds the run `replay` holds, optionally steps it to a tick, and brings the viewport to the front.
415    ///
416    /// # Errors
417    ///
418    /// Returns a message when this build or this machine has no model `replay.model`, or the model declares a different
419    /// number of parameters. A build that fails goes to the fault modal instead, and leaves no run open.
420    pub fn open_run(&mut self, replay: Replay, start: OpenAt) -> Result<(), String> {
421        let declared = self
422            .lookup(&replay.model)
423            .map_err(|error| lookup_message(&error))?
424            .param_descriptors()
425            .len();
426        if replay.params.len() != declared {
427            return Err(format!(
428                "This run sets {} {}, but model '{}' has {declared}",
429                replay.params.len(),
430                crate::ui::plural(replay.params.len() as u64, "parameter"),
431                replay.model
432            ));
433        }
434
435        let entry = self.lookup(&replay.model).map_err(|error| lookup_message(&error))?;
436        RunSetup::from_replay(entry, &replay).map_err(|error| setup_message(&error))?;
437
438        let built = self.open_values(
439            &replay.model,
440            &replay.params,
441            Some(replay.seed),
442            &replay.schedule,
443            start,
444        );
445        if built {
446            self.opened_run = Some(OpenedRun {
447                replay,
448                modified: false,
449            });
450        }
451        Ok(())
452    }
453
454    /// Builds the model of the set under `setup`'s model id from the setup's values, seed and schedule, optionally
455    /// steps it to a tick, and brings the viewport to the front.
456    ///
457    /// The setup's fields are stored in the fields the next build reads, and a default seed stays the default.
458    ///
459    /// # Errors
460    ///
461    /// Returns a message when this build or this machine has no model under the setup's id, or that model rejects the
462    /// setup's values. A build that fails goes to the fault modal instead.
463    pub fn open_setup(&mut self, setup: &RunSetup, start: OpenAt) -> Result<(), String> {
464        let id = setup.entry().id();
465        let entry = self.lookup(id).map_err(|error| lookup_message(&error))?;
466        RunSetup::from_parts(entry, setup.values(), setup.seed(), setup.schedule().clone())
467            .map_err(|error| setup_message(&error))?;
468        self.open_values(id, setup.values(), setup.seed(), setup.schedule(), start);
469        Ok(())
470    }
471
472    /// Selects model `id`, builds it from `params`, `seed` and `schedule`, steps it to `start`, and brings the
473    /// viewport to the front. Returns whether the build succeeded.
474    fn open_values(
475        &mut self,
476        id: &str,
477        params: &[ParamValue],
478        seed: Option<u64>,
479        schedule: &Schedule,
480        start: OpenAt,
481    ) -> bool {
482        self.opened_run = None;
483        self.opening_refusal = None;
484        self.selected_model = Some(id.to_owned());
485        self.param_values = params.to_vec();
486        self.pending_reload = vec![false; params.len()];
487        self.seed = seed;
488        self.seed_text = seed.map(|seed| seed.to_string()).unwrap_or_default();
489        self.schedule = schedule.clone();
490        self.schedule_action_input = 0;
491        self.reset_simulation();
492        if self.sim_thread.is_none() {
493            return false;
494        }
495        if let OpenAt::Tick(tick) = start {
496            self.run_to_input = tick;
497            self.run_to(tick);
498        }
499        self.focus_request = Some(Tab::Viewport);
500        true
501    }
502
503    /// Steps the loaded model uncapped to `tick` and pauses there.
504    ///
505    /// A tick behind the current one rebuilds first. Note that the rebuild builds the selected model, which is the
506    /// loaded one only while [`Self::selection_is_loaded`] holds.
507    pub fn run_to(&mut self, tick: u64) {
508        if self.snapshot.as_ref().is_some_and(|snap| tick < snap.tick) {
509            self.reset_simulation();
510        }
511        let Some(thread) = &mut self.sim_thread else {
512            return;
513        };
514        thread.send(SimCommand::RunTo(tick));
515        self.sim_running = false;
516        self.run_to_target = Some(tick);
517    }
518
519    /// Marks the opened run modified, after a live edit or an action.
520    pub fn mark_opened_run_modified(&mut self) {
521        if let Some(run) = &mut self.opened_run {
522            run.modified = true;
523        }
524    }
525
526    /// Returns whether the Seed field holds a seed the loaded model was not built with.
527    pub fn seed_pending(&self) -> bool {
528        self.selection_is_loaded() && self.loaded_seed != self.seed
529    }
530
531    /// Returns whether the scheduled actions differ from those the loaded model was built with.
532    pub fn schedule_pending(&self) -> bool {
533        self.selection_is_loaded() && self.loaded_schedule != self.schedule
534    }
535
536    /// Builds the selected model and the thread that will drive it.
537    ///
538    /// # Errors
539    ///
540    /// Returns the fault when the build panics, the GPU rejects it, or this machine has no device for a GPU model.
541    fn build_runner(&self, setup: &RunSetup, wake: &WakeFn) -> Result<SimRunner, Fault> {
542        let entry = setup.entry();
543        match entry.build(setup.values(), setup.seed(), self.gpu_ctx.as_ref())? {
544            ModelState::Cpu(state) => catching(BUILDING, || {
545                let mut thread = SimThread::new(
546                    state,
547                    self.target_tps,
548                    Some(wake.clone()),
549                    self.render_ctx.faults.clone(),
550                );
551                if self.uncapped {
552                    thread.send(SimCommand::SetUncapped(true));
553                }
554                if entry.topology_hint().edges {
555                    thread.send(self.layout_command());
556                }
557                SimRunner::Cpu(thread)
558            }),
559            ModelState::Gpu(state) => {
560                // Unreachable in practice. Without a context the Model panel hides every GPU entry,
561                // and an entry built without one returns a fault.
562                let Some(ctx) = self.gpu_ctx.clone() else {
563                    return Err(Fault::refused(BUILDING, "no GPU context is available"));
564                };
565                let settings = GpuBatchSettings {
566                    adaptive: self.gpu_adaptive,
567                    batch_size: self.gpu_batch_size,
568                    target_ms: self.gpu_target_ms,
569                };
570                catching_on(&ctx.clone(), BUILDING, || {
571                    SimRunner::Gpu(GpuSimThread::new(ctx, state, settings, Some(wake.clone())))
572                })
573            }
574        }
575    }
576
577    /// Returns a waker that repaints the UI.
578    ///
579    /// A sim thread or a sweep calls it after publishing, so an idle UI picks the result up next frame instead of
580    /// waiting for whatever input event happens to arrive.
581    #[cfg_attr(
582        all(target_arch = "wasm32", target_feature = "atomics"),
583        expect(
584            clippy::arc_with_non_send_sync,
585            reason = "a `WakeFn` is not `Send` on wasm with atomics"
586        )
587    )]
588    pub fn repaint_waker(&self) -> WakeFn {
589        let ctx = self.egui_ctx.clone();
590        Arc::new(move || ctx.request_repaint())
591    }
592
593    /// Pauses the live simulation, a run to a tick included.
594    pub fn pause_simulation(&mut self) {
595        let Some(thread) = &mut self.sim_thread else {
596            return;
597        };
598        if self.sim_running || self.run_to_target.is_some() {
599            thread.pause();
600        }
601        self.sim_running = false;
602        self.run_to_target = None;
603    }
604
605    /// Offloads the live simulation and passes the fault to the modal. A running sweep carries on.
606    ///
607    /// A fault while building belongs to the selected model, and any other to the loaded one. The Model panel stays
608    /// usable while a model runs, and the selection can be another model by then.
609    pub fn report_fault(&mut self, fault: Fault) {
610        log::error!("{fault}");
611        let model = if fault.during == BUILDING {
612            self.selected_entry()
613        } else {
614            self.loaded_entry()
615        };
616        let model = model.map(|entry| entry.name().to_owned());
617        self.offload_simulation();
618        self.fault = Some(ShownFault { fault, model });
619    }
620
621    pub fn offload_simulation(&mut self) {
622        self.stop_recording();
623        self.sim_thread = None;
624        drop(self.render_ctx.faults.take());
625        self.snapshot = None;
626        self.sim_running = false;
627        self.grid_texture = None;
628        self.density_texture = None;
629        self.last_rendered_serial = None;
630        self.stats_history = None;
631        self.loaded_model = None;
632        self.opened_run = None;
633        self.run_to_target = None;
634        if let Some(layer) = &mut self.agent_layer {
635            layer.clear();
636        }
637    }
638
639    pub fn layout_command(&self) -> SimCommand {
640        SimCommand::SetLayout {
641            on: self.layout_on,
642            budget_ms: self.layout_budget_ms,
643            while_paused: self.layout_while_paused,
644        }
645    }
646
647    pub fn edge_style(&self) -> EdgeStyle {
648        EdgeStyle {
649            visible: self.show_edges,
650            arrows: self.edge_arrows,
651        }
652    }
653
654    pub fn selection_is_loaded(&self) -> bool {
655        self.loaded_model.is_some() && self.loaded_model == self.selected_model
656    }
657
658    /// Entry of the selected model.
659    pub fn selected_entry(&self) -> Option<&ModelEntry> {
660        self.models.get(self.selected_model.as_deref()?)
661    }
662
663    /// Returns the setup the next build reads, checked against the selected model, or `None` with no model selected.
664    ///
665    /// The setup holds `param_values`, `seed` and `schedule`, each checked as [`RunSetup::from_parts`] checks them.
666    pub fn build_setup(&self) -> Option<Result<RunSetup, SetupError>> {
667        let entry = self.selected_entry()?;
668        Some(RunSetup::from_parts(
669            entry,
670            &self.param_values,
671            self.seed,
672            self.schedule.clone(),
673        ))
674    }
675
676    /// Entry of the model the live simulation was built from.
677    pub fn loaded_entry(&self) -> Option<&ModelEntry> {
678        self.models.get(self.loaded_model.as_deref()?)
679    }
680
681    /// Returns the models of the set that run on this machine, in the set's order.
682    pub fn offered_models(&self) -> impl Iterator<Item = &ModelEntry> + '_ {
683        self.models.runnable(self.gpu_ctx.as_ref())
684    }
685
686    /// Returns entry `id`, or the reason this build or this machine cannot run it.
687    ///
688    /// # Errors
689    ///
690    /// Returns [`ModelLookupError::NotInSet`] for a model the host does not offer, and
691    /// [`ModelLookupError::NeedsGpu`] for a GPU model on an adapter without compute.
692    pub fn lookup(&self, id: &str) -> Result<&ModelEntry, ModelLookupError> {
693        self.models.lookup(id, self.gpu_ctx.as_ref())
694    }
695
696    /// Selects model `id` with its default values and no scheduled actions, or the values, seed and actions the
697    /// loaded model runs with when `id` is the loaded model.
698    ///
699    /// An already selected model keeps its values. An id missing from the set leaves no values.
700    pub fn select_model(&mut self, id: &str) {
701        if self.selected_model.as_deref() == Some(id) {
702            return;
703        }
704        self.selected_model = Some(id.to_owned());
705        self.opening_refusal = None;
706        self.schedule_action_input = 0;
707        if self.loaded_model.as_deref() == Some(id) {
708            self.param_values.clone_from(&self.loaded_values);
709            self.pending_reload = vec![false; self.param_values.len()];
710            self.seed = self.loaded_seed;
711            self.seed_text = self.loaded_seed.map(|seed| seed.to_string()).unwrap_or_default();
712            self.schedule = self.loaded_schedule.clone();
713            return;
714        }
715        self.param_values = self.models.get(id).map(default_values).unwrap_or_default();
716        self.pending_reload = vec![false; self.param_values.len()];
717        // Entries index the previous model's actions.
718        self.schedule = Schedule::default();
719    }
720
721    /// Sends a live edit of parameter `index` to the loaded model, and records `value` as a value that the model
722    /// runs with.
723    ///
724    /// Returns whether the edit was sent. It is sent only while the selection is the loaded model.
725    pub fn send_live_param(&mut self, index: usize, value: ParamValue) -> bool {
726        if !self.selection_is_loaded() {
727            return false;
728        }
729        let Some(thread) = &mut self.sim_thread else {
730            return false;
731        };
732        thread.send(SimCommand::SetParam {
733            index,
734            value: value.clone(),
735        });
736        if let Some(slot) = self.loaded_values.get_mut(index) {
737            *slot = value;
738        }
739        true
740    }
741
742    /// Reasons this machine cannot build the selection. Always empty for a CPU model.
743    pub fn selection_shortfalls(&self) -> Vec<String> {
744        self.selected_entry().map_or_else(Vec::new, |entry| {
745            entry.shortfalls(&self.param_values, &self.runtime.granted)
746        })
747    }
748
749    pub fn is_gpu(&self) -> bool {
750        self.sim_thread.as_ref().is_some_and(|t| t.gpu_stats().is_some())
751    }
752
753    /// Opens a save dialog for `bytes` under the name `name`, for the panel `target`.
754    ///
755    /// Results can be polled via [`Self::poll_saves`].
756    pub fn save_as(&mut self, target: SaveTarget, name: &str, bytes: Vec<u8>) {
757        *self.save_status(target) = Some(format!("{SAVE_PENDING} {name}"));
758        spawn_save(
759            target,
760            name.to_owned(),
761            bytes,
762            self.saves.0.clone(),
763            self.egui_ctx.clone(),
764        );
765    }
766
767    /// Opens a dialog that saves the four files of a sweep, `files`, together, for the panel `target`.
768    ///
769    /// Results can be polled via [`Self::poll_saves`].
770    pub fn save_files(&mut self, target: SaveTarget, files: Arc<SweepFiles>) {
771        *self.save_status(target) = Some(format!("{SAVE_PENDING} {} files", files.entries().len()));
772        spawn_save_files(target, files, self.saves.0.clone(), self.egui_ctx.clone());
773    }
774
775    /// Returns the status line a save for `target` reports to.
776    fn save_status(&mut self, target: SaveTarget) -> &mut Option<String> {
777        match target {
778            SaveTarget::Export => &mut self.export_status,
779            SaveTarget::SweepSpec | SaveTarget::SweepResults(_) => &mut self.sweep.status,
780        }
781    }
782
783    pub fn poll_saves(&mut self) {
784        while let Ok(SaveOutcome { target, result }) = self.saves.1.try_recv() {
785            // A download of one file counts as saved. The user requested it, and the app cannot read back anything
786            // the browser asks the user. The downloads of several files leave them unsaved. A browser can hold back
787            // every download after the first.
788            if let SaveTarget::SweepResults(generation) = target
789                && matches!(result, SaveResult::Saved(_) | SaveResult::Downloaded(_))
790            {
791                self.results.mark_files_saved(generation);
792            }
793            *self.save_status(target) = Some(match result {
794                SaveResult::Saved(name) => format!("Saved {name}"),
795                SaveResult::Downloaded(name) => format!("Downloaded {name}"),
796                SaveResult::DownloadsStarted => DOWNLOADS_STARTED.to_owned(),
797                SaveResult::Failed(message) => format!("Save failed: {message}"),
798                SaveResult::Canceled => "Save canceled".to_owned(),
799            });
800        }
801    }
802
803    /// Opens a dialog that picks the files or the folder that `target` requests.
804    ///
805    /// Results can be polled via [`Self::poll_opens`].
806    pub fn open_file(&self, target: OpenTarget) {
807        spawn_open(target, self.opens.0.clone(), self.egui_ctx.clone());
808    }
809
810    pub fn poll_opens(&mut self) {
811        while let Ok(OpenOutcome { target, result }) = self.opens.1.try_recv() {
812            match target {
813                OpenTarget::SweepSpec | OpenTarget::DesignTable | OpenTarget::OutputFolder => {
814                    crate::ui::sweep::receive_open(self, target, result);
815                }
816                OpenTarget::Results => crate::ui::results::receive_open(self, result),
817            }
818        }
819    }
820
821    /// Appends the snapshot's stats to the recording, and stops the recording on a write error.
822    pub fn record(&mut self, snapshot: &Snapshot) {
823        if let Err(err) = self.recording.push(snapshot.tick, &snapshot.stats) {
824            self.export_status = Some(format!("Recording stopped: {err}"));
825            self.recording = Recording::Off;
826        }
827    }
828
829    /// Stops recording and closes the CSV file.
830    pub fn stop_recording(&mut self) {
831        let to_tick = self.snapshot.as_ref().map_or(0, |snap| snap.tick);
832        match std::mem::replace(&mut self.recording, Recording::Off).stop(to_tick) {
833            Ok(recording) => self.recording = recording,
834            Err(err) => self.export_status = Some(format!("Recording failed: {err}")),
835        }
836    }
837
838    /// Draws the layers into an offscreen target at their own resolution and starts reading it back.
839    ///
840    /// Results can be polled via [`Self::poll_capture`].
841    pub fn request_viewport_capture(&mut self, name: String) {
842        match crate::ui::export::image::start(self, name) {
843            Ok(pending) => {
844                self.export_status = Some("Capturing viewport".to_owned());
845                self.capture = Some(pending);
846            }
847            Err(err) => self.export_status = Some(format!("Capture failed: {err}")),
848        }
849    }
850
851    pub fn poll_capture(&mut self) {
852        let Some(pending) = &self.capture else {
853            return;
854        };
855        let Some(result) = pending.poll(&self.render_ctx.device) else {
856            return;
857        };
858        let name = pending.name.clone();
859        self.capture = None;
860        match result {
861            Ok(png) => self.save_as(SaveTarget::Export, &name, png),
862            Err(err) => self.export_status = Some(format!("Capture failed: {err}")),
863        }
864    }
865}
866
867#[cfg(test)]
868impl AppState {
869    /// Returns an app over `models` on a headless device, or `None` to skip on a machine without a device.
870    ///
871    /// Without `compute` the app sees an adapter that cannot run compute shaders, and hides its GPU models.
872    ///
873    /// # Panics
874    ///
875    /// Panics when `HENAD_REQUIRE_GPU` is set and no device is available.
876    pub fn headless(models: ModelSet, compute: bool) -> Option<Self> {
877        use henad_explore::testing::{TestDeviceRequest, headless_test_device};
878
879        let ctx = headless_test_device(&TestDeviceRequest::raised(models.gpu_needs()))?;
880        let runtime = ctx
881            .runtime_info()
882            .expect("a headless device carries its runtime info")
883            .clone();
884        let product = crate::options::AppOptions::new(
885            ModelSet::new(henad_core::build_info!()),
886            "Henad",
887            henad_core::build_info!(),
888        )
889        .cli_command("henad-cli")
890        .__official()
891        .product;
892        let gpu_ctx = compute.then(|| ctx.clone());
893        Some(Self::new(
894            egui::Context::default(),
895            models,
896            product,
897            ctx,
898            gpu_ctx,
899            runtime,
900        ))
901    }
902}
903
904/// Returns the default value of every parameter of `entry`.
905pub fn default_values(entry: &ModelEntry) -> Vec<ParamValue> {
906    entry
907        .param_descriptors()
908        .iter()
909        .map(|descriptor| descriptor.kind.default_value())
910        .collect()
911}
912
913/// Returns `error` capitalised, as in "This build does not include model 'x'".
914pub fn lookup_message(error: &ModelLookupError) -> String {
915    crate::ui::sweep::draft::capitalize(&error.to_string())
916}
917
918/// Returns `error` as Build's disabled reason, as in `Parameter 'infection_rate': 2 is outside 0..=1`.
919pub fn setup_message(error: &SetupError) -> String {
920    use crate::ui::sweep::draft::describe_error;
921    match error {
922        SetupError::Param(reason) => describe_error(reason),
923        other => describe_error(other),
924    }
925}
926
927#[cfg(test)]
928mod tests {
929    use henad_core::action::{Schedule, Scheduled};
930    use henad_core::explore::replay::Replay;
931    use henad_core::params::ParamValue;
932
933    use henad_compute::entry::ModelSet;
934    use henad_compute::fault::{BUILDING, Fault, STEPPING};
935    use henad_core::metadata::Backend;
936    use henad_explore::output::details::{ChoiceForm, params_by_id_json};
937
938    use super::{AppState, OpenAt, OpenedRun};
939    use crate::options::AppOpening;
940    use crate::ui::dock::Tab;
941    use crate::ui::export::metadata::run_details;
942
943    /// Returns an app over the example models whose adapter, as the app sees it, cannot run compute shaders, or `None`
944    /// to skip on a machine without a device.
945    fn app_without_compute() -> Option<AppState> {
946        AppState::headless(henad_models::example_models(), false)
947    }
948
949    /// Returns a replay of model `model` with no parameters.
950    fn replay_of(model: &str) -> Replay {
951        Replay {
952            model: model.to_owned(),
953            params: Vec::new(),
954            seed: 1,
955            schedule: Schedule::default(),
956            ticks: 10,
957            label: "Sweep run 0".to_owned(),
958        }
959    }
960
961    #[test]
962    fn an_app_without_compute_hides_gpu_models_and_names_each_missing_one() {
963        let Some(mut app) = app_without_compute() else {
964            return;
965        };
966        let offered: Vec<&str> = app.offered_models().map(|entry| entry.id()).collect();
967        assert_eq!(
968            offered,
969            ["sir", "boids", "game_of_life", "ants", "virus_network", "team_assembly"]
970        );
971        assert_eq!(
972            app.selected_model.as_deref(),
973            Some("sir"),
974            "the first model that runs here"
975        );
976
977        assert_eq!(
978            app.open_run(replay_of("gpu_sir"), OpenAt::Start),
979            Err("Model 'gpu_sir' needs a GPU with compute support, and this device has none".to_owned())
980        );
981        assert_eq!(
982            app.open_run(replay_of("absent"), OpenAt::Start),
983            Err("This build does not include model 'absent'".to_owned())
984        );
985        assert_eq!(
986            app.selected_model.as_deref(),
987            Some("sir"),
988            "a refused run leaves the selection alone"
989        );
990    }
991
992    #[test]
993    fn a_build_follows_the_run_only_from_its_own_values() {
994        let schedule = Schedule::from_entries(vec![Scheduled {
995            index: 0,
996            id: "seed_outbreak".to_owned(),
997            tick: 50,
998        }]);
999        let params = vec![ParamValue::U32(64), ParamValue::F32(0.3)];
1000        let run = OpenedRun {
1001            replay: Replay {
1002                model: "sir".to_owned(),
1003                params: params.clone(),
1004                seed: 42,
1005                schedule: schedule.clone(),
1006                ticks: 300,
1007                label: "Sweep run 0: config 0, replicate 0".to_owned(),
1008            },
1009            modified: false,
1010        };
1011
1012        assert!(run.is_built_by(&params, Some(42), &schedule));
1013        assert!(
1014            !run.is_built_by(&params, None, &schedule),
1015            "the Default seed matched seed 42"
1016        );
1017        assert!(!run.is_built_by(&params, Some(43), &schedule), "another seed");
1018        assert!(!run.is_built_by(&params, Some(42), &Schedule::default()), "no schedule");
1019        let edited = [ParamValue::U32(64), ParamValue::F32(0.4)];
1020        assert!(!run.is_built_by(&edited, Some(42), &schedule), "an edited parameter");
1021    }
1022
1023    #[test]
1024    fn an_opened_setup_keeps_the_default_seed() {
1025        let Some(mut app) = app_without_compute() else {
1026            return;
1027        };
1028        let sir = app.models.get("sir").expect("the example models include sir").clone();
1029        let setup = sir
1030            .setup()
1031            .set("infection_rate", 0.4_f32)
1032            .and_then(|setup| setup.act_at("seed_outbreak", 20))
1033            .expect("a valid setup");
1034
1035        assert_eq!(app.open_setup(&setup, OpenAt::Start), Ok(()));
1036        assert_eq!(app.selected_model.as_deref(), Some("sir"));
1037        assert_eq!(app.param_values, setup.values());
1038        assert_eq!(app.schedule, *setup.schedule());
1039        assert_eq!(app.seed, None, "the default seed became a number");
1040        assert_eq!(app.seed_text, "", "the Seed field shows the Default hint");
1041        assert!(app.sim_thread.is_some(), "the setup built");
1042        assert_eq!(app.loaded_seed, None);
1043        assert_eq!(app.loaded_schedule, *setup.schedule());
1044        assert_eq!(app.opened_run, None, "a setup is no sweep run");
1045        assert_eq!(app.focus_request, Some(Tab::Viewport));
1046
1047        assert_eq!(app.open_setup(&setup.with_seed(7), OpenAt::Start), Ok(()));
1048        assert_eq!(app.seed, Some(7));
1049        assert_eq!(app.seed_text, "7");
1050        assert_eq!(app.loaded_seed, Some(7));
1051    }
1052
1053    /// Picking the loaded model again restores its values. Otherwise the run details would record values the model
1054    /// was not built with.
1055    #[test]
1056    fn picking_the_loaded_model_again_restores_what_it_runs_with() {
1057        let Some(mut app) = app_without_compute() else {
1058            return;
1059        };
1060        let sir = app.models.get("sir").expect("the example models include sir").clone();
1061        let setup = sir
1062            .setup()
1063            .set("infection_rate", 0.4_f32)
1064            .and_then(|setup| setup.act_at("seed_outbreak", 20))
1065            .expect("a valid setup")
1066            .with_seed(9);
1067        assert_eq!(app.open_setup(&setup, OpenAt::Start), Ok(()));
1068        let details = |app: &AppState| -> serde_json::Value {
1069            serde_json::from_str(&run_details(app)).expect("the run details are JSON")
1070        };
1071        let recorded = params_by_id_json(sir.param_descriptors(), setup.values(), ChoiceForm::Name);
1072
1073        app.select_model("boids");
1074        assert_eq!(
1075            details(&app)["params"],
1076            recorded,
1077            "another model's values under sir's ids"
1078        );
1079        app.select_model("sir");
1080        assert_eq!(app.param_values, setup.values());
1081        assert_eq!(app.pending_reload, vec![false; setup.values().len()]);
1082        assert_eq!((app.seed, app.seed_text.as_str()), (Some(9), "9"));
1083        assert_eq!(app.schedule, *setup.schedule());
1084        assert!(!app.seed_pending() && !app.schedule_pending());
1085        assert_eq!(details(&app)["params"], recorded);
1086        assert_eq!(details(&app)["params_match_running_model"], true);
1087
1088        let (index, live) = sir
1089            .param_descriptors()
1090            .iter()
1091            .enumerate()
1092            .find(|(_, descriptor)| descriptor.is_live())
1093            .expect("sir has a live parameter");
1094        let edited = match live.kind.default_value() {
1095            ParamValue::F32(_) => ParamValue::F32(0.25),
1096            other => other,
1097        };
1098        app.param_values[index] = edited.clone();
1099        assert!(app.send_live_param(index, edited.clone()));
1100        app.select_model("boids");
1101        assert!(
1102            !app.send_live_param(index, edited.clone()),
1103            "an edit of a model not selected"
1104        );
1105        app.select_model("sir");
1106        assert_eq!(
1107            app.param_values[index], edited,
1108            "the live edit came back with the model"
1109        );
1110        assert_eq!(app.loaded_values[index], edited);
1111    }
1112
1113    /// The modal shows the name of the model that faulted. The selection can be another model by then.
1114    #[test]
1115    fn a_fault_names_the_model_it_stopped() {
1116        let Some(mut app) = app_without_compute() else {
1117            return;
1118        };
1119        let name = |app: &AppState, id: &str| app.models.get(id).map(|entry| entry.name().to_owned());
1120        app.reset_simulation();
1121        assert_eq!(app.loaded_model.as_deref(), Some("sir"));
1122        app.select_model("boids");
1123        app.report_fault(Fault::refused(STEPPING, "a test fault"));
1124        assert_eq!(
1125            app.fault.as_ref().and_then(|shown| shown.model.clone()),
1126            name(&app, "sir")
1127        );
1128        assert_eq!(app.loaded_model, None, "the faulted model is offloaded");
1129
1130        app.report_fault(Fault::refused(BUILDING, "a test fault"));
1131        assert_eq!(
1132            app.fault.as_ref().and_then(|shown| shown.model.clone()),
1133            name(&app, "boids"),
1134            "a build fault is the selected model's"
1135        );
1136    }
1137
1138    #[test]
1139    fn a_run_with_another_parameter_count_names_its_model() {
1140        let Some(mut app) = app_without_compute() else {
1141            return;
1142        };
1143        let declared = app
1144            .models
1145            .get("sir")
1146            .expect("the example models include sir")
1147            .param_descriptors()
1148            .len();
1149        assert_eq!(
1150            app.open_run(replay_of("sir"), OpenAt::Start),
1151            Err(format!("This run sets 0 parameters, but model 'sir' has {declared}"))
1152        );
1153    }
1154
1155    #[test]
1156    fn a_gpu_only_set_without_compute_opens_with_nothing_selected() {
1157        let mut models = ModelSet::new(henad_core::build_info!());
1158        for entry in &henad_models::example_models() {
1159            if entry.metadata().backend == Backend::Gpu {
1160                models.insert(entry.clone()).expect("example ids are unique");
1161            }
1162        }
1163        assert!(!models.is_empty());
1164        let Some(app) = AppState::headless(models, false) else {
1165            return;
1166        };
1167        assert_eq!(app.offered_models().count(), 0);
1168        assert_eq!(app.selected_model, None);
1169        assert!(app.selected_entry().is_none());
1170        assert!(app.param_values.is_empty());
1171        assert!(app.build_setup().is_none(), "Build has nothing to build");
1172    }
1173
1174    #[test]
1175    fn an_opening_of_a_hidden_gpu_model_opens_with_nothing_selected() {
1176        let Some(mut app) = app_without_compute() else {
1177            return;
1178        };
1179        let needs_gpu = "Model 'gpu_sir' needs a GPU with compute support, and this device has none";
1180        let gpu_sir = app
1181            .models
1182            .get("gpu_sir")
1183            .expect("the example models include gpu_sir")
1184            .clone();
1185
1186        app.open(AppOpening::Setup {
1187            setup: gpu_sir.setup(),
1188            open_at: OpenAt::Start,
1189        });
1190        assert_eq!(app.selected_model, None);
1191        assert!(app.param_values.is_empty());
1192        assert!(app.sim_thread.is_none());
1193        assert_eq!(
1194            app.opening_refusal.as_ref().map(|refusal| refusal.reason.as_str()),
1195            Some(needs_gpu)
1196        );
1197        assert_eq!(app.focus_request, Some(Tab::Model), "the Model panel shows the reason");
1198        assert_eq!(
1199            app.opening_refusal.as_ref().map(|refusal| refusal.lead),
1200            Some("Model not opened")
1201        );
1202
1203        let mut replay = replay_of("gpu_sir");
1204        replay.params = gpu_sir.setup().values().to_vec();
1205        app.open(AppOpening::Run {
1206            replay,
1207            open_at: OpenAt::Tick(5),
1208        });
1209        assert_eq!(app.selected_model, None);
1210        assert_eq!(
1211            app.opening_refusal.as_ref().map(|refusal| refusal.reason.as_str()),
1212            Some(needs_gpu)
1213        );
1214        assert_eq!(app.run_to_target, None);
1215        assert_eq!(
1216            app.opening_refusal.as_ref().map(|refusal| refusal.lead),
1217            Some("Run not opened")
1218        );
1219
1220        app.select_model("sir");
1221        assert_eq!(app.opening_refusal, None, "a selected model replaces the reason");
1222    }
1223}