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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Encode, Decode)]
24#[serde(deny_unknown_fields)]
25pub struct ThresholdCountertermSpec {
26 pub schema_version: u32,
27 #[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 #[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 #[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 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 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 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 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 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}