Skip to main content

dag_ml_core/
generation.rs

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/// A reference to a single dimension choice — `dimension` is a [`GenerationDimension::name`] and
20/// `label` a [`GenerationChoice::label`] within it. Generation constraints are expressed as sets of
21/// these refs; a variant "contains" the ref when its selected choice for `dimension` carries `label`.
22///
23/// `Ord` is derived so refs can live in deterministically-ordered collections (sorted constraint
24/// groups, stable fingerprints).
25#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
26pub struct ChoiceRef {
27    pub dimension: String,
28    pub label: String,
29}
30
31/// Declarative pruning constraints applied to the enumerated variant set BEFORE materialization.
32///
33/// All three keywords are read off the same `(dimension, label)` coordinate space ([`ChoiceRef`]):
34///
35/// * `mutex` — the FULL group may not all co-occur in one variant (a group of 2+ refs); a variant
36///   is pruned only when every member is present. For a PAIR this is "the two may not co-occur";
37///   for 3+ members only the single all-present variant is forbidden, every proper subset survives.
38/// * `requires` — choosing the first ref of a pair requires the second to be present too.
39/// * `exclude` — the first and second ref of a pair may not both be present.
40///
41/// The keywords mirror the nirs4all generation oracle's `_mutex_` / `_requires_` / `_exclude_`. The
42/// oracle's fourth keyword `_depends_on_` is a DEAD keyword with no filter semantics and is omitted
43/// here on purpose.
44///
45/// ADDITIVE: every field is `skip_serializing_if = "Vec::is_empty"` and the whole value is skipped
46/// when empty on [`GenerationSpec`], so a constraint-free spec serializes byte-identically to before
47/// this field existed (its fingerprint is unchanged).
48#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
49pub struct GenerationConstraints {
50    /// Mutual-exclusion groups: the FULL group may not all co-occur in one variant (matching the
51    /// nirs4all legacy `issubset` rule). A variant is pruned only when every member is present —
52    /// for a pair this is "not both", for 3+ only the all-present variant drops (subsets survive).
53    #[serde(default, skip_serializing_if = "Vec::is_empty")]
54    pub mutex: Vec<Vec<ChoiceRef>>,
55    /// Dependency pairs `(a, b)`: if `a` is present, `b` must also be present.
56    #[serde(default, skip_serializing_if = "Vec::is_empty")]
57    pub requires: Vec<(ChoiceRef, ChoiceRef)>,
58    /// Forbidden pairs `(a, b)`: `a` and `b` may not both be present.
59    #[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    /// Operator-level variant: names the alternative sub-sequence this choice selects, distinct
76    /// from a parameter variant (`param_overrides`). A choice with neither field is a value-only
77    /// dimension; a choice may carry param_overrides XOR active_subsequence, never both. Skipped
78    /// (None) when absent, so existing specs/fixtures stay byte-identical (Phase 2: no behavior).
79    #[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    /// Variant-pruning constraints (`mutex` / `requires` / `exclude`). ADDITIVE: skipped when empty,
181    /// so a constraint-free spec is byte-identical (and fingerprint-stable) to before this field.
182    #[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    /// Validate that every [`ChoiceRef`] in the constraints resolves to an existing
251    /// `(dimension, label)` and that each constraint group is well-formed (mutex needs >= 2 distinct
252    /// refs; a requires/exclude pair needs two distinct refs).
253    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/// An operator-level variant model lowered from a single operator-level generator (Mechanism B's
309/// `PipelineDslStep::Generator`): a [`GenerationDimension`] whose every choice carries an
310/// `active_subsequence` (the choice's namespace key) — never `param_overrides` — paired with the
311/// EXACT set of namespaced node ids that choice activates.
312///
313/// The dimension is the search-space shape; `active_nodes` is the authoritative per-choice active
314/// set. Each entry is keyed by the choice's `active_subsequence` (identical to the matching
315/// choice's `active_subsequence`) and holds the node ids minted by
316/// `namespace_generated_sequence` for that choice — collected at the deterministic minting point,
317/// never by prefix-matching node id strings. `enumerate_variants` over `dimension`'s parent spec
318/// yields one [`VariantPlan`] per operator choice.
319///
320/// This model is produced by a NEW, opt-in compile entry point. It is NOT folded into
321/// `CompiledPipelineDsl.generation` / `search_space_fingerprint`, so the existing Mechanism B
322/// compilation (graph, OOF lanes, fingerprints) stays byte-identical.
323#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
324pub struct OperatorVariantModel {
325    /// The generator id this model lowers (the `PipelineDslStep::Generator` `id`).
326    pub generator_id: NodeId,
327    /// The operator dimension: one `active_subsequence`-only choice per operator sub-sequence.
328    pub dimension: GenerationDimension,
329    /// `active_subsequence` (choice key) -> the exact namespaced node ids that choice activates.
330    #[serde(default)]
331    pub active_nodes: BTreeMap<String, BTreeSet<NodeId>>,
332    /// `active_subsequence` (choice key) -> the choice's `variant_label`: the cross-language content
333    /// fingerprint (hex sha256) of that choice's LOWERED operator sub-sequence (Phase 5). The host
334    /// recomputes the SAME bytes from its own operator-choice config (via the public
335    /// [`operator_variant_label`](crate::operator_variant_label), exposed through the dag-ml-py
336    /// binding) to map a per-variant report back to the config, so the canonical form is a strict
337    /// cross-language CONTRACT. Empty (`default`) for an operator model carrying no labels; otherwise
338    /// it is a strict bijection with the choices' `active_subsequence`, exactly parallel to
339    /// `active_nodes`.
340    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
341    pub variant_labels: BTreeMap<String, String>,
342}
343
344impl OperatorVariantModel {
345    /// Validate the operator dimension and its active-node-id sets.
346    ///
347    /// Beyond `GenerationDimension::validate`, this enforces a STRICT BIJECTION between the choices
348    /// and `active_nodes`: every choice is operator-only (an `active_subsequence`, no
349    /// `param_overrides`); the choices' `active_subsequence` values are unique; every choice has
350    /// exactly one non-empty `active_nodes` entry keyed by its `active_subsequence`; and
351    /// `active_nodes` carries no stray key that does not correspond to a choice.
352    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        // No stray active_nodes key: every key must correspond to a choice active_subsequence.
388        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        // `variant_labels` is populated in Phase 5 (the cross-language content fingerprints). When
397        // present it is a STRICT BIJECTION with the choices (every choice keyed by its
398        // `active_subsequence`, every label a 64-hex sha256, no stray key) — exactly like
399        // `active_nodes`. An empty map is the pre-Phase-5 / label-less shape and is left untouched so
400        // hand-built fixtures without labels still validate.
401        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    /// Build a single-dimension [`GenerationSpec`] (cartesian over the one operator dimension) so
429    /// `enumerate_variants` yields one [`VariantPlan`] per operator choice.
430    ///
431    /// The constraints are intentionally `default()` (empty): under ADR-17 Option A the operator
432    /// dimension's CHOICES are ALREADY the constraint-pruned survivor set — `_mutex_`/`_requires_`/
433    /// `_exclude_` are applied during sequence-build (`expand_or_generator_sequences` /
434    /// `expand_cartesian_generator_sequences`), so each surviving multi-operator sequence becomes one
435    /// constraint-free choice. The model therefore stays a single, constraint-free dimension and the
436    /// existing prune/score/refit path works unchanged.
437    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        // Prune the enumerated cartesian/zip product BEFORE the max_variants check and before
533        // materializing VariantPlans, so the surviving set (and its fingerprints/order) is exactly
534        // the constraint-pruned set. `retain` is stable, so determinism/order is preserved.
535        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
557/// The SHARED constraint-rule core: true when a candidate (identified ONLY through the `present`
558/// membership predicate) violates none of the `constraints`. The rule logic — issubset-mutex,
559/// implication-requires, forbidden-pair-exclude — is defined HERE ONCE; callers supply only "is ref X
560/// present in this candidate" so a single rule core serves every candidate shape:
561///
562/// * [`satisfies_constraints`] (B's cartesian/zip variant pruning) passes a predicate that resolves a
563///   [`ChoiceRef`] against the variant's `dimension -> selected choice` map;
564/// * the operator-generator sequence-build prune (ADR-17 1a/1b) passes a predicate that resolves a
565///   ref against the operator-class SET each merged multi-operator sequence selected.
566///
567/// This mirrors nirs4all's `_generator/constraints.py::apply_all_constraints` (mutex → requires →
568/// exclude), each filter monotone over the input, so a single `retain` pass with this predicate is
569/// order-equivalent to the legacy three sequential passes.
570pub(crate) fn constraints_satisfied<F>(present: F, constraints: &GenerationConstraints) -> bool
571where
572    F: Fn(&ChoiceRef) -> bool,
573{
574    for group in &constraints.mutex {
575        // nirs4all LEGACY mutex semantic (`_generator/constraints.py::_satisfies_mutex`,
576        // `mutex_set.issubset(combo_set)`): a candidate is forbidden ONLY when EVERY member of
577        // the group co-occurs in it ("not all co-occur"), not when merely two-or-more do. For a
578        // PAIR (generator_or_pick_mutex 6->5) "all present" == "count > 1", so this
579        // is byte-identical to the prior `count > 1` rule; the two DIVERGE only for groups of 3+,
580        // where legacy forbids the SINGLE full-co-occurrence candidate and keeps every proper subset.
581        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
598/// True when `choices` (a single variant's `dimension -> selected choice` map) violates none of the
599/// `constraints`. A ref is "present" when the variant's choice for `ref.dimension` carries `ref.label`.
600/// Delegates the rule logic to the shared [`constraints_satisfied`] core.
601fn 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    /// Phase 2 byte-identity gate for the additive `active_subsequence` / `variant_label` fields.
756    ///
757    /// The committed example specs/fixtures carry NEITHER new field, so `skip_serializing_if =
758    /// "Option::is_none"` must keep them invisible: the structs round-trip to byte-identical JSON
759    /// and produce byte-identical fingerprints (`generation_spec_fingerprint` /
760    /// `stable_json_fingerprint`) as before the struct change. The fingerprints are pinned to the
761    /// values produced before the fields existed, proving pure additivity / zero behavior change.
762    #[cfg(dag_ml_workspace_contract_fixtures)]
763    #[test]
764    fn additive_variant_fields_are_invisible_when_absent() {
765        // `examples/campaign_oof_generation.json` — a CampaignSpec whose `generation` block is a
766        // value-only cartesian search (no param_overrides, no active_subsequence on any choice).
767        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        // Fingerprint pinned to the pre-field value: unchanged proves the new field is invisible.
777        assert_eq!(
778            generation_spec_fingerprint(&campaign.generation).unwrap(),
779            "8d10bce07876d936ab6a62f13063a8d241c967a1578b6d2295a43c26275edf47"
780        );
781
782        // `examples/fixtures/score_set.json` — a ScoreSet whose reports carry no variant_label
783        // (one report carries a variant_id, none carry variant_label).
784        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        // Only-param_overrides (existing param variant) stays legal.
816        let param_only = GenerationChoice {
817            active_subsequence: None,
818            ..choice.clone()
819        };
820        param_only.validate("dim").unwrap();
821
822        // Only-active_subsequence (operator variant) stays legal.
823        let operator_only = GenerationChoice {
824            param_overrides: Vec::new(),
825            ..choice.clone()
826        };
827        operator_only.validate("dim").unwrap();
828
829        // Neither (value-only) stays legal.
830        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        // The schema (minLength:1) and both Python validator paths require a non-empty
841        // active_subsequence; the Rust validate must reject empty/whitespace identically so
842        // CampaignSpec/ExecutionPlan validation does not accept a contract shape they reject.
843        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    // -------------------------------------------------------------------------
949    // Generation CONSTRAINTS (item B): native `mutex` / `requires` / `exclude`
950    // pruning. The survivor COUNTS are pinned to the nirs4all generation oracle's
951    // documented locks (`tests/integration/parity/cases_generators_conformance.py`
952    // + `test_generators_conformance_extra._CONSTRAINT_SURVIVORS`):
953    //   mutex 6 -> 5, requires 6 -> 4, exclude 6 -> 5, cartesian_exclude 4 -> 3,
954    //   combined (mutex + exclude) 6 -> 4, prunes-to-one -> 1.
955    // dag-ml reproduces those COUNTS natively over its own dimension model (one
956    // choice per dimension, cross-dimension co-occurrence pruning); the host
957    // translation of operator-combination semantics into these constraints is the
958    // separate follow-on, out of scope here.
959    // -------------------------------------------------------------------------
960
961    fn cref(dimension: &str, label: &str) -> ChoiceRef {
962        ChoiceRef {
963            dimension: dimension.to_string(),
964            label: label.to_string(),
965        }
966    }
967
968    /// A 2x3 cartesian (6 pre-prune variants): dim `a` in {a1, a2}, dim `b` in {b1, b2, b3}.
969    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    /// The survivor set as sorted `(dimension, label)` pairs per variant — the member-level lock
987    /// (not just the count), so a wrong-prune with the right count still fails.
988    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        // mutex [[a:a1, b:b1]]: the single {a1, b1} variant is removed -> 6 - 1 = 5.
1004        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        // The pruned set is deterministic + fingerprinted across calls.
1021        assert_eq!(variants, enumerate_variants(&spec, Some(11)).unwrap());
1022    }
1023
1024    #[test]
1025    fn constraint_requires_prunes() {
1026        // requires (a:a1 -> b:b1): variants with a1 but not b1 ({a1,b2}, {a1,b3}) drop -> 6 - 2 = 4.
1027        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        // a1 survives only paired with b1.
1040        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        // exclude (a:a1, b:b1): the {a1, b1} variant is forbidden -> 6 - 1 = 5.
1050        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        // 2x2 cartesian (4 variants); exclude one pair -> 3, mirroring the oracle's
1070        // `generator_cartesian_exclude` 4 -> 3 lock.
1071        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        // Two constraint kinds on one spec: mutex removes {a1,b1}, exclude removes {a2,b2}
1096        // -> 6 - 1 - 1 = 4 (the oracle's combined `_mutex_` + `_exclude_` 6 -> 4 lock shape).
1097        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        // A SIZE-3 mutex group [a:a1, b:b1, c:c1] over a 2x2x2 cartesian (8 variants). This is where
1123        // the "not all co-occur" (legacy `issubset`) rule DIVERGES from the old "<=1 present" rule:
1124        //   - legacy "not all co-occur" forbids ONLY the single {a1,b1,c1} variant       -> 8 - 1 = 7
1125        //   - the old "count > 1" rule would forbid EVERY variant with 2+ of {a1,b1,c1}  -> only 4
1126        // We lock 7 survivors with {a1,b1,c1} as the SOLE casualty, matching the nirs4all generation
1127        // oracle (`generator_or_pick_mutex3`): a >2 mutex prunes exactly the all-present combination
1128        // and keeps every proper subset. (For a PAIR the two rules coincide — see the cases above.)
1129        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        // The all-present {a1,b1,c1} variant is the ONLY casualty.
1155        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        // Every PROPER subset of the mutex group survives (would be wrongly pruned by "count > 1"):
1162        // exactly-two-present variants {a1,b1,c2}, {a1,b2,c1}, {a2,b1,c1} are all retained.
1163        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        // Deterministic + fingerprinted across calls.
1186        assert_eq!(variants, enumerate_variants(&spec, None).unwrap());
1187    }
1188
1189    #[test]
1190    fn constraint_prunes_to_one() {
1191        // Two mutex pairs prune the 2x3 product down to a single survivor, mirroring the oracle's
1192        // `generator_constraint_prunes_to_one` lock. a1 is mutex with both b1 and b2; a2 is mutex
1193        // with all of b1/b2/b3. Survivors: {a1,b3} only.
1194        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        // exclude removes a1-with-b*, mutex removes a2-with-b* -> no survivors -> error.
1223        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        // A no-constraint spec must serialize without a `constraints` key and fingerprint exactly
1281        // as it would before this field existed (additivity proof, parallel to the Phase-2 gate).
1282        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        // Round-trips through a constraint-less JSON shape identically.
1294        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}