Skip to main content

gammalooprs/graph/
threshold_counterterms.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use bincode_trait_derive::{Decode, Encode};
4use color_eyre::Result;
5use eyre::{Context, eyre};
6use linnet::{
7    half_edge::involution::{EdgeIndex, HedgePair},
8    half_edge::subgraph::SubSetLike,
9    permutation::Permutation,
10};
11use serde::{Deserialize, Serialize};
12
13use super::{Graph, lmb::LMBwithEdges};
14use crate::utils::serde_utils::{IsDefault, is_false, is_true};
15
16pub const THRESHOLD_COUNTERTERM_SCHEMA_VERSION: u32 = 1;
17
18/// Compact graph-local directives controlling threshold-counterterm generation.
19///
20/// This public representation deliberately stops at individual variants. Inferred sides,
21/// left/right Cartesian products, and local/integrated components belong to process-derived data
22/// and are never serialized into the graph attribute.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Encode, Decode)]
24#[serde(deny_unknown_fields)]
25pub struct ThresholdCountertermSpec {
26    pub schema_version: u32,
27    /// Definitions shared by all multiplier expressions in this graph.
28    #[serde(default, skip_serializing_if = "IsDefault::is_default")]
29    pub function_map: BTreeMap<String, String>,
30    #[serde(default)]
31    pub cuts: Vec<ThresholdCountertermCut>,
32}
33
34impl Default for ThresholdCountertermSpec {
35    fn default() -> Self {
36        Self {
37            schema_version: THRESHOLD_COUNTERTERM_SCHEMA_VERSION,
38            function_map: BTreeMap::new(),
39            cuts: Vec::new(),
40        }
41    }
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Encode, Decode)]
45#[serde(deny_unknown_fields)]
46pub struct ThresholdCountertermCut {
47    pub edges: Vec<EdgeIndex>,
48    #[serde(default, skip_serializing_if = "IsDefault::is_default")]
49    pub thresholds: Vec<ThresholdCountertermThreshold>,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Encode, Decode)]
53#[serde(deny_unknown_fields)]
54pub struct ThresholdCountertermThreshold {
55    pub edges: Vec<EdgeIndex>,
56    #[serde(default, skip_serializing_if = "IsDefault::is_default")]
57    pub counterterms: Vec<ThresholdCountertermVariant>,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Encode, Decode)]
61#[serde(deny_unknown_fields)]
62pub struct ThresholdCountertermVariant {
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub name: Option<String>,
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub group_id: Option<usize>,
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub subspace: Option<Vec<EdgeIndex>>,
69    /// Required in explicit graph metadata; absent only for internally synthesized defaults.
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub parent_lmb: Option<Vec<EdgeIndex>>,
72    #[serde(default, skip_serializing_if = "is_false")]
73    pub disable: bool,
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub multiplier: Option<ThresholdCountertermMultiplier>,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Encode, Decode)]
79#[serde(deny_unknown_fields)]
80pub struct ThresholdCountertermMultiplier {
81    pub expression: String,
82    /// Definitions local to this multiplier, overriding shared definitions.
83    #[serde(default, skip_serializing_if = "IsDefault::is_default")]
84    pub function_map: BTreeMap<String, String>,
85    #[serde(default, skip_serializing_if = "is_false")]
86    pub symmetrize: bool,
87    #[serde(
88        default = "default_opaque_derivatives",
89        skip_serializing_if = "is_true"
90    )]
91    pub opaque_derivatives: bool,
92}
93
94fn default_opaque_derivatives() -> bool {
95    true
96}
97
98impl ThresholdCountertermSpec {
99    pub(crate) fn validate_group_ids<'a>(specs: impl IntoIterator<Item = &'a Self>) -> Result<()> {
100        let ids = specs
101            .into_iter()
102            .flat_map(|spec| &spec.cuts)
103            .flat_map(|cut| &cut.thresholds)
104            .flat_map(|threshold| &threshold.counterterms)
105            .filter_map(|variant| variant.group_id)
106            .collect::<BTreeSet<_>>();
107        if let Some(max_id) = ids.iter().next_back().copied() {
108            for expected in 0..=max_id {
109                if !ids.contains(&expected) {
110                    return Err(eyre!(
111                        "threshold_counterterms group_id values must start at 0 and be contiguous; missing group_id {} (present: {:?})",
112                        expected,
113                        ids,
114                    ));
115                }
116            }
117        }
118        Ok(())
119    }
120    /// Whether all explicit entries are semantically identical to legacy subtraction.
121    ///
122    /// Names and explicit provenance do not affect this predicate. Process resolution can use it
123    /// to keep normalized `--include-autogenerated-fields` documents on the homogeneous fast path.
124    pub fn is_legacy_equivalent(&self) -> bool {
125        self.cuts.iter().all(|cut| {
126            cut.thresholds.iter().all(|threshold| {
127                threshold.counterterms.is_empty()
128                    || (threshold.counterterms.len() == 1
129                        && threshold.counterterms[0].is_legacy_equivalent())
130            })
131        })
132    }
133
134    pub(crate) fn parse_toml(source: &str) -> Result<Self> {
135        let mut spec: Self = toml::from_str(source)
136            .context("failed to parse graph threshold_counterterms TOML document")?;
137        spec.normalize_graph_independent()?;
138        Ok(spec)
139    }
140
141    pub(crate) fn to_toml(&self) -> Result<String> {
142        toml::to_string_pretty(self)
143            .context("failed to serialize graph threshold_counterterms TOML document")
144    }
145
146    /// Applies the edge permutation used while importing split cross-section graphs.
147    ///
148    /// Set-like cut and threshold identities are sorted after remapping. Ordered subspace and
149    /// parent-LMB declarations retain their declared order.
150    pub(crate) fn remap_edges(&mut self, permutation: &Permutation) -> Result<()> {
151        let remap = |edge: &mut EdgeIndex| -> Result<()> {
152            let mapped = permutation.map().get(edge.0).copied().ok_or_else(|| {
153                eyre!(
154                    "threshold_counterterms references edge {}, but the imported graph has only {} edges",
155                    edge.0,
156                    permutation.map().len(),
157                )
158            })?;
159            *edge = EdgeIndex(mapped);
160            Ok(())
161        };
162
163        for cut in &mut self.cuts {
164            for edge in &mut cut.edges {
165                remap(edge)?;
166            }
167            cut.edges.sort_unstable();
168
169            for threshold in &mut cut.thresholds {
170                for edge in &mut threshold.edges {
171                    remap(edge)?;
172                }
173                threshold.edges.sort_unstable();
174
175                for variant in &mut threshold.counterterms {
176                    if let Some(subspace) = &mut variant.subspace {
177                        for edge in subspace {
178                            remap(edge)?;
179                        }
180                    }
181                    if let Some(parent_lmb) = &mut variant.parent_lmb {
182                        for edge in parent_lmb {
183                            remap(edge)?;
184                        }
185                    }
186                }
187            }
188        }
189
190        Ok(())
191    }
192
193    pub(crate) fn validate_for_graph(&self, graph: &Graph) -> Result<()> {
194        let graph_is_amplitude = graph.initial_state_cut.is_empty();
195        // A declared amplitude member uses its master's edge IDs and topology. Group parsing
196        // can choose the master implicitly, so certify topology after the group is complete.
197        let defer_topology =
198            graph_is_amplitude && graph.group_id.is_some() && !graph.is_group_master;
199        let n_edges = graph.n_edges();
200        let validate_edge = |edge: EdgeIndex, role: &str| -> Result<()> {
201            if defer_topology && role != "cut" {
202                return Ok(());
203            }
204            if edge.0 >= n_edges {
205                return Err(eyre!(
206                    "graph '{}' threshold_counterterms {role} references edge {}, but the graph has only {n_edges} edges",
207                    graph.name,
208                    edge.0,
209                ));
210            }
211
212            let (pair, data) = graph
213                .iter_edges()
214                .find_map(|(pair, edge_id, data)| (edge_id == edge).then_some((pair, data)))
215                .ok_or_else(|| {
216                    eyre!(
217                        "graph '{}' threshold_counterterms {role} references missing edge {}",
218                        graph.name,
219                        edge.0,
220                    )
221                })?;
222            if !matches!(pair, HedgePair::Paired { .. }) || data.data.is_dummy {
223                return Err(eyre!(
224                    "graph '{}' threshold_counterterms {role} edge {} must be an internal, non-dummy edge",
225                    graph.name,
226                    edge.0,
227                ));
228            }
229            Ok(())
230        };
231
232        for cut in &self.cuts {
233            if graph_is_amplitude && !cut.edges.is_empty() {
234                return Err(eyre!(
235                    "amplitude graph '{}' threshold_counterterms cut edges must be empty",
236                    graph.name,
237                ));
238            }
239            if !graph_is_amplitude && cut.edges.is_empty() {
240                return Err(eyre!(
241                    "cross-section graph '{}' threshold_counterterms cut edges must not be empty",
242                    graph.name,
243                ));
244            }
245            for edge in &cut.edges {
246                validate_edge(*edge, "cut")?;
247            }
248
249            for threshold in &cut.thresholds {
250                for edge in &threshold.edges {
251                    validate_edge(*edge, "threshold")?;
252                }
253                for variant in &threshold.counterterms {
254                    if let Some(subspace) = &variant.subspace {
255                        for edge in subspace {
256                            validate_edge(*edge, "subspace")?;
257                        }
258                    }
259                    let parent_lmb = variant.parent_lmb.as_ref().ok_or_else(|| {
260                        eyre!(
261                            "graph '{}' threshold_counterterms cut {:?}, threshold {:?}, variant {:?} requires a full parent_lmb",
262                            graph.name,
263                            edge_numbers(&cut.edges),
264                            edge_numbers(&threshold.edges),
265                            variant.name,
266                        )
267                    })?;
268                    if let Some(subspace) = &variant.subspace {
269                        let missing = subspace
270                            .iter()
271                            .filter(|edge| !parent_lmb.contains(edge))
272                            .copied()
273                            .collect::<Vec<_>>();
274                        if !missing.is_empty() {
275                            return Err(eyre!(
276                                "graph '{}' threshold_counterterms variant {:?} selected edges {:?} are absent from parent_lmb {:?}",
277                                graph.name,
278                                variant.name,
279                                edge_numbers(&missing),
280                                edge_numbers(parent_lmb),
281                            ));
282                        }
283                    }
284                    for edge in parent_lmb {
285                        validate_edge(*edge, "parent_lmb")?;
286                    }
287                    if defer_topology {
288                        continue;
289                    }
290                    if parent_lmb.len() != graph.loop_momentum_basis.loop_edges.len() {
291                        return Err(eyre!(
292                            "graph '{}' threshold_counterterms variant {:?} requires a full parent_lmb with {} defining edges, received {:?}",
293                            graph.name,
294                            variant.name,
295                            graph.loop_momentum_basis.loop_edges.len(),
296                            edge_numbers(parent_lmb),
297                        ));
298                    }
299                    // A cross-section graph closes the incoming external lines into
300                    // topological cycles; generation turns those cycles back into fixed
301                    // external momenta before exposing the physical loop-momentum basis.
302                    let mut topological_parent = parent_lmb.clone();
303                    if !graph_is_amplitude {
304                        topological_parent.extend(graph.external_momentum_edge_order());
305                    }
306                    let resolved_parent = graph
307                        .lmb_with_loop_edges(topological_parent.as_slice())
308                        .with_context(|| format!(
309                            "graph '{}' threshold_counterterms variant {:?} has invalid full parent_lmb {:?}",
310                            graph.name,
311                            variant.name,
312                            edge_numbers(parent_lmb),
313                        ))?;
314                    if resolved_parent
315                        .loop_edges
316                        .iter()
317                        .copied()
318                        .collect::<BTreeSet<_>>()
319                        != topological_parent.iter().copied().collect::<BTreeSet<_>>()
320                    {
321                        return Err(eyre!(
322                            "graph '{}' threshold_counterterms variant {:?} parent_lmb {:?} does not define a full loop-momentum basis",
323                            graph.name,
324                            variant.name,
325                            edge_numbers(parent_lmb),
326                        ));
327                    }
328                }
329            }
330        }
331
332        // Amplitude graph groups share one namespace, certified once the complete group is known.
333        if !graph_is_amplitude || graph.group_id.is_none() {
334            Self::validate_group_ids([self])
335                .map_err(|error| eyre!("graph '{}': {error:#}", graph.name))?;
336        }
337
338        Ok(())
339    }
340
341    fn normalize_graph_independent(&mut self) -> Result<()> {
342        if self.schema_version != THRESHOLD_COUNTERTERM_SCHEMA_VERSION {
343            return Err(eyre!(
344                "unsupported threshold_counterterms schema_version {}; supported version is {}",
345                self.schema_version,
346                THRESHOLD_COUNTERTERM_SCHEMA_VERSION,
347            ));
348        }
349
350        let mut seen_cuts = BTreeSet::new();
351        for (cut_index, cut) in self.cuts.iter_mut().enumerate() {
352            canonicalize_set_edges(&mut cut.edges, &format!("cut #{cut_index}"))?;
353            if !seen_cuts.insert(cut.edges.clone()) {
354                return Err(eyre!(
355                    "duplicate threshold_counterterms cut with edges {:?}",
356                    edge_numbers(&cut.edges),
357                ));
358            }
359
360            let mut seen_thresholds = BTreeSet::new();
361            for (threshold_index, threshold) in cut.thresholds.iter_mut().enumerate() {
362                canonicalize_set_edges(
363                    &mut threshold.edges,
364                    &format!("cut #{cut_index} threshold #{threshold_index}"),
365                )?;
366                if threshold.edges.is_empty() {
367                    return Err(eyre!(
368                        "threshold_counterterms cut #{cut_index} threshold #{threshold_index} has an empty edge set",
369                    ));
370                }
371                if !seen_thresholds.insert(threshold.edges.clone()) {
372                    return Err(eyre!(
373                        "duplicate threshold_counterterms threshold with edges {:?} under cut {:?}",
374                        edge_numbers(&threshold.edges),
375                        edge_numbers(&cut.edges),
376                    ));
377                }
378
379                normalize_variants(
380                    &mut threshold.counterterms,
381                    &format!(
382                        "cut {:?}, threshold {:?}",
383                        edge_numbers(&cut.edges),
384                        edge_numbers(&threshold.edges),
385                    ),
386                )?;
387            }
388        }
389
390        Ok(())
391    }
392}
393
394impl ThresholdCountertermVariant {
395    pub fn is_legacy_equivalent(&self) -> bool {
396        !self.disable
397            && self.group_id.is_none()
398            && self.subspace.is_none()
399            && self.parent_lmb.is_none()
400            && self.multiplier.is_none()
401    }
402}
403
404fn canonicalize_set_edges(edges: &mut [EdgeIndex], context: &str) -> Result<()> {
405    edges.sort_unstable();
406    if edges.windows(2).any(|pair| pair[0] == pair[1]) {
407        return Err(eyre!(
408            "threshold_counterterms {context} contains duplicate edge IDs {:?}",
409            edge_numbers(edges),
410        ));
411    }
412    Ok(())
413}
414
415fn validate_ordered_edges(
416    edges: &Option<Vec<EdgeIndex>>,
417    field: &str,
418    context: &str,
419) -> Result<()> {
420    let Some(edges) = edges else {
421        return Ok(());
422    };
423    if edges.is_empty() {
424        return Err(eyre!(
425            "threshold_counterterms {context} has an empty {field}; explicit parent_lmb must be full and a selected subspace must be nonempty",
426        ));
427    }
428    let mut seen = BTreeSet::new();
429    if let Some(duplicate) = edges.iter().find(|edge| !seen.insert(**edge)) {
430        return Err(eyre!(
431            "threshold_counterterms {context} {field} contains duplicate edge {}",
432            duplicate.0,
433        ));
434    }
435    Ok(())
436}
437
438fn normalize_variants(variants: &mut [ThresholdCountertermVariant], context: &str) -> Result<()> {
439    if variants.len() == 1 && variants[0].name.is_none() {
440        variants[0].name = Some("default".to_string());
441    }
442
443    let mut names = BTreeSet::new();
444    for (variant_index, variant) in variants.iter().enumerate() {
445        let variant_context = format!("{context}, counterterm #{variant_index}");
446        let Some(name) = variant.name.as_deref() else {
447            return Err(eyre!(
448                "threshold_counterterms {variant_context} requires a name when multiple variants are present",
449            ));
450        };
451        if name.trim().is_empty() {
452            return Err(eyre!(
453                "threshold_counterterms {variant_context} has a blank name",
454            ));
455        }
456        if !names.insert(name) {
457            return Err(eyre!(
458                "threshold_counterterms {context} contains duplicate counterterm name '{name}'",
459            ));
460        }
461
462        validate_ordered_edges(&variant.subspace, "subspace", &variant_context)?;
463        validate_ordered_edges(&variant.parent_lmb, "parent_lmb", &variant_context)?;
464        if variant.parent_lmb.is_none() {
465            return Err(eyre!(
466                "threshold_counterterms {variant_context}, variant '{name}' requires a full parent_lmb to identify its signed loop cycles; omit the counterterm entry to use automatic defaults",
467            ));
468        }
469
470        if let Some(multiplier) = &variant.multiplier {
471            if multiplier.expression.trim().is_empty() {
472                return Err(eyre!(
473                    "threshold_counterterms {variant_context} multiplier expression must not be blank",
474                ));
475            }
476            if !multiplier.opaque_derivatives {
477                return Err(eyre!(
478                    "threshold_counterterms {variant_context} sets opaque_derivatives=false, which is not supported",
479                ));
480            }
481        }
482    }
483
484    Ok(())
485}
486
487fn edge_numbers(edges: &[EdgeIndex]) -> Vec<usize> {
488    edges.iter().map(|edge| edge.0).collect()
489}
490
491#[cfg(test)]
492mod tests {
493    use linnet::{half_edge::involution::EdgeIndex, permutation::Permutation};
494
495    use super::{
496        THRESHOLD_COUNTERTERM_SCHEMA_VERSION, ThresholdCountertermCut, ThresholdCountertermSpec,
497        ThresholdCountertermThreshold, ThresholdCountertermVariant,
498    };
499
500    #[test]
501    fn verbose_threshold_settings_preserve_optional_semantics() {
502        use crate::utils::serde_utils::ShowDefaultsGuard;
503
504        let spec = ThresholdCountertermSpec::parse_toml(
505            r#"
506schema_version = 1
507[[cuts]]
508edges = []
509[[cuts.thresholds]]
510edges = [1, 2]
511[[cuts.thresholds.counterterms]]
512parent_lmb = [1]
513[cuts.thresholds.counterterms.multiplier]
514expression = "f(1)"
515"#,
516        )
517        .unwrap();
518        let compact = {
519            let _defaults = ShowDefaultsGuard::new(false);
520            spec.to_toml().unwrap()
521        };
522        assert!(!compact.contains("disable ="));
523        assert!(!compact.contains("symmetrize ="));
524        assert!(!compact.contains("opaque_derivatives ="));
525        let verbose = {
526            let _defaults = ShowDefaultsGuard::new(true);
527            spec.to_toml().unwrap()
528        };
529        assert!(verbose.contains("disable = false"));
530        assert!(verbose.contains("symmetrize = false"));
531        assert!(verbose.contains("opaque_derivatives = true"));
532        assert!(verbose.contains("[function_map]"));
533        assert!(!verbose.contains("group_id"));
534        assert_eq!(
535            ThresholdCountertermSpec::parse_toml(&verbose).unwrap(),
536            spec
537        );
538    }
539
540    #[test]
541    fn parses_and_canonicalizes_compact_document() {
542        let spec = ThresholdCountertermSpec::parse_toml(
543            r#"
544schema_version = 1
545
546[[cuts]]
547edges = [4, 2]
548
549  [[cuts.thresholds]]
550  edges = [8, 7]
551
552    [[cuts.thresholds.counterterms]]
553    subspace = [7]
554    parent_lmb = [7, 8]
555
556      [cuts.thresholds.counterterms.multiplier]
557      expression = "eta(effective, eset(7, 8))"
558      symmetrize = true
559"#,
560        )
561        .unwrap();
562
563        assert_eq!(spec.schema_version, THRESHOLD_COUNTERTERM_SCHEMA_VERSION);
564        assert_eq!(spec.cuts[0].edges, vec![EdgeIndex(2), EdgeIndex(4)]);
565        assert_eq!(
566            spec.cuts[0].thresholds[0].edges,
567            vec![EdgeIndex(7), EdgeIndex(8)]
568        );
569        assert_eq!(
570            spec.cuts[0].thresholds[0].counterterms[0].name.as_deref(),
571            Some("default")
572        );
573        assert!(
574            spec.cuts[0].thresholds[0].counterterms[0]
575                .multiplier
576                .as_ref()
577                .unwrap()
578                .symmetrize
579        );
580    }
581
582    #[test]
583    fn rejects_unknown_fields_and_unsupported_options() {
584        let unknown = ThresholdCountertermSpec::parse_toml("schema_version = 1\nunknown = true\n")
585            .unwrap_err();
586        assert!(format!("{unknown:#}").contains("unknown field"));
587
588        let derivatives = ThresholdCountertermSpec::parse_toml(
589            r#"
590schema_version = 1
591[[cuts]]
592edges = []
593[[cuts.thresholds]]
594edges = [1, 2]
595[[cuts.thresholds.counterterms]]
596name = "v"
597parent_lmb = [1]
598[cuts.thresholds.counterterms.multiplier]
599expression = "1"
600opaque_derivatives = false
601"#,
602        )
603        .unwrap_err();
604        assert!(derivatives.to_string().contains("opaque_derivatives=false"));
605    }
606
607    #[test]
608    fn validates_variant_names_and_ordered_edge_lists() {
609        let unnamed = ThresholdCountertermSpec::parse_toml(
610            r#"
611schema_version = 1
612[[cuts]]
613edges = []
614[[cuts.thresholds]]
615edges = [1, 2]
616[[cuts.thresholds.counterterms]]
617subspace = [1]
618parent_lmb = [1, 2]
619[[cuts.thresholds.counterterms]]
620name = "second"
621subspace = [2]
622parent_lmb = [1, 2]
623"#,
624        )
625        .unwrap_err();
626        assert!(unnamed.to_string().contains("requires a name"));
627
628        let duplicate_subspace = ThresholdCountertermSpec::parse_toml(
629            r#"
630schema_version = 1
631[[cuts]]
632edges = []
633[[cuts.thresholds]]
634edges = [1, 2]
635[[cuts.thresholds.counterterms]]
636subspace = [1, 1]
637parent_lmb = [1, 2]
638"#,
639        )
640        .unwrap_err();
641        assert!(duplicate_subspace.to_string().contains("duplicate edge 1"));
642    }
643
644    #[test]
645    fn remaps_all_edge_fields_and_preserves_ordered_lists() {
646        let mut spec = ThresholdCountertermSpec::parse_toml(
647            r#"
648schema_version = 1
649[[cuts]]
650edges = [0, 2]
651[[cuts.thresholds]]
652edges = [1, 2]
653[[cuts.thresholds.counterterms]]
654subspace = [2, 0]
655parent_lmb = [1, 2, 0]
656"#,
657        )
658        .unwrap();
659        let permutation = Permutation::from_map(vec![2, 0, 1]);
660
661        spec.remap_edges(&permutation).unwrap();
662
663        assert_eq!(spec.cuts[0].edges, vec![EdgeIndex(1), EdgeIndex(2)]);
664        assert_eq!(
665            spec.cuts[0].thresholds[0].edges,
666            vec![EdgeIndex(0), EdgeIndex(1)]
667        );
668        let ThresholdCountertermVariant {
669            subspace,
670            parent_lmb,
671            ..
672        } = &spec.cuts[0].thresholds[0].counterterms[0];
673        assert_eq!(subspace.as_ref().unwrap(), &[EdgeIndex(1), EdgeIndex(2)]);
674        assert_eq!(
675            parent_lmb.as_ref().unwrap(),
676            &[EdgeIndex(0), EdgeIndex(1), EdgeIndex(2)]
677        );
678    }
679
680    #[test]
681    fn recognizes_semantically_legacy_documents() {
682        let empty = ThresholdCountertermSpec::parse_toml("schema_version = 1\n").unwrap();
683        assert!(empty.function_map.is_empty());
684        assert!(empty.is_legacy_equivalent());
685        assert!(empty.to_toml().unwrap().contains("cuts = []"));
686
687        let named_default = ThresholdCountertermSpec {
688            cuts: vec![ThresholdCountertermCut {
689                edges: vec![],
690                thresholds: vec![ThresholdCountertermThreshold {
691                    edges: vec![EdgeIndex(1), EdgeIndex(2)],
692                    counterterms: vec![ThresholdCountertermVariant {
693                        name: Some("normalized_default".to_string()),
694                        group_id: None,
695                        subspace: None,
696                        parent_lmb: None,
697                        disable: false,
698                        multiplier: None,
699                    }],
700                }],
701            }],
702            ..ThresholdCountertermSpec::default()
703        };
704        assert!(named_default.is_legacy_equivalent());
705
706        let custom_subspace = ThresholdCountertermSpec::parse_toml(
707            r#"
708schema_version = 1
709[[cuts]]
710edges = []
711[[cuts.thresholds]]
712edges = [1, 2]
713[[cuts.thresholds.counterterms]]
714subspace = [1]
715parent_lmb = [1, 2]
716"#,
717        )
718        .unwrap();
719        assert!(!custom_subspace.is_legacy_equivalent());
720
721        let disabled = ThresholdCountertermSpec::parse_toml(
722            r#"
723schema_version = 1
724[[cuts]]
725edges = []
726[[cuts.thresholds]]
727edges = [1, 2]
728[[cuts.thresholds.counterterms]]
729disable = true
730parent_lmb = [1, 2]
731"#,
732        )
733        .unwrap();
734        assert!(!disabled.is_legacy_equivalent());
735    }
736
737    #[test]
738    fn explicit_variants_require_parent_but_implicit_defaults_do_not() {
739        let defaults =
740            "schema_version = 1\n[[cuts]]\nedges = []\n[[cuts.thresholds]]\nedges = [1, 2]\n";
741        assert!(
742            ThresholdCountertermSpec::parse_toml(defaults)
743                .unwrap()
744                .is_legacy_equivalent()
745        );
746        for declaration in [
747            "name = \"named\"",
748            "subspace = [1]",
749            "disable = true",
750            "group_id = 0",
751        ] {
752            let error = ThresholdCountertermSpec::parse_toml(&format!(
753                "{defaults}[[cuts.thresholds.counterterms]]\n{declaration}\n"
754            ))
755            .unwrap_err();
756            assert!(error.to_string().contains("requires a full parent_lmb"));
757            assert!(error.to_string().contains("threshold [1, 2]"));
758        }
759    }
760
761    #[test]
762    fn group_ids_must_start_at_zero_and_be_contiguous() {
763        let mut spec = ThresholdCountertermSpec::default();
764        spec.cuts.push(ThresholdCountertermCut {
765            edges: vec![],
766            thresholds: vec![ThresholdCountertermThreshold {
767                edges: vec![EdgeIndex(1)],
768                counterterms: vec![ThresholdCountertermVariant {
769                    name: Some("gapped".to_string()),
770                    group_id: Some(1),
771                    subspace: None,
772                    parent_lmb: None,
773                    disable: false,
774                    multiplier: None,
775                }],
776            }],
777        });
778        let error = super::ThresholdCountertermSpec::validate_group_ids([&spec]).unwrap_err();
779        assert!(error.to_string().contains("missing group_id 0"));
780    }
781}