Skip to main content

gammaloop_api/
state.rs

1use std::{
2    collections::{BTreeMap, BTreeSet, HashSet},
3    fs::{self},
4    io::{self, IsTerminal},
5    ops::ControlFlow,
6    path::{Path, PathBuf},
7    str::FromStr,
8    sync::{
9        atomic::{AtomicBool, AtomicU64, Ordering},
10        Arc, Mutex,
11    },
12    thread,
13    time::{Duration, Instant},
14};
15
16use clap::Args;
17use color_eyre::{Result, Section};
18use colored::Colorize;
19use eyre::{eyre, Context};
20use gammalooprs::{
21    processes::{Amplitude, CrossSection},
22    utils::serde_utils::IsDefault,
23};
24use linnet::half_edge::subgraph::SubGraphLike;
25use schemars::{schema_for, JsonSchema, Schema};
26use serde::{Deserialize, Serialize};
27use spenso::algebra::complex::Complex;
28use symbolica::numerical_integration::Sample;
29use sysinfo::{get_current_pid, ProcessRefreshKind, ProcessesToUpdate, System};
30use toml::Value as TomlValue;
31use tracing::{debug, info, info_span, Span};
32use tracing_indicatif::span_ext::IndicatifSpanExt;
33
34use gammalooprs::{
35    clear_interrupt_request,
36    feyngen::GenerationType,
37    graph::Graph,
38    initialisation::initialise,
39    integrands::{process::ProcessIntegrand, HasIntegrand},
40    is_interrupt_requested,
41    model::{InputParamCard, Model, SerializableInputParamCard, UFOSymbol},
42    processes::{
43        begin_phase, merge_generated_graph_reports, DotExportSettings, GeneratedGraphReport,
44        GenerationProcessKind, GenerationProgressMode, GenerationProgressModeGuard,
45        GenerationProgressObserver, GenerationProgressObserverGuard, GenerationProgressPhase,
46        GraphGenerationStats, GraphGroupSelectionMode, GraphGroupSelectionPlan,
47        GraphGroupSelectionReport, GraphGroupSelectionSpec, NamedGraphGenerationReport, Process,
48        ProcessCollection, ProcessDefinition, ProcessList,
49    },
50    settings::{
51        global::GenerationSettings, runtime::LockedRuntimeSettings, GlobalSettings, RuntimeSettings,
52    },
53    utils::{
54        serde_utils::{get_schema_folder, SmartSerde},
55        tracing::{init_bench_tracing, init_test_tracing},
56        F,
57    },
58    GammaLoopContextContainer,
59};
60
61use crate::{
62    command_parser::{normalize_clap_args, split_command_line},
63    commands::{save::SaveState, Commands},
64    integrand_info::{collect_integrand_info, IntegrandInfo},
65    model_parameters::{external_model_parameter_type, validate_model_parameter_type},
66    render_smart_toml,
67    tracing::{set_file_log_filter, set_log_style, set_stderr_log_filter},
68    CLISettings,
69};
70
71#[derive(Debug, Clone, Copy, Default)]
72pub struct GenerationResourceSummary {
73    pub peak_ram_bytes: u64,
74    pub generation_cores: usize,
75}
76
77#[derive(Debug, Clone, Default)]
78pub struct GenerationReports {
79    pub reports: Vec<GeneratedGraphReport>,
80    pub resources: GenerationResourceSummary,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
84pub struct IntegrandGenerationSummaryKey {
85    pub process_id: usize,
86    pub integrand_name: String,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct IntegrandGenerationSummary {
91    pub peak_ram_bytes: u64,
92    pub reports: Vec<GeneratedGraphReport>,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct SelectedGraphGroups {
97    pub source_process_id: usize,
98    pub source_process_name: String,
99    pub source_integrand_name: String,
100    pub process_id: usize,
101    pub process_name: String,
102    pub integrand_name: String,
103    pub report: GraphGroupSelectionReport,
104    pub copied_to_output: bool,
105    pub replaced_existing_target: bool,
106    pub removed_target_artifacts: bool,
107    pub discarded_generated_integrand: bool,
108    pub removed_generated_artifacts: bool,
109    pub removed_generation_summary: bool,
110}
111
112#[derive(Debug, Clone, Default, PartialEq, Eq)]
113pub struct GraphGroupSelectionTarget {
114    pub output_process_name: Option<String>,
115    pub output_integrand_name: Option<String>,
116    pub clear_existing_processes: bool,
117}
118
119impl GraphGroupSelectionTarget {
120    pub fn in_place() -> Self {
121        Self::default()
122    }
123
124    pub fn copy(
125        output_process_name: Option<String>,
126        output_integrand_name: Option<String>,
127        clear_existing_processes: bool,
128    ) -> Self {
129        Self {
130            output_process_name,
131            output_integrand_name,
132            clear_existing_processes,
133        }
134    }
135
136    fn is_copy_mode(&self) -> bool {
137        self.output_process_name.is_some() || self.output_integrand_name.is_some()
138    }
139}
140
141#[derive(Debug, Clone, Copy)]
142pub struct GraphGroupSelectionContext<'a> {
143    pub generation_settings: &'a GenerationSettings,
144    pub state_folder: &'a Path,
145    pub read_only_state: bool,
146}
147
148impl<'a> GraphGroupSelectionContext<'a> {
149    pub fn new(
150        generation_settings: &'a GenerationSettings,
151        state_folder: &'a Path,
152        read_only_state: bool,
153    ) -> Self {
154        Self {
155            generation_settings,
156            state_folder,
157            read_only_state,
158        }
159    }
160}
161
162struct IntegrandCopyInsertion {
163    process_id: usize,
164    process_name: String,
165    integrand_name: String,
166    replaced_existing_target: bool,
167    removed_target_artifacts: bool,
168}
169
170struct SelectionSource {
171    process_id: usize,
172    process_name: String,
173    integrand_name: String,
174}
175
176enum IntegrandCopyPayload {
177    Amplitude(Amplitude),
178    CrossSection(CrossSection),
179}
180
181impl IntegrandCopyPayload {
182    fn rename(&mut self, new_name: &str) {
183        match self {
184            Self::Amplitude(amplitude) => {
185                amplitude.name = new_name.to_string();
186                rename_process_integrand(amplitude.integrand.as_mut(), new_name);
187            }
188            Self::CrossSection(cross_section) => {
189                cross_section.name = new_name.to_string();
190                rename_process_integrand(cross_section.integrand.as_mut(), new_name);
191            }
192        }
193    }
194
195    fn apply_graph_group_selection(&mut self, plan: &GraphGroupSelectionPlan) -> Result<()> {
196        match self {
197            Self::Amplitude(amplitude) => amplitude.apply_graph_group_selection(plan),
198            Self::CrossSection(cross_section) => cross_section.apply_graph_group_selection(plan),
199        }
200    }
201
202    fn is_compatible_with(&self, collection: &ProcessCollection) -> bool {
203        matches!(
204            (self, collection),
205            (Self::Amplitude(_), ProcessCollection::Amplitudes(_))
206                | (Self::CrossSection(_), ProcessCollection::CrossSections(_))
207        )
208    }
209
210    fn kind_name(&self) -> &'static str {
211        match self {
212            Self::Amplitude(_) => "amplitudes",
213            Self::CrossSection(_) => "cross sections",
214        }
215    }
216
217    fn into_collection(self) -> ProcessCollection {
218        match self {
219            Self::Amplitude(amplitude) => {
220                let mut collection = ProcessCollection::Amplitudes(BTreeMap::new());
221                collection.add_amplitude(amplitude);
222                collection
223            }
224            Self::CrossSection(cross_section) => {
225                let mut collection = ProcessCollection::CrossSections(BTreeMap::new());
226                collection.add_cross_section(cross_section);
227                collection
228            }
229        }
230    }
231
232    fn insert_into_process(self, process: &mut Process, process_name: &str) -> Result<()> {
233        match (self, &mut process.collection) {
234            (Self::Amplitude(amplitude), ProcessCollection::Amplitudes(amplitudes)) => {
235                amplitudes.insert(amplitude.name.clone(), amplitude);
236                Ok(())
237            }
238            (
239                Self::CrossSection(cross_section),
240                ProcessCollection::CrossSections(cross_sections),
241            ) => {
242                cross_sections.insert(cross_section.name.clone(), cross_section);
243                Ok(())
244            }
245            (payload, _) => Err(eyre!(
246                "Destination process '{}' exists but does not contain {}",
247                process_name,
248                payload.kind_name()
249            )),
250        }
251    }
252}
253
254struct GenerationMonitor {
255    peak_ram_bytes: Arc<AtomicU64>,
256    current_ram_bytes: Arc<AtomicU64>,
257    stop_requested: Arc<AtomicBool>,
258    handle: Option<thread::JoinHandle<()>>,
259}
260
261struct AggregateGenerationProgressReporter {
262    progress_span: Span,
263    current_ram_bytes: Arc<AtomicU64>,
264    peak_ram_bytes: Arc<AtomicU64>,
265    state: Mutex<AggregateGenerationProgressState>,
266}
267
268#[derive(Default)]
269struct AggregateGenerationProgressState {
270    phase: Option<GenerationProgressPhase>,
271    kind: Option<GenerationProcessKind>,
272    process: String,
273    integrand: String,
274    total_graphs: usize,
275    done_graphs: usize,
276    total_cuts: Option<usize>,
277    done_cuts: usize,
278    discovered_st_cuts: usize,
279    discovered_valid_cuts: usize,
280    active_graphs: BTreeSet<String>,
281    last_graph: Option<String>,
282    stats: GraphGenerationStats,
283}
284
285impl AggregateGenerationProgressReporter {
286    fn new(
287        current_ram_bytes: Arc<AtomicU64>,
288        peak_ram_bytes: Arc<AtomicU64>,
289        eta_warmup_steps: u64,
290    ) -> Arc<Self> {
291        let span = info_span!(
292            "Integrand generation",
293            indicatif.pb_show = true,
294            indicatif.pb_msg = "Starting integrand generation"
295        );
296        span.pb_set_style(
297            &gammalooprs::utils::long_running_progress_style_with_eta_warmup(eta_warmup_steps),
298        );
299        span.pb_start();
300        span.pb_set_length(0);
301        span.pb_set_position(0);
302        span.pb_tick();
303        Self::new_with_span(current_ram_bytes, peak_ram_bytes, span)
304    }
305
306    #[cfg(test)]
307    fn new_hidden(current_ram_bytes: Arc<AtomicU64>, peak_ram_bytes: Arc<AtomicU64>) -> Arc<Self> {
308        Self::new_with_span(current_ram_bytes, peak_ram_bytes, Span::none())
309    }
310
311    fn new_with_span(
312        current_ram_bytes: Arc<AtomicU64>,
313        peak_ram_bytes: Arc<AtomicU64>,
314        progress_span: Span,
315    ) -> Arc<Self> {
316        Arc::new(Self {
317            progress_span,
318            current_ram_bytes,
319            peak_ram_bytes,
320            state: Mutex::new(AggregateGenerationProgressState::default()),
321        })
322    }
323
324    fn fixed_field(value: &str, width: usize) -> String {
325        let mut chars = value.chars().collect::<Vec<_>>();
326        if chars.len() > width {
327            chars.truncate(width.saturating_sub(1));
328            chars.push('~');
329        }
330        format!("{:<width$}", chars.into_iter().collect::<String>())
331    }
332
333    fn progress_memory(bytes: u64) -> String {
334        const MIB: f64 = 1024.0 * 1024.0;
335        const GIB: f64 = MIB * 1024.0;
336        let value = bytes as f64;
337        if value >= GIB {
338            format!("{:>6.2} GiB", value / GIB)
339        } else {
340            format!("{:>6.0} MiB", value / MIB)
341        }
342    }
343
344    fn progress_percent(percent: f64) -> String {
345        if percent < 0.01 {
346            "0%".to_string()
347        } else if percent < 0.1 {
348            format!("{percent:.3}%")
349        } else if percent < 1.0 {
350            format!("{percent:.2}%")
351        } else if percent < 10.0 {
352            format!("{percent:.1}%")
353        } else {
354            format!("{percent:.0}%")
355        }
356    }
357
358    fn progress_time_share(duration: Duration, total: Duration) -> String {
359        if total.is_zero() {
360            "--".to_string()
361        } else {
362            Self::progress_percent(duration.as_secs_f64() * 100.0 / total.as_secs_f64())
363        }
364    }
365
366    fn progress_units(state: &AggregateGenerationProgressState) -> (u64, u64) {
367        match (state.phase, state.total_cuts) {
368            (Some(GenerationProgressPhase::GraphGeneration), Some(total_cuts)) => {
369                let total = total_cuts.saturating_add(state.total_graphs) as u64;
370                let completed = state
371                    .done_cuts
372                    .saturating_add(state.done_graphs)
373                    .min(total_cuts.saturating_add(state.total_graphs))
374                    as u64;
375                (completed, total)
376            }
377            _ => (state.done_graphs as u64, state.total_graphs as u64),
378        }
379    }
380
381    fn graph_progress_counts(state: &AggregateGenerationProgressState) -> (usize, usize, usize) {
382        let active = state
383            .active_graphs
384            .len()
385            .min(state.total_graphs.saturating_sub(state.done_graphs));
386        (state.done_graphs, active, state.total_graphs)
387    }
388
389    fn refresh(&self, state: &AggregateGenerationProgressState) {
390        let (completed_units, total_units) = Self::progress_units(state);
391        self.progress_span.pb_set_length(total_units);
392        self.progress_span.pb_set_position(completed_units);
393
394        let current_ram = Self::progress_memory(self.current_ram_bytes.load(Ordering::Relaxed));
395        let peak_ram = Self::progress_memory(self.peak_ram_bytes.load(Ordering::Relaxed));
396        let kind = match state.kind {
397            Some(GenerationProcessKind::Amplitude) => "AMP",
398            Some(GenerationProcessKind::CrossSection) => "XS",
399            None => "GEN",
400        };
401        let phase = match state.phase {
402            Some(GenerationProgressPhase::GraphPreprocessing) => "prep",
403            Some(GenerationProgressPhase::GraphGeneration) => "graphs",
404            Some(GenerationProgressPhase::Backend) => "backend",
405            None => "gen",
406        };
407        let last_graph = state.last_graph.as_deref().unwrap_or("-");
408        let cut_context = match (state.phase, state.kind, state.total_cuts) {
409            (
410                Some(GenerationProgressPhase::GraphPreprocessing),
411                Some(GenerationProcessKind::Amplitude),
412                _,
413            ) => None,
414            (Some(GenerationProgressPhase::GraphPreprocessing), _, _) => Some(format!(
415                "{:>4}/{:<4}",
416                state.discovered_valid_cuts, state.discovered_st_cuts
417            )),
418            (_, _, Some(total_cuts)) => Some(format!("{:>4}/{:<4}", state.done_cuts, total_cuts)),
419            _ => Some(format!("{:>4}/{:<4}", "-", "-")),
420        };
421        let phase = Self::fixed_field(phase, 7).bold().blue();
422        let kind = Self::fixed_field(kind, 3).cyan();
423        let identifier = if state.process.is_empty() {
424            state.integrand.clone()
425        } else {
426            format!("{}@{}", state.integrand, state.process)
427        };
428        let identifier = Self::fixed_field(&identifier, 24).yellow();
429        let last_graph = Self::fixed_field(last_graph, 8).green();
430        let (done_graphs, active_graphs, total_graphs) = Self::graph_progress_counts(state);
431        let graph_ratio = format!(
432            "{} {} / {}",
433            format!("{done_graphs:>3}").green(),
434            format!("({active_graphs:>3})").dimmed(),
435            format!("{total_graphs:<3}").green(),
436        );
437        let ram = format!("{current_ram}/{peak_ram}").yellow();
438        let phase_detail = if state.phase == Some(GenerationProgressPhase::GraphPreprocessing) {
439            let steps = match state.kind {
440                Some(GenerationProcessKind::Amplitude) => {
441                    "CFFs + LMBs + integrands + threshold CTs + tropical samplers"
442                }
443                _ => "cuts + CFFs + LMBs + integrands + threshold CTs",
444            };
445            format!("{} {}", "step".bold().blue(), steps.cyan())
446        } else {
447            let total_time = state.stats.total_time;
448            let expression_share =
449                Self::progress_time_share(state.stats.expression_build_time(), total_time);
450            let spenso_share =
451                Self::progress_time_share(state.stats.evaluator_spenso_time, total_time);
452            let symbolica_share =
453                Self::progress_time_share(state.stats.evaluator_symbolica_time, total_time);
454            let compile_share =
455                Self::progress_time_share(state.stats.evaluator_compile_time, total_time);
456            format!(
457                "{} {} {} / {} {} / {} {} / {} {}",
458                "time".bold().blue(),
459                "expr".bold().blue(),
460                expression_share.magenta(),
461                "spenso".bold().blue(),
462                spenso_share.cyan(),
463                "eval".bold().blue(),
464                symbolica_share.green(),
465                "compile".bold().blue(),
466                compile_share.yellow(),
467            )
468        };
469        let mut sections = vec![
470            format!("{} {} {}", phase, kind, identifier),
471            format!("{} {}", "g".bold().blue(), graph_ratio),
472            format!("{} {}", "last".bold().blue(), last_graph),
473        ];
474        if let Some(cut_context) = cut_context {
475            sections.push(format!("{} {}", "cut".bold().blue(), cut_context.green()));
476        }
477        sections.push(format!("{} {}", "ram".bold().blue(), ram));
478        sections.push(phase_detail);
479        self.progress_span.pb_set_message(&sections.join(" | "));
480        self.progress_span.pb_tick();
481    }
482}
483
484impl GenerationProgressObserver for AggregateGenerationProgressReporter {
485    fn begin_phase(
486        &self,
487        phase: GenerationProgressPhase,
488        kind: GenerationProcessKind,
489        process: &str,
490        integrand: &str,
491        total_graphs: usize,
492        total_cuts: Option<usize>,
493    ) {
494        let mut state = self
495            .state
496            .lock()
497            .expect("aggregate generation progress state mutex is poisoned");
498        state.phase = Some(phase);
499        state.kind = Some(kind);
500        state.process.clear();
501        state.process.push_str(process);
502        state.integrand.clear();
503        state.integrand.push_str(integrand);
504        state.total_graphs = total_graphs;
505        state.done_graphs = 0;
506        state.total_cuts = total_cuts;
507        state.done_cuts = 0;
508        state.discovered_st_cuts = 0;
509        state.discovered_valid_cuts = 0;
510        state.active_graphs.clear();
511        state.last_graph = None;
512        state.stats = GraphGenerationStats::default();
513        self.progress_span.pb_reset_elapsed();
514        self.refresh(&state);
515    }
516
517    fn graph_started(
518        &self,
519        _kind: GenerationProcessKind,
520        _integrand: &str,
521        graph: &str,
522        _cut_count: Option<usize>,
523    ) {
524        let mut state = self
525            .state
526            .lock()
527            .expect("aggregate generation progress state mutex is poisoned");
528        state.active_graphs.insert(graph.to_string());
529        state.last_graph = Some(graph.to_string());
530        self.refresh(&state);
531    }
532
533    fn graph_finished(
534        &self,
535        _kind: GenerationProcessKind,
536        _integrand: &str,
537        graph: &str,
538        stats: &GraphGenerationStats,
539        completed_cuts: Option<usize>,
540    ) {
541        let mut state = self
542            .state
543            .lock()
544            .expect("aggregate generation progress state mutex is poisoned");
545        state.done_graphs = state.done_graphs.saturating_add(1).min(state.total_graphs);
546        state.done_cuts += completed_cuts.unwrap_or(0);
547        state.active_graphs.remove(graph);
548        state.last_graph = Some(graph.to_string());
549        state.stats.merge_in_place(stats);
550        self.refresh(&state);
551    }
552
553    fn cuts_discovered(
554        &self,
555        _integrand: &str,
556        graph: &str,
557        st_cut_count: usize,
558        valid_cut_count: usize,
559    ) {
560        let mut state = self
561            .state
562            .lock()
563            .expect("aggregate generation progress state mutex is poisoned");
564        state.discovered_st_cuts += st_cut_count;
565        state.discovered_valid_cuts += valid_cut_count;
566        state.last_graph = Some(graph.to_string());
567        self.refresh(&state);
568    }
569
570    fn cut_finished(&self, _integrand: &str, graph: &str, cut_count: usize) {
571        let mut state = self
572            .state
573            .lock()
574            .expect("aggregate generation progress state mutex is poisoned");
575        state.done_cuts = state.done_cuts.saturating_add(cut_count);
576        if let Some(total_cuts) = state.total_cuts {
577            state.done_cuts = state.done_cuts.min(total_cuts);
578        }
579        state.last_graph = Some(graph.to_string());
580        self.refresh(&state);
581    }
582
583    fn backend_started(&self, kind: GenerationProcessKind, integrand: &str, graph_count: usize) {
584        let mut state = self
585            .state
586            .lock()
587            .expect("aggregate generation progress state mutex is poisoned");
588        state.phase = Some(GenerationProgressPhase::Backend);
589        state.kind = Some(kind);
590        state.integrand.clear();
591        state.integrand.push_str(integrand);
592        state.total_graphs = graph_count;
593        state.done_graphs = 0;
594        state.total_cuts = None;
595        state.done_cuts = 0;
596        state.active_graphs.clear();
597        state.last_graph = None;
598        self.progress_span.pb_reset_elapsed();
599        self.refresh(&state);
600    }
601
602    fn backend_finished(&self, _kind: GenerationProcessKind, _integrand: &str, elapsed: Duration) {
603        let mut state = self
604            .state
605            .lock()
606            .expect("aggregate generation progress state mutex is poisoned");
607        state.done_graphs = state.total_graphs;
608        state.stats.total_time += elapsed;
609        state.stats.evaluator_compile_time += elapsed;
610        self.refresh(&state);
611    }
612}
613
614impl GenerationMonitor {
615    const POLL_INTERVAL: Duration = Duration::from_millis(100);
616
617    fn start() -> Result<Self> {
618        let pid = get_current_pid().map_err(|err| eyre!("Failed to resolve current pid: {err}"))?;
619        let peak_ram_bytes = Arc::new(AtomicU64::new(0));
620        let current_ram_bytes = Arc::new(AtomicU64::new(0));
621        let stop_requested = Arc::new(AtomicBool::new(false));
622        let peak_ram_bytes_for_thread = Arc::clone(&peak_ram_bytes);
623        let current_ram_bytes_for_thread = Arc::clone(&current_ram_bytes);
624        let stop_requested_for_thread = Arc::clone(&stop_requested);
625
626        let handle = thread::Builder::new()
627            .name("generation-ram-monitor".to_string())
628            .spawn(move || {
629                let mut system = System::new();
630                loop {
631                    if stop_requested_for_thread.load(Ordering::Relaxed) || is_interrupt_requested()
632                    {
633                        break;
634                    }
635
636                    system.refresh_processes_specifics(
637                        ProcessesToUpdate::Some(&[pid]),
638                        true,
639                        ProcessRefreshKind::nothing().with_memory(),
640                    );
641                    if let Some(process) = system.process(pid) {
642                        let memory = process.memory();
643                        current_ram_bytes_for_thread.store(memory, Ordering::Relaxed);
644                        peak_ram_bytes_for_thread.fetch_max(memory, Ordering::Relaxed);
645                    }
646                    thread::sleep(Self::POLL_INTERVAL);
647                }
648            })
649            .map_err(|err| eyre!("Failed to spawn generation RAM monitor: {err}"))?;
650
651        Ok(Self {
652            peak_ram_bytes,
653            current_ram_bytes,
654            stop_requested,
655            handle: Some(handle),
656        })
657    }
658
659    fn current_ram_bytes(&self) -> Arc<AtomicU64> {
660        Arc::clone(&self.current_ram_bytes)
661    }
662
663    fn peak_ram_bytes(&self) -> Arc<AtomicU64> {
664        Arc::clone(&self.peak_ram_bytes)
665    }
666
667    fn finish(&mut self) -> u64 {
668        self.stop_requested.store(true, Ordering::Relaxed);
669        if let Some(handle) = self.handle.take() {
670            let _ = handle.join();
671        }
672        self.peak_ram_bytes.load(Ordering::Relaxed)
673    }
674}
675
676impl Drop for GenerationMonitor {
677    fn drop(&mut self) {
678        let _ = self.finish();
679    }
680}
681
682#[derive(Debug, Clone, PartialEq, Eq, JsonSchema)]
683pub enum ProcessRef {
684    Id(usize),
685    Name(String),
686    Unqualified(String),
687}
688
689impl Serialize for ProcessRef {
690    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
691    where
692        S: serde::Serializer,
693    {
694        match self {
695            ProcessRef::Id(id) => serializer.serialize_u64(*id as u64),
696            ProcessRef::Name(name) => serializer.serialize_str(&format!("name:{name}")),
697            ProcessRef::Unqualified(value) => serializer.serialize_str(value),
698        }
699    }
700}
701
702impl<'de> Deserialize<'de> for ProcessRef {
703    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
704    where
705        D: serde::Deserializer<'de>,
706    {
707        use serde::de::{self, Visitor};
708        use std::fmt;
709
710        struct ProcessRefVisitor;
711
712        impl<'de> Visitor<'de> for ProcessRefVisitor {
713            type Value = ProcessRef;
714
715            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
716                formatter.write_str("a process reference string or numeric id")
717            }
718
719            fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
720            where
721                E: de::Error,
722            {
723                Ok(ProcessRef::Id(value as usize))
724            }
725
726            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
727            where
728                E: de::Error,
729            {
730                ProcessRef::from_str(value).map_err(E::custom)
731            }
732
733            fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
734            where
735                E: de::Error,
736            {
737                self.visit_str(&value)
738            }
739        }
740
741        deserializer.deserialize_any(ProcessRefVisitor)
742    }
743}
744
745#[cfg(feature = "python_api")]
746impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for ProcessRef {
747    type Error = pyo3::PyErr;
748
749    fn extract(obj: pyo3::Borrowed<'a, 'py, pyo3::types::PyAny>) -> pyo3::PyResult<Self> {
750        if let Ok(process_id) = <usize as pyo3::FromPyObject<'a, 'py>>::extract(obj) {
751            return Ok(ProcessRef::Id(process_id));
752        }
753
754        let selector = <String as pyo3::FromPyObject<'a, 'py>>::extract(obj).map_err(|_| {
755            pyo3::exceptions::PyTypeError::new_err(
756                "process selectors must be either an integer process id or a string selector",
757            )
758        })?;
759        ProcessRef::from_str(&selector).map_err(pyo3::exceptions::PyValueError::new_err)
760    }
761}
762
763impl FromStr for ProcessRef {
764    type Err = String;
765
766    fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
767        if let Some(rest) = value.strip_prefix('#') {
768            let id = rest
769                .parse::<usize>()
770                .map_err(|_| format!("Invalid process id in '{value}'"))?;
771            return Ok(ProcessRef::Id(id));
772        }
773        if let Some(rest) = value.strip_prefix("name:") {
774            if rest.is_empty() {
775                return Err("Process name cannot be empty".to_string());
776            }
777            return Ok(ProcessRef::Name(rest.to_string()));
778        }
779        if value.is_empty() {
780            return Err("Process reference cannot be empty".to_string());
781        }
782        Ok(ProcessRef::Unqualified(value.to_string()))
783    }
784}
785
786#[test]
787fn try_complicated() {
788    "GGHHH3loop_no_iterative_optimization_3L"
789        .parse::<ProcessRef>()
790        .unwrap();
791    // ProcessRef::
792}
793
794impl ProcessRef {
795    pub fn resolve(&self, process_list: &ProcessList) -> Result<usize> {
796        let process_count = process_list.processes.len();
797        match self {
798            ProcessRef::Id(id) => {
799                if *id >= process_count {
800                    return Err(eyre!(
801                        "Process ID {} invalid, only {} processes available",
802                        id,
803                        process_count
804                    ));
805                }
806                Ok(*id)
807            }
808            ProcessRef::Name(name) => process_list
809                .processes
810                .iter()
811                .position(|p| p.definition.folder_name == *name)
812                .ok_or_else(|| {
813                    eyre!(
814                        "No process named '{}'. Use 'display processes' to list available processes.",
815                        name
816                    )
817                }),
818            ProcessRef::Unqualified(value) => {
819                let name_match = process_list
820                    .processes
821                    .iter()
822                    .position(|p| p.definition.folder_name == *value);
823                if let Ok(id) = value.parse::<usize>() {
824                    let id_valid = id < process_count;
825                    match (id_valid, name_match) {
826                        (true, Some(_)) => Err(eyre!(
827                            "Ambiguous process reference '{}'. Use '#{}' or 'name:{}' to disambiguate.",
828                            value,
829                            id,
830                            value
831                        )),
832                        (true, None) => Ok(id),
833                        (false, Some(index)) => Ok(index),
834                        (false, None) => Err(eyre!(
835                            "No process named '{}'. Use 'display processes' to list available processes.",
836                            value
837                        )),
838                    }
839                } else if let Some(index) = name_match {
840                    Ok(index)
841                } else {
842                    Err(eyre!(
843                        "No process named '{}'. Use 'display processes' to list available processes.",
844                        value
845                    ))
846                }
847            }
848        }
849    }
850}
851
852pub trait ProcessListExt {
853    fn find_integrand_ref(
854        &self,
855        process: Option<&ProcessRef>,
856        integrand_name: Option<&String>,
857    ) -> Result<(usize, String)>;
858    fn get_amplitude_mut_ref(
859        &mut self,
860        process: Option<&ProcessRef>,
861        integrand_name: Option<&String>,
862    ) -> Result<&mut Amplitude>;
863    fn get_cross_section_mut_ref(
864        &mut self,
865        process: Option<&ProcessRef>,
866        integrand_name: Option<&String>,
867    ) -> Result<&mut CrossSection>;
868}
869
870impl ProcessListExt for ProcessList {
871    fn find_integrand_ref(
872        &self,
873        process: Option<&ProcessRef>,
874        integrand_name: Option<&String>,
875    ) -> Result<(usize, String)> {
876        let process_id = match process {
877            Some(process_ref) => process_ref.resolve(self)?,
878            None => self.find_process(None)?,
879        };
880        let integrand_name = self.processes[process_id]
881            .collection
882            .find_integrand(integrand_name.cloned())
883            .with_note(|| format!("in process id {process_id}"))?;
884        Ok((process_id, integrand_name))
885    }
886
887    fn get_amplitude_mut_ref(
888        &mut self,
889        process: Option<&ProcessRef>,
890        integrand_name: Option<&String>,
891    ) -> Result<&mut Amplitude> {
892        let (process_id, integrand_name) = self.find_integrand_ref(process, integrand_name)?;
893        let process = &mut self.processes[process_id];
894        match &mut process.collection {
895            ProcessCollection::Amplitudes(amplitudes) => {
896                amplitudes.get_mut(&integrand_name).ok_or_else(|| {
897                    eyre!(
898                        "No amplitude named '{}' in process '{}'",
899                        integrand_name,
900                        process.definition.folder_name
901                    )
902                })
903            }
904            ProcessCollection::CrossSections(_) => Err(eyre!(
905                "Process '{}' does not contain amplitudes",
906                process.definition.folder_name
907            )),
908        }
909    }
910
911    fn get_cross_section_mut_ref(
912        &mut self,
913        process: Option<&ProcessRef>,
914        integrand_name: Option<&String>,
915    ) -> Result<&mut CrossSection> {
916        let (process_id, integrand_name) = self.find_integrand_ref(process, integrand_name)?;
917        let process = &mut self.processes[process_id];
918        match &mut process.collection {
919            ProcessCollection::CrossSections(crosssections) => {
920                crosssections.get_mut(&integrand_name).ok_or_else(|| {
921                    eyre!(
922                        "No cross section named '{}' in process '{}'",
923                        integrand_name,
924                        process.definition.folder_name
925                    )
926                })
927            }
928            ProcessCollection::Amplitudes(_) => Err(eyre!(
929                "Process '{}' does not contain crosssections",
930                process.definition.folder_name
931            )),
932        }
933    }
934}
935
936pub trait SyncSettings {
937    fn sync_settings(&self) -> Result<()>;
938}
939
940impl SyncSettings for CLISettings {
941    fn sync_settings(&self) -> Result<()> {
942        // println!("Syncing settings {}", self.global.logfile_directive);
943        set_file_log_filter(&self.global.logfile_directive)?;
944        set_stderr_log_filter(&self.global.display_directive)?;
945        set_log_style(self.global.log_style.to_runtime());
946        Ok(())
947    }
948}
949
950// Static flag to control serialization behavior
951static SERIALIZE_COMMANDS_AS_STRINGS: AtomicBool = AtomicBool::new(false);
952
953fn is_command_blocks_empty(command_blocks: &[CommandsBlock]) -> bool {
954    command_blocks.is_empty()
955}
956
957fn should_persist_command(command: &Commands) -> bool {
958    !matches!(
959        command,
960        Commands::Quit(_) | Commands::StartCommandsBlock(_) | Commands::FinishCommandsBlock
961    )
962}
963
964/// Set whether CommandHistory should serialize as strings when the raw_string is available
965pub fn set_serialize_commands_as_strings(value: bool) {
966    SERIALIZE_COMMANDS_AS_STRINGS.store(value, std::sync::atomic::Ordering::Relaxed);
967}
968
969/// Get the current setting for CommandHistory serialization behavior
970pub fn get_serialize_commands_as_strings() -> bool {
971    SERIALIZE_COMMANDS_AS_STRINGS.load(std::sync::atomic::Ordering::Relaxed)
972}
973
974pub struct SerializeCommandsAsStringsGuard {
975    previous: bool,
976}
977
978impl SerializeCommandsAsStringsGuard {
979    pub fn new(value: bool) -> Self {
980        let previous = get_serialize_commands_as_strings();
981        set_serialize_commands_as_strings(value);
982        Self { previous }
983    }
984}
985
986impl Drop for SerializeCommandsAsStringsGuard {
987    fn drop(&mut self) {
988        set_serialize_commands_as_strings(self.previous);
989    }
990}
991
992/// Represents a command with optional raw string representation
993///
994/// This struct stores both the parsed command and optionally the original
995/// string that was used to create it. This allows for preserving the exact
996/// user input while still having access to the structured command data.
997#[derive(Debug, Clone, JsonSchema, PartialEq)]
998pub struct CommandHistory {
999    /// The parsed command
1000    pub command: Commands,
1001    /// The original string representation of the command, if available
1002    pub raw_string: Option<String>,
1003}
1004
1005impl Serialize for CommandHistory {
1006    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1007    where
1008        S: serde::Serializer,
1009    {
1010        if get_serialize_commands_as_strings() {
1011            if let Some(ref raw_string) = self.raw_string {
1012                raw_string.serialize(serializer)
1013            } else {
1014                self.command.serialize(serializer)
1015            }
1016        } else {
1017            self.command.serialize(serializer)
1018        }
1019    }
1020}
1021
1022impl<'de> Deserialize<'de> for CommandHistory {
1023    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1024    where
1025        D: serde::Deserializer<'de>,
1026    {
1027        use serde::de::{self, Visitor};
1028        use std::fmt;
1029
1030        struct CommandHistoryVisitor;
1031
1032        impl<'de> Visitor<'de> for CommandHistoryVisitor {
1033            type Value = CommandHistory;
1034
1035            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
1036                formatter.write_str("a string or a Commands structure")
1037            }
1038
1039            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
1040            where
1041                E: de::Error,
1042            {
1043                CommandHistory::from_raw_string(value).map_err(|err| {
1044                    E::custom(format!(
1045                        "Failed to parse command string '{}': {}",
1046                        value, err
1047                    ))
1048                })
1049            }
1050
1051            fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
1052            where
1053                E: de::Error,
1054            {
1055                self.visit_str(&value)
1056            }
1057
1058            fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
1059            where
1060                A: de::SeqAccess<'de>,
1061            {
1062                // Handle TOML array format for enums like [Quit]
1063                let command =
1064                    Commands::deserialize(de::value::SeqAccessDeserializer::new(&mut seq))?;
1065                Ok(CommandHistory {
1066                    command,
1067                    raw_string: None,
1068                })
1069            }
1070
1071            fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
1072            where
1073                A: de::MapAccess<'de>,
1074            {
1075                // Handle map format for enums
1076                let command = Commands::deserialize(de::value::MapAccessDeserializer::new(map))?;
1077                Ok(CommandHistory {
1078                    command,
1079                    raw_string: None,
1080                })
1081            }
1082        }
1083
1084        deserializer.deserialize_any(CommandHistoryVisitor)
1085    }
1086}
1087
1088impl CommandHistory {
1089    /// Create a new CommandHistory with just a command (no raw string)
1090    pub fn new(command: Commands) -> Self {
1091        Self {
1092            command,
1093            raw_string: None,
1094        }
1095    }
1096
1097    /// Create a new CommandHistory with both command and raw string
1098    pub fn new_with_raw(command: Commands, raw_string: String) -> Self {
1099        Self {
1100            command,
1101            raw_string: Some(raw_string),
1102        }
1103    }
1104
1105    /// Create a CommandHistory from a command (alias for new)
1106    pub fn from_command(command: Commands) -> Self {
1107        Self::new(command)
1108    }
1109
1110    /// Parse a raw string into a CommandHistory
1111    ///
1112    /// This function attempts to parse the raw string using clap, and if successful,
1113    /// creates a CommandHistory with both the parsed command and the original string.
1114    pub fn from_raw_string(raw_string: &str) -> Result<Self, clap::Error> {
1115        use crate::Repl;
1116        use clap::error::ErrorKind;
1117        use clap::Parser;
1118
1119        let args = split_command_line(raw_string)
1120            .map(normalize_clap_args)
1121            .map_err(|_| {
1122                clap::Error::raw(
1123                    ErrorKind::InvalidValue,
1124                    "Could not parse command: unmatched quotes or trailing escape",
1125                )
1126            })?;
1127        let cli = Repl::try_parse_from(
1128            std::iter::once("gammaloop").chain(args.iter().map(String::as_str)),
1129        )?;
1130
1131        Ok(Self::new_with_raw(cli.command, raw_string.into()))
1132    }
1133}
1134
1135#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, PartialEq)]
1136#[serde(default, deny_unknown_fields)]
1137pub struct RunHistory {
1138    /// Default runtime settings restored before replaying the recorded commands.
1139    #[serde(skip_serializing_if = "IsDefault::is_default")]
1140    pub default_runtime_settings: RuntimeSettings,
1141
1142    /// State and global CLI settings captured with this run history.
1143    #[serde(skip_serializing_if = "IsDefault::is_default")]
1144    pub cli_settings: CLISettings,
1145
1146    /// Named reusable command sequences declared by run cards.
1147    #[serde(skip_serializing_if = "is_command_blocks_empty")]
1148    pub command_blocks: Vec<CommandsBlock>,
1149    // #[serde(with = "serde_yaml::with::singleton_map_recursive")]
1150    // #[schemars(with = "Vec<CommandHistory>")]
1151    /// Ordered top-level commands persisted for replay.
1152    pub commands: Vec<CommandHistory>,
1153}
1154
1155#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, PartialEq)]
1156#[serde(default, deny_unknown_fields)]
1157pub struct CommandsBlock {
1158    pub name: String,
1159    pub commands: Vec<CommandHistory>,
1160}
1161
1162#[derive(Debug, Deserialize, Default)]
1163#[serde(default, deny_unknown_fields)]
1164struct RawRunHistoryToml {
1165    default_runtime_settings: RuntimeSettings,
1166    cli_settings: CLISettings,
1167    command_blocks: Vec<RawCommandsBlockToml>,
1168    commands: Vec<TomlValue>,
1169}
1170
1171#[derive(Debug, Deserialize, Default)]
1172#[serde(default, deny_unknown_fields)]
1173struct RawCommandsBlockToml {
1174    name: String,
1175    commands: Vec<TomlValue>,
1176}
1177
1178impl CommandsBlock {
1179    pub fn semantically_eq(&self, other: &Self) -> bool {
1180        self.name == other.name
1181            && self.commands.len() == other.commands.len()
1182            && self
1183                .commands
1184                .iter()
1185                .zip(other.commands.iter())
1186                .all(|(left, right)| left.command == right.command)
1187    }
1188}
1189
1190impl SmartSerde for RunHistory {
1191    fn has_schema_path(&self, online: bool) -> Option<Result<PathBuf>> {
1192        Some(get_schema_folder(online).map(|f| f.join("runhistory.json")))
1193    }
1194}
1195
1196impl RunHistory {
1197    pub fn freeze_boot_settings_from(&mut self, boot_run_history: &RunHistory) {
1198        self.cli_settings.global = boot_run_history.cli_settings.global.clone();
1199        self.default_runtime_settings = boot_run_history.default_runtime_settings.clone();
1200    }
1201
1202    pub fn frozen_boot_settings_match(&self, boot_run_history: &RunHistory) -> bool {
1203        self.cli_settings.global == boot_run_history.cli_settings.global
1204            && self.default_runtime_settings == boot_run_history.default_runtime_settings
1205    }
1206
1207    /// Add a command to the run history
1208    pub fn push(&mut self, command: Commands) {
1209        self.push_with_raw(command, None);
1210    }
1211
1212    /// Add a command with optional raw string to the run history
1213    ///
1214    /// If raw_string is provided, it will be stored alongside the command
1215    /// for potential later serialization as a string.
1216    pub fn push_with_raw(&mut self, command: Commands, raw_string: Option<String>) {
1217        if should_persist_command(&command) {
1218            self.commands.push(CommandHistory {
1219                command,
1220                raw_string,
1221            });
1222        }
1223    }
1224
1225    pub fn schema() -> Schema {
1226        schema_for!(RunHistory)
1227    }
1228
1229    pub fn validate(&self) -> Result<()> {
1230        let mut seen_names = HashSet::with_capacity(self.command_blocks.len());
1231        for block in &self.command_blocks {
1232            if block.name.trim().is_empty() {
1233                return Err(eyre!(
1234                    "Run card `command_blocks` contains a block with an empty name"
1235                ));
1236            }
1237            if !seen_names.insert(block.name.clone()) {
1238                return Err(eyre!(
1239                    "Run card `command_blocks` contains duplicate block name '{}'",
1240                    block.name
1241                ));
1242            }
1243        }
1244        Ok(())
1245    }
1246
1247    pub fn command_block(&self, name: &str) -> Option<&CommandsBlock> {
1248        self.command_blocks.iter().find(|block| block.name == name)
1249    }
1250
1251    pub fn select_command_blocks(
1252        &self,
1253        selected_block_names: &[String],
1254    ) -> Result<Vec<CommandsBlock>> {
1255        let mut selected = Vec::with_capacity(selected_block_names.len());
1256        for name in selected_block_names {
1257            let block = self.command_block(name).ok_or_else(|| {
1258                eyre!(
1259                    "Unknown command block '{}'. Available command blocks: {}",
1260                    name,
1261                    self.command_blocks
1262                        .iter()
1263                        .map(|block| block.name.as_str())
1264                        .collect::<Vec<_>>()
1265                        .join(", ")
1266                )
1267            })?;
1268            selected.push(block.clone());
1269        }
1270        Ok(selected)
1271    }
1272
1273    pub fn conflicting_command_block_names(&self, command_blocks: &[CommandsBlock]) -> Vec<String> {
1274        command_blocks
1275            .iter()
1276            .filter_map(|new_block| match self.command_block(&new_block.name) {
1277                Some(existing_block) if !existing_block.semantically_eq(new_block) => {
1278                    Some(new_block.name.clone())
1279                }
1280                _ => None,
1281            })
1282            .collect()
1283    }
1284
1285    pub fn merge_command_blocks_with_overwrite(
1286        &mut self,
1287        command_blocks: &[CommandsBlock],
1288        overwrite_conflicts: bool,
1289    ) -> Result<()> {
1290        for new_block in command_blocks {
1291            match self
1292                .command_blocks
1293                .iter()
1294                .position(|existing| existing.name == new_block.name)
1295            {
1296                Some(index) if self.command_blocks[index].semantically_eq(new_block) => {}
1297                Some(index) if overwrite_conflicts => {
1298                    self.command_blocks[index] = new_block.clone();
1299                }
1300                Some(_) => {
1301                    return Err(eyre!(
1302                        "Run card command block '{}' redefines an existing block with different commands",
1303                        new_block.name
1304                    ));
1305                }
1306                None => self.command_blocks.push(new_block.clone()),
1307            }
1308        }
1309        self.validate()
1310    }
1311
1312    pub fn merge_command_blocks(&mut self, command_blocks: &[CommandsBlock]) -> Result<()> {
1313        self.merge_command_blocks_with_overwrite(command_blocks, false)
1314    }
1315
1316    pub fn apply_session_settings(
1317        &self,
1318        global_settings: &mut CLISettings,
1319        default_runtime_settings: &mut RuntimeSettings,
1320    ) -> Result<()> {
1321        if self.cli_settings.global != GlobalSettings::default() {
1322            global_settings.global = self.cli_settings.global.clone();
1323            global_settings.sync_settings()?;
1324        }
1325        if self.default_runtime_settings != RuntimeSettings::default() {
1326            *default_runtime_settings = self.default_runtime_settings.clone();
1327        }
1328        Ok(())
1329    }
1330
1331    pub fn run(
1332        &mut self,
1333        state: &mut State,
1334        global_settings: &mut CLISettings,
1335        default_runtime_settings: &mut RuntimeSettings,
1336    ) -> Result<ControlFlow<SaveState>> {
1337        let mut session_state = crate::session::CliSessionState::default();
1338        let mut session = crate::session::CliSession::new(
1339            state,
1340            self,
1341            global_settings,
1342            default_runtime_settings,
1343            &mut session_state,
1344        );
1345        session.replay_run_history()
1346    }
1347
1348    pub(crate) fn filtered_for_save(&self) -> Self {
1349        let mut filtered = self.clone();
1350        filtered
1351            .commands
1352            .retain(|command_history| should_persist_command(&command_history.command));
1353        filtered
1354    }
1355
1356    pub(crate) fn to_toml_string(&self, serialize_commands_as_strings: bool) -> Result<String> {
1357        let _serialize_commands_guard =
1358            SerializeCommandsAsStringsGuard::new(serialize_commands_as_strings);
1359        render_smart_toml(&self.filtered_for_save())
1360    }
1361
1362    pub fn load(path: impl AsRef<Path>) -> Result<Self> {
1363        let path = path.as_ref();
1364        debug!("Loaded run history from file {}", path.display());
1365
1366        let runhistory = match path.extension().and_then(|ext| ext.to_str()) {
1367            Some("toml") => Self::load_toml(path)?,
1368            _ => Self::from_file(path, "run history")?,
1369        };
1370        runhistory.validate()?;
1371        Ok(runhistory)
1372    }
1373
1374    pub fn save_toml(
1375        &self,
1376        root_folder: &Path,
1377        override_state_file: bool,
1378        _strict: bool,
1379    ) -> Result<()> {
1380        self.filtered_for_save()
1381            .to_file(root_folder.join("run.toml"), override_state_file)?;
1382
1383        //Self::schema().to_file(root_folder.join("run_schema.json"))?;
1384        Ok(())
1385    }
1386
1387    pub fn save_yaml(
1388        &self,
1389        root_folder: &Path,
1390        override_state_file: bool,
1391        _strict: bool,
1392    ) -> Result<()> {
1393        self.filtered_for_save()
1394            .to_file(root_folder.join("run.yaml"), override_state_file)?;
1395        //Self::schema().to_file(root_folder.join("run_schema.json"))?;
1396        Ok(())
1397    }
1398
1399    fn load_toml(path: &Path) -> Result<Self> {
1400        let raw = fs::read_to_string(path)
1401            .with_context(|| format!("Could not open run history file {}", path.display()))?;
1402        let raw_run_history: RawRunHistoryToml =
1403            toml::from_str(&raw).wrap_err("Error parsing run history toml")?;
1404
1405        let commands = raw_run_history
1406            .commands
1407            .into_iter()
1408            .enumerate()
1409            .map(|(index, value)| {
1410                parse_toml_command_history(value, &format!("top-level command #{}", index + 1))
1411            })
1412            .collect::<Result<Vec<_>>>()?;
1413
1414        let command_blocks = raw_run_history
1415            .command_blocks
1416            .into_iter()
1417            .map(|block| {
1418                let RawCommandsBlockToml { name, commands } = block;
1419                let commands = commands
1420                    .into_iter()
1421                    .enumerate()
1422                    .map(|(index, value)| {
1423                        parse_toml_command_history(
1424                            value,
1425                            &format!("command block '{}' command #{}", name, index + 1),
1426                        )
1427                    })
1428                    .collect::<Result<Vec<_>>>()?;
1429                Ok(CommandsBlock { name, commands })
1430            })
1431            .collect::<Result<Vec<_>>>()?;
1432
1433        Ok(Self {
1434            default_runtime_settings: raw_run_history.default_runtime_settings,
1435            cli_settings: raw_run_history.cli_settings,
1436            command_blocks,
1437            commands,
1438        })
1439    }
1440}
1441
1442fn parse_toml_command_history(value: TomlValue, context: &str) -> Result<CommandHistory> {
1443    match value {
1444        TomlValue::String(raw) => CommandHistory::from_raw_string(&raw)
1445            .map_err(|err| eyre!("Failed to parse {} '{}': {}", context, raw, err)),
1446        other => other
1447            .try_into::<CommandHistory>()
1448            .map_err(|err| eyre!("Failed to parse {}: {}", context, err)),
1449    }
1450}
1451
1452#[cfg_attr(
1453    feature = "python_api",
1454    pyo3::pyclass(from_py_object, unsendable, name = "GammaLoopState")
1455)]
1456#[derive(Clone)]
1457pub struct State {
1458    /// Active physics model used to interpret processes and integrands.
1459    pub model: Model,
1460    /// Numerical input-parameter values currently applied to the model.
1461    pub model_parameters: InputParamCard<F<f64>>,
1462    /// Imported processes together with their generated integrands.
1463    pub process_list: ProcessList,
1464    /// Generation provenance indexed by process and integrand.
1465    pub generation_summaries: BTreeMap<IntegrandGenerationSummaryKey, IntegrandGenerationSummary>,
1466}
1467
1468const STATE_MANIFEST_FILE: &str = "state_manifest.toml";
1469const INTEGRAND_GENERATION_SUMMARY_FILE: &str = "generation_summary.json";
1470const CURRENT_STATE_MANIFEST_VERSION: u32 = 1;
1471const GENERATION_THREAD_STACK_SIZE_BYTES: usize = 32 * 1024 * 1024;
1472
1473#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1474#[serde(default, deny_unknown_fields)]
1475struct StateManifest {
1476    version: u32,
1477}
1478
1479impl Default for StateManifest {
1480    fn default() -> Self {
1481        Self {
1482            version: CURRENT_STATE_MANIFEST_VERSION,
1483        }
1484    }
1485}
1486
1487fn ensure_supported_state_manifest_version(manifest: &StateManifest) -> Result<()> {
1488    if manifest.version > CURRENT_STATE_MANIFEST_VERSION {
1489        return Err(eyre!(
1490            "State version {} is newer than this binary supports (max {}). Please upgrade gammaloop.",
1491            manifest.version,
1492            CURRENT_STATE_MANIFEST_VERSION
1493        ));
1494    }
1495
1496    Ok(())
1497}
1498
1499fn run_state_migration_checks(manifest: &StateManifest, save_path: &Path) -> Result<()> {
1500    ensure_supported_state_manifest_version(manifest)?;
1501
1502    match manifest.version {
1503        1 => {
1504            if !save_path.join("symbolica_state.bin").exists() {
1505                return Err(eyre!(
1506                    "Saved state at '{}' is missing required file symbolica_state.bin",
1507                    save_path.display()
1508                ));
1509            }
1510            if !save_path.join("processes").exists() {
1511                return Err(eyre!(
1512                    "Saved state at '{}' is missing required folder processes/",
1513                    save_path.display()
1514                ));
1515            }
1516            if !save_path.join("model.json").exists() {
1517                return Err(eyre!(
1518                    "Saved state at '{}' is missing required file model.json",
1519                    save_path.display()
1520                ));
1521            }
1522            Ok(())
1523        }
1524        _ => Err(eyre!(
1525            "State version {} is not supported by this binary.",
1526            manifest.version
1527        )),
1528    }
1529}
1530
1531#[derive(Debug, Clone, PartialEq, Eq)]
1532pub enum StateFolderKind {
1533    /// The configured state path does not exist.
1534    Missing,
1535    /// The state directory is empty apart from its optional `logs` directory.
1536    Scratch,
1537    /// The directory contains data but no state manifest.
1538    Unmanifested,
1539    /// The directory contains a supported manifest and all required state files.
1540    Saved,
1541    /// The path cannot be used as a saved state; the payload explains why.
1542    Invalid(String),
1543}
1544
1545fn is_scratch_state_entry(entry: &fs::DirEntry) -> bool {
1546    entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false)
1547        && entry.file_name().to_string_lossy() == "logs"
1548}
1549
1550pub fn classify_state_folder(save_path: &Path) -> Result<StateFolderKind> {
1551    if !save_path.exists() {
1552        return Ok(StateFolderKind::Missing);
1553    }
1554    if !save_path.is_dir() {
1555        return Ok(StateFolderKind::Invalid(format!(
1556            "'{}' exists but is not a directory",
1557            save_path.display()
1558        )));
1559    }
1560
1561    let manifest_path = save_path.join(STATE_MANIFEST_FILE);
1562    if manifest_path.exists() {
1563        let manifest = load_state_manifest(save_path)?;
1564        return Ok(match run_state_migration_checks(&manifest, save_path) {
1565            Ok(()) => StateFolderKind::Saved,
1566            Err(err) => StateFolderKind::Invalid(err.to_string()),
1567        });
1568    }
1569
1570    let mut entries = fs::read_dir(save_path)
1571        .with_context(|| format!("Trying to read state folder '{}'", save_path.display()))?;
1572    if entries.by_ref().all(|entry| {
1573        entry
1574            .map(|entry| is_scratch_state_entry(&entry))
1575            .unwrap_or(false)
1576    }) {
1577        return Ok(StateFolderKind::Scratch);
1578    }
1579
1580    Ok(StateFolderKind::Unmanifested)
1581}
1582
1583fn load_state_manifest(save_path: &Path) -> Result<StateManifest> {
1584    let manifest_path = save_path.join(STATE_MANIFEST_FILE);
1585    let raw_manifest = fs::read_to_string(&manifest_path).with_context(|| {
1586        format!(
1587            "Trying to read state manifest file {}",
1588            manifest_path.display()
1589        )
1590    })?;
1591    let manifest = toml::from_str::<StateManifest>(&raw_manifest).with_context(|| {
1592        format!(
1593            "Trying to parse state manifest file {}",
1594            manifest_path.display()
1595        )
1596    })?;
1597    ensure_supported_state_manifest_version(&manifest)?;
1598    Ok(manifest)
1599}
1600
1601fn save_state_manifest(save_path: &Path) -> Result<()> {
1602    let manifest = StateManifest::default();
1603    let raw_manifest =
1604        toml::to_string_pretty(&manifest).context("Trying to serialize state manifest to TOML")?;
1605    fs::write(save_path.join(STATE_MANIFEST_FILE), raw_manifest).with_context(|| {
1606        format!(
1607            "Trying to write state manifest file {}",
1608            save_path.join(STATE_MANIFEST_FILE).display()
1609        )
1610    })?;
1611    Ok(())
1612}
1613
1614fn integrand_generation_summary_path(
1615    root_folder: &Path,
1616    process: &Process,
1617    integrand_name: &str,
1618) -> PathBuf {
1619    let process_kind_folder = match &process.collection {
1620        ProcessCollection::Amplitudes(_) => "amplitudes",
1621        ProcessCollection::CrossSections(_) => "cross_sections",
1622    };
1623
1624    root_folder
1625        .join("processes")
1626        .join(process_kind_folder)
1627        .join(&process.definition.folder_name)
1628        .join(integrand_name)
1629        .join(INTEGRAND_GENERATION_SUMMARY_FILE)
1630}
1631
1632fn process_kind_folder(root_folder: &Path, process: &Process) -> PathBuf {
1633    let process_kind_folder = match &process.collection {
1634        ProcessCollection::Amplitudes(_) => "amplitudes",
1635        ProcessCollection::CrossSections(_) => "cross_sections",
1636    };
1637
1638    root_folder.join("processes").join(process_kind_folder)
1639}
1640
1641fn process_artifact_folder(root_folder: &Path, process: &Process) -> PathBuf {
1642    process_kind_folder(root_folder, process).join(&process.definition.folder_name)
1643}
1644
1645fn integrand_artifact_folder(
1646    root_folder: &Path,
1647    process: &Process,
1648    integrand_name: &str,
1649) -> PathBuf {
1650    process_artifact_folder(root_folder, process).join(integrand_name)
1651}
1652
1653fn generated_integrand_artifact_path(
1654    root_folder: &Path,
1655    process: &Process,
1656    integrand_name: &str,
1657) -> PathBuf {
1658    integrand_artifact_folder(root_folder, process, integrand_name).join("integrand")
1659}
1660
1661fn ensure_existing_path_under_root(path: &Path, root: &Path, description: &str) -> Result<PathBuf> {
1662    let canonical_root = root
1663        .canonicalize()
1664        .with_context(|| format!("Trying to resolve artifact cleanup root {}", root.display()))?;
1665    let canonical_path = path.canonicalize().with_context(|| {
1666        format!(
1667            "Trying to resolve {} path {} before removal",
1668            description,
1669            path.display()
1670        )
1671    })?;
1672    if !canonical_path.starts_with(&canonical_root) {
1673        return Err(eyre!(
1674            "Refusing to remove {} path {} because it resolves outside {}",
1675            description,
1676            canonical_path.display(),
1677            canonical_root.display()
1678        ));
1679    }
1680    Ok(canonical_path)
1681}
1682
1683fn remove_file_if_exists_under_root(path: &Path, root: &Path, description: &str) -> Result<bool> {
1684    if !path.try_exists().with_context(|| {
1685        format!(
1686            "Trying to check whether {} path {} exists",
1687            description,
1688            path.display()
1689        )
1690    })? {
1691        return Ok(false);
1692    }
1693    let canonical_path = ensure_existing_path_under_root(path, root, description)?;
1694    fs::remove_file(path).with_context(|| {
1695        format!(
1696            "Trying to remove {} path {}",
1697            description,
1698            canonical_path.display()
1699        )
1700    })?;
1701    Ok(true)
1702}
1703
1704fn remove_dir_if_exists_under_root(path: &Path, root: &Path, description: &str) -> Result<bool> {
1705    if !path.try_exists().with_context(|| {
1706        format!(
1707            "Trying to check whether {} path {} exists",
1708            description,
1709            path.display()
1710        )
1711    })? {
1712        return Ok(false);
1713    }
1714    let canonical_path = ensure_existing_path_under_root(path, root, description)?;
1715    fs::remove_dir_all(path).with_context(|| {
1716        format!(
1717            "Trying to remove {} path {}",
1718            description,
1719            canonical_path.display()
1720        )
1721    })?;
1722    Ok(true)
1723}
1724
1725fn remove_saved_process_artifacts(root_folder: &Path, process: &Process) -> Result<bool> {
1726    let root = process_kind_folder(root_folder, process);
1727    let path = process_artifact_folder(root_folder, process);
1728    remove_dir_if_exists_under_root(&path, &root, "saved process artifacts")
1729}
1730
1731fn remove_saved_integrand_artifacts(
1732    root_folder: &Path,
1733    process: &Process,
1734    integrand_name: &str,
1735) -> Result<bool> {
1736    let root = process_artifact_folder(root_folder, process);
1737    let path = integrand_artifact_folder(root_folder, process, integrand_name);
1738    remove_dir_if_exists_under_root(&path, &root, "saved integrand artifacts")
1739}
1740
1741fn validate_output_name(value: &str, flag_name: &str) -> Result<()> {
1742    if value.trim().is_empty() {
1743        return Err(eyre!("{flag_name} must not be empty"));
1744    }
1745    Ok(())
1746}
1747
1748fn rename_process_integrand(integrand: Option<&mut ProcessIntegrand>, new_name: &str) {
1749    let Some(integrand) = integrand else {
1750        return;
1751    };
1752    let _ = integrand.get_mut_settings();
1753    match integrand {
1754        ProcessIntegrand::Amplitude(amplitude) => {
1755            amplitude.data.name = new_name.to_string();
1756        }
1757        ProcessIntegrand::CrossSection(cross_section) => {
1758            cross_section.data.name = new_name.to_string();
1759        }
1760    }
1761}
1762
1763fn load_integrand_generation_summaries(
1764    root_folder: &Path,
1765    process_list: &ProcessList,
1766) -> Result<BTreeMap<IntegrandGenerationSummaryKey, IntegrandGenerationSummary>> {
1767    let mut summaries = BTreeMap::new();
1768
1769    for (process_id, process) in process_list.processes.iter().enumerate() {
1770        for integrand_name in process.collection.get_integrand_names() {
1771            let summary_path =
1772                integrand_generation_summary_path(root_folder, process, integrand_name);
1773            if !summary_path.exists() {
1774                continue;
1775            }
1776
1777            let raw_summary = fs::read_to_string(&summary_path).with_context(|| {
1778                format!(
1779                    "Trying to read integrand generation summary {}",
1780                    summary_path.display()
1781                )
1782            })?;
1783            let summary = serde_json::from_str(&raw_summary).with_context(|| {
1784                format!(
1785                    "Trying to parse integrand generation summary {}",
1786                    summary_path.display()
1787                )
1788            })?;
1789            summaries.insert(
1790                IntegrandGenerationSummaryKey {
1791                    process_id,
1792                    integrand_name: integrand_name.to_string(),
1793                },
1794                summary,
1795            );
1796        }
1797    }
1798
1799    Ok(summaries)
1800}
1801
1802impl State {
1803    fn overridable_model_parameter_names(&self) -> Vec<String> {
1804        let mut names = self
1805            .model_parameters
1806            .keys()
1807            .map(|symbol| symbol.to_string())
1808            .collect::<Vec<_>>();
1809        names.sort();
1810        names
1811    }
1812
1813    fn resolve_overridable_model_parameter_type(
1814        &self,
1815        parameter_name: &str,
1816    ) -> Result<gammalooprs::model::ParameterType> {
1817        let possibilities = self.overridable_model_parameter_names();
1818        let parameter_type = external_model_parameter_type(&self.model, parameter_name)
1819            .ok_or_else(|| eyre!("No model parameter named '{parameter_name}'"))
1820            .with_note(|| {
1821                format!(
1822                    "Possible model parameters are: {}",
1823                    possibilities.join(", ")
1824                )
1825            })?;
1826
1827        let symbol = UFOSymbol::from(parameter_name);
1828        if !self.model_parameters.contains_key(&symbol) {
1829            return Err(eyre!(
1830                "Model parameter '{parameter_name}' cannot be overridden because it is not present in the shared top-level model_parameters.json"
1831            ))
1832            .with_note(|| format!("Possible model parameters are: {}", possibilities.join(", ")));
1833        }
1834
1835        Ok(parameter_type)
1836    }
1837
1838    pub fn find_generated_integrand_ref_by_name(
1839        &self,
1840        integrand_name: &str,
1841    ) -> Result<(usize, String)> {
1842        let matches = self
1843            .process_list
1844            .processes
1845            .iter()
1846            .enumerate()
1847            .filter_map(|(process_id, process)| {
1848                let found = match &process.collection {
1849                    ProcessCollection::Amplitudes(amplitudes) => amplitudes
1850                        .get(integrand_name)
1851                        .filter(|amplitude| amplitude.integrand.is_some())
1852                        .map(|_| (process_id, process.definition.folder_name.clone())),
1853                    ProcessCollection::CrossSections(cross_sections) => cross_sections
1854                        .get(integrand_name)
1855                        .filter(|cross_section| cross_section.integrand.is_some())
1856                        .map(|_| (process_id, process.definition.folder_name.clone())),
1857                };
1858                found.map(|(process_id, process_name)| {
1859                    (process_id, process_name, integrand_name.to_string())
1860                })
1861            })
1862            .collect::<Vec<_>>();
1863
1864        match matches.as_slice() {
1865            [(process_id, _, canonical_name)] => Ok((*process_id, canonical_name.clone())),
1866            [] => Err(eyre!(
1867                "No generated integrand named '{integrand_name}' was found. Per-integrand model parameters are only supported for generated integrands."
1868            )),
1869            _ => {
1870                let names = matches
1871                    .iter()
1872                    .map(|(process_id, process_name, _)| format!("#{process_id} ({process_name})"))
1873                    .collect::<Vec<_>>();
1874                Err(eyre!(
1875                    "Integrand name '{integrand_name}' is ambiguous across generated integrands"
1876                ))
1877                .with_note(|| format!("Matching processes: {}", names.join(", ")))
1878            }
1879        }
1880    }
1881
1882    pub fn import_model(&mut self, path: impl AsRef<Path>) -> Result<()> {
1883        self.model = Model::from_file(path)?;
1884        Ok(())
1885    }
1886
1887    fn matching_process_ids(&self, process: Option<&ProcessRef>) -> Result<Vec<usize>> {
1888        let Some(process) = process else {
1889            return Ok((0..self.process_list.processes.len()).collect());
1890        };
1891
1892        match process {
1893            ProcessRef::Id(id) => Ok((*id < self.process_list.processes.len())
1894                .then_some(*id)
1895                .into_iter()
1896                .collect()),
1897            ProcessRef::Name(name) => Ok(self
1898                .process_list
1899                .processes
1900                .iter()
1901                .position(|p| p.definition.folder_name == *name)
1902                .into_iter()
1903                .collect()),
1904            ProcessRef::Unqualified(value) => {
1905                let name_match = self
1906                    .process_list
1907                    .processes
1908                    .iter()
1909                    .position(|p| p.definition.folder_name == *value);
1910                if let Ok(id) = value.parse::<usize>() {
1911                    let id_valid = id < self.process_list.processes.len();
1912                    match (id_valid, name_match) {
1913                        (true, Some(_)) => Err(eyre!(
1914                            "Ambiguous process reference '{}'. Use '#{}' or 'name:{}' to disambiguate.",
1915                            value,
1916                            id,
1917                            value
1918                        )),
1919                        (true, None) => Ok(vec![id]),
1920                        (false, Some(index)) => Ok(vec![index]),
1921                        (false, None) => Ok(vec![]),
1922                    }
1923                } else {
1924                    Ok(name_match.into_iter().collect())
1925                }
1926            }
1927        }
1928    }
1929
1930    pub fn remove_selected_integrands(
1931        &mut self,
1932        process: Option<&ProcessRef>,
1933        integrand_name: Option<&str>,
1934    ) -> Result<Vec<RemovedIntegrand>> {
1935        let process_ids = self.matching_process_ids(process)?;
1936        let mut removed = Vec::new();
1937
1938        for process_id in process_ids.into_iter().rev() {
1939            let process_name = self.process_list.processes[process_id]
1940                .definition
1941                .folder_name
1942                .clone();
1943            let integrand_names = {
1944                let collection = &self.process_list.processes[process_id].collection;
1945                match integrand_name {
1946                    Some(name) => collection
1947                        .get_integrand_names()
1948                        .into_iter()
1949                        .find(|candidate| *candidate == name)
1950                        .map(|name| vec![name.to_string()])
1951                        .unwrap_or_default(),
1952                    None => collection
1953                        .get_integrand_names()
1954                        .into_iter()
1955                        .map(str::to_string)
1956                        .collect(),
1957                }
1958            };
1959
1960            if integrand_names.is_empty() {
1961                continue;
1962            }
1963
1964            {
1965                let process_entry = &mut self.process_list.processes[process_id];
1966                for integrand_name in &integrand_names {
1967                    process_entry.collection.remove_integrand(integrand_name)?;
1968                }
1969            }
1970            for integrand_name in &integrand_names {
1971                self.generation_summaries
1972                    .remove(&IntegrandGenerationSummaryKey {
1973                        process_id,
1974                        integrand_name: integrand_name.clone(),
1975                    });
1976            }
1977
1978            let removed_empty_process = self.process_list.processes[process_id]
1979                .collection
1980                .get_integrand_names()
1981                .is_empty();
1982            if removed_empty_process {
1983                self.process_list.processes.remove(process_id);
1984                self.remove_generation_summaries_for_process(process_id);
1985            }
1986
1987            for integrand_name in integrand_names {
1988                removed.push(RemovedIntegrand {
1989                    process_id,
1990                    process_name: process_name.clone(),
1991                    integrand_name,
1992                    removed_empty_process,
1993                });
1994            }
1995        }
1996
1997        removed.reverse();
1998        Ok(removed)
1999    }
2000
2001    pub fn remove_process(&mut self, process: Option<&ProcessRef>) -> Result<RemovedProcess> {
2002        let process_id = self.resolve_process_ref(process)?;
2003        let removed = self.process_list.processes.remove(process_id);
2004        self.remove_generation_summaries_for_process(process_id);
2005        Ok(RemovedProcess {
2006            process_id,
2007            process_name: removed.definition.folder_name,
2008        })
2009    }
2010
2011    pub fn remove_integrand(
2012        &mut self,
2013        process: &ProcessRef,
2014        integrand_name: &str,
2015    ) -> Result<RemovedIntegrand> {
2016        let process_id = process.resolve(&self.process_list)?;
2017        let (process_name, canonical_integrand_name, removed_empty_process) = {
2018            let process_entry = &mut self.process_list.processes[process_id];
2019            let process_name = process_entry.definition.folder_name.clone();
2020            let canonical_integrand_name = process_entry
2021                .collection
2022                .find_integrand(Some(integrand_name.to_string()))?;
2023            process_entry
2024                .collection
2025                .remove_integrand(&canonical_integrand_name)?;
2026            let removed_empty_process = process_entry.collection.get_integrand_names().is_empty();
2027            (
2028                process_name,
2029                canonical_integrand_name,
2030                removed_empty_process,
2031            )
2032        };
2033        self.generation_summaries
2034            .remove(&IntegrandGenerationSummaryKey {
2035                process_id,
2036                integrand_name: canonical_integrand_name.clone(),
2037            });
2038
2039        if removed_empty_process {
2040            self.process_list.processes.remove(process_id);
2041            self.remove_generation_summaries_for_process(process_id);
2042        }
2043
2044        Ok(RemovedIntegrand {
2045            process_id,
2046            process_name,
2047            integrand_name: canonical_integrand_name,
2048            removed_empty_process,
2049        })
2050    }
2051
2052    pub fn resolve_process_ref(&self, process: Option<&ProcessRef>) -> Result<usize> {
2053        match process {
2054            Some(process_ref) => process_ref.resolve(&self.process_list),
2055            None => self.process_list.find_process(None),
2056        }
2057    }
2058
2059    pub fn find_integrand_ref(
2060        &self,
2061        process: Option<&ProcessRef>,
2062        integrand_name: Option<&String>,
2063    ) -> Result<(usize, String)> {
2064        let process_id = self.resolve_process_ref(process)?;
2065        let integrand_name = self.process_list.processes[process_id]
2066            .collection
2067            .find_integrand(integrand_name.cloned())
2068            .with_note(|| format!("in process id {process_id}"))?;
2069        Ok((process_id, integrand_name))
2070    }
2071
2072    pub fn get_integrand_info(
2073        &self,
2074        process: Option<&ProcessRef>,
2075        integrand_name: Option<&String>,
2076    ) -> Result<IntegrandInfo> {
2077        let (process_id, integrand_name) = self.find_integrand_ref(process, integrand_name)?;
2078        collect_integrand_info(self, process_id, &integrand_name)
2079    }
2080
2081    pub fn duplicate_integrand(
2082        &mut self,
2083        process: Option<&ProcessRef>,
2084        integrand_name: Option<&String>,
2085        output_process_name: &str,
2086        output_integrand_name: &str,
2087    ) -> Result<()> {
2088        validate_output_name(output_process_name, "--output_process_name")?;
2089        validate_output_name(output_integrand_name, "--output_integrand_name")?;
2090
2091        let (source_process_id, source_integrand_name) =
2092            self.find_integrand_ref(process, integrand_name)?;
2093        let mut payload =
2094            self.cloned_integrand_payload(source_process_id, &source_integrand_name)?;
2095        payload.rename(output_integrand_name);
2096        self.insert_integrand_copy(
2097            source_process_id,
2098            output_process_name,
2099            output_integrand_name,
2100            payload,
2101            false,
2102            false,
2103            None,
2104            false,
2105        )?;
2106        Ok(())
2107    }
2108
2109    pub fn select_integrand_graph_groups(
2110        &mut self,
2111        process: Option<&ProcessRef>,
2112        integrand_name: Option<&String>,
2113        selection: &GraphGroupSelectionSpec,
2114        target: &GraphGroupSelectionTarget,
2115        context: GraphGroupSelectionContext<'_>,
2116    ) -> Result<SelectedGraphGroups> {
2117        if target.clear_existing_processes && !target.is_copy_mode() {
2118            return Err(eyre!(
2119                "--clear-existing-processes requires --output_process or --output_integrand for `select`"
2120            ));
2121        }
2122
2123        let (source_process_id, source_integrand_name) =
2124            self.find_integrand_ref(process, integrand_name)?;
2125        let (source_process_name, plan, discarded_generated_integrand) = {
2126            let process_entry = &self.process_list.processes[source_process_id];
2127            let process_name = process_entry.definition.folder_name.clone();
2128            match &process_entry.collection {
2129                ProcessCollection::Amplitudes(amplitudes) => {
2130                    if selection.has_raised_cut_rules() {
2131                        return Err(eyre!(
2132                            "Raised-cut signature selection can only be used with cross-section integrands."
2133                        ));
2134                    }
2135                    if selection.mode() == GraphGroupSelectionMode::CrossSectionAmplitudeGraphs {
2136                        return Err(eyre!(
2137                            "`select --amplitude-graphs` can only be used with cross-section integrands."
2138                        ));
2139                    }
2140                    let amplitude = amplitudes.get(&source_integrand_name).ok_or_else(|| {
2141                        eyre!(
2142                            "No amplitude named '{}' in process '{}'",
2143                            source_integrand_name,
2144                            process_name
2145                        )
2146                    })?;
2147                    let plan = amplitude.plan_graph_group_selection(selection)?;
2148                    amplitude.validate_graph_group_selection_plan(&plan)?;
2149                    (process_name, plan, amplitude.integrand.is_some())
2150                }
2151                ProcessCollection::CrossSections(cross_sections) => {
2152                    let cross_section =
2153                        cross_sections.get(&source_integrand_name).ok_or_else(|| {
2154                            eyre!(
2155                                "No cross section named '{}' in process '{}'",
2156                                source_integrand_name,
2157                                process_name
2158                            )
2159                        })?;
2160                    let plan = cross_section.plan_graph_group_selection_with_context(
2161                        selection,
2162                        &self.model,
2163                        &process_entry.definition,
2164                        context.generation_settings,
2165                    )?;
2166                    cross_section.validate_graph_group_selection_plan(&plan)?;
2167                    (process_name, plan, cross_section.integrand.is_some())
2168                }
2169            }
2170        };
2171        let report = plan.report().clone();
2172        let source = SelectionSource {
2173            process_id: source_process_id,
2174            process_name: source_process_name,
2175            integrand_name: source_integrand_name,
2176        };
2177
2178        if target.is_copy_mode() {
2179            return self.select_integrand_graph_groups_to_output(
2180                &source,
2181                &plan,
2182                report,
2183                target,
2184                context.state_folder,
2185                context.read_only_state,
2186            );
2187        }
2188
2189        let mut removed_generated_artifacts = false;
2190        let mut removed_generation_summary = false;
2191        if discarded_generated_integrand {
2192            if context.read_only_state {
2193                return Err(eyre!(
2194                    "Cannot select graph groups for generated integrand '{}' in process '{}' because this session was started with --read-only-state. Restart without --read-only-state or select before generation.",
2195                    source.integrand_name,
2196                    source.process_name
2197                ));
2198            }
2199
2200            let process_entry = &self.process_list.processes[source.process_id];
2201            let integrand_artifact_root = integrand_artifact_folder(
2202                context.state_folder,
2203                process_entry,
2204                &source.integrand_name,
2205            );
2206            let generated_artifact_path = generated_integrand_artifact_path(
2207                context.state_folder,
2208                process_entry,
2209                &source.integrand_name,
2210            );
2211            removed_generated_artifacts = remove_dir_if_exists_under_root(
2212                &generated_artifact_path,
2213                &integrand_artifact_root,
2214                "generated integrand artifact",
2215            )?;
2216            let generation_summary_path = integrand_generation_summary_path(
2217                context.state_folder,
2218                process_entry,
2219                &source.integrand_name,
2220            );
2221            removed_generation_summary = remove_file_if_exists_under_root(
2222                &generation_summary_path,
2223                &integrand_artifact_root,
2224                "integrand generation summary",
2225            )?;
2226        }
2227
2228        {
2229            let process_entry = &mut self.process_list.processes[source.process_id];
2230            match &mut process_entry.collection {
2231                ProcessCollection::Amplitudes(amplitudes) => {
2232                    let amplitude =
2233                        amplitudes.get_mut(&source.integrand_name).ok_or_else(|| {
2234                            eyre!(
2235                                "No amplitude named '{}' in process '{}'",
2236                                source.integrand_name,
2237                                source.process_name
2238                            )
2239                        })?;
2240                    amplitude.apply_graph_group_selection(&plan)?;
2241                }
2242                ProcessCollection::CrossSections(cross_sections) => {
2243                    let cross_section =
2244                        cross_sections
2245                            .get_mut(&source.integrand_name)
2246                            .ok_or_else(|| {
2247                                eyre!(
2248                                    "No cross section named '{}' in process '{}'",
2249                                    source.integrand_name,
2250                                    source.process_name
2251                                )
2252                            })?;
2253                    cross_section.apply_graph_group_selection(&plan)?;
2254                }
2255            }
2256        }
2257
2258        if discarded_generated_integrand {
2259            self.generation_summaries
2260                .remove(&IntegrandGenerationSummaryKey {
2261                    process_id: source.process_id,
2262                    integrand_name: source.integrand_name.clone(),
2263                });
2264        }
2265
2266        Ok(SelectedGraphGroups {
2267            source_process_id: source.process_id,
2268            source_process_name: source.process_name.clone(),
2269            source_integrand_name: source.integrand_name.clone(),
2270            process_id: source.process_id,
2271            process_name: source.process_name,
2272            integrand_name: source.integrand_name,
2273            report,
2274            copied_to_output: false,
2275            replaced_existing_target: false,
2276            removed_target_artifacts: false,
2277            discarded_generated_integrand,
2278            removed_generated_artifacts,
2279            removed_generation_summary,
2280        })
2281    }
2282
2283    fn select_integrand_graph_groups_to_output(
2284        &mut self,
2285        source: &SelectionSource,
2286        plan: &GraphGroupSelectionPlan,
2287        report: GraphGroupSelectionReport,
2288        target: &GraphGroupSelectionTarget,
2289        state_folder: &Path,
2290        read_only_state: bool,
2291    ) -> Result<SelectedGraphGroups> {
2292        let output_process_name = target
2293            .output_process_name
2294            .as_deref()
2295            .unwrap_or(&source.process_name);
2296        let output_integrand_name = target
2297            .output_integrand_name
2298            .as_deref()
2299            .unwrap_or(&source.integrand_name);
2300
2301        validate_output_name(output_process_name, "--output_process")?;
2302        validate_output_name(output_integrand_name, "--output_integrand")?;
2303
2304        if output_process_name == source.process_name
2305            && output_integrand_name == source.integrand_name
2306        {
2307            return Err(eyre!(
2308                "Copy-mode select target '{} / {}' is the selected source integrand. Omit --output_process/--output_integrand for in-place selection or choose a different output target.",
2309                output_process_name,
2310                output_integrand_name
2311            ));
2312        }
2313
2314        let target_process_id = self
2315            .process_list
2316            .processes
2317            .iter()
2318            .position(|process| process.definition.folder_name == output_process_name);
2319        let replace_existing_process = target.output_process_name.is_some()
2320            && target.clear_existing_processes
2321            && target_process_id.is_some_and(|process_id| process_id != source.process_id);
2322
2323        let mut payload =
2324            self.cloned_integrand_payload(source.process_id, &source.integrand_name)?;
2325        payload.rename(output_integrand_name);
2326        payload.apply_graph_group_selection(plan)?;
2327
2328        let insertion = self.insert_integrand_copy(
2329            source.process_id,
2330            output_process_name,
2331            output_integrand_name,
2332            payload,
2333            target.clear_existing_processes,
2334            replace_existing_process,
2335            Some(state_folder),
2336            read_only_state,
2337        )?;
2338
2339        Ok(SelectedGraphGroups {
2340            source_process_id: source.process_id,
2341            source_process_name: source.process_name.clone(),
2342            source_integrand_name: source.integrand_name.clone(),
2343            process_id: insertion.process_id,
2344            process_name: insertion.process_name,
2345            integrand_name: insertion.integrand_name,
2346            report,
2347            copied_to_output: true,
2348            replaced_existing_target: insertion.replaced_existing_target,
2349            removed_target_artifacts: insertion.removed_target_artifacts,
2350            discarded_generated_integrand: false,
2351            removed_generated_artifacts: false,
2352            removed_generation_summary: false,
2353        })
2354    }
2355
2356    fn cloned_integrand_payload(
2357        &self,
2358        source_process_id: usize,
2359        source_integrand_name: &str,
2360    ) -> Result<IntegrandCopyPayload> {
2361        let source_process = &self.process_list.processes[source_process_id];
2362        match &source_process.collection {
2363            ProcessCollection::Amplitudes(amplitudes) => amplitudes
2364                .get(source_integrand_name)
2365                .cloned()
2366                .map(IntegrandCopyPayload::Amplitude)
2367                .ok_or_else(|| eyre!("Missing source amplitude '{}'", source_integrand_name)),
2368            ProcessCollection::CrossSections(cross_sections) => cross_sections
2369                .get(source_integrand_name)
2370                .cloned()
2371                .map(IntegrandCopyPayload::CrossSection)
2372                .ok_or_else(|| eyre!("Missing source cross section '{}'", source_integrand_name)),
2373        }
2374    }
2375
2376    #[allow(clippy::too_many_arguments)]
2377    fn insert_integrand_copy(
2378        &mut self,
2379        source_process_id: usize,
2380        output_process_name: &str,
2381        output_integrand_name: &str,
2382        payload: IntegrandCopyPayload,
2383        clear_existing_processes: bool,
2384        replace_existing_process: bool,
2385        state_folder: Option<&Path>,
2386        read_only_state: bool,
2387    ) -> Result<IntegrandCopyInsertion> {
2388        if let Some(destination_process_id) = self
2389            .process_list
2390            .processes
2391            .iter()
2392            .position(|process| process.definition.folder_name == output_process_name)
2393        {
2394            if replace_existing_process {
2395                if read_only_state {
2396                    return Err(eyre!(
2397                        "Cannot overwrite output process '{}' because this session was started with --read-only-state.",
2398                        output_process_name
2399                    ));
2400                }
2401                let removed_target_artifacts = if let Some(state_folder) = state_folder {
2402                    remove_saved_process_artifacts(
2403                        state_folder,
2404                        &self.process_list.processes[destination_process_id],
2405                    )?
2406                } else {
2407                    false
2408                };
2409                self.remove_generation_summaries_for_process_without_shifting(
2410                    destination_process_id,
2411                );
2412                let process = self.single_integrand_process(
2413                    source_process_id,
2414                    destination_process_id,
2415                    output_process_name,
2416                    payload,
2417                );
2418                self.process_list.processes[destination_process_id] = process;
2419                return Ok(IntegrandCopyInsertion {
2420                    process_id: destination_process_id,
2421                    process_name: output_process_name.to_string(),
2422                    integrand_name: output_integrand_name.to_string(),
2423                    replaced_existing_target: true,
2424                    removed_target_artifacts,
2425                });
2426            }
2427
2428            let target_exists = self.process_list.processes[destination_process_id]
2429                .collection
2430                .get_integrand_names()
2431                .contains(&output_integrand_name);
2432            if target_exists {
2433                if !clear_existing_processes {
2434                    return Err(eyre!(
2435                        "An integrand '{}' already exists in process '{}'",
2436                        output_integrand_name,
2437                        output_process_name
2438                    ));
2439                }
2440                if read_only_state {
2441                    return Err(eyre!(
2442                        "Cannot overwrite output integrand '{}' in process '{}' because this session was started with --read-only-state.",
2443                        output_integrand_name,
2444                        output_process_name
2445                    ));
2446                }
2447            }
2448
2449            if !payload
2450                .is_compatible_with(&self.process_list.processes[destination_process_id].collection)
2451            {
2452                return Err(eyre!(
2453                    "Destination process '{}' exists but does not contain {}",
2454                    output_process_name,
2455                    payload.kind_name()
2456                ));
2457            }
2458
2459            let removed_target_artifacts = if target_exists {
2460                if let Some(state_folder) = state_folder {
2461                    remove_saved_integrand_artifacts(
2462                        state_folder,
2463                        &self.process_list.processes[destination_process_id],
2464                        output_integrand_name,
2465                    )?
2466                } else {
2467                    false
2468                }
2469            } else {
2470                false
2471            };
2472
2473            if target_exists {
2474                self.generation_summaries
2475                    .remove(&IntegrandGenerationSummaryKey {
2476                        process_id: destination_process_id,
2477                        integrand_name: output_integrand_name.to_string(),
2478                    });
2479            }
2480
2481            payload.insert_into_process(
2482                &mut self.process_list.processes[destination_process_id],
2483                output_process_name,
2484            )?;
2485            Ok(IntegrandCopyInsertion {
2486                process_id: destination_process_id,
2487                process_name: output_process_name.to_string(),
2488                integrand_name: output_integrand_name.to_string(),
2489                replaced_existing_target: target_exists,
2490                removed_target_artifacts,
2491            })
2492        } else {
2493            let process_id = self.process_list.processes.len();
2494            let process = self.single_integrand_process(
2495                source_process_id,
2496                process_id,
2497                output_process_name,
2498                payload,
2499            );
2500            self.process_list.add_process(process);
2501            Ok(IntegrandCopyInsertion {
2502                process_id,
2503                process_name: output_process_name.to_string(),
2504                integrand_name: output_integrand_name.to_string(),
2505                replaced_existing_target: false,
2506                removed_target_artifacts: false,
2507            })
2508        }
2509    }
2510
2511    fn single_integrand_process(
2512        &self,
2513        source_process_id: usize,
2514        process_id: usize,
2515        process_name: &str,
2516        payload: IntegrandCopyPayload,
2517    ) -> Process {
2518        let source_process = &self.process_list.processes[source_process_id];
2519        let mut definition = source_process.definition.clone();
2520        definition.folder_name = process_name.to_string();
2521        definition.process_id = process_id;
2522        Process {
2523            definition,
2524            settings_history: source_process.settings_history.clone(),
2525            collection: payload.into_collection(),
2526        }
2527    }
2528
2529    pub fn resolve_effective_model_parameter_card_for_settings(
2530        &self,
2531        settings: &RuntimeSettings,
2532    ) -> Result<InputParamCard<F<f64>>> {
2533        let mut card = self.model_parameters.clone();
2534
2535        for (parameter_name, value) in &settings.model.external_parameters {
2536            let parameter_type = self.resolve_overridable_model_parameter_type(parameter_name)?;
2537            let value = Complex::new(value.0, value.1);
2538            validate_model_parameter_type(parameter_name, parameter_type, &value)?;
2539
2540            let parameter = card
2541                .get_mut(&UFOSymbol::from(parameter_name.as_str()))
2542                .ok_or_else(|| {
2543                    eyre!(
2544                        "Model parameter '{parameter_name}' is missing from the shared top-level model_parameters.json"
2545                    )
2546                })?;
2547            *parameter = value;
2548        }
2549
2550        Ok(card)
2551    }
2552
2553    pub fn resolve_serializable_model_parameter_card_for_settings(
2554        &self,
2555        settings: &RuntimeSettings,
2556    ) -> Result<SerializableInputParamCard<F<f64>>> {
2557        Ok(self
2558            .resolve_effective_model_parameter_card_for_settings(settings)?
2559            .to_serializable())
2560    }
2561
2562    pub fn resolve_effective_model_parameter_card_for_integrand(
2563        &self,
2564        process_id: usize,
2565        integrand_name: &str,
2566    ) -> Result<SerializableInputParamCard<F<f64>>> {
2567        let resolved = self
2568            .process_list
2569            .get_integrand(process_id, integrand_name)?;
2570        match resolved.get_settings() {
2571            Some(settings) => self.resolve_serializable_model_parameter_card_for_settings(settings),
2572            None => Ok(self.model_parameters.to_serializable()),
2573        }
2574    }
2575
2576    pub fn resolve_model_for_settings(&self, settings: &RuntimeSettings) -> Result<Model> {
2577        let mut model = self.model.clone();
2578        self.resolve_effective_model_parameter_card_for_settings(settings)?
2579            .apply_to_model(&mut model)?;
2580        Ok(model)
2581    }
2582
2583    pub fn resolve_model_for_integrand(
2584        &self,
2585        process_id: usize,
2586        integrand_name: &str,
2587    ) -> Result<Model> {
2588        let resolved = self
2589            .process_list
2590            .get_integrand(process_id, integrand_name)?;
2591        match resolved.get_settings() {
2592            Some(settings) => self.resolve_model_for_settings(settings),
2593            None => Ok(self.model.clone()),
2594        }
2595    }
2596
2597    pub fn generate_integrands(
2598        &mut self,
2599        global_settings: &GlobalSettings,
2600        runtime_default: LockedRuntimeSettings,
2601    ) -> Result<GenerationReports> {
2602        self.run_generation_with_monitor(global_settings, move |state, generation_pool| {
2603            let mut reports = state.process_list.preprocess(
2604                &state.model,
2605                global_settings,
2606                &runtime_default,
2607                generation_pool,
2608            )?;
2609            merge_generated_graph_reports(
2610                &mut reports,
2611                state.process_list.generate_integrands(
2612                    &state.model,
2613                    global_settings,
2614                    runtime_default,
2615                    generation_pool,
2616                )?,
2617            );
2618            Ok(reports)
2619        })
2620    }
2621
2622    fn attach_process_id_to_named_reports(
2623        process_id: usize,
2624        reports: Vec<NamedGraphGenerationReport>,
2625    ) -> Vec<GeneratedGraphReport> {
2626        reports
2627            .into_iter()
2628            .map(|report| GeneratedGraphReport {
2629                process_id,
2630                integrand_name: report.integrand_name,
2631                graph_name: report.graph_name,
2632                stats: report.stats,
2633            })
2634            .collect()
2635    }
2636
2637    pub fn generate_integrand(
2638        &mut self,
2639        global_settings: &GlobalSettings,
2640        runtime_default: LockedRuntimeSettings,
2641        process_id: usize,
2642        integrand_name: Option<String>,
2643    ) -> Result<GenerationReports> {
2644        self.run_generation_with_monitor(global_settings, move |state, generation_pool| {
2645            let p = &mut state.process_list.processes[process_id];
2646            let process_name = p.definition.folder_name.clone();
2647            if let Some(name) = &integrand_name {
2648                let mut reports = Vec::new();
2649                match &mut p.collection {
2650                    ProcessCollection::Amplitudes(a) => {
2651                        if let Some(a) = a.get_mut(name) {
2652                            begin_phase(
2653                                GenerationProgressPhase::GraphPreprocessing,
2654                                GenerationProcessKind::Amplitude,
2655                                &process_name,
2656                                &a.name,
2657                                a.graphs.len(),
2658                                None,
2659                            );
2660                            merge_generated_graph_reports(
2661                                &mut reports,
2662                                Self::attach_process_id_to_named_reports(
2663                                    process_id,
2664                                    a.preprocess(
2665                                        &state.model,
2666                                        &global_settings.generation,
2667                                        &runtime_default,
2668                                        generation_pool,
2669                                    )?,
2670                                ),
2671                            );
2672                            merge_generated_graph_reports(
2673                                &mut reports,
2674                                Self::attach_process_id_to_named_reports(
2675                                    process_id,
2676                                    a.build_integrand(
2677                                        &state.model,
2678                                        &process_name,
2679                                        global_settings,
2680                                        runtime_default,
2681                                        generation_pool,
2682                                    )?,
2683                                ),
2684                            );
2685                        } else {
2686                            return Err(eyre!(
2687                                "No amplitude named '{}' in process id {}",
2688                                name,
2689                                process_id
2690                            ));
2691                        }
2692                    }
2693                    ProcessCollection::CrossSections(cs) => {
2694                        if let Some(cs) = cs.get_mut(name) {
2695                            merge_generated_graph_reports(
2696                                &mut reports,
2697                                Self::attach_process_id_to_named_reports(
2698                                    process_id,
2699                                    cs.preprocess(
2700                                        &state.model,
2701                                        &p.definition,
2702                                        &global_settings.generation,
2703                                        runtime_default,
2704                                        generation_pool,
2705                                    )?,
2706                                ),
2707                            );
2708                            merge_generated_graph_reports(
2709                                &mut reports,
2710                                Self::attach_process_id_to_named_reports(
2711                                    process_id,
2712                                    cs.build_integrand(
2713                                        &state.model,
2714                                        &process_name,
2715                                        global_settings,
2716                                        runtime_default,
2717                                        generation_pool,
2718                                    )?,
2719                                ),
2720                            );
2721                        } else {
2722                            return Err(eyre!(
2723                                "No cross section named '{}' in process id {}",
2724                                name,
2725                                process_id
2726                            ));
2727                        }
2728                    }
2729                }
2730                Ok(reports)
2731            } else {
2732                let mut reports = p.preprocess(
2733                    &state.model,
2734                    global_settings,
2735                    &runtime_default,
2736                    generation_pool,
2737                )?;
2738                merge_generated_graph_reports(
2739                    &mut reports,
2740                    p.generate_integrands(
2741                        &state.model,
2742                        global_settings,
2743                        runtime_default,
2744                        generation_pool,
2745                    )?,
2746                );
2747                Ok(reports)
2748            }
2749        })
2750    }
2751
2752    fn run_generation_with_monitor<F>(
2753        &mut self,
2754        global_settings: &GlobalSettings,
2755        generation: F,
2756    ) -> Result<GenerationReports>
2757    where
2758        F: FnOnce(&mut Self, &rayon::ThreadPool) -> Result<Vec<GeneratedGraphReport>>,
2759    {
2760        let generation_pool = rayon::ThreadPoolBuilder::new()
2761            .num_threads(global_settings.n_cores.generate)
2762            .stack_size(GENERATION_THREAD_STACK_SIZE_BYTES)
2763            .build()?;
2764        let generation_cores = generation_pool.current_num_threads();
2765        clear_interrupt_request();
2766        let mut monitor = GenerationMonitor::start()?;
2767        let stderr_is_terminal = io::stderr().is_terminal();
2768        let progress_mode = if generation_cores == 1 && stderr_is_terminal {
2769            GenerationProgressMode::Detailed
2770        } else {
2771            GenerationProgressMode::Aggregate
2772        };
2773        let _progress_mode_guard = GenerationProgressModeGuard::set(progress_mode);
2774        let _progress_observer_guard = if generation_cores > 1 && stderr_is_terminal {
2775            let reporter = AggregateGenerationProgressReporter::new(
2776                monitor.current_ram_bytes(),
2777                monitor.peak_ram_bytes(),
2778                generation_cores as u64,
2779            );
2780            Some(GenerationProgressObserverGuard::set(reporter))
2781        } else {
2782            None
2783        };
2784        let generation_result = generation(self, &generation_pool);
2785        let peak_ram_bytes = monitor.finish();
2786        clear_interrupt_request();
2787
2788        generation_result.map(|reports| GenerationReports {
2789            reports,
2790            resources: GenerationResourceSummary {
2791                peak_ram_bytes,
2792                generation_cores,
2793            },
2794        })
2795    }
2796
2797    pub fn compile_integrands(
2798        &mut self,
2799        folder: impl AsRef<Path>,
2800        override_existing: bool,
2801        global_settings: &GlobalSettings,
2802        process_id: Option<usize>,
2803        integrand_name: Option<String>,
2804    ) -> Result<Vec<GeneratedGraphReport>> {
2805        let compile_pool = rayon::ThreadPoolBuilder::new()
2806            .num_threads(global_settings.n_cores.compile)
2807            .build()?;
2808        self.process_list.compile(
2809            folder,
2810            override_existing,
2811            process_id,
2812            integrand_name,
2813            &compile_pool,
2814        )
2815    }
2816
2817    pub fn generation_summary(
2818        &self,
2819        process_id: usize,
2820        integrand_name: &str,
2821    ) -> Option<&IntegrandGenerationSummary> {
2822        self.generation_summaries
2823            .get(&IntegrandGenerationSummaryKey {
2824                process_id,
2825                integrand_name: integrand_name.to_string(),
2826            })
2827    }
2828
2829    pub fn record_generation_summary(
2830        &mut self,
2831        reports: &[GeneratedGraphReport],
2832        resources: GenerationResourceSummary,
2833    ) {
2834        let mut reports_by_integrand: BTreeMap<
2835            IntegrandGenerationSummaryKey,
2836            Vec<GeneratedGraphReport>,
2837        > = BTreeMap::new();
2838
2839        for report in reports {
2840            let Some(process) = self.process_list.processes.get(report.process_id) else {
2841                continue;
2842            };
2843            if !process
2844                .collection
2845                .get_integrand_names()
2846                .contains(&report.integrand_name.as_str())
2847            {
2848                continue;
2849            }
2850
2851            reports_by_integrand
2852                .entry(IntegrandGenerationSummaryKey {
2853                    process_id: report.process_id,
2854                    integrand_name: report.integrand_name.clone(),
2855                })
2856                .or_default()
2857                .push(report.clone());
2858        }
2859
2860        for (key, reports) in reports_by_integrand {
2861            self.generation_summaries.insert(
2862                key,
2863                IntegrandGenerationSummary {
2864                    peak_ram_bytes: resources.peak_ram_bytes,
2865                    reports,
2866                },
2867            );
2868        }
2869    }
2870
2871    fn remove_generation_summaries_for_process(&mut self, process_id: usize) {
2872        let mut updated = BTreeMap::new();
2873        for (mut key, mut summary) in std::mem::take(&mut self.generation_summaries) {
2874            if key.process_id == process_id {
2875                continue;
2876            }
2877            if key.process_id > process_id {
2878                key.process_id -= 1;
2879                for report in &mut summary.reports {
2880                    if report.process_id > process_id {
2881                        report.process_id -= 1;
2882                    }
2883                }
2884            }
2885            updated.insert(key, summary);
2886        }
2887        self.generation_summaries = updated;
2888    }
2889
2890    fn remove_generation_summaries_for_process_without_shifting(&mut self, process_id: usize) {
2891        self.generation_summaries
2892            .retain(|key, _| key.process_id != process_id);
2893    }
2894
2895    pub fn export_dots(
2896        &mut self,
2897        path: impl AsRef<Path>,
2898        settings: &DotExportSettings,
2899    ) -> Result<()> {
2900        self.process_list.export_dot(path, settings)?;
2901        Ok(())
2902    }
2903
2904    pub fn import_graphs(
2905        &mut self,
2906        graphs: Vec<Graph>,
2907        process_name: Option<String>,
2908        process_id: Option<usize>,
2909        integrand_name: Option<String>,
2910        overwrite: bool,
2911        append: bool,
2912    ) -> Result<()> {
2913        let generation_type = if graphs.iter().all(|g| g.initial_state_cut.nedges(g) == 0) {
2914            GenerationType::Amplitude
2915        } else if graphs.iter().all(|g| g.initial_state_cut.nedges(g) > 0) {
2916            GenerationType::CrossSection
2917        } else {
2918            return Err(eyre!(
2919                "Mix of amplitude and cross section graphs in the same file is not supported"
2920            ));
2921        };
2922
2923        let integrand_base_name = integrand_name.clone().unwrap_or("default".to_string());
2924        let process = if let Some(proc_id) = process_id {
2925            if proc_id >= self.process_list.processes.len() {
2926                return Err(eyre!(
2927                    "Process ID {} invalid, only {} processes available",
2928                    proc_id,
2929                    self.process_list.processes.len()
2930                ));
2931            }
2932            Some(&mut self.process_list.processes[proc_id])
2933        } else {
2934            let p_name = match process_name {
2935                Some(n) => n,
2936                None => {
2937                    return Err(eyre!(
2938                        "Either process ID or process name must be provided when importing graphs"
2939                    ));
2940                }
2941            };
2942            if let Some(existing_proc) = self
2943                .process_list
2944                .processes
2945                .iter_mut()
2946                .find(|p| p.definition.folder_name == p_name)
2947            {
2948                Some(existing_proc)
2949            } else {
2950                let process_defintion =
2951                    ProcessDefinition::from_graph_list(&graphs, generation_type, &self.model)?;
2952                let process = Process::from_graph_list(
2953                    p_name,
2954                    integrand_base_name.clone(),
2955                    // TODO: avoid clone here
2956                    graphs.clone(),
2957                    generation_type,
2958                    Some(process_defintion),
2959                    None,
2960                    &self.model,
2961                )?;
2962
2963                self.process_list.add_process(process);
2964                None
2965            }
2966        };
2967        if let Some(p) = process {
2968            let existing_names = p.get_integrand_names();
2969            let integrand_name = if existing_names.contains(&integrand_base_name.as_str()) {
2970                if append {
2971                    let mut integrand_i = 0;
2972                    while existing_names
2973                        .iter()
2974                        .any(|ce| *ce == format!("{}_{}", integrand_base_name, integrand_i))
2975                    {
2976                        integrand_i += 1;
2977                    }
2978                    format!("{}_{}", integrand_base_name, integrand_i)
2979                } else if overwrite {
2980                    p.collection.remove_integrand(&integrand_base_name)?;
2981                    integrand_base_name.clone()
2982                } else {
2983                    return Err(eyre!(
2984                        "Integrand name '{}' already exists in process '{}', use either --overwrite or --append flag when loading graphs",
2985                        integrand_base_name,
2986                        p.definition.folder_name
2987                    ));
2988                }
2989            } else {
2990                integrand_base_name.clone()
2991            };
2992
2993            match generation_type {
2994                GenerationType::Amplitude => p
2995                    .collection
2996                    .add_amplitude(Amplitude::from_graph_list(integrand_name.clone(), graphs)?),
2997                GenerationType::CrossSection => {
2998                    p.collection
2999                        .add_cross_section(CrossSection::from_graph_list(
3000                            integrand_name.clone(),
3001                            graphs,
3002                            &self.model,
3003                        )?)
3004                }
3005            }
3006        }
3007
3008        Ok(())
3009    }
3010
3011    pub fn bench(
3012        &mut self,
3013        samples: usize,
3014        process_id: usize,
3015        integrand_name: String,
3016        _n_cores: usize,
3017    ) -> Result<()> {
3018        let integrand = self
3019            .process_list
3020            .get_integrand_mut(process_id, integrand_name)?;
3021        let name = integrand.name();
3022
3023        info!(
3024            "\nBenchmarking runtime of integrand '{}' over {} samples...\n",
3025            name.green(),
3026            samples.to_string().blue()
3027        );
3028
3029        let now = Instant::now();
3030        for _ in 0..samples {
3031            let _ = integrand.evaluate_sample(
3032                &Sample::Continuous(
3033                    F(1.),
3034                    (0..integrand.get_n_dim())
3035                        .map(|_| F(rand::random::<f64>()))
3036                        .collect(),
3037                ),
3038                &self.model,
3039                F(1.),
3040                1,
3041                false,
3042                Complex::new_zero(),
3043            );
3044        }
3045        let total_time = now.elapsed().as_secs_f64();
3046        info!(
3047            "\n> Total time: {} s for {} samples, {} ms per sample\n",
3048            format!("{:.1}", total_time).blue(),
3049            format!("{}", samples).blue(),
3050            format!("{:.5}", total_time * 1000. / (samples as f64)).green(),
3051        );
3052
3053        Ok(())
3054    }
3055
3056    pub fn new(log_dir: impl AsRef<Path>, log_file_name: Option<String>) -> Self {
3057        super::tracing::init_tracing(log_dir.as_ref().join("logs"), log_file_name);
3058        let _ = initialise();
3059
3060        Self {
3061            model: Model::default(),
3062            process_list: ProcessList::default(),
3063            model_parameters: InputParamCard::default(),
3064            generation_summaries: BTreeMap::new(),
3065        }
3066    }
3067
3068    pub fn new_test() -> Self {
3069        init_test_tracing();
3070
3071        Self {
3072            model: Model::default(),
3073            process_list: ProcessList::default(),
3074            model_parameters: InputParamCard::default(),
3075            generation_summaries: BTreeMap::new(),
3076        }
3077    }
3078
3079    pub fn new_bench() -> Self {
3080        init_bench_tracing();
3081
3082        Self {
3083            model: Model::default(),
3084            process_list: ProcessList::default(),
3085            model_parameters: InputParamCard::default(),
3086            generation_summaries: BTreeMap::new(),
3087        }
3088    }
3089}
3090
3091#[derive(Debug, Clone, PartialEq, Eq)]
3092pub struct RemovedProcess {
3093    pub process_id: usize,
3094    pub process_name: String,
3095}
3096
3097#[derive(Debug, Clone, PartialEq, Eq)]
3098pub struct RemovedIntegrand {
3099    pub process_id: usize,
3100    pub process_name: String,
3101    pub integrand_name: String,
3102    pub removed_empty_process: bool,
3103}
3104
3105impl State {
3106    pub fn load(
3107        save_path: PathBuf,
3108        model_path: Option<PathBuf>,
3109        trace_logs_filename: Option<String>,
3110    ) -> Result<Self> {
3111        // let root_folder = root_folder.join("gammaloop_state");
3112        let manifest = load_state_manifest(&save_path)?;
3113        run_state_migration_checks(&manifest, &save_path)?;
3114        // Install GammaLoop's subscriber before importing Symbolica state. Symbolica warnings
3115        // initialize its fallback subscriber on first use, which would otherwise claim the global
3116        // tracing dispatch and escape ANSI styling in all subsequent GammaLoop output.
3117        let mut loaded_state = State::new(&save_path, trace_logs_filename);
3118        debug!("Loading state manifest version {}", manifest.version);
3119
3120        let mut model = if let Some(model_path) = &model_path {
3121            info!("Loading model from {}", model_path.display());
3122            Model::from_file(model_path)?
3123        } else {
3124            let model_dir = save_path.join("model.json");
3125            info!(
3126                "Loading model from default location: {}",
3127                model_dir.display()
3128            );
3129            Model::from_file(model_dir)?
3130        };
3131
3132        debug!("Loaded model: {}", model.name);
3133
3134        let input_param_card = if save_path.join("model_parameters.json").exists() {
3135            let a = InputParamCard::from_file(save_path.join("model_parameters.json"))?;
3136
3137            let _ = model.apply_param_card(&a);
3138            a
3139        } else {
3140            InputParamCard::default_from_model(&model)
3141        };
3142
3143        let symbolica_state = symbolica::state::State::import(
3144            &mut fs::File::open(save_path.join("symbolica_state.bin"))
3145                .context("Trying to open symbolica state binary")?,
3146            None,
3147        )?;
3148
3149        let context: GammaLoopContextContainer<'_> = GammaLoopContextContainer {
3150            state_map: &symbolica_state,
3151            model: &model,
3152        };
3153
3154        let process_list =
3155            ProcessList::load(&save_path, context).context("Trying to load processList")?;
3156
3157        loaded_state.process_list = process_list;
3158        loaded_state.model = model;
3159        loaded_state.model_parameters = input_param_card;
3160        loaded_state.generation_summaries =
3161            load_integrand_generation_summaries(&save_path, &loaded_state.process_list)?;
3162        Ok(loaded_state)
3163    }
3164
3165    pub fn activate_loaded_integrand_backends(
3166        &mut self,
3167        allow_symjit_fallback: bool,
3168    ) -> Result<()> {
3169        self.process_list
3170            .activate_loaded_integrand_backends(allow_symjit_fallback)
3171    }
3172
3173    pub fn compile(
3174        &mut self,
3175        root_folder: &Path,
3176        override_compiled: bool,
3177        settings: &GlobalSettings,
3178    ) -> Result<()> {
3179        fs::create_dir_all(root_folder)?;
3180
3181        let compile_pool = rayon::ThreadPoolBuilder::new()
3182            .num_threads(settings.n_cores.compile)
3183            .build()?;
3184        self.process_list
3185            .compile(root_folder, override_compiled, None, None, &compile_pool)?;
3186        Ok(())
3187    }
3188
3189    fn save_generation_summaries(&self, root_folder: &Path) -> Result<()> {
3190        for (process_id, process) in self.process_list.processes.iter().enumerate() {
3191            for integrand_name in process.collection.get_integrand_names() {
3192                let summary_path =
3193                    integrand_generation_summary_path(root_folder, process, integrand_name);
3194                let key = IntegrandGenerationSummaryKey {
3195                    process_id,
3196                    integrand_name: integrand_name.to_string(),
3197                };
3198                if let Some(summary) = self.generation_summaries.get(&key) {
3199                    if let Some(parent) = summary_path.parent() {
3200                        fs::create_dir_all(parent)?;
3201                    }
3202                    let raw_summary = serde_json::to_string_pretty(summary).with_context(|| {
3203                        format!(
3204                            "Trying to serialize integrand generation summary {}",
3205                            summary_path.display()
3206                        )
3207                    })?;
3208                    fs::write(&summary_path, raw_summary).with_context(|| {
3209                        format!(
3210                            "Trying to write integrand generation summary {}",
3211                            summary_path.display()
3212                        )
3213                    })?;
3214                } else if summary_path.exists() {
3215                    fs::remove_file(&summary_path).with_context(|| {
3216                        format!(
3217                            "Trying to remove stale integrand generation summary {}",
3218                            summary_path.display()
3219                        )
3220                    })?;
3221                }
3222            }
3223        }
3224
3225        Ok(())
3226    }
3227
3228    pub fn save(
3229        &mut self,
3230        root_folder: &Path,
3231        override_state_file: bool,
3232        strict: bool,
3233    ) -> Result<()> {
3234        // let root_folder = root_folder.join("gammaloop_state");
3235
3236        // check if the export root exists, if not create it, if it does return error
3237        let mut selected_root_folder = PathBuf::from(root_folder);
3238        let mut user_input = String::new();
3239        if !root_folder.exists() {
3240            fs::create_dir_all(root_folder)?;
3241        } else {
3242            if strict {
3243                return Err(eyre!(
3244                    "Export root already exists, please choose a different path or remove the existing directory",
3245                ));
3246            }
3247
3248            if !override_state_file {
3249                while selected_root_folder.exists() {
3250                    println!(
3251                        "Gammaloop export root {} already exists. Specify 'o' for overwriting, 'n' for not saving, or '<NEW_PATH>' to specify where to save current state to:",
3252                        selected_root_folder.display()
3253                    );
3254                    user_input.clear();
3255                    io::stdin()
3256                        .read_line(&mut user_input)
3257                        .expect("Could not read user-specified gammaloop state export destination");
3258                    //user_input = user_input.trim().into();
3259                    match user_input.trim() {
3260                        "o" => break,
3261                        "n" => {
3262                            return Ok(());
3263                        }
3264                        new_path => {
3265                            selected_root_folder = PathBuf::from(new_path);
3266                            continue;
3267                        }
3268                    }
3269                }
3270            }
3271        }
3272
3273        fs::create_dir_all(&selected_root_folder)?;
3274
3275        let mut state_file =
3276        // info!("Hi");
3277            fs::File::create(selected_root_folder.join("symbolica_state.bin"))?;
3278
3279        symbolica::state::State::export(&mut state_file)?;
3280        self.process_list
3281            .save(&selected_root_folder, override_state_file)?;
3282        self.save_generation_summaries(&selected_root_folder)?;
3283
3284        // let binary = bincode::encode_to_vec(&self.integrands, bincode::config::standard())?;
3285        // fs::write(root_folder.join("process_list.bin"), binary)?;?
3286        self.model
3287            .to_serializable()
3288            .to_file(selected_root_folder.join("model.json"), override_state_file)?;
3289        self.model_parameters.to_file(
3290            selected_root_folder.join("model_parameters.json"),
3291            override_state_file,
3292        )?;
3293        save_state_manifest(&selected_root_folder)?;
3294        Ok(())
3295    }
3296}
3297
3298#[cfg(test)]
3299mod tests {
3300    use std::fs;
3301
3302    use gammalooprs::{
3303        graph::Graph,
3304        initialisation::test_initialise,
3305        integrands::process::ActiveF64Backend,
3306        model::InputParamCard,
3307        momentum::{Dep, ExternalMomenta, Helicity},
3308        processes::{
3309            process::ProcessCollection, RaisedPropagatorScope, RaisedPropagatorSignature,
3310            SelectionPolarity,
3311        },
3312        settings::global::{CompilationMode, FrozenCompilationMode},
3313        settings::{
3314            runtime::kinematic::{improvement::PhaseSpaceImprovementSettings, Externals},
3315            KinematicsSettings, RuntimeSettings,
3316        },
3317        utils::{load_generic_model, serde_utils::SHOWDEFAULTS},
3318    };
3319    use tempfile::tempdir;
3320
3321    use crate::commands::{
3322        display::Display,
3323        save::SaveState,
3324        set::{ProcessSetArgs, Set, SetArgs},
3325    };
3326
3327    use super::*;
3328
3329    fn build_generated_scalar_bubble_state_with_external_backend() -> State {
3330        test_initialise().expect("test initialisation should succeed");
3331        let mut state = State::new_test();
3332        state.model = load_generic_model("scalars");
3333        state.model_parameters = InputParamCard::default_from_model(&state.model);
3334
3335        let graph_path =
3336            crate::test_workspace_root().join("tests/resources/graphs/scalar_bubble.dot");
3337        let graphs = Graph::from_path(&graph_path, &state.model)
3338            .expect("scalar bubble graph fixture should load");
3339
3340        state
3341            .import_graphs(
3342                graphs,
3343                Some("scalar_bubble".to_string()),
3344                None,
3345                Some("default".to_string()),
3346                false,
3347                false,
3348            )
3349            .expect("graph import should succeed");
3350
3351        let mut cli_settings = CLISettings::default();
3352        cli_settings.global.generation.evaluator.compile = true;
3353        cli_settings.global.generation.compile.compilation_mode = CompilationMode::Assembly;
3354
3355        let runtime_defaults = RuntimeSettings::default();
3356        state
3357            .generate_integrands(&cli_settings.global, (&runtime_defaults).into())
3358            .expect("integrand generation should succeed");
3359
3360        state
3361    }
3362
3363    #[test]
3364    fn aggregate_generation_progress_tracks_counts_and_timings() {
3365        let current_ram_bytes = Arc::new(AtomicU64::new(256 * 1024 * 1024));
3366        let peak_ram_bytes = Arc::new(AtomicU64::new(512 * 1024 * 1024));
3367        let reporter =
3368            AggregateGenerationProgressReporter::new_hidden(current_ram_bytes, peak_ram_bytes);
3369
3370        reporter.begin_phase(
3371            GenerationProgressPhase::GraphPreprocessing,
3372            GenerationProcessKind::CrossSection,
3373            "proc",
3374            "itg",
3375            2,
3376            None,
3377        );
3378        reporter.graph_started(GenerationProcessKind::CrossSection, "itg", "GL01", None);
3379        reporter.cuts_discovered("itg", "GL01", 4, 2);
3380        {
3381            let state = reporter
3382                .state
3383                .lock()
3384                .expect("aggregate generation progress state mutex is poisoned");
3385            assert_eq!(state.done_graphs, 0);
3386            assert_eq!(state.discovered_st_cuts, 4);
3387            assert_eq!(state.discovered_valid_cuts, 2);
3388            assert_eq!(
3389                AggregateGenerationProgressReporter::progress_units(&state),
3390                (0, 2)
3391            );
3392            assert_eq!(
3393                AggregateGenerationProgressReporter::graph_progress_counts(&state),
3394                (0, 1, 2)
3395            );
3396        }
3397        reporter.graph_finished(
3398            GenerationProcessKind::CrossSection,
3399            "itg",
3400            "GL01",
3401            &GraphGenerationStats {
3402                total_time: Duration::from_secs(3),
3403                ..GraphGenerationStats::default()
3404            },
3405            None,
3406        );
3407        {
3408            let state = reporter
3409                .state
3410                .lock()
3411                .expect("aggregate generation progress state mutex is poisoned");
3412            assert_eq!(state.done_graphs, 1);
3413            assert_eq!(state.stats.total_time, Duration::from_secs(3));
3414            assert_eq!(
3415                AggregateGenerationProgressReporter::graph_progress_counts(&state),
3416                (1, 0, 2)
3417            );
3418        }
3419
3420        reporter.begin_phase(
3421            GenerationProgressPhase::GraphGeneration,
3422            GenerationProcessKind::CrossSection,
3423            "proc",
3424            "itg",
3425            2,
3426            Some(3),
3427        );
3428        reporter.graph_started(GenerationProcessKind::CrossSection, "itg", "GL01", Some(1));
3429        reporter.graph_started(GenerationProcessKind::CrossSection, "itg", "GL02", Some(2));
3430        {
3431            let state = reporter
3432                .state
3433                .lock()
3434                .expect("aggregate generation progress state mutex is poisoned");
3435            assert_eq!(
3436                AggregateGenerationProgressReporter::graph_progress_counts(&state),
3437                (0, 2, 2)
3438            );
3439        }
3440        reporter.graph_finished(
3441            GenerationProcessKind::CrossSection,
3442            "itg",
3443            "GL01",
3444            &GraphGenerationStats {
3445                evaluator_count: 2,
3446                total_time: Duration::from_secs(4),
3447                evaluator_spenso_time: Duration::from_secs(1),
3448                evaluator_symbolica_time: Duration::from_secs(1),
3449                evaluator_compile_time: Duration::ZERO,
3450            },
3451            None,
3452        );
3453        reporter.cut_finished("itg", "GL01", 1);
3454        reporter.graph_finished(
3455            GenerationProcessKind::CrossSection,
3456            "itg",
3457            "GL02",
3458            &GraphGenerationStats {
3459                evaluator_count: 3,
3460                total_time: Duration::from_secs(6),
3461                evaluator_spenso_time: Duration::from_secs(2),
3462                evaluator_symbolica_time: Duration::ZERO,
3463                evaluator_compile_time: Duration::ZERO,
3464            },
3465            None,
3466        );
3467        reporter.cut_finished("itg", "GL02", 2);
3468
3469        {
3470            let state = reporter
3471                .state
3472                .lock()
3473                .expect("aggregate generation progress state mutex is poisoned");
3474            assert_eq!(state.done_graphs, 2);
3475            assert_eq!(state.done_cuts, 3);
3476            assert!(state.active_graphs.is_empty());
3477            assert_eq!(state.last_graph.as_deref(), Some("GL02"));
3478            assert_eq!(state.stats.evaluator_count, 5);
3479            assert_eq!(state.stats.total_time, Duration::from_secs(10));
3480            assert_eq!(state.stats.expression_build_time(), Duration::from_secs(6));
3481            assert_eq!(state.stats.evaluator_spenso_time, Duration::from_secs(3));
3482            assert_eq!(state.stats.evaluator_symbolica_time, Duration::from_secs(1));
3483            assert_eq!(
3484                AggregateGenerationProgressReporter::progress_units(&state),
3485                (5, 5)
3486            );
3487            assert_eq!(
3488                AggregateGenerationProgressReporter::graph_progress_counts(&state),
3489                (2, 0, 2)
3490            );
3491        }
3492
3493        reporter.backend_started(GenerationProcessKind::CrossSection, "itg", 2);
3494        reporter.backend_finished(
3495            GenerationProcessKind::CrossSection,
3496            "itg",
3497            Duration::from_secs(2),
3498        );
3499
3500        let state = reporter
3501            .state
3502            .lock()
3503            .expect("aggregate generation progress state mutex is poisoned");
3504        assert_eq!(state.phase, Some(GenerationProgressPhase::Backend));
3505        assert_eq!(state.done_graphs, 2);
3506        assert_eq!(state.stats.total_time, Duration::from_secs(12));
3507        assert_eq!(state.stats.evaluator_compile_time, Duration::from_secs(2));
3508    }
3509
3510    #[test]
3511    fn aggregate_generation_progress_tracks_amplitude_preprocessing() {
3512        let reporter = AggregateGenerationProgressReporter::new_hidden(
3513            Arc::new(AtomicU64::new(0)),
3514            Arc::new(AtomicU64::new(0)),
3515        );
3516
3517        reporter.begin_phase(
3518            GenerationProgressPhase::GraphPreprocessing,
3519            GenerationProcessKind::Amplitude,
3520            "proc",
3521            "amp",
3522            3,
3523            None,
3524        );
3525        reporter.graph_started(GenerationProcessKind::Amplitude, "amp", "GL01", None);
3526        reporter.graph_started(GenerationProcessKind::Amplitude, "amp", "GL02", None);
3527
3528        {
3529            let state = reporter
3530                .state
3531                .lock()
3532                .expect("aggregate generation progress state mutex is poisoned");
3533            assert_eq!(state.kind, Some(GenerationProcessKind::Amplitude));
3534            assert_eq!(state.process, "proc");
3535            assert_eq!(state.integrand, "amp");
3536            assert_eq!(
3537                AggregateGenerationProgressReporter::progress_units(&state),
3538                (0, 3)
3539            );
3540            assert_eq!(
3541                AggregateGenerationProgressReporter::graph_progress_counts(&state),
3542                (0, 2, 3)
3543            );
3544        }
3545
3546        reporter.graph_finished(
3547            GenerationProcessKind::Amplitude,
3548            "amp",
3549            "GL01",
3550            &GraphGenerationStats::default(),
3551            None,
3552        );
3553
3554        let state = reporter
3555            .state
3556            .lock()
3557            .expect("aggregate generation progress state mutex is poisoned");
3558        assert_eq!(
3559            AggregateGenerationProgressReporter::progress_units(&state),
3560            (1, 3)
3561        );
3562        assert_eq!(
3563            AggregateGenerationProgressReporter::graph_progress_counts(&state),
3564            (1, 1, 3)
3565        );
3566        assert_eq!(state.last_graph.as_deref(), Some("GL01"));
3567    }
3568
3569    #[test]
3570    fn aggregate_generation_progress_formats_memory_and_time_shares() {
3571        assert_eq!(
3572            AggregateGenerationProgressReporter::progress_memory(512 * 1024 * 1024),
3573            "   512 MiB"
3574        );
3575        assert_eq!(
3576            AggregateGenerationProgressReporter::progress_memory(5 * 1024 * 1024 * 1024),
3577            "  5.00 GiB"
3578        );
3579
3580        assert_eq!(
3581            AggregateGenerationProgressReporter::progress_percent(0.0),
3582            "0%"
3583        );
3584        assert_eq!(
3585            AggregateGenerationProgressReporter::progress_percent(0.009),
3586            "0%"
3587        );
3588        assert_eq!(
3589            AggregateGenerationProgressReporter::progress_percent(0.024),
3590            "0.024%"
3591        );
3592        assert_eq!(
3593            AggregateGenerationProgressReporter::progress_percent(0.24),
3594            "0.24%"
3595        );
3596        assert_eq!(
3597            AggregateGenerationProgressReporter::progress_percent(2.4),
3598            "2.4%"
3599        );
3600        assert_eq!(
3601            AggregateGenerationProgressReporter::progress_percent(54.0),
3602            "54%"
3603        );
3604
3605        assert_eq!(
3606            AggregateGenerationProgressReporter::progress_time_share(
3607                Duration::ZERO,
3608                Duration::ZERO,
3609            ),
3610            "--"
3611        );
3612        assert_eq!(
3613            AggregateGenerationProgressReporter::progress_time_share(
3614                Duration::ZERO,
3615                Duration::from_secs(4),
3616            ),
3617            "0%"
3618        );
3619        assert_eq!(
3620            AggregateGenerationProgressReporter::progress_time_share(
3621                Duration::from_secs(1),
3622                Duration::from_secs(4),
3623            ),
3624            "25%"
3625        );
3626    }
3627
3628    fn build_scalar_bubble_diagram_state() -> State {
3629        test_initialise().expect("test initialisation should succeed");
3630        let mut state = State::new_test();
3631        state.model = load_generic_model("scalars");
3632        state.model_parameters = InputParamCard::default_from_model(&state.model);
3633
3634        let graph_path =
3635            crate::test_workspace_root().join("tests/resources/graphs/scalar_bubble.dot");
3636        let graphs = Graph::from_path(&graph_path, &state.model)
3637            .expect("scalar bubble graph fixture should load");
3638        state
3639            .import_graphs(
3640                graphs,
3641                Some("scalar_bubble".to_string()),
3642                None,
3643                Some("default".to_string()),
3644                false,
3645                false,
3646            )
3647            .expect("graph import should succeed");
3648        state
3649    }
3650
3651    fn scalar_bubble_master_graph_name(state: &State, integrand_name: &str) -> String {
3652        let process = &state.process_list.processes[0];
3653        match &process.collection {
3654            ProcessCollection::Amplitudes(amplitudes) => amplitudes
3655                .get(integrand_name)
3656                .expect("amplitude should exist")
3657                .graphs[0]
3658                .graph
3659                .name
3660                .clone(),
3661            ProcessCollection::CrossSections(cross_sections) => cross_sections
3662                .get(integrand_name)
3663                .expect("cross section should exist")
3664                .supergraphs[0]
3665                .graph
3666                .name
3667                .clone(),
3668        }
3669    }
3670
3671    #[test]
3672    fn select_copy_mode_creates_pregenerated_target_without_mutating_source() {
3673        let _guard = crate::LOG_TEST_MUTEX
3674            .lock()
3675            .unwrap_or_else(|err| err.into_inner());
3676        let mut state = build_scalar_bubble_diagram_state();
3677        let temp = tempdir().unwrap();
3678        let graph_name = scalar_bubble_master_graph_name(&state, "default");
3679        let selection = GraphGroupSelectionSpec::new().with_master_graph_names(vec![graph_name]);
3680        let target = GraphGroupSelectionTarget::copy(None, Some("selected".to_string()), false);
3681
3682        let selected = state
3683            .select_integrand_graph_groups(
3684                Some(&ProcessRef::Name("scalar_bubble".to_string())),
3685                Some(&"default".to_string()),
3686                &selection,
3687                &target,
3688                GraphGroupSelectionContext::new(&GenerationSettings::default(), temp.path(), false),
3689            )
3690            .unwrap();
3691
3692        assert!(selected.copied_to_output);
3693        assert_eq!(selected.process_id, 0);
3694        assert_eq!(selected.integrand_name, "selected");
3695        let process = &state.process_list.processes[0];
3696        assert!(process
3697            .collection
3698            .get_integrand_names()
3699            .contains(&"default"));
3700        assert!(process
3701            .collection
3702            .get_integrand_names()
3703            .contains(&"selected"));
3704        match &process.collection {
3705            ProcessCollection::Amplitudes(amplitudes) => {
3706                assert!(amplitudes["default"].integrand.is_none());
3707                assert!(amplitudes["selected"].integrand.is_none());
3708                assert_eq!(amplitudes["default"].graphs.len(), 1);
3709                assert_eq!(amplitudes["selected"].graphs.len(), 1);
3710            }
3711            ProcessCollection::CrossSections(cross_sections) => {
3712                assert!(cross_sections["default"].integrand.is_none());
3713                assert!(cross_sections["selected"].integrand.is_none());
3714                assert_eq!(cross_sections["default"].supergraphs.len(), 1);
3715                assert_eq!(cross_sections["selected"].supergraphs.len(), 1);
3716            }
3717        }
3718    }
3719
3720    #[test]
3721    fn select_copy_mode_rejects_and_clears_existing_target_integrand() {
3722        let _guard = crate::LOG_TEST_MUTEX
3723            .lock()
3724            .unwrap_or_else(|err| err.into_inner());
3725        let mut state = build_scalar_bubble_diagram_state();
3726        let temp = tempdir().unwrap();
3727        let graph_name = scalar_bubble_master_graph_name(&state, "default");
3728        let selection = GraphGroupSelectionSpec::new().with_master_graph_names(vec![graph_name]);
3729        let target = GraphGroupSelectionTarget::copy(None, Some("selected".to_string()), false);
3730
3731        state
3732            .select_integrand_graph_groups(
3733                Some(&ProcessRef::Name("scalar_bubble".to_string())),
3734                Some(&"default".to_string()),
3735                &selection,
3736                &target,
3737                GraphGroupSelectionContext::new(&GenerationSettings::default(), temp.path(), false),
3738            )
3739            .unwrap();
3740        let err = state
3741            .select_integrand_graph_groups(
3742                Some(&ProcessRef::Name("scalar_bubble".to_string())),
3743                Some(&"default".to_string()),
3744                &selection,
3745                &target,
3746                GraphGroupSelectionContext::new(&GenerationSettings::default(), temp.path(), false),
3747            )
3748            .unwrap_err();
3749        assert!(format!("{err}").contains("already exists"));
3750
3751        let process = &state.process_list.processes[0];
3752        let kind_folder = match &process.collection {
3753            ProcessCollection::Amplitudes(_) => "amplitudes",
3754            ProcessCollection::CrossSections(_) => "cross_sections",
3755        };
3756        let stale_integrand_folder = temp
3757            .path()
3758            .join("processes")
3759            .join(kind_folder)
3760            .join("scalar_bubble")
3761            .join("selected");
3762        fs::create_dir_all(stale_integrand_folder.join("integrand")).unwrap();
3763        state.generation_summaries.insert(
3764            IntegrandGenerationSummaryKey {
3765                process_id: 0,
3766                integrand_name: "selected".to_string(),
3767            },
3768            IntegrandGenerationSummary {
3769                peak_ram_bytes: 1,
3770                reports: Vec::new(),
3771            },
3772        );
3773
3774        let clear_target =
3775            GraphGroupSelectionTarget::copy(None, Some("selected".to_string()), true);
3776        let selected = state
3777            .select_integrand_graph_groups(
3778                Some(&ProcessRef::Name("scalar_bubble".to_string())),
3779                Some(&"default".to_string()),
3780                &selection,
3781                &clear_target,
3782                GraphGroupSelectionContext::new(&GenerationSettings::default(), temp.path(), false),
3783            )
3784            .unwrap();
3785        assert!(selected.replaced_existing_target);
3786        assert!(selected.removed_target_artifacts);
3787        assert!(!stale_integrand_folder.exists());
3788        assert!(!state
3789            .generation_summaries
3790            .contains_key(&IntegrandGenerationSummaryKey {
3791                process_id: 0,
3792                integrand_name: "selected".to_string(),
3793            }));
3794
3795        let err = state
3796            .select_integrand_graph_groups(
3797                Some(&ProcessRef::Name("scalar_bubble".to_string())),
3798                Some(&"default".to_string()),
3799                &selection,
3800                &clear_target,
3801                GraphGroupSelectionContext::new(&GenerationSettings::default(), temp.path(), true),
3802            )
3803            .unwrap_err();
3804        assert!(format!("{err}").contains("--read-only-state"));
3805    }
3806
3807    #[test]
3808    fn select_amplitude_graphs_mode_rejects_amplitude_integrands() {
3809        let _guard = crate::LOG_TEST_MUTEX
3810            .lock()
3811            .unwrap_or_else(|err| err.into_inner());
3812        let mut state = build_scalar_bubble_diagram_state();
3813        let temp = tempdir().unwrap();
3814        let graph_name = scalar_bubble_master_graph_name(&state, "default");
3815        let selection = GraphGroupSelectionSpec::new()
3816            .with_mode(GraphGroupSelectionMode::CrossSectionAmplitudeGraphs)
3817            .with_master_graph_names(vec![graph_name]);
3818        let target = GraphGroupSelectionTarget::in_place();
3819
3820        let err = state
3821            .select_integrand_graph_groups(
3822                Some(&ProcessRef::Name("scalar_bubble".to_string())),
3823                Some(&"default".to_string()),
3824                &selection,
3825                &target,
3826                GraphGroupSelectionContext::new(&GenerationSettings::default(), temp.path(), false),
3827            )
3828            .unwrap_err();
3829
3830        assert!(format!("{err}").contains("--amplitude-graphs"));
3831    }
3832
3833    #[test]
3834    fn select_raised_cut_filters_reject_amplitude_integrands() {
3835        let _guard = crate::LOG_TEST_MUTEX
3836            .lock()
3837            .unwrap_or_else(|err| err.into_inner());
3838        let mut state = build_scalar_bubble_diagram_state();
3839        let temp = tempdir().unwrap();
3840        let selection = GraphGroupSelectionSpec::new().with_raised_cut_signatures(
3841            SelectionPolarity::With,
3842            RaisedPropagatorScope::All,
3843            vec![RaisedPropagatorSignature::from_str("[]").unwrap()],
3844        );
3845        let target = GraphGroupSelectionTarget::in_place();
3846
3847        let err = state
3848            .select_integrand_graph_groups(
3849                Some(&ProcessRef::Name("scalar_bubble".to_string())),
3850                Some(&"default".to_string()),
3851                &selection,
3852                &target,
3853                GraphGroupSelectionContext::new(&GenerationSettings::default(), temp.path(), false),
3854            )
3855            .unwrap_err();
3856
3857        assert!(format!("{err}").contains("cross-section integrands"));
3858    }
3859
3860    #[test]
3861    fn test_run_history() {
3862        use crate::state::RunHistory;
3863        //SHOWDEFAULTS.store(true, std::sync::atomic::Ordering::Relaxed);
3864        let mut run_history: RunHistory = Default::default();
3865        let kinematics_settings = KinematicsSettings {
3866            e_cm: 100.0,
3867            externals: Externals::Constant {
3868                momenta: vec![
3869                    ExternalMomenta::Independent([F(1.), F(2.), F(3.), F(4.)]),
3870                    ExternalMomenta::Dependent(Dep::Dep),
3871                ],
3872                improvement_settings: PhaseSpaceImprovementSettings::default(),
3873                helicities: vec![Helicity::PLUS, Helicity::MINUS],
3874                f_64_cache: None,
3875                f_128_cache: None,
3876            },
3877        };
3878
3879        run_history.push(Commands::Set(Set::Global {
3880            input: SetArgs::Stored,
3881        }));
3882
3883        run_history.default_runtime_settings.kinematics = kinematics_settings;
3884        set_serialize_commands_as_strings(true);
3885        let toml = toml::to_string_pretty(&run_history).unwrap();
3886        println!("{}", toml);
3887        let deserialized: RunHistory = toml::from_str(&toml).unwrap();
3888        assert_eq!(run_history, deserialized);
3889        SHOWDEFAULTS.store(false, std::sync::atomic::Ordering::Relaxed);
3890
3891        run_history.to_file("test_path.toml", true).unwrap();
3892        let deserialized_from_file = RunHistory::from_file("test_path.toml", " ").unwrap();
3893        assert_eq!(run_history, deserialized_from_file);
3894    }
3895
3896    #[test]
3897    fn run_history_applies_default_runtime_before_commands() {
3898        let mut run_history = RunHistory {
3899            default_runtime_settings: toml::from_str(
3900                r#"
3901[sampling]
3902graphs = "monte_carlo"
3903orientations = "summed"
3904lmb_multichanneling = false
3905lmb_channels = "summed"
3906coordinate_system = "tropical"
3907mapping = "linear"
3908b = 1.0
3909"#,
3910            )
3911            .unwrap(),
3912            ..Default::default()
3913        };
3914
3915        let mut state = State::new_test();
3916        let mut cli_settings = CLISettings::default();
3917        let mut default_runtime_settings = RuntimeSettings::default();
3918
3919        let _ = run_history
3920            .run(&mut state, &mut cli_settings, &mut default_runtime_settings)
3921            .unwrap();
3922
3923        assert_eq!(
3924            default_runtime_settings.sampling,
3925            run_history.default_runtime_settings.sampling
3926        );
3927    }
3928
3929    #[test]
3930    fn test_command_history_serialization() {
3931        use super::{set_serialize_commands_as_strings, CommandHistory};
3932        use crate::commands::Commands;
3933
3934        // Test basic construction
3935        let cmd_history = CommandHistory::new(Commands::Quit(SaveState::default()));
3936        assert_eq!(cmd_history.raw_string, None);
3937
3938        // Test with raw string
3939        let cmd_history_with_raw =
3940            CommandHistory::new_with_raw(Commands::Quit(SaveState::default()), "quit".to_string());
3941        assert_eq!(cmd_history_with_raw.raw_string, Some("quit".to_string()));
3942
3943        // Test serialization as Commands (default behavior)
3944        set_serialize_commands_as_strings(false);
3945
3946        let json = serde_json::to_string(&cmd_history).unwrap();
3947        let deserialized_json: CommandHistory = serde_json::from_str(&json).unwrap();
3948        let toml = toml::to_string(&cmd_history).unwrap();
3949        let deserialized_toml: CommandHistory = toml::from_str(&toml).unwrap();
3950        assert_eq!(cmd_history, deserialized_json);
3951        assert_eq!(cmd_history, deserialized_toml);
3952
3953        // Test serialization as string
3954        set_serialize_commands_as_strings(true);
3955
3956        let json_string = serde_json::to_string_pretty(&cmd_history_with_raw).unwrap();
3957        assert!(json_string.contains("quit"));
3958
3959        // Reset flag
3960        set_serialize_commands_as_strings(false);
3961    }
3962
3963    #[test]
3964    fn test_command_history_toml_and_json_formats() {
3965        use super::{set_serialize_commands_as_strings, CommandHistory};
3966        use crate::commands::Commands;
3967
3968        // Test different command types
3969        let quit_cmd = CommandHistory::new(Commands::Quit(SaveState::default()));
3970        let quit_with_raw =
3971            CommandHistory::new_with_raw(Commands::Quit(SaveState::default()), "quit".to_string());
3972
3973        // Test JSON serialization/deserialization
3974        {
3975            // Test Commands format in JSON
3976            set_serialize_commands_as_strings(false);
3977            let json = serde_json::to_string_pretty(&quit_cmd).unwrap();
3978            let deserialized: CommandHistory = serde_json::from_str(&json).unwrap();
3979            assert_eq!(quit_cmd, deserialized);
3980
3981            // Test string format in JSON
3982            set_serialize_commands_as_strings(true);
3983            let json_string = serde_json::to_string_pretty(&quit_with_raw).unwrap();
3984            assert!(json_string.contains("quit"));
3985            let deserialized_string: CommandHistory = serde_json::from_str(&json_string).unwrap();
3986            assert_eq!(quit_with_raw, deserialized_string);
3987        }
3988
3989        // Test TOML serialization/deserialization with wrapper struct
3990        {
3991            #[derive(serde::Serialize, serde::Deserialize)]
3992            struct CommandWrapper {
3993                command: CommandHistory,
3994            }
3995
3996            // Test Commands format in TOML
3997            set_serialize_commands_as_strings(false);
3998            let wrapper = CommandWrapper {
3999                command: quit_cmd.clone(),
4000            };
4001            let toml = toml::to_string_pretty(&wrapper).unwrap();
4002            let deserialized_wrapper: CommandWrapper = toml::from_str(&toml).unwrap();
4003            assert_eq!(quit_cmd, deserialized_wrapper.command);
4004
4005            // Test string format in TOML
4006            set_serialize_commands_as_strings(true);
4007            let wrapper_string = CommandWrapper {
4008                command: quit_with_raw.clone(),
4009            };
4010            let toml_string = toml::to_string_pretty(&wrapper_string).unwrap();
4011            assert!(toml_string.contains("quit"));
4012            let deserialized_string_wrapper: CommandWrapper = toml::from_str(&toml_string).unwrap();
4013            assert_eq!(quit_with_raw, deserialized_string_wrapper.command);
4014        }
4015
4016        // Test cross-format compatibility: serialize in one format, deserialize in another
4017        {
4018            set_serialize_commands_as_strings(false);
4019
4020            // Serialize as JSON, deserialize the Commands directly from JSON Value
4021            let json = serde_json::to_string(&quit_cmd).unwrap();
4022            let json_value: serde_json::Value = serde_json::from_str(&json).unwrap();
4023            let from_json: CommandHistory = serde_json::from_value(json_value).unwrap();
4024            assert_eq!(quit_cmd, from_json);
4025        }
4026        // Reset flag
4027        set_serialize_commands_as_strings(false);
4028    }
4029
4030    #[test]
4031    fn run_history_push_with_raw_skips_quit_and_definition_commands() {
4032        use super::RunHistory;
4033        use crate::commands::{run::Run, StartCommandsBlock};
4034
4035        let mut run_history = RunHistory::default();
4036        run_history.push_with_raw(
4037            Commands::Run(Run {
4038                block_names: vec!["block_a".to_string()],
4039                commands: None,
4040            }),
4041            Some("run block_a".to_string()),
4042        );
4043        run_history.push_with_raw(
4044            Commands::Quit(SaveState::default()),
4045            Some("quit".to_string()),
4046        );
4047        run_history.push_with_raw(
4048            Commands::StartCommandsBlock(StartCommandsBlock {
4049                name: "block_a".to_string(),
4050            }),
4051            Some("start_commands_block block_a".to_string()),
4052        );
4053        run_history.push_with_raw(
4054            Commands::FinishCommandsBlock,
4055            Some("finish_commands_block".to_string()),
4056        );
4057
4058        assert_eq!(run_history.commands.len(), 1);
4059        assert_eq!(
4060            run_history.commands[0].raw_string.as_deref(),
4061            Some("run block_a")
4062        );
4063    }
4064
4065    #[test]
4066    fn run_history_filtered_for_save_preserves_command_blocks() {
4067        let mut run_history = RunHistory {
4068            command_blocks: vec![
4069                CommandsBlock {
4070                    name: "generate".to_string(),
4071                    commands: vec![CommandHistory::new_with_raw(
4072                        Commands::Display(Display::Processes),
4073                        "display processes".to_string(),
4074                    )],
4075                },
4076                CommandsBlock {
4077                    name: "integrate".to_string(),
4078                    commands: vec![CommandHistory::new_with_raw(
4079                        CommandHistory::from_raw_string("quit -o").unwrap().command,
4080                        "quit -o".to_string(),
4081                    )],
4082                },
4083            ],
4084            ..Default::default()
4085        };
4086        run_history.push_with_raw(
4087            CommandHistory::from_raw_string("display processes")
4088                .unwrap()
4089                .command,
4090            Some("display processes".to_string()),
4091        );
4092
4093        let filtered = run_history.filtered_for_save();
4094        set_serialize_commands_as_strings(true);
4095        let toml = toml::to_string_pretty(&filtered).unwrap();
4096        set_serialize_commands_as_strings(false);
4097
4098        assert_eq!(filtered.command_blocks.len(), 2);
4099        assert!(toml.contains("commands = ["));
4100        assert!(toml.contains("[[command_blocks]]"));
4101        assert!(toml.contains("quit -o"));
4102    }
4103
4104    #[test]
4105    fn command_history_parses_multiline_set_string() {
4106        let raw = "set process -p epem_a_tth -i LO string '[integrator]\nn_start = 1000\n'";
4107        let cmd = CommandHistory::from_raw_string(raw).unwrap();
4108        assert_eq!(cmd.raw_string.as_deref(), Some(raw));
4109
4110        match cmd.command {
4111            Commands::Set(Set::Process { input, .. }) => match input {
4112                ProcessSetArgs::String { string } => {
4113                    assert_eq!(string, "[integrator]\nn_start = 1000\n");
4114                }
4115                other => panic!("Expected string set input, got {other:?}"),
4116            },
4117            other => panic!("Expected set process command, got {other:?}"),
4118        }
4119    }
4120
4121    #[test]
4122    fn command_history_parses_hash_process_refs() {
4123        let cmd = CommandHistory::from_raw_string("display integrand -p #12").unwrap();
4124        match cmd.command {
4125            Commands::Display(Display::Integrands {
4126                process,
4127                integrand_name,
4128                graphs,
4129                categories,
4130                hide_non_existing_thresholds,
4131            }) => {
4132                assert_eq!(process, Some(ProcessRef::Id(12)));
4133                assert_eq!(integrand_name, None);
4134                assert!(graphs.is_empty());
4135                assert!(categories.is_empty());
4136                assert!(!hide_non_existing_thresholds);
4137            }
4138            other => panic!("Expected display integrand command, got {other:?}"),
4139        }
4140    }
4141
4142    #[test]
4143    fn command_history_requires_explicit_generate_mode() {
4144        assert!(CommandHistory::from_raw_string("generate e+ e- > d d~").is_err());
4145    }
4146
4147    #[test]
4148    fn run_history_parses_triple_quoted_set_kv_command() {
4149        let toml = r#"
4150commands = [
4151    """set default-runtime kv kinematics.externals='{"type":"constant","data":{"momenta":[[1.0,2.0,3.0,4.0],[5.0,6.0,-7.0,-8.0]],"helicities":[1,1]}}'""",
4152]
4153
4154[default_runtime_settings.general]
4155integral_unit = "picobarn"
4156"#;
4157
4158        let run_history: RunHistory = toml::from_str(toml).unwrap();
4159        assert_eq!(run_history.commands.len(), 1);
4160
4161        let expected_cmd = r#"set default-runtime kv kinematics.externals='{"type":"constant","data":{"momenta":[[1.0,2.0,3.0,4.0],[5.0,6.0,-7.0,-8.0]],"helicities":[1,1]}}'"#;
4162        let command_history = &run_history.commands[0];
4163        assert_eq!(command_history.raw_string.as_deref(), Some(expected_cmd));
4164
4165        match &command_history.command {
4166            Commands::Set(Set::DefaultRuntime {
4167                input: SetArgs::Kv { pairs },
4168            }) => {
4169                assert_eq!(pairs.len(), 1);
4170                assert_eq!(pairs[0].key, "kinematics.externals");
4171                assert_eq!(
4172                    pairs[0].value,
4173                    r#"{"type":"constant","data":{"momenta":[[1.0,2.0,3.0,4.0],[5.0,6.0,-7.0,-8.0]],"helicities":[1,1]}}"#
4174                );
4175            }
4176            other => panic!("Expected set default-runtime kv command, got {other:?}"),
4177        }
4178
4179        assert_eq!(
4180            run_history.default_runtime_settings.general.integral_unit,
4181            gammalooprs::settings::runtime::IntegralUnit::Picobarn
4182        );
4183    }
4184
4185    #[test]
4186    fn run_history_load_preserves_command_blocks() {
4187        let temp = tempdir().unwrap();
4188        let run_path = temp.path().join("run.toml");
4189        fs::write(
4190            &run_path,
4191            r#"
4192[[command_blocks]]
4193name = "zeta"
4194commands = ["quit -n"]
4195
4196[[command_blocks]]
4197name = "alpha"
4198commands = ["quit -o"]
4199"#,
4200        )
4201        .unwrap();
4202
4203        let run_history = RunHistory::load(&run_path).unwrap();
4204        assert!(run_history.commands.is_empty());
4205        assert_eq!(run_history.command_blocks.len(), 2);
4206    }
4207
4208    #[test]
4209    fn run_history_selects_named_command_blocks_in_order() {
4210        let temp = tempdir().unwrap();
4211        let run_path = temp.path().join("run.toml");
4212        fs::write(
4213            &run_path,
4214            r#"
4215[[command_blocks]]
4216name = "first"
4217commands = ["quit -n"]
4218
4219[[command_blocks]]
4220name = "second"
4221commands = ["quit -o"]
4222"#,
4223        )
4224        .unwrap();
4225
4226        let requested = vec!["second".to_string(), "first".to_string()];
4227        let run_history = RunHistory::load(&run_path).unwrap();
4228        let selected = run_history
4229            .select_command_blocks(requested.as_slice())
4230            .unwrap();
4231        assert_eq!(selected.len(), 2);
4232        assert_eq!(
4233            selected[0].commands[0].raw_string.as_deref(),
4234            Some("quit -o")
4235        );
4236        assert_eq!(
4237            selected[1].commands[0].raw_string.as_deref(),
4238            Some("quit -n")
4239        );
4240    }
4241
4242    #[test]
4243    fn run_history_selection_rejects_unknown_command_block() {
4244        let temp = tempdir().unwrap();
4245        let run_path = temp.path().join("run.toml");
4246        fs::write(
4247            &run_path,
4248            r#"
4249[[command_blocks]]
4250name = "first"
4251commands = ["quit -n"]
4252"#,
4253        )
4254        .unwrap();
4255
4256        let requested = vec!["missing".to_string()];
4257        let run_history = RunHistory::load(&run_path).unwrap();
4258        let err = run_history
4259            .select_command_blocks(requested.as_slice())
4260            .unwrap_err();
4261        let message = format!("{err}");
4262        assert!(message.contains("Unknown command block 'missing'"));
4263        assert!(message.contains("first"));
4264    }
4265
4266    #[test]
4267    fn run_history_selection_rejects_missing_command_block_when_only_legacy_commands_exist() {
4268        let temp = tempdir().unwrap();
4269        let run_path = temp.path().join("run.toml");
4270        fs::write(
4271            &run_path,
4272            r#"
4273commands = ["quit -o"]
4274"#,
4275        )
4276        .unwrap();
4277
4278        let requested = vec!["first".to_string()];
4279        let run_history = RunHistory::load(&run_path).unwrap();
4280        let err = run_history
4281            .select_command_blocks(requested.as_slice())
4282            .unwrap_err();
4283        assert!(format!("{err}").contains("Unknown command block"));
4284    }
4285
4286    #[test]
4287    fn run_history_load_accepts_commands_and_command_blocks_together() {
4288        let temp = tempdir().unwrap();
4289        let run_path = temp.path().join("run.toml");
4290        fs::write(
4291            &run_path,
4292            r#"
4293commands = ["quit -o"]
4294
4295[[command_blocks]]
4296name = "first"
4297commands = ["quit -n"]
4298"#,
4299        )
4300        .unwrap();
4301
4302        let run_history = RunHistory::load(&run_path).unwrap();
4303        assert_eq!(run_history.commands.len(), 1);
4304        assert_eq!(run_history.command_blocks.len(), 1);
4305        assert_eq!(
4306            run_history.commands[0].raw_string.as_deref(),
4307            Some("quit -o")
4308        );
4309    }
4310
4311    #[test]
4312    fn run_history_load_rejects_duplicate_command_block_names() {
4313        let temp = tempdir().unwrap();
4314        let run_path = temp.path().join("run.toml");
4315        fs::write(
4316            &run_path,
4317            r#"
4318[[command_blocks]]
4319name = "first"
4320commands = ["quit -o"]
4321
4322[[command_blocks]]
4323name = "first"
4324commands = ["quit -n"]
4325"#,
4326        )
4327        .unwrap();
4328
4329        let err = RunHistory::load(&run_path).unwrap_err();
4330        assert!(format!("{err}").contains("duplicate block name"));
4331    }
4332
4333    #[test]
4334    fn state_manifest_roundtrip_current_version() {
4335        let temp = tempdir().unwrap();
4336        save_state_manifest(temp.path()).unwrap();
4337
4338        let manifest = load_state_manifest(temp.path()).unwrap();
4339        assert_eq!(manifest.version, CURRENT_STATE_MANIFEST_VERSION);
4340    }
4341
4342    #[test]
4343    fn state_manifest_rejects_future_versions() {
4344        let temp = tempdir().unwrap();
4345        let future_manifest = StateManifest {
4346            version: CURRENT_STATE_MANIFEST_VERSION + 1,
4347        };
4348        fs::write(
4349            temp.path().join(STATE_MANIFEST_FILE),
4350            toml::to_string_pretty(&future_manifest).unwrap(),
4351        )
4352        .unwrap();
4353
4354        let err = load_state_manifest(temp.path()).unwrap_err();
4355        assert!(format!("{err}").contains("newer than this binary supports"));
4356    }
4357
4358    #[test]
4359    fn state_folder_classifies_saved_layout() {
4360        let temp = tempdir().unwrap();
4361        save_state_manifest(temp.path()).unwrap();
4362        fs::write(temp.path().join("model.json"), "{}").unwrap();
4363        fs::write(temp.path().join("symbolica_state.bin"), []).unwrap();
4364        fs::create_dir_all(temp.path().join("processes")).unwrap();
4365
4366        assert_eq!(
4367            classify_state_folder(temp.path()).unwrap(),
4368            StateFolderKind::Saved
4369        );
4370    }
4371
4372    #[test]
4373    fn state_folder_classifies_logs_only_folder_as_scratch() {
4374        let temp = tempdir().unwrap();
4375        fs::create_dir_all(temp.path().join("logs")).unwrap();
4376        fs::write(temp.path().join("logs").join("gammalog.jsonl"), "").unwrap();
4377
4378        assert_eq!(
4379            classify_state_folder(temp.path()).unwrap(),
4380            StateFolderKind::Scratch
4381        );
4382    }
4383
4384    #[test]
4385    fn state_folder_classifies_non_manifest_contents_as_unmanifested() {
4386        let temp = tempdir().unwrap();
4387        fs::create_dir_all(temp.path().join("processes").join("amplitudes")).unwrap();
4388
4389        assert_eq!(
4390            classify_state_folder(temp.path()).unwrap(),
4391            StateFolderKind::Unmanifested
4392        );
4393    }
4394
4395    #[test]
4396    fn activate_loaded_integrand_backends_falls_back_to_eager_when_external_artifacts_are_missing()
4397    {
4398        let mut state = build_generated_scalar_bubble_state_with_external_backend();
4399
4400        state.activate_loaded_integrand_backends(false).unwrap();
4401
4402        for process in &state.process_list.processes {
4403            match &process.collection {
4404                ProcessCollection::Amplitudes(amplitudes) => {
4405                    for amplitude in amplitudes.values() {
4406                        let integrand = amplitude.integrand.as_ref().unwrap();
4407                        if matches!(
4408                            integrand.frozen_compilation(),
4409                            FrozenCompilationMode::Cpp(_) | FrozenCompilationMode::Assembly(_)
4410                        ) {
4411                            assert_eq!(integrand.active_f64_backend(), ActiveF64Backend::Eager);
4412                        }
4413                    }
4414                }
4415                ProcessCollection::CrossSections(cross_sections) => {
4416                    for cross_section in cross_sections.values() {
4417                        let integrand = cross_section.integrand.as_ref().unwrap();
4418                        if matches!(
4419                            integrand.frozen_compilation(),
4420                            FrozenCompilationMode::Cpp(_) | FrozenCompilationMode::Assembly(_)
4421                        ) {
4422                            assert_eq!(integrand.active_f64_backend(), ActiveF64Backend::Eager);
4423                        }
4424                    }
4425                }
4426            }
4427        }
4428    }
4429
4430    #[test]
4431    fn resolve_effective_model_parameter_card_overlays_runtime_model_settings() {
4432        let mut state = State::new_test();
4433        state.model = load_generic_model("scalars");
4434        state.model_parameters = InputParamCard::default_from_model(&state.model);
4435
4436        let mut settings = RuntimeSettings::default();
4437        settings
4438            .model
4439            .external_parameters
4440            .insert("mass_scalar_2".to_string(), (F(7.5), F(0.0)));
4441
4442        let resolved = state
4443            .resolve_effective_model_parameter_card_for_settings(&settings)
4444            .unwrap();
4445
4446        assert_eq!(
4447            resolved[&UFOSymbol::from("mass_scalar_2")],
4448            Complex::new(F(7.5), F(0.0))
4449        );
4450        assert_eq!(
4451            resolved[&UFOSymbol::from("mass_scalar_1")],
4452            state.model_parameters[&UFOSymbol::from("mass_scalar_1")]
4453        );
4454    }
4455
4456    #[test]
4457    fn resolve_effective_model_parameter_card_rejects_non_overridable_parameters() {
4458        let mut state = State::new_test();
4459        state.model = load_generic_model("scalars");
4460        state.model_parameters = InputParamCard::default_from_model(&state.model);
4461        state
4462            .model_parameters
4463            .remove(&UFOSymbol::from("mass_scalar_2"));
4464
4465        let mut settings = RuntimeSettings::default();
4466        settings
4467            .model
4468            .external_parameters
4469            .insert("mass_scalar_2".to_string(), (F(7.5), F(0.0)));
4470
4471        let err = state
4472            .resolve_effective_model_parameter_card_for_settings(&settings)
4473            .unwrap_err();
4474
4475        assert!(err
4476            .to_string()
4477            .contains("cannot be overridden because it is not present"));
4478    }
4479}
4480
4481#[derive(Args, Debug, Clone)]
4482pub struct ExistingArgs {
4483    pub process_id: u32,
4484    pub name: Option<String>,
4485}