Skip to main content

gammaloop_api/commands/
integrate.rs

1use std::{
2    any::Any,
3    collections::{BTreeMap, HashSet},
4    fmt, fs,
5    io::{self, IsTerminal, Write},
6    ops::Deref,
7    panic::{self, AssertUnwindSafe},
8    path::PathBuf,
9    sync::mpsc,
10    thread,
11    time::Duration,
12};
13
14use clap::{ArgAction, Args, ValueEnum};
15use crossterm::{
16    cursor::{Hide, MoveDown, MoveToColumn, MoveUp, Show},
17    event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
18    execute, queue,
19    terminal::{
20        self, disable_raw_mode, enable_raw_mode, Clear, ClearType, EnterAlternateScreen,
21        LeaveAlternateScreen,
22    },
23};
24use gammalooprs::integrands::process::GaussianReferenceFunction;
25use gammalooprs::utils::serde_utils::{IsDefault, SmartSerde};
26use schemars::JsonSchema;
27use serde::{Deserialize, Serialize};
28use spenso::algebra::complex::Complex;
29use unicode_width::UnicodeWidthChar;
30
31use color_eyre::{eyre::eyre, Result};
32use colored::Colorize;
33use gammalooprs::{
34    integrands::{HasIntegrand, Integrand},
35    integrate::{
36        build_integration_result, emit_integration_status_via_tracing, havana_integrate,
37        latest_observable_resume_state_path, print_integral_result,
38        render_saved_integration_summary, render_status_update_tabled, slot_workspace_path,
39        workspace_manifest_path, workspace_state_path, ContributionSortMode,
40        HavanaIntegrateRequest, IntegrationSlot, IntegrationState, IntegrationStatusKind,
41        IntegrationStatusPhaseDisplay, IntegrationStatusViewOptions, IntegrationWorkspaceManifest,
42        IterationBatchingSettings, RatatuiDashboardState, SamplingCorrelationMode, SlotMeta,
43        StatusUpdate, TabledRenderOptions, WorkspaceSnapshotControl,
44    },
45    model::{Model, SerializableInputParamCard},
46    observables::ObservableSnapshotBundle,
47    request_interrupt, request_iteration_abort,
48    settings::{
49        runtime::{IntegratedPhase, IntegrationResult, SamplingSettings},
50        RuntimeSettings,
51    },
52    utils::F,
53};
54use itertools::{izip, Itertools};
55use ratatui::{backend::CrosstermBackend, Terminal};
56use symbolica::numerical_integration::Grid;
57use tracing::{info, warn};
58
59use crate::{
60    completion::CompletionArgExt,
61    state::{ProcessRef, State},
62    CLISettings,
63};
64
65#[cfg_attr(
66    feature = "python",
67    pyo3::pyclass(from_py_object, unsendable, name = "IntegrationSettings")
68)]
69#[derive(Debug, Args, Serialize, Deserialize, Clone, JsonSchema, PartialEq)]
70pub struct Integrate {
71    /// Process reference: `#<id>`, `name:<name>`, or `<id>/<name>`; repeat with `-i` to select multiple integrands
72    #[arg(
73        short = 'p',
74        long = "process",
75        value_name = "PROCESS",
76        completion_process_selector(crate::completion::SelectorKind::Any)
77    )]
78    pub process: Vec<ProcessRef>,
79
80    /// The integrand name to integrate; repeat with -p to select multiple integrands
81    #[arg(
82        short = 'i',
83        long = "integrand-name",
84        value_name = "NAME",
85        completion_integrand_selector(crate::completion::SelectorKind::Any)
86    )]
87    pub integrand_name: Vec<String>,
88
89    /// Number of cores to parallelize over
90    #[arg(short = 'c', long)]
91    pub n_cores: Option<usize>,
92
93    /// Workspace directory used for resumable integration state and persisted results
94    #[arg(short = 'w', long, value_hint = clap::ValueHint::DirPath)]
95    pub workspace_path: Option<PathBuf>,
96
97    /// Specify a target as either `re im` / `re,im` for one shared target or `process@integrand=re,im`
98    #[arg(
99        short = 't',
100        long,
101        num_args = 1..=2,
102        action = ArgAction::Append,
103        allow_negative_numbers = true,
104        completion_selected_integrand_target()
105    )]
106    pub target: Vec<String>,
107
108    /// Integrate a Gaussian acceptance target, e.g. '{"width":300}' or
109    /// `'{"width":300,"center":[1,2,3]}'`. Re is normalization; Im is the raw
110    /// second moment divided by its known expectation (both targets are one).
111    /// Omitted/empty center means centered. Omission requests physics; repeat
112    /// the same descriptor when resuming a reference workspace.
113    #[arg(long = "reference-gaussian", value_name = "JSON")]
114    #[serde(default, skip_serializing_if = "IsDefault::is_default")]
115    pub reference_gaussian: Option<GaussianReferenceFunction>,
116
117    /// Whether to restart the integration from scratch, or continue from a previous run if possible
118    #[arg(short = 'r', long)]
119    pub restart: bool,
120
121    /// Use one independent sampling/training grid per selected integrand instead of a shared grid
122    #[arg(long = "uncorrelated")]
123    pub uncorrelated: bool,
124
125    /// Show the overall max-weight details table
126    #[arg(long = "show-max-weight-info", default_value_t = true)]
127    pub show_max_weight_info: bool,
128
129    /// Hide integration statistics during intermediate iteration updates
130    #[arg(long = "no-show-integration-statistics")]
131    pub no_show_integration_statistics: bool,
132
133    /// Control which complex phase is shown in integration summary tables
134    #[arg(long = "show-phase", default_value = "both")]
135    pub show_phase: ShowPhaseOption,
136
137    /// Show monitoring rows for the first non-trivial discrete dimension
138    #[arg(long = "show-top-discrete-grid")]
139    pub show_top_discrete_grid: bool,
140
141    /// Show the summed discrete contributions block independently of per-bin rows
142    #[arg(long = "show-discrete-contributions-sum")]
143    pub show_discrete_contributions_sum: bool,
144
145    /// Sort discrete contribution rows by index, absolute integral, or error
146    #[arg(long = "sort-contributions", default_value = "error")]
147    pub sort_contributions: ContributionSortOption,
148
149    /// Show per-bin max-weight information for the first non-trivial discrete dimension
150    #[arg(
151        long = "show-max-weight-info-for-discrete-bins",
152        default_value_t = false
153    )]
154    pub show_max_weight_info_for_discrete_bins: bool,
155
156    /// Only render the saved integration summary, without performing integration work
157    #[arg(long = "show-summary-only")]
158    pub show_summary_only: bool,
159
160    /// Disable streaming of end-of-iteration integration summaries in interactive terminals
161    #[arg(long = "no-stream-iterations")]
162    pub no_stream_iterations: bool,
163
164    /// Disable live streaming of in-iteration integration status updates
165    #[arg(long = "no-stream-updates")]
166    pub no_stream_updates: bool,
167
168    /// Renderer used for interactive streaming integration updates
169    #[arg(long = "renderer", default_value = "ratatui")]
170    pub renderer: RendererOption,
171
172    /// Evaluate each iteration in per-core batches of this size
173    #[arg(long = "batch-size")]
174    pub batch_size: Option<usize>,
175
176    /// Target per-core batch wall time in seconds when batch size is not specified
177    #[arg(long = "batch-timing", default_value_t = 5.0)]
178    pub batch_timing: f64,
179
180    /// Minimum time between streamed in-iteration status updates
181    #[arg(long = "min-time-between-status-updates", default_value_t = 0.0)]
182    pub min_time_between_status_updates: f64,
183
184    /// Maximum width used when normalizing integration status tables
185    #[arg(long = "max-table-width", default_value_t = 250)]
186    pub max_table_width: usize,
187
188    /// Keep per-iteration result and observable snapshot files in addition to the latest snapshots
189    #[arg(long = "write-results-for-each-iteration")]
190    pub write_results_for_each_iteration: bool,
191}
192
193#[derive(Debug, Clone, Default, Serialize, Deserialize)]
194#[serde(default, deny_unknown_fields)]
195pub struct IntegrationOutput {
196    /// Final estimates and convergence data for every selected process/integrand slot.
197    pub result: IntegrationResult,
198    /// Observable snapshots keyed by each slot's `process@integrand` identifier.
199    pub observables: BTreeMap<String, ObservableSnapshotBundle>,
200    /// Workspace that stores resumable integration state, results, and observable snapshots.
201    pub workspace_path: PathBuf,
202}
203
204impl IntegrationOutput {
205    pub fn slot_observables(&self, key: &str) -> Option<&ObservableSnapshotBundle> {
206        self.observables.get(key)
207    }
208
209    pub fn single_slot_observables(&self) -> Option<&ObservableSnapshotBundle> {
210        (self.observables.len() == 1)
211            .then(|| self.observables.values().next())
212            .flatten()
213    }
214}
215
216impl Deref for IntegrationOutput {
217    type Target = IntegrationResult;
218
219    fn deref(&self) -> &Self::Target {
220        &self.result
221    }
222}
223
224impl fmt::Display for IntegrationOutput {
225    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
226        self.result.fmt(f)
227    }
228}
229
230impl Default for Integrate {
231    fn default() -> Self {
232        Self {
233            process: Vec::new(),
234            integrand_name: Vec::new(),
235            n_cores: None,
236            workspace_path: None,
237            target: Vec::new(),
238            reference_gaussian: None,
239            restart: false,
240            uncorrelated: false,
241            show_max_weight_info: true,
242            no_show_integration_statistics: false,
243            show_phase: ShowPhaseOption::Both,
244            show_top_discrete_grid: false,
245            show_discrete_contributions_sum: false,
246            sort_contributions: ContributionSortOption::Error,
247            show_max_weight_info_for_discrete_bins: false,
248            show_summary_only: false,
249            no_stream_iterations: false,
250            no_stream_updates: false,
251            renderer: RendererOption::Ratatui,
252            batch_size: None,
253            batch_timing: 5.0,
254            min_time_between_status_updates: 0.0,
255            max_table_width: 250,
256            write_results_for_each_iteration: false,
257        }
258    }
259}
260
261#[derive(
262    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ValueEnum, Default,
263)]
264pub enum ShowPhaseOption {
265    #[default]
266    Both,
267    Real,
268    Imag,
269    Selected,
270}
271
272impl ShowPhaseOption {
273    fn resolve(self, selected_phase: IntegratedPhase) -> IntegrationStatusPhaseDisplay {
274        match self {
275            Self::Both => IntegrationStatusPhaseDisplay::Both,
276            Self::Real => IntegrationStatusPhaseDisplay::Real,
277            Self::Imag => IntegrationStatusPhaseDisplay::Imag,
278            Self::Selected => match selected_phase {
279                IntegratedPhase::Imag => IntegrationStatusPhaseDisplay::Imag,
280                IntegratedPhase::Both => IntegrationStatusPhaseDisplay::Both,
281                IntegratedPhase::Real => IntegrationStatusPhaseDisplay::Real,
282            },
283        }
284    }
285}
286
287#[derive(
288    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ValueEnum, Default,
289)]
290pub enum ContributionSortOption {
291    Index,
292    Integral,
293    #[default]
294    Error,
295}
296
297#[derive(
298    Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ValueEnum, Default,
299)]
300pub enum RendererOption {
301    Tabled,
302    #[default]
303    Ratatui,
304}
305
306impl From<ContributionSortOption> for ContributionSortMode {
307    fn from(value: ContributionSortOption) -> Self {
308        match value {
309            ContributionSortOption::Index => ContributionSortMode::Index,
310            ContributionSortOption::Integral => ContributionSortMode::Integral,
311            ContributionSortOption::Error => ContributionSortMode::Error,
312        }
313    }
314}
315
316struct TabledStreamRenderer {
317    stderr: io::Stderr,
318    rendered_line_count: usize,
319    control_listener: Option<TabledControlListener>,
320}
321
322impl TabledStreamRenderer {
323    fn new() -> Self {
324        Self {
325            stderr: io::stderr(),
326            rendered_line_count: 0,
327            control_listener: None,
328        }
329    }
330
331    fn render(&mut self, block: &str) -> Result<()> {
332        self.ensure_control_listener();
333        let prepared = prepare_stream_block(block, stream_terminal_width());
334        if self.rendered_line_count > 0 {
335            self.clear_rendered_block()?;
336        }
337
338        queue!(self.stderr, MoveToColumn(0))?;
339        write!(self.stderr, "{prepared}")?;
340        self.stderr.flush()?;
341        self.rendered_line_count = prepared.lines().count().max(1);
342        Ok(())
343    }
344
345    fn clear(&mut self) -> Result<()> {
346        if self.rendered_line_count > 0 {
347            self.clear_rendered_block()?;
348            self.rendered_line_count = 0;
349            self.stderr.flush()?;
350        }
351
352        self.shutdown_control_listener()?;
353        Ok(())
354    }
355
356    fn ensure_control_listener(&mut self) {
357        if self.control_listener.is_none() {
358            self.control_listener = TabledControlListener::new()
359                .map_err(|err| {
360                    warn!("Failed to start tabled streaming control listener: {err}");
361                    err
362                })
363                .ok();
364        }
365    }
366
367    fn shutdown_control_listener(&mut self) -> Result<()> {
368        if let Some(mut control_listener) = self.control_listener.take() {
369            control_listener.shutdown()?;
370        }
371        Ok(())
372    }
373
374    fn clear_rendered_block(&mut self) -> Result<()> {
375        queue!(self.stderr, MoveToColumn(0))?;
376        if self.rendered_line_count > 1 {
377            queue!(
378                self.stderr,
379                MoveUp((self.rendered_line_count.saturating_sub(1)) as u16)
380            )?;
381        }
382
383        for line_index in 0..self.rendered_line_count {
384            queue!(self.stderr, MoveToColumn(0), Clear(ClearType::CurrentLine))?;
385            if line_index + 1 < self.rendered_line_count {
386                queue!(self.stderr, MoveDown(1))?;
387            }
388        }
389        queue!(self.stderr, MoveToColumn(0))?;
390        if self.rendered_line_count > 1 {
391            queue!(
392                self.stderr,
393                MoveUp((self.rendered_line_count.saturating_sub(1)) as u16)
394            )?;
395        }
396        Ok(())
397    }
398}
399
400impl Drop for TabledStreamRenderer {
401    fn drop(&mut self) {
402        let _ = self.clear();
403    }
404}
405
406enum TabledControlCommand {
407    Shutdown(mpsc::SyncSender<Result<()>>),
408}
409
410struct TabledControlListener {
411    sender: mpsc::Sender<TabledControlCommand>,
412    handle: Option<thread::JoinHandle<Result<()>>>,
413}
414
415impl TabledControlListener {
416    fn new() -> Result<Self> {
417        let (sender, receiver) = mpsc::channel();
418        let (startup_sender, startup_receiver) = mpsc::sync_channel(0);
419        let handle = thread::spawn(move || tabled_control_event_loop(receiver, startup_sender));
420        startup_receiver
421            .recv()
422            .map_err(|err| eyre!("Failed to start tabled control listener: {err}"))?
423            .map_err(|err| eyre!("Failed to start tabled control listener: {err}"))?;
424        Ok(Self {
425            sender,
426            handle: Some(handle),
427        })
428    }
429
430    fn shutdown(&mut self) -> Result<()> {
431        let (ack_sender, ack_receiver) = mpsc::sync_channel(0);
432        self.sender
433            .send(TabledControlCommand::Shutdown(ack_sender))
434            .map_err(|err| eyre!("Failed to stop tabled control listener: {err}"))?;
435        ack_receiver.recv().map_err(|err| {
436            eyre!("Failed to receive tabled control listener acknowledgement: {err}")
437        })??;
438        if let Some(handle) = self.handle.take() {
439            handle
440                .join()
441                .map_err(|_| eyre!("Tabled control listener thread panicked"))??;
442        }
443        Ok(())
444    }
445}
446
447impl Drop for TabledControlListener {
448    fn drop(&mut self) {
449        let _ = self.shutdown();
450    }
451}
452
453enum DashboardCommand {
454    Update(Box<StatusUpdate>),
455    Suspend(mpsc::SyncSender<Result<()>>),
456    Shutdown(mpsc::SyncSender<Result<()>>),
457}
458
459struct RatatuiTerminal {
460    terminal: Terminal<CrosstermBackend<io::Stderr>>,
461}
462
463impl RatatuiTerminal {
464    fn enter() -> Result<Self> {
465        enable_raw_mode()?;
466        let mut stderr = io::stderr();
467        execute!(stderr, EnterAlternateScreen, Hide)?;
468        let backend = CrosstermBackend::new(io::stderr());
469        let mut terminal = Terminal::new(backend)?;
470        terminal.clear()?;
471        Ok(Self { terminal })
472    }
473
474    fn draw(&mut self, dashboard: &RatatuiDashboardState) -> Result<()> {
475        self.terminal.draw(|frame| dashboard.draw(frame))?;
476        Ok(())
477    }
478
479    fn leave(mut self) -> Result<()> {
480        self.terminal.clear()?;
481        execute!(self.terminal.backend_mut(), Show, LeaveAlternateScreen)?;
482        disable_raw_mode()?;
483        Ok(())
484    }
485}
486
487struct RatatuiStreamRenderer {
488    sender: mpsc::Sender<DashboardCommand>,
489    handle: Option<thread::JoinHandle<Result<()>>>,
490}
491
492impl RatatuiStreamRenderer {
493    fn new() -> Self {
494        let (sender, receiver) = mpsc::channel();
495        let handle = thread::spawn(move || dashboard_event_loop(receiver));
496        Self {
497            sender,
498            handle: Some(handle),
499        }
500    }
501
502    fn render(&mut self, update: StatusUpdate) -> Result<()> {
503        self.sender
504            .send(DashboardCommand::Update(Box::new(update)))
505            .map_err(|err| eyre!("Failed to send dashboard update: {err}"))?;
506        Ok(())
507    }
508
509    fn suspend(&mut self) -> Result<()> {
510        self.send_ack_command(DashboardCommand::Suspend)
511    }
512
513    fn shutdown(&mut self) -> Result<()> {
514        self.send_ack_command(DashboardCommand::Shutdown)?;
515        if let Some(handle) = self.handle.take() {
516            handle
517                .join()
518                .map_err(|_| eyre!("Ratatui dashboard thread panicked"))??;
519        }
520        Ok(())
521    }
522
523    fn send_ack_command(
524        &mut self,
525        build: impl FnOnce(mpsc::SyncSender<Result<()>>) -> DashboardCommand,
526    ) -> Result<()> {
527        let (ack_sender, ack_receiver) = mpsc::sync_channel(0);
528        self.sender
529            .send(build(ack_sender))
530            .map_err(|err| eyre!("Failed to send dashboard command: {err}"))?;
531        ack_receiver
532            .recv()
533            .map_err(|err| eyre!("Failed to receive dashboard acknowledgement: {err}"))??;
534        Ok(())
535    }
536}
537
538impl Drop for RatatuiStreamRenderer {
539    fn drop(&mut self) {
540        let _ = self.shutdown();
541    }
542}
543
544enum StreamRenderer {
545    Tabled(TabledStreamRenderer),
546    Ratatui(RatatuiStreamRenderer),
547}
548
549impl StreamRenderer {
550    fn new(renderer: RendererOption) -> Self {
551        match renderer {
552            RendererOption::Tabled => Self::Tabled(TabledStreamRenderer::new()),
553            RendererOption::Ratatui => Self::Ratatui(RatatuiStreamRenderer::new()),
554        }
555    }
556
557    fn render(&mut self, update: StatusUpdate, tabled_block: &str) -> Result<()> {
558        match self {
559            Self::Tabled(renderer) => renderer.render(tabled_block),
560            Self::Ratatui(renderer) => renderer.render(update),
561        }
562    }
563
564    fn clear(&mut self) -> Result<()> {
565        match self {
566            Self::Tabled(renderer) => renderer.clear(),
567            Self::Ratatui(renderer) => renderer.suspend(),
568        }
569    }
570
571    fn shutdown(&mut self) -> Result<()> {
572        match self {
573            Self::Tabled(renderer) => renderer.clear(),
574            Self::Ratatui(renderer) => renderer.shutdown(),
575        }
576    }
577}
578
579struct StreamingDisplayController {
580    renderer_kind: RendererOption,
581    stream_updates: bool,
582    stream_iterations: bool,
583    tabled_options: TabledRenderOptions,
584    renderer: Option<StreamRenderer>,
585}
586
587impl StreamingDisplayController {
588    fn new(
589        renderer_kind: RendererOption,
590        stream_updates: bool,
591        stream_iterations: bool,
592        tabled_options: TabledRenderOptions,
593    ) -> Self {
594        Self {
595            renderer_kind,
596            stream_updates,
597            stream_iterations,
598            tabled_options,
599            renderer: (stream_updates || stream_iterations)
600                .then(|| StreamRenderer::new(renderer_kind)),
601        }
602    }
603
604    fn handle_status_update(&mut self, status_update: StatusUpdate) -> Result<()> {
605        let kind = status_update.kind();
606        let renderer_kind = self.renderer_kind;
607        let tabled_options = self.tabled_options;
608
609        if let Some(renderer) = self.renderer.as_mut() {
610            match kind {
611                IntegrationStatusKind::Live => {
612                    if self.stream_updates || status_update.is_initial_live_status() {
613                        let status_block = Self::render_stream_block(
614                            renderer_kind,
615                            tabled_options,
616                            &status_update,
617                        );
618                        renderer.render(status_update, &status_block)?;
619                    }
620                }
621                IntegrationStatusKind::Iteration => {
622                    if self.stream_iterations {
623                        let status_block = Self::render_stream_block(
624                            renderer_kind,
625                            tabled_options,
626                            &status_update,
627                        );
628                        renderer.render(status_update, &status_block)?;
629                    } else {
630                        renderer.clear()?;
631                        self.emit_tabled_status(kind, &status_update)?;
632                    }
633                }
634                IntegrationStatusKind::Final => {
635                    if let Some(mut renderer) = self.renderer.take() {
636                        renderer.shutdown()?;
637                    }
638                    self.emit_tabled_status(kind, &status_update)?;
639                }
640            }
641        } else if kind != IntegrationStatusKind::Live {
642            self.emit_tabled_status(kind, &status_update)?;
643        }
644
645        Ok(())
646    }
647
648    fn emit_tabled_status(
649        &self,
650        kind: IntegrationStatusKind,
651        status_update: &StatusUpdate,
652    ) -> Result<()> {
653        let status_block = render_status_update_tabled(status_update, &self.tabled_options);
654        emit_integration_status_via_tracing(kind, &status_block)
655    }
656
657    fn handle_panic(&mut self, payload: Box<dyn Any + Send>) -> color_eyre::Report {
658        if !self.stream_updates && !self.stream_iterations {
659            // Without streaming, the panic hook's original report is still
660            // visible. Preserve normal unwinding instead of reporting it twice.
661            panic::resume_unwind(payload);
662        }
663        if let Some(mut renderer) = self.renderer.take() {
664            let _ = renderer.shutdown();
665        }
666        let message = if let Some(message) = payload.downcast_ref::<&'static str>() {
667            message
668        } else if let Some(message) = payload.downcast_ref::<String>() {
669            message.as_str()
670        } else {
671            "panic payload is not a string"
672        };
673        eyre!("Integration panicked: {message}")
674    }
675
676    fn render_stream_block(
677        renderer_kind: RendererOption,
678        tabled_options: TabledRenderOptions,
679        status_update: &StatusUpdate,
680    ) -> String {
681        if matches!(renderer_kind, RendererOption::Tabled) {
682            render_status_update_tabled(status_update, &tabled_options)
683        } else {
684            String::new()
685        }
686    }
687}
688
689fn stream_terminal_width() -> usize {
690    terminal::size()
691        .map(|(width, _)| width.saturating_sub(1).max(1) as usize)
692        .unwrap_or(120)
693}
694
695fn prepare_stream_block(block: &str, max_width: usize) -> String {
696    block
697        .lines()
698        .map(|line| truncate_ansi_line(line, max_width))
699        .collect::<Vec<_>>()
700        // Tabled streaming can run while the terminal is in raw mode, where a
701        // bare `\n` no longer returns the cursor to column zero.
702        .join("\r\n")
703}
704
705fn truncate_ansi_line(line: &str, max_width: usize) -> String {
706    if max_width == 0 {
707        return String::new();
708    }
709
710    let mut truncated = String::new();
711    let mut chars = line.chars().peekable();
712    let mut visible_width = 0usize;
713    let mut saw_escape = false;
714    let mut was_truncated = false;
715
716    while let Some(ch) = chars.next() {
717        if ch == '\u{1b}' && chars.peek() == Some(&'[') {
718            saw_escape = true;
719            truncated.push(ch);
720            truncated.push(chars.next().unwrap());
721            for code in chars.by_ref() {
722                truncated.push(code);
723                if ('@'..='~').contains(&code) {
724                    break;
725                }
726            }
727            continue;
728        }
729
730        let char_width = UnicodeWidthChar::width(ch).unwrap_or(0);
731        if visible_width + char_width > max_width {
732            was_truncated = true;
733            break;
734        }
735
736        truncated.push(ch);
737        visible_width += char_width;
738    }
739
740    if was_truncated && saw_escape && !truncated.ends_with("\u{1b}[0m") {
741        truncated.push_str("\u{1b}[0m");
742    }
743
744    truncated
745}
746
747fn tabled_control_event_loop(
748    receiver: mpsc::Receiver<TabledControlCommand>,
749    startup_sender: mpsc::SyncSender<std::result::Result<(), String>>,
750) -> Result<()> {
751    if let Err(err) = enable_raw_mode() {
752        let _ = startup_sender.send(Err(err.to_string()));
753        return Err(err.into());
754    }
755    let _ = startup_sender.send(Ok(()));
756
757    let mut shutdown_ack = None;
758    let result = (|| -> Result<()> {
759        loop {
760            match receiver.recv_timeout(Duration::from_millis(50)) {
761                Ok(TabledControlCommand::Shutdown(ack_sender)) => {
762                    shutdown_ack = Some(ack_sender);
763                    break;
764                }
765                Err(mpsc::RecvTimeoutError::Timeout) => {}
766                Err(mpsc::RecvTimeoutError::Disconnected) => break,
767            }
768
769            while event::poll(Duration::from_millis(0))? {
770                if let Event::Key(key) = event::read()? {
771                    if key.kind != KeyEventKind::Press {
772                        continue;
773                    }
774
775                    match handle_tabled_key_event(key) {
776                        TabledKeyAction::Ignored => {}
777                        TabledKeyAction::InterruptIntegration => request_interrupt(),
778                        TabledKeyAction::AbortCurrentIteration => request_iteration_abort(),
779                    }
780                }
781            }
782        }
783
784        Ok(())
785    })();
786
787    let cleanup_result: Result<()> = disable_raw_mode().map_err(|err| eyre!(err));
788    if let Some(ack_sender) = shutdown_ack.take() {
789        let ack_result = match cleanup_result.as_ref() {
790            Ok(()) => Ok(()),
791            Err(err) => Err(eyre!(err.to_string())),
792        };
793        let _ = ack_sender.send(ack_result);
794    }
795    cleanup_result?;
796    result
797}
798
799fn dashboard_event_loop(receiver: mpsc::Receiver<DashboardCommand>) -> Result<()> {
800    let mut dashboard = RatatuiDashboardState::new();
801    let mut terminal = None;
802    let mut dirty = false;
803
804    loop {
805        match receiver.recv_timeout(Duration::from_millis(50)) {
806            Ok(DashboardCommand::Update(update)) => {
807                dashboard.update(*update);
808                if terminal.is_none() {
809                    terminal = Some(RatatuiTerminal::enter()?);
810                }
811                dirty = true;
812            }
813            Ok(DashboardCommand::Suspend(ack_sender)) => {
814                let result = match terminal.take() {
815                    Some(terminal) => terminal.leave(),
816                    None => Ok(()),
817                };
818                let _ = ack_sender.send(result);
819                dirty = false;
820            }
821            Ok(DashboardCommand::Shutdown(ack_sender)) => {
822                let result = match terminal.take() {
823                    Some(terminal) => terminal.leave(),
824                    None => Ok(()),
825                };
826                let _ = ack_sender.send(result);
827                break;
828            }
829            Err(mpsc::RecvTimeoutError::Timeout) => {}
830            Err(mpsc::RecvTimeoutError::Disconnected) => break,
831        }
832
833        while terminal.is_some() && event::poll(Duration::from_millis(0))? {
834            let mut handled = false;
835            match event::read()? {
836                Event::Key(key) if key.kind == KeyEventKind::Press => {
837                    handled = match handle_dashboard_key_event(&mut dashboard, key) {
838                        DashboardKeyAction::Redraw => true,
839                        DashboardKeyAction::InterruptIntegration => {
840                            request_interrupt();
841                            true
842                        }
843                        DashboardKeyAction::AbortCurrentIteration => {
844                            request_iteration_abort();
845                            true
846                        }
847                        DashboardKeyAction::Ignored => false,
848                    };
849                }
850                Event::Resize(_, _) => handled = true,
851                _ => {}
852            }
853            dirty |= handled;
854        }
855
856        if dirty {
857            if let Some(terminal) = terminal.as_mut() {
858                terminal.draw(&dashboard)?;
859                dirty = false;
860            }
861        }
862    }
863
864    Ok(())
865}
866
867#[derive(Clone, Copy, Debug, Eq, PartialEq)]
868enum DashboardKeyAction {
869    Ignored,
870    Redraw,
871    InterruptIntegration,
872    AbortCurrentIteration,
873}
874
875#[derive(Clone, Copy, Debug, Eq, PartialEq)]
876enum TabledKeyAction {
877    Ignored,
878    InterruptIntegration,
879    AbortCurrentIteration,
880}
881
882fn handle_tabled_key_event(key: KeyEvent) -> TabledKeyAction {
883    if key.modifiers.contains(KeyModifiers::CONTROL)
884        && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
885    {
886        return TabledKeyAction::InterruptIntegration;
887    }
888
889    if matches!(key.code, KeyCode::Char('x') | KeyCode::Char('X')) {
890        return TabledKeyAction::AbortCurrentIteration;
891    }
892
893    TabledKeyAction::Ignored
894}
895
896fn handle_dashboard_key_event(
897    dashboard: &mut RatatuiDashboardState,
898    key: KeyEvent,
899) -> DashboardKeyAction {
900    if key.modifiers.contains(KeyModifiers::CONTROL)
901        && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('C'))
902    {
903        return DashboardKeyAction::InterruptIntegration;
904    }
905
906    if matches!(key.code, KeyCode::Char('x') | KeyCode::Char('X')) {
907        return DashboardKeyAction::AbortCurrentIteration;
908    }
909
910    match key.code {
911        KeyCode::Char('1') => dashboard.select_tab(0),
912        KeyCode::Char('2') => dashboard.select_tab(1),
913        KeyCode::Char('3') => dashboard.select_tab(2),
914        KeyCode::Char('a') | KeyCode::Char('A') => dashboard.toggle_integral_view(),
915        KeyCode::Left => dashboard.previous_tab(),
916        KeyCode::Right => dashboard.next_tab(),
917        KeyCode::Char('[') => dashboard.focus_previous_slot(),
918        KeyCode::Char(']') => dashboard.focus_next_slot(),
919        KeyCode::Char('i') | KeyCode::Char('I') => dashboard.toggle_statistics_scope(),
920        KeyCode::Down | KeyCode::Char('j') => dashboard.select_next_discrete_row(),
921        KeyCode::Up | KeyCode::Char('k') => dashboard.select_previous_discrete_row(),
922        KeyCode::Char('s') => dashboard.cycle_discrete_sort(),
923        KeyCode::Char('v') => dashboard.toggle_discrete_sort_direction(),
924        KeyCode::Char('r') => dashboard.toggle_relative_error(),
925        KeyCode::Char('c') => dashboard.toggle_chi_sq(),
926        KeyCode::Char('w') => dashboard.toggle_max_weight_impact(),
927        KeyCode::Char('p') | KeyCode::Char('P') => dashboard.toggle_chart_component(),
928        KeyCode::Char('g') | KeyCode::Char('G') => dashboard.toggle_chart_history_window(),
929        KeyCode::Char('+') | KeyCode::Char('=') => dashboard.widen_chart_history_window(),
930        KeyCode::Char('-') | KeyCode::Char('_') => dashboard.narrow_chart_history_window(),
931        KeyCode::Char(',') | KeyCode::Char('<') => dashboard.narrow_chart_y_sigma_span(),
932        KeyCode::Char('.') | KeyCode::Char('>') => dashboard.widen_chart_y_sigma_span(),
933        KeyCode::Char('0') => dashboard.reset_chart_y_sigma_span(),
934        KeyCode::Esc | KeyCode::Char('?') => dashboard.toggle_help(),
935        _ => return DashboardKeyAction::Ignored,
936    }
937    DashboardKeyAction::Redraw
938}
939
940#[derive(Debug, Clone)]
941struct ResolvedIntegrandSlot {
942    process_id: usize,
943    slot_meta: SlotMeta,
944}
945
946fn slot_settings_path(workspace: &std::path::Path, slot_meta: &SlotMeta) -> PathBuf {
947    slot_workspace_path(workspace, slot_meta).join("settings.toml")
948}
949
950fn read_existing_workspace_state(
951    workspace_path: &std::path::Path,
952) -> Result<(IntegrationWorkspaceManifest, IntegrationState)> {
953    let manifest = IntegrationWorkspaceManifest::from_file(
954        workspace_manifest_path(workspace_path),
955        "integration manifest",
956    )?;
957    manifest.validate_version()?;
958    let state_path = workspace_state_path(workspace_path);
959    let state_bytes = fs::read(&state_path).map_err(|err| {
960        eyre!(
961            "Could not read integration workspace state from {}: {err}",
962            state_path.display()
963        )
964    })?;
965    let integration_state = bincode::decode_from_slice::<IntegrationState, _>(
966        &state_bytes,
967        bincode::config::standard(),
968    )
969    .map_err(|err| {
970        eyre!(
971"Could not deserialize integration state from {}: {err}. The checkpoint may use an incompatible accumulator layout; use --restart to start a new integration.",
972            state_path.display()
973        )
974    })?
975    .0;
976    Ok((manifest, integration_state))
977}
978
979fn grids_have_compatible_sample_shape(lhs: &Grid<F<f64>>, rhs: &Grid<F<f64>>) -> bool {
980    match (lhs, rhs) {
981        (Grid::Continuous(lhs), Grid::Continuous(rhs)) => {
982            lhs.continuous_dimensions.len() == rhs.continuous_dimensions.len()
983        }
984        (Grid::Discrete(lhs), Grid::Discrete(rhs)) => {
985            lhs.bins.len() == rhs.bins.len()
986                && lhs
987                    .bins
988                    .iter()
989                    .zip(rhs.bins.iter())
990                    .all(
991                        |(lhs_bin, rhs_bin)| match (&lhs_bin.sub_grid, &rhs_bin.sub_grid) {
992                            (Some(lhs_sub_grid), Some(rhs_sub_grid)) => {
993                                grids_have_compatible_sample_shape(lhs_sub_grid, rhs_sub_grid)
994                            }
995                            (None, None) => true,
996                            _ => false,
997                        },
998                    )
999        }
1000        (
1001            Grid::Uniform(lhs_discrete, lhs_continuous),
1002            Grid::Uniform(rhs_discrete, rhs_continuous),
1003        ) => {
1004            lhs_discrete == rhs_discrete
1005                && lhs_continuous.continuous_dimensions.len()
1006                    == rhs_continuous.continuous_dimensions.len()
1007        }
1008        _ => false,
1009    }
1010}
1011
1012impl Integrate {
1013    pub fn from_slots<I, S>(slots: I) -> Self
1014    where
1015        I: IntoIterator<Item = (ProcessRef, S)>,
1016        S: Into<String>,
1017    {
1018        let mut integrate = Self::default();
1019        for (process, integrand_name) in slots {
1020            integrate.process.push(process);
1021            integrate.integrand_name.push(integrand_name.into());
1022        }
1023        integrate
1024    }
1025
1026    pub fn with_single_target(mut self, target: Complex<F<f64>>) -> Self {
1027        self.target = vec![target.re.to_string(), target.im.to_string()];
1028        self
1029    }
1030
1031    pub fn with_keyed_targets<I, S>(mut self, targets: I) -> Self
1032    where
1033        I: IntoIterator<Item = (S, Complex<F<f64>>)>,
1034        S: Into<String>,
1035    {
1036        self.target = targets
1037            .into_iter()
1038            .map(|(slot_key, target)| format!("{}={},{}", slot_key.into(), target.re, target.im))
1039            .collect();
1040        self
1041    }
1042
1043    fn default_workspace_path(&self, global_cli_settings: &CLISettings) -> PathBuf {
1044        if global_cli_settings.session.read_only_state {
1045            global_cli_settings.cwd_output_path_with_state_name("integration_workspace")
1046        } else {
1047            global_cli_settings
1048                .state
1049                .folder
1050                .join("integration_workspace")
1051        }
1052    }
1053
1054    fn selected_training_phase_display(
1055        integrated_phase: IntegratedPhase,
1056    ) -> IntegrationStatusPhaseDisplay {
1057        match integrated_phase {
1058            IntegratedPhase::Real | IntegratedPhase::Both => IntegrationStatusPhaseDisplay::Real,
1059            IntegratedPhase::Imag => IntegrationStatusPhaseDisplay::Imag,
1060        }
1061    }
1062
1063    fn resolve_selected_slots(&self, state: &State) -> Result<Vec<ResolvedIntegrandSlot>> {
1064        let selections = if self.process.is_empty() && self.integrand_name.is_empty() {
1065            vec![state.find_integrand_ref(None, None)?]
1066        } else if self.process.len() == 1 && self.integrand_name.is_empty() {
1067            vec![state.find_integrand_ref(self.process.first(), None)?]
1068        } else if self.process.is_empty() && self.integrand_name.len() == 1 {
1069            vec![state.find_integrand_ref(None, self.integrand_name.first())?]
1070        } else if self.process.len() == self.integrand_name.len() {
1071            self.process
1072                .iter()
1073                .zip(self.integrand_name.iter())
1074                .map(|(process, integrand_name)| {
1075                    state.find_integrand_ref(Some(process), Some(integrand_name))
1076                })
1077                .collect::<Result<Vec<_>>>()?
1078        } else {
1079            return Err(eyre!(
1080                "The integrate command expects repeated `-p/-i` pairs. Received {} process selector(s) and {} integrand selector(s).",
1081                self.process.len(),
1082                self.integrand_name.len()
1083            ));
1084        };
1085
1086        let mut seen = HashSet::new();
1087        let mut resolved = Vec::with_capacity(selections.len());
1088        for (process_id, integrand_name) in selections {
1089            let process_name = state.process_list.processes[process_id]
1090                .definition
1091                .folder_name
1092                .clone();
1093            let slot_meta = SlotMeta {
1094                process_name,
1095                integrand_name,
1096            };
1097            if !seen.insert(slot_meta.key()) {
1098                return Err(eyre!(
1099                    "The same integrand '{}' was selected more than once",
1100                    slot_meta.key()
1101                ));
1102            }
1103            resolved.push(ResolvedIntegrandSlot {
1104                process_id,
1105                slot_meta,
1106            });
1107        }
1108
1109        Ok(resolved)
1110    }
1111
1112    fn resolve_manifest_slots(
1113        &self,
1114        state: &State,
1115        manifest: &IntegrationWorkspaceManifest,
1116    ) -> Result<Vec<ResolvedIntegrandSlot>> {
1117        manifest
1118            .slots
1119            .iter()
1120            .map(|slot_meta| {
1121                let process_ref = ProcessRef::Name(slot_meta.process_name.clone());
1122                let integrand_name = slot_meta.integrand_name.clone();
1123                let (process_id, resolved_integrand_name) =
1124                    state.find_integrand_ref(Some(&process_ref), Some(&integrand_name))?;
1125                Ok(ResolvedIntegrandSlot {
1126                    process_id,
1127                    slot_meta: SlotMeta {
1128                        process_name: slot_meta.process_name.clone(),
1129                        integrand_name: resolved_integrand_name,
1130                    },
1131                })
1132            })
1133            .collect()
1134    }
1135
1136    pub(crate) fn build_render_options(
1137        &self,
1138        slot_settings: &[RuntimeSettings],
1139        show_statistics: bool,
1140    ) -> IntegrationStatusViewOptions {
1141        let settings = &slot_settings[0];
1142        IntegrationStatusViewOptions {
1143            phase_display: self
1144                .show_phase
1145                .resolve(settings.integrator.integrated_phase),
1146            training_phase_display: Self::selected_training_phase_display(
1147                settings.integrator.integrated_phase,
1148            ),
1149            training_slot: 0,
1150            slot_training_phase_displays: slot_settings
1151                .iter()
1152                .map(|slot_settings| {
1153                    Self::selected_training_phase_display(slot_settings.integrator.integrated_phase)
1154                })
1155                .collect(),
1156            per_slot_training_phase: self.uncorrelated,
1157            target_relative_accuracy: settings.integrator.target_relative_accuracy,
1158            target_absolute_accuracy: settings.integrator.target_absolute_accuracy,
1159            show_statistics,
1160            show_max_weight_details: self.show_max_weight_info,
1161            show_top_discrete_grid: self.show_top_discrete_grid,
1162            show_discrete_contributions_sum: self.show_discrete_contributions_sum,
1163            contribution_sort: self.sort_contributions.into(),
1164            show_max_weight_info_for_discrete_bins: self.show_max_weight_info_for_discrete_bins,
1165        }
1166    }
1167
1168    fn build_tabled_render_options(&self) -> TabledRenderOptions {
1169        TabledRenderOptions {
1170            max_table_width: self.max_table_width,
1171            show_statistics: !self.no_show_integration_statistics,
1172            show_max_weight_details: self.show_max_weight_info,
1173            show_top_discrete_grid: self.show_top_discrete_grid,
1174            show_discrete_contributions_sum: self.show_discrete_contributions_sum,
1175            show_max_weight_info_for_discrete_bins: self.show_max_weight_info_for_discrete_bins,
1176        }
1177    }
1178
1179    fn workspace_snapshot_control(&self) -> WorkspaceSnapshotControl {
1180        WorkspaceSnapshotControl {
1181            write_iteration_archives: self.write_results_for_each_iteration,
1182        }
1183    }
1184
1185    fn sampling_correlation_mode(&self) -> SamplingCorrelationMode {
1186        if self.uncorrelated {
1187            SamplingCorrelationMode::Uncorrelated
1188        } else {
1189            SamplingCorrelationMode::Correlated
1190        }
1191    }
1192
1193    fn build_batching_settings(
1194        &self,
1195        emit_live_status_updates: bool,
1196        emit_initial_status_update: bool,
1197    ) -> IterationBatchingSettings {
1198        IterationBatchingSettings {
1199            batch_size: self.batch_size,
1200            batch_timing_seconds: self.batch_timing,
1201            min_time_between_status_updates_seconds: self.min_time_between_status_updates,
1202            emit_live_status_updates,
1203            emit_initial_status_update,
1204        }
1205    }
1206
1207    fn parse_target_components(first: &str, second: Option<&str>) -> Result<Complex<F<f64>>> {
1208        if let Some(second) = second {
1209            return Ok(Complex::new(F(first.parse()?), F(second.parse()?)));
1210        }
1211
1212        let (re, im) = first
1213            .split_once(',')
1214            .ok_or_else(|| eyre!("Targets must be provided as `re im` or `re,im`"))?;
1215        Ok(Complex::new(F(re.parse()?), F(im.parse()?)))
1216    }
1217
1218    fn resolve_targets(
1219        &self,
1220        selected_slots: &[ResolvedIntegrandSlot],
1221    ) -> Result<Vec<Option<Complex<F<f64>>>>> {
1222        let mut resolved = vec![None; selected_slots.len()];
1223        if self.target.is_empty() {
1224            return Ok(resolved);
1225        }
1226
1227        if self.target.iter().all(|target| !target.contains('=')) {
1228            let target = match self.target.as_slice() {
1229                [single] => Self::parse_target_components(single, None)?,
1230                [re, im] => Self::parse_target_components(re, Some(im))?,
1231                _ => {
1232                    return Err(eyre!(
1233                        "A shared target must be given as `--target re im` or `--target re,im`"
1234                    ));
1235                }
1236            };
1237            resolved.fill(Some(target));
1238            return Ok(resolved);
1239        }
1240
1241        for target in &self.target {
1242            let (slot_key, values) = target.split_once('=').ok_or_else(|| {
1243                eyre!(
1244                    "Multi-integrand targets must use the keyed form `--target process@integrand=re,im`"
1245                )
1246            })?;
1247            let parsed = Self::parse_target_components(values, None)?;
1248            let slot_index = selected_slots
1249                .iter()
1250                .position(|slot| slot.slot_meta.key() == slot_key)
1251                .ok_or_else(|| eyre!("Unknown target slot key '{}'", slot_key))?;
1252            if resolved[slot_index].is_some() {
1253                return Err(eyre!(
1254                    "The target for '{}' was specified more than once",
1255                    slot_key
1256                ));
1257            }
1258            resolved[slot_index] = Some(parsed);
1259        }
1260
1261        Ok(resolved)
1262    }
1263
1264    fn resolve_reference_gaussians(
1265        &self,
1266        state: &State,
1267        slots: &[ResolvedIntegrandSlot],
1268    ) -> Result<Vec<Option<GaussianReferenceFunction>>> {
1269        slots
1270            .iter()
1271            .map(|slot| {
1272                self.reference_gaussian
1273                    .as_ref()
1274                    .map(|reference| {
1275                        let integrand = state
1276                            .process_list
1277                            .get_integrand(slot.process_id, &slot.slot_meta.integrand_name)?
1278                            .require_generated()?;
1279                        reference.for_integrand(integrand)
1280                    })
1281                    .transpose()
1282            })
1283            .collect()
1284    }
1285
1286    fn load_or_prepare_workspace_state(
1287        &self,
1288        state: &mut State,
1289        selected_slots: &[ResolvedIntegrandSlot],
1290        requested: &mut IntegrationWorkspaceManifest,
1291        workspace_path: &std::path::Path,
1292    ) -> Result<Option<IntegrationState>> {
1293        let path_to_state = workspace_state_path(workspace_path);
1294        let manifest_path = workspace_manifest_path(workspace_path);
1295        match fs::read(&path_to_state) {
1296            Ok(state_bytes) => {
1297                let manifest: IntegrationWorkspaceManifest =
1298                    IntegrationWorkspaceManifest::from_file(
1299                        &manifest_path,
1300                        "integration manifest",
1301                    )?;
1302                manifest.validate_version()?;
1303                manifest.validate_reference_config(&requested.reference_gaussians)?;
1304                if requested.reference_gaussians.iter().any(Option::is_some)
1305                    && manifest.targets != requested.targets
1306                {
1307                    return Err(eyre!(
1308                        "Reference workspace must retain both normalized observable targets equal to one"
1309                    ));
1310                }
1311                if manifest.sampling_correlation_mode != self.sampling_correlation_mode() {
1312                    return Err(eyre!(
1313                        "Workspace integration sampling mode does not match the requested mode; use --restart to switch between correlated and uncorrelated integration"
1314                    ));
1315                }
1316                if manifest.slots != requested.slots {
1317                    return Err(eyre!(
1318                        "Workspace integration slots do not match the currently selected integrands"
1319                    ));
1320                }
1321                if manifest.targets != requested.targets {
1322                    warn!("targets have changed with respect to workspace, reverting changes");
1323                    requested.targets = manifest.targets.clone();
1324                }
1325                if manifest.integrand_fingerprints.len() != selected_slots.len() {
1326                    return Err(eyre!(
1327                        "Workspace integrand fingerprint metadata is inconsistent with the selected slots"
1328                    ));
1329                }
1330                if manifest.effective_model_parameters.len() != selected_slots.len() {
1331                    return Err(eyre!(
1332                        "Workspace effective model parameter metadata is inconsistent with the selected slots"
1333                    ));
1334                }
1335                if requested.effective_model_parameters.len() != selected_slots.len() {
1336                    return Err(eyre!(
1337                        "Current effective model parameter metadata is inconsistent with the selected slots"
1338                    ));
1339                }
1340                let mismatched_slots = selected_slots
1341                    .iter()
1342                    .zip(manifest.effective_model_parameters.iter())
1343                    .zip(requested.effective_model_parameters.iter())
1344                    .filter(|((_, saved_card), current_card)| saved_card != current_card)
1345                    .map(|((slot, _), _)| slot.slot_meta.key())
1346                    .collect_vec();
1347                if !mismatched_slots.is_empty() {
1348                    return Err(eyre!(
1349                        "Workspace effective model parameters do not match the current state for {}. Resume requires an exact match; use --restart or restore the shared/per-integrand model parameters.",
1350                        mismatched_slots.join(", ")
1351                    ));
1352                }
1353                let workspace_settings = selected_slots
1354                    .iter()
1355                    .map(|slot| {
1356                        RuntimeSettings::from_file(
1357                            slot_settings_path(workspace_path, &slot.slot_meta),
1358                            &format!("workspace settings for {}", slot.slot_meta.key()),
1359                        )
1360                    })
1361                    .collect::<Result<Vec<_>>>()?;
1362                let comparison_integrand_fingerprints = izip!(
1363                    selected_slots.iter(),
1364                    workspace_settings.iter(),
1365                    requested.integrand_fingerprints.iter(),
1366                )
1367                .map(|(slot, settings, current_fingerprint)| {
1368                    if settings.sampling.selected_graph_names().is_empty() {
1369                        return Ok(current_fingerprint.clone());
1370                    }
1371
1372                    // A filtered workspace fingerprints the warmed full source. Recreate that
1373                    // state on a clone with the saved non-model settings so a permitted settings
1374                    // change cannot mutate the live source or invalidate resume prematurely.
1375                    let mut integrand = state
1376                        .process_list
1377                        .get_integrand(slot.process_id, &slot.slot_meta.integrand_name)?
1378                        .require_generated()?
1379                        .clone();
1380                    let preserved_model_overrides = integrand.get_settings().model.clone();
1381                    *integrand.get_mut_settings() = settings.clone();
1382                    integrand.get_mut_settings().model = preserved_model_overrides;
1383                    let model = state.resolve_model_for_integrand(
1384                        slot.process_id,
1385                        &slot.slot_meta.integrand_name,
1386                    )?;
1387                    integrand.warm_up(&model)?;
1388                    integrand.resume_fingerprint()
1389                })
1390                .collect::<Result<Vec<_>>>()?;
1391                let mismatched_fingerprint_slots = selected_slots
1392                    .iter()
1393                    .zip(manifest.integrand_fingerprints.iter())
1394                    .zip(comparison_integrand_fingerprints.iter())
1395                    .filter(|&((_, saved), current)| saved != current)
1396                    .map(|((slot, _), _)| slot.slot_meta.key())
1397                    .collect_vec();
1398                if !mismatched_fingerprint_slots.is_empty() {
1399                    return Err(eyre!(
1400                        "Workspace integrand fingerprints do not match the current generated integrands for {}. Resume requires the exact same generated integrands; use --restart or restore the previous generation.",
1401                        mismatched_fingerprint_slots.join(", ")
1402                    ));
1403                }
1404
1405                info!(
1406                    "{}",
1407                    "Found integration state, result of previous integration:".yellow()
1408                );
1409                info!("");
1410
1411                let integration_state: IntegrationState =
1412                    bincode::decode_from_slice::<IntegrationState, _>(
1413                        &state_bytes,
1414                        bincode::config::standard(),
1415                    )
1416                    .map_err(|err| {
1417                        eyre!(
1418"Could not deserialize integration state from {}: {err}. The checkpoint may use an incompatible accumulator layout; use --restart to start a new integration.",
1419                            path_to_state.display()
1420                        )
1421                    })?
1422                    .0;
1423
1424                for ((slot, target), workspace_settings) in selected_slots
1425                    .iter()
1426                    .zip(requested.targets.iter())
1427                    .zip(workspace_settings)
1428                {
1429                    let gloop_integrand = state
1430                        .process_list
1431                        .get_integrand_mut(slot.process_id, &slot.slot_meta.integrand_name)?;
1432                    let current_settings = gloop_integrand.get_mut_settings();
1433                    if *current_settings != workspace_settings {
1434                        warn!(
1435                            "settings for {} have changed with respect to workspace, reverting non-model changes",
1436                            slot.slot_meta.key()
1437                        );
1438                        let preserved_model_overrides = current_settings.model.clone();
1439                        *current_settings = workspace_settings;
1440                        current_settings.model = preserved_model_overrides;
1441                    }
1442
1443                    let label = format!("itg {}", slot.slot_meta.key());
1444                    let saved_slot = integration_state
1445                        .all_integrals
1446                        .get(
1447                            selected_slots
1448                                .iter()
1449                                .position(|candidate| candidate.slot_meta == slot.slot_meta)
1450                                .expect("selected slot must exist"),
1451                        )
1452                        .expect("saved slot must exist");
1453                    print_integral_result(
1454                        &saved_slot.re,
1455                        &label,
1456                        integration_state.iter,
1457                        "re",
1458                        target.as_ref().map(|value| value.re),
1459                    );
1460                    print_integral_result(
1461                        &saved_slot.im,
1462                        &label,
1463                        integration_state.iter,
1464                        "im",
1465                        target.as_ref().map(|value| value.im),
1466                    );
1467                }
1468                info!("");
1469                warn!(
1470                    "Any changes to the settings will be ignored, integrate with the {} option for changes to take effect",
1471                    "--restart".blue()
1472                );
1473                info!("{}", "Resuming integration".yellow());
1474
1475                Ok(Some(integration_state))
1476            }
1477            Err(err) if err.kind() == io::ErrorKind::NotFound => {
1478                info!("No integration state found, starting new integration");
1479                Ok(None)
1480            }
1481            Err(err) => Err(eyre!(
1482                "Could not read integration workspace state from {}: {err}",
1483                path_to_state.display()
1484            )),
1485        }
1486    }
1487
1488    fn warm_and_clone_integrands(
1489        &self,
1490        state: &mut State,
1491        selected_slots: &[ResolvedIntegrandSlot],
1492        slot_models: &[Model],
1493    ) -> Result<Vec<gammalooprs::integrands::process::ProcessIntegrand>> {
1494        info!(
1495            "Gammaloop now integrates {}",
1496            selected_slots
1497                .iter()
1498                .map(|slot| slot.slot_meta.key().green().bold().to_string())
1499                .join(", ")
1500        );
1501
1502        selected_slots
1503            .iter()
1504            .zip(slot_models.iter())
1505            .map(|slot| {
1506                let (slot, model) = slot;
1507                let gloop_integrand = state
1508                    .process_list
1509                    .get_integrand_mut(slot.process_id, &slot.slot_meta.integrand_name)?;
1510                let selected_graph_names = gloop_integrand
1511                    .get_settings()
1512                    .sampling
1513                    .selected_graph_names()
1514                    .to_vec();
1515                if selected_graph_names.is_empty() {
1516                    gloop_integrand.warm_up(model)?;
1517                    return Ok(gloop_integrand.clone());
1518                }
1519
1520                gloop_integrand.warm_up(model)?;
1521                let full_group_count = gloop_integrand.graph_group_count();
1522                let mut integration_view =
1523                    gloop_integrand.clone_with_selected_graph_groups(&selected_graph_names)?;
1524                info!(
1525                    "Runtime graph-group subset for {}: {} of {} groups [{}]",
1526                    slot.slot_meta.key().green().bold(),
1527                    integration_view.graph_group_count(),
1528                    full_group_count,
1529                    integration_view.graph_group_master_names().join(", "),
1530                );
1531                integration_view.warm_up(model)?;
1532                Ok(integration_view)
1533            })
1534            .collect()
1535    }
1536
1537    fn restore_workspace_observables(
1538        &self,
1539        workspace_path: &std::path::Path,
1540        selected_slots: &[ResolvedIntegrandSlot],
1541        integration_state: Option<&IntegrationState>,
1542        slot_integrands: &mut [gammalooprs::integrands::process::ProcessIntegrand],
1543    ) -> Result<()> {
1544        let Some(integration_state) = integration_state else {
1545            return Ok(());
1546        };
1547        if integration_state.iter == 0 {
1548            return Ok(());
1549        }
1550
1551        for ((slot, integrand), slot_meta) in selected_slots
1552            .iter()
1553            .zip(slot_integrands.iter_mut())
1554            .zip(integration_state.slot_metas.iter())
1555        {
1556            debug_assert_eq!(slot.slot_meta, *slot_meta);
1557            if integrand.observable_snapshot_bundle().is_none() {
1558                continue;
1559            }
1560
1561            let snapshot_path =
1562                latest_observable_resume_state_path(workspace_path, &slot.slot_meta);
1563            let snapshot =
1564                ObservableSnapshotBundle::from_json_file(&snapshot_path).map_err(|err| {
1565                    eyre!(
1566                        "Could not restore observable checkpoint for {} from {}: {err}",
1567                        slot.slot_meta.key(),
1568                        snapshot_path.display()
1569                    )
1570                })?;
1571            integrand.restore_observable_snapshot_bundle(&snapshot)?;
1572        }
1573
1574        Ok(())
1575    }
1576
1577    fn resolve_slot_models(
1578        &self,
1579        state: &State,
1580        selected_slots: &[ResolvedIntegrandSlot],
1581    ) -> Result<Vec<Model>> {
1582        selected_slots
1583            .iter()
1584            .map(|slot| {
1585                state.resolve_model_for_integrand(slot.process_id, &slot.slot_meta.integrand_name)
1586            })
1587            .collect()
1588    }
1589
1590    fn resolve_effective_model_parameters(
1591        &self,
1592        state: &State,
1593        selected_slots: &[ResolvedIntegrandSlot],
1594    ) -> Result<Vec<SerializableInputParamCard<F<f64>>>> {
1595        selected_slots
1596            .iter()
1597            .map(|slot| {
1598                state.resolve_effective_model_parameter_card_for_integrand(
1599                    slot.process_id,
1600                    &slot.slot_meta.integrand_name,
1601                )
1602            })
1603            .collect()
1604    }
1605
1606    fn resolve_integrand_fingerprints(
1607        &self,
1608        state: &mut State,
1609        selected_slots: &[ResolvedIntegrandSlot],
1610    ) -> Result<Vec<String>> {
1611        selected_slots
1612            .iter()
1613            .map(|slot| {
1614                state
1615                    .process_list
1616                    .get_integrand_mut(slot.process_id, &slot.slot_meta.integrand_name)?
1617                    .resume_fingerprint()
1618            })
1619            .collect()
1620    }
1621
1622    fn validate_slot_compatibility(
1623        &self,
1624        selected_slots: &[ResolvedIntegrandSlot],
1625        slot_integrands: &[gammalooprs::integrands::process::ProcessIntegrand],
1626    ) -> Result<()> {
1627        if self.uncorrelated || slot_integrands.len() <= 1 {
1628            return Ok(());
1629        }
1630
1631        let reference = &slot_integrands[0];
1632        let reference_grid = reference.create_grid();
1633        let reference_sampling = &reference.get_settings().sampling;
1634
1635        for (slot, integrand) in selected_slots.iter().zip(slot_integrands.iter()).skip(1) {
1636            let sampling = &integrand.get_settings().sampling;
1637            let sampling_matches = match (reference_sampling, sampling) {
1638                (
1639                    SamplingSettings::DiscreteGraphs(reference),
1640                    SamplingSettings::DiscreteGraphs(candidate),
1641                ) if !reference.graph_names.is_empty() && !candidate.graph_names.is_empty() => {
1642                    reference.sample_orientations == candidate.sample_orientations
1643                        && reference.sampling_type == candidate.sampling_type
1644                }
1645                _ => sampling == reference_sampling,
1646            };
1647            if !sampling_matches {
1648                return Err(eyre!(
1649                    "Integrand '{}' does not share the same sampling settings as the leading integrand '{}'",
1650                    slot.slot_meta.key(),
1651                    selected_slots[0].slot_meta.key()
1652                ));
1653            }
1654            if !grids_have_compatible_sample_shape(&reference_grid, &integrand.create_grid()) {
1655                return Err(eyre!(
1656                    "Integrand '{}' does not have a sample shape compatible with the leading integrand '{}'",
1657                    slot.slot_meta.key(),
1658                    selected_slots[0].slot_meta.key()
1659                ));
1660            }
1661        }
1662
1663        Ok(())
1664    }
1665
1666    fn write_workspace_manifest_and_settings(
1667        &self,
1668        slot_integrands: &[gammalooprs::integrands::process::ProcessIntegrand],
1669        selected_slots: &[ResolvedIntegrandSlot],
1670        manifest: &IntegrationWorkspaceManifest,
1671        workspace_path: &std::path::Path,
1672    ) -> Result<()> {
1673        if manifest.integrand_fingerprints.len() != selected_slots.len() {
1674            return Err(eyre!(
1675                "Resolved {} generated-integrand fingerprints for {} integration slots",
1676                manifest.integrand_fingerprints.len(),
1677                selected_slots.len(),
1678            ));
1679        }
1680        manifest.to_file(workspace_manifest_path(workspace_path), true)?;
1681
1682        for (slot, integrand) in selected_slots.iter().zip(slot_integrands.iter()) {
1683            let slot_workspace = slot_workspace_path(workspace_path, &slot.slot_meta);
1684            fs::create_dir_all(&slot_workspace)?;
1685            integrand
1686                .get_settings()
1687                .to_file(slot_settings_path(workspace_path, &slot.slot_meta), true)?;
1688        }
1689
1690        Ok(())
1691    }
1692
1693    fn collect_workspace_observable_snapshots(
1694        &self,
1695        workspace_path: &std::path::Path,
1696        slot_metas: impl IntoIterator<Item = SlotMeta>,
1697    ) -> Result<BTreeMap<String, ObservableSnapshotBundle>> {
1698        let mut observables = BTreeMap::new();
1699        for slot_meta in slot_metas {
1700            let snapshot_path = latest_observable_resume_state_path(workspace_path, &slot_meta);
1701            if !snapshot_path.exists() {
1702                continue;
1703            }
1704            let snapshot =
1705                ObservableSnapshotBundle::from_json_file(&snapshot_path).map_err(|err| {
1706                    eyre!(
1707                        "Could not load observable snapshot for {} from {}: {err}",
1708                        slot_meta.key(),
1709                        snapshot_path.display()
1710                    )
1711                })?;
1712            observables.insert(slot_meta.key(), snapshot);
1713        }
1714        Ok(observables)
1715    }
1716
1717    pub fn run(
1718        &self,
1719        state: &mut State,
1720        global_cli_settings: &CLISettings,
1721    ) -> Result<IntegrationOutput> {
1722        if self.show_summary_only && self.restart {
1723            return Err(eyre!(
1724                "The integrate options `--show-summary-only` and `--restart` cannot be used together"
1725            ));
1726        }
1727        if self.max_table_width == 0 {
1728            return Err(eyre!(
1729                "The integrate option `--max-table-width` must be greater than zero"
1730            ));
1731        }
1732        if self.batch_size == Some(0) {
1733            return Err(eyre!(
1734                "The integrate option `--batch-size` must be greater than zero"
1735            ));
1736        }
1737        if self.batch_timing < 0.0 {
1738            return Err(eyre!(
1739                "The integrate option `--batch-timing` must be greater than or equal to zero"
1740            ));
1741        }
1742        if self.min_time_between_status_updates < 0.0 {
1743            return Err(eyre!(
1744                "The integrate option `--min-time-between-status-updates` must be greater than or equal to zero"
1745            ));
1746        }
1747
1748        let default_workspace_path = self.default_workspace_path(global_cli_settings);
1749        let workspace_path = if let Some(p) = self.workspace_path.clone() {
1750            p
1751        } else {
1752            default_workspace_path.clone()
1753        };
1754
1755        if self.show_summary_only {
1756            let (manifest, integration_state) = read_existing_workspace_state(&workspace_path)?;
1757            manifest.validate_reference_config(&manifest.reference_gaussians)?;
1758            let selected_slots = if self.process.is_empty() && self.integrand_name.is_empty() {
1759                self.resolve_manifest_slots(state, &manifest)?
1760            } else {
1761                self.resolve_selected_slots(state)?
1762            };
1763            let expected_slots = selected_slots
1764                .iter()
1765                .map(|slot| slot.slot_meta.clone())
1766                .collect_vec();
1767            if manifest.slots != expected_slots {
1768                return Err(eyre!(
1769                    "Workspace integration slots do not match the currently selected integrands"
1770                ));
1771            }
1772
1773            if self.reference_gaussian.is_some() {
1774                let references = self.resolve_reference_gaussians(state, &selected_slots)?;
1775                manifest.validate_reference_config(&references)?;
1776            }
1777            let mut targets = if self.target.is_empty() {
1778                manifest.targets.clone()
1779            } else {
1780                self.resolve_targets(&selected_slots)?
1781            };
1782            if manifest.reference_gaussians.iter().any(Option::is_some)
1783                && [&targets, &manifest.targets].iter().any(|values| {
1784                    values.len() != manifest.slots.len()
1785                        || values
1786                            .iter()
1787                            .any(|target| *target != Some(Complex::new(F(1.0), F(1.0))))
1788                })
1789            {
1790                return Err(eyre!(
1791                    "Saved Gaussian reference summary requires both normalized targets (1,1)"
1792                ));
1793            }
1794            if targets != manifest.targets {
1795                warn!("targets have changed with respect to workspace, reverting changes");
1796                targets = manifest.targets.clone();
1797            }
1798
1799            for (slot, reference) in manifest.slots.iter().zip(&manifest.reference_gaussians) {
1800                if let Some(reference) = reference {
1801                    info!(
1802                        "Saved reference acceptance {}: Re=normalization, Im=raw |K|² moment / {}; targets=1",
1803                        slot.key(),
1804                        reference.expected_second_moment::<f64>()
1805                    );
1806                }
1807            }
1808
1809            if integration_state.num_points == 0 {
1810                return Err(eyre!(
1811                    "No completed integration iteration is available in {}",
1812                    workspace_path.display()
1813                ));
1814            }
1815
1816            let slot0_meta = manifest.slots.first().ok_or_else(|| {
1817                eyre!("Integration workspace does not contain any integrand slots")
1818            })?;
1819            let slot0_settings: RuntimeSettings = RuntimeSettings::from_file(
1820                slot_settings_path(&workspace_path, slot0_meta),
1821                &format!("workspace settings for {}", slot0_meta.key()),
1822            )?;
1823            let view_options =
1824                self.build_render_options(std::slice::from_ref(&slot0_settings), true);
1825            let tabled_options = self.build_tabled_render_options();
1826            emit_integration_status_via_tracing(
1827                IntegrationStatusKind::Final,
1828                render_saved_integration_summary(
1829                    &integration_state,
1830                    &targets,
1831                    &view_options,
1832                    &tabled_options,
1833                ),
1834            )?;
1835
1836            return Ok(IntegrationOutput {
1837                result: build_integration_result(&integration_state, &targets),
1838                observables: self.collect_workspace_observable_snapshots(
1839                    &workspace_path,
1840                    selected_slots.iter().map(|slot| slot.slot_meta.clone()),
1841                )?,
1842                workspace_path,
1843            });
1844        }
1845
1846        global_cli_settings.ensure_write_target_outside_active_state(
1847            &workspace_path,
1848            "create or update the integration workspace",
1849        )?;
1850
1851        let selected_slots = self.resolve_selected_slots(state)?;
1852        let references = self.resolve_reference_gaussians(state, &selected_slots)?;
1853        let mut targets = self.resolve_targets(&selected_slots)?;
1854        for (reference, target) in references.iter().zip(&mut targets) {
1855            if reference.is_some() {
1856                let expected = Complex::new(F(1.0), F(1.0));
1857                if target.as_ref().is_some_and(|target| *target != expected) {
1858                    return Err(eyre!(
1859                        "Gaussian reference integration has two normalized targets (1,1); a different --target is contradictory"
1860                    ));
1861                }
1862                *target = Some(expected);
1863            }
1864        }
1865
1866        if self.restart && workspace_path.exists() {
1867            fs::remove_dir_all(&workspace_path)?;
1868        }
1869
1870        if !workspace_path.exists() {
1871            fs::create_dir_all(&workspace_path)?;
1872            info!(
1873                "Created workspace directory at {}",
1874                workspace_path.display()
1875            );
1876        }
1877        let slot_models = self.resolve_slot_models(state, &selected_slots)?;
1878        let mut manifest = IntegrationWorkspaceManifest {
1879            version: IntegrationWorkspaceManifest::CURRENT_VERSION,
1880            slots: selected_slots
1881                .iter()
1882                .map(|slot| slot.slot_meta.clone())
1883                .collect(),
1884            targets,
1885            effective_model_parameters: self
1886                .resolve_effective_model_parameters(state, &selected_slots)?,
1887            integrand_fingerprints: self.resolve_integrand_fingerprints(state, &selected_slots)?,
1888            reference_observable_convention: references
1889                .iter()
1890                .any(Option::is_some)
1891                .then(|| GaussianReferenceFunction::INTEGRATION_CONVENTION.to_owned()),
1892            reference_gaussians: if references.iter().any(Option::is_some) {
1893                references
1894            } else {
1895                Vec::new()
1896            },
1897            training_slot: 0,
1898            integrator_settings_slot: 0,
1899            sampling_correlation_mode: self.sampling_correlation_mode(),
1900        };
1901        let integration_state = self.load_or_prepare_workspace_state(
1902            state,
1903            &selected_slots,
1904            &mut manifest,
1905            &workspace_path,
1906        )?;
1907        let mut slot_integrands =
1908            self.warm_and_clone_integrands(state, &selected_slots, &slot_models)?;
1909        // Persist the post-warm full-source fingerprint for both filtered and unfiltered slots.
1910        manifest.integrand_fingerprints =
1911            self.resolve_integrand_fingerprints(state, &selected_slots)?;
1912        self.restore_workspace_observables(
1913            &workspace_path,
1914            &selected_slots,
1915            integration_state.as_ref(),
1916            &mut slot_integrands,
1917        )?;
1918        self.validate_slot_compatibility(&selected_slots, &slot_integrands)?;
1919        for ((slot, integrand), reference) in selected_slots
1920            .iter()
1921            .zip(&slot_integrands)
1922            .zip(&manifest.reference_gaussians)
1923        {
1924            if let Some(reference) = reference {
1925                reference.for_integrand(integrand)?;
1926                info!(
1927                    "Reference acceptance {}: Re=Gaussian normalization, Im=raw |K|² moment / {}; both targets=1; no physical events",
1928                    slot.slot_meta.key(),
1929                    reference.expected_second_moment::<f64>()
1930                );
1931            }
1932        }
1933        let slot_settings = slot_integrands
1934            .iter()
1935            .map(|integrand| integrand.get_settings().clone())
1936            .collect_vec();
1937        let view_options =
1938            self.build_render_options(&slot_settings, !self.no_show_integration_statistics);
1939        let tabled_options = self.build_tabled_render_options();
1940        self.write_workspace_manifest_and_settings(
1941            &slot_integrands,
1942            &selected_slots,
1943            &manifest,
1944            &workspace_path,
1945        )?;
1946
1947        let n_cores = self
1948            .n_cores
1949            .unwrap_or(global_cli_settings.global.n_cores.integrate);
1950
1951        let stderr_is_tty = io::stderr().is_terminal();
1952        let stream_updates = !self.no_stream_updates && stderr_is_tty;
1953        let stream_iterations = !self.no_stream_iterations && stderr_is_tty;
1954        if !stderr_is_tty && (!self.no_stream_updates || !self.no_stream_iterations) {
1955            info!(
1956                "Streaming integration updates disabled because stderr is not a TTY; live updates will be skipped and iteration summaries will be logged."
1957            );
1958        }
1959        let stream_renderer_kind = self.renderer;
1960        let mut stream_controller = StreamingDisplayController::new(
1961            stream_renderer_kind,
1962            stream_updates,
1963            stream_iterations,
1964            tabled_options,
1965        );
1966        let slots = izip!(
1967            selected_slots.iter().map(|slot| slot.slot_meta.clone()),
1968            slot_settings,
1969            slot_models,
1970            slot_integrands,
1971            manifest.targets
1972        )
1973        .enumerate()
1974        .map(|(slot_index, (meta, settings, model, integrand, target))| {
1975            let mut slot = IntegrationSlot::new(
1976                meta,
1977                settings,
1978                model,
1979                Integrand::ProcessIntegrand(Box::new(integrand)),
1980                target,
1981            );
1982            slot.reference_gaussian = manifest
1983                .reference_gaussians
1984                .get(slot_index)
1985                .cloned()
1986                .flatten();
1987            slot
1988        })
1989        .collect();
1990
1991        let result = match panic::catch_unwind(AssertUnwindSafe(|| {
1992            havana_integrate(
1993                HavanaIntegrateRequest {
1994                    slots,
1995                    sampling_correlation_mode: self.sampling_correlation_mode(),
1996                    n_cores,
1997                    state: integration_state,
1998                    workspace: Some(workspace_path.clone()),
1999                    output_control: self.workspace_snapshot_control(),
2000                    batching: self.build_batching_settings(
2001                        stream_updates,
2002                        stream_updates || stream_iterations,
2003                    ),
2004                    view_options,
2005                },
2006                |status_update: StatusUpdate| stream_controller.handle_status_update(status_update),
2007            )
2008        })) {
2009            Ok(result) => result?,
2010            Err(payload) => return Err(stream_controller.handle_panic(payload)),
2011        };
2012
2013        Ok(IntegrationOutput {
2014            observables: self.collect_workspace_observable_snapshots(
2015                &workspace_path,
2016                selected_slots.iter().map(|slot| slot.slot_meta.clone()),
2017            )?,
2018            result,
2019            workspace_path,
2020        })
2021    }
2022}
2023
2024#[cfg(test)]
2025mod tests {
2026    use super::Integrate;
2027    use super::IntegrationOutput;
2028    use super::ResolvedIntegrandSlot;
2029    use super::{
2030        ContributionSortOption, DashboardKeyAction, RendererOption, ShowPhaseOption,
2031        StreamingDisplayController, TabledKeyAction,
2032    };
2033    use crate::{
2034        state::{CommandHistory, ProcessRef, State},
2035        CLISettings, Commands, SessionSettings, StateSettings,
2036    };
2037    use clap::Parser;
2038    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2039    use gammalooprs::{
2040        integrate::{
2041            workspace_manifest_path, workspace_state_path, ContributionSortMode,
2042            IntegrationStatusPhaseDisplay, SlotMeta,
2043        },
2044        observables::ObservableSnapshotBundle,
2045        settings::{runtime::IntegratedPhase, IntegratorSettings, RuntimeSettings},
2046        utils::F,
2047    };
2048    use spenso::algebra::complex::Complex;
2049    use std::collections::BTreeMap;
2050    use std::path::{Path, PathBuf};
2051    use symbolica::numerical_integration::{ContinuousGrid, DiscreteGrid, Grid};
2052    use tempfile::tempdir;
2053
2054    #[test]
2055    fn non_streamed_panics_resume_the_original_payload() {
2056        for renderer in [RendererOption::Tabled, RendererOption::Ratatui] {
2057            let mut controller =
2058                StreamingDisplayController::new(renderer, false, false, Default::default());
2059            let payload = Box::new(vec![1_u8, 2, 3]);
2060            let address = payload.as_ref() as *const Vec<u8>;
2061            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2062                controller.handle_panic(payload)
2063            }));
2064            let resumed = result.expect_err("non-streamed integration must keep unwinding");
2065            assert_eq!(
2066                resumed.downcast_ref::<Vec<u8>>().unwrap() as *const Vec<u8>,
2067                address
2068            );
2069        }
2070    }
2071
2072    #[test]
2073    fn streamed_panics_stop_the_renderer_and_return_an_error() {
2074        for renderer in [RendererOption::Tabled, RendererOption::Ratatui] {
2075            for (updates, iterations) in [(true, false), (false, true), (true, true)] {
2076                for (payload, expected) in [
2077                    (
2078                        Box::new("borrowed panic") as Box<dyn std::any::Any + Send>,
2079                        "borrowed panic",
2080                    ),
2081                    (Box::new(String::from("owned panic")), "owned panic"),
2082                    (Box::new(42_u32), "panic payload is not a string"),
2083                ] {
2084                    let mut controller = StreamingDisplayController::new(
2085                        renderer,
2086                        updates,
2087                        iterations,
2088                        Default::default(),
2089                    );
2090                    let report = controller.handle_panic(payload);
2091                    assert_eq!(
2092                        report.to_string(),
2093                        format!("Integration panicked: {expected}")
2094                    );
2095                    assert!(controller.renderer.is_none());
2096                    // Streamed runs still recover after Final has closed the display.
2097                    let report = controller.handle_panic(Box::new("after display cleanup"));
2098                    assert_eq!(
2099                        report.to_string(),
2100                        "Integration panicked: after display cleanup"
2101                    );
2102                }
2103            }
2104        }
2105    }
2106
2107    fn write_unversioned_workspace(
2108        workspace: &Path,
2109    ) -> gammalooprs::integrate::IntegrationWorkspaceManifest {
2110        let state_path = workspace_state_path(workspace);
2111        std::fs::create_dir_all(state_path.parent().unwrap()).unwrap();
2112        std::fs::write(
2113            workspace_manifest_path(workspace),
2114            r#"{
2115                "slots": [],
2116                "targets": [],
2117                "effective_model_parameters": [],
2118                "integrand_fingerprints": [],
2119                "training_slot": 0,
2120                "integrator_settings_slot": 0,
2121                "sampling_correlation_mode": "correlated"
2122            }"#,
2123        )
2124        .unwrap();
2125        std::fs::write(state_path, b"not a bincode integration state").unwrap();
2126        serde_json::from_slice(&std::fs::read(workspace_manifest_path(workspace)).unwrap()).unwrap()
2127    }
2128
2129    #[test]
2130    fn summary_rejects_unversioned_workspace_before_decoding_state() {
2131        let temp = tempdir().unwrap();
2132        write_unversioned_workspace(temp.path());
2133
2134        let error = super::read_existing_workspace_state(temp.path())
2135            .err()
2136            .expect("unversioned workspace must be rejected");
2137        let message = error.to_string();
2138        assert!(message.contains("unversioned"), "{message}");
2139        assert!(message.contains("--restart"), "{message}");
2140        assert!(!message.contains("deserialize"), "{message}");
2141    }
2142
2143    #[test]
2144    fn resume_rejects_unversioned_workspace_before_decoding_state() {
2145        let temp = tempdir().unwrap();
2146        let mut requested = write_unversioned_workspace(temp.path());
2147        let mut state = State::new_test();
2148
2149        let error = Integrate::default()
2150            .load_or_prepare_workspace_state(&mut state, &[], &mut requested, temp.path())
2151            .err()
2152            .expect("unversioned workspace must be rejected");
2153        let message = error.to_string();
2154        assert!(message.contains("unversioned"), "{message}");
2155        assert!(message.contains("--restart"), "{message}");
2156        assert!(!message.contains("deserialize"), "{message}");
2157    }
2158
2159    #[test]
2160    fn resume_propagates_workspace_state_read_errors() {
2161        let temp = tempdir().unwrap();
2162        let state_path = workspace_state_path(temp.path());
2163        let mut requested = write_unversioned_workspace(temp.path());
2164        std::fs::remove_file(&state_path).unwrap();
2165        std::fs::create_dir_all(&state_path).unwrap();
2166        let mut state = State::new_test();
2167
2168        let error = Integrate::default()
2169            .load_or_prepare_workspace_state(&mut state, &[], &mut requested, temp.path())
2170            .err()
2171            .expect("workspace read error must be propagated");
2172        let message = error.to_string();
2173        assert!(
2174            message.contains("Could not read integration workspace state"),
2175            "{message}"
2176        );
2177        assert!(
2178            message.contains(&state_path.display().to_string()),
2179            "{message}"
2180        );
2181    }
2182
2183    fn resolved_slot(process_name: &str, integrand_name: &str) -> ResolvedIntegrandSlot {
2184        ResolvedIntegrandSlot {
2185            process_id: 0,
2186            slot_meta: SlotMeta {
2187                process_name: process_name.to_string(),
2188                integrand_name: integrand_name.to_string(),
2189            },
2190        }
2191    }
2192
2193    #[derive(Debug, Parser)]
2194    struct IntegrateCli {
2195        #[command(flatten)]
2196        integrate: Integrate,
2197    }
2198
2199    #[test]
2200    fn read_only_state_uses_cwd_workspace_default() {
2201        let integrate = Integrate::default();
2202        let mut settings = CLISettings {
2203            state: StateSettings {
2204                folder: PathBuf::from("/tmp/saved_state"),
2205                name: Some("gg_hhh_1l".to_string()),
2206            },
2207            session: SessionSettings {
2208                read_only_state: true,
2209                ..SessionSettings::default()
2210            },
2211            ..Default::default()
2212        };
2213
2214        assert_eq!(
2215            integrate.default_workspace_path(&settings),
2216            PathBuf::from("./integration_workspace_gg_hhh_1l")
2217        );
2218
2219        settings.state.name = None;
2220        assert_eq!(
2221            integrate.default_workspace_path(&settings),
2222            PathBuf::from("./integration_workspace")
2223        );
2224    }
2225
2226    #[test]
2227    fn read_only_state_rejects_workspace_inside_active_state() {
2228        let integrate = Integrate {
2229            workspace_path: Some(PathBuf::from("/tmp/saved_state/integration_workspace")),
2230            ..Integrate::default()
2231        };
2232        let settings = CLISettings {
2233            state: StateSettings {
2234                folder: PathBuf::from("/tmp/saved_state"),
2235                ..StateSettings::default()
2236            },
2237            session: SessionSettings {
2238                read_only_state: true,
2239                ..SessionSettings::default()
2240            },
2241            ..Default::default()
2242        };
2243        let err = integrate
2244            .run(&mut State::new_test(), &settings)
2245            .unwrap_err();
2246        assert!(format!("{err:?}").contains("--read-only-state"));
2247    }
2248
2249    #[test]
2250    fn integrate_defaults_to_showing_overall_max_weight_info() {
2251        assert!(Integrate::default().show_max_weight_info);
2252        assert!(!Integrate::default().show_max_weight_info_for_discrete_bins);
2253    }
2254
2255    #[test]
2256    fn truncate_ansi_line_limits_visible_width() {
2257        let line = "\u{1b}[32mhello\u{1b}[0m world";
2258        let truncated = super::truncate_ansi_line(line, 7);
2259
2260        assert!(truncated.contains('\u{1b}'));
2261        assert!(truncated.ends_with("\u{1b}[0m"));
2262        assert!(!truncated.contains("world"));
2263    }
2264
2265    #[test]
2266    fn prepare_stream_block_truncates_each_line_independently() {
2267        let block = "123456789\nabcdefghi";
2268        let prepared = super::prepare_stream_block(block, 5);
2269
2270        assert_eq!(prepared, "12345\r\nabcde");
2271    }
2272
2273    #[test]
2274    fn resolve_targets_supports_keyed_multi_slot_targets() {
2275        let integrate = Integrate {
2276            target: vec![
2277                "triangle@LO=1.0,2.0".to_string(),
2278                "box@scalar_box=3.0,4.0".to_string(),
2279            ],
2280            ..Integrate::default()
2281        };
2282        let slots = vec![
2283            resolved_slot("triangle", "LO"),
2284            resolved_slot("box", "scalar_box"),
2285        ];
2286
2287        let targets = integrate.resolve_targets(&slots).unwrap();
2288
2289        assert_eq!(
2290            targets,
2291            vec![
2292                Some(Complex::new(F(1.0), F(2.0))),
2293                Some(Complex::new(F(3.0), F(4.0))),
2294            ]
2295        );
2296    }
2297
2298    #[test]
2299    fn resolve_targets_preserves_single_slot_legacy_target_format() {
2300        let integrate = Integrate {
2301            target: vec!["1.0".to_string(), "2.0".to_string()],
2302            ..Integrate::default()
2303        };
2304        let slots = vec![resolved_slot("triangle", "LO")];
2305
2306        let targets = integrate.resolve_targets(&slots).unwrap();
2307
2308        assert_eq!(targets, vec![Some(Complex::new(F(1.0), F(2.0)))]);
2309    }
2310
2311    #[test]
2312    fn resolve_targets_applies_shared_legacy_target_to_all_selected_slots() {
2313        let integrate = Integrate {
2314            target: vec!["1.0".to_string(), "2.0".to_string()],
2315            ..Integrate::default()
2316        };
2317        let slots = vec![
2318            resolved_slot("triangle", "LO"),
2319            resolved_slot("box", "scalar_box"),
2320        ];
2321
2322        let targets = integrate.resolve_targets(&slots).unwrap();
2323
2324        assert_eq!(
2325            targets,
2326            vec![
2327                Some(Complex::new(F(1.0), F(2.0))),
2328                Some(Complex::new(F(1.0), F(2.0))),
2329            ]
2330        );
2331    }
2332
2333    #[test]
2334    fn reference_descriptor_cli_serde_and_workspace_contract() {
2335        use clap::Parser;
2336        use gammalooprs::{
2337            integrands::process::GaussianReferenceFunction, integrate::IntegrationWorkspaceManifest,
2338        };
2339        let parsed =
2340            IntegrateCli::try_parse_from(["test", "--reference-gaussian", r#"{"width":300}"#])
2341                .unwrap();
2342        let descriptor = parsed.integrate.reference_gaussian.as_ref().unwrap();
2343        assert_eq!(descriptor.width(), 300.0);
2344        assert!(descriptor.center().is_empty());
2345        let encoded = toml::to_string(&parsed.integrate).unwrap();
2346        let decoded: Integrate = toml::from_str(&encoded).unwrap();
2347        assert_eq!(
2348            decoded.reference_gaussian,
2349            parsed.integrate.reference_gaussian
2350        );
2351        let centered = GaussianReferenceFunction::centered(300.0, 4).unwrap();
2352        let manifest: IntegrationWorkspaceManifest = serde_json::from_value(serde_json::json!({
2353            "version":IntegrationWorkspaceManifest::CURRENT_VERSION,"slots":[{"process_name":"p","integrand_name":"i"}],"targets":[],
2354            "effective_model_parameters":[],"integrand_fingerprints":[],"training_slot":0,
2355            "integrator_settings_slot":0,"sampling_correlation_mode":"correlated",
2356            "reference_gaussians":[centered],
2357            "reference_observable_convention":GaussianReferenceFunction::INTEGRATION_CONVENTION
2358        }))
2359        .unwrap();
2360        manifest
2361            .validate_reference_config(&[Some(centered)])
2362            .unwrap();
2363        assert!(manifest.validate_reference_config(&[None]).is_err());
2364        assert!(manifest
2365            .validate_reference_config(&[Some(
2366                GaussianReferenceFunction::centered(301.0, 4).unwrap()
2367            )])
2368            .is_err());
2369        let mut unknown = manifest;
2370        unknown.reference_observable_convention = Some("unknown".into());
2371        assert!(unknown
2372            .validate_reference_config(&unknown.reference_gaussians)
2373            .is_err());
2374    }
2375
2376    #[test]
2377    fn clap_parses_repeated_keyed_targets() {
2378        let parsed = IntegrateCli::try_parse_from([
2379            "gammaloop",
2380            "--target",
2381            "triangle@LO=1.0,2.0",
2382            "--target",
2383            "box@scalar_box=3.0,4.0",
2384        ])
2385        .unwrap();
2386
2387        assert_eq!(
2388            parsed.integrate.target,
2389            vec![
2390                "triangle@LO=1.0,2.0".to_string(),
2391                "box@scalar_box=3.0,4.0".to_string(),
2392            ]
2393        );
2394    }
2395
2396    #[test]
2397    fn clap_still_parses_legacy_single_slot_target_components() {
2398        let parsed =
2399            IntegrateCli::try_parse_from(["gammaloop", "--target", "-1.0", "2.0"]).unwrap();
2400
2401        assert_eq!(
2402            parsed.integrate.target,
2403            vec!["-1.0".to_string(), "2.0".to_string()]
2404        );
2405    }
2406
2407    #[test]
2408    fn clap_parses_single_token_negative_target_when_attached_to_flag() {
2409        let parsed = IntegrateCli::try_parse_from(["gammaloop", "--target=-1.0,-2.0"]).unwrap();
2410
2411        assert_eq!(parsed.integrate.target, vec!["-1.0,-2.0".to_string()]);
2412    }
2413
2414    #[test]
2415    fn command_history_parses_shared_target_for_multi_integrand_integration() {
2416        let raw = "integrate -p aa_aa -i 1L -p aa_aa -i 1L_m_uv_UP -p aa_aa -i 1L_m_uv_DOWN -p aa_aa -i 1L_mu_r_UP -p aa_aa -i 1L_mu_r_DOWN --n-cores 5 --target -1.0214510394091818e-6 0.0 --renderer ratatui --batch-size 10000 --show-phase both --show-max-weight-info --show-top-discrete-grid --show-discrete-contributions-sum --write-results-for-each-iteration --restart";
2417        let parsed = CommandHistory::from_raw_string(raw).unwrap();
2418
2419        let Commands::Integrate(integrate) = parsed.command else {
2420            panic!("expected integrate command");
2421        };
2422
2423        assert_eq!(
2424            integrate.target,
2425            vec!["-1.0214510394091818e-6,0.0".to_string()]
2426        );
2427    }
2428
2429    #[test]
2430    fn from_slots_preserves_slot_order() {
2431        let integrate = Integrate::from_slots([
2432            (ProcessRef::Id(2), "first"),
2433            (ProcessRef::Name("triangle".to_string()), "second"),
2434        ]);
2435
2436        assert_eq!(
2437            integrate.process,
2438            vec![ProcessRef::Id(2), ProcessRef::Name("triangle".to_string())]
2439        );
2440        assert_eq!(
2441            integrate.integrand_name,
2442            vec!["first".to_string(), "second".to_string()]
2443        );
2444    }
2445
2446    #[test]
2447    fn integration_output_single_slot_observables_returns_only_bundle() {
2448        let mut observables = BTreeMap::new();
2449        observables.insert(
2450            "box@scalar_box".to_string(),
2451            ObservableSnapshotBundle::default(),
2452        );
2453        let output = IntegrationOutput {
2454            observables,
2455            ..IntegrationOutput::default()
2456        };
2457
2458        assert!(output.single_slot_observables().is_some());
2459    }
2460
2461    #[test]
2462    fn sample_shape_compatibility_requires_matching_grid_topology() {
2463        let continuous_2d = Grid::Continuous(ContinuousGrid::new(2, 8, 0, None, false).unwrap());
2464        let continuous_3d = Grid::Continuous(ContinuousGrid::new(3, 8, 0, None, false).unwrap());
2465        let discrete_2 =
2466            Grid::Discrete(DiscreteGrid::new(vec![None, None], F(10.0), false).unwrap());
2467        let discrete_nested = Grid::Discrete(
2468            DiscreteGrid::new(
2469                vec![
2470                    Some(Grid::Continuous(
2471                        ContinuousGrid::new(2, 8, 0, None, false).unwrap(),
2472                    )),
2473                    Some(Grid::Continuous(
2474                        ContinuousGrid::new(2, 8, 0, None, false).unwrap(),
2475                    )),
2476                ],
2477                F(10.0),
2478                false,
2479            )
2480            .unwrap(),
2481        );
2482
2483        assert!(super::grids_have_compatible_sample_shape(
2484            &continuous_2d,
2485            &Grid::Continuous(ContinuousGrid::new(2, 4, 0, None, false).unwrap())
2486        ));
2487        assert!(!super::grids_have_compatible_sample_shape(
2488            &continuous_2d,
2489            &continuous_3d
2490        ));
2491        assert!(!super::grids_have_compatible_sample_shape(
2492            &continuous_2d,
2493            &discrete_2
2494        ));
2495        assert!(!super::grids_have_compatible_sample_shape(
2496            &discrete_2,
2497            &discrete_nested
2498        ));
2499    }
2500
2501    #[test]
2502    fn selected_show_phase_resolves_from_slot_zero_phase() {
2503        assert_eq!(
2504            ShowPhaseOption::Selected.resolve(IntegratedPhase::Real),
2505            IntegrationStatusPhaseDisplay::Real
2506        );
2507        assert_eq!(
2508            ShowPhaseOption::Selected.resolve(IntegratedPhase::Imag),
2509            IntegrationStatusPhaseDisplay::Imag
2510        );
2511    }
2512
2513    #[test]
2514    fn build_render_options_propagates_discrete_monitoring_flags() {
2515        let integrate = Integrate {
2516            show_phase: ShowPhaseOption::Imag,
2517            show_max_weight_info: true,
2518            show_top_discrete_grid: true,
2519            show_discrete_contributions_sum: true,
2520            sort_contributions: ContributionSortOption::Integral,
2521            show_max_weight_info_for_discrete_bins: true,
2522            ..Integrate::default()
2523        };
2524        let settings = RuntimeSettings {
2525            integrator: IntegratorSettings {
2526                integrated_phase: IntegratedPhase::Real,
2527                ..Default::default()
2528            },
2529            ..RuntimeSettings::default()
2530        };
2531
2532        let render_options = integrate.build_render_options(std::slice::from_ref(&settings), false);
2533
2534        assert_eq!(
2535            render_options.phase_display,
2536            IntegrationStatusPhaseDisplay::Imag
2537        );
2538        assert!(!render_options.show_statistics);
2539        assert!(render_options.show_max_weight_details);
2540        assert!(render_options.show_top_discrete_grid);
2541        assert!(render_options.show_discrete_contributions_sum);
2542        assert_eq!(
2543            render_options.contribution_sort,
2544            ContributionSortMode::Integral
2545        );
2546        assert!(render_options.show_max_weight_info_for_discrete_bins);
2547    }
2548
2549    #[test]
2550    fn build_render_options_defaults_training_phase_to_real_for_both() {
2551        let integrate = Integrate::default();
2552        let settings = RuntimeSettings {
2553            integrator: IntegratorSettings {
2554                integrated_phase: IntegratedPhase::Both,
2555                ..Default::default()
2556            },
2557            ..RuntimeSettings::default()
2558        };
2559
2560        let render_options = integrate.build_render_options(&[settings], false);
2561
2562        assert_eq!(
2563            render_options.training_phase_display,
2564            IntegrationStatusPhaseDisplay::Real
2565        );
2566        assert_eq!(
2567            render_options.slot_training_phase_displays,
2568            vec![IntegrationStatusPhaseDisplay::Real]
2569        );
2570    }
2571
2572    #[test]
2573    fn dashboard_ctrl_c_requests_integration_interrupt() {
2574        let mut dashboard = gammalooprs::integrate::RatatuiDashboardState::new();
2575
2576        assert_eq!(
2577            super::handle_dashboard_key_event(
2578                &mut dashboard,
2579                KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
2580            ),
2581            DashboardKeyAction::InterruptIntegration
2582        );
2583    }
2584
2585    #[test]
2586    fn dashboard_x_requests_iteration_abort() {
2587        let mut dashboard = gammalooprs::integrate::RatatuiDashboardState::new();
2588
2589        assert_eq!(
2590            super::handle_dashboard_key_event(
2591                &mut dashboard,
2592                KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE),
2593            ),
2594            DashboardKeyAction::AbortCurrentIteration
2595        );
2596    }
2597
2598    #[test]
2599    fn dashboard_g_toggles_chart_history_window() {
2600        let mut dashboard = gammalooprs::integrate::RatatuiDashboardState::new();
2601
2602        assert_eq!(
2603            super::handle_dashboard_key_event(
2604                &mut dashboard,
2605                KeyEvent::new(KeyCode::Char('g'), KeyModifiers::NONE),
2606            ),
2607            DashboardKeyAction::Redraw
2608        );
2609    }
2610
2611    #[test]
2612    fn dashboard_plus_adjusts_chart_history_window() {
2613        let mut dashboard = gammalooprs::integrate::RatatuiDashboardState::new();
2614
2615        assert_eq!(
2616            super::handle_dashboard_key_event(
2617                &mut dashboard,
2618                KeyEvent::new(KeyCode::Char('+'), KeyModifiers::NONE),
2619            ),
2620            DashboardKeyAction::Redraw
2621        );
2622    }
2623
2624    #[test]
2625    fn dashboard_comma_adjusts_chart_y_range() {
2626        let mut dashboard = gammalooprs::integrate::RatatuiDashboardState::new();
2627
2628        assert_eq!(
2629            super::handle_dashboard_key_event(
2630                &mut dashboard,
2631                KeyEvent::new(KeyCode::Char(','), KeyModifiers::NONE),
2632            ),
2633            DashboardKeyAction::Redraw
2634        );
2635    }
2636
2637    #[test]
2638    fn dashboard_zero_resets_chart_y_range() {
2639        let mut dashboard = gammalooprs::integrate::RatatuiDashboardState::new();
2640
2641        assert_eq!(
2642            super::handle_dashboard_key_event(
2643                &mut dashboard,
2644                KeyEvent::new(KeyCode::Char('0'), KeyModifiers::NONE),
2645            ),
2646            DashboardKeyAction::Redraw
2647        );
2648    }
2649
2650    #[test]
2651    fn dashboard_p_toggles_chart_phase() {
2652        let mut dashboard = gammalooprs::integrate::RatatuiDashboardState::new();
2653
2654        assert_eq!(
2655            super::handle_dashboard_key_event(
2656                &mut dashboard,
2657                KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE),
2658            ),
2659            DashboardKeyAction::Redraw
2660        );
2661    }
2662
2663    #[test]
2664    fn dashboard_i_toggles_statistics_scope() {
2665        let mut dashboard = gammalooprs::integrate::RatatuiDashboardState::new();
2666
2667        assert_eq!(
2668            super::handle_dashboard_key_event(
2669                &mut dashboard,
2670                KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE),
2671            ),
2672            DashboardKeyAction::Redraw
2673        );
2674    }
2675
2676    #[test]
2677    fn dashboard_a_toggles_absolute_integral_view() {
2678        let mut dashboard = gammalooprs::integrate::RatatuiDashboardState::new();
2679        assert!(!dashboard.is_absolute_view());
2680
2681        assert_eq!(
2682            super::handle_dashboard_key_event(
2683                &mut dashboard,
2684                KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE),
2685            ),
2686            DashboardKeyAction::Redraw
2687        );
2688        assert!(dashboard.is_absolute_view());
2689
2690        assert_eq!(
2691            super::handle_dashboard_key_event(
2692                &mut dashboard,
2693                KeyEvent::new(KeyCode::Char('A'), KeyModifiers::SHIFT),
2694            ),
2695            DashboardKeyAction::Redraw
2696        );
2697        assert!(!dashboard.is_absolute_view());
2698    }
2699
2700    #[test]
2701    fn tabled_ctrl_c_requests_integration_interrupt() {
2702        assert_eq!(
2703            super::handle_tabled_key_event(KeyEvent::new(
2704                KeyCode::Char('c'),
2705                KeyModifiers::CONTROL,
2706            )),
2707            TabledKeyAction::InterruptIntegration
2708        );
2709    }
2710
2711    #[test]
2712    fn batching_settings_can_emit_initial_status_without_live_updates() {
2713        let integrate = Integrate::default();
2714        let batching = integrate.build_batching_settings(false, true);
2715
2716        assert!(!batching.emit_live_status_updates);
2717        assert!(batching.emit_initial_status_update);
2718    }
2719}