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