Skip to main content

gammaloop_api/
state.rs

1#[cfg(test)]
2use gammalooprs::integrands::process::EvaluationTarget;
3use std::{
4    collections::{BTreeMap, BTreeSet, HashSet},
5    fs::{self},
6    io::{self, IsTerminal},
7    ops::ControlFlow,
8    path::{Path, PathBuf},
9    str::FromStr,
10    sync::{
11        atomic::{AtomicBool, AtomicU64, Ordering},
12        Arc, Mutex,
13    },
14    thread,
15    time::Duration,
16};
17
18use clap::Args;
19use color_eyre::{Result, Section};
20use colored::Colorize;
21use eyre::{eyre, Context};
22use gammalooprs::{
23    processes::{Amplitude, CrossSection},
24    utils::serde_utils::IsDefault,
25};
26use linnet::half_edge::subgraph::{SuBitGraph, SubGraphLike, SubSetLike};
27use schemars::{schema_for, JsonSchema, Schema};
28use serde::{Deserialize, Serialize};
29use spenso::algebra::complex::Complex;
30#[cfg(not(target_os = "linux"))]
31use sysinfo::{get_current_pid, ProcessRefreshKind, ProcessesToUpdate, System};
32use toml::Value as TomlValue;
33use tracing::{debug, info, info_span, Span};
34use tracing_indicatif::span_ext::IndicatifSpanExt;
35
36use gammalooprs::{
37    clear_interrupt_request,
38    feyngen::GenerationType,
39    graph::Graph,
40    initialisation::initialise,
41    integrands::process::ProcessIntegrand,
42    is_interrupt_requested,
43    model::{InputParamCard, Model, SerializableInputParamCard, UFOSymbol},
44    processes::{
45        begin_phase, merge_generated_graph_reports, DotExportSettings, GeneratedGraphReport,
46        GenerationProcessKind, GenerationProgressMode, GenerationProgressModeGuard,
47        GenerationProgressObserver, GenerationProgressObserverGuard, GenerationProgressPhase,
48        GraphGenerationStats, GraphGroupSelectionMode, GraphGroupSelectionPlan,
49        GraphGroupSelectionReport, GraphGroupSelectionSpec, NamedGraphGenerationReport, Process,
50        ProcessCollection, ProcessDefinition, ProcessList, ProcessLoadSelection,
51    },
52    settings::{
53        global::GenerationSettings, runtime::LockedRuntimeSettings, GlobalSettings, RuntimeSettings,
54    },
55    utils::{
56        serde_utils::{get_schema_folder, SmartSerde},
57        tracing::{init_bench_tracing, init_test_tracing},
58        F,
59    },
60    GammaLoopContextContainer,
61};
62
63use crate::{
64    command_parser::{normalize_clap_args, split_command_line},
65    command_template::{contains_placeholder, placeholder_specs, PlaceholderSpec},
66    commands::{save::SaveState, Commands},
67    integrand_info::{collect_integrand_info, IntegrandInfo},
68    model_parameters::{external_model_parameter_type, validate_model_parameter_type},
69    render_smart_toml,
70    tracing::{set_file_log_filter, set_log_style, set_stderr_log_filter},
71    CLISettings,
72};
73
74#[derive(Debug, Clone, Copy, Default)]
75pub struct GenerationResourceSummary {
76    pub peak_ram_bytes: u64,
77    pub generation_cores: usize,
78}
79
80#[derive(Debug, Clone, Default)]
81pub struct GenerationReports {
82    pub reports: Vec<GeneratedGraphReport>,
83    pub resources: GenerationResourceSummary,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
87pub struct IntegrandGenerationSummaryKey {
88    pub process_id: usize,
89    pub integrand_name: String,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct IntegrandGenerationSummary {
94    pub peak_ram_bytes: u64,
95    pub reports: Vec<GeneratedGraphReport>,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct SelectedGraphGroups {
100    pub source_process_id: usize,
101    pub source_process_name: String,
102    pub source_integrand_name: String,
103    pub process_id: usize,
104    pub process_name: String,
105    pub integrand_name: String,
106    pub report: GraphGroupSelectionReport,
107    pub copied_to_output: bool,
108    pub replaced_existing_target: bool,
109    pub removed_target_artifacts: bool,
110    pub discarded_generated_integrand: bool,
111    pub removed_generated_artifacts: bool,
112    pub removed_generation_summary: bool,
113}
114
115#[derive(Debug, Clone, Default, PartialEq, Eq)]
116pub struct GraphGroupSelectionTarget {
117    pub output_process_name: Option<String>,
118    pub output_integrand_name: Option<String>,
119    pub clear_existing_processes: bool,
120}
121
122impl GraphGroupSelectionTarget {
123    pub fn in_place() -> Self {
124        Self::default()
125    }
126
127    pub fn copy(
128        output_process_name: Option<String>,
129        output_integrand_name: Option<String>,
130        clear_existing_processes: bool,
131    ) -> Self {
132        Self {
133            output_process_name,
134            output_integrand_name,
135            clear_existing_processes,
136        }
137    }
138
139    fn is_copy_mode(&self) -> bool {
140        self.output_process_name.is_some() || self.output_integrand_name.is_some()
141    }
142}
143
144#[derive(Debug, Clone, Copy)]
145pub struct GraphGroupSelectionContext<'a> {
146    pub generation_settings: &'a GenerationSettings,
147    pub state_folder: &'a Path,
148    pub read_only_state: bool,
149}
150
151impl<'a> GraphGroupSelectionContext<'a> {
152    pub fn new(
153        generation_settings: &'a GenerationSettings,
154        state_folder: &'a Path,
155        read_only_state: bool,
156    ) -> Self {
157        Self {
158            generation_settings,
159            state_folder,
160            read_only_state,
161        }
162    }
163}
164
165struct IntegrandCopyInsertion {
166    process_id: usize,
167    process_name: String,
168    integrand_name: String,
169    replaced_existing_target: bool,
170    removed_target_artifacts: bool,
171}
172
173struct SelectionSource {
174    process_id: usize,
175    process_name: String,
176    integrand_name: String,
177}
178
179enum IntegrandCopyPayload {
180    Amplitude(Amplitude),
181    CrossSection(CrossSection),
182}
183
184impl IntegrandCopyPayload {
185    fn rename(&mut self, new_name: &str) {
186        match self {
187            Self::Amplitude(amplitude) => {
188                amplitude.name = new_name.to_string();
189                rename_process_integrand(amplitude.integrand.as_mut(), new_name);
190            }
191            Self::CrossSection(cross_section) => {
192                cross_section.name = new_name.to_string();
193                rename_process_integrand(cross_section.integrand.as_mut(), new_name);
194            }
195        }
196    }
197
198    fn apply_graph_group_selection(&mut self, plan: &GraphGroupSelectionPlan) -> Result<()> {
199        match self {
200            Self::Amplitude(amplitude) => amplitude.apply_graph_group_selection(plan),
201            Self::CrossSection(cross_section) => cross_section.apply_graph_group_selection(plan),
202        }
203    }
204
205    fn is_compatible_with(&self, collection: &ProcessCollection) -> bool {
206        matches!(
207            (self, collection),
208            (Self::Amplitude(_), ProcessCollection::Amplitudes(_))
209                | (Self::CrossSection(_), ProcessCollection::CrossSections(_))
210        )
211    }
212
213    fn kind_name(&self) -> &'static str {
214        match self {
215            Self::Amplitude(_) => "amplitudes",
216            Self::CrossSection(_) => "cross sections",
217        }
218    }
219
220    fn into_collection(self) -> ProcessCollection {
221        match self {
222            Self::Amplitude(amplitude) => {
223                let mut collection = ProcessCollection::Amplitudes(BTreeMap::new());
224                collection.add_amplitude(amplitude);
225                collection
226            }
227            Self::CrossSection(cross_section) => {
228                let mut collection = ProcessCollection::CrossSections(BTreeMap::new());
229                collection.add_cross_section(cross_section);
230                collection
231            }
232        }
233    }
234
235    fn insert_into_process(self, process: &mut Process, process_name: &str) -> Result<()> {
236        match (self, &mut process.collection) {
237            (Self::Amplitude(amplitude), ProcessCollection::Amplitudes(amplitudes)) => {
238                amplitudes.insert(amplitude.name.clone(), amplitude);
239                Ok(())
240            }
241            (
242                Self::CrossSection(cross_section),
243                ProcessCollection::CrossSections(cross_sections),
244            ) => {
245                cross_sections.insert(cross_section.name.clone(), cross_section);
246                Ok(())
247            }
248            (payload, _) => Err(eyre!(
249                "Destination process '{}' exists but does not contain {}",
250                process_name,
251                payload.kind_name()
252            )),
253        }
254    }
255}
256
257struct GenerationMonitor {
258    peak_ram_bytes: Arc<AtomicU64>,
259    current_ram_bytes: Arc<AtomicU64>,
260    stop_requested: Arc<AtomicBool>,
261    handle: Option<thread::JoinHandle<()>>,
262}
263
264struct AggregateGenerationProgressReporter {
265    progress_span: Span,
266    current_ram_bytes: Arc<AtomicU64>,
267    peak_ram_bytes: Arc<AtomicU64>,
268    state: Mutex<AggregateGenerationProgressState>,
269}
270
271#[derive(Default)]
272struct AggregateGenerationProgressState {
273    phase: Option<GenerationProgressPhase>,
274    kind: Option<GenerationProcessKind>,
275    process: String,
276    integrand: String,
277    total_graphs: usize,
278    done_graphs: usize,
279    total_cuts: Option<usize>,
280    done_cuts: usize,
281    discovered_st_cuts: usize,
282    discovered_valid_cuts: usize,
283    active_graphs: BTreeSet<String>,
284    last_graph: Option<String>,
285    stats: GraphGenerationStats,
286}
287
288impl AggregateGenerationProgressReporter {
289    fn new(
290        current_ram_bytes: Arc<AtomicU64>,
291        peak_ram_bytes: Arc<AtomicU64>,
292        eta_warmup_steps: u64,
293    ) -> Arc<Self> {
294        let span = info_span!(
295            "Integrand generation",
296            indicatif.pb_show = true,
297            indicatif.pb_msg = "Starting integrand generation"
298        );
299        span.pb_set_style(
300            &gammalooprs::utils::long_running_progress_style_with_eta_warmup(eta_warmup_steps),
301        );
302        span.pb_start();
303        span.pb_set_length(0);
304        span.pb_set_position(0);
305        span.pb_tick();
306        Self::new_with_span(current_ram_bytes, peak_ram_bytes, span)
307    }
308
309    #[cfg(test)]
310    fn new_hidden(current_ram_bytes: Arc<AtomicU64>, peak_ram_bytes: Arc<AtomicU64>) -> Arc<Self> {
311        Self::new_with_span(current_ram_bytes, peak_ram_bytes, Span::none())
312    }
313
314    fn new_with_span(
315        current_ram_bytes: Arc<AtomicU64>,
316        peak_ram_bytes: Arc<AtomicU64>,
317        progress_span: Span,
318    ) -> Arc<Self> {
319        Arc::new(Self {
320            progress_span,
321            current_ram_bytes,
322            peak_ram_bytes,
323            state: Mutex::new(AggregateGenerationProgressState::default()),
324        })
325    }
326
327    fn fixed_field(value: &str, width: usize) -> String {
328        let mut chars = value.chars().collect::<Vec<_>>();
329        if chars.len() > width {
330            chars.truncate(width.saturating_sub(1));
331            chars.push('~');
332        }
333        format!("{:<width$}", chars.into_iter().collect::<String>())
334    }
335
336    fn progress_memory(bytes: u64) -> String {
337        const MIB: f64 = 1024.0 * 1024.0;
338        const GIB: f64 = MIB * 1024.0;
339        let value = bytes as f64;
340        if value >= GIB {
341            format!("{:>6.2} GiB", value / GIB)
342        } else {
343            format!("{:>6.0} MiB", value / MIB)
344        }
345    }
346
347    fn progress_percent(percent: f64) -> String {
348        if percent < 0.01 {
349            "0%".to_string()
350        } else if percent < 0.1 {
351            format!("{percent:.3}%")
352        } else if percent < 1.0 {
353            format!("{percent:.2}%")
354        } else if percent < 10.0 {
355            format!("{percent:.1}%")
356        } else {
357            format!("{percent:.0}%")
358        }
359    }
360
361    fn progress_time_share(duration: Duration, total: Duration) -> String {
362        if total.is_zero() {
363            "--".to_string()
364        } else {
365            Self::progress_percent(duration.as_secs_f64() * 100.0 / total.as_secs_f64())
366        }
367    }
368
369    fn progress_units(state: &AggregateGenerationProgressState) -> (u64, u64) {
370        match (state.phase, state.total_cuts) {
371            (Some(GenerationProgressPhase::GraphGeneration), Some(total_cuts)) => {
372                let total = total_cuts.saturating_add(state.total_graphs) as u64;
373                let completed = state
374                    .done_cuts
375                    .saturating_add(state.done_graphs)
376                    .min(total_cuts.saturating_add(state.total_graphs))
377                    as u64;
378                (completed, total)
379            }
380            _ => (state.done_graphs as u64, state.total_graphs as u64),
381        }
382    }
383
384    fn graph_progress_counts(state: &AggregateGenerationProgressState) -> (usize, usize, usize) {
385        let active = state
386            .active_graphs
387            .len()
388            .min(state.total_graphs.saturating_sub(state.done_graphs));
389        (state.done_graphs, active, state.total_graphs)
390    }
391
392    fn refresh(&self, state: &AggregateGenerationProgressState) {
393        let (completed_units, total_units) = Self::progress_units(state);
394        self.progress_span.pb_set_length(total_units);
395        self.progress_span.pb_set_position(completed_units);
396
397        let current_ram = Self::progress_memory(self.current_ram_bytes.load(Ordering::Relaxed));
398        let peak_ram = Self::progress_memory(self.peak_ram_bytes.load(Ordering::Relaxed));
399        let kind = match state.kind {
400            Some(GenerationProcessKind::Amplitude) => "AMP",
401            Some(GenerationProcessKind::CrossSection) => "XS",
402            None => "GEN",
403        };
404        let phase = match state.phase {
405            Some(GenerationProgressPhase::GraphPreprocessing) => "prep",
406            Some(GenerationProgressPhase::GraphGeneration) => "graphs",
407            Some(GenerationProgressPhase::Backend) => "backend",
408            None => "gen",
409        };
410        let last_graph = state.last_graph.as_deref().unwrap_or("-");
411        let cut_context = match (state.phase, state.kind, state.total_cuts) {
412            (
413                Some(GenerationProgressPhase::GraphPreprocessing),
414                Some(GenerationProcessKind::Amplitude),
415                _,
416            ) => None,
417            (Some(GenerationProgressPhase::GraphPreprocessing), _, _) => Some(format!(
418                "{:>4}/{:<4}",
419                state.discovered_valid_cuts, state.discovered_st_cuts
420            )),
421            (_, _, Some(total_cuts)) => Some(format!("{:>4}/{:<4}", state.done_cuts, total_cuts)),
422            _ => Some(format!("{:>4}/{:<4}", "-", "-")),
423        };
424        let phase = Self::fixed_field(phase, 7).bold().blue();
425        let kind = Self::fixed_field(kind, 3).cyan();
426        let identifier = if state.process.is_empty() {
427            state.integrand.clone()
428        } else {
429            format!("{}@{}", state.integrand, state.process)
430        };
431        let identifier = Self::fixed_field(&identifier, 24).yellow();
432        let last_graph = Self::fixed_field(last_graph, 8).green();
433        let (done_graphs, active_graphs, total_graphs) = Self::graph_progress_counts(state);
434        let graph_ratio = format!(
435            "{} {} / {}",
436            format!("{done_graphs:>3}").green(),
437            format!("({active_graphs:>3})").dimmed(),
438            format!("{total_graphs:<3}").green(),
439        );
440        let ram = format!("{current_ram}/{peak_ram}").yellow();
441        let phase_detail = if state.phase == Some(GenerationProgressPhase::GraphPreprocessing) {
442            let steps = match state.kind {
443                Some(GenerationProcessKind::Amplitude) => {
444                    "CFFs + LMBs + integrands + threshold CTs + tropical samplers"
445                }
446                _ => "cuts + CFFs + LMBs + integrands + threshold CTs",
447            };
448            format!("{} {}", "step".bold().blue(), steps.cyan())
449        } else {
450            let total_time = state.stats.total_time;
451            let expression_share =
452                Self::progress_time_share(state.stats.expression_build_time(), total_time);
453            let spenso_share =
454                Self::progress_time_share(state.stats.evaluator_spenso_time, total_time);
455            let symbolica_share =
456                Self::progress_time_share(state.stats.evaluator_symbolica_time, total_time);
457            let compile_share =
458                Self::progress_time_share(state.stats.evaluator_compile_time, total_time);
459            format!(
460                "{} {} {} / {} {} / {} {} / {} {}",
461                "time".bold().blue(),
462                "expr".bold().blue(),
463                expression_share.magenta(),
464                "spenso".bold().blue(),
465                spenso_share.cyan(),
466                "eval".bold().blue(),
467                symbolica_share.green(),
468                "compile".bold().blue(),
469                compile_share.yellow(),
470            )
471        };
472        let mut sections = vec![
473            format!("{} {} {}", phase, kind, identifier),
474            format!("{} {}", "g".bold().blue(), graph_ratio),
475            format!("{} {}", "last".bold().blue(), last_graph),
476        ];
477        if let Some(cut_context) = cut_context {
478            sections.push(format!("{} {}", "cut".bold().blue(), cut_context.green()));
479        }
480        sections.push(format!("{} {}", "ram".bold().blue(), ram));
481        sections.push(phase_detail);
482        self.progress_span.pb_set_message(&sections.join(" | "));
483        self.progress_span.pb_tick();
484    }
485}
486
487impl GenerationProgressObserver for AggregateGenerationProgressReporter {
488    fn begin_phase(
489        &self,
490        phase: GenerationProgressPhase,
491        kind: GenerationProcessKind,
492        process: &str,
493        integrand: &str,
494        total_graphs: usize,
495        total_cuts: Option<usize>,
496    ) {
497        let mut state = self
498            .state
499            .lock()
500            .expect("aggregate generation progress state mutex is poisoned");
501        state.phase = Some(phase);
502        state.kind = Some(kind);
503        state.process.clear();
504        state.process.push_str(process);
505        state.integrand.clear();
506        state.integrand.push_str(integrand);
507        state.total_graphs = total_graphs;
508        state.done_graphs = 0;
509        state.total_cuts = total_cuts;
510        state.done_cuts = 0;
511        state.discovered_st_cuts = 0;
512        state.discovered_valid_cuts = 0;
513        state.active_graphs.clear();
514        state.last_graph = None;
515        state.stats = GraphGenerationStats::default();
516        self.progress_span.pb_reset_elapsed();
517        self.refresh(&state);
518    }
519
520    fn graph_started(
521        &self,
522        _kind: GenerationProcessKind,
523        _integrand: &str,
524        graph: &str,
525        _cut_count: Option<usize>,
526    ) {
527        let mut state = self
528            .state
529            .lock()
530            .expect("aggregate generation progress state mutex is poisoned");
531        state.active_graphs.insert(graph.to_string());
532        state.last_graph = Some(graph.to_string());
533        self.refresh(&state);
534    }
535
536    fn graph_finished(
537        &self,
538        _kind: GenerationProcessKind,
539        _integrand: &str,
540        graph: &str,
541        stats: &GraphGenerationStats,
542        completed_cuts: Option<usize>,
543    ) {
544        let mut state = self
545            .state
546            .lock()
547            .expect("aggregate generation progress state mutex is poisoned");
548        state.done_graphs = state.done_graphs.saturating_add(1).min(state.total_graphs);
549        state.done_cuts += completed_cuts.unwrap_or(0);
550        state.active_graphs.remove(graph);
551        state.last_graph = Some(graph.to_string());
552        state.stats.merge_in_place(stats);
553        self.refresh(&state);
554    }
555
556    fn cuts_discovered(
557        &self,
558        _integrand: &str,
559        graph: &str,
560        st_cut_count: usize,
561        valid_cut_count: usize,
562    ) {
563        let mut state = self
564            .state
565            .lock()
566            .expect("aggregate generation progress state mutex is poisoned");
567        state.discovered_st_cuts += st_cut_count;
568        state.discovered_valid_cuts += valid_cut_count;
569        state.last_graph = Some(graph.to_string());
570        self.refresh(&state);
571    }
572
573    fn cut_finished(&self, _integrand: &str, graph: &str, cut_count: usize) {
574        let mut state = self
575            .state
576            .lock()
577            .expect("aggregate generation progress state mutex is poisoned");
578        state.done_cuts = state.done_cuts.saturating_add(cut_count);
579        if let Some(total_cuts) = state.total_cuts {
580            state.done_cuts = state.done_cuts.min(total_cuts);
581        }
582        state.last_graph = Some(graph.to_string());
583        self.refresh(&state);
584    }
585
586    fn backend_started(&self, kind: GenerationProcessKind, integrand: &str, graph_count: usize) {
587        let mut state = self
588            .state
589            .lock()
590            .expect("aggregate generation progress state mutex is poisoned");
591        state.phase = Some(GenerationProgressPhase::Backend);
592        state.kind = Some(kind);
593        state.integrand.clear();
594        state.integrand.push_str(integrand);
595        state.total_graphs = graph_count;
596        state.done_graphs = 0;
597        state.total_cuts = None;
598        state.done_cuts = 0;
599        state.active_graphs.clear();
600        state.last_graph = None;
601        self.progress_span.pb_reset_elapsed();
602        self.refresh(&state);
603    }
604
605    fn backend_finished(&self, _kind: GenerationProcessKind, _integrand: &str, elapsed: Duration) {
606        let mut state = self
607            .state
608            .lock()
609            .expect("aggregate generation progress state mutex is poisoned");
610        state.done_graphs = state.total_graphs;
611        state.stats.total_time += elapsed;
612        state.stats.evaluator_compile_time += elapsed;
613        self.refresh(&state);
614    }
615}
616
617impl GenerationMonitor {
618    const POLL_INTERVAL: Duration = Duration::from_millis(100);
619
620    #[cfg(target_os = "linux")]
621    fn parse_resident_memory_bytes(status: &str) -> Option<u64> {
622        let mut fields = status
623            .lines()
624            .find_map(|line| line.strip_prefix("VmRSS:"))?
625            .split_ascii_whitespace();
626        let kibibytes = fields.next()?.parse::<u64>().ok()?;
627        if fields.next()? != "kB" || fields.next().is_some() {
628            return None;
629        }
630        kibibytes.checked_mul(1024)
631    }
632
633    #[cfg(target_os = "linux")]
634    fn resident_memory_bytes() -> Option<u64> {
635        // Refreshing one PID with sysinfo also walks the host's processes/tasks.
636        let status = fs::read_to_string("/proc/self/status").ok()?;
637        Self::parse_resident_memory_bytes(&status)
638    }
639
640    fn start() -> Result<Self> {
641        #[cfg(not(target_os = "linux"))]
642        let pid = get_current_pid().map_err(|err| eyre!("Failed to resolve current pid: {err}"))?;
643        #[cfg(target_os = "linux")]
644        let initial_memory = Self::resident_memory_bytes().unwrap_or(0);
645        #[cfg(not(target_os = "linux"))]
646        let initial_memory = 0;
647        let peak_ram_bytes = Arc::new(AtomicU64::new(initial_memory));
648        let current_ram_bytes = Arc::new(AtomicU64::new(initial_memory));
649        let stop_requested = Arc::new(AtomicBool::new(false));
650        let peak_ram_bytes_for_thread = Arc::clone(&peak_ram_bytes);
651        let current_ram_bytes_for_thread = Arc::clone(&current_ram_bytes);
652        let stop_requested_for_thread = Arc::clone(&stop_requested);
653
654        let handle = thread::Builder::new()
655            .name("generation-ram-monitor".to_string())
656            .spawn(move || {
657                #[cfg(not(target_os = "linux"))]
658                let mut system = System::new();
659                loop {
660                    if stop_requested_for_thread.load(Ordering::Relaxed) || is_interrupt_requested()
661                    {
662                        break;
663                    }
664
665                    #[cfg(target_os = "linux")]
666                    let memory = Self::resident_memory_bytes();
667                    #[cfg(not(target_os = "linux"))]
668                    let memory = {
669                        system.refresh_processes_specifics(
670                            ProcessesToUpdate::Some(&[pid]),
671                            true,
672                            ProcessRefreshKind::nothing().with_memory(),
673                        );
674                        system.process(pid).map(|process| process.memory())
675                    };
676                    if let Some(memory) = memory {
677                        current_ram_bytes_for_thread.store(memory, Ordering::Relaxed);
678                        peak_ram_bytes_for_thread.fetch_max(memory, Ordering::Relaxed);
679                    }
680                    thread::park_timeout(Self::POLL_INTERVAL);
681                }
682            })
683            .map_err(|err| eyre!("Failed to spawn generation RAM monitor: {err}"))?;
684
685        Ok(Self {
686            peak_ram_bytes,
687            current_ram_bytes,
688            stop_requested,
689            handle: Some(handle),
690        })
691    }
692
693    fn current_ram_bytes(&self) -> Arc<AtomicU64> {
694        Arc::clone(&self.current_ram_bytes)
695    }
696
697    fn peak_ram_bytes(&self) -> Arc<AtomicU64> {
698        Arc::clone(&self.peak_ram_bytes)
699    }
700
701    fn finish(&mut self) -> u64 {
702        self.stop_requested.store(true, Ordering::Relaxed);
703        if let Some(handle) = self.handle.take() {
704            handle.thread().unpark();
705            let _ = handle.join();
706        }
707        self.peak_ram_bytes.load(Ordering::Relaxed)
708    }
709}
710
711impl Drop for GenerationMonitor {
712    fn drop(&mut self) {
713        let _ = self.finish();
714    }
715}
716
717#[derive(Debug, Clone, PartialEq, Eq, JsonSchema)]
718pub enum ProcessRef {
719    Id(usize),
720    Name(String),
721    Unqualified(String),
722}
723
724#[derive(Debug, Clone, Default)]
725pub struct GraphImportOptions {
726    pub process_name: Option<String>,
727    pub process_id: Option<usize>,
728    pub process_definition: Option<ProcessDefinition>,
729    pub integrand_name: Option<String>,
730    pub overwrite: bool,
731    pub append: bool,
732}
733
734impl Serialize for ProcessRef {
735    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
736    where
737        S: serde::Serializer,
738    {
739        match self {
740            ProcessRef::Id(id) => serializer.serialize_u64(*id as u64),
741            ProcessRef::Name(name) => serializer.serialize_str(&format!("name:{name}")),
742            ProcessRef::Unqualified(value) => serializer.serialize_str(value),
743        }
744    }
745}
746
747impl<'de> Deserialize<'de> for ProcessRef {
748    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
749    where
750        D: serde::Deserializer<'de>,
751    {
752        use serde::de::{self, Visitor};
753        use std::fmt;
754
755        struct ProcessRefVisitor;
756
757        impl<'de> Visitor<'de> for ProcessRefVisitor {
758            type Value = ProcessRef;
759
760            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
761                formatter.write_str("a process reference string or numeric id")
762            }
763
764            fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
765            where
766                E: de::Error,
767            {
768                usize::try_from(value)
769                    .map(ProcessRef::Id)
770                    .map_err(E::custom)
771            }
772
773            fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
774            where
775                E: de::Error,
776            {
777                usize::try_from(value)
778                    .map(ProcessRef::Id)
779                    .map_err(E::custom)
780            }
781
782            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
783            where
784                E: de::Error,
785            {
786                ProcessRef::from_str(value).map_err(E::custom)
787            }
788
789            fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
790            where
791                E: de::Error,
792            {
793                self.visit_str(&value)
794            }
795        }
796
797        deserializer.deserialize_any(ProcessRefVisitor)
798    }
799}
800
801#[cfg(feature = "python")]
802impl<'a, 'py> pyo3::FromPyObject<'a, 'py> for ProcessRef {
803    type Error = pyo3::PyErr;
804
805    fn extract(obj: pyo3::Borrowed<'a, 'py, pyo3::types::PyAny>) -> pyo3::PyResult<Self> {
806        if let Ok(process_id) = <usize as pyo3::FromPyObject<'a, 'py>>::extract(obj) {
807            return Ok(ProcessRef::Id(process_id));
808        }
809
810        let selector = <String as pyo3::FromPyObject<'a, 'py>>::extract(obj).map_err(|_| {
811            pyo3::exceptions::PyTypeError::new_err(
812                "process selectors must be either an integer process id or a string selector",
813            )
814        })?;
815        ProcessRef::from_str(&selector).map_err(pyo3::exceptions::PyValueError::new_err)
816    }
817}
818
819impl FromStr for ProcessRef {
820    type Err = String;
821
822    fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
823        if let Some(rest) = value.strip_prefix('#') {
824            let id = rest
825                .parse::<usize>()
826                .map_err(|_| format!("Invalid process id in '{value}'"))?;
827            return Ok(ProcessRef::Id(id));
828        }
829        if let Some(rest) = value.strip_prefix("name:") {
830            if rest.is_empty() {
831                return Err("Process name cannot be empty".to_string());
832            }
833            return Ok(ProcessRef::Name(rest.to_string()));
834        }
835        if value.is_empty() {
836            return Err("Process reference cannot be empty".to_string());
837        }
838        Ok(ProcessRef::Unqualified(value.to_string()))
839    }
840}
841
842#[test]
843fn try_complicated() {
844    "GGHHH3loop_no_iterative_optimization_3L"
845        .parse::<ProcessRef>()
846        .unwrap();
847    // ProcessRef::
848}
849
850impl ProcessRef {
851    pub fn resolve(&self, process_list: &ProcessList) -> Result<usize> {
852        let process_count = process_list.processes.len();
853        match self {
854            ProcessRef::Id(id) => {
855                if *id >= process_count {
856                    return Err(eyre!(
857                        "Process ID {} invalid, only {} processes available",
858                        id,
859                        process_count
860                    ));
861                }
862                Ok(*id)
863            }
864            ProcessRef::Name(name) => process_list
865                .processes
866                .iter()
867                .position(|p| p.definition.folder_name == *name)
868                .ok_or_else(|| {
869                    eyre!(
870                        "No process named '{}'. Use 'display processes' to list available processes.",
871                        name
872                    )
873                }),
874            ProcessRef::Unqualified(value) => {
875                let name_match = process_list
876                    .processes
877                    .iter()
878                    .position(|p| p.definition.folder_name == *value);
879                if let Ok(id) = value.parse::<usize>() {
880                    let id_valid = id < process_count;
881                    match (id_valid, name_match) {
882                        (true, Some(_)) => Err(eyre!(
883                            "Ambiguous process reference '{}'. Use '#{}' or 'name:{}' to disambiguate.",
884                            value,
885                            id,
886                            value
887                        )),
888                        (true, None) => Ok(id),
889                        (false, Some(index)) => Ok(index),
890                        (false, None) => Err(eyre!(
891                            "No process named '{}'. Use 'display processes' to list available processes.",
892                            value
893                        )),
894                    }
895                } else if let Some(index) = name_match {
896                    Ok(index)
897                } else {
898                    Err(eyre!(
899                        "No process named '{}'. Use 'display processes' to list available processes.",
900                        value
901                    ))
902                }
903            }
904        }
905    }
906}
907
908pub trait ProcessListExt {
909    fn find_integrand_ref(
910        &self,
911        process: Option<&ProcessRef>,
912        integrand_name: Option<&String>,
913    ) -> Result<(usize, String)>;
914    fn get_amplitude_mut_ref(
915        &mut self,
916        process: Option<&ProcessRef>,
917        integrand_name: Option<&String>,
918    ) -> Result<&mut Amplitude>;
919    fn get_cross_section_mut_ref(
920        &mut self,
921        process: Option<&ProcessRef>,
922        integrand_name: Option<&String>,
923    ) -> Result<&mut CrossSection>;
924}
925
926impl ProcessListExt for ProcessList {
927    fn find_integrand_ref(
928        &self,
929        process: Option<&ProcessRef>,
930        integrand_name: Option<&String>,
931    ) -> Result<(usize, String)> {
932        let process_id = match process {
933            Some(process_ref) => process_ref.resolve(self)?,
934            None => self.find_process(None)?,
935        };
936        let integrand_name = self.processes[process_id]
937            .collection
938            .find_integrand(integrand_name.cloned())
939            .with_note(|| format!("in process id {process_id}"))?;
940        Ok((process_id, integrand_name))
941    }
942
943    fn get_amplitude_mut_ref(
944        &mut self,
945        process: Option<&ProcessRef>,
946        integrand_name: Option<&String>,
947    ) -> Result<&mut Amplitude> {
948        let (process_id, integrand_name) = self.find_integrand_ref(process, integrand_name)?;
949        let process = &mut self.processes[process_id];
950        match &mut process.collection {
951            ProcessCollection::Amplitudes(amplitudes) => {
952                amplitudes.get_mut(&integrand_name).ok_or_else(|| {
953                    eyre!(
954                        "No amplitude named '{}' in process '{}'",
955                        integrand_name,
956                        process.definition.folder_name
957                    )
958                })
959            }
960            ProcessCollection::CrossSections(_) => Err(eyre!(
961                "Process '{}' does not contain amplitudes",
962                process.definition.folder_name
963            )),
964        }
965    }
966
967    fn get_cross_section_mut_ref(
968        &mut self,
969        process: Option<&ProcessRef>,
970        integrand_name: Option<&String>,
971    ) -> Result<&mut CrossSection> {
972        let (process_id, integrand_name) = self.find_integrand_ref(process, integrand_name)?;
973        let process = &mut self.processes[process_id];
974        match &mut process.collection {
975            ProcessCollection::CrossSections(crosssections) => {
976                crosssections.get_mut(&integrand_name).ok_or_else(|| {
977                    eyre!(
978                        "No cross section named '{}' in process '{}'",
979                        integrand_name,
980                        process.definition.folder_name
981                    )
982                })
983            }
984            ProcessCollection::Amplitudes(_) => Err(eyre!(
985                "Process '{}' does not contain crosssections",
986                process.definition.folder_name
987            )),
988        }
989    }
990}
991
992pub trait SyncSettings {
993    fn sync_settings(&self) -> Result<()>;
994}
995
996impl SyncSettings for CLISettings {
997    fn sync_settings(&self) -> Result<()> {
998        // println!("Syncing settings {}", self.global.logfile_directive);
999        set_file_log_filter(&self.global.logfile_directive)?;
1000        set_stderr_log_filter(&self.global.display_directive)?;
1001        set_log_style(self.global.log_style.to_runtime());
1002        Ok(())
1003    }
1004}
1005
1006// Static flag to control serialization behavior
1007static SERIALIZE_COMMANDS_AS_STRINGS: AtomicBool = AtomicBool::new(false);
1008
1009fn is_command_blocks_empty(command_blocks: &[CommandsBlock]) -> bool {
1010    command_blocks.is_empty()
1011}
1012
1013fn should_persist_command(command: &Commands) -> bool {
1014    !matches!(
1015        command,
1016        Commands::Quit(_) | Commands::StartCommandsBlock(_) | Commands::FinishCommandsBlock
1017    )
1018}
1019
1020/// Set whether CommandHistory should serialize as strings when the raw_string is available
1021pub fn set_serialize_commands_as_strings(value: bool) {
1022    SERIALIZE_COMMANDS_AS_STRINGS.store(value, std::sync::atomic::Ordering::Relaxed);
1023}
1024
1025/// Get the current setting for CommandHistory serialization behavior
1026pub fn get_serialize_commands_as_strings() -> bool {
1027    SERIALIZE_COMMANDS_AS_STRINGS.load(std::sync::atomic::Ordering::Relaxed)
1028}
1029
1030pub struct SerializeCommandsAsStringsGuard {
1031    previous: bool,
1032}
1033
1034impl SerializeCommandsAsStringsGuard {
1035    pub fn new(value: bool) -> Self {
1036        let previous = get_serialize_commands_as_strings();
1037        set_serialize_commands_as_strings(value);
1038        Self { previous }
1039    }
1040}
1041
1042impl Drop for SerializeCommandsAsStringsGuard {
1043    fn drop(&mut self) {
1044        set_serialize_commands_as_strings(self.previous);
1045    }
1046}
1047
1048/// Represents a command with optional raw string representation
1049///
1050/// This struct stores a parsed command or a template awaiting scoped variables,
1051/// plus the original string when available. This preserves the exact user input
1052/// while keeping structured command data accessible. Templates carry their own
1053/// required text in the command variant.
1054#[derive(Debug, Clone, PartialEq)]
1055pub struct CommandHistory {
1056    /// The parsed command or a template awaiting scoped variables
1057    pub command: Commands,
1058    /// The original string representation of the command, if available
1059    pub raw_string: Option<String>,
1060}
1061
1062impl JsonSchema for CommandHistory {
1063    fn schema_name() -> std::borrow::Cow<'static, str> {
1064        "CommandHistory".into()
1065    }
1066
1067    fn json_schema(generator: &mut schemars::SchemaGenerator) -> Schema {
1068        // Serialization exposes the command directly, including deferred template strings.
1069        schemars::json_schema!({
1070            "description": "A raw command string (including scoped templates) or a structured command.",
1071            "anyOf": [
1072                { "type": "string" },
1073                generator.subschema_for::<Commands>()
1074            ]
1075        })
1076    }
1077}
1078
1079impl Serialize for CommandHistory {
1080    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1081    where
1082        S: serde::Serializer,
1083    {
1084        if let Commands::CommandTemplate(raw) = &self.command {
1085            return raw.serialize(serializer);
1086        }
1087
1088        if get_serialize_commands_as_strings() {
1089            if let Some(ref raw_string) = self.raw_string {
1090                raw_string.serialize(serializer)
1091            } else {
1092                self.command.serialize(serializer)
1093            }
1094        } else {
1095            self.command.serialize(serializer)
1096        }
1097    }
1098}
1099
1100impl<'de> Deserialize<'de> for CommandHistory {
1101    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1102    where
1103        D: serde::Deserializer<'de>,
1104    {
1105        use serde::de::{self, Visitor};
1106        use std::fmt;
1107
1108        struct CommandHistoryVisitor;
1109
1110        impl<'de> Visitor<'de> for CommandHistoryVisitor {
1111            type Value = CommandHistory;
1112
1113            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
1114                formatter.write_str("a string or a Commands structure")
1115            }
1116
1117            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
1118            where
1119                E: de::Error,
1120            {
1121                CommandHistory::from_raw_string(value).map_err(|err| {
1122                    E::custom(format!(
1123                        "Failed to parse command string '{}': {}",
1124                        value, err
1125                    ))
1126                })
1127            }
1128
1129            fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
1130            where
1131                E: de::Error,
1132            {
1133                self.visit_str(&value)
1134            }
1135
1136            fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
1137            where
1138                A: de::SeqAccess<'de>,
1139            {
1140                // Handle TOML array format for enums like [Quit]
1141                let command =
1142                    Commands::deserialize(de::value::SeqAccessDeserializer::new(&mut seq))?;
1143                Ok(CommandHistory {
1144                    command,
1145                    raw_string: None,
1146                })
1147            }
1148
1149            fn visit_map<A>(self, map: A) -> Result<Self::Value, A::Error>
1150            where
1151                A: de::MapAccess<'de>,
1152            {
1153                // Handle map format for enums
1154                let command = Commands::deserialize(de::value::MapAccessDeserializer::new(map))?;
1155                Ok(CommandHistory {
1156                    command,
1157                    raw_string: None,
1158                })
1159            }
1160        }
1161
1162        deserializer.deserialize_any(CommandHistoryVisitor)
1163    }
1164}
1165
1166impl CommandHistory {
1167    /// Create a new CommandHistory with just a command (no raw string)
1168    pub fn new(command: Commands) -> Self {
1169        Self {
1170            command,
1171            raw_string: None,
1172        }
1173    }
1174
1175    /// Create a new CommandHistory with both command and raw string
1176    pub fn new_with_raw(command: Commands, raw_string: String) -> Self {
1177        Self {
1178            command,
1179            raw_string: Some(raw_string),
1180        }
1181    }
1182
1183    pub fn new_template(raw_string: String) -> Self {
1184        Self::new(Commands::CommandTemplate(raw_string))
1185    }
1186
1187    pub fn is_template(&self) -> bool {
1188        matches!(self.command, Commands::CommandTemplate(_))
1189    }
1190
1191    pub fn raw_string(&self) -> Option<&str> {
1192        match &self.command {
1193            Commands::CommandTemplate(raw) => Some(raw),
1194            _ => self.raw_string.as_deref(),
1195        }
1196    }
1197
1198    pub fn semantically_eq(&self, other: &Self) -> bool {
1199        self.command == other.command
1200    }
1201
1202    /// Create a CommandHistory from a command (alias for new)
1203    pub fn from_command(command: Commands) -> Self {
1204        Self::new(command)
1205    }
1206
1207    /// Parse a raw string into a CommandHistory
1208    ///
1209    /// This function attempts to parse the raw string using clap, and if successful,
1210    /// creates a CommandHistory with both the parsed command and the original string.
1211    pub fn from_raw_string(raw_string: &str) -> Result<Self, clap::Error> {
1212        use clap::error::ErrorKind;
1213
1214        let args = split_command_line(raw_string)
1215            .map(normalize_clap_args)
1216            .map_err(|_| {
1217                clap::Error::raw(
1218                    ErrorKind::InvalidValue,
1219                    "Could not parse command: unmatched quotes or trailing escape",
1220                )
1221            })?;
1222
1223        match Self::from_args_and_raw(args, raw_string.to_string()) {
1224            Ok(command) => Ok(command),
1225            Err(_) if contains_placeholder(raw_string) => Ok(Self::new_template(raw_string.into())),
1226            Err(err) => Err(err),
1227        }
1228    }
1229
1230    pub fn from_args_and_raw(args: Vec<String>, raw_string: String) -> Result<Self, clap::Error> {
1231        use crate::Repl;
1232        use clap::Parser;
1233
1234        let cli = Repl::try_parse_from(
1235            std::iter::once("gammaloop").chain(args.iter().map(String::as_str)),
1236        )?;
1237
1238        Ok(Self::new_with_raw(cli.command, raw_string))
1239    }
1240}
1241
1242#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, PartialEq)]
1243#[serde(default, deny_unknown_fields)]
1244pub struct RunHistory {
1245    /// Default runtime settings restored before replaying the recorded commands.
1246    #[serde(skip_serializing_if = "IsDefault::is_default")]
1247    pub default_runtime_settings: RuntimeSettings,
1248
1249    /// State and global CLI settings captured with this run history.
1250    #[serde(skip_serializing_if = "IsDefault::is_default")]
1251    pub cli_settings: CLISettings,
1252
1253    /// Named reusable command sequences declared by run cards.
1254    #[serde(skip_serializing_if = "is_command_blocks_empty")]
1255    pub command_blocks: Vec<CommandsBlock>,
1256    // #[serde(with = "serde_yaml::with::singleton_map_recursive")]
1257    // #[schemars(with = "Vec<CommandHistory>")]
1258    /// Ordered top-level commands persisted for replay.
1259    pub commands: Vec<CommandHistory>,
1260}
1261
1262#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema, PartialEq)]
1263#[serde(default, deny_unknown_fields)]
1264pub struct CommandsBlock {
1265    pub name: String,
1266    pub commands: Vec<CommandHistory>,
1267}
1268
1269#[derive(Debug, Deserialize, Default)]
1270#[serde(default, deny_unknown_fields)]
1271struct RawRunHistoryToml {
1272    default_runtime_settings: RuntimeSettings,
1273    cli_settings: CLISettings,
1274    command_blocks: Vec<RawCommandsBlockToml>,
1275    commands: Vec<TomlValue>,
1276}
1277
1278#[derive(Debug, Deserialize, Default)]
1279#[serde(default, deny_unknown_fields)]
1280struct RawCommandsBlockToml {
1281    name: String,
1282    commands: Vec<TomlValue>,
1283}
1284
1285impl CommandsBlock {
1286    pub fn semantically_eq(&self, other: &Self) -> bool {
1287        self.name == other.name
1288            && self.commands.len() == other.commands.len()
1289            && self
1290                .commands
1291                .iter()
1292                .zip(other.commands.iter())
1293                .all(|(left, right)| left.semantically_eq(right))
1294    }
1295}
1296
1297impl SmartSerde for RunHistory {
1298    fn has_schema_path(&self, online: bool) -> Option<Result<PathBuf>> {
1299        Some(get_schema_folder(online).map(|f| f.join("runhistory.json")))
1300    }
1301}
1302
1303impl RunHistory {
1304    pub fn freeze_boot_settings_from(&mut self, boot_run_history: &RunHistory) {
1305        self.cli_settings.global = boot_run_history.cli_settings.global.clone();
1306        self.default_runtime_settings = boot_run_history.default_runtime_settings.clone();
1307    }
1308
1309    pub fn frozen_boot_settings_match(&self, boot_run_history: &RunHistory) -> bool {
1310        self.cli_settings.global == boot_run_history.cli_settings.global
1311            && self.default_runtime_settings == boot_run_history.default_runtime_settings
1312    }
1313
1314    /// Add a command to the run history
1315    pub fn push(&mut self, command: Commands) {
1316        self.push_with_raw(command, None);
1317    }
1318
1319    /// Add a command with optional raw string to the run history
1320    ///
1321    /// If raw_string is provided, it will be stored alongside the command
1322    /// for potential later serialization as a string.
1323    pub fn push_with_raw(&mut self, command: Commands, raw_string: Option<String>) {
1324        if should_persist_command(&command) {
1325            self.commands.push(CommandHistory {
1326                command,
1327                raw_string,
1328            });
1329        }
1330    }
1331
1332    pub fn schema() -> Schema {
1333        schema_for!(RunHistory)
1334    }
1335
1336    pub fn validate(&self) -> Result<()> {
1337        let mut seen_names = HashSet::with_capacity(self.command_blocks.len());
1338        for block in &self.command_blocks {
1339            if block.name.trim().is_empty() {
1340                return Err(eyre!(
1341                    "Run card `command_blocks` contains a block with an empty name"
1342                ));
1343            }
1344            if !seen_names.insert(block.name.clone()) {
1345                return Err(eyre!(
1346                    "Run card `command_blocks` contains duplicate block name '{}'",
1347                    block.name
1348                ));
1349            }
1350        }
1351        Ok(())
1352    }
1353
1354    pub fn command_block(&self, name: &str) -> Option<&CommandsBlock> {
1355        self.command_blocks.iter().find(|block| block.name == name)
1356    }
1357
1358    pub fn command_block_placeholder_names(&self, name: &str) -> BTreeSet<String> {
1359        self.command_block_placeholder_specs(name)
1360            .into_iter()
1361            .map(|spec| spec.name)
1362            .collect()
1363    }
1364
1365    pub fn command_block_placeholder_specs(&self, name: &str) -> BTreeSet<PlaceholderSpec> {
1366        let mut placeholders = BTreeSet::new();
1367        let mut visited = HashSet::new();
1368        self.collect_command_block_placeholder_specs(name, &mut visited, &mut placeholders);
1369        placeholders
1370    }
1371
1372    fn collect_command_block_placeholder_specs(
1373        &self,
1374        name: &str,
1375        visited: &mut HashSet<String>,
1376        placeholders: &mut BTreeSet<PlaceholderSpec>,
1377    ) {
1378        if !visited.insert(name.to_string()) {
1379            return;
1380        }
1381
1382        let Some(block) = self.command_block(name) else {
1383            return;
1384        };
1385
1386        for command in &block.commands {
1387            if let Some(raw) = command.raw_string() {
1388                placeholders.extend(placeholder_specs(raw));
1389            }
1390            let Commands::Run(run) = &command.command else {
1391                continue;
1392            };
1393            for nested_name in run.selected_block_names() {
1394                let mut nested_placeholders = BTreeSet::new();
1395                self.collect_command_block_placeholder_specs(
1396                    nested_name,
1397                    visited,
1398                    &mut nested_placeholders,
1399                );
1400                for defined in &run.defines {
1401                    nested_placeholders.retain(|spec| spec.name.as_str() != defined.key.as_str());
1402                }
1403                placeholders.extend(nested_placeholders);
1404            }
1405        }
1406        visited.remove(name);
1407    }
1408
1409    pub fn select_command_blocks(
1410        &self,
1411        selected_block_names: &[String],
1412    ) -> Result<Vec<CommandsBlock>> {
1413        let mut selected = Vec::with_capacity(selected_block_names.len());
1414        for name in selected_block_names {
1415            let block = self.command_block(name).ok_or_else(|| {
1416                eyre!(
1417                    "Unknown command block '{}'. Available command blocks: {}",
1418                    name,
1419                    self.command_blocks
1420                        .iter()
1421                        .map(|block| block.name.as_str())
1422                        .collect::<Vec<_>>()
1423                        .join(", ")
1424                )
1425            })?;
1426            selected.push(block.clone());
1427        }
1428        Ok(selected)
1429    }
1430
1431    pub fn conflicting_command_block_names(&self, command_blocks: &[CommandsBlock]) -> Vec<String> {
1432        command_blocks
1433            .iter()
1434            .filter_map(|new_block| match self.command_block(&new_block.name) {
1435                Some(existing_block) if !existing_block.semantically_eq(new_block) => {
1436                    Some(new_block.name.clone())
1437                }
1438                _ => None,
1439            })
1440            .collect()
1441    }
1442
1443    pub fn merge_command_blocks_with_overwrite(
1444        &mut self,
1445        command_blocks: &[CommandsBlock],
1446        overwrite_conflicts: bool,
1447    ) -> Result<()> {
1448        for new_block in command_blocks {
1449            match self
1450                .command_blocks
1451                .iter()
1452                .position(|existing| existing.name == new_block.name)
1453            {
1454                Some(index) if self.command_blocks[index].semantically_eq(new_block) => {}
1455                Some(index) if overwrite_conflicts => {
1456                    self.command_blocks[index] = new_block.clone();
1457                }
1458                Some(_) => {
1459                    return Err(eyre!(
1460                        "Run card command block '{}' redefines an existing block with different commands",
1461                        new_block.name
1462                    ));
1463                }
1464                None => self.command_blocks.push(new_block.clone()),
1465            }
1466        }
1467        self.validate()
1468    }
1469
1470    pub fn merge_command_blocks(&mut self, command_blocks: &[CommandsBlock]) -> Result<()> {
1471        self.merge_command_blocks_with_overwrite(command_blocks, false)
1472    }
1473
1474    pub fn apply_session_settings(
1475        &self,
1476        global_settings: &mut CLISettings,
1477        default_runtime_settings: &mut RuntimeSettings,
1478    ) -> Result<()> {
1479        if self.cli_settings.global != GlobalSettings::default() {
1480            global_settings.global = self.cli_settings.global.clone();
1481            global_settings.sync_settings()?;
1482        }
1483        if self.default_runtime_settings != RuntimeSettings::default() {
1484            *default_runtime_settings = self.default_runtime_settings.clone();
1485        }
1486        Ok(())
1487    }
1488
1489    pub fn run(
1490        &mut self,
1491        state: &mut State,
1492        global_settings: &mut CLISettings,
1493        default_runtime_settings: &mut RuntimeSettings,
1494    ) -> Result<ControlFlow<SaveState>> {
1495        let mut session_state = crate::session::CliSessionState::default();
1496        let mut session = crate::session::CliSession::new(
1497            state,
1498            self,
1499            global_settings,
1500            default_runtime_settings,
1501            &mut session_state,
1502        );
1503        session.replay_run_history()
1504    }
1505
1506    pub(crate) fn filtered_for_save(&self) -> Self {
1507        let mut filtered = self.clone();
1508        filtered.commands.retain(|command_history| {
1509            command_history.is_template() || should_persist_command(&command_history.command)
1510        });
1511        filtered
1512    }
1513
1514    pub(crate) fn to_toml_string(&self, serialize_commands_as_strings: bool) -> Result<String> {
1515        let _serialize_commands_guard =
1516            SerializeCommandsAsStringsGuard::new(serialize_commands_as_strings);
1517        render_smart_toml(&self.filtered_for_save())
1518    }
1519
1520    pub fn load(path: impl AsRef<Path>) -> Result<Self> {
1521        let path = path.as_ref();
1522        debug!("Loaded run history from file {}", path.display());
1523
1524        let runhistory = match path.extension().and_then(|ext| ext.to_str()) {
1525            Some("toml") => Self::load_toml(path)?,
1526            _ => Self::from_file(path, "run history")?,
1527        };
1528        runhistory.validate()?;
1529        Ok(runhistory)
1530    }
1531
1532    pub fn save_toml(
1533        &self,
1534        root_folder: &Path,
1535        override_state_file: bool,
1536        _strict: bool,
1537    ) -> Result<()> {
1538        self.filtered_for_save()
1539            .to_file(root_folder.join("run.toml"), override_state_file)?;
1540
1541        //Self::schema().to_file(root_folder.join("run_schema.json"))?;
1542        Ok(())
1543    }
1544
1545    pub fn save_yaml(
1546        &self,
1547        root_folder: &Path,
1548        override_state_file: bool,
1549        _strict: bool,
1550    ) -> Result<()> {
1551        self.filtered_for_save()
1552            .to_file(root_folder.join("run.yaml"), override_state_file)?;
1553        //Self::schema().to_file(root_folder.join("run_schema.json"))?;
1554        Ok(())
1555    }
1556
1557    fn load_toml(path: &Path) -> Result<Self> {
1558        let raw = fs::read_to_string(path)
1559            .with_context(|| format!("Could not open run history file {}", path.display()))?;
1560        let raw_run_history: RawRunHistoryToml =
1561            toml::from_str(&raw).wrap_err("Error parsing run history toml")?;
1562
1563        let commands = raw_run_history
1564            .commands
1565            .into_iter()
1566            .enumerate()
1567            .map(|(index, value)| {
1568                parse_toml_command_history(value, &format!("top-level command #{}", index + 1))
1569            })
1570            .collect::<Result<Vec<_>>>()?;
1571
1572        let command_blocks = raw_run_history
1573            .command_blocks
1574            .into_iter()
1575            .map(|block| {
1576                let RawCommandsBlockToml { name, commands } = block;
1577                let commands = commands
1578                    .into_iter()
1579                    .enumerate()
1580                    .map(|(index, value)| {
1581                        parse_toml_command_history(
1582                            value,
1583                            &format!("command block '{}' command #{}", name, index + 1),
1584                        )
1585                    })
1586                    .collect::<Result<Vec<_>>>()?;
1587                Ok(CommandsBlock { name, commands })
1588            })
1589            .collect::<Result<Vec<_>>>()?;
1590
1591        Ok(Self {
1592            default_runtime_settings: raw_run_history.default_runtime_settings,
1593            cli_settings: raw_run_history.cli_settings,
1594            command_blocks,
1595            commands,
1596        })
1597    }
1598}
1599
1600fn parse_toml_command_history(value: TomlValue, context: &str) -> Result<CommandHistory> {
1601    match value {
1602        TomlValue::String(raw) => CommandHistory::from_raw_string(&raw)
1603            .map_err(|err| eyre!("Failed to parse {} '{}': {}", context, raw, err)),
1604        other => other
1605            .try_into::<CommandHistory>()
1606            .map_err(|err| eyre!("Failed to parse {}: {}", context, err)),
1607    }
1608}
1609
1610#[cfg_attr(
1611    feature = "python",
1612    pyo3::pyclass(from_py_object, unsendable, name = "GammaLoopState")
1613)]
1614#[derive(Clone)]
1615pub struct State {
1616    /// Active physics model used to interpret processes and integrands.
1617    pub model: Model,
1618    /// Numerical input-parameter values currently applied to the model.
1619    pub model_parameters: InputParamCard<F<f64>>,
1620    /// Imported processes together with their generated integrands.
1621    pub process_list: ProcessList,
1622    /// Generation provenance indexed by process and integrand.
1623    pub generation_summaries: BTreeMap<IntegrandGenerationSummaryKey, IntegrandGenerationSummary>,
1624}
1625
1626const STATE_MANIFEST_FILE: &str = "state_manifest.toml";
1627const INTEGRAND_GENERATION_SUMMARY_FILE: &str = "generation_summary.json";
1628// Version 15 gives each orientation-specific Fermi sector a one-entry evaluator
1629// catalogue indexed locally from zero. Older full-catalogue sectors must be regenerated.
1630// Version 14 stores one source-frame sign per generated CFF and rational component,
1631// replacing the duplicate core/denominator signs in the positional bincode layout.
1632// Version 13 persists the selected medium in graph parameter builders. Amplitude graph
1633// terms store denominator H-surfaces and zero-temperature Fermi-surface evaluators.
1634// Frozen SymJIT, C++, and assembly modes for amplitudes and cross sections store
1635// CompilationOptionsSnapshot, including jit_direct_translation:
1636// SymJIT replaces its bare optimization level, while C++/assembly snapshots gain the field.
1637// Older positional bincode layouts must be regenerated.
1638// Version 12 names square roots OnShellEnergy and energy sums Esurface. Reject older
1639// states before Symbolica import can assign the new meaning to an old Esurface atom.
1640// Version 11 persists function inlining policies and caller-scope alias metadata.
1641// Version 10 records UFO and subgraph printer registrations, including symbols removed
1642// from a restricted model. Older archives cannot restore those callbacks and must be regenerated.
1643// Version 9 combines the Symbolica 3 evaluator/CFF payloads with advanced sampling
1644// and multiplier layouts. Earlier states must be regenerated.
1645// Version 8 adds multiplier function-map metadata and on-shell energy inputs to
1646// the positional bincode layouts. Older generated states must be regenerated.
1647// Version 7 stores CFF coefficients using native Rational encoding.
1648// Version 7 stores master-topology amplitude metadata and removes the coerced
1649// common-parent LU subspaces. Older generated states must be regenerated.
1650// Version 6 removes obsolete deferred-integrand fields from the positional
1651// amplitude and cut-integrand layouts.
1652// Version 5 persists component-local generated-CFF ownership and prefactor
1653// metadata. Older states use a previous positional bincode layout and must be
1654// regenerated rather than decoded as the new expression type.
1655// Local UV forest contributions remain compatible with this layout because
1656// they are combined into one persisted integrand before evaluator construction.
1657const CURRENT_STATE_MANIFEST_VERSION: u32 = 15;
1658const GENERATION_THREAD_STACK_SIZE_BYTES: usize = 32 * 1024 * 1024;
1659
1660#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1661#[serde(deny_unknown_fields)]
1662struct StateManifest {
1663    version: u32,
1664    #[serde(default)]
1665    ufo_print_symbols: Vec<String>,
1666    #[serde(default)]
1667    linnet_subgraph_print_labels: Vec<String>,
1668}
1669
1670impl Default for StateManifest {
1671    fn default() -> Self {
1672        Self {
1673            version: CURRENT_STATE_MANIFEST_VERSION,
1674            ufo_print_symbols: Vec::new(),
1675            linnet_subgraph_print_labels: Vec::new(),
1676        }
1677    }
1678}
1679
1680fn ensure_supported_state_manifest_version(manifest: &StateManifest) -> Result<()> {
1681    if manifest.version < CURRENT_STATE_MANIFEST_VERSION {
1682        return Err(eyre!(
1683            "State version {} predates the supported version {} and cannot be migrated. Please regenerate the saved state with this gammaloop binary.",
1684            manifest.version,
1685            CURRENT_STATE_MANIFEST_VERSION
1686        ));
1687    }
1688    if manifest.version > CURRENT_STATE_MANIFEST_VERSION {
1689        return Err(eyre!(
1690            "State version {} is newer than this binary supports (max {}). Please upgrade gammaloop.",
1691            manifest.version,
1692            CURRENT_STATE_MANIFEST_VERSION
1693        ));
1694    }
1695
1696    Ok(())
1697}
1698
1699fn validate_state_layout(manifest: &StateManifest, save_path: &Path) -> Result<()> {
1700    ensure_supported_state_manifest_version(manifest)?;
1701
1702    match manifest.version {
1703        CURRENT_STATE_MANIFEST_VERSION => {
1704            if !save_path.join("symbolica_state.bin").exists() {
1705                return Err(eyre!(
1706                    "Saved state at '{}' is missing required file symbolica_state.bin",
1707                    save_path.display()
1708                ));
1709            }
1710            if !save_path.join("processes").exists() {
1711                return Err(eyre!(
1712                    "Saved state at '{}' is missing required folder processes/",
1713                    save_path.display()
1714                ));
1715            }
1716            if !save_path.join("model.json").exists() {
1717                return Err(eyre!(
1718                    "Saved state at '{}' is missing required file model.json",
1719                    save_path.display()
1720                ));
1721            }
1722            Ok(())
1723        }
1724        _ => Err(eyre!(
1725            "State version {} is not supported by this binary.",
1726            manifest.version
1727        )),
1728    }
1729}
1730
1731#[derive(Debug, Clone, PartialEq, Eq)]
1732pub enum StateFolderKind {
1733    /// The configured state path does not exist.
1734    Missing,
1735    /// The state directory is empty apart from its optional `logs` directory.
1736    Scratch,
1737    /// The directory contains data but no state manifest.
1738    Unmanifested,
1739    /// The directory contains a supported manifest and all required state files.
1740    Saved,
1741    /// The path cannot be used as a saved state; the payload explains why.
1742    Invalid(String),
1743}
1744
1745fn is_scratch_state_entry(entry: &fs::DirEntry) -> bool {
1746    entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false)
1747        && entry.file_name().to_string_lossy() == "logs"
1748}
1749
1750pub fn classify_state_folder(save_path: &Path) -> Result<StateFolderKind> {
1751    if !save_path.exists() {
1752        return Ok(StateFolderKind::Missing);
1753    }
1754    if !save_path.is_dir() {
1755        return Ok(StateFolderKind::Invalid(format!(
1756            "'{}' exists but is not a directory",
1757            save_path.display()
1758        )));
1759    }
1760
1761    let manifest_path = save_path.join(STATE_MANIFEST_FILE);
1762    if manifest_path.exists() {
1763        let manifest = load_state_manifest(save_path)?;
1764        return Ok(match validate_state_layout(&manifest, save_path) {
1765            Ok(()) => StateFolderKind::Saved,
1766            Err(err) => StateFolderKind::Invalid(err.to_string()),
1767        });
1768    }
1769
1770    let mut entries = fs::read_dir(save_path)
1771        .with_context(|| format!("Trying to read state folder '{}'", save_path.display()))?;
1772    if entries.by_ref().all(|entry| {
1773        entry
1774            .map(|entry| is_scratch_state_entry(&entry))
1775            .unwrap_or(false)
1776    }) {
1777        return Ok(StateFolderKind::Scratch);
1778    }
1779
1780    Ok(StateFolderKind::Unmanifested)
1781}
1782
1783fn load_state_manifest(save_path: &Path) -> Result<StateManifest> {
1784    let manifest_path = save_path.join(STATE_MANIFEST_FILE);
1785    let raw_manifest = fs::read_to_string(&manifest_path).with_context(|| {
1786        format!(
1787            "Trying to read state manifest file {}",
1788            manifest_path.display()
1789        )
1790    })?;
1791    let manifest = toml::from_str::<StateManifest>(&raw_manifest).map_err(|error| {
1792        eyre!(
1793            "Trying to parse state manifest file {}: {error}",
1794            manifest_path.display()
1795        )
1796    })?;
1797    ensure_supported_state_manifest_version(&manifest)?;
1798    Ok(manifest)
1799}
1800
1801fn save_state_manifest(save_path: &Path) -> Result<()> {
1802    // Symbolica exports the entire symbol registry, including couplings removed from the
1803    // active model by restrictions. Its archive cannot serialize their print callbacks.
1804    let mut ufo_print_symbols: Vec<_> = symbolica::state::State::symbol_iter()
1805        .filter(|(symbol, _)| {
1806            symbol.get_namespace() == "UFO" && symbol.get_print_function().is_some()
1807        })
1808        .map(|(symbol, _)| symbol.get_stripped_name().to_owned())
1809        .collect();
1810    ufo_print_symbols.sort_unstable();
1811    let mut linnet_subgraph_print_labels: Vec<_> = symbolica::state::State::symbol_iter()
1812        .filter(|(symbol, _)| {
1813            symbol.get_namespace() == "linnet" && symbol.get_print_function().is_some()
1814        })
1815        .filter_map(|(symbol, _)| {
1816            symbol
1817                .get_stripped_name()
1818                .strip_prefix("S_")
1819                .map(str::to_owned)
1820        })
1821        .collect();
1822    linnet_subgraph_print_labels.sort_unstable();
1823    let manifest = StateManifest {
1824        ufo_print_symbols,
1825        linnet_subgraph_print_labels,
1826        ..StateManifest::default()
1827    };
1828    let raw_manifest =
1829        toml::to_string_pretty(&manifest).context("Trying to serialize state manifest to TOML")?;
1830    fs::write(save_path.join(STATE_MANIFEST_FILE), raw_manifest).with_context(|| {
1831        format!(
1832            "Trying to write state manifest file {}",
1833            save_path.join(STATE_MANIFEST_FILE).display()
1834        )
1835    })?;
1836    Ok(())
1837}
1838
1839fn integrand_generation_summary_path(
1840    root_folder: &Path,
1841    process: &Process,
1842    integrand_name: &str,
1843) -> PathBuf {
1844    let process_kind_folder = match &process.collection {
1845        ProcessCollection::Amplitudes(_) => "amplitudes",
1846        ProcessCollection::CrossSections(_) => "cross_sections",
1847    };
1848
1849    root_folder
1850        .join("processes")
1851        .join(process_kind_folder)
1852        .join(&process.definition.folder_name)
1853        .join(integrand_name)
1854        .join(INTEGRAND_GENERATION_SUMMARY_FILE)
1855}
1856
1857fn process_kind_folder(root_folder: &Path, process: &Process) -> PathBuf {
1858    let process_kind_folder = match &process.collection {
1859        ProcessCollection::Amplitudes(_) => "amplitudes",
1860        ProcessCollection::CrossSections(_) => "cross_sections",
1861    };
1862
1863    root_folder.join("processes").join(process_kind_folder)
1864}
1865
1866fn process_artifact_folder(root_folder: &Path, process: &Process) -> PathBuf {
1867    process_kind_folder(root_folder, process).join(&process.definition.folder_name)
1868}
1869
1870fn integrand_artifact_folder(
1871    root_folder: &Path,
1872    process: &Process,
1873    integrand_name: &str,
1874) -> PathBuf {
1875    process_artifact_folder(root_folder, process).join(integrand_name)
1876}
1877
1878fn generated_integrand_artifact_path(
1879    root_folder: &Path,
1880    process: &Process,
1881    integrand_name: &str,
1882) -> PathBuf {
1883    integrand_artifact_folder(root_folder, process, integrand_name).join("integrand")
1884}
1885
1886fn ensure_existing_path_under_root(path: &Path, root: &Path, description: &str) -> Result<PathBuf> {
1887    let canonical_root = root
1888        .canonicalize()
1889        .with_context(|| format!("Trying to resolve artifact cleanup root {}", root.display()))?;
1890    let canonical_path = path.canonicalize().with_context(|| {
1891        format!(
1892            "Trying to resolve {} path {} before removal",
1893            description,
1894            path.display()
1895        )
1896    })?;
1897    if !canonical_path.starts_with(&canonical_root) {
1898        return Err(eyre!(
1899            "Refusing to remove {} path {} because it resolves outside {}",
1900            description,
1901            canonical_path.display(),
1902            canonical_root.display()
1903        ));
1904    }
1905    Ok(canonical_path)
1906}
1907
1908fn remove_file_if_exists_under_root(path: &Path, root: &Path, description: &str) -> Result<bool> {
1909    if !path.try_exists().with_context(|| {
1910        format!(
1911            "Trying to check whether {} path {} exists",
1912            description,
1913            path.display()
1914        )
1915    })? {
1916        return Ok(false);
1917    }
1918    let canonical_path = ensure_existing_path_under_root(path, root, description)?;
1919    fs::remove_file(path).with_context(|| {
1920        format!(
1921            "Trying to remove {} path {}",
1922            description,
1923            canonical_path.display()
1924        )
1925    })?;
1926    Ok(true)
1927}
1928
1929fn remove_dir_if_exists_under_root(path: &Path, root: &Path, description: &str) -> Result<bool> {
1930    if !path.try_exists().with_context(|| {
1931        format!(
1932            "Trying to check whether {} path {} exists",
1933            description,
1934            path.display()
1935        )
1936    })? {
1937        return Ok(false);
1938    }
1939    let canonical_path = ensure_existing_path_under_root(path, root, description)?;
1940    fs::remove_dir_all(path).with_context(|| {
1941        format!(
1942            "Trying to remove {} path {}",
1943            description,
1944            canonical_path.display()
1945        )
1946    })?;
1947    Ok(true)
1948}
1949
1950fn remove_saved_process_artifacts(root_folder: &Path, process: &Process) -> Result<bool> {
1951    let root = process_kind_folder(root_folder, process);
1952    let path = process_artifact_folder(root_folder, process);
1953    remove_dir_if_exists_under_root(&path, &root, "saved process artifacts")
1954}
1955
1956fn remove_saved_integrand_artifacts(
1957    root_folder: &Path,
1958    process: &Process,
1959    integrand_name: &str,
1960) -> Result<bool> {
1961    let root = process_artifact_folder(root_folder, process);
1962    let path = integrand_artifact_folder(root_folder, process, integrand_name);
1963    remove_dir_if_exists_under_root(&path, &root, "saved integrand artifacts")
1964}
1965
1966fn validate_output_name(value: &str, flag_name: &str) -> Result<()> {
1967    if value.trim().is_empty() {
1968        return Err(eyre!("{flag_name} must not be empty"));
1969    }
1970    Ok(())
1971}
1972
1973fn rename_process_integrand(integrand: Option<&mut ProcessIntegrand>, new_name: &str) {
1974    let Some(integrand) = integrand else {
1975        return;
1976    };
1977    let _ = integrand.get_mut_settings();
1978    match integrand {
1979        ProcessIntegrand::Amplitude(amplitude) => {
1980            amplitude.data.name = new_name.to_string();
1981        }
1982        ProcessIntegrand::CrossSection(cross_section) => {
1983            cross_section.data.name = new_name.to_string();
1984        }
1985    }
1986}
1987
1988fn load_integrand_generation_summaries(
1989    root_folder: &Path,
1990    process_list: &ProcessList,
1991) -> Result<BTreeMap<IntegrandGenerationSummaryKey, IntegrandGenerationSummary>> {
1992    let mut summaries = BTreeMap::new();
1993
1994    for (process_id, process) in process_list.processes.iter().enumerate() {
1995        for integrand_name in process.collection.get_integrand_names() {
1996            let summary_path =
1997                integrand_generation_summary_path(root_folder, process, integrand_name);
1998            if !summary_path.exists() {
1999                continue;
2000            }
2001
2002            let raw_summary = fs::read_to_string(&summary_path).with_context(|| {
2003                format!(
2004                    "Trying to read integrand generation summary {}",
2005                    summary_path.display()
2006                )
2007            })?;
2008            let mut summary: IntegrandGenerationSummary = serde_json::from_str(&raw_summary)
2009                .with_context(|| {
2010                    format!(
2011                        "Trying to parse integrand generation summary {}",
2012                        summary_path.display()
2013                    )
2014                })?;
2015            // Reports persist the process id from the state that generated them. A
2016            // selective load renumbers the in-memory process list densely, so the
2017            // summary must follow its owning process rather than retain a stale id.
2018            for report in &mut summary.reports {
2019                report.process_id = process_id;
2020            }
2021            summaries.insert(
2022                IntegrandGenerationSummaryKey {
2023                    process_id,
2024                    integrand_name: integrand_name.to_string(),
2025                },
2026                summary,
2027            );
2028        }
2029    }
2030
2031    Ok(summaries)
2032}
2033
2034impl State {
2035    fn overridable_model_parameter_names(&self) -> Vec<String> {
2036        let mut names = self
2037            .model_parameters
2038            .keys()
2039            .map(|symbol| symbol.to_string())
2040            .collect::<Vec<_>>();
2041        names.sort();
2042        names
2043    }
2044
2045    fn resolve_overridable_model_parameter_type(
2046        &self,
2047        parameter_name: &str,
2048    ) -> Result<gammalooprs::model::ParameterType> {
2049        let possibilities = self.overridable_model_parameter_names();
2050        let parameter_type = external_model_parameter_type(&self.model, parameter_name)
2051            .ok_or_else(|| eyre!("No model parameter named '{parameter_name}'"))
2052            .with_note(|| {
2053                format!(
2054                    "Possible model parameters are: {}",
2055                    possibilities.join(", ")
2056                )
2057            })?;
2058
2059        let symbol = UFOSymbol::from(parameter_name);
2060        if !self.model_parameters.contains_key(&symbol) {
2061            return Err(eyre!(
2062                "Model parameter '{parameter_name}' cannot be overridden because it is not present in the shared top-level model_parameters.json"
2063            ))
2064            .with_note(|| format!("Possible model parameters are: {}", possibilities.join(", ")));
2065        }
2066
2067        Ok(parameter_type)
2068    }
2069
2070    pub fn find_generated_integrand_ref_by_name(
2071        &self,
2072        integrand_name: &str,
2073    ) -> Result<(usize, String)> {
2074        let matches = self
2075            .process_list
2076            .processes
2077            .iter()
2078            .enumerate()
2079            .filter_map(|(process_id, process)| {
2080                let found = match &process.collection {
2081                    ProcessCollection::Amplitudes(amplitudes) => amplitudes
2082                        .get(integrand_name)
2083                        .filter(|amplitude| amplitude.integrand.is_some())
2084                        .map(|_| (process_id, process.definition.folder_name.clone())),
2085                    ProcessCollection::CrossSections(cross_sections) => cross_sections
2086                        .get(integrand_name)
2087                        .filter(|cross_section| cross_section.integrand.is_some())
2088                        .map(|_| (process_id, process.definition.folder_name.clone())),
2089                };
2090                found.map(|(process_id, process_name)| {
2091                    (process_id, process_name, integrand_name.to_string())
2092                })
2093            })
2094            .collect::<Vec<_>>();
2095
2096        match matches.as_slice() {
2097            [(process_id, _, canonical_name)] => Ok((*process_id, canonical_name.clone())),
2098            [] => Err(eyre!(
2099                "No generated integrand named '{integrand_name}' was found. Per-integrand model parameters are only supported for generated integrands."
2100            )),
2101            _ => {
2102                let names = matches
2103                    .iter()
2104                    .map(|(process_id, process_name, _)| format!("#{process_id} ({process_name})"))
2105                    .collect::<Vec<_>>();
2106                Err(eyre!(
2107                    "Integrand name '{integrand_name}' is ambiguous across generated integrands"
2108                ))
2109                .with_note(|| format!("Matching processes: {}", names.join(", ")))
2110            }
2111        }
2112    }
2113
2114    pub fn import_model(&mut self, path: impl AsRef<Path>) -> Result<()> {
2115        self.model = Model::from_file(path)?;
2116        Ok(())
2117    }
2118
2119    fn matching_process_ids(&self, process: Option<&ProcessRef>) -> Result<Vec<usize>> {
2120        let Some(process) = process else {
2121            return Ok((0..self.process_list.processes.len()).collect());
2122        };
2123
2124        match process {
2125            ProcessRef::Id(id) => Ok((*id < self.process_list.processes.len())
2126                .then_some(*id)
2127                .into_iter()
2128                .collect()),
2129            ProcessRef::Name(name) => Ok(self
2130                .process_list
2131                .processes
2132                .iter()
2133                .position(|p| p.definition.folder_name == *name)
2134                .into_iter()
2135                .collect()),
2136            ProcessRef::Unqualified(value) => {
2137                let name_match = self
2138                    .process_list
2139                    .processes
2140                    .iter()
2141                    .position(|p| p.definition.folder_name == *value);
2142                if let Ok(id) = value.parse::<usize>() {
2143                    let id_valid = id < self.process_list.processes.len();
2144                    match (id_valid, name_match) {
2145                        (true, Some(_)) => Err(eyre!(
2146                            "Ambiguous process reference '{}'. Use '#{}' or 'name:{}' to disambiguate.",
2147                            value,
2148                            id,
2149                            value
2150                        )),
2151                        (true, None) => Ok(vec![id]),
2152                        (false, Some(index)) => Ok(vec![index]),
2153                        (false, None) => Ok(vec![]),
2154                    }
2155                } else {
2156                    Ok(name_match.into_iter().collect())
2157                }
2158            }
2159        }
2160    }
2161
2162    pub fn remove_selected_integrands(
2163        &mut self,
2164        process: Option<&ProcessRef>,
2165        integrand_name: Option<&str>,
2166    ) -> Result<Vec<RemovedIntegrand>> {
2167        let process_ids = self.matching_process_ids(process)?;
2168        let mut removed = Vec::new();
2169
2170        for process_id in process_ids.into_iter().rev() {
2171            let process_name = self.process_list.processes[process_id]
2172                .definition
2173                .folder_name
2174                .clone();
2175            let integrand_names = {
2176                let collection = &self.process_list.processes[process_id].collection;
2177                match integrand_name {
2178                    Some(name) => collection
2179                        .get_integrand_names()
2180                        .into_iter()
2181                        .find(|candidate| *candidate == name)
2182                        .map(|name| vec![name.to_string()])
2183                        .unwrap_or_default(),
2184                    None => collection
2185                        .get_integrand_names()
2186                        .into_iter()
2187                        .map(str::to_string)
2188                        .collect(),
2189                }
2190            };
2191
2192            if integrand_names.is_empty() {
2193                continue;
2194            }
2195
2196            {
2197                let process_entry = &mut self.process_list.processes[process_id];
2198                for integrand_name in &integrand_names {
2199                    process_entry.collection.remove_integrand(integrand_name)?;
2200                }
2201            }
2202            for integrand_name in &integrand_names {
2203                self.generation_summaries
2204                    .remove(&IntegrandGenerationSummaryKey {
2205                        process_id,
2206                        integrand_name: integrand_name.clone(),
2207                    });
2208            }
2209
2210            let removed_empty_process = self.process_list.processes[process_id]
2211                .collection
2212                .get_integrand_names()
2213                .is_empty();
2214            if removed_empty_process {
2215                self.process_list.processes.remove(process_id);
2216                self.remove_generation_summaries_for_process(process_id);
2217            }
2218
2219            for integrand_name in integrand_names {
2220                removed.push(RemovedIntegrand {
2221                    process_id,
2222                    process_name: process_name.clone(),
2223                    integrand_name,
2224                    removed_empty_process,
2225                });
2226            }
2227        }
2228
2229        removed.reverse();
2230        Ok(removed)
2231    }
2232
2233    pub fn remove_process(&mut self, process: Option<&ProcessRef>) -> Result<RemovedProcess> {
2234        let process_id = self.resolve_process_ref(process)?;
2235        let removed = self.process_list.processes.remove(process_id);
2236        self.remove_generation_summaries_for_process(process_id);
2237        Ok(RemovedProcess {
2238            process_id,
2239            process_name: removed.definition.folder_name,
2240        })
2241    }
2242
2243    pub fn remove_integrand(
2244        &mut self,
2245        process: &ProcessRef,
2246        integrand_name: &str,
2247    ) -> Result<RemovedIntegrand> {
2248        let process_id = process.resolve(&self.process_list)?;
2249        let (process_name, canonical_integrand_name, removed_empty_process) = {
2250            let process_entry = &mut self.process_list.processes[process_id];
2251            let process_name = process_entry.definition.folder_name.clone();
2252            let canonical_integrand_name = process_entry
2253                .collection
2254                .find_integrand(Some(integrand_name.to_string()))?;
2255            process_entry
2256                .collection
2257                .remove_integrand(&canonical_integrand_name)?;
2258            let removed_empty_process = process_entry.collection.get_integrand_names().is_empty();
2259            (
2260                process_name,
2261                canonical_integrand_name,
2262                removed_empty_process,
2263            )
2264        };
2265        self.generation_summaries
2266            .remove(&IntegrandGenerationSummaryKey {
2267                process_id,
2268                integrand_name: canonical_integrand_name.clone(),
2269            });
2270
2271        if removed_empty_process {
2272            self.process_list.processes.remove(process_id);
2273            self.remove_generation_summaries_for_process(process_id);
2274        }
2275
2276        Ok(RemovedIntegrand {
2277            process_id,
2278            process_name,
2279            integrand_name: canonical_integrand_name,
2280            removed_empty_process,
2281        })
2282    }
2283
2284    pub fn resolve_process_ref(&self, process: Option<&ProcessRef>) -> Result<usize> {
2285        match process {
2286            Some(process_ref) => process_ref.resolve(&self.process_list),
2287            None => self.process_list.find_process(None),
2288        }
2289    }
2290
2291    pub fn find_integrand_ref(
2292        &self,
2293        process: Option<&ProcessRef>,
2294        integrand_name: Option<&String>,
2295    ) -> Result<(usize, String)> {
2296        let process_id = self.resolve_process_ref(process)?;
2297        let integrand_name = self.process_list.processes[process_id]
2298            .collection
2299            .find_integrand(integrand_name.cloned())
2300            .with_note(|| format!("in process id {process_id}"))?;
2301        Ok((process_id, integrand_name))
2302    }
2303
2304    pub fn get_integrand_info(
2305        &self,
2306        process: Option<&ProcessRef>,
2307        integrand_name: Option<&String>,
2308    ) -> Result<IntegrandInfo> {
2309        let (process_id, integrand_name) = self.find_integrand_ref(process, integrand_name)?;
2310        collect_integrand_info(self, process_id, &integrand_name)
2311    }
2312
2313    pub fn duplicate_integrand(
2314        &mut self,
2315        process: Option<&ProcessRef>,
2316        integrand_name: Option<&String>,
2317        output_process_name: &str,
2318        output_integrand_name: &str,
2319    ) -> Result<()> {
2320        validate_output_name(output_process_name, "--output_process_name")?;
2321        validate_output_name(output_integrand_name, "--output_integrand_name")?;
2322
2323        let (source_process_id, source_integrand_name) =
2324            self.find_integrand_ref(process, integrand_name)?;
2325        let mut payload =
2326            self.cloned_integrand_payload(source_process_id, &source_integrand_name)?;
2327        payload.rename(output_integrand_name);
2328        self.insert_integrand_copy(
2329            source_process_id,
2330            output_process_name,
2331            output_integrand_name,
2332            payload,
2333            false,
2334            false,
2335            None,
2336            false,
2337        )?;
2338        Ok(())
2339    }
2340
2341    pub fn select_integrand_graph_groups(
2342        &mut self,
2343        process: Option<&ProcessRef>,
2344        integrand_name: Option<&String>,
2345        selection: &GraphGroupSelectionSpec,
2346        target: &GraphGroupSelectionTarget,
2347        context: GraphGroupSelectionContext<'_>,
2348    ) -> Result<SelectedGraphGroups> {
2349        if target.clear_existing_processes && !target.is_copy_mode() {
2350            return Err(eyre!(
2351                "--clear-existing-processes requires --output_process or --output_integrand for `select`"
2352            ));
2353        }
2354
2355        let (source_process_id, source_integrand_name) =
2356            self.find_integrand_ref(process, integrand_name)?;
2357        let (source_process_name, plan, discarded_generated_integrand) = {
2358            let process_entry = &self.process_list.processes[source_process_id];
2359            let process_name = process_entry.definition.folder_name.clone();
2360            match &process_entry.collection {
2361                ProcessCollection::Amplitudes(amplitudes) => {
2362                    if selection.has_raised_cut_rules() {
2363                        return Err(eyre!(
2364                            "Raised-cut signature selection can only be used with cross-section integrands."
2365                        ));
2366                    }
2367                    if selection.mode() == GraphGroupSelectionMode::CrossSectionAmplitudeGraphs {
2368                        return Err(eyre!(
2369                            "`select --amplitude-graphs` can only be used with cross-section integrands."
2370                        ));
2371                    }
2372                    let amplitude = amplitudes.get(&source_integrand_name).ok_or_else(|| {
2373                        eyre!(
2374                            "No amplitude named '{}' in process '{}'",
2375                            source_integrand_name,
2376                            process_name
2377                        )
2378                    })?;
2379                    let plan = amplitude.plan_graph_group_selection(selection)?;
2380                    amplitude.validate_graph_group_selection_plan(&plan)?;
2381                    (process_name, plan, amplitude.integrand.is_some())
2382                }
2383                ProcessCollection::CrossSections(cross_sections) => {
2384                    let cross_section =
2385                        cross_sections.get(&source_integrand_name).ok_or_else(|| {
2386                            eyre!(
2387                                "No cross section named '{}' in process '{}'",
2388                                source_integrand_name,
2389                                process_name
2390                            )
2391                        })?;
2392                    let plan = cross_section.plan_graph_group_selection_with_context(
2393                        selection,
2394                        &self.model,
2395                        &process_entry.definition,
2396                        context.generation_settings,
2397                    )?;
2398                    cross_section.validate_graph_group_selection_plan(&plan)?;
2399                    (process_name, plan, cross_section.integrand.is_some())
2400                }
2401            }
2402        };
2403        let report = plan.report().clone();
2404        let source = SelectionSource {
2405            process_id: source_process_id,
2406            process_name: source_process_name,
2407            integrand_name: source_integrand_name,
2408        };
2409
2410        if target.is_copy_mode() {
2411            return self.select_integrand_graph_groups_to_output(
2412                &source,
2413                &plan,
2414                report,
2415                target,
2416                context.state_folder,
2417                context.read_only_state,
2418            );
2419        }
2420
2421        let mut removed_generated_artifacts = false;
2422        let mut removed_generation_summary = false;
2423        if discarded_generated_integrand {
2424            if context.read_only_state {
2425                return Err(eyre!(
2426                    "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.",
2427                    source.integrand_name,
2428                    source.process_name
2429                ));
2430            }
2431
2432            let process_entry = &self.process_list.processes[source.process_id];
2433            let integrand_artifact_root = integrand_artifact_folder(
2434                context.state_folder,
2435                process_entry,
2436                &source.integrand_name,
2437            );
2438            let generated_artifact_path = generated_integrand_artifact_path(
2439                context.state_folder,
2440                process_entry,
2441                &source.integrand_name,
2442            );
2443            removed_generated_artifacts = remove_dir_if_exists_under_root(
2444                &generated_artifact_path,
2445                &integrand_artifact_root,
2446                "generated integrand artifact",
2447            )?;
2448            let generation_summary_path = integrand_generation_summary_path(
2449                context.state_folder,
2450                process_entry,
2451                &source.integrand_name,
2452            );
2453            removed_generation_summary = remove_file_if_exists_under_root(
2454                &generation_summary_path,
2455                &integrand_artifact_root,
2456                "integrand generation summary",
2457            )?;
2458        }
2459
2460        {
2461            let process_entry = &mut self.process_list.processes[source.process_id];
2462            match &mut process_entry.collection {
2463                ProcessCollection::Amplitudes(amplitudes) => {
2464                    let amplitude =
2465                        amplitudes.get_mut(&source.integrand_name).ok_or_else(|| {
2466                            eyre!(
2467                                "No amplitude named '{}' in process '{}'",
2468                                source.integrand_name,
2469                                source.process_name
2470                            )
2471                        })?;
2472                    amplitude.apply_graph_group_selection(&plan)?;
2473                }
2474                ProcessCollection::CrossSections(cross_sections) => {
2475                    let cross_section =
2476                        cross_sections
2477                            .get_mut(&source.integrand_name)
2478                            .ok_or_else(|| {
2479                                eyre!(
2480                                    "No cross section named '{}' in process '{}'",
2481                                    source.integrand_name,
2482                                    source.process_name
2483                                )
2484                            })?;
2485                    cross_section.apply_graph_group_selection(&plan)?;
2486                }
2487            }
2488        }
2489
2490        if discarded_generated_integrand {
2491            self.generation_summaries
2492                .remove(&IntegrandGenerationSummaryKey {
2493                    process_id: source.process_id,
2494                    integrand_name: source.integrand_name.clone(),
2495                });
2496        }
2497
2498        Ok(SelectedGraphGroups {
2499            source_process_id: source.process_id,
2500            source_process_name: source.process_name.clone(),
2501            source_integrand_name: source.integrand_name.clone(),
2502            process_id: source.process_id,
2503            process_name: source.process_name,
2504            integrand_name: source.integrand_name,
2505            report,
2506            copied_to_output: false,
2507            replaced_existing_target: false,
2508            removed_target_artifacts: false,
2509            discarded_generated_integrand,
2510            removed_generated_artifacts,
2511            removed_generation_summary,
2512        })
2513    }
2514
2515    fn select_integrand_graph_groups_to_output(
2516        &mut self,
2517        source: &SelectionSource,
2518        plan: &GraphGroupSelectionPlan,
2519        report: GraphGroupSelectionReport,
2520        target: &GraphGroupSelectionTarget,
2521        state_folder: &Path,
2522        read_only_state: bool,
2523    ) -> Result<SelectedGraphGroups> {
2524        let output_process_name = target
2525            .output_process_name
2526            .as_deref()
2527            .unwrap_or(&source.process_name);
2528        let output_integrand_name = target
2529            .output_integrand_name
2530            .as_deref()
2531            .unwrap_or(&source.integrand_name);
2532
2533        validate_output_name(output_process_name, "--output_process")?;
2534        validate_output_name(output_integrand_name, "--output_integrand")?;
2535
2536        if output_process_name == source.process_name
2537            && output_integrand_name == source.integrand_name
2538        {
2539            return Err(eyre!(
2540                "Copy-mode select target '{} / {}' is the selected source integrand. Omit --output_process/--output_integrand for in-place selection or choose a different output target.",
2541                output_process_name,
2542                output_integrand_name
2543            ));
2544        }
2545
2546        let target_process_id = self
2547            .process_list
2548            .processes
2549            .iter()
2550            .position(|process| process.definition.folder_name == output_process_name);
2551        let replace_existing_process = target.output_process_name.is_some()
2552            && target.clear_existing_processes
2553            && target_process_id.is_some_and(|process_id| process_id != source.process_id);
2554
2555        let mut payload =
2556            self.cloned_integrand_payload(source.process_id, &source.integrand_name)?;
2557        payload.rename(output_integrand_name);
2558        payload.apply_graph_group_selection(plan)?;
2559
2560        let insertion = self.insert_integrand_copy(
2561            source.process_id,
2562            output_process_name,
2563            output_integrand_name,
2564            payload,
2565            target.clear_existing_processes,
2566            replace_existing_process,
2567            Some(state_folder),
2568            read_only_state,
2569        )?;
2570
2571        Ok(SelectedGraphGroups {
2572            source_process_id: source.process_id,
2573            source_process_name: source.process_name.clone(),
2574            source_integrand_name: source.integrand_name.clone(),
2575            process_id: insertion.process_id,
2576            process_name: insertion.process_name,
2577            integrand_name: insertion.integrand_name,
2578            report,
2579            copied_to_output: true,
2580            replaced_existing_target: insertion.replaced_existing_target,
2581            removed_target_artifacts: insertion.removed_target_artifacts,
2582            discarded_generated_integrand: false,
2583            removed_generated_artifacts: false,
2584            removed_generation_summary: false,
2585        })
2586    }
2587
2588    fn cloned_integrand_payload(
2589        &self,
2590        source_process_id: usize,
2591        source_integrand_name: &str,
2592    ) -> Result<IntegrandCopyPayload> {
2593        let source_process = &self.process_list.processes[source_process_id];
2594        match &source_process.collection {
2595            ProcessCollection::Amplitudes(amplitudes) => amplitudes
2596                .get(source_integrand_name)
2597                .cloned()
2598                .map(IntegrandCopyPayload::Amplitude)
2599                .ok_or_else(|| eyre!("Missing source amplitude '{}'", source_integrand_name)),
2600            ProcessCollection::CrossSections(cross_sections) => cross_sections
2601                .get(source_integrand_name)
2602                .cloned()
2603                .map(IntegrandCopyPayload::CrossSection)
2604                .ok_or_else(|| eyre!("Missing source cross section '{}'", source_integrand_name)),
2605        }
2606    }
2607
2608    #[allow(clippy::too_many_arguments)]
2609    fn insert_integrand_copy(
2610        &mut self,
2611        source_process_id: usize,
2612        output_process_name: &str,
2613        output_integrand_name: &str,
2614        payload: IntegrandCopyPayload,
2615        clear_existing_processes: bool,
2616        replace_existing_process: bool,
2617        state_folder: Option<&Path>,
2618        read_only_state: bool,
2619    ) -> Result<IntegrandCopyInsertion> {
2620        if let Some(destination_process_id) = self
2621            .process_list
2622            .processes
2623            .iter()
2624            .position(|process| process.definition.folder_name == output_process_name)
2625        {
2626            if replace_existing_process {
2627                if read_only_state {
2628                    return Err(eyre!(
2629                        "Cannot overwrite output process '{}' because this session was started with --read-only-state.",
2630                        output_process_name
2631                    ));
2632                }
2633                let removed_target_artifacts = if let Some(state_folder) = state_folder {
2634                    remove_saved_process_artifacts(
2635                        state_folder,
2636                        &self.process_list.processes[destination_process_id],
2637                    )?
2638                } else {
2639                    false
2640                };
2641                self.remove_generation_summaries_for_process_without_shifting(
2642                    destination_process_id,
2643                );
2644                let process = self.single_integrand_process(
2645                    source_process_id,
2646                    destination_process_id,
2647                    output_process_name,
2648                    payload,
2649                );
2650                self.process_list.processes[destination_process_id] = process;
2651                return Ok(IntegrandCopyInsertion {
2652                    process_id: destination_process_id,
2653                    process_name: output_process_name.to_string(),
2654                    integrand_name: output_integrand_name.to_string(),
2655                    replaced_existing_target: true,
2656                    removed_target_artifacts,
2657                });
2658            }
2659
2660            let target_exists = self.process_list.processes[destination_process_id]
2661                .collection
2662                .get_integrand_names()
2663                .contains(&output_integrand_name);
2664            if target_exists {
2665                if !clear_existing_processes {
2666                    return Err(eyre!(
2667                        "An integrand '{}' already exists in process '{}'",
2668                        output_integrand_name,
2669                        output_process_name
2670                    ));
2671                }
2672                if read_only_state {
2673                    return Err(eyre!(
2674                        "Cannot overwrite output integrand '{}' in process '{}' because this session was started with --read-only-state.",
2675                        output_integrand_name,
2676                        output_process_name
2677                    ));
2678                }
2679            }
2680
2681            if !payload
2682                .is_compatible_with(&self.process_list.processes[destination_process_id].collection)
2683            {
2684                return Err(eyre!(
2685                    "Destination process '{}' exists but does not contain {}",
2686                    output_process_name,
2687                    payload.kind_name()
2688                ));
2689            }
2690
2691            let removed_target_artifacts = if target_exists {
2692                if let Some(state_folder) = state_folder {
2693                    remove_saved_integrand_artifacts(
2694                        state_folder,
2695                        &self.process_list.processes[destination_process_id],
2696                        output_integrand_name,
2697                    )?
2698                } else {
2699                    false
2700                }
2701            } else {
2702                false
2703            };
2704
2705            if target_exists {
2706                self.generation_summaries
2707                    .remove(&IntegrandGenerationSummaryKey {
2708                        process_id: destination_process_id,
2709                        integrand_name: output_integrand_name.to_string(),
2710                    });
2711            }
2712
2713            payload.insert_into_process(
2714                &mut self.process_list.processes[destination_process_id],
2715                output_process_name,
2716            )?;
2717            Ok(IntegrandCopyInsertion {
2718                process_id: destination_process_id,
2719                process_name: output_process_name.to_string(),
2720                integrand_name: output_integrand_name.to_string(),
2721                replaced_existing_target: target_exists,
2722                removed_target_artifacts,
2723            })
2724        } else {
2725            let process_id = self.process_list.processes.len();
2726            let process = self.single_integrand_process(
2727                source_process_id,
2728                process_id,
2729                output_process_name,
2730                payload,
2731            );
2732            self.process_list.add_process(process);
2733            Ok(IntegrandCopyInsertion {
2734                process_id,
2735                process_name: output_process_name.to_string(),
2736                integrand_name: output_integrand_name.to_string(),
2737                replaced_existing_target: false,
2738                removed_target_artifacts: false,
2739            })
2740        }
2741    }
2742
2743    fn single_integrand_process(
2744        &self,
2745        source_process_id: usize,
2746        process_id: usize,
2747        process_name: &str,
2748        payload: IntegrandCopyPayload,
2749    ) -> Process {
2750        let source_process = &self.process_list.processes[source_process_id];
2751        let mut definition = source_process.definition.clone();
2752        definition.folder_name = process_name.to_string();
2753        definition.process_id = process_id;
2754        Process {
2755            definition,
2756            settings_history: source_process.settings_history.clone(),
2757            collection: payload.into_collection(),
2758        }
2759    }
2760
2761    pub fn resolve_effective_model_parameter_card_for_settings(
2762        &self,
2763        settings: &RuntimeSettings,
2764    ) -> Result<InputParamCard<F<f64>>> {
2765        let mut card = self.model_parameters.clone();
2766
2767        for (parameter_name, value) in &settings.model.external_parameters {
2768            let parameter_type = self.resolve_overridable_model_parameter_type(parameter_name)?;
2769            let value = Complex::new(value.0, value.1);
2770            validate_model_parameter_type(parameter_name, parameter_type, &value)?;
2771
2772            let parameter = card
2773                .get_mut(&UFOSymbol::from(parameter_name.as_str()))
2774                .ok_or_else(|| {
2775                    eyre!(
2776                        "Model parameter '{parameter_name}' is missing from the shared top-level model_parameters.json"
2777                    )
2778                })?;
2779            *parameter = value;
2780        }
2781
2782        Ok(card)
2783    }
2784
2785    pub fn resolve_serializable_model_parameter_card_for_settings(
2786        &self,
2787        settings: &RuntimeSettings,
2788    ) -> Result<SerializableInputParamCard<F<f64>>> {
2789        Ok(self
2790            .resolve_effective_model_parameter_card_for_settings(settings)?
2791            .to_serializable())
2792    }
2793
2794    pub fn resolve_effective_model_parameter_card_for_integrand(
2795        &self,
2796        process_id: usize,
2797        integrand_name: &str,
2798    ) -> Result<SerializableInputParamCard<F<f64>>> {
2799        let resolved = self
2800            .process_list
2801            .get_integrand(process_id, integrand_name)?;
2802        match resolved.get_settings() {
2803            Some(settings) => self.resolve_serializable_model_parameter_card_for_settings(settings),
2804            None => Ok(self.model_parameters.to_serializable()),
2805        }
2806    }
2807
2808    pub fn resolve_model_for_settings(&self, settings: &RuntimeSettings) -> Result<Model> {
2809        let mut model = self.model.clone();
2810        self.resolve_effective_model_parameter_card_for_settings(settings)?
2811            .apply_to_model(&mut model)?;
2812        Ok(model)
2813    }
2814
2815    pub fn resolve_model_for_integrand(
2816        &self,
2817        process_id: usize,
2818        integrand_name: &str,
2819    ) -> Result<Model> {
2820        let resolved = self
2821            .process_list
2822            .get_integrand(process_id, integrand_name)?;
2823        match resolved.get_settings() {
2824            Some(settings) => self.resolve_model_for_settings(settings),
2825            None => Ok(self.model.clone()),
2826        }
2827    }
2828
2829    pub fn generate_integrands(
2830        &mut self,
2831        global_settings: &GlobalSettings,
2832        runtime_default: LockedRuntimeSettings,
2833    ) -> Result<GenerationReports> {
2834        self.run_generation_with_monitor(global_settings, move |state, generation_pool| {
2835            let mut reports = state.process_list.preprocess(
2836                &state.model,
2837                global_settings,
2838                &runtime_default,
2839                generation_pool,
2840            )?;
2841            merge_generated_graph_reports(
2842                &mut reports,
2843                state.process_list.generate_integrands(
2844                    &state.model,
2845                    global_settings,
2846                    runtime_default,
2847                    generation_pool,
2848                )?,
2849            );
2850            Ok(reports)
2851        })
2852    }
2853
2854    fn attach_process_id_to_named_reports(
2855        process_id: usize,
2856        reports: Vec<NamedGraphGenerationReport>,
2857    ) -> Vec<GeneratedGraphReport> {
2858        reports
2859            .into_iter()
2860            .map(|report| GeneratedGraphReport {
2861                process_id,
2862                integrand_name: report.integrand_name,
2863                graph_name: report.graph_name,
2864                stats: report.stats,
2865            })
2866            .collect()
2867    }
2868
2869    pub fn generate_integrand(
2870        &mut self,
2871        global_settings: &GlobalSettings,
2872        runtime_default: LockedRuntimeSettings,
2873        process_id: usize,
2874        integrand_name: Option<String>,
2875    ) -> Result<GenerationReports> {
2876        self.run_generation_with_monitor(global_settings, move |state, generation_pool| {
2877            let p = &mut state.process_list.processes[process_id];
2878            let process_name = p.definition.folder_name.clone();
2879            if let Some(name) = &integrand_name {
2880                let _ = p.get_integrand(name)?;
2881                // A process has one shared generation history. Do not relabel
2882                // a generated sibling with settings which only this target uses.
2883                for other in p.get_integrand_names() {
2884                    if other != name.as_str()
2885                        && p.get_integrand(other)?.integrand.is_some()
2886                        && p.settings_history.as_ref().is_none_or(|history| {
2887                            history.generation != global_settings.generation
2888                        })
2889                    {
2890                        return Err(eyre!(
2891                            "Cannot generate only integrand '{}' in process '{}' with absent or different shared generation settings while sibling '{}' is already generated; regenerate the whole process without --integrand-name",
2892                            name, process_name, other
2893                        ));
2894                    }
2895                }
2896                let mut reports = Vec::new();
2897                match &mut p.collection {
2898                    ProcessCollection::Amplitudes(a) => {
2899                        if let Some(a) = a.get_mut(name) {
2900                            begin_phase(
2901                                GenerationProgressPhase::GraphPreprocessing,
2902                                GenerationProcessKind::Amplitude,
2903                                &process_name,
2904                                &a.name,
2905                                a.graphs.len(),
2906                                None,
2907                            );
2908                            merge_generated_graph_reports(
2909                                &mut reports,
2910                                Self::attach_process_id_to_named_reports(
2911                                    process_id,
2912                                    a.preprocess(
2913                                        &state.model,
2914                                        &global_settings.generation,
2915                                        &runtime_default,
2916                                        generation_pool,
2917                                    )?,
2918                                ),
2919                            );
2920                            merge_generated_graph_reports(
2921                                &mut reports,
2922                                Self::attach_process_id_to_named_reports(
2923                                    process_id,
2924                                    a.build_integrand(
2925                                        &state.model,
2926                                        &process_name,
2927                                        global_settings,
2928                                        runtime_default,
2929                                        generation_pool,
2930                                    )?,
2931                                ),
2932                            );
2933                        } else {
2934                            return Err(eyre!(
2935                                "No amplitude named '{}' in process id {}",
2936                                name,
2937                                process_id
2938                            ));
2939                        }
2940                    }
2941                    ProcessCollection::CrossSections(cs) => {
2942                        if let Some(cs) = cs.get_mut(name) {
2943                            merge_generated_graph_reports(
2944                                &mut reports,
2945                                Self::attach_process_id_to_named_reports(
2946                                    process_id,
2947                                    cs.preprocess(
2948                                        &state.model,
2949                                        &p.definition,
2950                                        &global_settings.generation,
2951                                        runtime_default,
2952                                        generation_pool,
2953                                    )?,
2954                                ),
2955                            );
2956                            merge_generated_graph_reports(
2957                                &mut reports,
2958                                Self::attach_process_id_to_named_reports(
2959                                    process_id,
2960                                    cs.build_integrand(
2961                                        &state.model,
2962                                        &p.definition,
2963                                        global_settings,
2964                                        runtime_default,
2965                                        generation_pool,
2966                                    )?,
2967                                ),
2968                            );
2969                        } else {
2970                            return Err(eyre!(
2971                                "No cross section named '{}' in process id {}",
2972                                name,
2973                                process_id
2974                            ));
2975                        }
2976                    }
2977                }
2978                // Named generation bypasses Process::preprocess, which normally
2979                // records the settings required by persistent source exports.
2980                p.settings_history = Some(global_settings.clone());
2981                Ok(reports)
2982            } else {
2983                let mut reports = p.preprocess(
2984                    &state.model,
2985                    global_settings,
2986                    &runtime_default,
2987                    generation_pool,
2988                )?;
2989                merge_generated_graph_reports(
2990                    &mut reports,
2991                    p.generate_integrands(
2992                        &state.model,
2993                        global_settings,
2994                        runtime_default,
2995                        generation_pool,
2996                    )?,
2997                );
2998                Ok(reports)
2999            }
3000        })
3001    }
3002
3003    fn run_generation_with_monitor<F>(
3004        &mut self,
3005        global_settings: &GlobalSettings,
3006        generation: F,
3007    ) -> Result<GenerationReports>
3008    where
3009        F: FnOnce(&mut Self, &rayon::ThreadPool) -> Result<Vec<GeneratedGraphReport>>,
3010    {
3011        let generation_pool = rayon::ThreadPoolBuilder::new()
3012            .num_threads(global_settings.n_cores.generate)
3013            .stack_size(GENERATION_THREAD_STACK_SIZE_BYTES)
3014            .build()?;
3015        let generation_cores = generation_pool.current_num_threads();
3016        clear_interrupt_request();
3017        let mut monitor = GenerationMonitor::start()?;
3018        let stderr_is_terminal = io::stderr().is_terminal();
3019        let progress_mode = if generation_cores == 1 && stderr_is_terminal {
3020            GenerationProgressMode::Detailed
3021        } else {
3022            GenerationProgressMode::Aggregate
3023        };
3024        let _progress_mode_guard = GenerationProgressModeGuard::set(progress_mode);
3025        let _progress_observer_guard = if generation_cores > 1 && stderr_is_terminal {
3026            let reporter = AggregateGenerationProgressReporter::new(
3027                monitor.current_ram_bytes(),
3028                monitor.peak_ram_bytes(),
3029                generation_cores as u64,
3030            );
3031            Some(GenerationProgressObserverGuard::set(reporter))
3032        } else {
3033            None
3034        };
3035        let generation_result = generation(self, &generation_pool);
3036        let peak_ram_bytes = monitor.finish();
3037        clear_interrupt_request();
3038
3039        generation_result.map(|reports| GenerationReports {
3040            reports,
3041            resources: GenerationResourceSummary {
3042                peak_ram_bytes,
3043                generation_cores,
3044            },
3045        })
3046    }
3047
3048    pub fn compile_integrands(
3049        &mut self,
3050        folder: impl AsRef<Path>,
3051        override_existing: bool,
3052        global_settings: &GlobalSettings,
3053        process_id: Option<usize>,
3054        integrand_name: Option<String>,
3055    ) -> Result<Vec<GeneratedGraphReport>> {
3056        let compile_pool = rayon::ThreadPoolBuilder::new()
3057            .num_threads(global_settings.n_cores.compile)
3058            .build()?;
3059        self.process_list.compile(
3060            folder,
3061            override_existing,
3062            process_id,
3063            integrand_name,
3064            &compile_pool,
3065        )
3066    }
3067
3068    pub fn generation_summary(
3069        &self,
3070        process_id: usize,
3071        integrand_name: &str,
3072    ) -> Option<&IntegrandGenerationSummary> {
3073        self.generation_summaries
3074            .get(&IntegrandGenerationSummaryKey {
3075                process_id,
3076                integrand_name: integrand_name.to_string(),
3077            })
3078    }
3079
3080    pub fn record_generation_summary(
3081        &mut self,
3082        reports: &[GeneratedGraphReport],
3083        resources: GenerationResourceSummary,
3084    ) {
3085        let mut reports_by_integrand: BTreeMap<
3086            IntegrandGenerationSummaryKey,
3087            Vec<GeneratedGraphReport>,
3088        > = BTreeMap::new();
3089
3090        for report in reports {
3091            let Some(process) = self.process_list.processes.get(report.process_id) else {
3092                continue;
3093            };
3094            if !process
3095                .collection
3096                .get_integrand_names()
3097                .contains(&report.integrand_name.as_str())
3098            {
3099                continue;
3100            }
3101
3102            reports_by_integrand
3103                .entry(IntegrandGenerationSummaryKey {
3104                    process_id: report.process_id,
3105                    integrand_name: report.integrand_name.clone(),
3106                })
3107                .or_default()
3108                .push(report.clone());
3109        }
3110
3111        for (key, reports) in reports_by_integrand {
3112            self.generation_summaries.insert(
3113                key,
3114                IntegrandGenerationSummary {
3115                    peak_ram_bytes: resources.peak_ram_bytes,
3116                    reports,
3117                },
3118            );
3119        }
3120    }
3121
3122    fn remove_generation_summaries_for_process(&mut self, process_id: usize) {
3123        let mut updated = BTreeMap::new();
3124        for (mut key, mut summary) in std::mem::take(&mut self.generation_summaries) {
3125            if key.process_id == process_id {
3126                continue;
3127            }
3128            if key.process_id > process_id {
3129                key.process_id -= 1;
3130                for report in &mut summary.reports {
3131                    if report.process_id > process_id {
3132                        report.process_id -= 1;
3133                    }
3134                }
3135            }
3136            updated.insert(key, summary);
3137        }
3138        self.generation_summaries = updated;
3139    }
3140
3141    fn remove_generation_summaries_for_process_without_shifting(&mut self, process_id: usize) {
3142        self.generation_summaries
3143            .retain(|key, _| key.process_id != process_id);
3144    }
3145
3146    pub fn export_dots(
3147        &mut self,
3148        path: impl AsRef<Path>,
3149        settings: &DotExportSettings,
3150    ) -> Result<()> {
3151        self.process_list.export_dot(path, settings)?;
3152        Ok(())
3153    }
3154
3155    pub fn import_graphs(&mut self, graphs: Vec<Graph>, options: GraphImportOptions) -> Result<()> {
3156        let GraphImportOptions {
3157            process_name,
3158            process_id,
3159            process_definition,
3160            integrand_name,
3161            overwrite,
3162            append,
3163        } = options;
3164        let generation_type = Self::infer_graph_list_generation_type(&graphs)?;
3165        if let Some(definition) = &process_definition {
3166            if definition.generation_type != generation_type {
3167                return Err(eyre!(
3168                    "--process-spec describes a {} process, but the imported graph list is {}",
3169                    definition.generation_type,
3170                    generation_type
3171                ));
3172            }
3173        }
3174
3175        let integrand_base_name = integrand_name.clone().unwrap_or("default".to_string());
3176        let process = if let Some(proc_id) = process_id {
3177            if proc_id >= self.process_list.processes.len() {
3178                return Err(eyre!(
3179                    "Process ID {} invalid, only {} processes available",
3180                    proc_id,
3181                    self.process_list.processes.len()
3182                ));
3183            }
3184            Some(&mut self.process_list.processes[proc_id])
3185        } else {
3186            let p_name = match process_name {
3187                Some(n) => n,
3188                None => {
3189                    return Err(eyre!(
3190                        "Either process ID or process name must be provided when importing graphs"
3191                    ));
3192                }
3193            };
3194            if let Some(existing_proc) = self
3195                .process_list
3196                .processes
3197                .iter_mut()
3198                .find(|p| p.definition.folder_name == p_name)
3199            {
3200                Some(existing_proc)
3201            } else {
3202                let mut process_definition = match process_definition.clone() {
3203                    Some(definition) => definition,
3204                    None => {
3205                        ProcessDefinition::from_graph_list(&graphs, generation_type, &self.model)?
3206                    }
3207                };
3208                process_definition.process_id = self.process_list.processes.len();
3209                let process = Process::from_graph_list(
3210                    p_name,
3211                    integrand_base_name.clone(),
3212                    // TODO: avoid clone here
3213                    graphs.clone(),
3214                    generation_type,
3215                    Some(process_definition),
3216                    None,
3217                    &self.model,
3218                )?;
3219
3220                self.process_list.add_process(process);
3221                None
3222            }
3223        };
3224        if let Some(p) = process {
3225            if let Some(mut imported_definition) = process_definition {
3226                imported_definition.folder_name = p.definition.folder_name.clone();
3227                imported_definition.process_id = p.definition.process_id;
3228                if imported_definition != p.definition {
3229                    return Err(eyre!(
3230                        "--process-spec does not match existing process '{}'. Import the graphs into a new process or use the same process specification that created this process.",
3231                        p.definition.folder_name
3232                    ));
3233                }
3234            }
3235
3236            let existing_names = p.get_integrand_names();
3237            let integrand_name = if existing_names.contains(&integrand_base_name.as_str()) {
3238                if append {
3239                    let mut integrand_i = 0;
3240                    while existing_names
3241                        .iter()
3242                        .any(|ce| *ce == format!("{}_{}", integrand_base_name, integrand_i))
3243                    {
3244                        integrand_i += 1;
3245                    }
3246                    format!("{}_{}", integrand_base_name, integrand_i)
3247                } else if overwrite {
3248                    p.collection.remove_integrand(&integrand_base_name)?;
3249                    integrand_base_name.clone()
3250                } else {
3251                    return Err(eyre!(
3252                        "Integrand name '{}' already exists in process '{}', use either --overwrite or --append flag when loading graphs",
3253                        integrand_base_name,
3254                        p.definition.folder_name
3255                    ));
3256                }
3257            } else {
3258                integrand_base_name.clone()
3259            };
3260
3261            match generation_type {
3262                GenerationType::Amplitude => p
3263                    .collection
3264                    .add_amplitude(Amplitude::from_graph_list(integrand_name.clone(), graphs)?),
3265                GenerationType::CrossSection => {
3266                    p.collection
3267                        .add_cross_section(CrossSection::from_graph_list(
3268                            integrand_name.clone(),
3269                            graphs,
3270                            &self.model,
3271                        )?)
3272                }
3273            }
3274        }
3275
3276        Ok(())
3277    }
3278
3279    pub(crate) fn infer_graph_list_generation_type(graphs: &[Graph]) -> Result<GenerationType> {
3280        if graphs.iter().all(|g| g.initial_state_cut.nedges(g) == 0) {
3281            Ok(GenerationType::Amplitude)
3282        } else if graphs.iter().all(|g| g.initial_state_cut.nedges(g) > 0) {
3283            Ok(GenerationType::CrossSection)
3284        } else {
3285            Err(eyre!(
3286                "Mix of amplitude and cross section graphs in the same file is not supported"
3287            ))
3288        }
3289    }
3290
3291    pub fn new(log_dir: impl AsRef<Path>, log_file_name: Option<String>) -> Self {
3292        super::tracing::init_tracing(log_dir.as_ref().join("logs"), log_file_name);
3293        let _ = initialise();
3294
3295        Self {
3296            model: Model::default(),
3297            process_list: ProcessList::default(),
3298            model_parameters: InputParamCard::default(),
3299            generation_summaries: BTreeMap::new(),
3300        }
3301    }
3302
3303    pub fn new_test() -> Self {
3304        init_test_tracing();
3305
3306        Self {
3307            model: Model::default(),
3308            process_list: ProcessList::default(),
3309            model_parameters: InputParamCard::default(),
3310            generation_summaries: BTreeMap::new(),
3311        }
3312    }
3313
3314    pub fn new_bench() -> Self {
3315        init_bench_tracing();
3316
3317        Self {
3318            model: Model::default(),
3319            process_list: ProcessList::default(),
3320            model_parameters: InputParamCard::default(),
3321            generation_summaries: BTreeMap::new(),
3322        }
3323    }
3324}
3325
3326#[derive(Debug, Clone, PartialEq, Eq)]
3327pub struct RemovedProcess {
3328    pub process_id: usize,
3329    pub process_name: String,
3330}
3331
3332#[derive(Debug, Clone, PartialEq, Eq)]
3333pub struct RemovedIntegrand {
3334    pub process_id: usize,
3335    pub process_name: String,
3336    pub integrand_name: String,
3337    pub removed_empty_process: bool,
3338}
3339
3340impl State {
3341    pub fn load(
3342        save_path: PathBuf,
3343        model_path: Option<PathBuf>,
3344        trace_logs_filename: Option<String>,
3345    ) -> Result<Self> {
3346        Self::load_with_selection(save_path, model_path, trace_logs_filename, None)
3347    }
3348
3349    pub fn load_with_selection(
3350        save_path: PathBuf,
3351        model_path: Option<PathBuf>,
3352        trace_logs_filename: Option<String>,
3353        selection: Option<&ProcessLoadSelection>,
3354    ) -> Result<Self> {
3355        // let root_folder = root_folder.join("gammaloop_state");
3356        let manifest = load_state_manifest(&save_path)?;
3357        validate_state_layout(&manifest, &save_path)?;
3358        // Install GammaLoop's subscriber before importing Symbolica state. Symbolica warnings
3359        // initialize its fallback subscriber on first use, which would otherwise claim the global
3360        // tracing dispatch and escape ANSI styling in all subsequent GammaLoop output.
3361        let mut loaded_state = State::new(&save_path, trace_logs_filename);
3362        debug!("Loading state manifest version {}", manifest.version);
3363
3364        // Restore even symbols absent from the restricted model before parsing expressions
3365        // or importing the archive: Symbolica cannot attach callbacks to a plain symbol later.
3366        for name in &manifest.ufo_print_symbols {
3367            let _ = UFOSymbol::from(name);
3368        }
3369        for label in &manifest.linnet_subgraph_print_labels {
3370            let _ = SuBitGraph::symbol_from_label(label.clone());
3371        }
3372
3373        // Load the model before importing Symbolica state so UFO symbols retain their custom print
3374        // callbacks; the import then remaps serialized symbol ids onto those definitions.
3375        let mut model = if let Some(model_path) = &model_path {
3376            info!("Loading model from {}", model_path.display());
3377            Model::from_file(model_path)?
3378        } else {
3379            let model_dir = save_path.join("model.json");
3380            info!(
3381                "Loading model from default location: {}",
3382                model_dir.display()
3383            );
3384            Model::from_file(model_dir)?
3385        };
3386
3387        debug!("Loaded model: {}", model.name);
3388
3389        let input_param_card = if save_path.join("model_parameters.json").exists() {
3390            let a = InputParamCard::from_file(save_path.join("model_parameters.json"))?;
3391
3392            let _ = model.apply_param_card(&a);
3393            a
3394        } else {
3395            InputParamCard::default_from_model(&model)
3396        };
3397
3398        let symbolica_state = symbolica::state::State::import(
3399            &mut fs::File::open(save_path.join("symbolica_state.bin"))
3400                .context("Trying to open symbolica state binary")?,
3401            None,
3402        )?;
3403
3404        let context: GammaLoopContextContainer<'_> = GammaLoopContextContainer {
3405            state_map: &symbolica_state,
3406            model: &model,
3407        };
3408
3409        let process_list = ProcessList::load_with_selection(&save_path, context, selection)
3410            .context("Trying to load processList")?;
3411
3412        loaded_state.process_list = process_list;
3413        loaded_state.model = model;
3414        loaded_state.model_parameters = input_param_card;
3415        loaded_state.generation_summaries =
3416            load_integrand_generation_summaries(&save_path, &loaded_state.process_list)?;
3417        Ok(loaded_state)
3418    }
3419
3420    pub fn activate_loaded_integrand_backends(
3421        &mut self,
3422        allow_symjit_fallback: bool,
3423    ) -> Result<()> {
3424        self.process_list
3425            .activate_loaded_integrand_backends(allow_symjit_fallback)
3426    }
3427
3428    pub fn compile(
3429        &mut self,
3430        root_folder: &Path,
3431        override_compiled: bool,
3432        settings: &GlobalSettings,
3433    ) -> Result<()> {
3434        fs::create_dir_all(root_folder)?;
3435
3436        let compile_pool = rayon::ThreadPoolBuilder::new()
3437            .num_threads(settings.n_cores.compile)
3438            .build()?;
3439        self.process_list
3440            .compile(root_folder, override_compiled, None, None, &compile_pool)?;
3441        Ok(())
3442    }
3443
3444    fn save_generation_summaries(&self, root_folder: &Path) -> Result<()> {
3445        for (process_id, process) in self.process_list.processes.iter().enumerate() {
3446            for integrand_name in process.collection.get_integrand_names() {
3447                let summary_path =
3448                    integrand_generation_summary_path(root_folder, process, integrand_name);
3449                let key = IntegrandGenerationSummaryKey {
3450                    process_id,
3451                    integrand_name: integrand_name.to_string(),
3452                };
3453                if let Some(summary) = self.generation_summaries.get(&key) {
3454                    if let Some(parent) = summary_path.parent() {
3455                        fs::create_dir_all(parent)?;
3456                    }
3457                    let raw_summary = serde_json::to_string_pretty(summary).with_context(|| {
3458                        format!(
3459                            "Trying to serialize integrand generation summary {}",
3460                            summary_path.display()
3461                        )
3462                    })?;
3463                    fs::write(&summary_path, raw_summary).with_context(|| {
3464                        format!(
3465                            "Trying to write integrand generation summary {}",
3466                            summary_path.display()
3467                        )
3468                    })?;
3469                } else if summary_path.exists() {
3470                    fs::remove_file(&summary_path).with_context(|| {
3471                        format!(
3472                            "Trying to remove stale integrand generation summary {}",
3473                            summary_path.display()
3474                        )
3475                    })?;
3476                }
3477            }
3478        }
3479
3480        Ok(())
3481    }
3482
3483    pub fn save(
3484        &mut self,
3485        root_folder: &Path,
3486        override_state_file: bool,
3487        strict: bool,
3488    ) -> Result<()> {
3489        // let root_folder = root_folder.join("gammaloop_state");
3490
3491        // check if the export root exists, if not create it, if it does return error
3492        let mut selected_root_folder = PathBuf::from(root_folder);
3493        let mut user_input = String::new();
3494        if !root_folder.exists() {
3495            fs::create_dir_all(root_folder)?;
3496        } else {
3497            if strict {
3498                return Err(eyre!(
3499                    "Export root already exists, please choose a different path or remove the existing directory",
3500                ));
3501            }
3502
3503            if !override_state_file {
3504                while selected_root_folder.exists() {
3505                    println!(
3506                        "Gammaloop export root {} already exists. Specify 'o' for overwriting, 'n' for not saving, or '<NEW_PATH>' to specify where to save current state to:",
3507                        selected_root_folder.display()
3508                    );
3509                    user_input.clear();
3510                    io::stdin()
3511                        .read_line(&mut user_input)
3512                        .expect("Could not read user-specified gammaloop state export destination");
3513                    //user_input = user_input.trim().into();
3514                    match user_input.trim() {
3515                        "o" => break,
3516                        "n" => {
3517                            return Ok(());
3518                        }
3519                        new_path => {
3520                            selected_root_folder = PathBuf::from(new_path);
3521                            continue;
3522                        }
3523                    }
3524                }
3525            }
3526        }
3527
3528        if selected_root_folder.join(STATE_MANIFEST_FILE).exists() {
3529            load_state_manifest(&selected_root_folder)?;
3530        }
3531        fs::create_dir_all(&selected_root_folder)?;
3532
3533        let mut state_file =
3534        // info!("Hi");
3535            fs::File::create(selected_root_folder.join("symbolica_state.bin"))?;
3536
3537        symbolica::state::State::export(&mut state_file)?;
3538        self.process_list
3539            .save(&selected_root_folder, override_state_file)?;
3540        self.save_generation_summaries(&selected_root_folder)?;
3541
3542        // let binary = bincode::encode_to_vec(&self.integrands, bincode::config::standard())?;
3543        // fs::write(root_folder.join("process_list.bin"), binary)?;?
3544        self.model
3545            .to_serializable()
3546            .to_file(selected_root_folder.join("model.json"), override_state_file)?;
3547        self.model_parameters.to_file(
3548            selected_root_folder.join("model_parameters.json"),
3549            override_state_file,
3550        )?;
3551        save_state_manifest(&selected_root_folder)?;
3552        Ok(())
3553    }
3554}
3555
3556#[cfg(test)]
3557mod tests {
3558    use std::{fs, process::Command};
3559
3560    use gammalooprs::{
3561        graph::Graph,
3562        initialisation::test_initialise,
3563        integrands::process::{ActiveF64Backend, GaussianReferenceFunction},
3564        model::InputParamCard,
3565        momentum::{Dep, ExternalMomenta, Helicity},
3566        processes::{
3567            process::ProcessCollection, RaisedPropagatorScope, RaisedPropagatorSignature,
3568            SelectionPolarity,
3569        },
3570        settings::global::{CompilationMode, FrozenCompilationMode},
3571        settings::{
3572            runtime::kinematic::{improvement::PhaseSpaceImprovementSettings, Externals},
3573            KinematicsSettings, RuntimeSettings,
3574        },
3575        utils::{load_generic_model, serde_utils::SHOWDEFAULTS},
3576    };
3577    use spenso::shadowing::symbolica_utils::SpensoPrintSettings;
3578    use symbolica::atom::{Atom, AtomCore};
3579    use tempfile::tempdir;
3580
3581    use crate::commands::{
3582        display::Display,
3583        save::SaveState,
3584        set::{ProcessSetArgs, Set, SetArgs},
3585    };
3586
3587    use super::*;
3588
3589    const STATE_LOAD_ORDER_CHILD: &str = "GAMMALOOP_STATE_LOAD_ORDER_CHILD";
3590    const STATE_LOAD_ORDER_TEST: &str =
3591        "state::tests::state_load_preserves_ufo_symbols_and_model_values";
3592
3593    #[cfg(target_os = "linux")]
3594    #[test]
3595    fn generation_ram_monitor_parses_resident_bytes() {
3596        assert_eq!(
3597            GenerationMonitor::parse_resident_memory_bytes(
3598                "Name:\tgammaloop\nVmSize:\t9999 kB\nVmRSS:\t 123 kB\nRssAnon:\t100 kB\n"
3599            ),
3600            Some(123 * 1024)
3601        );
3602        assert_eq!(
3603            GenerationMonitor::parse_resident_memory_bytes("VmRSS: 0 kB\n"),
3604            Some(0)
3605        );
3606        for status in [
3607            "VmSize: 123 kB\n",
3608            "VmRSS: invalid kB\n",
3609            "VmRSS: 123\n",
3610            "VmRSS: 123 MB\n",
3611            "VmRSS: 123 kB extra\n",
3612            "VmRSS: 18446744073709551615 kB\n",
3613        ] {
3614            assert_eq!(GenerationMonitor::parse_resident_memory_bytes(status), None);
3615        }
3616    }
3617
3618    #[cfg(target_os = "linux")]
3619    #[test]
3620    fn generation_ram_monitor_records_short_generation() {
3621        let mut monitor = GenerationMonitor::start().unwrap();
3622        let initial_memory = monitor.current_ram_bytes().load(Ordering::Relaxed);
3623        assert!(initial_memory > 0);
3624        let peak_memory = monitor.finish();
3625        assert!(peak_memory >= initial_memory);
3626        assert!(peak_memory >= monitor.current_ram_bytes().load(Ordering::Relaxed));
3627        assert!(monitor.stop_requested.load(Ordering::Relaxed));
3628        assert!(monitor.handle.is_none());
3629        assert_eq!(monitor.finish(), peak_memory);
3630    }
3631
3632    #[test]
3633    fn generation_ram_monitor_wakes_on_finish() {
3634        let stop_requested = Arc::new(AtomicBool::new(false));
3635        let stop_for_thread = Arc::clone(&stop_requested);
3636        let (ready, ready_receiver) = std::sync::mpsc::channel();
3637        let handle = thread::spawn(move || {
3638            ready.send(()).unwrap();
3639            loop {
3640                thread::park();
3641                if stop_for_thread.load(Ordering::Relaxed) {
3642                    break;
3643                }
3644            }
3645        });
3646        let mut monitor = GenerationMonitor {
3647            peak_ram_bytes: Arc::new(AtomicU64::new(123)),
3648            current_ram_bytes: Arc::new(AtomicU64::new(123)),
3649            stop_requested,
3650            handle: Some(handle),
3651        };
3652        ready_receiver.recv().unwrap();
3653        assert_eq!(monitor.finish(), 123);
3654    }
3655
3656    #[test]
3657    fn state_load_preserves_ufo_symbols_and_model_values() {
3658        if let Some(state_path) = std::env::var_os(STATE_LOAD_ORDER_CHILD) {
3659            for _ in 0..2 {
3660                let state = State::load(PathBuf::from(&state_path), None, None).unwrap();
3661                let ProcessCollection::Amplitudes(amplitudes) =
3662                    &state.process_list.processes[0].collection
3663                else {
3664                    panic!("expected an amplitude");
3665                };
3666                let mut params = amplitudes["default"].graphs[0].graph.param_builder.clone();
3667                let saved_values = params.values.clone();
3668                assert_eq!(saved_values.len(), 3);
3669                assert!(!params.pairs.model_parameters.params.is_empty());
3670                params.update_model_values(&state.model).unwrap();
3671                for (actual, expected) in params.values.iter().zip(&saved_values) {
3672                    for (actual, expected) in actual.iter().zip(expected) {
3673                        for (actual, expected) in
3674                            [(actual.re.0, expected.re.0), (actual.im.0, expected.im.0)]
3675                        {
3676                            assert!(
3677                                (actual - expected).abs() <= 1e-13 * expected.abs().max(1.0),
3678                                "model value changed after reload: {actual} != {expected}"
3679                            );
3680                        }
3681                    }
3682                }
3683                let mut model = state.model.clone();
3684                model.get_parameter_mut("aS").unwrap().value = Some(Complex::new_re(F(1.0)));
3685                model.recompute_dependents().unwrap();
3686                params.update_model_values(&model).unwrap();
3687                let coupling = model.get_coupling("GC_12");
3688                let position = params.pairs.model_parameters.value_range.start
3689                    + params
3690                        .pairs
3691                        .model_parameters
3692                        .params
3693                        .iter()
3694                        .position(|param| *param == Atom::from(coupling.name))
3695                        .unwrap();
3696                for (value_index, values) in params.values.iter().enumerate() {
3697                    assert_eq!(
3698                        values[position * (value_index + 1)],
3699                        coupling.value.unwrap().map(F)
3700                    );
3701                }
3702                for name in ["G", "orphan_ufo_printer"] {
3703                    let symbol = UFOSymbol::from(name);
3704                    assert!(symbol.0.get_print_function().is_some(), "{name}");
3705                    let rendered = Atom::from(symbol)
3706                        .printer(SpensoPrintSettings::typst_options())
3707                        .to_string();
3708                    assert_eq!(rendered, format!("\"{name}\""));
3709                }
3710                for label in ["Dd0E4", "1⦻2"] {
3711                    let symbol = symbolica::get_symbol!(&format!("linnet::S_{label}")).unwrap();
3712                    assert!(symbol.get_print_function().is_some(), "{label}");
3713                    assert_eq!(
3714                        Atom::var(symbol)
3715                            .printer(SpensoPrintSettings::typst_options())
3716                            .to_string(),
3717                        format!("#S(g,\"{label}\")")
3718                    );
3719                }
3720            }
3721            return;
3722        }
3723
3724        let temp = tempdir().unwrap();
3725        let mut state = State::new(temp.path(), None);
3726        // Force a different registration order from the alphabetical manifest replay.
3727        let _ = UFOSymbol::from("GC_12");
3728        state.model = load_generic_model("sm");
3729        state.model_parameters = InputParamCard::default_from_model(&state.model);
3730        let mut graphs = Graph::from_string(
3731            "digraph model_values { node [num=1]; edge [num=1, mass=0]; A -> A; }",
3732            &state.model,
3733        )
3734        .unwrap();
3735        graphs[0].param_builder.initialize_duals(3);
3736        state
3737            .import_graphs(
3738                graphs,
3739                GraphImportOptions {
3740                    process_name: Some("model_values".into()),
3741                    process_id: None,
3742                    process_definition: None,
3743                    integrand_name: Some("default".into()),
3744                    overwrite: false,
3745                    append: false,
3746                },
3747            )
3748            .unwrap();
3749        // Reproduce a coupling removed by a restriction: it survives in Symbolica's
3750        // global registry but has no declaration in the saved model.
3751        let _ = UFOSymbol::from("orphan_ufo_printer");
3752        for label in ["Dd0E4", "1⦻2"] {
3753            let _ = SuBitGraph::symbol_from_label(label.to_owned());
3754        }
3755        state.save(temp.path(), true, false).unwrap();
3756        let manifest = load_state_manifest(temp.path()).unwrap();
3757        assert!(manifest
3758            .ufo_print_symbols
3759            .contains(&"orphan_ufo_printer".to_owned()));
3760        assert!(!fs::read_to_string(temp.path().join("model.json"))
3761            .unwrap()
3762            .contains("orphan_ufo_printer"));
3763
3764        let output = Command::new(std::env::current_exe().unwrap())
3765            .arg(STATE_LOAD_ORDER_TEST)
3766            .arg("--exact")
3767            .arg("--nocapture")
3768            .env(STATE_LOAD_ORDER_CHILD, temp.path())
3769            .env_remove("GL_ALL_LOG_FILTER")
3770            .env("GL_DISPLAY_FILTER", "warn")
3771            .output()
3772            .unwrap();
3773        let transcript = format!(
3774            "stdout:\n{}\nstderr:\n{}",
3775            String::from_utf8_lossy(&output.stdout),
3776            String::from_utf8_lossy(&output.stderr),
3777        );
3778        assert!(
3779            output.status.success(),
3780            "child failed: {}\n{transcript}",
3781            output.status
3782        );
3783        assert!(
3784            !transcript.contains("Imported symbol UFO::")
3785                && !transcript.contains("Imported symbol linnet::S_"),
3786            "{transcript}"
3787        );
3788    }
3789
3790    fn build_generated_scalar_bubble_state_with_external_backend() -> State {
3791        test_initialise().expect("test initialisation should succeed");
3792        let mut state = State::new_test();
3793        state.model = load_generic_model("scalars");
3794        state.model_parameters = InputParamCard::default_from_model(&state.model);
3795
3796        let graph_path =
3797            crate::test_workspace_root().join("tests/resources/graphs/scalar_bubble.dot");
3798        let graphs = Graph::from_path(&graph_path, &state.model)
3799            .expect("scalar bubble graph fixture should load");
3800
3801        state
3802            .import_graphs(
3803                graphs,
3804                GraphImportOptions {
3805                    process_name: Some("scalar_bubble".to_string()),
3806                    process_id: None,
3807                    process_definition: None,
3808                    integrand_name: Some("default".to_string()),
3809                    overwrite: false,
3810                    append: false,
3811                },
3812            )
3813            .expect("graph import should succeed");
3814
3815        let mut cli_settings = CLISettings::default();
3816        cli_settings.global.generation.evaluator.compile = true;
3817        cli_settings.global.generation.compile.compilation_mode = CompilationMode::Assembly;
3818
3819        let runtime_defaults = RuntimeSettings::default();
3820        state
3821            .generate_integrands(&cli_settings.global, (&runtime_defaults).into())
3822            .expect("integrand generation should succeed");
3823
3824        state
3825    }
3826
3827    #[test]
3828    fn named_generation_records_common_settings_for_persistent_uv_exports() -> Result<()> {
3829        use gammalooprs::{
3830            cff::{expression::AllOrientations, surface::GammaLoopSurfaceCache},
3831            uv::{export::UVForestExportSettings, UVOrchestrator},
3832        };
3833
3834        test_initialise()?;
3835        for (folder, source) in [
3836            (
3837                "amplitudes",
3838                include_str!("../../../tests/resources/graphs/scalar_bubble.dot"),
3839            ),
3840            (
3841                "cross_sections",
3842                include_str!(
3843                    "../../../tests/resources/graphs/mass_approach_scalar_self_energy.dot"
3844                ),
3845            ),
3846        ] {
3847            let temp = tempdir()?;
3848            let mut state = State::new_test();
3849            state.model = load_generic_model("scalars");
3850            state.model_parameters = InputParamCard::default_from_model(&state.model);
3851            let mut graphs = Graph::from_string(source, &state.model)?;
3852            graphs.truncate(1);
3853            let graph_name = graphs[0].name.clone();
3854            for name in ["first", "second"] {
3855                state.import_graphs(
3856                    graphs.clone(),
3857                    GraphImportOptions {
3858                        process_name: Some("named_export".into()),
3859                        process_id: None,
3860                        process_definition: None,
3861                        integrand_name: Some(name.into()),
3862                        overwrite: false,
3863                        append: false,
3864                    },
3865                )?;
3866            }
3867            let mut settings = GlobalSettings::default();
3868            settings.n_cores.generate = 1;
3869            settings.generation.uv.orchestrator = UVOrchestrator::HedgePoset;
3870            assert!(!settings.generation.evaluator.compile);
3871            assert!(!settings.generation.evaluator.store_atom);
3872            assert!(settings.generation.uv.subtract_uv);
3873            assert!(settings.generation.uv.generate_integrated);
3874            assert!(settings.generation.uv.softct);
3875            assert!(settings.generation.threshold_subtraction.enable_thresholds);
3876            let runtime = RuntimeSettings::default();
3877            assert!(state.process_list.processes[0].settings_history.is_none());
3878            for name in ["first", "second"] {
3879                // The second named build must keep the already-generated sibling's
3880                // identical source settings, without regenerating the whole process.
3881                state.generate_integrand(&settings, (&runtime).into(), 0, Some(name.into()))?;
3882                assert_eq!(
3883                    state.process_list.processes[0].settings_history.as_ref(),
3884                    Some(&settings)
3885                );
3886                state.process_list.processes[0]
3887                    .get_integrand(name)?
3888                    .require_generated()?;
3889            }
3890
3891            let source_expressions = |state: &State| {
3892                ["first", "second"].map(|name| {
3893                    let source = match &state.process_list.processes[0].collection {
3894                        ProcessCollection::Amplitudes(amplitudes) => amplitudes[name].graphs[0]
3895                            .derived_data
3896                            .cff_expression
3897                            .as_ref()
3898                            .unwrap(),
3899                        ProcessCollection::CrossSections(cross_sections) => cross_sections[name]
3900                            .supergraphs[0]
3901                            .derived_data
3902                            .global_cff_expression
3903                            .as_ref()
3904                            .unwrap(),
3905                    };
3906                    source
3907                        .expression
3908                        .to_atom(AllOrientations)
3909                        .replace_multiple(source.expression.surfaces.get_all_replacements_gs(&[]))
3910                })
3911            };
3912
3913            // This State boundary cannot safely overwrite a generated sibling's
3914            // provenance. Check both missing and conflicting process history.
3915            let mut changed = settings.clone();
3916            changed.generation.explicit_orientation_sum_only = true;
3917            for missing_history in [false, true] {
3918                if missing_history {
3919                    state.process_list.processes[0].settings_history = None;
3920                }
3921                let before = source_expressions(&state);
3922                let history_before = state.process_list.processes[0].settings_history.clone();
3923                let error = state
3924                    .generate_integrand(
3925                        if missing_history { &settings } else { &changed },
3926                        (&runtime).into(),
3927                        0,
3928                        Some("first".into()),
3929                    )
3930                    .unwrap_err();
3931                assert!(
3932                    error.to_string().contains("regenerate the whole process"),
3933                    "{error:?}"
3934                );
3935                assert!(
3936                    source_expressions(&state)
3937                        .into_iter()
3938                        .zip(before)
3939                        .all(|(actual, expected)| (actual - expected).expand().is_zero()),
3940                    "named-generation source expressions changed after rejected generation"
3941                );
3942                assert_eq!(
3943                    state.process_list.processes[0].settings_history,
3944                    history_before
3945                );
3946                for name in ["first", "second"] {
3947                    state.process_list.processes[0]
3948                        .get_integrand(name)?
3949                        .require_generated()?;
3950                }
3951                state.process_list.processes[0].settings_history = Some(settings.clone());
3952            }
3953
3954            let source_before = source_expressions(&state);
3955            let mut exported_expression: Option<Atom> = None;
3956            for loaded in [false, true] {
3957                let process = &state.process_list.processes[0];
3958                assert_eq!(process.settings_history.as_ref(), Some(&settings));
3959                assert!(
3960                    source_expressions(&state)
3961                        .into_iter()
3962                        .zip(&source_before)
3963                        .all(|(actual, expected)| (actual - expected).expand().is_zero()),
3964                    "named-generation source expressions changed after persistence"
3965                );
3966                for computed in [false, true] {
3967                    let output = temp.path().join(format!("export-{loaded}-{computed}"));
3968                    state.process_list.export_uv_forests(
3969                        &output,
3970                        0,
3971                        "first",
3972                        &[0],
3973                        &UVForestExportSettings { computed },
3974                    )?;
3975                    let graph_dir = output
3976                        .join("processes")
3977                        .join(folder)
3978                        .join("named_export/first");
3979                    assert!(graph_dir.join(format!("{graph_name}.forest.dot")).is_file());
3980                    let nodes = graph_dir.join(format!("{graph_name}_nodes"));
3981                    if computed {
3982                        let mut exported_graph = false;
3983                        let mut expression = Atom::Zero;
3984                        for forest in fs::read_dir(&nodes)? {
3985                            for node in fs::read_dir(forest?.path())? {
3986                                let node = node?;
3987                                let dot = fs::read_to_string(node.path())?;
3988                                assert!(dot.contains("forest_residue_index"));
3989                                for graph in Graph::from_string(&dot, &state.model)? {
3990                                    exported_graph = true;
3991                                    expression += graph.full_numerator_atom();
3992                                }
3993                            }
3994                        }
3995                        assert!(exported_graph, "computed export must contain a graph");
3996                        // Re-import the public export to compare its consumed function,
3997                        // including persisted source conventions, independently of node layout.
3998                        if let Some(expected) = &exported_expression {
3999                            assert!(
4000                                (&expression - expected).expand().is_zero(),
4001                                "computed UV export changed after persistence"
4002                            );
4003                        } else {
4004                            exported_expression = Some(expression);
4005                        }
4006                    } else {
4007                        assert!(!nodes.exists());
4008                    }
4009                }
4010                if !loaded {
4011                    let saved = temp.path().join("saved");
4012                    state.save(&saved, true, false)?;
4013                    let history_path = saved
4014                        .join("processes")
4015                        .join(folder)
4016                        .join("named_export/settings_history.toml");
4017                    let history: GlobalSettings =
4018                        toml::from_str(&fs::read_to_string(history_path)?)?;
4019                    assert_eq!(history, settings);
4020                    state = State::load(saved, None, None)?;
4021                }
4022            }
4023        }
4024        Ok(())
4025    }
4026
4027    #[test]
4028    fn aggregate_generation_progress_tracks_counts_and_timings() {
4029        let current_ram_bytes = Arc::new(AtomicU64::new(256 * 1024 * 1024));
4030        let peak_ram_bytes = Arc::new(AtomicU64::new(512 * 1024 * 1024));
4031        let reporter =
4032            AggregateGenerationProgressReporter::new_hidden(current_ram_bytes, peak_ram_bytes);
4033
4034        reporter.begin_phase(
4035            GenerationProgressPhase::GraphPreprocessing,
4036            GenerationProcessKind::CrossSection,
4037            "proc",
4038            "itg",
4039            2,
4040            None,
4041        );
4042        reporter.graph_started(GenerationProcessKind::CrossSection, "itg", "GL01", None);
4043        reporter.cuts_discovered("itg", "GL01", 4, 2);
4044        {
4045            let state = reporter
4046                .state
4047                .lock()
4048                .expect("aggregate generation progress state mutex is poisoned");
4049            assert_eq!(state.done_graphs, 0);
4050            assert_eq!(state.discovered_st_cuts, 4);
4051            assert_eq!(state.discovered_valid_cuts, 2);
4052            assert_eq!(
4053                AggregateGenerationProgressReporter::progress_units(&state),
4054                (0, 2)
4055            );
4056            assert_eq!(
4057                AggregateGenerationProgressReporter::graph_progress_counts(&state),
4058                (0, 1, 2)
4059            );
4060        }
4061        reporter.graph_finished(
4062            GenerationProcessKind::CrossSection,
4063            "itg",
4064            "GL01",
4065            &GraphGenerationStats {
4066                total_time: Duration::from_secs(3),
4067                ..GraphGenerationStats::default()
4068            },
4069            None,
4070        );
4071        {
4072            let state = reporter
4073                .state
4074                .lock()
4075                .expect("aggregate generation progress state mutex is poisoned");
4076            assert_eq!(state.done_graphs, 1);
4077            assert_eq!(state.stats.total_time, Duration::from_secs(3));
4078            assert_eq!(
4079                AggregateGenerationProgressReporter::graph_progress_counts(&state),
4080                (1, 0, 2)
4081            );
4082        }
4083
4084        reporter.begin_phase(
4085            GenerationProgressPhase::GraphGeneration,
4086            GenerationProcessKind::CrossSection,
4087            "proc",
4088            "itg",
4089            2,
4090            Some(3),
4091        );
4092        reporter.graph_started(GenerationProcessKind::CrossSection, "itg", "GL01", Some(1));
4093        reporter.graph_started(GenerationProcessKind::CrossSection, "itg", "GL02", Some(2));
4094        {
4095            let state = reporter
4096                .state
4097                .lock()
4098                .expect("aggregate generation progress state mutex is poisoned");
4099            assert_eq!(
4100                AggregateGenerationProgressReporter::graph_progress_counts(&state),
4101                (0, 2, 2)
4102            );
4103        }
4104        reporter.graph_finished(
4105            GenerationProcessKind::CrossSection,
4106            "itg",
4107            "GL01",
4108            &GraphGenerationStats {
4109                evaluator_count: 2,
4110                total_time: Duration::from_secs(4),
4111                evaluator_spenso_time: Duration::from_secs(1),
4112                evaluator_symbolica_time: Duration::from_secs(1),
4113                evaluator_compile_time: Duration::ZERO,
4114                ..GraphGenerationStats::default()
4115            },
4116            None,
4117        );
4118        reporter.cut_finished("itg", "GL01", 1);
4119        reporter.graph_finished(
4120            GenerationProcessKind::CrossSection,
4121            "itg",
4122            "GL02",
4123            &GraphGenerationStats {
4124                evaluator_count: 3,
4125                total_time: Duration::from_secs(6),
4126                evaluator_spenso_time: Duration::from_secs(2),
4127                evaluator_symbolica_time: Duration::ZERO,
4128                evaluator_compile_time: Duration::ZERO,
4129                ..GraphGenerationStats::default()
4130            },
4131            None,
4132        );
4133        reporter.cut_finished("itg", "GL02", 2);
4134
4135        {
4136            let state = reporter
4137                .state
4138                .lock()
4139                .expect("aggregate generation progress state mutex is poisoned");
4140            assert_eq!(state.done_graphs, 2);
4141            assert_eq!(state.done_cuts, 3);
4142            assert!(state.active_graphs.is_empty());
4143            assert_eq!(state.last_graph.as_deref(), Some("GL02"));
4144            assert_eq!(state.stats.evaluator_count, 5);
4145            assert_eq!(state.stats.total_time, Duration::from_secs(10));
4146            assert_eq!(state.stats.expression_build_time(), Duration::from_secs(6));
4147            assert_eq!(state.stats.evaluator_spenso_time, Duration::from_secs(3));
4148            assert_eq!(state.stats.evaluator_symbolica_time, Duration::from_secs(1));
4149            assert_eq!(
4150                AggregateGenerationProgressReporter::progress_units(&state),
4151                (5, 5)
4152            );
4153            assert_eq!(
4154                AggregateGenerationProgressReporter::graph_progress_counts(&state),
4155                (2, 0, 2)
4156            );
4157        }
4158
4159        reporter.backend_started(GenerationProcessKind::CrossSection, "itg", 2);
4160        reporter.backend_finished(
4161            GenerationProcessKind::CrossSection,
4162            "itg",
4163            Duration::from_secs(2),
4164        );
4165
4166        let state = reporter
4167            .state
4168            .lock()
4169            .expect("aggregate generation progress state mutex is poisoned");
4170        assert_eq!(state.phase, Some(GenerationProgressPhase::Backend));
4171        assert_eq!(state.done_graphs, 2);
4172        assert_eq!(state.stats.total_time, Duration::from_secs(12));
4173        assert_eq!(state.stats.evaluator_compile_time, Duration::from_secs(2));
4174    }
4175
4176    #[test]
4177    fn aggregate_generation_progress_tracks_amplitude_preprocessing() {
4178        let reporter = AggregateGenerationProgressReporter::new_hidden(
4179            Arc::new(AtomicU64::new(0)),
4180            Arc::new(AtomicU64::new(0)),
4181        );
4182
4183        reporter.begin_phase(
4184            GenerationProgressPhase::GraphPreprocessing,
4185            GenerationProcessKind::Amplitude,
4186            "proc",
4187            "amp",
4188            3,
4189            None,
4190        );
4191        reporter.graph_started(GenerationProcessKind::Amplitude, "amp", "GL01", None);
4192        reporter.graph_started(GenerationProcessKind::Amplitude, "amp", "GL02", None);
4193
4194        {
4195            let state = reporter
4196                .state
4197                .lock()
4198                .expect("aggregate generation progress state mutex is poisoned");
4199            assert_eq!(state.kind, Some(GenerationProcessKind::Amplitude));
4200            assert_eq!(state.process, "proc");
4201            assert_eq!(state.integrand, "amp");
4202            assert_eq!(
4203                AggregateGenerationProgressReporter::progress_units(&state),
4204                (0, 3)
4205            );
4206            assert_eq!(
4207                AggregateGenerationProgressReporter::graph_progress_counts(&state),
4208                (0, 2, 3)
4209            );
4210        }
4211
4212        reporter.graph_finished(
4213            GenerationProcessKind::Amplitude,
4214            "amp",
4215            "GL01",
4216            &GraphGenerationStats::default(),
4217            None,
4218        );
4219
4220        let state = reporter
4221            .state
4222            .lock()
4223            .expect("aggregate generation progress state mutex is poisoned");
4224        assert_eq!(
4225            AggregateGenerationProgressReporter::progress_units(&state),
4226            (1, 3)
4227        );
4228        assert_eq!(
4229            AggregateGenerationProgressReporter::graph_progress_counts(&state),
4230            (1, 1, 3)
4231        );
4232        assert_eq!(state.last_graph.as_deref(), Some("GL01"));
4233    }
4234
4235    #[test]
4236    fn aggregate_generation_progress_formats_memory_and_time_shares() {
4237        assert_eq!(
4238            AggregateGenerationProgressReporter::progress_memory(512 * 1024 * 1024),
4239            "   512 MiB"
4240        );
4241        assert_eq!(
4242            AggregateGenerationProgressReporter::progress_memory(5 * 1024 * 1024 * 1024),
4243            "  5.00 GiB"
4244        );
4245
4246        assert_eq!(
4247            AggregateGenerationProgressReporter::progress_percent(0.0),
4248            "0%"
4249        );
4250        assert_eq!(
4251            AggregateGenerationProgressReporter::progress_percent(0.009),
4252            "0%"
4253        );
4254        assert_eq!(
4255            AggregateGenerationProgressReporter::progress_percent(0.024),
4256            "0.024%"
4257        );
4258        assert_eq!(
4259            AggregateGenerationProgressReporter::progress_percent(0.24),
4260            "0.24%"
4261        );
4262        assert_eq!(
4263            AggregateGenerationProgressReporter::progress_percent(2.4),
4264            "2.4%"
4265        );
4266        assert_eq!(
4267            AggregateGenerationProgressReporter::progress_percent(54.0),
4268            "54%"
4269        );
4270
4271        assert_eq!(
4272            AggregateGenerationProgressReporter::progress_time_share(
4273                Duration::ZERO,
4274                Duration::ZERO,
4275            ),
4276            "--"
4277        );
4278        assert_eq!(
4279            AggregateGenerationProgressReporter::progress_time_share(
4280                Duration::ZERO,
4281                Duration::from_secs(4),
4282            ),
4283            "0%"
4284        );
4285        assert_eq!(
4286            AggregateGenerationProgressReporter::progress_time_share(
4287                Duration::from_secs(1),
4288                Duration::from_secs(4),
4289            ),
4290            "25%"
4291        );
4292    }
4293
4294    fn build_scalar_bubble_diagram_state() -> State {
4295        test_initialise().expect("test initialisation should succeed");
4296        let mut state = State::new_test();
4297        state.model = load_generic_model("scalars");
4298        state.model_parameters = InputParamCard::default_from_model(&state.model);
4299
4300        let graph_path =
4301            crate::test_workspace_root().join("tests/resources/graphs/scalar_bubble.dot");
4302        let graphs = Graph::from_path(&graph_path, &state.model)
4303            .expect("scalar bubble graph fixture should load");
4304        state
4305            .import_graphs(
4306                graphs,
4307                GraphImportOptions {
4308                    process_name: Some("scalar_bubble".to_string()),
4309                    process_id: None,
4310                    process_definition: None,
4311                    integrand_name: Some("default".to_string()),
4312                    overwrite: false,
4313                    append: false,
4314                },
4315            )
4316            .expect("graph import should succeed");
4317        state
4318    }
4319
4320    fn scalar_bubble_master_graph_name(state: &State, integrand_name: &str) -> String {
4321        let process = &state.process_list.processes[0];
4322        match &process.collection {
4323            ProcessCollection::Amplitudes(amplitudes) => amplitudes
4324                .get(integrand_name)
4325                .expect("amplitude should exist")
4326                .graphs[0]
4327                .graph
4328                .name
4329                .clone(),
4330            ProcessCollection::CrossSections(cross_sections) => cross_sections
4331                .get(integrand_name)
4332                .expect("cross section should exist")
4333                .supergraphs[0]
4334                .graph
4335                .name
4336                .clone(),
4337        }
4338    }
4339
4340    #[test]
4341    fn select_copy_mode_creates_pregenerated_target_without_mutating_source() {
4342        let _guard = crate::LOG_TEST_MUTEX
4343            .lock()
4344            .unwrap_or_else(|err| err.into_inner());
4345        let mut state = build_scalar_bubble_diagram_state();
4346        let temp = tempdir().unwrap();
4347        let graph_name = scalar_bubble_master_graph_name(&state, "default");
4348        let selection = GraphGroupSelectionSpec::new().with_master_graph_names(vec![graph_name]);
4349        let target = GraphGroupSelectionTarget::copy(None, Some("selected".to_string()), false);
4350
4351        let selected = state
4352            .select_integrand_graph_groups(
4353                Some(&ProcessRef::Name("scalar_bubble".to_string())),
4354                Some(&"default".to_string()),
4355                &selection,
4356                &target,
4357                GraphGroupSelectionContext::new(&GenerationSettings::default(), temp.path(), false),
4358            )
4359            .unwrap();
4360
4361        assert!(selected.copied_to_output);
4362        assert_eq!(selected.process_id, 0);
4363        assert_eq!(selected.integrand_name, "selected");
4364        let process = &state.process_list.processes[0];
4365        assert!(process
4366            .collection
4367            .get_integrand_names()
4368            .contains(&"default"));
4369        assert!(process
4370            .collection
4371            .get_integrand_names()
4372            .contains(&"selected"));
4373        match &process.collection {
4374            ProcessCollection::Amplitudes(amplitudes) => {
4375                assert!(amplitudes["default"].integrand.is_none());
4376                assert!(amplitudes["selected"].integrand.is_none());
4377                assert_eq!(amplitudes["default"].graphs.len(), 1);
4378                assert_eq!(amplitudes["selected"].graphs.len(), 1);
4379            }
4380            ProcessCollection::CrossSections(cross_sections) => {
4381                assert!(cross_sections["default"].integrand.is_none());
4382                assert!(cross_sections["selected"].integrand.is_none());
4383                assert_eq!(cross_sections["default"].supergraphs.len(), 1);
4384                assert_eq!(cross_sections["selected"].supergraphs.len(), 1);
4385            }
4386        }
4387    }
4388
4389    #[test]
4390    fn select_copy_mode_rejects_and_clears_existing_target_integrand() {
4391        let _guard = crate::LOG_TEST_MUTEX
4392            .lock()
4393            .unwrap_or_else(|err| err.into_inner());
4394        let mut state = build_scalar_bubble_diagram_state();
4395        let temp = tempdir().unwrap();
4396        let graph_name = scalar_bubble_master_graph_name(&state, "default");
4397        let selection = GraphGroupSelectionSpec::new().with_master_graph_names(vec![graph_name]);
4398        let target = GraphGroupSelectionTarget::copy(None, Some("selected".to_string()), false);
4399
4400        state
4401            .select_integrand_graph_groups(
4402                Some(&ProcessRef::Name("scalar_bubble".to_string())),
4403                Some(&"default".to_string()),
4404                &selection,
4405                &target,
4406                GraphGroupSelectionContext::new(&GenerationSettings::default(), temp.path(), false),
4407            )
4408            .unwrap();
4409        let err = state
4410            .select_integrand_graph_groups(
4411                Some(&ProcessRef::Name("scalar_bubble".to_string())),
4412                Some(&"default".to_string()),
4413                &selection,
4414                &target,
4415                GraphGroupSelectionContext::new(&GenerationSettings::default(), temp.path(), false),
4416            )
4417            .unwrap_err();
4418        assert!(format!("{err}").contains("already exists"));
4419
4420        let process = &state.process_list.processes[0];
4421        let kind_folder = match &process.collection {
4422            ProcessCollection::Amplitudes(_) => "amplitudes",
4423            ProcessCollection::CrossSections(_) => "cross_sections",
4424        };
4425        let stale_integrand_folder = temp
4426            .path()
4427            .join("processes")
4428            .join(kind_folder)
4429            .join("scalar_bubble")
4430            .join("selected");
4431        fs::create_dir_all(stale_integrand_folder.join("integrand")).unwrap();
4432        state.generation_summaries.insert(
4433            IntegrandGenerationSummaryKey {
4434                process_id: 0,
4435                integrand_name: "selected".to_string(),
4436            },
4437            IntegrandGenerationSummary {
4438                peak_ram_bytes: 1,
4439                reports: Vec::new(),
4440            },
4441        );
4442
4443        let clear_target =
4444            GraphGroupSelectionTarget::copy(None, Some("selected".to_string()), true);
4445        let selected = state
4446            .select_integrand_graph_groups(
4447                Some(&ProcessRef::Name("scalar_bubble".to_string())),
4448                Some(&"default".to_string()),
4449                &selection,
4450                &clear_target,
4451                GraphGroupSelectionContext::new(&GenerationSettings::default(), temp.path(), false),
4452            )
4453            .unwrap();
4454        assert!(selected.replaced_existing_target);
4455        assert!(selected.removed_target_artifacts);
4456        assert!(!stale_integrand_folder.exists());
4457        assert!(!state
4458            .generation_summaries
4459            .contains_key(&IntegrandGenerationSummaryKey {
4460                process_id: 0,
4461                integrand_name: "selected".to_string(),
4462            }));
4463
4464        let err = state
4465            .select_integrand_graph_groups(
4466                Some(&ProcessRef::Name("scalar_bubble".to_string())),
4467                Some(&"default".to_string()),
4468                &selection,
4469                &clear_target,
4470                GraphGroupSelectionContext::new(&GenerationSettings::default(), temp.path(), true),
4471            )
4472            .unwrap_err();
4473        assert!(format!("{err}").contains("--read-only-state"));
4474    }
4475
4476    #[test]
4477    fn select_amplitude_graphs_mode_rejects_amplitude_integrands() {
4478        let _guard = crate::LOG_TEST_MUTEX
4479            .lock()
4480            .unwrap_or_else(|err| err.into_inner());
4481        let mut state = build_scalar_bubble_diagram_state();
4482        let temp = tempdir().unwrap();
4483        let graph_name = scalar_bubble_master_graph_name(&state, "default");
4484        let selection = GraphGroupSelectionSpec::new()
4485            .with_mode(GraphGroupSelectionMode::CrossSectionAmplitudeGraphs)
4486            .with_master_graph_names(vec![graph_name]);
4487        let target = GraphGroupSelectionTarget::in_place();
4488
4489        let err = state
4490            .select_integrand_graph_groups(
4491                Some(&ProcessRef::Name("scalar_bubble".to_string())),
4492                Some(&"default".to_string()),
4493                &selection,
4494                &target,
4495                GraphGroupSelectionContext::new(&GenerationSettings::default(), temp.path(), false),
4496            )
4497            .unwrap_err();
4498
4499        assert!(format!("{err}").contains("--amplitude-graphs"));
4500    }
4501
4502    #[test]
4503    fn select_raised_cut_filters_reject_amplitude_integrands() {
4504        let _guard = crate::LOG_TEST_MUTEX
4505            .lock()
4506            .unwrap_or_else(|err| err.into_inner());
4507        let mut state = build_scalar_bubble_diagram_state();
4508        let temp = tempdir().unwrap();
4509        let selection = GraphGroupSelectionSpec::new().with_raised_cut_signatures(
4510            SelectionPolarity::With,
4511            RaisedPropagatorScope::All,
4512            vec![RaisedPropagatorSignature::from_str("[]").unwrap()],
4513        );
4514        let target = GraphGroupSelectionTarget::in_place();
4515
4516        let err = state
4517            .select_integrand_graph_groups(
4518                Some(&ProcessRef::Name("scalar_bubble".to_string())),
4519                Some(&"default".to_string()),
4520                &selection,
4521                &target,
4522                GraphGroupSelectionContext::new(&GenerationSettings::default(), temp.path(), false),
4523            )
4524            .unwrap_err();
4525
4526        assert!(format!("{err}").contains("cross-section integrands"));
4527    }
4528
4529    #[test]
4530    fn test_run_history() {
4531        use crate::state::RunHistory;
4532        //SHOWDEFAULTS.store(true, std::sync::atomic::Ordering::Relaxed);
4533        let mut run_history: RunHistory = Default::default();
4534        let kinematics_settings = KinematicsSettings {
4535            e_cm: 100.0,
4536            externals: Externals::Constant {
4537                momenta: vec![
4538                    ExternalMomenta::Independent([F(1.), F(2.), F(3.), F(4.)]),
4539                    ExternalMomenta::Dependent(Dep::Dep),
4540                ],
4541                improvement_settings: PhaseSpaceImprovementSettings::default(),
4542                helicities: vec![Helicity::PLUS, Helicity::MINUS],
4543                f_64_cache: None,
4544                f_128_cache: None,
4545                arb_cache: Default::default(),
4546            },
4547        };
4548
4549        run_history.push(Commands::Set(Set::Global {
4550            input: SetArgs::Stored,
4551        }));
4552
4553        run_history.default_runtime_settings.kinematics = kinematics_settings;
4554        set_serialize_commands_as_strings(true);
4555        let toml = toml::to_string_pretty(&run_history).unwrap();
4556        println!("{}", toml);
4557        let deserialized: RunHistory = toml::from_str(&toml).unwrap();
4558        assert_eq!(run_history, deserialized);
4559        SHOWDEFAULTS.store(false, std::sync::atomic::Ordering::Relaxed);
4560
4561        run_history.to_file("test_path.toml", true).unwrap();
4562        let deserialized_from_file = RunHistory::from_file("test_path.toml", " ").unwrap();
4563        assert_eq!(run_history, deserialized_from_file);
4564    }
4565
4566    #[test]
4567    fn run_history_applies_default_runtime_before_commands() {
4568        let mut run_history = RunHistory {
4569            default_runtime_settings: toml::from_str(
4570                r#"
4571[sampling]
4572graphs = "monte_carlo"
4573orientations = "summed"
4574sampling_multichanneling = false
4575sampling_channels = "summed"
4576coordinate_system = "tropical"
4577mapping = "linear"
4578b = 1.0
4579"#,
4580            )
4581            .unwrap(),
4582            ..Default::default()
4583        };
4584
4585        let mut state = State::new_test();
4586        let mut cli_settings = CLISettings::default();
4587        let mut default_runtime_settings = RuntimeSettings::default();
4588
4589        let _ = run_history
4590            .run(&mut state, &mut cli_settings, &mut default_runtime_settings)
4591            .unwrap();
4592
4593        assert_eq!(
4594            default_runtime_settings.sampling,
4595            run_history.default_runtime_settings.sampling
4596        );
4597    }
4598
4599    #[test]
4600    fn test_command_history_serialization() {
4601        use super::{set_serialize_commands_as_strings, CommandHistory};
4602        use crate::commands::Commands;
4603
4604        // Test basic construction
4605        let cmd_history = CommandHistory::new(Commands::Quit(SaveState::default()));
4606        assert_eq!(cmd_history.raw_string, None);
4607
4608        // Test with raw string
4609        let cmd_history_with_raw =
4610            CommandHistory::new_with_raw(Commands::Quit(SaveState::default()), "quit".to_string());
4611        assert_eq!(cmd_history_with_raw.raw_string, Some("quit".to_string()));
4612
4613        // Test serialization as Commands (default behavior)
4614        set_serialize_commands_as_strings(false);
4615
4616        let json = serde_json::to_string(&cmd_history).unwrap();
4617        let deserialized_json: CommandHistory = serde_json::from_str(&json).unwrap();
4618        let toml = toml::to_string(&cmd_history).unwrap();
4619        let deserialized_toml: CommandHistory = toml::from_str(&toml).unwrap();
4620        assert_eq!(cmd_history, deserialized_json);
4621        assert_eq!(cmd_history, deserialized_toml);
4622
4623        // Test serialization as string
4624        set_serialize_commands_as_strings(true);
4625
4626        let json_string = serde_json::to_string_pretty(&cmd_history_with_raw).unwrap();
4627        assert!(json_string.contains("quit"));
4628
4629        // Reset flag
4630        set_serialize_commands_as_strings(false);
4631    }
4632
4633    #[test]
4634    fn test_command_history_toml_and_json_formats() {
4635        use super::{set_serialize_commands_as_strings, CommandHistory};
4636        use crate::commands::Commands;
4637
4638        // Test different command types
4639        let quit_cmd = CommandHistory::new(Commands::Quit(SaveState::default()));
4640        let quit_with_raw =
4641            CommandHistory::new_with_raw(Commands::Quit(SaveState::default()), "quit".to_string());
4642
4643        // Test JSON serialization/deserialization
4644        {
4645            // Test Commands format in JSON
4646            set_serialize_commands_as_strings(false);
4647            let json = serde_json::to_string_pretty(&quit_cmd).unwrap();
4648            let deserialized: CommandHistory = serde_json::from_str(&json).unwrap();
4649            assert_eq!(quit_cmd, deserialized);
4650
4651            // Test string format in JSON
4652            set_serialize_commands_as_strings(true);
4653            let json_string = serde_json::to_string_pretty(&quit_with_raw).unwrap();
4654            assert!(json_string.contains("quit"));
4655            let deserialized_string: CommandHistory = serde_json::from_str(&json_string).unwrap();
4656            assert_eq!(quit_with_raw, deserialized_string);
4657        }
4658
4659        // Test TOML serialization/deserialization with wrapper struct
4660        {
4661            #[derive(serde::Serialize, serde::Deserialize)]
4662            struct CommandWrapper {
4663                command: CommandHistory,
4664            }
4665
4666            // Test Commands format in TOML
4667            set_serialize_commands_as_strings(false);
4668            let wrapper = CommandWrapper {
4669                command: quit_cmd.clone(),
4670            };
4671            let toml = toml::to_string_pretty(&wrapper).unwrap();
4672            let deserialized_wrapper: CommandWrapper = toml::from_str(&toml).unwrap();
4673            assert_eq!(quit_cmd, deserialized_wrapper.command);
4674
4675            // Test string format in TOML
4676            set_serialize_commands_as_strings(true);
4677            let wrapper_string = CommandWrapper {
4678                command: quit_with_raw.clone(),
4679            };
4680            let toml_string = toml::to_string_pretty(&wrapper_string).unwrap();
4681            assert!(toml_string.contains("quit"));
4682            let deserialized_string_wrapper: CommandWrapper = toml::from_str(&toml_string).unwrap();
4683            assert_eq!(quit_with_raw, deserialized_string_wrapper.command);
4684        }
4685
4686        // Test cross-format compatibility: serialize in one format, deserialize in another
4687        {
4688            set_serialize_commands_as_strings(false);
4689
4690            // Serialize as JSON, deserialize the Commands directly from JSON Value
4691            let json = serde_json::to_string(&quit_cmd).unwrap();
4692            let json_value: serde_json::Value = serde_json::from_str(&json).unwrap();
4693            let from_json: CommandHistory = serde_json::from_value(json_value).unwrap();
4694            assert_eq!(quit_cmd, from_json);
4695        }
4696        // Reset flag
4697        set_serialize_commands_as_strings(false);
4698    }
4699
4700    #[test]
4701    fn run_history_push_with_raw_skips_quit_and_definition_commands() {
4702        use super::RunHistory;
4703        use crate::commands::{run::Run, StartCommandsBlock};
4704
4705        let mut run_history = RunHistory::default();
4706        run_history.push_with_raw(
4707            Commands::Run(Run {
4708                block_names: vec!["block_a".to_string()],
4709                defines: Vec::new(),
4710                commands: None,
4711            }),
4712            Some("run block_a".to_string()),
4713        );
4714        run_history.push_with_raw(
4715            Commands::Quit(SaveState::default()),
4716            Some("quit".to_string()),
4717        );
4718        run_history.push_with_raw(
4719            Commands::StartCommandsBlock(StartCommandsBlock {
4720                name: "block_a".to_string(),
4721            }),
4722            Some("start_commands_block block_a".to_string()),
4723        );
4724        run_history.push_with_raw(
4725            Commands::FinishCommandsBlock,
4726            Some("finish_commands_block".to_string()),
4727        );
4728
4729        assert_eq!(run_history.commands.len(), 1);
4730        assert_eq!(
4731            run_history.commands[0].raw_string.as_deref(),
4732            Some("run block_a")
4733        );
4734    }
4735
4736    #[test]
4737    fn run_history_filtered_for_save_preserves_command_blocks() {
4738        let mut run_history = RunHistory {
4739            command_blocks: vec![
4740                CommandsBlock {
4741                    name: "generate".to_string(),
4742                    commands: vec![CommandHistory::new_with_raw(
4743                        Commands::Display(Display::Processes),
4744                        "display processes".to_string(),
4745                    )],
4746                },
4747                CommandsBlock {
4748                    name: "integrate".to_string(),
4749                    commands: vec![CommandHistory::new_with_raw(
4750                        CommandHistory::from_raw_string("quit -o").unwrap().command,
4751                        "quit -o".to_string(),
4752                    )],
4753                },
4754            ],
4755            ..Default::default()
4756        };
4757        run_history.push_with_raw(
4758            CommandHistory::from_raw_string("display processes")
4759                .unwrap()
4760                .command,
4761            Some("display processes".to_string()),
4762        );
4763
4764        let filtered = run_history.filtered_for_save();
4765        set_serialize_commands_as_strings(true);
4766        let toml = toml::to_string_pretty(&filtered).unwrap();
4767        set_serialize_commands_as_strings(false);
4768
4769        assert_eq!(filtered.command_blocks.len(), 2);
4770        assert!(toml.contains("commands = ["));
4771        assert!(toml.contains("[[command_blocks]]"));
4772        assert!(toml.contains("quit -o"));
4773    }
4774
4775    #[test]
4776    fn command_history_parses_multiline_set_string() {
4777        let raw = "set process -p epem_a_tth -i LO string '[integrator]\nn_start = 1000\n'";
4778        let cmd = CommandHistory::from_raw_string(raw).unwrap();
4779        assert_eq!(cmd.raw_string.as_deref(), Some(raw));
4780
4781        match cmd.command {
4782            Commands::Set(Set::Process { input, .. }) => match input {
4783                ProcessSetArgs::String { string } => {
4784                    assert_eq!(string, "[integrator]\nn_start = 1000\n");
4785                }
4786                other => panic!("Expected string set input, got {other:?}"),
4787            },
4788            other => panic!("Expected set process command, got {other:?}"),
4789        }
4790    }
4791
4792    #[test]
4793    fn command_history_parses_hash_process_refs() {
4794        // TOML reports positive integer IDs through the signed visitor, while
4795        // JSON uses the unsigned visitor. Both must round-trip numeric IDs.
4796        for process in [
4797            ProcessRef::Id(0),
4798            ProcessRef::Id(12),
4799            ProcessRef::Name("12".into()),
4800            ProcessRef::Unqualified("scalar_bubble".into()),
4801        ] {
4802            let refs = BTreeMap::from([("process".to_string(), process)]);
4803            let toml = toml::to_string(&refs).unwrap();
4804            assert_eq!(
4805                toml::from_str::<BTreeMap<String, ProcessRef>>(&toml).unwrap(),
4806                refs
4807            );
4808            let json = serde_json::to_string(&refs).unwrap();
4809            assert_eq!(
4810                serde_json::from_str::<BTreeMap<String, ProcessRef>>(&json).unwrap(),
4811                refs
4812            );
4813        }
4814        for invalid in ["process = -1", "process = 1.5"] {
4815            assert!(toml::from_str::<BTreeMap<String, ProcessRef>>(invalid).is_err());
4816        }
4817        assert!(serde_json::from_str::<ProcessRef>("-1").is_err());
4818
4819        let cmd = CommandHistory::from_raw_string("display integrand -p #12").unwrap();
4820        match cmd.command {
4821            Commands::Display(Display::Integrands {
4822                process,
4823                integrand_name,
4824                graphs,
4825                categories,
4826                hide_non_existing_thresholds,
4827                show_threshold_functions,
4828                show_sampling,
4829                show_threshold_subtraction,
4830            }) => {
4831                assert_eq!(process, Some(ProcessRef::Id(12)));
4832                assert_eq!(integrand_name, None);
4833                assert!(graphs.is_empty());
4834                assert!(categories.is_empty());
4835                assert!(!hide_non_existing_thresholds);
4836                assert!(!show_threshold_functions);
4837                assert_eq!(show_sampling, None);
4838                assert_eq!(show_threshold_subtraction, None);
4839            }
4840            other => panic!("Expected display integrand command, got {other:?}"),
4841        }
4842    }
4843
4844    #[test]
4845    fn command_history_requires_explicit_generate_mode() {
4846        assert!(CommandHistory::from_raw_string("generate e+ e- > d d~").is_err());
4847    }
4848
4849    #[test]
4850    fn run_history_parses_triple_quoted_set_kv_command() {
4851        let toml = r#"
4852commands = [
4853    """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]}}'""",
4854]
4855
4856[default_runtime_settings.general]
4857integral_unit = "picobarn"
4858"#;
4859
4860        let run_history: RunHistory = toml::from_str(toml).unwrap();
4861        assert_eq!(run_history.commands.len(), 1);
4862
4863        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]}}'"#;
4864        let command_history = &run_history.commands[0];
4865        assert_eq!(command_history.raw_string.as_deref(), Some(expected_cmd));
4866
4867        match &command_history.command {
4868            Commands::Set(Set::DefaultRuntime {
4869                input: SetArgs::Kv { pairs },
4870            }) => {
4871                assert_eq!(pairs.len(), 1);
4872                assert_eq!(pairs[0].key, "kinematics.externals");
4873                assert_eq!(
4874                    pairs[0].value,
4875                    r#"{"type":"constant","data":{"momenta":[[1.0,2.0,3.0,4.0],[5.0,6.0,-7.0,-8.0]],"helicities":[1,1]}}"#
4876                );
4877            }
4878            other => panic!("Expected set default-runtime kv command, got {other:?}"),
4879        }
4880
4881        assert_eq!(
4882            run_history.default_runtime_settings.general.integral_unit,
4883            gammalooprs::settings::runtime::IntegralUnit::Picobarn
4884        );
4885    }
4886
4887    #[test]
4888    fn run_history_load_preserves_command_blocks() {
4889        let temp = tempdir().unwrap();
4890        let run_path = temp.path().join("run.toml");
4891        fs::write(
4892            &run_path,
4893            r#"
4894[[command_blocks]]
4895name = "zeta"
4896commands = ["quit -n"]
4897
4898[[command_blocks]]
4899name = "alpha"
4900commands = ["quit -o"]
4901"#,
4902        )
4903        .unwrap();
4904
4905        let run_history = RunHistory::load(&run_path).unwrap();
4906        assert!(run_history.commands.is_empty());
4907        assert_eq!(run_history.command_blocks.len(), 2);
4908    }
4909
4910    #[test]
4911    fn run_history_selects_named_command_blocks_in_order() {
4912        let temp = tempdir().unwrap();
4913        let run_path = temp.path().join("run.toml");
4914        fs::write(
4915            &run_path,
4916            r#"
4917[[command_blocks]]
4918name = "first"
4919commands = ["quit -n"]
4920
4921[[command_blocks]]
4922name = "second"
4923commands = ["quit -o"]
4924"#,
4925        )
4926        .unwrap();
4927
4928        let requested = vec!["second".to_string(), "first".to_string()];
4929        let run_history = RunHistory::load(&run_path).unwrap();
4930        let selected = run_history
4931            .select_command_blocks(requested.as_slice())
4932            .unwrap();
4933        assert_eq!(selected.len(), 2);
4934        assert_eq!(
4935            selected[0].commands[0].raw_string.as_deref(),
4936            Some("quit -o")
4937        );
4938        assert_eq!(
4939            selected[1].commands[0].raw_string.as_deref(),
4940            Some("quit -n")
4941        );
4942    }
4943
4944    #[test]
4945    fn run_history_selection_rejects_unknown_command_block() {
4946        let temp = tempdir().unwrap();
4947        let run_path = temp.path().join("run.toml");
4948        fs::write(
4949            &run_path,
4950            r#"
4951[[command_blocks]]
4952name = "first"
4953commands = ["quit -n"]
4954"#,
4955        )
4956        .unwrap();
4957
4958        let requested = vec!["missing".to_string()];
4959        let run_history = RunHistory::load(&run_path).unwrap();
4960        let err = run_history
4961            .select_command_blocks(requested.as_slice())
4962            .unwrap_err();
4963        let message = format!("{err}");
4964        assert!(message.contains("Unknown command block 'missing'"));
4965        assert!(message.contains("first"));
4966    }
4967
4968    #[test]
4969    fn run_history_selection_rejects_missing_command_block_when_only_legacy_commands_exist() {
4970        let temp = tempdir().unwrap();
4971        let run_path = temp.path().join("run.toml");
4972        fs::write(
4973            &run_path,
4974            r#"
4975commands = ["quit -o"]
4976"#,
4977        )
4978        .unwrap();
4979
4980        let requested = vec!["first".to_string()];
4981        let run_history = RunHistory::load(&run_path).unwrap();
4982        let err = run_history
4983            .select_command_blocks(requested.as_slice())
4984            .unwrap_err();
4985        assert!(format!("{err}").contains("Unknown command block"));
4986    }
4987
4988    #[test]
4989    fn run_history_load_accepts_commands_and_command_blocks_together() {
4990        let temp = tempdir().unwrap();
4991        let run_path = temp.path().join("run.toml");
4992        fs::write(
4993            &run_path,
4994            r#"
4995commands = ["quit -o"]
4996
4997[[command_blocks]]
4998name = "first"
4999commands = ["quit -n"]
5000"#,
5001        )
5002        .unwrap();
5003
5004        let run_history = RunHistory::load(&run_path).unwrap();
5005        assert_eq!(run_history.commands.len(), 1);
5006        assert_eq!(run_history.command_blocks.len(), 1);
5007        assert_eq!(
5008            run_history.commands[0].raw_string.as_deref(),
5009            Some("quit -o")
5010        );
5011    }
5012
5013    #[test]
5014    fn run_history_load_rejects_duplicate_command_block_names() {
5015        let temp = tempdir().unwrap();
5016        let run_path = temp.path().join("run.toml");
5017        fs::write(
5018            &run_path,
5019            r#"
5020[[command_blocks]]
5021name = "first"
5022commands = ["quit -o"]
5023
5024[[command_blocks]]
5025name = "first"
5026commands = ["quit -n"]
5027"#,
5028        )
5029        .unwrap();
5030
5031        let err = RunHistory::load(&run_path).unwrap_err();
5032        assert!(format!("{err}").contains("duplicate block name"));
5033    }
5034
5035    #[test]
5036    fn state_manifest_roundtrip_current_version() {
5037        let temp = tempdir().unwrap();
5038        save_state_manifest(temp.path()).unwrap();
5039
5040        let manifest = load_state_manifest(temp.path()).unwrap();
5041        assert_eq!(manifest.version, CURRENT_STATE_MANIFEST_VERSION);
5042    }
5043
5044    #[test]
5045    fn state_manifest_rejects_legacy_versions() {
5046        for version in 0..CURRENT_STATE_MANIFEST_VERSION {
5047            let temp = tempdir().unwrap();
5048            fs::write(
5049                temp.path().join(STATE_MANIFEST_FILE),
5050                format!("version = {version}\n"),
5051            )
5052            .unwrap();
5053
5054            let err = load_state_manifest(temp.path()).unwrap_err();
5055            let message = err.to_string();
5056            assert!(message.contains("cannot be migrated"));
5057            assert!(message.contains("regenerate the saved state"));
5058        }
5059    }
5060
5061    #[test]
5062    fn state_manifest_requires_an_explicit_version() {
5063        let temp = tempdir().unwrap();
5064        fs::write(temp.path().join(STATE_MANIFEST_FILE), "").unwrap();
5065
5066        let err = load_state_manifest(temp.path()).unwrap_err();
5067        assert!(err.to_string().contains("missing field `version`"));
5068    }
5069
5070    #[test]
5071    fn state_save_rejects_legacy_target_before_writing() {
5072        let temp = tempdir().unwrap();
5073        let symbolica_state = temp.path().join("symbolica_state.bin");
5074        fs::write(temp.path().join(STATE_MANIFEST_FILE), "version = 1\n").unwrap();
5075        fs::write(&symbolica_state, b"legacy sentinel").unwrap();
5076        let mut state = State::new_test();
5077
5078        let err = state.save(temp.path(), true, false).unwrap_err();
5079        assert!(err.to_string().contains("cannot be migrated"));
5080        assert_eq!(fs::read(symbolica_state).unwrap(), b"legacy sentinel");
5081        assert!(!temp.path().join("model.json").exists());
5082    }
5083
5084    #[test]
5085    fn state_manifest_rejects_future_versions() {
5086        let temp = tempdir().unwrap();
5087        let future_manifest = StateManifest {
5088            version: CURRENT_STATE_MANIFEST_VERSION + 1,
5089            ..StateManifest::default()
5090        };
5091        fs::write(
5092            temp.path().join(STATE_MANIFEST_FILE),
5093            toml::to_string_pretty(&future_manifest).unwrap(),
5094        )
5095        .unwrap();
5096
5097        let err = load_state_manifest(temp.path()).unwrap_err();
5098        assert!(format!("{err}").contains("newer than this binary supports"));
5099    }
5100
5101    #[test]
5102    fn state_folder_classifies_saved_layout() {
5103        let temp = tempdir().unwrap();
5104        save_state_manifest(temp.path()).unwrap();
5105        fs::write(temp.path().join("model.json"), "{}").unwrap();
5106        fs::write(temp.path().join("symbolica_state.bin"), []).unwrap();
5107        fs::create_dir_all(temp.path().join("processes")).unwrap();
5108
5109        assert_eq!(
5110            classify_state_folder(temp.path()).unwrap(),
5111            StateFolderKind::Saved
5112        );
5113    }
5114
5115    #[test]
5116    fn state_folder_classifies_logs_only_folder_as_scratch() {
5117        let temp = tempdir().unwrap();
5118        fs::create_dir_all(temp.path().join("logs")).unwrap();
5119        fs::write(temp.path().join("logs").join("gammalog.jsonl"), "").unwrap();
5120
5121        assert_eq!(
5122            classify_state_folder(temp.path()).unwrap(),
5123            StateFolderKind::Scratch
5124        );
5125    }
5126
5127    #[test]
5128    fn state_folder_classifies_non_manifest_contents_as_unmanifested() {
5129        let temp = tempdir().unwrap();
5130        fs::create_dir_all(temp.path().join("processes").join("amplitudes")).unwrap();
5131
5132        assert_eq!(
5133            classify_state_folder(temp.path()).unwrap(),
5134            StateFolderKind::Unmanifested
5135        );
5136    }
5137
5138    #[test]
5139    fn activate_loaded_integrand_backends_falls_back_to_eager_when_external_artifacts_are_missing()
5140    {
5141        let mut state = build_generated_scalar_bubble_state_with_external_backend();
5142
5143        state.activate_loaded_integrand_backends(false).unwrap();
5144
5145        for process in &state.process_list.processes {
5146            match &process.collection {
5147                ProcessCollection::Amplitudes(amplitudes) => {
5148                    for amplitude in amplitudes.values() {
5149                        let integrand = amplitude.integrand.as_ref().unwrap();
5150                        if matches!(
5151                            integrand.frozen_compilation(),
5152                            FrozenCompilationMode::Cpp(_) | FrozenCompilationMode::Assembly(_)
5153                        ) {
5154                            assert_eq!(integrand.active_f64_backend(), ActiveF64Backend::Eager);
5155                        }
5156                    }
5157                }
5158                ProcessCollection::CrossSections(cross_sections) => {
5159                    for cross_section in cross_sections.values() {
5160                        let integrand = cross_section.integrand.as_ref().unwrap();
5161                        if matches!(
5162                            integrand.frozen_compilation(),
5163                            FrozenCompilationMode::Cpp(_) | FrozenCompilationMode::Assembly(_)
5164                        ) {
5165                            assert_eq!(integrand.active_f64_backend(), ActiveF64Backend::Eager);
5166                        }
5167                    }
5168                }
5169            }
5170        }
5171    }
5172
5173    #[test]
5174    fn saved_generated_process_runs_reference_acceptance_after_reload() -> Result<()> {
5175        use gammalooprs::{
5176            graph::GroupId,
5177            settings::runtime::{
5178                DiscreteGraphSamplingSettings, DiscreteGraphSamplingType, SamplingSettings,
5179                StabilityLevelSetting,
5180            },
5181        };
5182        use symbolica::numerical_integration::Sample;
5183
5184        let _guard = crate::LOG_TEST_MUTEX
5185            .lock()
5186            .unwrap_or_else(|err| err.into_inner());
5187        let bubble = build_generated_scalar_bubble_state_with_external_backend();
5188        let mut kite = State::new_test();
5189        kite.model = load_generic_model("scalars");
5190        kite.model.get_parameter_mut("mass_scalar_1")?.value = Some(Complex::new_re(F(1.0)));
5191        kite.model_parameters = InputParamCard::default_from_model(&kite.model);
5192        kite.import_graphs(
5193            Graph::from_path(
5194                crate::test_workspace_root().join("tests/resources/graphs/massive_kite.dot"),
5195                &kite.model,
5196            )?,
5197            GraphImportOptions {
5198                process_name: Some("sampling_kite".into()),
5199                process_id: None,
5200                process_definition: None,
5201                integrand_name: Some("default".into()),
5202                overwrite: false,
5203                append: false,
5204            },
5205        )?;
5206        let global: GlobalSettings = toml::from_str(
5207            r#"
5208[n_cores]
5209generate = 1
5210[generation]
5211override_lmb_heuristics = true
5212[generation.uv]
5213subtract_uv = false
5214generate_integrated = false
5215[generation.evaluator]
5216compile = false
5217summed = false
5218summed_function_map = true
5219iterative_orientation_optimization = false
5220"#,
5221        )?;
5222        let mut runtime: RuntimeSettings = toml::from_str(
5223            r#"
5224[general]
5225evaluator_method = "SummedFunctionMap"
5226integral_unit = "none"
5227enable_cache = false
5228[kinematics]
5229e_cm = 5.0
5230[kinematics.externals]
5231type = "constant"
5232[kinematics.externals.data]
5233momenta = [[5.0, 0.0, 0.0, 0.0], "dependent"]
5234helicities = [0, 0]
5235[sampling]
5236graphs = "summed"
5237orientations = "summed"
5238sampling_multichanneling = true
5239sampling_channels = "summed"
5240power = 2.0
5241default_channel_selection = ["C", "D", "ordinary"]
5242[sampling.channel_definitions.massive_kite.C]
5243around = "surface(2,4,6)"
5244parent_lmb = [4,6]
5245subspace_lmb = [4,6]
5246[sampling.channel_definitions.massive_kite.D]
5247around = "surface(3,5,6)"
5248parent_lmb = [4,6]
5249subspace_lmb = [4,6]
5250[sampling.channel_definitions.massive_kite.ordinary]
5251around = "lmb(4,6)"
5252parent_lmb = [4,6]
5253[stability]
5254rotation_axis = [{type = "x"}, {type = "y"}]
5255"#,
5256        )?;
5257        runtime.stability.levels = vec![StabilityLevelSetting::default_double()];
5258        kite.generate_integrands(&global, (&runtime).into())?;
5259
5260        // Reuse the same saved-process acceptance path for a physical cut. Two
5261        // names deliberately share its geometry while retaining distinct radial
5262        // proposals; the broad components give the shifted Gaussian full support.
5263        let mut cut = State::new_test();
5264        cut.model = kite.model.clone();
5265        cut.model_parameters = InputParamCard::default_from_model(&cut.model);
5266        cut.import_graphs(
5267            Graph::from_string(
5268                r#"digraph sampling_cut_bubble {
5269                    num=1; edge [pdg=1001]; node [num=1];
5270                    ext [style=invis, is_cut=0];
5271                    ext -> a [id=0];
5272                    a -> b [id=1, lmb_id=0];
5273                    a -> b [id=2];
5274                    b -> ext [id=3];
5275                }"#,
5276                &cut.model,
5277            )?,
5278            GraphImportOptions {
5279                process_name: Some("sampling_cut".into()),
5280                process_id: None,
5281                process_definition: None,
5282                integrand_name: Some("default".into()),
5283                overwrite: false,
5284                append: false,
5285            },
5286        )?;
5287        let mut cut_runtime: RuntimeSettings = toml::from_str(
5288            r#"
5289[general]
5290evaluator_method = "SummedFunctionMap"
5291integral_unit = "none"
5292enable_cache = false
5293[kinematics]
5294e_cm = 5.0
5295[kinematics.externals]
5296type = "constant"
5297[kinematics.externals.data]
5298momenta = [[5.0, 0.0, 0.0, 0.0]]
5299helicities = ["summed_averaged"]
5300[sampling]
5301graphs = "summed"
5302orientations = "summed"
5303sampling_multichanneling = true
5304sampling_channels = "summed"
5305sampling_channel_weight = "map_density"
5306power = 2.0
5307default_channel_selection = ["matched", "wide"]
5308[sampling.channel_definitions.sampling_cut_bubble.matched]
5309around = "phase_space(cut(1,2))"
5310parent_lmb = [1]
5311subspace_lmb = [1]
5312radial_profile = "lu_h"
5313[sampling.channel_definitions.sampling_cut_bubble.wide]
5314around = "phase_space(cut(1,2))"
5315parent_lmb = [1]
5316subspace_lmb = [1]
5317radial_profile = { kind = "lu_h", approximation = "log_logistic", scale = 2.0, shape = 1.5, broad_fraction = 0.08 }
5318"#,
5319        )?;
5320        cut_runtime.stability.levels = vec![StabilityLevelSetting::default_double()];
5321        // Eager dual-program construction in an unoptimized test build needs
5322        // the same larger stack as the graph-generation fixtures.
5323        std::thread::scope(|scope| {
5324            std::thread::Builder::new()
5325                .stack_size(64 * 1024 * 1024)
5326                .spawn_scoped(scope, || {
5327                    cut.generate_integrands(&global, (&cut_runtime).into())
5328                })?
5329                .join()
5330                .unwrap()
5331        })?;
5332
5333        for (mut state, reference) in [
5334            (
5335                bubble,
5336                GaussianReferenceFunction::new(2.0, vec![0.7, -0.4, 0.2])?,
5337            ),
5338            (
5339                kite,
5340                GaussianReferenceFunction::new(1.2, vec![0.4, -0.3, 0.2, -0.2, 0.1, 0.35])?,
5341            ),
5342            (
5343                cut,
5344                GaussianReferenceFunction::new(1.5, vec![0.2, -0.3, 0.1])?,
5345            ),
5346        ] {
5347            let temp = tempdir()?;
5348            let saved = temp.path().join("saved");
5349            state.save(&saved, true, false)?;
5350            let mut loaded = State::load(saved, None, None)?;
5351            loaded.activate_loaded_integrand_backends(false)?;
5352            let integrand = loaded.process_list.get_integrand_mut(0, "default")?;
5353            std::thread::scope(|scope| {
5354                std::thread::Builder::new()
5355                    .stack_size(64 * 1024 * 1024)
5356                    .spawn_scoped(scope, || integrand.warm_up(&loaded.model))?
5357                    .join()
5358                    .unwrap()
5359            })?;
5360
5361            // Exercise the complete saved-process parameterization with genuine
5362            // multidimensional Halton points. Finiteness, normalization, moments
5363            // and Jacobian propagation are independent of a trained integration
5364            // grid. The deterministic dispersion is not a randomized-MC error.
5365            let coordinates = (1..=8192)
5366                .map(|index| {
5367                    [2, 3, 5, 7, 11, 13]
5368                        .iter()
5369                        .take(reference.center().len())
5370                        .map(|&base| {
5371                            let (mut index, mut fraction, mut value) = (index, 1.0, 0.0);
5372                            while index > 0 {
5373                                fraction /= base as f64;
5374                                value += (index % base) as f64 * fraction;
5375                                index /= base;
5376                            }
5377                            value
5378                        })
5379                        .collect::<Vec<_>>()
5380                })
5381                .collect::<Vec<_>>();
5382            let report = integrand.evaluate_reference_coordinates(&coordinates, &reference)?;
5383            assert_eq!(report.sample_count, coordinates.len());
5384            assert_eq!(report.finite_sample_count, coordinates.len());
5385            assert!((report.normalization - 1.0).abs() < 2.0e-2, "{report:?}");
5386            assert!(
5387                (report.second_moment - report.expected_second_moment).abs() < 2.0e-1,
5388                "{report:?}"
5389            );
5390
5391            if reference.center().len() == 6 {
5392                // The same saved three-channel catalogue must also reproduce
5393                // the full threshold-subtracted amplitude under explicit MC
5394                // selection; the reference overlay cannot stand in for this gate.
5395                let original_settings = integrand.get_settings().clone();
5396                let SamplingSettings::MultiChanneling(multichanneling) =
5397                    original_settings.sampling.clone()
5398                else {
5399                    panic!("expected summed kite channels")
5400                };
5401                let ids = integrand.group_sampling_channel_ids(
5402                    GroupId(0),
5403                    &multichanneling.parameterization_settings,
5404                )?;
5405                assert_eq!(ids.len(), 3);
5406                let xs = vec![F(0.19), F(0.27), F(0.61), F(0.39), F(0.72), F(0.58)];
5407                let summed_sample = Sample::Continuous(F(1.0), xs.clone());
5408                let summed = integrand
5409                    .evaluate_samples_raw(
5410                        EvaluationTarget::Physical(&loaded.model),
5411                        std::slice::from_ref(&summed_sample),
5412                        0,
5413                        false,
5414                        false,
5415                        Complex::new_zero(),
5416                    )?
5417                    .samples
5418                    .remove(0);
5419                let summed_reference =
5420                    integrand.evaluate_reference_sample_detailed(&summed_sample, &reference)?;
5421                let mut settings = original_settings.clone();
5422                settings.sampling =
5423                    SamplingSettings::DiscreteGraphs(DiscreteGraphSamplingSettings {
5424                        sample_orientations: false,
5425                        sampling_type: DiscreteGraphSamplingType::SamplingMultiChanneling(
5426                            multichanneling,
5427                        ),
5428                        ..Default::default()
5429                    });
5430                *integrand.get_mut_settings() = settings;
5431                integrand.warm_up(&loaded.model)?;
5432                let mut expected = Complex::<F<f64>>::new_zero();
5433                let mut expected_reference = (0.0, 0.0);
5434                for channel_id in ids {
5435                    let sample = Sample::Discrete(
5436                        F(1.0),
5437                        0,
5438                        Some(Box::new(Sample::Discrete(
5439                            F(1.0),
5440                            channel_id.0,
5441                            Some(Box::new(Sample::Continuous(F(1.0), xs.clone()))),
5442                        ))),
5443                    );
5444                    let result = integrand
5445                        .evaluate_samples_raw(
5446                            EvaluationTarget::Physical(&loaded.model),
5447                            std::slice::from_ref(&sample),
5448                            0,
5449                            false,
5450                            false,
5451                            Complex::new_zero(),
5452                        )?
5453                        .samples
5454                        .remove(0);
5455                    expected += result.integrand_result * result.parameterization_jacobian.unwrap();
5456                    let result =
5457                        integrand.evaluate_reference_sample_detailed(&sample, &reference)?;
5458                    let jacobian = result.evaluation.parameterization_jacobian.unwrap().0;
5459                    expected_reference.0 += result.evaluation.integrand_result.re.0 * jacobian;
5460                    expected_reference.1 += result.moments.second_moment.0 * jacobian;
5461                }
5462                let actual = summed.integrand_result * summed.parameterization_jacobian.unwrap();
5463                let scale = expected.re.0.abs() + expected.im.0.abs();
5464                assert!(scale > 1.0e-18 && scale.is_finite());
5465                assert!(
5466                    (actual.re.0 - expected.re.0).abs() + (actual.im.0 - expected.im.0).abs()
5467                        < 1.0e-9 * scale
5468                );
5469                let jacobian = summed_reference
5470                    .evaluation
5471                    .parameterization_jacobian
5472                    .unwrap()
5473                    .0;
5474                for (actual, expected) in [
5475                    (
5476                        summed_reference.evaluation.integrand_result.re.0 * jacobian,
5477                        expected_reference.0,
5478                    ),
5479                    (
5480                        summed_reference.moments.second_moment.0 * jacobian,
5481                        expected_reference.1,
5482                    ),
5483                ] {
5484                    assert!((actual - expected).abs() < 1.0e-10 * expected.abs().max(1.0));
5485                }
5486            }
5487
5488            // The loaded-state command uses the real sampling/training grids,
5489            // worker clones, native target and complex statistics. Re and Im
5490            // are normalization and normalized raw moment, not physical phases.
5491            std::thread::scope(|scope| {
5492                std::thread::Builder::new()
5493                    .stack_size(64 * 1024 * 1024)
5494                    .spawn_scoped(scope, || -> Result<()> {
5495                        use crate::commands::integrate::Integrate;
5496                        use gammalooprs::integrands::Integrand;
5497                        use gammalooprs::integrate::{
5498                            havana_integrate, slot_workspace_path, workspace_manifest_path,
5499                            workspace_state_path, HavanaIntegrateRequest, IntegrationSlot,
5500                            IntegrationState, IntegrationStatusKind, IntegrationWorkspaceManifest,
5501                            IterationBatchingSettings, SamplingCorrelationMode, SlotMeta,
5502                            WorkspaceSnapshotControl,
5503                        };
5504                        use gammalooprs::utils::serde_utils::SmartSerde;
5505                        use symbolica::numerical_integration::{Grid, MonteCarloRng};
5506
5507                        let process = loaded.process_list.get_integrand_mut(0, "default")?;
5508                        let settings = process.get_mut_settings();
5509                        settings.integrator.n_bins = 8;
5510                        settings.integrator.min_samples_for_update = 4;
5511                        settings.integrator.n_start = 256;
5512                        settings.integrator.n_increase = 0;
5513                        settings.integrator.n_max = 512;
5514                        settings.integrator.seed = 7331;
5515                        let mut command = Integrate::from_slots([(ProcessRef::Id(0), "default")]);
5516                        command.n_cores = Some(2);
5517                        command.workspace_path = Some(temp.path().join("reference_full"));
5518                        command.batch_size = Some(64);
5519                        command.batch_timing = 0.0;
5520                        // Exercise centered shorthand as well as shifted descriptors.
5521                        command.reference_gaussian = Some(
5522                            if process.kind_name() == "amplitude" && reference.center().len() == 3 {
5523                                GaussianReferenceFunction::new(reference.width(), vec![])?
5524                            } else {
5525                                reference.clone()
5526                            },
5527                        );
5528                        let resolved = command
5529                            .reference_gaussian
5530                            .as_ref()
5531                            .unwrap()
5532                            .for_integrand(process)?;
5533                        let mut cli = CLISettings::default();
5534                        cli.state.folder = temp.path().join("saved");
5535                        cli.session.set_user_requested_read_only_state(true);
5536                        let source_path = generated_integrand_artifact_path(
5537                            &cli.state.folder,
5538                            &loaded.process_list.processes[0],
5539                            "default",
5540                        )
5541                        .join("integrand.bin");
5542                        let before_source = fs::read(&source_path)?;
5543                        let full = command.run(&mut loaded, &cli)?;
5544                        let result = full.result.single_slot().unwrap();
5545                        assert_eq!(result.integral.neval, 512);
5546                        assert_eq!(result.target, Some(Complex::new(F(1.0), F(1.0))));
5547                        for (mean, error) in [
5548                            (result.integral.result.re.0, result.integral.error.re.0),
5549                            (result.integral.result.im.0, result.integral.error.im.0),
5550                        ] {
5551                            assert!(mean.is_finite() && error.is_finite() && error > 0.0);
5552                            assert!(
5553                                (mean - 1.0).abs() < 6.0 * error,
5554                                "reference mean={mean}, error={error}"
5555                            );
5556                        }
5557                        assert!(full.observables.is_empty());
5558                        let meta = SlotMeta {
5559                            process_name: result.process.clone(),
5560                            integrand_name: result.integrand.clone(),
5561                        };
5562                        let manifest = IntegrationWorkspaceManifest::from_file(
5563                            workspace_manifest_path(&full.workspace_path),
5564                            "reference manifest",
5565                        )?;
5566                        assert_eq!(manifest.reference_gaussians, vec![Some(resolved.clone())]);
5567                        assert_eq!(
5568                            manifest.reference_observable_convention.as_deref(),
5569                            Some(GaussianReferenceFunction::INTEGRATION_CONVENTION)
5570                        );
5571
5572                        // Produce a completed first-iteration checkpoint using the same
5573                        // engine and interrupt hook, then resume it through the CLI owner.
5574                        let partial = temp.path().join("reference_resume");
5575                        fs::create_dir_all(slot_workspace_path(&partial, &meta))?;
5576                        fs::copy(
5577                            workspace_manifest_path(&full.workspace_path),
5578                            workspace_manifest_path(&partial),
5579                        )?;
5580                        fs::copy(
5581                            slot_workspace_path(&full.workspace_path, &meta).join("settings.toml"),
5582                            slot_workspace_path(&partial, &meta).join("settings.toml"),
5583                        )?;
5584                        let process = loaded.process_list.get_integrand_mut(0, "default")?.clone();
5585                        let settings = process.get_settings().clone();
5586                        let mut slot = IntegrationSlot::new(
5587                            meta,
5588                            settings.clone(),
5589                            loaded.model.clone(),
5590                            Integrand::ProcessIntegrand(Box::new(process)),
5591                            Some(Complex::new(F(1.0), F(1.0))),
5592                        );
5593                        slot.reference_gaussian = Some(resolved.clone());
5594                        let stopped = havana_integrate(
5595                            HavanaIntegrateRequest {
5596                                slots: vec![slot],
5597                                sampling_correlation_mode: SamplingCorrelationMode::Correlated,
5598                                n_cores: 2,
5599                                state: None,
5600                                workspace: Some(partial.clone()),
5601                                output_control: WorkspaceSnapshotControl::default(),
5602                                batching: IterationBatchingSettings {
5603                                    batch_size: Some(64),
5604                                    batch_timing_seconds: 0.0,
5605                                    ..Default::default()
5606                                },
5607                                view_options: command.build_render_options(&[settings], false),
5608                            },
5609                            |status| {
5610                                if status.kind() == IntegrationStatusKind::Iteration {
5611                                    gammalooprs::request_interrupt();
5612                                }
5613                                Ok(())
5614                            },
5615                        );
5616                        gammalooprs::clear_interrupt_request();
5617                        stopped?;
5618                        let bytes = fs::read(workspace_state_path(&partial))?;
5619                        let (checkpoint, _): (IntegrationState, usize) =
5620                            bincode::decode_from_slice(&bytes, bincode::config::standard())?;
5621                        assert_eq!(checkpoint.iter, 1);
5622                        assert_eq!(checkpoint.num_points, 256);
5623                        // A draw from the trained checkpoint proves that both observables
5624                        // retain actual non-unit grid probabilities exactly once.
5625                        let serialized = serde_json::to_value(&checkpoint)?;
5626                        let mut grid: Grid<F<f64>> = serde_json::from_value(
5627                            serialized["sampling_states"][0]["grid"].clone(),
5628                        )?;
5629                        let mut rng = MonteCarloRng::new(42, 0);
5630                        let mut point = Sample::new();
5631                        grid.sample(&mut rng, &mut point);
5632                        assert!((point.get_weight().0 - 1.0).abs() > 1.0e-6);
5633                        let process = loaded.process_list.get_integrand_mut(0, "default")?;
5634                        let detailed =
5635                            process.evaluate_reference_sample_detailed(&point, &resolved)?;
5636                        let pair = process
5637                            .evaluate_samples_raw(
5638                                EvaluationTarget::Reference(&resolved),
5639                                &[point.clone()],
5640                                0,
5641                                false,
5642                                false,
5643                                Complex::new_zero(),
5644                            )?
5645                            .samples
5646                            .remove(0);
5647                        assert_eq!(pair.integrator_weight, point.get_weight());
5648                        assert!(
5649                            (pair.integrand_result.re.0
5650                                - detailed.evaluation.integrand_result.re.0)
5651                                .abs()
5652                                < 1.0e-12
5653                        );
5654                        assert!(
5655                            (pair.integrand_result.im.0
5656                                - detailed.moments.second_moment.0
5657                                    / resolved.expected_second_moment::<f64>().0)
5658                                .abs()
5659                                < 1.0e-12
5660                        );
5661
5662                        command.workspace_path = Some(partial.clone());
5663                        let resumed = command.run(&mut loaded, &cli)?;
5664                        let actual = &resumed.result.single_slot().unwrap().integral;
5665                        assert_eq!(actual.neval, result.integral.neval);
5666                        assert_eq!(actual.result, result.integral.result);
5667                        assert_eq!(actual.error, result.integral.error);
5668                        assert_eq!(fs::read(&source_path)?, before_source);
5669                        // A mismatch must fail before saved settings are restored.
5670                        loaded
5671                            .process_list
5672                            .get_integrand_mut(0, "default")?
5673                            .get_mut_settings()
5674                            .integrator
5675                            .seed = 19;
5676                        let mut physical = command.clone();
5677                        physical.reference_gaussian = None;
5678                        let unchanged = fs::read(workspace_state_path(&partial))?;
5679                        assert!(physical
5680                            .run(&mut loaded, &cli)
5681                            .unwrap_err()
5682                            .to_string()
5683                            .contains("physical/reference"));
5684                        assert_eq!(
5685                            loaded
5686                                .process_list
5687                                .get_integrand_mut(0, "default")?
5688                                .get_settings()
5689                                .integrator
5690                                .seed,
5691                            19
5692                        );
5693                        assert_eq!(fs::read(workspace_state_path(&partial))?, unchanged);
5694                        command.target = vec!["1,2".into()];
5695                        assert!(command
5696                            .run(&mut loaded, &cli)
5697                            .unwrap_err()
5698                            .to_string()
5699                            .contains("contradictory"));
5700                        Ok(())
5701                    })?
5702                    .join()
5703                    .unwrap()
5704            })?;
5705        }
5706        Ok(())
5707    }
5708
5709    #[test]
5710    fn summed_sampling_matches_explicit_canonical_channels() -> Result<()> {
5711        use gammalooprs::{
5712            graph::GroupId,
5713            settings::runtime::{
5714                DiscreteGraphSamplingSettings, DiscreteGraphSamplingType, SamplingSettings,
5715                StabilityLevelSetting,
5716            },
5717        };
5718        use symbolica::numerical_integration::Sample;
5719
5720        use gammalooprs::integrands::evaluation::StabilityStatus;
5721
5722        let _guard = crate::LOG_TEST_MUTEX
5723            .lock()
5724            .unwrap_or_else(|err| err.into_inner());
5725        let mut state = build_generated_scalar_bubble_state_with_external_backend();
5726        state.activate_loaded_integrand_backends(false)?;
5727        let integrand = state.process_list.get_integrand_mut(0, "default")?;
5728        let mut settings: RuntimeSettings = toml::from_str(
5729            r#"
5730[kinematics]
5731e_cm = 2.0
5732[kinematics.externals]
5733type = "constant"
5734[kinematics.externals.data]
5735momenta = [[0.5, 0.2, 0.3, 0.1], "dependent"]
5736helicities = [0, 0]
5737[sampling]
5738graphs = "summed"
5739orientations = "summed"
5740sampling_multichanneling = true
5741sampling_channels = "summed"
5742default_channel_selection = ["auto:lmb"]
5743[stability]
5744rotation_axis = [{type = "x"}, {type = "y"}]
5745"#,
5746        )?;
5747        settings.stability.levels = vec![StabilityLevelSetting::default_double()];
5748        let SamplingSettings::MultiChanneling(multichanneling) = settings.sampling.clone() else {
5749            panic!("expected summed sampling settings");
5750        };
5751        let channel_ids = integrand
5752            .group_sampling_channel_ids(GroupId(0), &multichanneling.parameterization_settings)?;
5753        assert_eq!(
5754            channel_ids.len(),
5755            2,
5756            "bubble must exercise two distinct affine LMB charts"
5757        );
5758        let coordinates = [
5759            vec![F(0.27), F(0.36), F(0.71)],
5760            vec![F(0.62), F(0.24), F(0.53)],
5761        ];
5762        let samples = coordinates
5763            .iter()
5764            .map(|xs| Sample::Continuous(F(1.0), xs.clone()))
5765            .collect::<Vec<_>>();
5766        *integrand.get_mut_settings() = settings.clone();
5767        integrand.warm_up(&state.model)?;
5768        let reference = GaussianReferenceFunction::new(2.0, vec![0.7, -0.4, 0.2])?;
5769        let summed_reference = samples
5770            .iter()
5771            .map(|sample| integrand.evaluate_reference_sample_detailed(sample, &reference))
5772            .collect::<Result<Vec<_>>>()?;
5773        let summed = integrand.evaluate_samples_raw(
5774            EvaluationTarget::Physical(&state.model),
5775            &samples,
5776            0,
5777            false,
5778            false,
5779            Complex::new_zero(),
5780        )?;
5781
5782        settings.sampling = SamplingSettings::DiscreteGraphs(DiscreteGraphSamplingSettings {
5783            sample_orientations: false,
5784            sampling_type: DiscreteGraphSamplingType::SamplingMultiChanneling(
5785                multichanneling.clone(),
5786            ),
5787            ..Default::default()
5788        });
5789        *integrand.get_mut_settings() = settings.clone();
5790        integrand.warm_up(&state.model)?;
5791        let mut expected = vec![Complex::<F<f64>>::new_zero(); coordinates.len()];
5792        let mut expected_reference = vec![(0.0, 0.0); coordinates.len()];
5793        for channel_id in channel_ids {
5794            let samples = coordinates
5795                .iter()
5796                .map(|xs| {
5797                    Sample::Discrete(
5798                        F(1.0),
5799                        0,
5800                        Some(Box::new(Sample::Discrete(
5801                            F(1.0),
5802                            channel_id.0,
5803                            Some(Box::new(Sample::Continuous(F(1.0), xs.clone()))),
5804                        ))),
5805                    )
5806                })
5807                .collect::<Vec<_>>();
5808            for (expected, sample) in expected_reference.iter_mut().zip(&samples) {
5809                let result = integrand.evaluate_reference_sample_detailed(sample, &reference)?;
5810                let jacobian = result.evaluation.parameterization_jacobian.unwrap().0;
5811                expected.0 += result.evaluation.integrand_result.re.0 * jacobian;
5812                expected.1 += result.moments.second_moment.0 * jacobian;
5813            }
5814            let result = integrand.evaluate_samples_raw(
5815                EvaluationTarget::Physical(&state.model),
5816                &samples,
5817                0,
5818                false,
5819                false,
5820                Complex::new_zero(),
5821            )?;
5822            for (total, sample) in expected.iter_mut().zip(result.samples) {
5823                *total +=
5824                    sample.integrand_result * sample.parameterization_jacobian.unwrap_or(F(1.0));
5825            }
5826        }
5827        // Check both ways of summing channels. The physical evaluator and
5828        // stability rotations must see the same maps as explicit MC selections.
5829        settings.sampling = SamplingSettings::DiscreteGraphs(DiscreteGraphSamplingSettings {
5830            sample_orientations: false,
5831            sampling_type: DiscreteGraphSamplingType::MultiChanneling(multichanneling),
5832            ..Default::default()
5833        });
5834        *integrand.get_mut_settings() = settings;
5835        integrand.warm_up(&state.model)?;
5836        let samples = samples
5837            .into_iter()
5838            .map(|sample| Sample::Discrete(F(1.0), 0, Some(Box::new(sample))))
5839            .collect::<Vec<_>>();
5840        let group_summed_reference = samples
5841            .iter()
5842            .map(|sample| integrand.evaluate_reference_sample_detailed(sample, &reference))
5843            .collect::<Result<Vec<_>>>()?;
5844        for results in [summed_reference, group_summed_reference] {
5845            for (result, expected) in results.iter().zip(&expected_reference) {
5846                let jacobian = result.evaluation.parameterization_jacobian.unwrap().0;
5847                for (actual, expected) in [
5848                    (
5849                        result.evaluation.integrand_result.re.0 * jacobian,
5850                        expected.0,
5851                    ),
5852                    (result.moments.second_moment.0 * jacobian, expected.1),
5853                ] {
5854                    assert!(
5855                        (actual - expected).abs() < 1.0e-11 * expected.abs().max(1.0),
5856                        "summed reference {actual} differs from explicit channel sum {expected}"
5857                    );
5858                }
5859            }
5860        }
5861        let group_summed = integrand.evaluate_samples_raw(
5862            EvaluationTarget::Physical(&state.model),
5863            &samples,
5864            0,
5865            false,
5866            false,
5867            Complex::new_zero(),
5868        )?;
5869        for result in [summed, group_summed] {
5870            for (sample, expected) in result.samples.iter().zip(&expected) {
5871                let value =
5872                    sample.integrand_result * sample.parameterization_jacobian.unwrap_or(F(1.0));
5873                let scale = expected.re.0.abs() + expected.im.0.abs();
5874                assert!(
5875                    scale > 1.0e-18 && scale.is_finite(),
5876                    "invalid oracle {expected}"
5877                );
5878                let error = (value - expected).re.0.abs() + (value - expected).im.0.abs();
5879                assert!(
5880                    error < scale * 1.0e-10,
5881                    "summed {value} differs from explicit channels {expected}"
5882                );
5883                assert!(!sample.evaluation_metadata.is_nan);
5884                assert!(
5885                    sample
5886                        .evaluation_metadata
5887                        .stability_results
5888                        .iter()
5889                        .all(|level| matches!(level.status, StabilityStatus::Stable(_))),
5890                    "rotation invariance failed"
5891                );
5892            }
5893        }
5894        // Default selected-LMB sampling must be converted at the same boundary
5895        // as the reference overlay. Compare both targets to an independently
5896        // reconstructed raw point, including the nonzero external shift.
5897        use gammalooprs::{
5898            integrands::process::{
5899                sampling::context::SamplingMapContext, GraphTerm, MomentumSpaceEvaluationInput,
5900                ProcessIntegrand, ProcessIntegrandImpl, SamplingMapComponent,
5901                SamplingMapDefinition, SamplingMapKernel,
5902            },
5903            momentum::ThreeMomentum,
5904        };
5905        let mut settings = integrand.get_settings().clone();
5906        let mut parameterization = settings.sampling.get_parameterization_settings().unwrap();
5907        parameterization
5908            .lmb_basis_ids
5909            .insert("bubble".into(), vec![1]);
5910        parameterization.sampling_channels.default_channel_selection =
5911            vec!["auto:optimized_lmb".into()];
5912        settings.sampling = SamplingSettings::Default(parameterization.clone());
5913        *integrand.get_mut_settings() = settings.clone();
5914        integrand.warm_up(&state.model)?;
5915        let frame = {
5916            let ProcessIntegrand::Amplitude(amplitude) = integrand else {
5917                unreachable!()
5918            };
5919            let term = &amplitude.data.graph_terms[0];
5920            let basis = term.selected_lmb_basis_id(&parameterization)?;
5921            assert_eq!(usize::from(basis), 1);
5922            let externals = settings
5923                .kinematics
5924                .externals
5925                .get_dependent_externals::<f64>(amplitude.get_dependent_momenta_constructor())?;
5926            let externals = externals
5927                .iter()
5928                .map(|p| {
5929                    [
5930                        p.temporal.value.0,
5931                        p.spatial.px.0,
5932                        p.spatial.py.0,
5933                        p.spatial.pz.0,
5934                    ]
5935                })
5936                .collect::<Vec<_>>();
5937            let setup = term.sampling_setup();
5938            setup.lmb_frame_map(&setup.all_bases[basis], &externals)?
5939        };
5940        let kernel = SamplingMapKernel::new(
5941            SamplingMapDefinition::Lmb(vec![0]),
5942            parameterization,
5943            settings.kinematics.e_cm,
5944            1,
5945        )?;
5946        let xs = [0.27, 0.36, 0.71];
5947        let selected =
5948            SamplingMapComponent::forward(&kernel, &xs, &mut SamplingMapContext::detached(&[]))?;
5949        let raw = frame.forward(&selected.point, &mut SamplingMapContext::detached(&[]))?;
5950        let sampling_jacobian = selected.jacobian * raw.jacobian;
5951        let sample = Sample::Continuous(F(1.0), xs.into_iter().map(F).collect());
5952        let reference_result = integrand.evaluate_reference_sample_detailed(&sample, &reference)?;
5953        let distance_squared = raw
5954            .point
5955            .iter()
5956            .zip(reference.center())
5957            .map(|(x, c)| (x - c).powi(2))
5958            .sum::<f64>();
5959        let expected = (-distance_squared / (2.0 * reference.width().powi(2))).exp()
5960            / (2.0 * std::f64::consts::PI * reference.width().powi(2)).powf(1.5);
5961        // X-space results and moments now contain their native Jacobian.
5962        // Undo the independently reconstructed map factor for this raw-frame oracle.
5963        assert_eq!(
5964            reference_result.evaluation.parameterization_jacobian,
5965            Some(F(1.0))
5966        );
5967        assert!(
5968            (reference_result.evaluation.integrand_result.re.0 / sampling_jacobian - expected)
5969                .abs()
5970                < expected * 1.0e-12
5971        );
5972        let radius_squared = raw.point.iter().map(|x| x * x).sum::<f64>();
5973        assert!(
5974            (reference_result.moments.second_moment.0 / sampling_jacobian
5975                - expected * radius_squared)
5976                .abs()
5977                < 1.0e-12
5978        );
5979        let physical = integrand
5980            .evaluate_samples_raw(
5981                EvaluationTarget::Physical(&state.model),
5982                &[sample],
5983                0,
5984                false,
5985                false,
5986                Complex::new_zero(),
5987            )?
5988            .samples
5989            .remove(0);
5990        let raw_result = integrand.evaluate_momentum_configuration(
5991            &state.model,
5992            &MomentumSpaceEvaluationInput {
5993                loop_momenta: raw
5994                    .point
5995                    .as_chunks::<3>()
5996                    .0
5997                    .iter()
5998                    .map(|p| ThreeMomentum {
5999                        px: F(p[0]),
6000                        py: F(p[1]),
6001                        pz: F(p[2]),
6002                    })
6003                    .collect(),
6004                integrator_weight: F(1.0),
6005                graph_id: Some(0),
6006                group_id: None,
6007                orientation: None,
6008                channel_id: None,
6009            },
6010            false,
6011        )?;
6012        assert_eq!(physical.parameterization_jacobian, Some(F(1.0)));
6013        let delta = physical.integrand_result / Complex::new_re(F(sampling_jacobian))
6014            - raw_result.integrand_result;
6015        let scale = raw_result.integrand_result.re.0.abs() + raw_result.integrand_result.im.0.abs();
6016        assert!(scale > 1.0e-18 && scale.is_finite());
6017        assert!(delta.re.0.abs() + delta.im.0.abs() < 1.0e-10 * scale);
6018        Ok(())
6019    }
6020
6021    #[test]
6022    fn resolve_effective_model_parameter_card_overlays_runtime_model_settings() {
6023        let mut state = State::new_test();
6024        state.model = load_generic_model("scalars");
6025        state.model_parameters = InputParamCard::default_from_model(&state.model);
6026
6027        let mut settings = RuntimeSettings::default();
6028        settings
6029            .model
6030            .external_parameters
6031            .insert("mass_scalar_2".to_string(), (F(7.5), F(0.0)));
6032
6033        let resolved = state
6034            .resolve_effective_model_parameter_card_for_settings(&settings)
6035            .unwrap();
6036
6037        assert_eq!(
6038            resolved[&UFOSymbol::from("mass_scalar_2")],
6039            Complex::new(F(7.5), F(0.0))
6040        );
6041        assert_eq!(
6042            resolved[&UFOSymbol::from("mass_scalar_1")],
6043            state.model_parameters[&UFOSymbol::from("mass_scalar_1")]
6044        );
6045    }
6046
6047    #[test]
6048    fn resolve_effective_model_parameter_card_rejects_non_overridable_parameters() {
6049        let mut state = State::new_test();
6050        state.model = load_generic_model("scalars");
6051        state.model_parameters = InputParamCard::default_from_model(&state.model);
6052        state
6053            .model_parameters
6054            .remove(&UFOSymbol::from("mass_scalar_2"));
6055
6056        let mut settings = RuntimeSettings::default();
6057        settings
6058            .model
6059            .external_parameters
6060            .insert("mass_scalar_2".to_string(), (F(7.5), F(0.0)));
6061
6062        let err = state
6063            .resolve_effective_model_parameter_card_for_settings(&settings)
6064            .unwrap_err();
6065
6066        assert!(err
6067            .to_string()
6068            .contains("cannot be overridden because it is not present"));
6069    }
6070}
6071
6072#[derive(Args, Debug, Clone)]
6073pub struct ExistingArgs {
6074    pub process_id: u32,
6075    pub name: Option<String>,
6076}