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