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 #[cfg(dag_ml_workspace_contract_fixtures)]
763 #[test]
764 fn additive_variant_fields_are_invisible_when_absent() {
765 let campaign: crate::plan::CampaignSpec = serde_json::from_str(include_str!(
768 "../../../examples/campaign_oof_generation.json"
769 ))
770 .unwrap();
771 let generation_serialized = serde_json::to_string(&campaign.generation).unwrap();
772 assert!(
773 !generation_serialized.contains("active_subsequence"),
774 "absent active_subsequence must not serialize: {generation_serialized}"
775 );
776 assert_eq!(
778 generation_spec_fingerprint(&campaign.generation).unwrap(),
779 "8d10bce07876d936ab6a62f13063a8d241c967a1578b6d2295a43c26275edf47"
780 );
781
782 let score_set: crate::metrics::ScoreSet =
785 serde_json::from_str(include_str!("../../../examples/fixtures/score_set.json"))
786 .unwrap();
787 let score_set_serialized = serde_json::to_string(&score_set).unwrap();
788 assert!(
789 !score_set_serialized.contains("variant_label"),
790 "absent variant_label must not serialize: {score_set_serialized}"
791 );
792 assert_eq!(
793 stable_json_fingerprint(&score_set).unwrap(),
794 "e99fa78d79ef2a2b99927276cfaf4c265210abf3cf8b3575477355264fda4a9d"
795 );
796 }
797
798 #[test]
799 fn choice_cannot_set_both_param_overrides_and_active_subsequence() {
800 let choice = GenerationChoice {
801 label: "both".to_string(),
802 value: json!("both"),
803 param_overrides: vec![GenerationParamOverride {
804 node_id: NodeId::new("model:base").unwrap(),
805 params: BTreeMap::from([("n_components".to_string(), json!(4))]),
806 }],
807 active_subsequence: Some("alt".to_string()),
808 };
809 let error = choice.validate("dim").unwrap_err().to_string();
810 assert!(
811 error.contains("cannot set both param_overrides and active_subsequence"),
812 "{error}"
813 );
814
815 let param_only = GenerationChoice {
817 active_subsequence: None,
818 ..choice.clone()
819 };
820 param_only.validate("dim").unwrap();
821
822 let operator_only = GenerationChoice {
824 param_overrides: Vec::new(),
825 ..choice.clone()
826 };
827 operator_only.validate("dim").unwrap();
828
829 let value_only = GenerationChoice {
831 param_overrides: Vec::new(),
832 active_subsequence: None,
833 ..choice
834 };
835 value_only.validate("dim").unwrap();
836 }
837
838 #[test]
839 fn choice_rejects_empty_active_subsequence() {
840 for blank in ["", " "] {
844 let choice = GenerationChoice {
845 label: "op".to_string(),
846 value: json!("op"),
847 param_overrides: Vec::new(),
848 active_subsequence: Some(blank.to_string()),
849 };
850 let error = choice.validate("dim").unwrap_err().to_string();
851 assert!(error.contains("has an empty active_subsequence"), "{error}");
852 }
853 }
854
855 #[test]
856 fn zip_generation_requires_same_choice_count() {
857 let spec = GenerationSpec {
858 strategy: GenerationStrategy::Zip,
859 dimensions: vec![
860 GenerationDimension {
861 name: "a".to_string(),
862 choices: vec![choice("a1", json!(1))],
863 },
864 GenerationDimension {
865 name: "b".to_string(),
866 choices: vec![choice("b1", json!(1)), choice("b2", json!(2))],
867 },
868 ],
869 max_variants: None,
870 constraints: GenerationConstraints::default(),
871 };
872
873 assert!(spec.validate().is_err());
874 }
875
876 #[test]
877 fn generation_respects_variant_limit() {
878 let spec = GenerationSpec {
879 strategy: GenerationStrategy::Cartesian,
880 dimensions: vec![GenerationDimension {
881 name: "x".to_string(),
882 choices: vec![choice("a", json!(1)), choice("b", json!(2))],
883 }],
884 max_variants: Some(1),
885 constraints: GenerationConstraints::default(),
886 };
887
888 assert!(enumerate_variants(&spec, None).is_err());
889 }
890
891 #[test]
892 fn variant_applies_node_param_overrides() {
893 let spec = GenerationSpec {
894 strategy: GenerationStrategy::Cartesian,
895 dimensions: vec![GenerationDimension {
896 name: "model_family".to_string(),
897 choices: vec![override_choice(
898 "pls",
899 "model:base",
900 BTreeMap::from([("n_components".to_string(), json!(8))]),
901 )],
902 }],
903 max_variants: Some(1),
904 constraints: GenerationConstraints::default(),
905 };
906 let variants = enumerate_variants(&spec, Some(7)).unwrap();
907 let base = BTreeMap::from([("scale".to_string(), json!(true))]);
908
909 let params = variants[0]
910 .effective_params_for_node(&NodeId::new("model:base").unwrap(), &base)
911 .unwrap();
912
913 assert_eq!(params["scale"], json!(true));
914 assert_eq!(params["n_components"], json!(8));
915 }
916
917 #[test]
918 fn variant_rejects_conflicting_param_overrides() {
919 let spec = GenerationSpec {
920 strategy: GenerationStrategy::Cartesian,
921 dimensions: vec![
922 GenerationDimension {
923 name: "family".to_string(),
924 choices: vec![override_choice(
925 "pls",
926 "model:base",
927 BTreeMap::from([("alpha".to_string(), json!(1))]),
928 )],
929 },
930 GenerationDimension {
931 name: "regularization".to_string(),
932 choices: vec![override_choice(
933 "ridge",
934 "model:base",
935 BTreeMap::from([("alpha".to_string(), json!(2))]),
936 )],
937 },
938 ],
939 max_variants: Some(1),
940 constraints: GenerationConstraints::default(),
941 };
942
943 let error = enumerate_variants(&spec, None).unwrap_err().to_string();
944
945 assert!(error.contains("conflicting generation overrides"));
946 }
947
948 fn cref(dimension: &str, label: &str) -> ChoiceRef {
962 ChoiceRef {
963 dimension: dimension.to_string(),
964 label: label.to_string(),
965 }
966 }
967
968 fn two_by_three_dimensions() -> Vec<GenerationDimension> {
970 vec![
971 GenerationDimension {
972 name: "a".to_string(),
973 choices: vec![choice("a1", json!("a1")), choice("a2", json!("a2"))],
974 },
975 GenerationDimension {
976 name: "b".to_string(),
977 choices: vec![
978 choice("b1", json!("b1")),
979 choice("b2", json!("b2")),
980 choice("b3", json!("b3")),
981 ],
982 },
983 ]
984 }
985
986 fn survivor_signatures(variants: &[VariantPlan]) -> Vec<Vec<(String, String)>> {
989 variants
990 .iter()
991 .map(|variant| {
992 variant
993 .choices
994 .iter()
995 .map(|(dimension, choice)| (dimension.clone(), choice.label.clone()))
996 .collect()
997 })
998 .collect()
999 }
1000
1001 #[test]
1002 fn constraint_mutex_prunes_pair() {
1003 let spec = GenerationSpec {
1005 strategy: GenerationStrategy::Cartesian,
1006 dimensions: two_by_three_dimensions(),
1007 max_variants: Some(6),
1008 constraints: GenerationConstraints {
1009 mutex: vec![vec![cref("a", "a1"), cref("b", "b1")]],
1010 ..GenerationConstraints::default()
1011 },
1012 };
1013 let variants = enumerate_variants(&spec, Some(11)).unwrap();
1014 assert_eq!(variants.len(), 5);
1015 let signatures = survivor_signatures(&variants);
1016 assert!(!signatures.contains(&vec![
1017 ("a".to_string(), "a1".to_string()),
1018 ("b".to_string(), "b1".to_string())
1019 ]));
1020 assert_eq!(variants, enumerate_variants(&spec, Some(11)).unwrap());
1022 }
1023
1024 #[test]
1025 fn constraint_requires_prunes() {
1026 let spec = GenerationSpec {
1028 strategy: GenerationStrategy::Cartesian,
1029 dimensions: two_by_three_dimensions(),
1030 max_variants: Some(6),
1031 constraints: GenerationConstraints {
1032 requires: vec![(cref("a", "a1"), cref("b", "b1"))],
1033 ..GenerationConstraints::default()
1034 },
1035 };
1036 let variants = enumerate_variants(&spec, None).unwrap();
1037 assert_eq!(variants.len(), 4);
1038 let signatures = survivor_signatures(&variants);
1039 for signature in &signatures {
1041 if signature.contains(&("a".to_string(), "a1".to_string())) {
1042 assert!(signature.contains(&("b".to_string(), "b1".to_string())));
1043 }
1044 }
1045 }
1046
1047 #[test]
1048 fn constraint_exclude_prunes_pair() {
1049 let spec = GenerationSpec {
1051 strategy: GenerationStrategy::Cartesian,
1052 dimensions: two_by_three_dimensions(),
1053 max_variants: Some(6),
1054 constraints: GenerationConstraints {
1055 exclude: vec![(cref("a", "a1"), cref("b", "b1"))],
1056 ..GenerationConstraints::default()
1057 },
1058 };
1059 let variants = enumerate_variants(&spec, None).unwrap();
1060 assert_eq!(variants.len(), 5);
1061 assert!(!survivor_signatures(&variants).contains(&vec![
1062 ("a".to_string(), "a1".to_string()),
1063 ("b".to_string(), "b1".to_string())
1064 ]));
1065 }
1066
1067 #[test]
1068 fn constraint_cartesian_exclude_prunes_one_of_four() {
1069 let spec = GenerationSpec {
1072 strategy: GenerationStrategy::Cartesian,
1073 dimensions: vec![
1074 GenerationDimension {
1075 name: "a".to_string(),
1076 choices: vec![choice("a1", json!("a1")), choice("a2", json!("a2"))],
1077 },
1078 GenerationDimension {
1079 name: "b".to_string(),
1080 choices: vec![choice("b1", json!("b1")), choice("b2", json!("b2"))],
1081 },
1082 ],
1083 max_variants: Some(4),
1084 constraints: GenerationConstraints {
1085 exclude: vec![(cref("a", "a1"), cref("b", "b1"))],
1086 ..GenerationConstraints::default()
1087 },
1088 };
1089 let variants = enumerate_variants(&spec, None).unwrap();
1090 assert_eq!(variants.len(), 3);
1091 }
1092
1093 #[test]
1094 fn constraint_combined_mutex_and_exclude() {
1095 let spec = GenerationSpec {
1098 strategy: GenerationStrategy::Cartesian,
1099 dimensions: two_by_three_dimensions(),
1100 max_variants: Some(6),
1101 constraints: GenerationConstraints {
1102 mutex: vec![vec![cref("a", "a1"), cref("b", "b1")]],
1103 exclude: vec![(cref("a", "a2"), cref("b", "b2"))],
1104 ..GenerationConstraints::default()
1105 },
1106 };
1107 let variants = enumerate_variants(&spec, None).unwrap();
1108 assert_eq!(variants.len(), 4);
1109 let signatures = survivor_signatures(&variants);
1110 assert!(!signatures.contains(&vec![
1111 ("a".to_string(), "a1".to_string()),
1112 ("b".to_string(), "b1".to_string())
1113 ]));
1114 assert!(!signatures.contains(&vec![
1115 ("a".to_string(), "a2".to_string()),
1116 ("b".to_string(), "b2".to_string())
1117 ]));
1118 }
1119
1120 #[test]
1121 fn constraint_mutex_group_of_three_forbids_only_full_co_occurrence() {
1122 let spec = GenerationSpec {
1130 strategy: GenerationStrategy::Cartesian,
1131 dimensions: vec![
1132 GenerationDimension {
1133 name: "a".to_string(),
1134 choices: vec![choice("a1", json!("a1")), choice("a2", json!("a2"))],
1135 },
1136 GenerationDimension {
1137 name: "b".to_string(),
1138 choices: vec![choice("b1", json!("b1")), choice("b2", json!("b2"))],
1139 },
1140 GenerationDimension {
1141 name: "c".to_string(),
1142 choices: vec![choice("c1", json!("c1")), choice("c2", json!("c2"))],
1143 },
1144 ],
1145 max_variants: Some(8),
1146 constraints: GenerationConstraints {
1147 mutex: vec![vec![cref("a", "a1"), cref("b", "b1"), cref("c", "c1")]],
1148 ..GenerationConstraints::default()
1149 },
1150 };
1151 let variants = enumerate_variants(&spec, None).unwrap();
1152 assert_eq!(variants.len(), 7);
1153 let signatures = survivor_signatures(&variants);
1154 let all_present = vec![
1156 ("a".to_string(), "a1".to_string()),
1157 ("b".to_string(), "b1".to_string()),
1158 ("c".to_string(), "c1".to_string()),
1159 ];
1160 assert!(!signatures.contains(&all_present));
1161 for retained in [
1164 vec![
1165 ("a".to_string(), "a1".to_string()),
1166 ("b".to_string(), "b1".to_string()),
1167 ("c".to_string(), "c2".to_string()),
1168 ],
1169 vec![
1170 ("a".to_string(), "a1".to_string()),
1171 ("b".to_string(), "b2".to_string()),
1172 ("c".to_string(), "c1".to_string()),
1173 ],
1174 vec![
1175 ("a".to_string(), "a2".to_string()),
1176 ("b".to_string(), "b1".to_string()),
1177 ("c".to_string(), "c1".to_string()),
1178 ],
1179 ] {
1180 assert!(
1181 signatures.contains(&retained),
1182 "proper subset {retained:?} was wrongly pruned"
1183 );
1184 }
1185 assert_eq!(variants, enumerate_variants(&spec, None).unwrap());
1187 }
1188
1189 #[test]
1190 fn constraint_prunes_to_one() {
1191 let spec = GenerationSpec {
1195 strategy: GenerationStrategy::Cartesian,
1196 dimensions: two_by_three_dimensions(),
1197 max_variants: Some(6),
1198 constraints: GenerationConstraints {
1199 mutex: vec![
1200 vec![cref("a", "a1"), cref("b", "b1")],
1201 vec![cref("a", "a1"), cref("b", "b2")],
1202 vec![cref("a", "a2"), cref("b", "b1")],
1203 vec![cref("a", "a2"), cref("b", "b2")],
1204 vec![cref("a", "a2"), cref("b", "b3")],
1205 ],
1206 ..GenerationConstraints::default()
1207 },
1208 };
1209 let variants = enumerate_variants(&spec, None).unwrap();
1210 assert_eq!(variants.len(), 1);
1211 assert_eq!(
1212 survivor_signatures(&variants),
1213 vec![vec![
1214 ("a".to_string(), "a1".to_string()),
1215 ("b".to_string(), "b3".to_string())
1216 ]]
1217 );
1218 }
1219
1220 #[test]
1221 fn constraint_all_pruned_is_an_error() {
1222 let spec = GenerationSpec {
1224 strategy: GenerationStrategy::Cartesian,
1225 dimensions: vec![
1226 GenerationDimension {
1227 name: "a".to_string(),
1228 choices: vec![choice("a1", json!("a1"))],
1229 },
1230 GenerationDimension {
1231 name: "b".to_string(),
1232 choices: vec![choice("b1", json!("b1"))],
1233 },
1234 ],
1235 max_variants: Some(1),
1236 constraints: GenerationConstraints {
1237 exclude: vec![(cref("a", "a1"), cref("b", "b1"))],
1238 ..GenerationConstraints::default()
1239 },
1240 };
1241 let error = enumerate_variants(&spec, None).unwrap_err().to_string();
1242 assert!(error.contains("pruned every variant"), "{error}");
1243 }
1244
1245 #[test]
1246 fn constraint_unknown_choice_is_rejected() {
1247 let spec = GenerationSpec {
1248 strategy: GenerationStrategy::Cartesian,
1249 dimensions: two_by_three_dimensions(),
1250 max_variants: Some(6),
1251 constraints: GenerationConstraints {
1252 mutex: vec![vec![cref("a", "a1"), cref("b", "nope")]],
1253 ..GenerationConstraints::default()
1254 },
1255 };
1256 let error = spec.validate().unwrap_err().to_string();
1257 assert!(error.contains("unknown choice `b:nope`"), "{error}");
1258 }
1259
1260 #[test]
1261 fn constraints_require_a_strategy() {
1262 let spec = GenerationSpec {
1263 strategy: GenerationStrategy::None,
1264 dimensions: Vec::new(),
1265 max_variants: Some(1),
1266 constraints: GenerationConstraints {
1267 exclude: vec![(cref("a", "a1"), cref("b", "b1"))],
1268 ..GenerationConstraints::default()
1269 },
1270 };
1271 let error = spec.validate().unwrap_err().to_string();
1272 assert!(
1273 error.contains("constraints require cartesian or zip"),
1274 "{error}"
1275 );
1276 }
1277
1278 #[test]
1279 fn constraints_absent_keep_spec_byte_identical() {
1280 let with_default = GenerationSpec {
1283 strategy: GenerationStrategy::Cartesian,
1284 dimensions: two_by_three_dimensions(),
1285 max_variants: Some(6),
1286 constraints: GenerationConstraints::default(),
1287 };
1288 let serialized = serde_json::to_string(&with_default).unwrap();
1289 assert!(
1290 !serialized.contains("constraints"),
1291 "absent constraints must not serialize: {serialized}"
1292 );
1293 let reparsed: GenerationSpec = serde_json::from_str(
1295 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}"#,
1296 )
1297 .unwrap();
1298 assert_eq!(
1299 generation_spec_fingerprint(&with_default).unwrap(),
1300 generation_spec_fingerprint(&reparsed).unwrap()
1301 );
1302 assert!(reparsed.constraints.is_empty());
1303 }
1304}