Skip to main content

gammaloop_api/commands/
set.rs

1use std::{collections::BTreeMap, fs, path::PathBuf, str::FromStr};
2
3use clap::Subcommand;
4use color_eyre::{Result, Section};
5use eyre::{eyre, Context, Report};
6use figment::{
7    providers::{Format, Serialized},
8    value::{Dict, Value as FigmentValue},
9    Figment,
10};
11use gammalooprs::{
12    model::{ParameterNature, UFOSymbol},
13    processes::ProcessCollection,
14    settings::RuntimeSettings,
15    utils::F,
16};
17use schemars::JsonSchema;
18use serde::{de::DeserializeOwned, Deserialize, Serialize};
19use serde_json::Value as JsonValue;
20use spenso::algebra::complex::Complex;
21use tracing::warn;
22
23use crate::{
24    commands::generate::ProcessArgs,
25    commands::process_settings::{
26        observable_template, parse_quantity_kind, parse_selector_kind, quantity_template,
27        selector_template,
28    },
29    commands::Commands,
30    model_parameters::{
31        external_model_parameter_type, parse_model_parameter_value, validate_model_parameter_type,
32    },
33    state::{State, SyncSettings},
34    tracing::{clear_file_log_filter_override_on_settings_change, file_log_boot_disabled_reason},
35    CLISettings,
36};
37
38// Keep Figment's source metadata and profile handling while sharing the TOML
39// parser used by the rest of the API.
40struct Toml;
41
42impl Format for Toml {
43    type Error = toml::de::Error;
44
45    const NAME: &'static str = "TOML";
46
47    fn from_str<T: DeserializeOwned>(string: &str) -> Result<T, Self::Error> {
48        toml::from_str(string)
49    }
50}
51
52impl FromStr for Set {
53    type Err = Report;
54    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
55        if let Commands::Set(cmd) = Commands::from_str(s)? {
56            Ok(cmd)
57        } else {
58            Err(eyre!("Not a 'set' command"))
59        }
60    }
61}
62
63#[derive(Subcommand, Debug, Serialize, Deserialize, Clone, JsonSchema, PartialEq)]
64pub enum Set {
65    /// Set the base directory for gammaloop state and logs
66    BaseDir {
67        /// Directory used to resolve relative state and log paths.
68        #[arg(value_hint = clap::ValueHint::DirPath)]
69        path: PathBuf,
70    },
71    /// Change global generation, logging, and parallelism settings.
72    Global {
73        #[command(subcommand)]
74        input: SetArgs,
75    },
76
77    /// Change the runtime settings inherited by newly generated integrands.
78    DefaultRuntime {
79        #[command(subcommand)]
80        input: SetArgs,
81    },
82
83    /// Assign model parameters in the selected context or reset them to defaults.
84    Model {
85        #[command(flatten)]
86        target: ProcessArgs,
87        /// Any number of PARAM=COMPLEX pairs, or 'defaults' for process-targeted resets
88        #[arg(
89            value_name = "defaults|PARAM=COMPLEX",
90            num_args = 1..,
91            value_parser = ModelSetValue::from_str
92        )]
93        values: Vec<ModelSetValue>,
94    },
95
96    /// Change runtime settings or named analysis objects for one process or integrand.
97    Process {
98        #[command(subcommand)]
99        input: ProcessSetArgs,
100        #[command(flatten)]
101        process: ProcessArgs,
102    },
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
106pub enum ModelSetValue {
107    Defaults,
108    Assignment(KvPair),
109}
110
111impl FromStr for ModelSetValue {
112    type Err = String;
113
114    fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
115        if value.trim() == "defaults" {
116            return Ok(Self::Defaults);
117        }
118        KvPair::from_str(value).map(Self::Assignment)
119    }
120}
121
122fn resolve_generated_model_targets(
123    state: &State,
124    target: &ProcessArgs,
125) -> Result<Option<Vec<(usize, String)>>> {
126    match (&target.process, &target.integrand_name) {
127        (None, None) => Ok(None),
128        (None, Some(integrand_name)) => Ok(Some(vec![
129            state.find_generated_integrand_ref_by_name(integrand_name)?
130        ])),
131        (Some(process), None) => {
132            let process_id = state.resolve_process_ref(Some(process))?;
133            let process = &state.process_list.processes[process_id];
134            let generated = match &process.collection {
135                ProcessCollection::Amplitudes(amplitudes) => amplitudes
136                    .iter()
137                    .filter(|(_, amplitude)| amplitude.integrand.is_some())
138                    .map(|(name, _)| (process_id, name.clone()))
139                    .collect::<Vec<_>>(),
140                ProcessCollection::CrossSections(cross_sections) => cross_sections
141                    .iter()
142                    .filter(|(_, cross_section)| cross_section.integrand.is_some())
143                    .map(|(name, _)| (process_id, name.clone()))
144                    .collect::<Vec<_>>(),
145            };
146            if generated.is_empty() {
147                return Err(eyre!(
148                    "Process '{}' has no generated integrands. Per-integrand model parameters are only supported for generated integrands.",
149                    process.definition.folder_name
150                ));
151            }
152            Ok(Some(generated))
153        }
154        (Some(process), Some(integrand_name)) => {
155            let process_id = state.resolve_process_ref(Some(process))?;
156            let canonical_name = state.process_list.processes[process_id]
157                .collection
158                .find_integrand(Some(integrand_name.clone()))?;
159            state
160                .process_list
161                .get_integrand(process_id, &canonical_name)
162                .and_then(|resolved| resolved.require_generated().map(|_| resolved))
163                .with_context(|| {
164                    "Per-integrand model parameters are only supported for generated integrands"
165                })?;
166            Ok(Some(vec![(process_id, canonical_name)]))
167        }
168    }
169}
170
171fn generated_process_setting_targets_for_process(
172    state: &State,
173    process_id: usize,
174) -> Vec<(usize, String)> {
175    let process = &state.process_list.processes[process_id];
176    match &process.collection {
177        ProcessCollection::Amplitudes(amplitudes) => amplitudes
178            .iter()
179            .filter(|(_, amplitude)| amplitude.integrand.is_some())
180            .map(|(name, _)| (process_id, name.clone()))
181            .collect(),
182        ProcessCollection::CrossSections(cross_sections) => cross_sections
183            .iter()
184            .filter(|(_, cross_section)| cross_section.integrand.is_some())
185            .map(|(name, _)| (process_id, name.clone()))
186            .collect(),
187    }
188}
189
190fn resolve_process_settings_targets(
191    state: &State,
192    target: &ProcessArgs,
193) -> Result<Vec<(usize, String)>> {
194    match (&target.process, &target.integrand_name) {
195        (None, Some(_)) => Err(eyre!(
196            "--integrand-name requires --process for `set process`"
197        )),
198        (None, None) => Ok(state
199            .process_list
200            .processes
201            .iter()
202            .enumerate()
203            .flat_map(|(process_id, _)| {
204                generated_process_setting_targets_for_process(state, process_id)
205            })
206            .collect()),
207        (Some(process), None) => {
208            let process_id = state.resolve_process_ref(Some(process))?;
209            Ok(generated_process_setting_targets_for_process(
210                state, process_id,
211            ))
212        }
213        (Some(process), Some(integrand_name)) => {
214            let process_id = state.resolve_process_ref(Some(process))?;
215            let resolved = state
216                .process_list
217                .get_integrand(process_id, integrand_name)?;
218            resolved.require_generated()?;
219            Ok(vec![(process_id, resolved.canonical_name)])
220        }
221    }
222}
223
224fn apply_process_settings_transactionally(
225    state: &mut State,
226    input: &ProcessSetArgs,
227    target: &ProcessArgs,
228    default_runtime_settings: &RuntimeSettings,
229    runtime_model_validation: &RuntimeModelValidationContext,
230) -> Result<()> {
231    let targets = resolve_process_settings_targets(state, target)?;
232    let mut updated_settings = Vec::with_capacity(targets.len());
233
234    for (process_id, integrand_name) in &targets {
235        let mut settings = state
236            .process_list
237            .get_integrand(*process_id, integrand_name)?
238            .require_generated()?
239            .get_settings()
240            .clone();
241        apply_process_set_args(
242            input,
243            &mut settings,
244            default_runtime_settings,
245            Some(runtime_model_validation),
246        )?;
247        updated_settings.push(settings);
248    }
249
250    for ((process_id, integrand_name), settings) in targets.into_iter().zip(updated_settings) {
251        *state
252            .process_list
253            .get_integrand_mut(process_id, &integrand_name)?
254            .get_mut_settings() = settings;
255    }
256
257    Ok(())
258}
259
260#[allow(clippy::type_complexity)]
261fn parse_model_assignments(
262    state: &State,
263    values: &[ModelSetValue],
264) -> Result<Option<Vec<(String, Complex<F<f64>>)>>> {
265    if values
266        .iter()
267        .any(|value| matches!(value, ModelSetValue::Defaults))
268    {
269        if values.len() != 1 {
270            return Err(eyre!(
271                "Model parameter updates cannot mix 'defaults' with explicit PARAM=VALUE assignments"
272            ));
273        }
274        return Ok(None);
275    }
276
277    let assignments = values
278        .iter()
279        .map(|value| match value {
280            ModelSetValue::Assignment(KvPair { key, value }) => {
281                let parameter = state
282                    .model
283                    .get_parameter_opt(key)
284                    .filter(|parameter| parameter.nature == ParameterNature::External);
285                if parameter.is_none() {
286                    let possibilities = state
287                        .model_parameters
288                        .keys()
289                        .map(|s| s.to_string())
290                        .collect::<Vec<_>>();
291                    return Err(eyre!("No model parameter named '{key}'")).with_context(|| {
292                        format!(
293                            "Possible model parameters are: {}",
294                            possibilities.join(", ")
295                        )
296                    });
297                }
298                let parameter_type = parameter.unwrap().parameter_type.clone();
299                let value = parse_model_parameter_value(value).with_context(|| {
300                    format!("While parsing model parameter value {value} for key '{key}'")
301                })?;
302                validate_model_parameter_type(key, parameter_type, &value)?;
303                Ok((key.clone(), value))
304            }
305            ModelSetValue::Defaults => unreachable!("defaults handled above"),
306        })
307        .collect::<Result<Vec<_>>>()?;
308
309    Ok(Some(assignments))
310}
311
312fn apply_model_assignments(
313    settings: &mut RuntimeSettings,
314    default_model_parameters: &gammalooprs::model::InputParamCard<F<f64>>,
315    assignments: &[(String, Complex<F<f64>>)],
316) -> Result<()> {
317    for (parameter_name, value) in assignments {
318        let symbol = UFOSymbol::from(parameter_name.as_str());
319        let default_value = default_model_parameters.get(&symbol).ok_or_else(|| {
320            eyre!(
321                "Model parameter '{parameter_name}' cannot be overridden because it is not present in the shared top-level model_parameters.json"
322            )
323        })?;
324
325        if default_value == value {
326            settings.model.external_parameters.remove(parameter_name);
327        } else {
328            settings
329                .model
330                .external_parameters
331                .insert(parameter_name.clone(), (value.re, value.im));
332        }
333    }
334
335    Ok(())
336}
337
338impl Set {
339    pub fn run(
340        &self,
341        state: &mut State,
342        global_settings: &mut CLISettings,
343        default_runtime_settings: &mut RuntimeSettings,
344    ) -> Result<()> {
345        let runtime_model_validation = RuntimeModelValidationContext::from_state(state);
346        match self {
347            Set::Model { target, values } => {
348                let targets = resolve_generated_model_targets(state, target)?;
349                let assignments = parse_model_assignments(state, values)?;
350
351                if let Some(targets) = targets {
352                    let default_model_parameters = state.model_parameters.clone();
353                    if let Some(assignments) = assignments.as_ref() {
354                        for (process_id, integrand_name) in targets {
355                            let settings = state
356                                .process_list
357                                .get_integrand_mut(process_id, &integrand_name)?
358                                .get_mut_settings();
359                            apply_model_assignments(
360                                settings,
361                                &default_model_parameters,
362                                assignments,
363                            )?;
364                        }
365                    } else {
366                        for (process_id, integrand_name) in targets {
367                            let settings = state
368                                .process_list
369                                .get_integrand_mut(process_id, &integrand_name)?
370                                .get_mut_settings();
371                            settings.model = default_runtime_settings.model.clone();
372                        }
373                    }
374                } else {
375                    let Some(assignments) = assignments else {
376                        return Err(eyre!(
377                            "The 'defaults' shortcut for set model requires a process or integrand target"
378                        ));
379                    };
380
381                    for (parameter_name, value) in assignments {
382                        if let Some(parameter) = state
383                            .model_parameters
384                            .get_mut(&UFOSymbol::from(parameter_name.as_str()))
385                        {
386                            *parameter = value;
387                            continue;
388                        }
389                        let possibilities = state
390                            .model_parameters
391                            .keys()
392                            .map(|s| s.to_string())
393                            .collect::<Vec<_>>();
394                        return Err(eyre!("No model parameter named '{parameter_name}'"))
395                            .with_context(|| {
396                                format!(
397                                    "Possible model parameters are: {}",
398                                    possibilities.join(", ")
399                                )
400                            });
401                    }
402                    state.model_parameters.apply_to_model(&mut state.model)?;
403                }
404            }
405            Set::BaseDir { path } => {
406                warn!(
407                    "Ignoring base-dir change request to '{}': state folder is fixed for the current session ('{}')",
408                    path.display(),
409                    global_settings.state.folder.display()
410                );
411            }
412            Self::Global { input } => {
413                let fig = Figment::from(Serialized::defaults(&global_settings));
414                let updates_display_directive = input.updates_global_display_directive()?;
415                let updates_logfile_directive = input.updates_global_logfile_directive()?;
416
417                if updates_logfile_directive {
418                    if let Some(reason) = file_log_boot_disabled_reason() {
419                        return Err(eyre!(
420                            "Cannot change global.logfile_directive because this session was started with the logfile logger disabled ({reason})"
421                        ));
422                    }
423                }
424
425                *global_settings = input.merge_figment(fig)?.extract()?;
426                if updates_display_directive {
427                    crate::tracing::set_stderr_log_filter_override(None)?;
428                }
429                if updates_logfile_directive {
430                    clear_file_log_filter_override_on_settings_change()?;
431                }
432                global_settings.sync_settings()?;
433            }
434            Self::DefaultRuntime { input } => {
435                let merged = merge_runtime_settings_input(default_runtime_settings, input)?;
436                runtime_model_validation.validate(&merged)?;
437                *default_runtime_settings = merged;
438            }
439            Self::Process { input, process } => {
440                apply_process_settings_transactionally(
441                    state,
442                    input,
443                    process,
444                    default_runtime_settings,
445                    &runtime_model_validation,
446                )?;
447            }
448        }
449        Ok(())
450    }
451}
452
453// Shared input forms for set commands
454#[derive(Subcommand, Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
455pub enum SetArgs {
456    /// Merge a TOML file into the current global or default-runtime settings.
457    File {
458        /// TOML settings file merged into the current global or default-runtime settings.
459        #[arg(value_hint = clap::ValueHint::FilePath)]
460        file: PathBuf,
461    },
462
463    /// Load settings from TOML content passed as a CLI string
464    String {
465        /// Inline TOML document merged into the current global or default-runtime settings.
466        #[arg(value_name = "TOML")]
467        string: String,
468    },
469
470    /// Merge one or more dotted `KEY=VALUE` assignments into the current settings.
471    Kv {
472        /// Any number of KEY=VALUE pairs
473        #[arg(value_name = "KEY=VALUE", num_args = 1.., value_parser = KvPair::from_str)]
474        pairs: Vec<KvPair>,
475    },
476
477    /// Sync process runtime settings from the current default runtime settings
478    Defaults,
479
480    /// Reload the corresponding settings from the active state's stored file.
481    Stored,
482}
483
484#[derive(Subcommand, Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
485pub enum ProcessSetArgs {
486    /// Merge a TOML file into the selected process or integrand settings.
487    File {
488        /// TOML settings file merged into the selected process or integrand settings.
489        #[arg(value_hint = clap::ValueHint::FilePath)]
490        file: PathBuf,
491    },
492
493    /// Load settings from TOML content passed as a CLI string
494    String {
495        /// Inline TOML document merged into the selected process or integrand settings.
496        #[arg(value_name = "TOML")]
497        string: String,
498    },
499
500    /// Merge one or more dotted `KEY=VALUE` assignments into the selected settings.
501    Kv {
502        /// Any number of KEY=VALUE pairs
503        #[arg(value_name = "KEY=VALUE", num_args = 1.., value_parser = KvPair::from_str)]
504        pairs: Vec<KvPair>,
505    },
506
507    /// Sync process runtime settings from the current default runtime settings
508    Defaults,
509
510    /// Reload the selected process or integrand settings from its stored file.
511    Stored,
512
513    /// Add a named quantity/observable/selector
514    Add {
515        #[command(subcommand)]
516        target: ProcessAddTarget,
517    },
518
519    /// Update a named quantity/observable/selector
520    Update {
521        #[command(subcommand)]
522        target: ProcessUpdateTarget,
523    },
524
525    /// Remove a named quantity/observable/selector
526    Remove {
527        #[command(subcommand)]
528        target: ProcessRemoveTarget,
529    },
530}
531
532#[derive(Subcommand, Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
533pub enum ProcessAddTarget {
534    /// Add a named derived quantity to the selected process settings.
535    Quantity {
536        /// Unique name used to reference the quantity from expressions and observables.
537        name: String,
538        /// Registered quantity implementation to instantiate.
539        #[arg(value_parser = parse_quantity_kind)]
540        kind: String,
541        /// Initial quantity settings as dotted `KEY=VALUE` assignments.
542        #[arg(value_name = "KEY=VALUE", num_args = 0.., value_parser = KvPair::from_str)]
543        pairs: Vec<KvPair>,
544    },
545    /// Add a named observable to the selected process settings.
546    Observable {
547        /// Unique name used for the observable and its output files.
548        name: String,
549        /// Initial observable settings as dotted `KEY=VALUE` assignments, including its type.
550        #[arg(value_name = "KEY=VALUE", num_args = 0.., value_parser = KvPair::from_str)]
551        pairs: Vec<KvPair>,
552    },
553    /// Add a named event selector to the selected process settings.
554    Selector {
555        /// Unique name used to identify the selector in diagnostics.
556        name: String,
557        /// Registered selector implementation to instantiate.
558        #[arg(value_parser = parse_selector_kind)]
559        kind: String,
560        /// Initial selector settings as dotted `KEY=VALUE` assignments.
561        #[arg(value_name = "KEY=VALUE", num_args = 0.., value_parser = KvPair::from_str)]
562        pairs: Vec<KvPair>,
563    },
564}
565
566#[derive(Subcommand, Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
567pub enum ProcessUpdateTarget {
568    /// Update fields of an existing named quantity.
569    Quantity {
570        /// Name of the quantity to update.
571        name: String,
572        /// One or more dotted quantity-setting assignments.
573        #[arg(value_name = "KEY=VALUE", num_args = 1.., value_parser = KvPair::from_str)]
574        pairs: Vec<KvPair>,
575    },
576    /// Update fields of an existing named observable.
577    Observable {
578        /// Name of the observable to update.
579        name: String,
580        /// One or more dotted observable-setting assignments.
581        #[arg(value_name = "KEY=VALUE", num_args = 1.., value_parser = KvPair::from_str)]
582        pairs: Vec<KvPair>,
583    },
584    /// Update fields of an existing named selector.
585    Selector {
586        /// Name of the selector to update.
587        name: String,
588        /// One or more dotted selector-setting assignments.
589        #[arg(value_name = "KEY=VALUE", num_args = 1.., value_parser = KvPair::from_str)]
590        pairs: Vec<KvPair>,
591    },
592}
593
594#[derive(Subcommand, Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
595pub enum ProcessRemoveTarget {
596    /// Remove a named quantity from the selected process settings.
597    Quantity {
598        /// Name of the quantity to remove.
599        name: String,
600    },
601    /// Remove a named observable from the selected process settings.
602    Observable {
603        /// Name of the observable to remove.
604        name: String,
605    },
606    /// Remove a named selector from the selected process settings.
607    Selector {
608        /// Name of the selector to remove.
609        name: String,
610    },
611}
612
613#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
614pub struct KvPair {
615    pub key: String,
616    pub value: String,
617}
618
619impl FromStr for KvPair {
620    type Err = String;
621    fn from_str(s: &str) -> Result<Self, Self::Err> {
622        let (k, v) = s.split_once('=').ok_or("Expected KEY=VALUE")?;
623        if k.trim().is_empty() {
624            return Err("Empty KEY".into());
625        }
626        Ok(KvPair {
627            key: k.trim().into(),
628            value: v.trim().into(),
629        })
630    }
631}
632impl SetArgs {
633    pub fn updates_global_display_directive(&self) -> Result<bool> {
634        self.updates_global_path(&["global", "display_directive"], "global.display_directive")
635    }
636
637    pub fn updates_global_logfile_directive(&self) -> Result<bool> {
638        self.updates_global_path(&["global", "logfile_directive"], "global.logfile_directive")
639    }
640
641    fn updates_global_path(&self, path: &[&str], label: &str) -> Result<bool> {
642        let dotted_path = path.join(".");
643        match self {
644            SetArgs::Kv { pairs } => Ok(pairs.iter().any(|pair| pair.key == dotted_path)),
645            SetArgs::String { string } => {
646                let value: toml::Value = toml::from_str(string).with_context(|| {
647                    format!("Failed parsing TOML payload while checking for {label}")
648                })?;
649                Ok(toml_contains_path(&value, path))
650            }
651            SetArgs::File { file } => {
652                let ext = file
653                    .extension()
654                    .and_then(|s| s.to_str())
655                    .unwrap_or("")
656                    .to_ascii_lowercase();
657                let contents = fs::read_to_string(file).with_context(|| {
658                    format!("Failed reading settings file '{}'", file.display())
659                })?;
660                match ext.as_str() {
661                    "toml" => {
662                        let value: toml::Value = toml::from_str(&contents).with_context(|| {
663                            format!(
664                                "Failed parsing TOML settings file '{}' while checking for {label}",
665                                file.display()
666                            )
667                        })?;
668                        Ok(toml_contains_path(&value, path))
669                    }
670                    "json" => {
671                        let value: JsonValue =
672                            serde_json::from_str(&contents).with_context(|| {
673                                format!(
674                                "Failed parsing JSON settings file '{}' while checking for {label}",
675                                file.display()
676                            )
677                            })?;
678                        Ok(json_contains_path(&value, path))
679                    }
680                    _ => Ok(false),
681                }
682            }
683            SetArgs::Stored | SetArgs::Defaults => Ok(false),
684        }
685    }
686
687    pub fn merge_figment(&self, fig: Figment) -> Result<Figment> {
688        match self {
689            SetArgs::File { file } => {
690                let ext = file
691                    .extension()
692                    .and_then(|s| s.to_str())
693                    .unwrap_or("")
694                    .to_ascii_lowercase();
695                match ext.as_str() {
696                    "toml" => Ok(fig.merge(Toml::file_exact(file))),
697                    // "yaml" | "yml" => Ok(fig.merge(figment::providers::Yaml::file(file))),
698                    "json" => Ok(fig.merge(figment::providers::Json::file_exact(file))),
699                    _ => Err(color_eyre::eyre::eyre!(
700                        "Unsupported settings file extension: {}",
701                        ext
702                    )),
703                }
704            }
705            SetArgs::String { string } => Ok(fig.merge(Toml::string(string))),
706            SetArgs::Kv { pairs } => {
707                let mut figment = Figment::new();
708
709                for KvPair { key, value } in pairs.iter() {
710                    figment = figment.adjoin((key.clone(), infer_cli_value(value)?));
711                }
712
713                Ok(fig.merge(figment))
714            }
715            SetArgs::Stored => Ok(fig),
716            SetArgs::Defaults => Err(eyre!("'defaults' is only supported for 'set process'")),
717        }
718    }
719}
720
721impl ProcessSetArgs {
722    fn as_set_args(&self) -> Option<SetArgs> {
723        match self {
724            ProcessSetArgs::File { file } => Some(SetArgs::File { file: file.clone() }),
725            ProcessSetArgs::String { string } => Some(SetArgs::String {
726                string: string.clone(),
727            }),
728            ProcessSetArgs::Kv { pairs } => Some(SetArgs::Kv {
729                pairs: pairs.clone(),
730            }),
731            ProcessSetArgs::Defaults => Some(SetArgs::Defaults),
732            ProcessSetArgs::Stored => Some(SetArgs::Stored),
733            ProcessSetArgs::Add { .. }
734            | ProcessSetArgs::Update { .. }
735            | ProcessSetArgs::Remove { .. } => None,
736        }
737    }
738}
739
740fn apply_process_set_args(
741    input: &ProcessSetArgs,
742    settings: &mut RuntimeSettings,
743    default_runtime_settings: &RuntimeSettings,
744    validation_context: Option<&RuntimeModelValidationContext>,
745) -> Result<()> {
746    match input {
747        ProcessSetArgs::Add { target } => apply_process_add_target(settings, target),
748        ProcessSetArgs::Update { target } => apply_process_update_target(settings, target),
749        ProcessSetArgs::Remove { target } => apply_process_remove_target(settings, target),
750        ProcessSetArgs::Defaults => {
751            *settings = default_runtime_settings.clone();
752            Ok(())
753        }
754        _ => {
755            let input = input
756                .as_set_args()
757                .expect("non-mutation process set args should convert to SetArgs");
758            *settings = merge_runtime_settings_input(settings, &input)?;
759            if let Some(validation_context) = validation_context {
760                validation_context.validate(settings)?;
761            }
762            Ok(())
763        }
764    }?;
765
766    normalize_runtime_quantities(settings)?;
767    Ok(())
768}
769
770fn apply_process_add_target(
771    settings: &mut RuntimeSettings,
772    target: &ProcessAddTarget,
773) -> Result<()> {
774    match target {
775        ProcessAddTarget::Quantity { name, kind, pairs } => {
776            let entry = merge_named_settings(
777                &quantity_template(kind).ok_or_else(|| eyre!("Unknown quantity kind '{kind}'"))?,
778                pairs,
779                &format!("new quantity '{name}'"),
780            )?;
781            add_named_map_entry(&mut settings.quantities, name, entry, "quantity")
782        }
783        ProcessAddTarget::Observable { name, pairs } => {
784            let entry = merge_named_settings(
785                &observable_template(),
786                pairs,
787                &format!("new observable '{name}'"),
788            )?;
789            add_named_map_entry(&mut settings.observables, name, entry, "observable")
790        }
791        ProcessAddTarget::Selector { name, kind, pairs } => {
792            let entry = merge_named_settings(
793                &selector_template(kind).ok_or_else(|| eyre!("Unknown selector kind '{kind}'"))?,
794                pairs,
795                &format!("new selector '{name}'"),
796            )?;
797            add_named_map_entry(&mut settings.selectors, name, entry, "selector")
798        }
799    }
800}
801
802fn apply_process_update_target(
803    settings: &mut RuntimeSettings,
804    target: &ProcessUpdateTarget,
805) -> Result<()> {
806    match target {
807        ProcessUpdateTarget::Quantity { name, pairs } => {
808            update_named_map_entry(&mut settings.quantities, name, pairs, "quantity")
809        }
810        ProcessUpdateTarget::Observable { name, pairs } => {
811            update_named_map_entry(&mut settings.observables, name, pairs, "observable")
812        }
813        ProcessUpdateTarget::Selector { name, pairs } => {
814            update_named_map_entry(&mut settings.selectors, name, pairs, "selector")
815        }
816    }
817}
818
819fn apply_process_remove_target(
820    settings: &mut RuntimeSettings,
821    target: &ProcessRemoveTarget,
822) -> Result<()> {
823    match target {
824        ProcessRemoveTarget::Quantity { name } => {
825            remove_named_map_entry(&mut settings.quantities, name, "quantity")
826        }
827        ProcessRemoveTarget::Observable { name } => {
828            remove_named_map_entry(&mut settings.observables, name, "observable")
829        }
830        ProcessRemoveTarget::Selector { name } => {
831            remove_named_map_entry(&mut settings.selectors, name, "selector")
832        }
833    }
834}
835
836fn merge_named_settings<T>(base: &T, pairs: &[KvPair], label: &str) -> Result<T>
837where
838    T: Serialize + DeserializeOwned,
839{
840    let fig = Figment::from(Serialized::defaults(base));
841    SetArgs::Kv {
842        pairs: pairs.to_vec(),
843    }
844    .merge_figment(fig)?
845    .extract()
846    .with_context(|| format!("While building {label}"))
847}
848
849fn add_named_map_entry<T>(
850    map: &mut BTreeMap<String, T>,
851    name: &str,
852    entry: T,
853    label: &str,
854) -> Result<()> {
855    if map.contains_key(name) {
856        return Err(eyre!("A {label} named '{name}' already exists"));
857    }
858    map.insert(name.to_string(), entry);
859    Ok(())
860}
861
862fn update_named_map_entry<T>(
863    map: &mut BTreeMap<String, T>,
864    name: &str,
865    pairs: &[KvPair],
866    label: &str,
867) -> Result<()>
868where
869    T: Clone + Serialize + DeserializeOwned,
870{
871    let current = map
872        .get(name)
873        .cloned()
874        .ok_or_else(|| eyre!("No {label} named '{name}'"))?;
875    let updated = merge_named_settings(&current, pairs, &format!("{label} '{name}'"))?;
876    map.insert(name.to_string(), updated);
877    Ok(())
878}
879
880fn remove_named_map_entry<T>(map: &mut BTreeMap<String, T>, name: &str, label: &str) -> Result<()> {
881    if map.remove(name).is_none() {
882        return Err(eyre!("No {label} named '{name}'"));
883    }
884    Ok(())
885}
886
887use serde_json::Value as J;
888use serde_yaml::Value as Y;
889
890fn toml_contains_path(value: &toml::Value, path: &[&str]) -> bool {
891    match path.split_first() {
892        None => true,
893        Some((head, tail)) => value
894            .as_table()
895            .and_then(|table| table.get(*head))
896            .is_some_and(|next| toml_contains_path(next, tail)),
897    }
898}
899
900fn json_contains_path(value: &JsonValue, path: &[&str]) -> bool {
901    match path.split_first() {
902        None => true,
903        Some((head, tail)) => value
904            .as_object()
905            .and_then(|table| table.get(*head))
906            .is_some_and(|next| json_contains_path(next, tail)),
907    }
908}
909
910fn infer_cli_value(raw: &str) -> Result<J> {
911    // 1) Try strict JSON (numbers/bools work; strings need quotes)
912    if let Ok(v) = json5::from_str::<J>(raw) {
913        return Ok(v);
914    }
915    // 2) YAML scalars: true/false, 42, 3.14, and bare words -> string
916    if let Ok(v) = serde_yaml::from_str::<Y>(raw) {
917        return yaml_to_json(v).with_context(|| format!("While trying yaml on :{raw}"));
918    }
919    // 3) Fallback: plain string
920    Ok(J::String(raw.to_string()))
921}
922
923fn json_number_to_f64(value: &serde_json::Number) -> Result<f64> {
924    value
925        .as_f64()
926        .ok_or_else(|| eyre!("Could not represent JSON number '{value}' as f64"))
927}
928
929fn parse_runtime_model_setting_value(value: &J) -> Result<(F<f64>, F<f64>)> {
930    match value {
931        J::Number(number) => Ok((F(json_number_to_f64(number)?), F(0.0))),
932        J::String(string) => {
933            let parsed = parse_model_parameter_value(string)?;
934            Ok((parsed.re, parsed.im))
935        }
936        J::Array(entries) if entries.len() == 2 => {
937            let parse_component = |entry: &J| -> Result<F<f64>> {
938                match entry {
939                    J::Number(number) => Ok(F(json_number_to_f64(number)?)),
940                    other => Err(eyre!(
941                        "Runtime model setting components must be numeric, found {other:?}"
942                    )),
943                }
944            };
945            Ok((parse_component(&entries[0])?, parse_component(&entries[1])?))
946        }
947        other => Err(eyre!(
948            "Runtime model settings must be specified as a number, a complex string, or a two-element numeric array, found {other:?}"
949        )),
950    }
951}
952
953#[allow(clippy::type_complexity)]
954fn extract_runtime_model_updates(value: &mut Dict) -> Result<BTreeMap<String, (F<f64>, F<f64>)>> {
955    let Some(model_value) = value.remove("model") else {
956        return Ok(BTreeMap::new());
957    };
958
959    let FigmentValue::Dict(_, model_object) = model_value else {
960        return Err(eyre!(
961            "The runtime settings 'model' block must be a table/object"
962        ));
963    };
964
965    model_object
966        .into_iter()
967        .map(|(parameter_name, value)| {
968            parse_runtime_model_setting_value(&value.deserialize()?)
969                .map(|parsed| (parameter_name.clone(), parsed))
970                .with_context(|| format!("While parsing runtime model setting '{parameter_name}'"))
971        })
972        .collect()
973}
974
975#[derive(Clone)]
976struct RuntimeModelValidationContext {
977    model: gammalooprs::model::Model,
978    model_parameters: gammalooprs::model::InputParamCard<F<f64>>,
979}
980
981impl RuntimeModelValidationContext {
982    fn from_state(state: &State) -> Self {
983        Self {
984            model: state.model.clone(),
985            model_parameters: state.model_parameters.clone(),
986        }
987    }
988
989    fn validate(&self, settings: &RuntimeSettings) -> Result<()> {
990        let mut possibilities = self
991            .model_parameters
992            .keys()
993            .map(|symbol| symbol.to_string())
994            .collect::<Vec<_>>();
995        possibilities.sort();
996
997        for (parameter_name, value) in &settings.model.external_parameters {
998            let parameter_type = external_model_parameter_type(&self.model, parameter_name)
999                .ok_or_else(|| eyre!("No model parameter named '{parameter_name}'"))
1000                .with_note(|| {
1001                    format!(
1002                        "Possible model parameters are: {}",
1003                        possibilities.join(", ")
1004                    )
1005                })?;
1006
1007            let symbol = UFOSymbol::from(parameter_name.as_str());
1008            if !self.model_parameters.contains_key(&symbol) {
1009                return Err(eyre!(
1010                    "Model parameter '{parameter_name}' cannot be overridden because it is not present in the shared top-level model_parameters.json"
1011                ))
1012                .with_note(|| format!("Possible model parameters are: {}", possibilities.join(", ")));
1013            }
1014
1015            validate_model_parameter_type(
1016                parameter_name,
1017                parameter_type,
1018                &Complex::new(value.0, value.1),
1019            )?;
1020        }
1021
1022        Ok(())
1023    }
1024}
1025
1026fn merge_runtime_settings_input(
1027    settings: &RuntimeSettings,
1028    input: &SetArgs,
1029) -> Result<RuntimeSettings> {
1030    let mut settings_without_model = settings.clone();
1031    let preserved_model = settings_without_model.model.clone();
1032    settings_without_model.model = Default::default();
1033
1034    let mut updates: Dict = input.merge_figment(Figment::new())?.extract()?;
1035    let model_updates = extract_runtime_model_updates(&mut updates)?;
1036    // Normalize aliases before merging with serialized settings: Serde only sees
1037    // one spelling per field, even when the stored value uses the canonical name.
1038    if let Some(FigmentValue::Dict(_, sampling)) = updates.get_mut("sampling") {
1039        for (alias, canonical) in [
1040            ("lmb_multichanneling", "sampling_multichanneling"),
1041            ("lmb_channels", "sampling_channels"),
1042            ("lmb_channel_weight", "sampling_channel_weight"),
1043        ] {
1044            if let Some(value) = sampling.remove(alias) {
1045                if sampling.contains_key(canonical) {
1046                    return Err(eyre!(
1047                        "Runtime settings update specifies both 'sampling.{alias}' and 'sampling.{canonical}'"
1048                    ));
1049                }
1050                sampling.insert(canonical.to_string(), value);
1051            }
1052        }
1053    }
1054    let mut merged_settings: RuntimeSettings = if updates.is_empty() {
1055        settings.clone()
1056    } else {
1057        Figment::from(Serialized::defaults(&settings_without_model))
1058            .merge(Serialized::defaults(updates))
1059            .extract()?
1060    };
1061    merged_settings.model = preserved_model;
1062    for (parameter_name, value) in model_updates {
1063        merged_settings
1064            .model
1065            .external_parameters
1066            .insert(parameter_name, value);
1067    }
1068
1069    normalize_runtime_quantities(&mut merged_settings)?;
1070    Ok(merged_settings)
1071}
1072
1073fn normalize_runtime_quantities(settings: &mut RuntimeSettings) -> Result<()> {
1074    settings.quantities = std::mem::take(&mut settings.quantities)
1075        .into_iter()
1076        .map(|(name, quantity)| quantity.try_normalized().map(|quantity| (name, quantity)))
1077        .collect::<Result<_>>()?;
1078    Ok(())
1079}
1080
1081fn yaml_to_json(v: Y) -> Result<J> {
1082    match v {
1083        Y::Null => Ok(J::Null),
1084        Y::Bool(b) => Ok(J::Bool(b)),
1085        Y::Number(n) => {
1086            if let Some(i) = n.as_i64() {
1087                Ok(J::Number(i.into()))
1088            } else if let Some(f) = n.as_f64() {
1089                Ok(J::Number(serde_json::Number::from_f64(f).unwrap()))
1090            } else {
1091                Ok(J::String(n.to_string()))
1092            }
1093        }
1094        Y::String(s) => Ok(J::String(s)),
1095        Y::Sequence(values) => values
1096            .into_iter()
1097            .map(yaml_to_json)
1098            .collect::<Result<Vec<_>>>()
1099            .map(J::Array),
1100        Y::Mapping(entries) => {
1101            let mut object = serde_json::Map::with_capacity(entries.len());
1102            for (key, value) in entries {
1103                let key = match key {
1104                    Y::String(s) => s,
1105                    Y::Bool(b) => b.to_string(),
1106                    Y::Number(n) => n.to_string(),
1107                    Y::Null => "null".to_string(),
1108                    other => {
1109                        return Err(eyre!(
1110                            "Unsupported YAML mapping key in CLI key-value: {:?}",
1111                            other
1112                        ));
1113                    }
1114                };
1115                object.insert(key, yaml_to_json(value)?);
1116            }
1117            Ok(J::Object(object))
1118        }
1119        other => Err(eyre!(
1120            "Unsupported YAML value in CLI key-value: {:?}",
1121            other
1122        )),
1123    }
1124}
1125
1126#[cfg(test)]
1127mod test {
1128    use std::str::FromStr;
1129
1130    use clap::Parser;
1131    use figment::{providers::Serialized, Figment};
1132    use gammalooprs::{
1133        graph::Graph,
1134        initialisation::test_initialise,
1135        model::InputParamCard,
1136        model::{ParameterNature, ParameterType, UFOSymbol},
1137        observables::{
1138            FilterQuantity, PairQuantity, QuantityComputation, QuantityOrder, QuantityOrdering,
1139            QuantitySettings, SelectorDefinitionSettings,
1140        },
1141        settings::{global::OrientationPattern, RuntimeSettings},
1142        utils::{load_generic_model, F},
1143    };
1144    use serde::{Deserialize, Serialize};
1145    use spenso::algebra::complex::Complex;
1146
1147    use crate::{
1148        model_parameters::{
1149            model_value_format_hint, parse_model_parameter_value, MODEL_COMPLEX_VALUE_FORMAT_HINT,
1150            MODEL_REAL_VALUE_FORMAT_HINT,
1151        },
1152        state::{ProcessRef, State},
1153        tracing::{get_stderr_log_filter, set_stderr_log_filter, set_stderr_log_filter_override},
1154        CLISettings, Repl,
1155    };
1156
1157    use super::{
1158        super::Commands, apply_process_set_args, validate_model_parameter_type, KvPair,
1159        ModelSetValue, ProcessAddTarget, ProcessArgs, ProcessRemoveTarget, ProcessSetArgs,
1160        ProcessUpdateTarget, Set, SetArgs,
1161    };
1162
1163    fn build_generated_scalar_bubble_state(process_names: &[&str]) -> State {
1164        test_initialise().expect("test initialisation should succeed");
1165        let mut state = State::new_test();
1166        state.model = load_generic_model("scalars");
1167        state.model_parameters = InputParamCard::default_from_model(&state.model);
1168        let graph_path =
1169            crate::test_workspace_root().join("tests/resources/graphs/scalar_bubble.dot");
1170        let graphs = Graph::from_path(&graph_path, &state.model)
1171            .expect("scalar bubble graph fixture should load");
1172
1173        for process_name in process_names {
1174            state
1175                .import_graphs(
1176                    graphs.clone(),
1177                    crate::state::GraphImportOptions {
1178                        process_name: Some((*process_name).to_string()),
1179                        process_id: None,
1180                        process_definition: None,
1181                        integrand_name: Some("default".to_string()),
1182                        overwrite: false,
1183                        append: false,
1184                    },
1185                )
1186                .expect("graph import should succeed");
1187        }
1188
1189        let runtime_defaults = RuntimeSettings::default();
1190        state
1191            .generate_integrands(&CLISettings::default().global, (&runtime_defaults).into())
1192            .expect("integrand generation should succeed");
1193        state
1194    }
1195
1196    fn first_integrand_name(state: &State, process_id: usize) -> String {
1197        state.process_list.processes[process_id]
1198            .get_integrand_names()
1199            .into_iter()
1200            .next()
1201            .expect("fixture process should expose one integrand")
1202            .to_string()
1203    }
1204
1205    #[test]
1206    fn serialize_complex() {
1207        let s = Complex::new(F(1.), F(-2.));
1208        let j = serde_json::to_string(&s).unwrap();
1209        assert_eq!(j, r#"{"re":1.0,"im":-2.0}"#.to_string());
1210    }
1211
1212    #[test]
1213    fn parse_model_parameter_value_supports_real_literals() {
1214        let parsed = parse_model_parameter_value("-1.0e12").unwrap();
1215        assert_eq!(parsed, Complex::new(F(-1.0e12), F(0.0)));
1216    }
1217
1218    #[test]
1219    fn parse_model_parameter_value_supports_i_and_j_suffixes() {
1220        let with_i = parse_model_parameter_value("-1.0e13-33.0e12i").unwrap();
1221        let with_j = parse_model_parameter_value("-1.0e13+33.0e12j").unwrap();
1222        let pure_imag = parse_model_parameter_value("-2.5j").unwrap();
1223
1224        assert_eq!(with_i, Complex::new(F(-1.0e13), F(-33.0e12)));
1225        assert_eq!(with_j, Complex::new(F(-1.0e13), F(33.0e12)));
1226        assert_eq!(pure_imag, Complex::new(F(0.0), F(-2.5)));
1227    }
1228
1229    #[test]
1230    fn parse_model_parameter_value_rejects_legacy_json_style() {
1231        let err = parse_model_parameter_value("{re:1.0,im:0.0}").unwrap_err();
1232        assert!(err.to_string().contains(MODEL_COMPLEX_VALUE_FORMAT_HINT));
1233
1234        let err = parse_model_parameter_value("[1.0,0.0]").unwrap_err();
1235        assert!(err.to_string().contains(MODEL_COMPLEX_VALUE_FORMAT_HINT));
1236    }
1237
1238    #[test]
1239    fn model_value_format_hint_depends_on_parameter_type() {
1240        assert_eq!(
1241            model_value_format_hint(Some(ParameterType::Real)),
1242            MODEL_REAL_VALUE_FORMAT_HINT
1243        );
1244        assert_eq!(
1245            model_value_format_hint(Some(ParameterType::Imaginary)),
1246            MODEL_COMPLEX_VALUE_FORMAT_HINT
1247        );
1248        assert_eq!(
1249            model_value_format_hint(None),
1250            MODEL_COMPLEX_VALUE_FORMAT_HINT
1251        );
1252    }
1253
1254    #[test]
1255    fn validate_model_parameter_type_rejects_imaginary_part_for_real_parameters() {
1256        let err = validate_model_parameter_type(
1257            "alpha",
1258            ParameterType::Real,
1259            &Complex::new(F(1.0), F(2.0)),
1260        )
1261        .unwrap_err();
1262        assert!(err
1263            .to_string()
1264            .contains("cannot be assigned an imaginary component"));
1265        assert!(err.to_string().contains(MODEL_REAL_VALUE_FORMAT_HINT));
1266    }
1267
1268    #[test]
1269    fn validate_model_parameter_type_accepts_complex_values_for_imaginary_parameters() {
1270        validate_model_parameter_type(
1271            "alpha",
1272            ParameterType::Imaginary,
1273            &Complex::new(F(1.0), F(2.0)),
1274        )
1275        .unwrap();
1276    }
1277
1278    #[test]
1279    fn merge_runtime_settings_input_preserves_existing_model_overrides() {
1280        let mut settings = RuntimeSettings::default();
1281        settings
1282            .model
1283            .external_parameters
1284            .insert("mass_scalar_2".to_string(), (F(2.0), F(0.0)));
1285
1286        let merged = super::merge_runtime_settings_input(
1287            &settings,
1288            &SetArgs::Kv {
1289                pairs: vec![KvPair {
1290                    key: "integrator.n_start".to_string(),
1291                    value: "123".to_string(),
1292                }],
1293            },
1294        )
1295        .unwrap();
1296
1297        assert_eq!(merged.integrator.n_start, 123);
1298        assert_eq!(
1299            merged.model.external_parameters.get("mass_scalar_2"),
1300            Some(&(F(2.0), F(0.0)))
1301        );
1302    }
1303
1304    #[test]
1305    fn merge_runtime_settings_input_accepts_model_block_updates() {
1306        let merged = super::merge_runtime_settings_input(
1307            &RuntimeSettings::default(),
1308            &SetArgs::String {
1309                string: "[model]\nmass_scalar_2 = [1.0, 0.0]\n".to_string(),
1310            },
1311        )
1312        .unwrap();
1313
1314        assert_eq!(
1315            merged.model.external_parameters.get("mass_scalar_2"),
1316            Some(&(F(1.0), F(0.0)))
1317        );
1318    }
1319
1320    #[test]
1321    fn merge_runtime_settings_input_normalizes_sampling_aliases() {
1322        let settings: RuntimeSettings = toml::from_str(
1323            r#"
1324[sampling]
1325graphs = "monte_carlo"
1326sampling_multichanneling = true
1327sampling_channels = "monte_carlo"
1328sampling_channel_weight = "ose"
1329default_channel_selection = ["auto:lmb"]
1330alpha = 2.0
1331[integrator]
1332n_start = 321
1333"#,
1334        )
1335        .unwrap();
1336        let string = r#"
1337[sampling]
1338lmb_multichanneling = false
1339lmb_channels = "summed"
1340lmb_channel_weight = "map_density"
1341"#;
1342        let mut expected = settings.clone();
1343        expected.sampling = toml::from_str(
1344            "graphs = 'monte_carlo'\nsampling_multichanneling = false\nalpha = 2.0\ndefault_channel_selection = ['auto:lmb']",
1345        )
1346        .unwrap();
1347        for input in [
1348            SetArgs::String {
1349                string: string.to_string(),
1350            },
1351            SetArgs::Kv {
1352                pairs: [
1353                    "sampling.lmb_multichanneling=false",
1354                    "sampling.lmb_channels=summed",
1355                    "sampling.lmb_channel_weight=map_density",
1356                ]
1357                .map(|pair| pair.parse().unwrap())
1358                .to_vec(),
1359            },
1360        ] {
1361            let merged = super::merge_runtime_settings_input(&settings, &input).unwrap();
1362            assert_eq!(merged, expected);
1363        }
1364        assert_eq!(
1365            super::merge_runtime_settings_input(&settings, &SetArgs::Stored).unwrap(),
1366            settings
1367        );
1368    }
1369
1370    #[test]
1371    fn merge_runtime_settings_input_rejects_conflicting_sampling_aliases() {
1372        for (alias, canonical, value) in [
1373            ("lmb_multichanneling", "sampling_multichanneling", "true"),
1374            ("lmb_channels", "sampling_channels", "'summed'"),
1375            ("lmb_channel_weight", "sampling_channel_weight", "'ose'"),
1376        ] {
1377            for input in [
1378                SetArgs::String {
1379                    string: format!("[sampling]\n{alias} = {value}\n{canonical} = {value}"),
1380                },
1381                SetArgs::Kv {
1382                    pairs: [alias, canonical]
1383                        .map(|key| KvPair {
1384                            key: format!("sampling.{key}"),
1385                            value: value.to_string(),
1386                        })
1387                        .to_vec(),
1388                },
1389            ] {
1390                let error =
1391                    super::merge_runtime_settings_input(&RuntimeSettings::default(), &input)
1392                        .unwrap_err();
1393                assert!(error.to_string().contains(&format!(
1394                    "specifies both 'sampling.{alias}' and 'sampling.{canonical}'"
1395                )));
1396            }
1397        }
1398    }
1399
1400    #[test]
1401    fn merge_runtime_settings_input_accepts_file_model_and_sampling_updates() {
1402        let mut settings = RuntimeSettings::default();
1403        settings
1404            .model
1405            .external_parameters
1406            .insert("mass_scalar_1".to_string(), (F(2.0), F(0.0)));
1407        settings.sampling = toml::from_str(
1408            "sampling_multichanneling = true\nsampling_channel_weight = 'ose'\ndefault_channel_selection = ['auto:lmb']",
1409        )
1410        .unwrap();
1411        settings.integrator.n_start = 321;
1412        for (extension, contents) in [
1413            (
1414                "toml",
1415                "[model]\nmass_scalar_2 = [3.0, 1.0]\n[sampling]\nlmb_multichanneling = false\nlmb_channel_weight = 'map_density'",
1416            ),
1417            (
1418                "json",
1419                r#"{"model":{"mass_scalar_2":[3.0,1.0]},"sampling":{"lmb_multichanneling":false,"lmb_channel_weight":"map_density"}}"#,
1420            ),
1421        ] {
1422            let file = std::env::temp_dir().join(format!(
1423                "gammaloop_runtime_sampling_aliases_{}.{extension}",
1424                std::process::id()
1425            ));
1426            std::fs::write(&file, contents).unwrap();
1427            let result = super::merge_runtime_settings_input(
1428                &settings,
1429                &SetArgs::File { file: file.clone() },
1430            );
1431            std::fs::remove_file(&file).unwrap();
1432            let mut expected = settings.clone();
1433            expected.sampling =
1434                toml::from_str("default_channel_selection = ['auto:lmb']").unwrap();
1435            expected.model.external_parameters.insert(
1436                "mass_scalar_2".to_string(),
1437                (F(3.0), F(1.0)),
1438            );
1439            assert_eq!(result.unwrap(), expected);
1440            assert!(super::merge_runtime_settings_input(&settings, &SetArgs::File { file }).is_err());
1441        }
1442    }
1443
1444    #[test]
1445    fn process_settings_reject_non_overridable_model_parameters() {
1446        let mut state = State::new_test();
1447        state.model = load_generic_model("sm");
1448        state.model_parameters =
1449            gammalooprs::model::InputParamCard::default_from_model(&state.model);
1450        let removable_parameter = state
1451            .model
1452            .parameters
1453            .values()
1454            .find(|parameter| {
1455                parameter.nature == ParameterNature::External
1456                    && state.model_parameters.contains_key(&parameter.name)
1457            })
1458            .expect("SM model must contain at least one overridable external parameter")
1459            .name
1460            .to_string();
1461        state
1462            .model_parameters
1463            .remove(&UFOSymbol::from(removable_parameter.as_str()));
1464        let validation_context = super::RuntimeModelValidationContext::from_state(&state);
1465        let mut settings = RuntimeSettings::default();
1466
1467        let err = super::apply_process_set_args(
1468            &ProcessSetArgs::Kv {
1469                pairs: vec![KvPair {
1470                    key: format!("model.{removable_parameter}"),
1471                    value: "1.0".to_string(),
1472                }],
1473            },
1474            &mut settings,
1475            &RuntimeSettings::default(),
1476            Some(&validation_context),
1477        )
1478        .unwrap_err();
1479
1480        assert!(err
1481            .to_string()
1482            .contains("cannot be overridden because it is not present"));
1483    }
1484
1485    #[test]
1486    fn process_settings_reject_invalid_model_value_types() {
1487        let mut state = State::new_test();
1488        state.model = load_generic_model("sm");
1489        state.model_parameters =
1490            gammalooprs::model::InputParamCard::default_from_model(&state.model);
1491        let parameter = state
1492            .model
1493            .parameters
1494            .values()
1495            .find(|parameter| {
1496                parameter.nature == ParameterNature::External
1497                    && parameter.parameter_type == ParameterType::Real
1498            })
1499            .expect("expected at least one external real model parameter")
1500            .name
1501            .to_string();
1502        let validation_context = super::RuntimeModelValidationContext::from_state(&state);
1503        let mut settings = RuntimeSettings::default();
1504
1505        let err = super::apply_process_set_args(
1506            &ProcessSetArgs::Kv {
1507                pairs: vec![KvPair {
1508                    key: format!("model.{parameter}"),
1509                    value: "1.0+2.0i".to_string(),
1510                }],
1511            },
1512            &mut settings,
1513            &RuntimeSettings::default(),
1514            Some(&validation_context),
1515        )
1516        .unwrap_err();
1517
1518        assert!(err
1519            .to_string()
1520            .contains("cannot be assigned an imaginary component"));
1521    }
1522
1523    #[test]
1524    fn parse_set_process_defaults() {
1525        let cmd = Set::from_str("set process -p epem_a_tth -i LO defaults").unwrap();
1526
1527        match cmd {
1528            Set::Process { input, process } => {
1529                assert_eq!(input, ProcessSetArgs::Defaults);
1530                assert_eq!(
1531                    process.process,
1532                    Some(ProcessRef::Unqualified("epem_a_tth".to_string()))
1533                );
1534                assert_eq!(process.integrand_name, Some("LO".to_string()));
1535            }
1536            other => panic!("Expected set process command, got {other:?}"),
1537        }
1538    }
1539
1540    #[test]
1541    fn parse_set_model_targeted_defaults() {
1542        let cmd = Set::from_str("set model -p epem_a_tth -i LO defaults").unwrap();
1543
1544        match cmd {
1545            Set::Model { target, values } => {
1546                assert_eq!(
1547                    target.process,
1548                    Some(ProcessRef::Unqualified("epem_a_tth".to_string()))
1549                );
1550                assert_eq!(target.integrand_name, Some("LO".to_string()));
1551                assert_eq!(values, vec![ModelSetValue::Defaults]);
1552            }
1553            other => panic!("Expected set model command, got {other:?}"),
1554        }
1555    }
1556
1557    #[test]
1558    fn parse_set_model_targeted_assignment() {
1559        let cmd = Set::from_str("set model -i LO alpha=1.0+2.0i").unwrap();
1560
1561        match cmd {
1562            Set::Model { target, values } => {
1563                assert_eq!(target.process, None);
1564                assert_eq!(target.integrand_name, Some("LO".to_string()));
1565                assert_eq!(
1566                    values,
1567                    vec![ModelSetValue::Assignment(KvPair {
1568                        key: "alpha".to_string(),
1569                        value: "1.0+2.0i".to_string(),
1570                    })]
1571                );
1572            }
1573            other => panic!("Expected set model command, got {other:?}"),
1574        }
1575    }
1576
1577    #[test]
1578    fn defaults_is_rejected_outside_set_process() {
1579        let err = SetArgs::Defaults.merge_figment(Figment::new()).unwrap_err();
1580        assert!(err
1581            .to_string()
1582            .contains("'defaults' is only supported for 'set process'"));
1583    }
1584
1585    #[test]
1586    fn parse_set_process_string() {
1587        let repl = Repl::try_parse_from([
1588            "gammaloop",
1589            "set",
1590            "process",
1591            "-p",
1592            "epem_a_tth",
1593            "-i",
1594            "LO",
1595            "string",
1596            "alpha = 2",
1597        ])
1598        .unwrap();
1599
1600        let cmd = match repl.command {
1601            Commands::Set(set) => set,
1602            other => panic!("Expected set command, got {other:?}"),
1603        };
1604
1605        match cmd {
1606            Set::Process { input, process } => {
1607                assert_eq!(
1608                    input,
1609                    ProcessSetArgs::String {
1610                        string: "alpha = 2".to_string()
1611                    }
1612                );
1613                assert_eq!(
1614                    process.process,
1615                    Some(ProcessRef::Unqualified("epem_a_tth".to_string()))
1616                );
1617                assert_eq!(process.integrand_name, Some("LO".to_string()));
1618            }
1619            other => panic!("Expected set process command, got {other:?}"),
1620        }
1621    }
1622
1623    #[test]
1624    fn parse_set_process_string_multiline() {
1625        let multiline = "alpha = 2\nbeta = true\n";
1626        let repl = Repl::try_parse_from([
1627            "gammaloop",
1628            "set",
1629            "process",
1630            "-p",
1631            "epem_a_tth",
1632            "-i",
1633            "LO",
1634            "string",
1635            multiline,
1636        ])
1637        .unwrap();
1638
1639        let cmd = match repl.command {
1640            Commands::Set(set) => set,
1641            other => panic!("Expected set command, got {other:?}"),
1642        };
1643
1644        match cmd {
1645            Set::Process { input, .. } => {
1646                assert_eq!(
1647                    input,
1648                    ProcessSetArgs::String {
1649                        string: multiline.to_string()
1650                    }
1651                );
1652            }
1653            other => panic!("Expected set process command, got {other:?}"),
1654        }
1655    }
1656
1657    #[test]
1658    fn parse_set_process_add_quantity() {
1659        let cmd = Set::from_str(
1660            "set process -p epem_a_tth -i LO add quantity top_pt particle quantity=PT",
1661        )
1662        .unwrap();
1663
1664        match cmd {
1665            Set::Process { input, process } => {
1666                assert_eq!(
1667                    input,
1668                    ProcessSetArgs::Add {
1669                        target: ProcessAddTarget::Quantity {
1670                            name: "top_pt".to_string(),
1671                            kind: "particle".to_string(),
1672                            pairs: vec![KvPair {
1673                                key: "quantity".to_string(),
1674                                value: "PT".to_string(),
1675                            }],
1676                        }
1677                    }
1678                );
1679                assert_eq!(
1680                    process.process,
1681                    Some(ProcessRef::Unqualified("epem_a_tth".to_string()))
1682                );
1683                assert_eq!(process.integrand_name, Some("LO".to_string()));
1684            }
1685            other => panic!("Expected set process command, got {other:?}"),
1686        }
1687    }
1688
1689    #[test]
1690    fn parse_set_process_add_quantity_with_negative_integer_list() {
1691        let cmd = Set::from_str(
1692            "set process -p epem_a_tth -i LO add quantity jets jet quantity=PT clustered_pdgs=[-1,1,21,82]",
1693        )
1694        .unwrap();
1695
1696        match cmd {
1697            Set::Process { input, .. } => {
1698                assert_eq!(
1699                    input,
1700                    ProcessSetArgs::Add {
1701                        target: ProcessAddTarget::Quantity {
1702                            name: "jets".to_string(),
1703                            kind: "jet".to_string(),
1704                            pairs: vec![
1705                                KvPair {
1706                                    key: "quantity".to_string(),
1707                                    value: "PT".to_string(),
1708                                },
1709                                KvPair {
1710                                    key: "clustered_pdgs".to_string(),
1711                                    value: "[-1,1,21,82]".to_string(),
1712                                },
1713                            ],
1714                        }
1715                    }
1716                );
1717            }
1718            other => panic!("Expected set process command, got {other:?}"),
1719        }
1720    }
1721
1722    #[test]
1723    fn parse_set_process_update_observable() {
1724        let cmd = Set::from_str(
1725            "set process -p epem_a_tth -i LO update observable top_pt_hist n_bins=100",
1726        )
1727        .unwrap();
1728
1729        match cmd {
1730            Set::Process { input, .. } => {
1731                assert_eq!(
1732                    input,
1733                    ProcessSetArgs::Update {
1734                        target: ProcessUpdateTarget::Observable {
1735                            name: "top_pt_hist".to_string(),
1736                            pairs: vec![KvPair {
1737                                key: "n_bins".to_string(),
1738                                value: "100".to_string(),
1739                            }],
1740                        }
1741                    }
1742                );
1743            }
1744            other => panic!("Expected set process command, got {other:?}"),
1745        }
1746    }
1747
1748    #[test]
1749    fn parse_set_process_remove_selector() {
1750        let cmd = Set::from_str("set process -p epem_a_tth -i LO remove selector top_cut").unwrap();
1751
1752        match cmd {
1753            Set::Process { input, .. } => {
1754                assert_eq!(
1755                    input,
1756                    ProcessSetArgs::Remove {
1757                        target: ProcessRemoveTarget::Selector {
1758                            name: "top_cut".to_string(),
1759                        }
1760                    }
1761                );
1762            }
1763            other => panic!("Expected set process command, got {other:?}"),
1764        }
1765    }
1766
1767    #[test]
1768    fn set_process_without_targets_applies_to_all_generated_integrands() {
1769        let mut state =
1770            build_generated_scalar_bubble_state(&["scalar_bubble", "scalar_bubble_copy"]);
1771        let mut cli_settings = CLISettings::default();
1772        let mut default_runtime_settings = RuntimeSettings::default();
1773
1774        Set::Process {
1775            input: ProcessSetArgs::Kv {
1776                pairs: vec![KvPair {
1777                    key: "integrator.n_start".to_string(),
1778                    value: "321".to_string(),
1779                }],
1780            },
1781            process: ProcessArgs {
1782                process: None,
1783                integrand_name: None,
1784            },
1785        }
1786        .run(&mut state, &mut cli_settings, &mut default_runtime_settings)
1787        .expect("set process should apply to every generated integrand");
1788
1789        for process_id in 0..state.process_list.processes.len() {
1790            let integrand_name = first_integrand_name(&state, process_id);
1791            let settings = state
1792                .process_list
1793                .get_integrand(process_id, &integrand_name)
1794                .expect("integrand should exist")
1795                .get_settings()
1796                .expect("fixture integrand should be generated");
1797            assert_eq!(settings.integrator.n_start, 321);
1798        }
1799    }
1800
1801    #[test]
1802    fn set_process_without_integrand_applies_to_all_generated_integrands_in_one_process() {
1803        let mut state =
1804            build_generated_scalar_bubble_state(&["scalar_bubble", "scalar_bubble_copy"]);
1805        let mut cli_settings = CLISettings::default();
1806        let mut default_runtime_settings = RuntimeSettings::default();
1807
1808        Set::Process {
1809            input: ProcessSetArgs::Kv {
1810                pairs: vec![KvPair {
1811                    key: "integrator.n_start".to_string(),
1812                    value: "654".to_string(),
1813                }],
1814            },
1815            process: ProcessArgs {
1816                process: Some(ProcessRef::Id(0)),
1817                integrand_name: None,
1818            },
1819        }
1820        .run(&mut state, &mut cli_settings, &mut default_runtime_settings)
1821        .expect("set process should apply to every generated integrand in the selected process");
1822
1823        let first_integrand = first_integrand_name(&state, 0);
1824        let first_settings = state
1825            .process_list
1826            .get_integrand(0, &first_integrand)
1827            .expect("first integrand should exist")
1828            .get_settings()
1829            .expect("first integrand should be generated");
1830        assert_eq!(first_settings.integrator.n_start, 654);
1831
1832        let second_integrand = first_integrand_name(&state, 1);
1833        let second_settings = state
1834            .process_list
1835            .get_integrand(1, &second_integrand)
1836            .expect("second integrand should exist")
1837            .get_settings()
1838            .expect("second integrand should be generated");
1839        assert_eq!(
1840            second_settings.integrator.n_start,
1841            RuntimeSettings::default().integrator.n_start
1842        );
1843    }
1844
1845    #[test]
1846    fn set_process_rejects_integrand_without_process_target() {
1847        let mut state = build_generated_scalar_bubble_state(&["scalar_bubble"]);
1848        let mut cli_settings = CLISettings::default();
1849        let mut default_runtime_settings = RuntimeSettings::default();
1850
1851        let err = Set::Process {
1852            input: ProcessSetArgs::Kv {
1853                pairs: vec![KvPair {
1854                    key: "integrator.n_start".to_string(),
1855                    value: "321".to_string(),
1856                }],
1857            },
1858            process: ProcessArgs {
1859                process: None,
1860                integrand_name: Some("default".to_string()),
1861            },
1862        }
1863        .run(&mut state, &mut cli_settings, &mut default_runtime_settings)
1864        .unwrap_err();
1865
1866        assert!(err
1867            .to_string()
1868            .contains("--integrand-name requires --process for `set process`"));
1869    }
1870
1871    #[test]
1872    fn set_process_updates_are_transactional_across_multiple_targets() {
1873        let mut state =
1874            build_generated_scalar_bubble_state(&["scalar_bubble", "scalar_bubble_copy"]);
1875        let mut cli_settings = CLISettings::default();
1876        let mut default_runtime_settings = RuntimeSettings::default();
1877
1878        Set::Process {
1879            input: ProcessSetArgs::Add {
1880                target: ProcessAddTarget::Quantity {
1881                    name: "top_pt".to_string(),
1882                    kind: "particle".to_string(),
1883                    pairs: vec![
1884                        KvPair {
1885                            key: "pdgs".to_string(),
1886                            value: "[6,-6]".to_string(),
1887                        },
1888                        KvPair {
1889                            key: "quantity".to_string(),
1890                            value: "PT".to_string(),
1891                        },
1892                    ],
1893                },
1894            },
1895            process: ProcessArgs {
1896                process: Some(ProcessRef::Id(0)),
1897                integrand_name: Some(first_integrand_name(&state, 0)),
1898            },
1899        }
1900        .run(&mut state, &mut cli_settings, &mut default_runtime_settings)
1901        .expect("preparing the first target should succeed");
1902
1903        let err = Set::Process {
1904            input: ProcessSetArgs::Update {
1905                target: ProcessUpdateTarget::Quantity {
1906                    name: "top_pt".to_string(),
1907                    pairs: vec![KvPair {
1908                        key: "quantity".to_string(),
1909                        value: "E".to_string(),
1910                    }],
1911                },
1912            },
1913            process: ProcessArgs {
1914                process: None,
1915                integrand_name: None,
1916            },
1917        }
1918        .run(&mut state, &mut cli_settings, &mut default_runtime_settings)
1919        .unwrap_err();
1920
1921        assert!(err.to_string().contains("No quantity named 'top_pt'"));
1922
1923        let first_integrand_name = first_integrand_name(&state, 0);
1924        let first_settings = state
1925            .process_list
1926            .get_integrand(0, &first_integrand_name)
1927            .expect("first integrand should exist")
1928            .get_settings()
1929            .expect("first integrand should stay generated");
1930        let QuantitySettings::Particle(quantity) = first_settings
1931            .quantities
1932            .get("top_pt")
1933            .expect("first integrand quantity should remain present")
1934        else {
1935            panic!("expected particle quantity");
1936        };
1937        assert_eq!(quantity.computation.quantity, Some(FilterQuantity::PT));
1938    }
1939
1940    #[test]
1941    fn process_named_settings_mutations_update_runtime_maps() {
1942        let defaults = RuntimeSettings::default();
1943        let mut settings = RuntimeSettings::default();
1944
1945        apply_process_set_args(
1946            &ProcessSetArgs::Add {
1947                target: ProcessAddTarget::Quantity {
1948                    name: "top_pt".to_string(),
1949                    kind: "particle".to_string(),
1950                    pairs: vec![
1951                        KvPair {
1952                            key: "pdgs".to_string(),
1953                            value: "[6,-6]".to_string(),
1954                        },
1955                        KvPair {
1956                            key: "quantity".to_string(),
1957                            value: "PT".to_string(),
1958                        },
1959                    ],
1960                },
1961            },
1962            &mut settings,
1963            &defaults,
1964            None,
1965        )
1966        .unwrap();
1967
1968        let quantity = settings
1969            .quantities
1970            .get("top_pt")
1971            .expect("quantity should have been inserted");
1972        match quantity {
1973            QuantitySettings::Particle(particle) => {
1974                assert_eq!(particle.pdgs, vec![6, -6]);
1975                assert_eq!(
1976                    particle.computation.computation,
1977                    QuantityComputation::Scalar
1978                );
1979                assert_eq!(particle.computation.quantity, Some(FilterQuantity::PT));
1980                assert_eq!(
1981                    particle.computation.ordering,
1982                    Some(QuantityOrdering::Quantity)
1983                );
1984                assert_eq!(particle.computation.order, QuantityOrder::Descending);
1985            }
1986            other => panic!("Expected particle quantity, got {other:?}"),
1987        }
1988
1989        apply_process_set_args(
1990            &ProcessSetArgs::Add {
1991                target: ProcessAddTarget::Observable {
1992                    name: "top_pt_hist".to_string(),
1993                    pairs: vec![
1994                        KvPair {
1995                            key: "quantity".to_string(),
1996                            value: "top_pt".to_string(),
1997                        },
1998                        KvPair {
1999                            key: "x_max".to_string(),
2000                            value: "500.0".to_string(),
2001                        },
2002                        KvPair {
2003                            key: "n_bins".to_string(),
2004                            value: "50".to_string(),
2005                        },
2006                    ],
2007                },
2008            },
2009            &mut settings,
2010            &defaults,
2011            None,
2012        )
2013        .unwrap();
2014        let observable = settings
2015            .observables
2016            .get("top_pt_hist")
2017            .expect("observable should have been inserted");
2018        let gammalooprs::observables::HistogramSettings::Continuous(histogram) =
2019            &observable.histogram
2020        else {
2021            panic!("expected continuous histogram");
2022        };
2023        assert_eq!(histogram.n_bins, 50);
2024
2025        apply_process_set_args(
2026            &ProcessSetArgs::String {
2027                string: r#"
2028[selectors.top_pt_cut]
2029quantity = "top_pt"
2030selector = "value_range"
2031entry_selection = "leading_only"
2032min = 10.0
2033max = 500.0
2034"#
2035                .to_string(),
2036            },
2037            &mut settings,
2038            &defaults,
2039            None,
2040        )
2041        .unwrap();
2042        let selector = settings
2043            .selectors
2044            .get("top_pt_cut")
2045            .expect("selector should have been inserted");
2046        assert_eq!(selector.quantity, "top_pt");
2047        match &selector.selector {
2048            SelectorDefinitionSettings::ValueRange(selector) => {
2049                assert_eq!(selector.min, Some(10.0));
2050                assert_eq!(selector.max, Some(500.0));
2051            }
2052            other => panic!("Expected value-range selector, got {other:?}"),
2053        }
2054
2055        apply_process_set_args(
2056            &ProcessSetArgs::Update {
2057                target: ProcessUpdateTarget::Observable {
2058                    name: "top_pt_hist".to_string(),
2059                    pairs: vec![KvPair {
2060                        key: "n_bins".to_string(),
2061                        value: "80".to_string(),
2062                    }],
2063                },
2064            },
2065            &mut settings,
2066            &defaults,
2067            None,
2068        )
2069        .unwrap();
2070        let observable = settings
2071            .observables
2072            .get("top_pt_hist")
2073            .expect("observable should still exist");
2074        let gammalooprs::observables::HistogramSettings::Continuous(histogram) =
2075            &observable.histogram
2076        else {
2077            panic!("expected continuous histogram");
2078        };
2079        assert_eq!(histogram.n_bins, 80);
2080
2081        apply_process_set_args(
2082            &ProcessSetArgs::Update {
2083                target: ProcessUpdateTarget::Selector {
2084                    name: "top_pt_cut".to_string(),
2085                    pairs: vec![KvPair {
2086                        key: "max".to_string(),
2087                        value: "250.0".to_string(),
2088                    }],
2089                },
2090            },
2091            &mut settings,
2092            &defaults,
2093            None,
2094        )
2095        .unwrap();
2096        match &settings
2097            .selectors
2098            .get("top_pt_cut")
2099            .expect("selector should still exist")
2100            .selector
2101        {
2102            SelectorDefinitionSettings::ValueRange(selector) => {
2103                assert_eq!(selector.max, Some(250.0));
2104            }
2105            other => panic!("Expected value-range selector, got {other:?}"),
2106        }
2107
2108        apply_process_set_args(
2109            &ProcessSetArgs::Remove {
2110                target: ProcessRemoveTarget::Quantity {
2111                    name: "top_pt".to_string(),
2112                },
2113            },
2114            &mut settings,
2115            &defaults,
2116            None,
2117        )
2118        .unwrap();
2119        assert!(!settings.quantities.contains_key("top_pt"));
2120    }
2121
2122    #[test]
2123    fn process_quantity_normalization_clears_irrelevant_scalar_fields_for_counts() {
2124        let defaults = RuntimeSettings::default();
2125        let mut settings = RuntimeSettings::default();
2126
2127        apply_process_set_args(
2128            &ProcessSetArgs::Add {
2129                target: ProcessAddTarget::Quantity {
2130                    name: "jet_count".to_string(),
2131                    kind: "jet".to_string(),
2132                    pairs: vec![
2133                        KvPair {
2134                            key: "computation".to_string(),
2135                            value: "count".to_string(),
2136                        },
2137                        KvPair {
2138                            key: "quantity".to_string(),
2139                            value: "PT".to_string(),
2140                        },
2141                    ],
2142                },
2143            },
2144            &mut settings,
2145            &defaults,
2146            None,
2147        )
2148        .unwrap();
2149
2150        let QuantitySettings::Jet(jet) = settings.quantities["jet_count"].clone() else {
2151            panic!("expected jet quantity");
2152        };
2153        assert_eq!(jet.computation.computation, QuantityComputation::Count);
2154        assert_eq!(jet.computation.quantity, None);
2155        assert_eq!(jet.computation.pair_quantity, None);
2156        assert_eq!(jet.computation.ordering, None);
2157        assert_eq!(jet.computation.order, QuantityOrder::Descending);
2158    }
2159
2160    #[test]
2161    fn process_quantity_normalization_defaults_pair_settings() {
2162        let defaults = RuntimeSettings::default();
2163        let mut settings = RuntimeSettings::default();
2164
2165        apply_process_set_args(
2166            &ProcessSetArgs::Add {
2167                target: ProcessAddTarget::Quantity {
2168                    name: "jet_delta_r".to_string(),
2169                    kind: "jet".to_string(),
2170                    pairs: vec![KvPair {
2171                        key: "computation".to_string(),
2172                        value: "pair".to_string(),
2173                    }],
2174                },
2175            },
2176            &mut settings,
2177            &defaults,
2178            None,
2179        )
2180        .unwrap();
2181
2182        let QuantitySettings::Jet(jet) = settings.quantities["jet_delta_r"].clone() else {
2183            panic!("expected jet quantity");
2184        };
2185        assert_eq!(jet.computation.computation, QuantityComputation::Pair);
2186        assert_eq!(jet.computation.pair_quantity, Some(PairQuantity::DeltaR));
2187        assert!(jet.computation.quantity.is_none());
2188        assert_eq!(jet.computation.ordering, Some(QuantityOrdering::Quantity));
2189        assert_eq!(jet.computation.order, QuantityOrder::Descending);
2190    }
2191
2192    #[test]
2193    fn process_quantity_normalization_defaults_scalar_ordering_by_source_family() {
2194        let defaults = RuntimeSettings::default();
2195        let mut settings = RuntimeSettings::default();
2196
2197        apply_process_set_args(
2198            &ProcessSetArgs::Add {
2199                target: ProcessAddTarget::Quantity {
2200                    name: "particle_energy".to_string(),
2201                    kind: "particle".to_string(),
2202                    pairs: vec![
2203                        KvPair {
2204                            key: "pdgs".to_string(),
2205                            value: "[6,-6]".to_string(),
2206                        },
2207                        KvPair {
2208                            key: "quantity".to_string(),
2209                            value: "E".to_string(),
2210                        },
2211                    ],
2212                },
2213            },
2214            &mut settings,
2215            &defaults,
2216            None,
2217        )
2218        .unwrap();
2219
2220        apply_process_set_args(
2221            &ProcessSetArgs::Add {
2222                target: ProcessAddTarget::Quantity {
2223                    name: "jet_energy".to_string(),
2224                    kind: "jet".to_string(),
2225                    pairs: vec![KvPair {
2226                        key: "quantity".to_string(),
2227                        value: "E".to_string(),
2228                    }],
2229                },
2230            },
2231            &mut settings,
2232            &defaults,
2233            None,
2234        )
2235        .unwrap();
2236
2237        let QuantitySettings::Particle(particle) = settings.quantities["particle_energy"].clone()
2238        else {
2239            panic!("expected particle quantity");
2240        };
2241        let QuantitySettings::Jet(jet) = settings.quantities["jet_energy"].clone() else {
2242            panic!("expected jet quantity");
2243        };
2244
2245        assert_eq!(
2246            particle.computation.ordering,
2247            Some(QuantityOrdering::Quantity)
2248        );
2249        assert_eq!(jet.computation.ordering, Some(QuantityOrdering::PT));
2250    }
2251
2252    #[test]
2253    fn process_quantity_normalization_rejects_non_quantity_pair_ordering() {
2254        let defaults = RuntimeSettings::default();
2255        let mut settings = RuntimeSettings::default();
2256
2257        let err = apply_process_set_args(
2258            &ProcessSetArgs::Add {
2259                target: ProcessAddTarget::Quantity {
2260                    name: "jet_delta_r".to_string(),
2261                    kind: "jet".to_string(),
2262                    pairs: vec![
2263                        KvPair {
2264                            key: "computation".to_string(),
2265                            value: "pair".to_string(),
2266                        },
2267                        KvPair {
2268                            key: "ordering".to_string(),
2269                            value: "PT".to_string(),
2270                        },
2271                    ],
2272                },
2273            },
2274            &mut settings,
2275            &defaults,
2276            None,
2277        )
2278        .unwrap_err();
2279
2280        assert!(err
2281            .to_string()
2282            .contains("Pair quantities only support ordering=Quantity"));
2283    }
2284
2285    #[test]
2286    fn process_selector_allows_missing_value_range_minimum() {
2287        let defaults = RuntimeSettings::default();
2288        let mut settings = RuntimeSettings::default();
2289
2290        apply_process_set_args(
2291            &ProcessSetArgs::String {
2292                string: r#"
2293[selectors.top_pt_cut]
2294quantity = "top_pt"
2295selector = "value_range"
2296max = 250.0
2297"#
2298                .to_string(),
2299            },
2300            &mut settings,
2301            &defaults,
2302            None,
2303        )
2304        .unwrap();
2305
2306        let selector = settings.selectors.get("top_pt_cut").unwrap();
2307        let SelectorDefinitionSettings::ValueRange(selector) = &selector.selector else {
2308            panic!("expected value-range selector");
2309        };
2310        assert_eq!(selector.min, None);
2311        assert_eq!(selector.max, Some(250.0));
2312    }
2313
2314    #[test]
2315    fn process_observable_accepts_histogram_title_and_type_description() {
2316        let defaults = RuntimeSettings::default();
2317        let mut settings = RuntimeSettings::default();
2318
2319        apply_process_set_args(
2320            &ProcessSetArgs::Add {
2321                target: ProcessAddTarget::Observable {
2322                    name: "top_pt_hist".to_string(),
2323                    pairs: vec![
2324                        KvPair {
2325                            key: "quantity".to_string(),
2326                            value: "top_pt".to_string(),
2327                        },
2328                        KvPair {
2329                            key: "title".to_string(),
2330                            value: "\"Leading top pT\"".to_string(),
2331                        },
2332                        KvPair {
2333                            key: "type_description".to_string(),
2334                            value: "SB".to_string(),
2335                        },
2336                    ],
2337                },
2338            },
2339            &mut settings,
2340            &defaults,
2341            None,
2342        )
2343        .unwrap();
2344
2345        let observable = settings.observables.get("top_pt_hist").unwrap();
2346        let gammalooprs::observables::HistogramSettings::Continuous(histogram) =
2347            &observable.histogram
2348        else {
2349            panic!("expected continuous histogram");
2350        };
2351        assert_eq!(histogram.title.as_deref(), Some("Leading top pT"));
2352        assert_eq!(histogram.type_description, "SB");
2353    }
2354
2355    #[test]
2356    fn infer_cli_value_accepts_yaml_sequences() {
2357        let parsed = super::infer_cli_value("[alphaloop, matad]").unwrap();
2358        assert_eq!(parsed, serde_json::json!(["alphaloop", "matad"]));
2359    }
2360
2361    #[test]
2362    fn infer_cli_value_accepts_negative_integer_sequences() {
2363        let parsed = super::infer_cli_value("[-1,1,21,82]").unwrap();
2364        assert_eq!(parsed, serde_json::json!([-1, 1, 21, 82]));
2365    }
2366
2367    #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
2368    struct MergeFixture {
2369        alpha: u64,
2370        beta: bool,
2371    }
2372
2373    #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
2374    struct CompileSettingsFixture {
2375        compilation_mode: String,
2376        optimization_level: String,
2377        fast_math: bool,
2378        unsafe_math: bool,
2379        custom: Vec<String>,
2380    }
2381
2382    #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
2383    struct NCoresFixture {
2384        feyngen: u64,
2385        generate: u64,
2386        compile: u64,
2387        integrate: u64,
2388    }
2389
2390    #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
2391    struct GenerationFixture {
2392        compile: CompileSettingsFixture,
2393    }
2394
2395    #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
2396    struct GlobalFixture {
2397        generation: GenerationFixture,
2398        n_cores: NCoresFixture,
2399    }
2400
2401    #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
2402    struct CliSettingsFixture {
2403        global: GlobalFixture,
2404    }
2405
2406    #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
2407    struct RunLikeFixture {
2408        cli_settings: CliSettingsFixture,
2409    }
2410
2411    #[test]
2412    fn string_toml_merges_like_file_toml() {
2413        let defaults = MergeFixture {
2414            alpha: 1,
2415            beta: false,
2416        };
2417        let toml = "alpha = 123\nbeta = true\n";
2418
2419        let tmp_name = format!(
2420            "gammaloop_set_args_test_{}_{}.toml",
2421            std::process::id(),
2422            std::time::SystemTime::now()
2423                .duration_since(std::time::UNIX_EPOCH)
2424                .unwrap()
2425                .as_nanos()
2426        );
2427        let file_path = std::env::temp_dir().join(tmp_name);
2428        std::fs::write(&file_path, toml).unwrap();
2429
2430        let base_a = Figment::from(Serialized::defaults(&defaults));
2431        let via_file: MergeFixture = SetArgs::File {
2432            file: file_path.clone(),
2433        }
2434        .merge_figment(base_a)
2435        .unwrap()
2436        .extract()
2437        .unwrap();
2438
2439        let base_b = Figment::from(Serialized::defaults(&defaults));
2440        let via_string: MergeFixture = SetArgs::String {
2441            string: toml.to_string(),
2442        }
2443        .merge_figment(base_b)
2444        .unwrap()
2445        .extract()
2446        .unwrap();
2447
2448        let _ = std::fs::remove_file(file_path);
2449        assert_eq!(via_string, via_file);
2450        assert_eq!(
2451            via_string,
2452            MergeFixture {
2453                alpha: 123,
2454                beta: true,
2455            }
2456        );
2457    }
2458
2459    #[test]
2460    fn string_toml_preserves_selected_profile_and_source_metadata() {
2461        let defaults = MergeFixture {
2462            alpha: 1,
2463            beta: false,
2464        };
2465        let base = Figment::from(Serialized::defaults(&defaults))
2466            .merge(Serialized::from(
2467                std::collections::BTreeMap::from([("alpha", 9)]),
2468                "production",
2469            ))
2470            .select("production");
2471        let merged = SetArgs::String {
2472            string: "alpha = 123\nbeta = true".to_string(),
2473        }
2474        .merge_figment(base)
2475        .unwrap();
2476
2477        assert_eq!(merged.profile(), &figment::Profile::new("production"));
2478        assert_eq!(
2479            merged.extract::<MergeFixture>().unwrap(),
2480            MergeFixture {
2481                alpha: 9,
2482                beta: true,
2483            }
2484        );
2485        assert_eq!(
2486            merged.find_metadata("beta").unwrap().name,
2487            "TOML source string"
2488        );
2489    }
2490
2491    #[test]
2492    fn toml_file_errors_preserve_source_and_field_metadata() {
2493        let directory = tempfile::tempdir().unwrap();
2494        let path = directory.path().join("settings.toml");
2495        std::fs::write(&path, "alpha = 'not a number'\nbeta = true").unwrap();
2496        let error = SetArgs::File { file: path.clone() }
2497            .merge_figment(Figment::new())
2498            .unwrap()
2499            .extract::<MergeFixture>()
2500            .unwrap_err();
2501
2502        assert_eq!(error.path, ["alpha"]);
2503        let metadata = error.metadata.unwrap();
2504        assert_eq!(metadata.name, "TOML file");
2505        assert_eq!(metadata.source.unwrap().file_path(), Some(path.as_path()));
2506
2507        std::fs::remove_file(&path).unwrap();
2508        let error = SetArgs::File { file: path.clone() }
2509            .merge_figment(Figment::new())
2510            .unwrap()
2511            .extract::<MergeFixture>()
2512            .unwrap_err();
2513        assert_eq!(
2514            error.metadata.unwrap().source.unwrap().file_path(),
2515            Some(path.as_path())
2516        );
2517    }
2518
2519    #[test]
2520    fn invalid_toml_strings_report_parser_errors() {
2521        for string in ["alpha = [", "alpha = 1\nalpha = 2"] {
2522            let error = SetArgs::String {
2523                string: string.to_string(),
2524            }
2525            .merge_figment(Figment::new())
2526            .unwrap()
2527            .extract::<MergeFixture>()
2528            .unwrap_err();
2529
2530            assert!(matches!(error.kind, figment::error::Kind::Message(_)));
2531            assert_eq!(error.metadata.unwrap().name, "TOML source string");
2532        }
2533    }
2534
2535    #[test]
2536    fn string_toml_supports_multiline_nested_tables() {
2537        let defaults = RunLikeFixture {
2538            cli_settings: CliSettingsFixture {
2539                global: GlobalFixture {
2540                    generation: GenerationFixture {
2541                        compile: CompileSettingsFixture {
2542                            compilation_mode: "c++".to_string(),
2543                            optimization_level: "O0".to_string(),
2544                            fast_math: false,
2545                            unsafe_math: false,
2546                            custom: vec!["-g".to_string()],
2547                        },
2548                    },
2549                    n_cores: NCoresFixture {
2550                        feyngen: 1,
2551                        generate: 1,
2552                        compile: 1,
2553                        integrate: 1,
2554                    },
2555                },
2556            },
2557        };
2558
2559        let multiline = r#"
2560[cli_settings.global.generation.compile]
2561compilation_mode = "assembly"
2562optimization_level = "O3"
2563fast_math = true
2564unsafe_math = true
2565custom = []
2566
2567[cli_settings.global.n_cores]
2568feyngen = 10
2569generate = 1
2570compile = 10
2571integrate = 10
2572"#;
2573
2574        let base = Figment::from(Serialized::defaults(&defaults));
2575        let merged: RunLikeFixture = SetArgs::String {
2576            string: multiline.to_string(),
2577        }
2578        .merge_figment(base)
2579        .unwrap()
2580        .extract()
2581        .unwrap();
2582
2583        assert_eq!(
2584            merged
2585                .cli_settings
2586                .global
2587                .generation
2588                .compile
2589                .compilation_mode,
2590            "assembly"
2591        );
2592        assert_eq!(
2593            merged
2594                .cli_settings
2595                .global
2596                .generation
2597                .compile
2598                .optimization_level,
2599            "O3"
2600        );
2601        assert_eq!(merged.cli_settings.global.n_cores.feyngen, 10);
2602        assert_eq!(merged.cli_settings.global.n_cores.generate, 1);
2603        assert_eq!(merged.cli_settings.global.n_cores.compile, 10);
2604        assert_eq!(merged.cli_settings.global.n_cores.integrate, 10);
2605    }
2606
2607    #[test]
2608    fn set_model_rejects_internal_parameters() {
2609        let model = load_generic_model("sm");
2610        let internal_param = model
2611            .parameters
2612            .values()
2613            .find(|param| param.nature == ParameterNature::Internal)
2614            .expect("SM model must contain internal parameters")
2615            .name
2616            .to_string();
2617
2618        let mut state = State::new_test();
2619        state.model = model;
2620        state.model_parameters =
2621            gammalooprs::model::InputParamCard::default_from_model(&state.model);
2622        assert!(!state
2623            .model_parameters
2624            .contains_key(&UFOSymbol::from(internal_param.as_str())));
2625
2626        let err = Set::Model {
2627            target: ProcessArgs {
2628                process: None,
2629                integrand_name: None,
2630            },
2631            values: vec![ModelSetValue::Assignment(KvPair {
2632                key: internal_param.clone(),
2633                value: "1.0".to_string(),
2634            })],
2635        }
2636        .run(
2637            &mut state,
2638            &mut CLISettings::default(),
2639            &mut RuntimeSettings::default(),
2640        )
2641        .unwrap_err();
2642
2643        let err_text = format!("{err:?}");
2644        assert!(!err_text.is_empty());
2645    }
2646
2647    #[test]
2648    fn set_model_rejects_imaginary_component_for_real_parameters() {
2649        let mut state = State::new_test();
2650        state.model = load_generic_model("sm");
2651        state.model_parameters =
2652            gammalooprs::model::InputParamCard::default_from_model(&state.model);
2653        let parameter = state
2654            .model
2655            .parameters
2656            .values()
2657            .find(|parameter| {
2658                parameter.nature == ParameterNature::External
2659                    && parameter.parameter_type == ParameterType::Real
2660            })
2661            .expect("expected at least one external real model parameter")
2662            .name
2663            .to_string();
2664
2665        let err = Set::Model {
2666            target: ProcessArgs {
2667                process: None,
2668                integrand_name: None,
2669            },
2670            values: vec![ModelSetValue::Assignment(KvPair {
2671                key: parameter,
2672                value: "1.0+2.0i".to_string(),
2673            })],
2674        }
2675        .run(
2676            &mut state,
2677            &mut CLISettings::default(),
2678            &mut RuntimeSettings::default(),
2679        )
2680        .unwrap_err();
2681
2682        assert!(err
2683            .to_string()
2684            .contains("cannot be assigned an imaginary component"));
2685    }
2686
2687    #[test]
2688    fn updates_global_display_directive_detects_kv_string_and_file_inputs() {
2689        let tmp_name = format!(
2690            "gammaloop_set_display_directive_{}_{}.toml",
2691            std::process::id(),
2692            std::time::SystemTime::now()
2693                .duration_since(std::time::UNIX_EPOCH)
2694                .unwrap()
2695                .as_nanos()
2696        );
2697        let file_path = std::env::temp_dir().join(tmp_name);
2698        std::fs::write(
2699            &file_path,
2700            "[global]\ndisplay_directive = \"warn\"\nlogfile_directive = \"off\"\n",
2701        )
2702        .unwrap();
2703
2704        assert!(SetArgs::Kv {
2705            pairs: vec![KvPair {
2706                key: "global.display_directive".to_string(),
2707                value: "warn".to_string(),
2708            }]
2709        }
2710        .updates_global_display_directive()
2711        .unwrap());
2712
2713        assert!(SetArgs::String {
2714            string: "[global]\ndisplay_directive = \"warn\"\n".to_string(),
2715        }
2716        .updates_global_display_directive()
2717        .unwrap());
2718
2719        assert!(SetArgs::File {
2720            file: file_path.clone(),
2721        }
2722        .updates_global_display_directive()
2723        .unwrap());
2724
2725        assert!(!SetArgs::Kv {
2726            pairs: vec![KvPair {
2727                key: "global.n_cores.generate".to_string(),
2728                value: "4".to_string(),
2729            }]
2730        }
2731        .updates_global_display_directive()
2732        .unwrap());
2733
2734        let _ = std::fs::remove_file(file_path);
2735    }
2736
2737    #[test]
2738    fn updates_global_logfile_directive_detects_kv_string_and_file_inputs() {
2739        let tmp_name = format!(
2740            "gammaloop_set_logfile_directive_{}_{}.toml",
2741            std::process::id(),
2742            std::time::SystemTime::now()
2743                .duration_since(std::time::UNIX_EPOCH)
2744                .unwrap()
2745                .as_nanos()
2746        );
2747        let file_path = std::env::temp_dir().join(tmp_name);
2748        std::fs::write(
2749            &file_path,
2750            "[global]\ndisplay_directive = \"warn\"\nlogfile_directive = \"debug\"\n",
2751        )
2752        .unwrap();
2753
2754        assert!(SetArgs::Kv {
2755            pairs: vec![KvPair {
2756                key: "global.logfile_directive".to_string(),
2757                value: "debug".to_string(),
2758            }]
2759        }
2760        .updates_global_logfile_directive()
2761        .unwrap());
2762
2763        assert!(SetArgs::String {
2764            string: "[global]\nlogfile_directive = \"debug\"\n".to_string(),
2765        }
2766        .updates_global_logfile_directive()
2767        .unwrap());
2768
2769        assert!(SetArgs::File {
2770            file: file_path.clone(),
2771        }
2772        .updates_global_logfile_directive()
2773        .unwrap());
2774
2775        assert!(!SetArgs::Kv {
2776            pairs: vec![KvPair {
2777                key: "global.display_directive".to_string(),
2778                value: "warn".to_string(),
2779            }]
2780        }
2781        .updates_global_logfile_directive()
2782        .unwrap());
2783
2784        let _ = std::fs::remove_file(file_path);
2785    }
2786
2787    #[test]
2788    fn set_global_display_directive_clears_cli_stderr_override() {
2789        let _guard = crate::LOG_TEST_MUTEX
2790            .lock()
2791            .unwrap_or_else(|err| err.into_inner());
2792
2793        let mut state = State::new_test();
2794        let mut cli_settings = CLISettings::default();
2795        let mut runtime_settings = RuntimeSettings::default();
2796
2797        set_stderr_log_filter("info").unwrap();
2798        set_stderr_log_filter_override(Some("gammaloop_api=debug,gammalooprs=debug".to_string()))
2799            .unwrap();
2800
2801        Set::Global {
2802            input: SetArgs::Kv {
2803                pairs: vec![KvPair {
2804                    key: "global.display_directive".to_string(),
2805                    value: "warn".to_string(),
2806                }],
2807            },
2808        }
2809        .run(&mut state, &mut cli_settings, &mut runtime_settings)
2810        .unwrap();
2811
2812        assert_eq!(cli_settings.global.display_directive, "warn");
2813        assert_eq!(get_stderr_log_filter(), "warn");
2814    }
2815
2816    #[test]
2817    fn set_global_logfile_directive_errors_when_file_logger_was_boot_disabled() {
2818        let _guard = crate::LOG_TEST_MUTEX
2819            .lock()
2820            .unwrap_or_else(|err| err.into_inner());
2821
2822        let mut state = State::new_test();
2823        let mut cli_settings = CLISettings::default();
2824        let mut runtime_settings = RuntimeSettings::default();
2825
2826        crate::tracing::set_file_log_filter("off").unwrap();
2827        crate::tracing::set_file_log_filter_override(Some(
2828            "gammaloop_api=off,gammalooprs=off".to_string(),
2829        ))
2830        .unwrap();
2831        crate::tracing::configure_file_log_boot_mode(true, Some("--logfile-level off")).unwrap();
2832
2833        let err = Set::Global {
2834            input: SetArgs::Kv {
2835                pairs: vec![KvPair {
2836                    key: "global.logfile_directive".to_string(),
2837                    value: "debug".to_string(),
2838                }],
2839            },
2840        }
2841        .run(&mut state, &mut cli_settings, &mut runtime_settings)
2842        .unwrap_err();
2843
2844        assert!(format!("{err:?}").contains("logfile logger disabled"));
2845        crate::tracing::configure_file_log_boot_mode(false, None).unwrap();
2846        crate::tracing::set_file_log_filter_override(None).unwrap();
2847    }
2848
2849    #[test]
2850    fn set_global_kv_preserves_existing_orientation_pattern() {
2851        let mut state = State::new_test();
2852        let mut cli_settings = CLISettings::default();
2853        let mut runtime_settings = RuntimeSettings::default();
2854
2855        cli_settings.global.generation.orientation_pattern =
2856            OrientationPattern::from_user_pattern("(+,-,0)").unwrap();
2857        let expected = cli_settings.global.generation.orientation_pattern.clone();
2858
2859        Set::Global {
2860            input: SetArgs::Kv {
2861                pairs: vec![KvPair {
2862                    key: "global.n_cores.feyngen".to_string(),
2863                    value: "10".to_string(),
2864                }],
2865            },
2866        }
2867        .run(&mut state, &mut cli_settings, &mut runtime_settings)
2868        .unwrap();
2869
2870        assert_eq!(cli_settings.global.n_cores.feyngen, 10);
2871        assert_eq!(cli_settings.global.generation.orientation_pattern, expected);
2872    }
2873}