1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::{Deserialize, Serialize};
4
5use crate::campaign::stable_json_fingerprint;
6use crate::error::{DagMlError, Result};
7use crate::ids::{NodeId, VariantId};
8use crate::rng::SeedContext;
9
10#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum GenerationStrategy {
13 #[default]
14 None,
15 Cartesian,
16 Zip,
17}
18
19#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
26pub struct ChoiceRef {
27 pub dimension: String,
28 pub label: String,
29}
30
31#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
49pub struct GenerationConstraints {
50 #[serde(default, skip_serializing_if = "Vec::is_empty")]
54 pub mutex: Vec<Vec<ChoiceRef>>,
55 #[serde(default, skip_serializing_if = "Vec::is_empty")]
57 pub requires: Vec<(ChoiceRef, ChoiceRef)>,
58 #[serde(default, skip_serializing_if = "Vec::is_empty")]
60 pub exclude: Vec<(ChoiceRef, ChoiceRef)>,
61}
62
63impl GenerationConstraints {
64 pub fn is_empty(&self) -> bool {
65 self.mutex.is_empty() && self.requires.is_empty() && self.exclude.is_empty()
66 }
67}
68
69#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
70pub struct GenerationChoice {
71 pub label: String,
72 pub value: serde_json::Value,
73 #[serde(default, skip_serializing_if = "Vec::is_empty")]
74 pub param_overrides: Vec<GenerationParamOverride>,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub active_subsequence: Option<String>,
81}
82
83#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
84pub struct GenerationParamOverride {
85 pub node_id: NodeId,
86 #[serde(default)]
87 pub params: BTreeMap<String, serde_json::Value>,
88}
89
90impl GenerationChoice {
91 fn validate(&self, dimension_name: &str) -> Result<()> {
92 if self.label.trim().is_empty() {
93 return Err(DagMlError::CampaignValidation(format!(
94 "generation dimension `{dimension_name}` has an empty choice label"
95 )));
96 }
97 if !self.param_overrides.is_empty() && self.active_subsequence.is_some() {
98 return Err(DagMlError::CampaignValidation(format!(
99 "generation choice `{}` in dimension `{dimension_name}` cannot set both param_overrides and active_subsequence",
100 self.label
101 )));
102 }
103 if let Some(active_subsequence) = &self.active_subsequence {
104 if active_subsequence.trim().is_empty() {
105 return Err(DagMlError::CampaignValidation(format!(
106 "generation choice `{}` in dimension `{dimension_name}` has an empty active_subsequence",
107 self.label
108 )));
109 }
110 }
111 for override_spec in &self.param_overrides {
112 override_spec.validate(dimension_name, &self.label)?;
113 }
114 Ok(())
115 }
116}
117
118impl GenerationParamOverride {
119 fn validate(&self, dimension_name: &str, choice_label: &str) -> Result<()> {
120 if self.params.is_empty() {
121 return Err(DagMlError::CampaignValidation(format!(
122 "generation choice `{choice_label}` in dimension `{dimension_name}` has an empty param override for node `{}`",
123 self.node_id
124 )));
125 }
126 for key in self.params.keys() {
127 if key.trim().is_empty() {
128 return Err(DagMlError::CampaignValidation(format!(
129 "generation choice `{choice_label}` in dimension `{dimension_name}` has an empty param override key for node `{}`",
130 self.node_id
131 )));
132 }
133 }
134 Ok(())
135 }
136}
137
138#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
139pub struct GenerationDimension {
140 pub name: String,
141 #[serde(default)]
142 pub choices: Vec<GenerationChoice>,
143}
144
145impl GenerationDimension {
146 fn validate(&self) -> Result<()> {
147 if self.name.trim().is_empty() {
148 return Err(DagMlError::CampaignValidation(
149 "generation dimension name is empty".to_string(),
150 ));
151 }
152 if self.choices.is_empty() {
153 return Err(DagMlError::CampaignValidation(format!(
154 "generation dimension `{}` has no choices",
155 self.name
156 )));
157 }
158 let mut labels = BTreeSet::new();
159 for choice in &self.choices {
160 choice.validate(&self.name)?;
161 if !labels.insert(choice.label.as_str()) {
162 return Err(DagMlError::CampaignValidation(format!(
163 "generation dimension `{}` has duplicate choice `{}`",
164 self.name, choice.label
165 )));
166 }
167 }
168 Ok(())
169 }
170}
171
172#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
173pub struct GenerationSpec {
174 #[serde(default)]
175 pub strategy: GenerationStrategy,
176 #[serde(default)]
177 pub dimensions: Vec<GenerationDimension>,
178 #[serde(default)]
179 pub max_variants: Option<usize>,
180 #[serde(default, skip_serializing_if = "GenerationConstraints::is_empty")]
183 pub constraints: GenerationConstraints,
184}
185
186impl Default for GenerationSpec {
187 fn default() -> Self {
188 Self {
189 strategy: GenerationStrategy::None,
190 dimensions: Vec::new(),
191 max_variants: Some(1),
192 constraints: GenerationConstraints::default(),
193 }
194 }
195}
196
197impl GenerationSpec {
198 pub fn validate(&self) -> Result<()> {
199 if self.max_variants == Some(0) {
200 return Err(DagMlError::CampaignValidation(
201 "generation max_variants cannot be zero".to_string(),
202 ));
203 }
204 if self.strategy == GenerationStrategy::None {
205 if !self.dimensions.is_empty() {
206 return Err(DagMlError::CampaignValidation(
207 "generation dimensions require cartesian or zip strategy".to_string(),
208 ));
209 }
210 if !self.constraints.is_empty() {
211 return Err(DagMlError::CampaignValidation(
212 "generation constraints require cartesian or zip strategy".to_string(),
213 ));
214 }
215 return Ok(());
216 }
217
218 if self.dimensions.is_empty() {
219 return Err(DagMlError::CampaignValidation(
220 "generation strategy requires at least one dimension".to_string(),
221 ));
222 }
223 let mut names = BTreeSet::new();
224 for dimension in &self.dimensions {
225 dimension.validate()?;
226 if !names.insert(dimension.name.as_str()) {
227 return Err(DagMlError::CampaignValidation(format!(
228 "duplicate generation dimension `{}`",
229 dimension.name
230 )));
231 }
232 }
233 if self.strategy == GenerationStrategy::Zip {
234 let expected = self.dimensions[0].choices.len();
235 if self
236 .dimensions
237 .iter()
238 .any(|dimension| dimension.choices.len() != expected)
239 {
240 return Err(DagMlError::CampaignValidation(
241 "zip generation requires every dimension to have the same number of choices"
242 .to_string(),
243 ));
244 }
245 }
246 self.validate_constraints()?;
247 Ok(())
248 }
249
250 fn validate_constraints(&self) -> Result<()> {
254 if self.constraints.is_empty() {
255 return Ok(());
256 }
257 let mut valid = BTreeSet::<(&str, &str)>::new();
258 for dimension in &self.dimensions {
259 for choice in &dimension.choices {
260 valid.insert((dimension.name.as_str(), choice.label.as_str()));
261 }
262 }
263 let check = |reference: &ChoiceRef| -> Result<()> {
264 if !valid.contains(&(reference.dimension.as_str(), reference.label.as_str())) {
265 return Err(DagMlError::CampaignValidation(format!(
266 "generation constraint references unknown choice `{}:{}`",
267 reference.dimension, reference.label
268 )));
269 }
270 Ok(())
271 };
272 for group in &self.constraints.mutex {
273 if group.len() < 2 {
274 return Err(DagMlError::CampaignValidation(
275 "generation mutex group requires at least two choices".to_string(),
276 ));
277 }
278 let mut distinct = BTreeSet::new();
279 for reference in group {
280 check(reference)?;
281 if !distinct.insert((reference.dimension.as_str(), reference.label.as_str())) {
282 return Err(DagMlError::CampaignValidation(format!(
283 "generation mutex group repeats choice `{}:{}`",
284 reference.dimension, reference.label
285 )));
286 }
287 }
288 }
289 for (group_label, pairs) in [
290 ("requires", &self.constraints.requires),
291 ("exclude", &self.constraints.exclude),
292 ] {
293 for (left, right) in pairs {
294 check(left)?;
295 check(right)?;
296 if left == right {
297 return Err(DagMlError::CampaignValidation(format!(
298 "generation {group_label} pair repeats choice `{}:{}`",
299 left.dimension, left.label
300 )));
301 }
302 }
303 }
304 Ok(())
305 }
306}
307
308#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
324pub struct OperatorVariantModel {
325 pub generator_id: NodeId,
327 pub dimension: GenerationDimension,
329 #[serde(default)]
331 pub active_nodes: BTreeMap<String, BTreeSet<NodeId>>,
332 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
341 pub variant_labels: BTreeMap<String, String>,
342}
343
344impl OperatorVariantModel {
345 pub fn validate(&self) -> Result<()> {
353 self.dimension.validate()?;
354 let mut active_subsequences = BTreeSet::new();
355 for choice in &self.dimension.choices {
356 if !choice.param_overrides.is_empty() {
357 return Err(DagMlError::CampaignValidation(format!(
358 "operator variant model `{}` choice `{}` must not carry param_overrides",
359 self.generator_id, choice.label
360 )));
361 }
362 let Some(active_subsequence) = &choice.active_subsequence else {
363 return Err(DagMlError::CampaignValidation(format!(
364 "operator variant model `{}` choice `{}` is missing an active_subsequence",
365 self.generator_id, choice.label
366 )));
367 };
368 if !active_subsequences.insert(active_subsequence.as_str()) {
369 return Err(DagMlError::CampaignValidation(format!(
370 "operator variant model `{}` has duplicate active_subsequence `{active_subsequence}`",
371 self.generator_id
372 )));
373 }
374 let Some(nodes) = self.active_nodes.get(active_subsequence) else {
375 return Err(DagMlError::CampaignValidation(format!(
376 "operator variant model `{}` choice `{}` has no active-node set for `{active_subsequence}`",
377 self.generator_id, choice.label
378 )));
379 };
380 if nodes.is_empty() {
381 return Err(DagMlError::CampaignValidation(format!(
382 "operator variant model `{}` choice `{}` has an empty active-node set",
383 self.generator_id, choice.label
384 )));
385 }
386 }
387 for key in self.active_nodes.keys() {
389 if !active_subsequences.contains(key.as_str()) {
390 return Err(DagMlError::CampaignValidation(format!(
391 "operator variant model `{}` has a stray active-node set `{key}` with no matching choice",
392 self.generator_id
393 )));
394 }
395 }
396 if !self.variant_labels.is_empty() {
402 for active_subsequence in &active_subsequences {
403 let Some(label) = self.variant_labels.get(*active_subsequence) else {
404 return Err(DagMlError::CampaignValidation(format!(
405 "operator variant model `{}` has no variant_label for `{active_subsequence}`",
406 self.generator_id
407 )));
408 };
409 if label.len() != 64 || !label.bytes().all(|byte| byte.is_ascii_hexdigit()) {
410 return Err(DagMlError::CampaignValidation(format!(
411 "operator variant model `{}` variant_label for `{active_subsequence}` is not a 64-hex sha256",
412 self.generator_id
413 )));
414 }
415 }
416 for key in self.variant_labels.keys() {
417 if !active_subsequences.contains(key.as_str()) {
418 return Err(DagMlError::CampaignValidation(format!(
419 "operator variant model `{}` has a stray variant_label `{key}` with no matching choice",
420 self.generator_id
421 )));
422 }
423 }
424 }
425 Ok(())
426 }
427
428 pub fn generation_spec(&self) -> GenerationSpec {
438 GenerationSpec {
439 strategy: GenerationStrategy::Cartesian,
440 dimensions: vec![self.dimension.clone()],
441 max_variants: Some(self.dimension.choices.len()),
442 constraints: GenerationConstraints::default(),
443 }
444 }
445}
446
447#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
448pub struct VariantPlan {
449 pub variant_id: VariantId,
450 #[serde(default)]
451 pub choices: BTreeMap<String, GenerationChoice>,
452 pub fingerprint: String,
453 pub seed: Option<u64>,
454}
455
456impl VariantPlan {
457 pub fn validate(&self) -> Result<()> {
458 if self.fingerprint.trim().is_empty() {
459 return Err(DagMlError::Planning(format!(
460 "variant `{}` has an empty fingerprint",
461 self.variant_id
462 )));
463 }
464 for (dimension_name, choice) in &self.choices {
465 choice.validate(dimension_name)?;
466 }
467 self.param_overrides_by_node()?;
468 Ok(())
469 }
470
471 pub fn effective_params_for_node(
472 &self,
473 node_id: &NodeId,
474 base_params: &BTreeMap<String, serde_json::Value>,
475 ) -> Result<BTreeMap<String, serde_json::Value>> {
476 let overrides_by_node = self.param_overrides_by_node()?;
477 let Some(overrides) = overrides_by_node.get(node_id) else {
478 return Ok(base_params.clone());
479 };
480 let mut params = base_params.clone();
481 params.extend(overrides.clone());
482 Ok(params)
483 }
484
485 pub fn param_override_targets(&self) -> Result<BTreeSet<NodeId>> {
486 Ok(self.param_overrides_by_node()?.into_keys().collect())
487 }
488
489 fn param_overrides_by_node(
490 &self,
491 ) -> Result<BTreeMap<NodeId, BTreeMap<String, serde_json::Value>>> {
492 let mut overrides = BTreeMap::<NodeId, BTreeMap<String, serde_json::Value>>::new();
493 let mut owners = BTreeMap::<(NodeId, String), String>::new();
494 for (dimension_name, choice) in &self.choices {
495 for override_spec in &choice.param_overrides {
496 for (param_key, value) in &override_spec.params {
497 let owner_key = (override_spec.node_id.clone(), param_key.clone());
498 if let Some(previous) =
499 owners.insert(owner_key, format!("{dimension_name}:{}", choice.label))
500 {
501 return Err(DagMlError::CampaignValidation(format!(
502 "variant `{}` has conflicting generation overrides for `{}.{}` from `{previous}` and `{}:{}`",
503 self.variant_id,
504 override_spec.node_id,
505 param_key,
506 dimension_name,
507 choice.label
508 )));
509 }
510 overrides
511 .entry(override_spec.node_id.clone())
512 .or_default()
513 .insert(param_key.clone(), value.clone());
514 }
515 }
516 }
517 Ok(overrides)
518 }
519}
520
521pub fn enumerate_variants(
522 spec: &GenerationSpec,
523 root_seed: Option<u64>,
524) -> Result<Vec<VariantPlan>> {
525 spec.validate()?;
526 let mut variants = match spec.strategy {
527 GenerationStrategy::None => vec![BTreeMap::new()],
528 GenerationStrategy::Cartesian => cartesian_choices(&spec.dimensions),
529 GenerationStrategy::Zip => zip_choices(&spec.dimensions),
530 };
531 if !spec.constraints.is_empty() {
532 variants.retain(|choices| satisfies_constraints(choices, &spec.constraints));
536 if variants.is_empty() {
537 return Err(DagMlError::CampaignValidation(
538 "generation constraints pruned every variant".to_string(),
539 ));
540 }
541 }
542 if let Some(max_variants) = spec.max_variants {
543 if variants.len() > max_variants {
544 return Err(DagMlError::CampaignValidation(format!(
545 "generation produced {} variants, above max_variants={max_variants}",
546 variants.len()
547 )));
548 }
549 }
550
551 variants
552 .drain(..)
553 .map(|choices| variant_from_choices(choices, root_seed))
554 .collect()
555}
556
557pub(crate) fn constraints_satisfied<F>(present: F, constraints: &GenerationConstraints) -> bool
571where
572 F: Fn(&ChoiceRef) -> bool,
573{
574 for group in &constraints.mutex {
575 if group.iter().all(&present) {
582 return false;
583 }
584 }
585 for (left, right) in &constraints.requires {
586 if present(left) && !present(right) {
587 return false;
588 }
589 }
590 for (left, right) in &constraints.exclude {
591 if present(left) && present(right) {
592 return false;
593 }
594 }
595 true
596}
597
598fn satisfies_constraints(
602 choices: &BTreeMap<String, GenerationChoice>,
603 constraints: &GenerationConstraints,
604) -> bool {
605 constraints_satisfied(
606 |reference: &ChoiceRef| {
607 choices
608 .get(&reference.dimension)
609 .is_some_and(|choice| choice.label == reference.label)
610 },
611 constraints,
612 )
613}
614
615pub fn generation_spec_fingerprint(spec: &GenerationSpec) -> Result<String> {
616 spec.validate()?;
617 stable_json_fingerprint(spec)
618}
619
620fn cartesian_choices(
621 dimensions: &[GenerationDimension],
622) -> Vec<BTreeMap<String, GenerationChoice>> {
623 let mut variants = vec![BTreeMap::new()];
624 for dimension in dimensions {
625 let mut next = Vec::with_capacity(variants.len() * dimension.choices.len());
626 for existing in &variants {
627 for choice in &dimension.choices {
628 let mut merged = existing.clone();
629 merged.insert(dimension.name.clone(), choice.clone());
630 next.push(merged);
631 }
632 }
633 variants = next;
634 }
635 variants
636}
637
638fn zip_choices(dimensions: &[GenerationDimension]) -> Vec<BTreeMap<String, GenerationChoice>> {
639 let len = dimensions
640 .first()
641 .map_or(0, |dimension| dimension.choices.len());
642 (0..len)
643 .map(|idx| {
644 dimensions
645 .iter()
646 .map(|dimension| (dimension.name.clone(), dimension.choices[idx].clone()))
647 .collect::<BTreeMap<_, _>>()
648 })
649 .collect()
650}
651
652fn variant_from_choices(
653 choices: BTreeMap<String, GenerationChoice>,
654 root_seed: Option<u64>,
655) -> Result<VariantPlan> {
656 let fingerprint = stable_json_fingerprint(&choices)?;
657 let suffix = if choices.is_empty() {
658 "base".to_string()
659 } else {
660 fingerprint[..16].to_string()
661 };
662 let variant_id = VariantId::new(format!("variant:{suffix}"))?;
663 let seed = root_seed.map(|seed| {
664 SeedContext::root(seed)
665 .child(format!("variant:{variant_id}"))
666 .derive_u64("variant")
667 });
668 let variant = VariantPlan {
669 variant_id,
670 choices,
671 fingerprint,
672 seed,
673 };
674 variant.validate()?;
675 Ok(variant)
676}
677
678#[cfg(test)]
679mod tests {
680 use serde_json::json;
681
682 use super::*;
683
684 fn choice(label: &str, value: serde_json::Value) -> GenerationChoice {
685 GenerationChoice {
686 label: label.to_string(),
687 value,
688 param_overrides: Vec::new(),
689 active_subsequence: None,
690 }
691 }
692
693 fn override_choice(
694 label: &str,
695 node_id: &str,
696 params: BTreeMap<String, serde_json::Value>,
697 ) -> GenerationChoice {
698 GenerationChoice {
699 label: label.to_string(),
700 value: json!(label),
701 param_overrides: vec![GenerationParamOverride {
702 node_id: NodeId::new(node_id).unwrap(),
703 params,
704 }],
705 active_subsequence: None,
706 }
707 }
708
709 #[test]
710 fn default_generation_produces_base_variant() {
711 let variants = enumerate_variants(&GenerationSpec::default(), Some(7)).unwrap();
712
713 assert_eq!(variants.len(), 1);
714 assert_eq!(variants[0].variant_id.as_str(), "variant:base");
715 assert!(variants[0].choices.is_empty());
716 assert!(variants[0].seed.is_some());
717 }
718
719 #[test]
720 fn cartesian_generation_is_deterministic_and_fingerprinted() {
721 let spec = GenerationSpec {
722 strategy: GenerationStrategy::Cartesian,
723 dimensions: vec![
724 GenerationDimension {
725 name: "model".to_string(),
726 choices: vec![choice("pls", json!("pls")), choice("rf", json!("rf"))],
727 },
728 GenerationDimension {
729 name: "window".to_string(),
730 choices: vec![choice("short", json!(7)), choice("long", json!(21))],
731 },
732 ],
733 max_variants: Some(4),
734 constraints: GenerationConstraints::default(),
735 };
736
737 let left = enumerate_variants(&spec, Some(11)).unwrap();
738 let right = enumerate_variants(&spec, Some(11)).unwrap();
739
740 assert_eq!(left.len(), 4);
741 assert_eq!(left, right);
742 let fingerprint = generation_spec_fingerprint(&spec).unwrap();
743 let mut changed_spec = spec.clone();
744 changed_spec.dimensions[0].choices[0].value = json!("changed");
745 assert_eq!(fingerprint, generation_spec_fingerprint(&spec).unwrap());
746 assert_ne!(
747 fingerprint,
748 generation_spec_fingerprint(&changed_spec).unwrap()
749 );
750 assert_ne!(left[0].variant_id, left[1].variant_id);
751 assert_eq!(left[0].choices["model"].label, "pls");
752 assert_eq!(left[0].choices["window"].label, "short");
753 }
754
755 #[test]
763 fn additive_variant_fields_are_invisible_when_absent() {
764 let campaign: crate::plan::CampaignSpec = serde_json::from_str(include_str!(
767 "../../../examples/campaign_oof_generation.json"
768 ))
769 .unwrap();
770 let generation_serialized = serde_json::to_string(&campaign.generation).unwrap();
771 assert!(
772 !generation_serialized.contains("active_subsequence"),
773 "absent active_subsequence must not serialize: {generation_serialized}"
774 );
775 assert_eq!(
777 generation_spec_fingerprint(&campaign.generation).unwrap(),
778 "8d10bce07876d936ab6a62f13063a8d241c967a1578b6d2295a43c26275edf47"
779 );
780
781 let score_set: crate::metrics::ScoreSet =
784 serde_json::from_str(include_str!("../../../examples/fixtures/score_set.json"))
785 .unwrap();
786 let score_set_serialized = serde_json::to_string(&score_set).unwrap();
787 assert!(
788 !score_set_serialized.contains("variant_label"),
789 "absent variant_label must not serialize: {score_set_serialized}"
790 );
791 assert_eq!(
792 stable_json_fingerprint(&score_set).unwrap(),
793 "e99fa78d79ef2a2b99927276cfaf4c265210abf3cf8b3575477355264fda4a9d"
794 );
795 }
796
797 #[test]
798 fn choice_cannot_set_both_param_overrides_and_active_subsequence() {
799 let choice = GenerationChoice {
800 label: "both".to_string(),
801 value: json!("both"),
802 param_overrides: vec![GenerationParamOverride {
803 node_id: NodeId::new("model:base").unwrap(),
804 params: BTreeMap::from([("n_components".to_string(), json!(4))]),
805 }],
806 active_subsequence: Some("alt".to_string()),
807 };
808 let error = choice.validate("dim").unwrap_err().to_string();
809 assert!(
810 error.contains("cannot set both param_overrides and active_subsequence"),
811 "{error}"
812 );
813
814 let param_only = GenerationChoice {
816 active_subsequence: None,
817 ..choice.clone()
818 };
819 param_only.validate("dim").unwrap();
820
821 let operator_only = GenerationChoice {
823 param_overrides: Vec::new(),
824 ..choice.clone()
825 };
826 operator_only.validate("dim").unwrap();
827
828 let value_only = GenerationChoice {
830 param_overrides: Vec::new(),
831 active_subsequence: None,
832 ..choice
833 };
834 value_only.validate("dim").unwrap();
835 }
836
837 #[test]
838 fn choice_rejects_empty_active_subsequence() {
839 for blank in ["", " "] {
843 let choice = GenerationChoice {
844 label: "op".to_string(),
845 value: json!("op"),
846 param_overrides: Vec::new(),
847 active_subsequence: Some(blank.to_string()),
848 };
849 let error = choice.validate("dim").unwrap_err().to_string();
850 assert!(error.contains("has an empty active_subsequence"), "{error}");
851 }
852 }
853
854 #[test]
855 fn zip_generation_requires_same_choice_count() {
856 let spec = GenerationSpec {
857 strategy: GenerationStrategy::Zip,
858 dimensions: vec![
859 GenerationDimension {
860 name: "a".to_string(),
861 choices: vec![choice("a1", json!(1))],
862 },
863 GenerationDimension {
864 name: "b".to_string(),
865 choices: vec![choice("b1", json!(1)), choice("b2", json!(2))],
866 },
867 ],
868 max_variants: None,
869 constraints: GenerationConstraints::default(),
870 };
871
872 assert!(spec.validate().is_err());
873 }
874
875 #[test]
876 fn generation_respects_variant_limit() {
877 let spec = GenerationSpec {
878 strategy: GenerationStrategy::Cartesian,
879 dimensions: vec![GenerationDimension {
880 name: "x".to_string(),
881 choices: vec![choice("a", json!(1)), choice("b", json!(2))],
882 }],
883 max_variants: Some(1),
884 constraints: GenerationConstraints::default(),
885 };
886
887 assert!(enumerate_variants(&spec, None).is_err());
888 }
889
890 #[test]
891 fn variant_applies_node_param_overrides() {
892 let spec = GenerationSpec {
893 strategy: GenerationStrategy::Cartesian,
894 dimensions: vec![GenerationDimension {
895 name: "model_family".to_string(),
896 choices: vec![override_choice(
897 "pls",
898 "model:base",
899 BTreeMap::from([("n_components".to_string(), json!(8))]),
900 )],
901 }],
902 max_variants: Some(1),
903 constraints: GenerationConstraints::default(),
904 };
905 let variants = enumerate_variants(&spec, Some(7)).unwrap();
906 let base = BTreeMap::from([("scale".to_string(), json!(true))]);
907
908 let params = variants[0]
909 .effective_params_for_node(&NodeId::new("model:base").unwrap(), &base)
910 .unwrap();
911
912 assert_eq!(params["scale"], json!(true));
913 assert_eq!(params["n_components"], json!(8));
914 }
915
916 #[test]
917 fn variant_rejects_conflicting_param_overrides() {
918 let spec = GenerationSpec {
919 strategy: GenerationStrategy::Cartesian,
920 dimensions: vec![
921 GenerationDimension {
922 name: "family".to_string(),
923 choices: vec![override_choice(
924 "pls",
925 "model:base",
926 BTreeMap::from([("alpha".to_string(), json!(1))]),
927 )],
928 },
929 GenerationDimension {
930 name: "regularization".to_string(),
931 choices: vec![override_choice(
932 "ridge",
933 "model:base",
934 BTreeMap::from([("alpha".to_string(), json!(2))]),
935 )],
936 },
937 ],
938 max_variants: Some(1),
939 constraints: GenerationConstraints::default(),
940 };
941
942 let error = enumerate_variants(&spec, None).unwrap_err().to_string();
943
944 assert!(error.contains("conflicting generation overrides"));
945 }
946
947 fn cref(dimension: &str, label: &str) -> ChoiceRef {
961 ChoiceRef {
962 dimension: dimension.to_string(),
963 label: label.to_string(),
964 }
965 }
966
967 fn two_by_three_dimensions() -> Vec<GenerationDimension> {
969 vec![
970 GenerationDimension {
971 name: "a".to_string(),
972 choices: vec![choice("a1", json!("a1")), choice("a2", json!("a2"))],
973 },
974 GenerationDimension {
975 name: "b".to_string(),
976 choices: vec![
977 choice("b1", json!("b1")),
978 choice("b2", json!("b2")),
979 choice("b3", json!("b3")),
980 ],
981 },
982 ]
983 }
984
985 fn survivor_signatures(variants: &[VariantPlan]) -> Vec<Vec<(String, String)>> {
988 variants
989 .iter()
990 .map(|variant| {
991 variant
992 .choices
993 .iter()
994 .map(|(dimension, choice)| (dimension.clone(), choice.label.clone()))
995 .collect()
996 })
997 .collect()
998 }
999
1000 #[test]
1001 fn constraint_mutex_prunes_pair() {
1002 let spec = GenerationSpec {
1004 strategy: GenerationStrategy::Cartesian,
1005 dimensions: two_by_three_dimensions(),
1006 max_variants: Some(6),
1007 constraints: GenerationConstraints {
1008 mutex: vec![vec![cref("a", "a1"), cref("b", "b1")]],
1009 ..GenerationConstraints::default()
1010 },
1011 };
1012 let variants = enumerate_variants(&spec, Some(11)).unwrap();
1013 assert_eq!(variants.len(), 5);
1014 let signatures = survivor_signatures(&variants);
1015 assert!(!signatures.contains(&vec![
1016 ("a".to_string(), "a1".to_string()),
1017 ("b".to_string(), "b1".to_string())
1018 ]));
1019 assert_eq!(variants, enumerate_variants(&spec, Some(11)).unwrap());
1021 }
1022
1023 #[test]
1024 fn constraint_requires_prunes() {
1025 let spec = GenerationSpec {
1027 strategy: GenerationStrategy::Cartesian,
1028 dimensions: two_by_three_dimensions(),
1029 max_variants: Some(6),
1030 constraints: GenerationConstraints {
1031 requires: vec![(cref("a", "a1"), cref("b", "b1"))],
1032 ..GenerationConstraints::default()
1033 },
1034 };
1035 let variants = enumerate_variants(&spec, None).unwrap();
1036 assert_eq!(variants.len(), 4);
1037 let signatures = survivor_signatures(&variants);
1038 for signature in &signatures {
1040 if signature.contains(&("a".to_string(), "a1".to_string())) {
1041 assert!(signature.contains(&("b".to_string(), "b1".to_string())));
1042 }
1043 }
1044 }
1045
1046 #[test]
1047 fn constraint_exclude_prunes_pair() {
1048 let spec = GenerationSpec {
1050 strategy: GenerationStrategy::Cartesian,
1051 dimensions: two_by_three_dimensions(),
1052 max_variants: Some(6),
1053 constraints: GenerationConstraints {
1054 exclude: vec![(cref("a", "a1"), cref("b", "b1"))],
1055 ..GenerationConstraints::default()
1056 },
1057 };
1058 let variants = enumerate_variants(&spec, None).unwrap();
1059 assert_eq!(variants.len(), 5);
1060 assert!(!survivor_signatures(&variants).contains(&vec![
1061 ("a".to_string(), "a1".to_string()),
1062 ("b".to_string(), "b1".to_string())
1063 ]));
1064 }
1065
1066 #[test]
1067 fn constraint_cartesian_exclude_prunes_one_of_four() {
1068 let spec = GenerationSpec {
1071 strategy: GenerationStrategy::Cartesian,
1072 dimensions: vec![
1073 GenerationDimension {
1074 name: "a".to_string(),
1075 choices: vec![choice("a1", json!("a1")), choice("a2", json!("a2"))],
1076 },
1077 GenerationDimension {
1078 name: "b".to_string(),
1079 choices: vec![choice("b1", json!("b1")), choice("b2", json!("b2"))],
1080 },
1081 ],
1082 max_variants: Some(4),
1083 constraints: GenerationConstraints {
1084 exclude: vec![(cref("a", "a1"), cref("b", "b1"))],
1085 ..GenerationConstraints::default()
1086 },
1087 };
1088 let variants = enumerate_variants(&spec, None).unwrap();
1089 assert_eq!(variants.len(), 3);
1090 }
1091
1092 #[test]
1093 fn constraint_combined_mutex_and_exclude() {
1094 let spec = GenerationSpec {
1097 strategy: GenerationStrategy::Cartesian,
1098 dimensions: two_by_three_dimensions(),
1099 max_variants: Some(6),
1100 constraints: GenerationConstraints {
1101 mutex: vec![vec![cref("a", "a1"), cref("b", "b1")]],
1102 exclude: vec![(cref("a", "a2"), cref("b", "b2"))],
1103 ..GenerationConstraints::default()
1104 },
1105 };
1106 let variants = enumerate_variants(&spec, None).unwrap();
1107 assert_eq!(variants.len(), 4);
1108 let signatures = survivor_signatures(&variants);
1109 assert!(!signatures.contains(&vec![
1110 ("a".to_string(), "a1".to_string()),
1111 ("b".to_string(), "b1".to_string())
1112 ]));
1113 assert!(!signatures.contains(&vec![
1114 ("a".to_string(), "a2".to_string()),
1115 ("b".to_string(), "b2".to_string())
1116 ]));
1117 }
1118
1119 #[test]
1120 fn constraint_mutex_group_of_three_forbids_only_full_co_occurrence() {
1121 let spec = GenerationSpec {
1129 strategy: GenerationStrategy::Cartesian,
1130 dimensions: vec![
1131 GenerationDimension {
1132 name: "a".to_string(),
1133 choices: vec![choice("a1", json!("a1")), choice("a2", json!("a2"))],
1134 },
1135 GenerationDimension {
1136 name: "b".to_string(),
1137 choices: vec![choice("b1", json!("b1")), choice("b2", json!("b2"))],
1138 },
1139 GenerationDimension {
1140 name: "c".to_string(),
1141 choices: vec![choice("c1", json!("c1")), choice("c2", json!("c2"))],
1142 },
1143 ],
1144 max_variants: Some(8),
1145 constraints: GenerationConstraints {
1146 mutex: vec![vec![cref("a", "a1"), cref("b", "b1"), cref("c", "c1")]],
1147 ..GenerationConstraints::default()
1148 },
1149 };
1150 let variants = enumerate_variants(&spec, None).unwrap();
1151 assert_eq!(variants.len(), 7);
1152 let signatures = survivor_signatures(&variants);
1153 let all_present = vec![
1155 ("a".to_string(), "a1".to_string()),
1156 ("b".to_string(), "b1".to_string()),
1157 ("c".to_string(), "c1".to_string()),
1158 ];
1159 assert!(!signatures.contains(&all_present));
1160 for retained in [
1163 vec![
1164 ("a".to_string(), "a1".to_string()),
1165 ("b".to_string(), "b1".to_string()),
1166 ("c".to_string(), "c2".to_string()),
1167 ],
1168 vec![
1169 ("a".to_string(), "a1".to_string()),
1170 ("b".to_string(), "b2".to_string()),
1171 ("c".to_string(), "c1".to_string()),
1172 ],
1173 vec![
1174 ("a".to_string(), "a2".to_string()),
1175 ("b".to_string(), "b1".to_string()),
1176 ("c".to_string(), "c1".to_string()),
1177 ],
1178 ] {
1179 assert!(
1180 signatures.contains(&retained),
1181 "proper subset {retained:?} was wrongly pruned"
1182 );
1183 }
1184 assert_eq!(variants, enumerate_variants(&spec, None).unwrap());
1186 }
1187
1188 #[test]
1189 fn constraint_prunes_to_one() {
1190 let spec = GenerationSpec {
1194 strategy: GenerationStrategy::Cartesian,
1195 dimensions: two_by_three_dimensions(),
1196 max_variants: Some(6),
1197 constraints: GenerationConstraints {
1198 mutex: vec![
1199 vec![cref("a", "a1"), cref("b", "b1")],
1200 vec![cref("a", "a1"), cref("b", "b2")],
1201 vec![cref("a", "a2"), cref("b", "b1")],
1202 vec![cref("a", "a2"), cref("b", "b2")],
1203 vec![cref("a", "a2"), cref("b", "b3")],
1204 ],
1205 ..GenerationConstraints::default()
1206 },
1207 };
1208 let variants = enumerate_variants(&spec, None).unwrap();
1209 assert_eq!(variants.len(), 1);
1210 assert_eq!(
1211 survivor_signatures(&variants),
1212 vec![vec![
1213 ("a".to_string(), "a1".to_string()),
1214 ("b".to_string(), "b3".to_string())
1215 ]]
1216 );
1217 }
1218
1219 #[test]
1220 fn constraint_all_pruned_is_an_error() {
1221 let spec = GenerationSpec {
1223 strategy: GenerationStrategy::Cartesian,
1224 dimensions: vec![
1225 GenerationDimension {
1226 name: "a".to_string(),
1227 choices: vec![choice("a1", json!("a1"))],
1228 },
1229 GenerationDimension {
1230 name: "b".to_string(),
1231 choices: vec![choice("b1", json!("b1"))],
1232 },
1233 ],
1234 max_variants: Some(1),
1235 constraints: GenerationConstraints {
1236 exclude: vec![(cref("a", "a1"), cref("b", "b1"))],
1237 ..GenerationConstraints::default()
1238 },
1239 };
1240 let error = enumerate_variants(&spec, None).unwrap_err().to_string();
1241 assert!(error.contains("pruned every variant"), "{error}");
1242 }
1243
1244 #[test]
1245 fn constraint_unknown_choice_is_rejected() {
1246 let spec = GenerationSpec {
1247 strategy: GenerationStrategy::Cartesian,
1248 dimensions: two_by_three_dimensions(),
1249 max_variants: Some(6),
1250 constraints: GenerationConstraints {
1251 mutex: vec![vec![cref("a", "a1"), cref("b", "nope")]],
1252 ..GenerationConstraints::default()
1253 },
1254 };
1255 let error = spec.validate().unwrap_err().to_string();
1256 assert!(error.contains("unknown choice `b:nope`"), "{error}");
1257 }
1258
1259 #[test]
1260 fn constraints_require_a_strategy() {
1261 let spec = GenerationSpec {
1262 strategy: GenerationStrategy::None,
1263 dimensions: Vec::new(),
1264 max_variants: Some(1),
1265 constraints: GenerationConstraints {
1266 exclude: vec![(cref("a", "a1"), cref("b", "b1"))],
1267 ..GenerationConstraints::default()
1268 },
1269 };
1270 let error = spec.validate().unwrap_err().to_string();
1271 assert!(
1272 error.contains("constraints require cartesian or zip"),
1273 "{error}"
1274 );
1275 }
1276
1277 #[test]
1278 fn constraints_absent_keep_spec_byte_identical() {
1279 let with_default = GenerationSpec {
1282 strategy: GenerationStrategy::Cartesian,
1283 dimensions: two_by_three_dimensions(),
1284 max_variants: Some(6),
1285 constraints: GenerationConstraints::default(),
1286 };
1287 let serialized = serde_json::to_string(&with_default).unwrap();
1288 assert!(
1289 !serialized.contains("constraints"),
1290 "absent constraints must not serialize: {serialized}"
1291 );
1292 let reparsed: GenerationSpec = serde_json::from_str(
1294 r#"{"strategy":"cartesian","dimensions":[{"name":"a","choices":[{"label":"a1","value":"a1"},{"label":"a2","value":"a2"}]},{"name":"b","choices":[{"label":"b1","value":"b1"},{"label":"b2","value":"b2"},{"label":"b3","value":"b3"}]}],"max_variants":6}"#,
1295 )
1296 .unwrap();
1297 assert_eq!(
1298 generation_spec_fingerprint(&with_default).unwrap(),
1299 generation_spec_fingerprint(&reparsed).unwrap()
1300 );
1301 assert!(reparsed.constraints.is_empty());
1302 }
1303}