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    #[test]
763    fn additive_variant_fields_are_invisible_when_absent() {
764        // `examples/campaign_oof_generation.json` — a CampaignSpec whose `generation` block is a
765        // value-only cartesian search (no param_overrides, no active_subsequence on any choice).
766        let campaign: crate::plan::CampaignSpec = serde_json::from_str(include_str!(
767            "../../../examples/campaign_oof_generation.json"
768        ))
769        .unwrap();
770        let generation_serialized = serde_json::to_string(&campaign.generation).unwrap();
771        assert!(
772            !generation_serialized.contains("active_subsequence"),
773            "absent active_subsequence must not serialize: {generation_serialized}"
774        );
775        // Fingerprint pinned to the pre-field value: unchanged proves the new field is invisible.
776        assert_eq!(
777            generation_spec_fingerprint(&campaign.generation).unwrap(),
778            "8d10bce07876d936ab6a62f13063a8d241c967a1578b6d2295a43c26275edf47"
779        );
780
781        // `examples/fixtures/score_set.json` — a ScoreSet whose reports carry no variant_label
782        // (one report carries a variant_id, none carry variant_label).
783        let score_set: crate::metrics::ScoreSet =
784            serde_json::from_str(include_str!("../../../examples/fixtures/score_set.json"))
785                .unwrap();
786        let score_set_serialized = serde_json::to_string(&score_set).unwrap();
787        assert!(
788            !score_set_serialized.contains("variant_label"),
789            "absent variant_label must not serialize: {score_set_serialized}"
790        );
791        assert_eq!(
792            stable_json_fingerprint(&score_set).unwrap(),
793            "e99fa78d79ef2a2b99927276cfaf4c265210abf3cf8b3575477355264fda4a9d"
794        );
795    }
796
797    #[test]
798    fn choice_cannot_set_both_param_overrides_and_active_subsequence() {
799        let choice = GenerationChoice {
800            label: "both".to_string(),
801            value: json!("both"),
802            param_overrides: vec![GenerationParamOverride {
803                node_id: NodeId::new("model:base").unwrap(),
804                params: BTreeMap::from([("n_components".to_string(), json!(4))]),
805            }],
806            active_subsequence: Some("alt".to_string()),
807        };
808        let error = choice.validate("dim").unwrap_err().to_string();
809        assert!(
810            error.contains("cannot set both param_overrides and active_subsequence"),
811            "{error}"
812        );
813
814        // Only-param_overrides (existing param variant) stays legal.
815        let param_only = GenerationChoice {
816            active_subsequence: None,
817            ..choice.clone()
818        };
819        param_only.validate("dim").unwrap();
820
821        // Only-active_subsequence (operator variant) stays legal.
822        let operator_only = GenerationChoice {
823            param_overrides: Vec::new(),
824            ..choice.clone()
825        };
826        operator_only.validate("dim").unwrap();
827
828        // Neither (value-only) stays legal.
829        let value_only = GenerationChoice {
830            param_overrides: Vec::new(),
831            active_subsequence: None,
832            ..choice
833        };
834        value_only.validate("dim").unwrap();
835    }
836
837    #[test]
838    fn choice_rejects_empty_active_subsequence() {
839        // The schema (minLength:1) and both Python validator paths require a non-empty
840        // active_subsequence; the Rust validate must reject empty/whitespace identically so
841        // CampaignSpec/ExecutionPlan validation does not accept a contract shape they reject.
842        for blank in ["", "   "] {
843            let choice = GenerationChoice {
844                label: "op".to_string(),
845                value: json!("op"),
846                param_overrides: Vec::new(),
847                active_subsequence: Some(blank.to_string()),
848            };
849            let error = choice.validate("dim").unwrap_err().to_string();
850            assert!(error.contains("has an empty active_subsequence"), "{error}");
851        }
852    }
853
854    #[test]
855    fn zip_generation_requires_same_choice_count() {
856        let spec = GenerationSpec {
857            strategy: GenerationStrategy::Zip,
858            dimensions: vec![
859                GenerationDimension {
860                    name: "a".to_string(),
861                    choices: vec![choice("a1", json!(1))],
862                },
863                GenerationDimension {
864                    name: "b".to_string(),
865                    choices: vec![choice("b1", json!(1)), choice("b2", json!(2))],
866                },
867            ],
868            max_variants: None,
869            constraints: GenerationConstraints::default(),
870        };
871
872        assert!(spec.validate().is_err());
873    }
874
875    #[test]
876    fn generation_respects_variant_limit() {
877        let spec = GenerationSpec {
878            strategy: GenerationStrategy::Cartesian,
879            dimensions: vec![GenerationDimension {
880                name: "x".to_string(),
881                choices: vec![choice("a", json!(1)), choice("b", json!(2))],
882            }],
883            max_variants: Some(1),
884            constraints: GenerationConstraints::default(),
885        };
886
887        assert!(enumerate_variants(&spec, None).is_err());
888    }
889
890    #[test]
891    fn variant_applies_node_param_overrides() {
892        let spec = GenerationSpec {
893            strategy: GenerationStrategy::Cartesian,
894            dimensions: vec![GenerationDimension {
895                name: "model_family".to_string(),
896                choices: vec![override_choice(
897                    "pls",
898                    "model:base",
899                    BTreeMap::from([("n_components".to_string(), json!(8))]),
900                )],
901            }],
902            max_variants: Some(1),
903            constraints: GenerationConstraints::default(),
904        };
905        let variants = enumerate_variants(&spec, Some(7)).unwrap();
906        let base = BTreeMap::from([("scale".to_string(), json!(true))]);
907
908        let params = variants[0]
909            .effective_params_for_node(&NodeId::new("model:base").unwrap(), &base)
910            .unwrap();
911
912        assert_eq!(params["scale"], json!(true));
913        assert_eq!(params["n_components"], json!(8));
914    }
915
916    #[test]
917    fn variant_rejects_conflicting_param_overrides() {
918        let spec = GenerationSpec {
919            strategy: GenerationStrategy::Cartesian,
920            dimensions: vec![
921                GenerationDimension {
922                    name: "family".to_string(),
923                    choices: vec![override_choice(
924                        "pls",
925                        "model:base",
926                        BTreeMap::from([("alpha".to_string(), json!(1))]),
927                    )],
928                },
929                GenerationDimension {
930                    name: "regularization".to_string(),
931                    choices: vec![override_choice(
932                        "ridge",
933                        "model:base",
934                        BTreeMap::from([("alpha".to_string(), json!(2))]),
935                    )],
936                },
937            ],
938            max_variants: Some(1),
939            constraints: GenerationConstraints::default(),
940        };
941
942        let error = enumerate_variants(&spec, None).unwrap_err().to_string();
943
944        assert!(error.contains("conflicting generation overrides"));
945    }
946
947    // -------------------------------------------------------------------------
948    // Generation CONSTRAINTS (item B): native `mutex` / `requires` / `exclude`
949    // pruning. The survivor COUNTS are pinned to the nirs4all generation oracle's
950    // documented locks (`tests/integration/parity/cases_generators_conformance.py`
951    // + `test_generators_conformance_extra._CONSTRAINT_SURVIVORS`):
952    //   mutex 6 -> 5, requires 6 -> 4, exclude 6 -> 5, cartesian_exclude 4 -> 3,
953    //   combined (mutex + exclude) 6 -> 4, prunes-to-one -> 1.
954    // dag-ml reproduces those COUNTS natively over its own dimension model (one
955    // choice per dimension, cross-dimension co-occurrence pruning); the host
956    // translation of operator-combination semantics into these constraints is the
957    // separate follow-on, out of scope here.
958    // -------------------------------------------------------------------------
959
960    fn cref(dimension: &str, label: &str) -> ChoiceRef {
961        ChoiceRef {
962            dimension: dimension.to_string(),
963            label: label.to_string(),
964        }
965    }
966
967    /// A 2x3 cartesian (6 pre-prune variants): dim `a` in {a1, a2}, dim `b` in {b1, b2, b3}.
968    fn two_by_three_dimensions() -> Vec<GenerationDimension> {
969        vec![
970            GenerationDimension {
971                name: "a".to_string(),
972                choices: vec![choice("a1", json!("a1")), choice("a2", json!("a2"))],
973            },
974            GenerationDimension {
975                name: "b".to_string(),
976                choices: vec![
977                    choice("b1", json!("b1")),
978                    choice("b2", json!("b2")),
979                    choice("b3", json!("b3")),
980                ],
981            },
982        ]
983    }
984
985    /// The survivor set as sorted `(dimension, label)` pairs per variant — the member-level lock
986    /// (not just the count), so a wrong-prune with the right count still fails.
987    fn survivor_signatures(variants: &[VariantPlan]) -> Vec<Vec<(String, String)>> {
988        variants
989            .iter()
990            .map(|variant| {
991                variant
992                    .choices
993                    .iter()
994                    .map(|(dimension, choice)| (dimension.clone(), choice.label.clone()))
995                    .collect()
996            })
997            .collect()
998    }
999
1000    #[test]
1001    fn constraint_mutex_prunes_pair() {
1002        // mutex [[a:a1, b:b1]]: the single {a1, b1} variant is removed -> 6 - 1 = 5.
1003        let spec = GenerationSpec {
1004            strategy: GenerationStrategy::Cartesian,
1005            dimensions: two_by_three_dimensions(),
1006            max_variants: Some(6),
1007            constraints: GenerationConstraints {
1008                mutex: vec![vec![cref("a", "a1"), cref("b", "b1")]],
1009                ..GenerationConstraints::default()
1010            },
1011        };
1012        let variants = enumerate_variants(&spec, Some(11)).unwrap();
1013        assert_eq!(variants.len(), 5);
1014        let signatures = survivor_signatures(&variants);
1015        assert!(!signatures.contains(&vec![
1016            ("a".to_string(), "a1".to_string()),
1017            ("b".to_string(), "b1".to_string())
1018        ]));
1019        // The pruned set is deterministic + fingerprinted across calls.
1020        assert_eq!(variants, enumerate_variants(&spec, Some(11)).unwrap());
1021    }
1022
1023    #[test]
1024    fn constraint_requires_prunes() {
1025        // requires (a:a1 -> b:b1): variants with a1 but not b1 ({a1,b2}, {a1,b3}) drop -> 6 - 2 = 4.
1026        let spec = GenerationSpec {
1027            strategy: GenerationStrategy::Cartesian,
1028            dimensions: two_by_three_dimensions(),
1029            max_variants: Some(6),
1030            constraints: GenerationConstraints {
1031                requires: vec![(cref("a", "a1"), cref("b", "b1"))],
1032                ..GenerationConstraints::default()
1033            },
1034        };
1035        let variants = enumerate_variants(&spec, None).unwrap();
1036        assert_eq!(variants.len(), 4);
1037        let signatures = survivor_signatures(&variants);
1038        // a1 survives only paired with b1.
1039        for signature in &signatures {
1040            if signature.contains(&("a".to_string(), "a1".to_string())) {
1041                assert!(signature.contains(&("b".to_string(), "b1".to_string())));
1042            }
1043        }
1044    }
1045
1046    #[test]
1047    fn constraint_exclude_prunes_pair() {
1048        // exclude (a:a1, b:b1): the {a1, b1} variant is forbidden -> 6 - 1 = 5.
1049        let spec = GenerationSpec {
1050            strategy: GenerationStrategy::Cartesian,
1051            dimensions: two_by_three_dimensions(),
1052            max_variants: Some(6),
1053            constraints: GenerationConstraints {
1054                exclude: vec![(cref("a", "a1"), cref("b", "b1"))],
1055                ..GenerationConstraints::default()
1056            },
1057        };
1058        let variants = enumerate_variants(&spec, None).unwrap();
1059        assert_eq!(variants.len(), 5);
1060        assert!(!survivor_signatures(&variants).contains(&vec![
1061            ("a".to_string(), "a1".to_string()),
1062            ("b".to_string(), "b1".to_string())
1063        ]));
1064    }
1065
1066    #[test]
1067    fn constraint_cartesian_exclude_prunes_one_of_four() {
1068        // 2x2 cartesian (4 variants); exclude one pair -> 3, mirroring the oracle's
1069        // `generator_cartesian_exclude` 4 -> 3 lock.
1070        let spec = GenerationSpec {
1071            strategy: GenerationStrategy::Cartesian,
1072            dimensions: vec![
1073                GenerationDimension {
1074                    name: "a".to_string(),
1075                    choices: vec![choice("a1", json!("a1")), choice("a2", json!("a2"))],
1076                },
1077                GenerationDimension {
1078                    name: "b".to_string(),
1079                    choices: vec![choice("b1", json!("b1")), choice("b2", json!("b2"))],
1080                },
1081            ],
1082            max_variants: Some(4),
1083            constraints: GenerationConstraints {
1084                exclude: vec![(cref("a", "a1"), cref("b", "b1"))],
1085                ..GenerationConstraints::default()
1086            },
1087        };
1088        let variants = enumerate_variants(&spec, None).unwrap();
1089        assert_eq!(variants.len(), 3);
1090    }
1091
1092    #[test]
1093    fn constraint_combined_mutex_and_exclude() {
1094        // Two constraint kinds on one spec: mutex removes {a1,b1}, exclude removes {a2,b2}
1095        // -> 6 - 1 - 1 = 4 (the oracle's combined `_mutex_` + `_exclude_` 6 -> 4 lock shape).
1096        let spec = GenerationSpec {
1097            strategy: GenerationStrategy::Cartesian,
1098            dimensions: two_by_three_dimensions(),
1099            max_variants: Some(6),
1100            constraints: GenerationConstraints {
1101                mutex: vec![vec![cref("a", "a1"), cref("b", "b1")]],
1102                exclude: vec![(cref("a", "a2"), cref("b", "b2"))],
1103                ..GenerationConstraints::default()
1104            },
1105        };
1106        let variants = enumerate_variants(&spec, None).unwrap();
1107        assert_eq!(variants.len(), 4);
1108        let signatures = survivor_signatures(&variants);
1109        assert!(!signatures.contains(&vec![
1110            ("a".to_string(), "a1".to_string()),
1111            ("b".to_string(), "b1".to_string())
1112        ]));
1113        assert!(!signatures.contains(&vec![
1114            ("a".to_string(), "a2".to_string()),
1115            ("b".to_string(), "b2".to_string())
1116        ]));
1117    }
1118
1119    #[test]
1120    fn constraint_mutex_group_of_three_forbids_only_full_co_occurrence() {
1121        // A SIZE-3 mutex group [a:a1, b:b1, c:c1] over a 2x2x2 cartesian (8 variants). This is where
1122        // the "not all co-occur" (legacy `issubset`) rule DIVERGES from the old "<=1 present" rule:
1123        //   - legacy "not all co-occur" forbids ONLY the single {a1,b1,c1} variant       -> 8 - 1 = 7
1124        //   - the old "count > 1" rule would forbid EVERY variant with 2+ of {a1,b1,c1}  -> only 4
1125        // We lock 7 survivors with {a1,b1,c1} as the SOLE casualty, matching the nirs4all generation
1126        // oracle (`generator_or_pick_mutex3`): a >2 mutex prunes exactly the all-present combination
1127        // and keeps every proper subset. (For a PAIR the two rules coincide — see the cases above.)
1128        let spec = GenerationSpec {
1129            strategy: GenerationStrategy::Cartesian,
1130            dimensions: vec![
1131                GenerationDimension {
1132                    name: "a".to_string(),
1133                    choices: vec![choice("a1", json!("a1")), choice("a2", json!("a2"))],
1134                },
1135                GenerationDimension {
1136                    name: "b".to_string(),
1137                    choices: vec![choice("b1", json!("b1")), choice("b2", json!("b2"))],
1138                },
1139                GenerationDimension {
1140                    name: "c".to_string(),
1141                    choices: vec![choice("c1", json!("c1")), choice("c2", json!("c2"))],
1142                },
1143            ],
1144            max_variants: Some(8),
1145            constraints: GenerationConstraints {
1146                mutex: vec![vec![cref("a", "a1"), cref("b", "b1"), cref("c", "c1")]],
1147                ..GenerationConstraints::default()
1148            },
1149        };
1150        let variants = enumerate_variants(&spec, None).unwrap();
1151        assert_eq!(variants.len(), 7);
1152        let signatures = survivor_signatures(&variants);
1153        // The all-present {a1,b1,c1} variant is the ONLY casualty.
1154        let all_present = vec![
1155            ("a".to_string(), "a1".to_string()),
1156            ("b".to_string(), "b1".to_string()),
1157            ("c".to_string(), "c1".to_string()),
1158        ];
1159        assert!(!signatures.contains(&all_present));
1160        // Every PROPER subset of the mutex group survives (would be wrongly pruned by "count > 1"):
1161        // exactly-two-present variants {a1,b1,c2}, {a1,b2,c1}, {a2,b1,c1} are all retained.
1162        for retained in [
1163            vec![
1164                ("a".to_string(), "a1".to_string()),
1165                ("b".to_string(), "b1".to_string()),
1166                ("c".to_string(), "c2".to_string()),
1167            ],
1168            vec![
1169                ("a".to_string(), "a1".to_string()),
1170                ("b".to_string(), "b2".to_string()),
1171                ("c".to_string(), "c1".to_string()),
1172            ],
1173            vec![
1174                ("a".to_string(), "a2".to_string()),
1175                ("b".to_string(), "b1".to_string()),
1176                ("c".to_string(), "c1".to_string()),
1177            ],
1178        ] {
1179            assert!(
1180                signatures.contains(&retained),
1181                "proper subset {retained:?} was wrongly pruned"
1182            );
1183        }
1184        // Deterministic + fingerprinted across calls.
1185        assert_eq!(variants, enumerate_variants(&spec, None).unwrap());
1186    }
1187
1188    #[test]
1189    fn constraint_prunes_to_one() {
1190        // Two mutex pairs prune the 2x3 product down to a single survivor, mirroring the oracle's
1191        // `generator_constraint_prunes_to_one` lock. a1 is mutex with both b1 and b2; a2 is mutex
1192        // with all of b1/b2/b3. Survivors: {a1,b3} only.
1193        let spec = GenerationSpec {
1194            strategy: GenerationStrategy::Cartesian,
1195            dimensions: two_by_three_dimensions(),
1196            max_variants: Some(6),
1197            constraints: GenerationConstraints {
1198                mutex: vec![
1199                    vec![cref("a", "a1"), cref("b", "b1")],
1200                    vec![cref("a", "a1"), cref("b", "b2")],
1201                    vec![cref("a", "a2"), cref("b", "b1")],
1202                    vec![cref("a", "a2"), cref("b", "b2")],
1203                    vec![cref("a", "a2"), cref("b", "b3")],
1204                ],
1205                ..GenerationConstraints::default()
1206            },
1207        };
1208        let variants = enumerate_variants(&spec, None).unwrap();
1209        assert_eq!(variants.len(), 1);
1210        assert_eq!(
1211            survivor_signatures(&variants),
1212            vec![vec![
1213                ("a".to_string(), "a1".to_string()),
1214                ("b".to_string(), "b3".to_string())
1215            ]]
1216        );
1217    }
1218
1219    #[test]
1220    fn constraint_all_pruned_is_an_error() {
1221        // exclude removes a1-with-b*, mutex removes a2-with-b* -> no survivors -> error.
1222        let spec = GenerationSpec {
1223            strategy: GenerationStrategy::Cartesian,
1224            dimensions: vec![
1225                GenerationDimension {
1226                    name: "a".to_string(),
1227                    choices: vec![choice("a1", json!("a1"))],
1228                },
1229                GenerationDimension {
1230                    name: "b".to_string(),
1231                    choices: vec![choice("b1", json!("b1"))],
1232                },
1233            ],
1234            max_variants: Some(1),
1235            constraints: GenerationConstraints {
1236                exclude: vec![(cref("a", "a1"), cref("b", "b1"))],
1237                ..GenerationConstraints::default()
1238            },
1239        };
1240        let error = enumerate_variants(&spec, None).unwrap_err().to_string();
1241        assert!(error.contains("pruned every variant"), "{error}");
1242    }
1243
1244    #[test]
1245    fn constraint_unknown_choice_is_rejected() {
1246        let spec = GenerationSpec {
1247            strategy: GenerationStrategy::Cartesian,
1248            dimensions: two_by_three_dimensions(),
1249            max_variants: Some(6),
1250            constraints: GenerationConstraints {
1251                mutex: vec![vec![cref("a", "a1"), cref("b", "nope")]],
1252                ..GenerationConstraints::default()
1253            },
1254        };
1255        let error = spec.validate().unwrap_err().to_string();
1256        assert!(error.contains("unknown choice `b:nope`"), "{error}");
1257    }
1258
1259    #[test]
1260    fn constraints_require_a_strategy() {
1261        let spec = GenerationSpec {
1262            strategy: GenerationStrategy::None,
1263            dimensions: Vec::new(),
1264            max_variants: Some(1),
1265            constraints: GenerationConstraints {
1266                exclude: vec![(cref("a", "a1"), cref("b", "b1"))],
1267                ..GenerationConstraints::default()
1268            },
1269        };
1270        let error = spec.validate().unwrap_err().to_string();
1271        assert!(
1272            error.contains("constraints require cartesian or zip"),
1273            "{error}"
1274        );
1275    }
1276
1277    #[test]
1278    fn constraints_absent_keep_spec_byte_identical() {
1279        // A no-constraint spec must serialize without a `constraints` key and fingerprint exactly
1280        // as it would before this field existed (additivity proof, parallel to the Phase-2 gate).
1281        let with_default = GenerationSpec {
1282            strategy: GenerationStrategy::Cartesian,
1283            dimensions: two_by_three_dimensions(),
1284            max_variants: Some(6),
1285            constraints: GenerationConstraints::default(),
1286        };
1287        let serialized = serde_json::to_string(&with_default).unwrap();
1288        assert!(
1289            !serialized.contains("constraints"),
1290            "absent constraints must not serialize: {serialized}"
1291        );
1292        // Round-trips through a constraint-less JSON shape identically.
1293        let reparsed: GenerationSpec = serde_json::from_str(
1294            r#"{"strategy":"cartesian","dimensions":[{"name":"a","choices":[{"label":"a1","value":"a1"},{"label":"a2","value":"a2"}]},{"name":"b","choices":[{"label":"b1","value":"b1"},{"label":"b2","value":"b2"},{"label":"b3","value":"b3"}]}],"max_variants":6}"#,
1295        )
1296        .unwrap();
1297        assert_eq!(
1298            generation_spec_fingerprint(&with_default).unwrap(),
1299            generation_spec_fingerprint(&reparsed).unwrap()
1300        );
1301        assert!(reparsed.constraints.is_empty());
1302    }
1303}