Skip to main content

gammaloop_api/commands/
integrate.rs

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