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 BaseDir {
52 #[arg(value_hint = clap::ValueHint::DirPath)]
54 path: PathBuf,
55 },
56 Global {
58 #[command(subcommand)]
59 input: SetArgs,
60 },
61
62 DefaultRuntime {
64 #[command(subcommand)]
65 input: SetArgs,
66 },
67
68 Model {
70 #[command(flatten)]
71 target: ProcessArgs,
72 #[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 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#[derive(Subcommand, Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
440pub enum SetArgs {
441 File {
443 #[arg(value_hint = clap::ValueHint::FilePath)]
445 file: PathBuf,
446 },
447
448 String {
450 #[arg(value_name = "TOML")]
452 string: String,
453 },
454
455 Kv {
457 #[arg(value_name = "KEY=VALUE", num_args = 1.., value_parser = KvPair::from_str)]
459 pairs: Vec<KvPair>,
460 },
461
462 Defaults,
464
465 Stored,
467}
468
469#[derive(Subcommand, Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
470pub enum ProcessSetArgs {
471 File {
473 #[arg(value_hint = clap::ValueHint::FilePath)]
475 file: PathBuf,
476 },
477
478 String {
480 #[arg(value_name = "TOML")]
482 string: String,
483 },
484
485 Kv {
487 #[arg(value_name = "KEY=VALUE", num_args = 1.., value_parser = KvPair::from_str)]
489 pairs: Vec<KvPair>,
490 },
491
492 Defaults,
494
495 Stored,
497
498 Add {
500 #[command(subcommand)]
501 target: ProcessAddTarget,
502 },
503
504 Update {
506 #[command(subcommand)]
507 target: ProcessUpdateTarget,
508 },
509
510 Remove {
512 #[command(subcommand)]
513 target: ProcessRemoveTarget,
514 },
515}
516
517#[derive(Subcommand, Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
518pub enum ProcessAddTarget {
519 Quantity {
521 name: String,
523 #[arg(value_parser = parse_quantity_kind)]
525 kind: String,
526 #[arg(value_name = "KEY=VALUE", num_args = 0.., value_parser = KvPair::from_str)]
528 pairs: Vec<KvPair>,
529 },
530 Observable {
532 name: String,
534 #[arg(value_name = "KEY=VALUE", num_args = 0.., value_parser = KvPair::from_str)]
536 pairs: Vec<KvPair>,
537 },
538 Selector {
540 name: String,
542 #[arg(value_parser = parse_selector_kind)]
544 kind: String,
545 #[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 Quantity {
555 name: String,
557 #[arg(value_name = "KEY=VALUE", num_args = 1.., value_parser = KvPair::from_str)]
559 pairs: Vec<KvPair>,
560 },
561 Observable {
563 name: String,
565 #[arg(value_name = "KEY=VALUE", num_args = 1.., value_parser = KvPair::from_str)]
567 pairs: Vec<KvPair>,
568 },
569 Selector {
571 name: String,
573 #[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 Quantity {
583 name: String,
585 },
586 Observable {
588 name: String,
590 },
591 Selector {
593 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 "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(¤t, 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 if let Ok(v) = json5::from_str::<J>(raw) {
898 return Ok(v);
899 }
900 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 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(¶meter.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}