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