use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use crate::campaign::stable_json_fingerprint;
use crate::error::{DagMlError, Result};
use crate::ids::{NodeId, VariantId};
use crate::rng::SeedContext;
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GenerationStrategy {
#[default]
None,
Cartesian,
Zip,
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct ChoiceRef {
pub dimension: String,
pub label: String,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct GenerationConstraints {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub mutex: Vec<Vec<ChoiceRef>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub requires: Vec<(ChoiceRef, ChoiceRef)>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub exclude: Vec<(ChoiceRef, ChoiceRef)>,
}
impl GenerationConstraints {
pub fn is_empty(&self) -> bool {
self.mutex.is_empty() && self.requires.is_empty() && self.exclude.is_empty()
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct GenerationChoice {
pub label: String,
pub value: serde_json::Value,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub param_overrides: Vec<GenerationParamOverride>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active_subsequence: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct GenerationParamOverride {
pub node_id: NodeId,
#[serde(default)]
pub params: BTreeMap<String, serde_json::Value>,
}
impl GenerationChoice {
fn validate(&self, dimension_name: &str) -> Result<()> {
if self.label.trim().is_empty() {
return Err(DagMlError::CampaignValidation(format!(
"generation dimension `{dimension_name}` has an empty choice label"
)));
}
if !self.param_overrides.is_empty() && self.active_subsequence.is_some() {
return Err(DagMlError::CampaignValidation(format!(
"generation choice `{}` in dimension `{dimension_name}` cannot set both param_overrides and active_subsequence",
self.label
)));
}
if let Some(active_subsequence) = &self.active_subsequence {
if active_subsequence.trim().is_empty() {
return Err(DagMlError::CampaignValidation(format!(
"generation choice `{}` in dimension `{dimension_name}` has an empty active_subsequence",
self.label
)));
}
}
for override_spec in &self.param_overrides {
override_spec.validate(dimension_name, &self.label)?;
}
Ok(())
}
}
impl GenerationParamOverride {
fn validate(&self, dimension_name: &str, choice_label: &str) -> Result<()> {
if self.params.is_empty() {
return Err(DagMlError::CampaignValidation(format!(
"generation choice `{choice_label}` in dimension `{dimension_name}` has an empty param override for node `{}`",
self.node_id
)));
}
for key in self.params.keys() {
if key.trim().is_empty() {
return Err(DagMlError::CampaignValidation(format!(
"generation choice `{choice_label}` in dimension `{dimension_name}` has an empty param override key for node `{}`",
self.node_id
)));
}
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct GenerationDimension {
pub name: String,
#[serde(default)]
pub choices: Vec<GenerationChoice>,
}
impl GenerationDimension {
fn validate(&self) -> Result<()> {
if self.name.trim().is_empty() {
return Err(DagMlError::CampaignValidation(
"generation dimension name is empty".to_string(),
));
}
if self.choices.is_empty() {
return Err(DagMlError::CampaignValidation(format!(
"generation dimension `{}` has no choices",
self.name
)));
}
let mut labels = BTreeSet::new();
for choice in &self.choices {
choice.validate(&self.name)?;
if !labels.insert(choice.label.as_str()) {
return Err(DagMlError::CampaignValidation(format!(
"generation dimension `{}` has duplicate choice `{}`",
self.name, choice.label
)));
}
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct GenerationSpec {
#[serde(default)]
pub strategy: GenerationStrategy,
#[serde(default)]
pub dimensions: Vec<GenerationDimension>,
#[serde(default)]
pub max_variants: Option<usize>,
#[serde(default, skip_serializing_if = "GenerationConstraints::is_empty")]
pub constraints: GenerationConstraints,
}
impl Default for GenerationSpec {
fn default() -> Self {
Self {
strategy: GenerationStrategy::None,
dimensions: Vec::new(),
max_variants: Some(1),
constraints: GenerationConstraints::default(),
}
}
}
impl GenerationSpec {
pub fn validate(&self) -> Result<()> {
if self.max_variants == Some(0) {
return Err(DagMlError::CampaignValidation(
"generation max_variants cannot be zero".to_string(),
));
}
if self.strategy == GenerationStrategy::None {
if !self.dimensions.is_empty() {
return Err(DagMlError::CampaignValidation(
"generation dimensions require cartesian or zip strategy".to_string(),
));
}
if !self.constraints.is_empty() {
return Err(DagMlError::CampaignValidation(
"generation constraints require cartesian or zip strategy".to_string(),
));
}
return Ok(());
}
if self.dimensions.is_empty() {
return Err(DagMlError::CampaignValidation(
"generation strategy requires at least one dimension".to_string(),
));
}
let mut names = BTreeSet::new();
for dimension in &self.dimensions {
dimension.validate()?;
if !names.insert(dimension.name.as_str()) {
return Err(DagMlError::CampaignValidation(format!(
"duplicate generation dimension `{}`",
dimension.name
)));
}
}
if self.strategy == GenerationStrategy::Zip {
let expected = self.dimensions[0].choices.len();
if self
.dimensions
.iter()
.any(|dimension| dimension.choices.len() != expected)
{
return Err(DagMlError::CampaignValidation(
"zip generation requires every dimension to have the same number of choices"
.to_string(),
));
}
}
self.validate_constraints()?;
Ok(())
}
fn validate_constraints(&self) -> Result<()> {
if self.constraints.is_empty() {
return Ok(());
}
let mut valid = BTreeSet::<(&str, &str)>::new();
for dimension in &self.dimensions {
for choice in &dimension.choices {
valid.insert((dimension.name.as_str(), choice.label.as_str()));
}
}
let check = |reference: &ChoiceRef| -> Result<()> {
if !valid.contains(&(reference.dimension.as_str(), reference.label.as_str())) {
return Err(DagMlError::CampaignValidation(format!(
"generation constraint references unknown choice `{}:{}`",
reference.dimension, reference.label
)));
}
Ok(())
};
for group in &self.constraints.mutex {
if group.len() < 2 {
return Err(DagMlError::CampaignValidation(
"generation mutex group requires at least two choices".to_string(),
));
}
let mut distinct = BTreeSet::new();
for reference in group {
check(reference)?;
if !distinct.insert((reference.dimension.as_str(), reference.label.as_str())) {
return Err(DagMlError::CampaignValidation(format!(
"generation mutex group repeats choice `{}:{}`",
reference.dimension, reference.label
)));
}
}
}
for (group_label, pairs) in [
("requires", &self.constraints.requires),
("exclude", &self.constraints.exclude),
] {
for (left, right) in pairs {
check(left)?;
check(right)?;
if left == right {
return Err(DagMlError::CampaignValidation(format!(
"generation {group_label} pair repeats choice `{}:{}`",
left.dimension, left.label
)));
}
}
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct OperatorVariantModel {
pub generator_id: NodeId,
pub dimension: GenerationDimension,
#[serde(default)]
pub active_nodes: BTreeMap<String, BTreeSet<NodeId>>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub variant_labels: BTreeMap<String, String>,
}
impl OperatorVariantModel {
pub fn validate(&self) -> Result<()> {
self.dimension.validate()?;
let mut active_subsequences = BTreeSet::new();
for choice in &self.dimension.choices {
if !choice.param_overrides.is_empty() {
return Err(DagMlError::CampaignValidation(format!(
"operator variant model `{}` choice `{}` must not carry param_overrides",
self.generator_id, choice.label
)));
}
let Some(active_subsequence) = &choice.active_subsequence else {
return Err(DagMlError::CampaignValidation(format!(
"operator variant model `{}` choice `{}` is missing an active_subsequence",
self.generator_id, choice.label
)));
};
if !active_subsequences.insert(active_subsequence.as_str()) {
return Err(DagMlError::CampaignValidation(format!(
"operator variant model `{}` has duplicate active_subsequence `{active_subsequence}`",
self.generator_id
)));
}
let Some(nodes) = self.active_nodes.get(active_subsequence) else {
return Err(DagMlError::CampaignValidation(format!(
"operator variant model `{}` choice `{}` has no active-node set for `{active_subsequence}`",
self.generator_id, choice.label
)));
};
if nodes.is_empty() {
return Err(DagMlError::CampaignValidation(format!(
"operator variant model `{}` choice `{}` has an empty active-node set",
self.generator_id, choice.label
)));
}
}
for key in self.active_nodes.keys() {
if !active_subsequences.contains(key.as_str()) {
return Err(DagMlError::CampaignValidation(format!(
"operator variant model `{}` has a stray active-node set `{key}` with no matching choice",
self.generator_id
)));
}
}
if !self.variant_labels.is_empty() {
for active_subsequence in &active_subsequences {
let Some(label) = self.variant_labels.get(*active_subsequence) else {
return Err(DagMlError::CampaignValidation(format!(
"operator variant model `{}` has no variant_label for `{active_subsequence}`",
self.generator_id
)));
};
if label.len() != 64 || !label.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err(DagMlError::CampaignValidation(format!(
"operator variant model `{}` variant_label for `{active_subsequence}` is not a 64-hex sha256",
self.generator_id
)));
}
}
for key in self.variant_labels.keys() {
if !active_subsequences.contains(key.as_str()) {
return Err(DagMlError::CampaignValidation(format!(
"operator variant model `{}` has a stray variant_label `{key}` with no matching choice",
self.generator_id
)));
}
}
}
Ok(())
}
pub fn generation_spec(&self) -> GenerationSpec {
GenerationSpec {
strategy: GenerationStrategy::Cartesian,
dimensions: vec![self.dimension.clone()],
max_variants: Some(self.dimension.choices.len()),
constraints: GenerationConstraints::default(),
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct VariantPlan {
pub variant_id: VariantId,
#[serde(default)]
pub choices: BTreeMap<String, GenerationChoice>,
pub fingerprint: String,
pub seed: Option<u64>,
}
impl VariantPlan {
pub fn validate(&self) -> Result<()> {
if self.fingerprint.trim().is_empty() {
return Err(DagMlError::Planning(format!(
"variant `{}` has an empty fingerprint",
self.variant_id
)));
}
for (dimension_name, choice) in &self.choices {
choice.validate(dimension_name)?;
}
self.param_overrides_by_node()?;
Ok(())
}
pub fn effective_params_for_node(
&self,
node_id: &NodeId,
base_params: &BTreeMap<String, serde_json::Value>,
) -> Result<BTreeMap<String, serde_json::Value>> {
let overrides_by_node = self.param_overrides_by_node()?;
let Some(overrides) = overrides_by_node.get(node_id) else {
return Ok(base_params.clone());
};
let mut params = base_params.clone();
params.extend(overrides.clone());
Ok(params)
}
pub fn param_override_targets(&self) -> Result<BTreeSet<NodeId>> {
Ok(self.param_overrides_by_node()?.into_keys().collect())
}
fn param_overrides_by_node(
&self,
) -> Result<BTreeMap<NodeId, BTreeMap<String, serde_json::Value>>> {
let mut overrides = BTreeMap::<NodeId, BTreeMap<String, serde_json::Value>>::new();
let mut owners = BTreeMap::<(NodeId, String), String>::new();
for (dimension_name, choice) in &self.choices {
for override_spec in &choice.param_overrides {
for (param_key, value) in &override_spec.params {
let owner_key = (override_spec.node_id.clone(), param_key.clone());
if let Some(previous) =
owners.insert(owner_key, format!("{dimension_name}:{}", choice.label))
{
return Err(DagMlError::CampaignValidation(format!(
"variant `{}` has conflicting generation overrides for `{}.{}` from `{previous}` and `{}:{}`",
self.variant_id,
override_spec.node_id,
param_key,
dimension_name,
choice.label
)));
}
overrides
.entry(override_spec.node_id.clone())
.or_default()
.insert(param_key.clone(), value.clone());
}
}
}
Ok(overrides)
}
}
pub fn enumerate_variants(
spec: &GenerationSpec,
root_seed: Option<u64>,
) -> Result<Vec<VariantPlan>> {
spec.validate()?;
let mut variants = match spec.strategy {
GenerationStrategy::None => vec![BTreeMap::new()],
GenerationStrategy::Cartesian => cartesian_choices(&spec.dimensions),
GenerationStrategy::Zip => zip_choices(&spec.dimensions),
};
if !spec.constraints.is_empty() {
variants.retain(|choices| satisfies_constraints(choices, &spec.constraints));
if variants.is_empty() {
return Err(DagMlError::CampaignValidation(
"generation constraints pruned every variant".to_string(),
));
}
}
if let Some(max_variants) = spec.max_variants {
if variants.len() > max_variants {
return Err(DagMlError::CampaignValidation(format!(
"generation produced {} variants, above max_variants={max_variants}",
variants.len()
)));
}
}
variants
.drain(..)
.map(|choices| variant_from_choices(choices, root_seed))
.collect()
}
pub(crate) fn constraints_satisfied<F>(present: F, constraints: &GenerationConstraints) -> bool
where
F: Fn(&ChoiceRef) -> bool,
{
for group in &constraints.mutex {
if group.iter().all(&present) {
return false;
}
}
for (left, right) in &constraints.requires {
if present(left) && !present(right) {
return false;
}
}
for (left, right) in &constraints.exclude {
if present(left) && present(right) {
return false;
}
}
true
}
fn satisfies_constraints(
choices: &BTreeMap<String, GenerationChoice>,
constraints: &GenerationConstraints,
) -> bool {
constraints_satisfied(
|reference: &ChoiceRef| {
choices
.get(&reference.dimension)
.is_some_and(|choice| choice.label == reference.label)
},
constraints,
)
}
pub fn generation_spec_fingerprint(spec: &GenerationSpec) -> Result<String> {
spec.validate()?;
stable_json_fingerprint(spec)
}
fn cartesian_choices(
dimensions: &[GenerationDimension],
) -> Vec<BTreeMap<String, GenerationChoice>> {
let mut variants = vec![BTreeMap::new()];
for dimension in dimensions {
let mut next = Vec::with_capacity(variants.len() * dimension.choices.len());
for existing in &variants {
for choice in &dimension.choices {
let mut merged = existing.clone();
merged.insert(dimension.name.clone(), choice.clone());
next.push(merged);
}
}
variants = next;
}
variants
}
fn zip_choices(dimensions: &[GenerationDimension]) -> Vec<BTreeMap<String, GenerationChoice>> {
let len = dimensions
.first()
.map_or(0, |dimension| dimension.choices.len());
(0..len)
.map(|idx| {
dimensions
.iter()
.map(|dimension| (dimension.name.clone(), dimension.choices[idx].clone()))
.collect::<BTreeMap<_, _>>()
})
.collect()
}
fn variant_from_choices(
choices: BTreeMap<String, GenerationChoice>,
root_seed: Option<u64>,
) -> Result<VariantPlan> {
let fingerprint = stable_json_fingerprint(&choices)?;
let suffix = if choices.is_empty() {
"base".to_string()
} else {
fingerprint[..16].to_string()
};
let variant_id = VariantId::new(format!("variant:{suffix}"))?;
let seed = root_seed.map(|seed| {
SeedContext::root(seed)
.child(format!("variant:{variant_id}"))
.derive_u64("variant")
});
let variant = VariantPlan {
variant_id,
choices,
fingerprint,
seed,
};
variant.validate()?;
Ok(variant)
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
fn choice(label: &str, value: serde_json::Value) -> GenerationChoice {
GenerationChoice {
label: label.to_string(),
value,
param_overrides: Vec::new(),
active_subsequence: None,
}
}
fn override_choice(
label: &str,
node_id: &str,
params: BTreeMap<String, serde_json::Value>,
) -> GenerationChoice {
GenerationChoice {
label: label.to_string(),
value: json!(label),
param_overrides: vec![GenerationParamOverride {
node_id: NodeId::new(node_id).unwrap(),
params,
}],
active_subsequence: None,
}
}
#[test]
fn default_generation_produces_base_variant() {
let variants = enumerate_variants(&GenerationSpec::default(), Some(7)).unwrap();
assert_eq!(variants.len(), 1);
assert_eq!(variants[0].variant_id.as_str(), "variant:base");
assert!(variants[0].choices.is_empty());
assert!(variants[0].seed.is_some());
}
#[test]
fn cartesian_generation_is_deterministic_and_fingerprinted() {
let spec = GenerationSpec {
strategy: GenerationStrategy::Cartesian,
dimensions: vec![
GenerationDimension {
name: "model".to_string(),
choices: vec![choice("pls", json!("pls")), choice("rf", json!("rf"))],
},
GenerationDimension {
name: "window".to_string(),
choices: vec![choice("short", json!(7)), choice("long", json!(21))],
},
],
max_variants: Some(4),
constraints: GenerationConstraints::default(),
};
let left = enumerate_variants(&spec, Some(11)).unwrap();
let right = enumerate_variants(&spec, Some(11)).unwrap();
assert_eq!(left.len(), 4);
assert_eq!(left, right);
let fingerprint = generation_spec_fingerprint(&spec).unwrap();
let mut changed_spec = spec.clone();
changed_spec.dimensions[0].choices[0].value = json!("changed");
assert_eq!(fingerprint, generation_spec_fingerprint(&spec).unwrap());
assert_ne!(
fingerprint,
generation_spec_fingerprint(&changed_spec).unwrap()
);
assert_ne!(left[0].variant_id, left[1].variant_id);
assert_eq!(left[0].choices["model"].label, "pls");
assert_eq!(left[0].choices["window"].label, "short");
}
#[test]
fn additive_variant_fields_are_invisible_when_absent() {
let campaign: crate::plan::CampaignSpec = serde_json::from_str(include_str!(
"../../../examples/campaign_oof_generation.json"
))
.unwrap();
let generation_serialized = serde_json::to_string(&campaign.generation).unwrap();
assert!(
!generation_serialized.contains("active_subsequence"),
"absent active_subsequence must not serialize: {generation_serialized}"
);
assert_eq!(
generation_spec_fingerprint(&campaign.generation).unwrap(),
"8d10bce07876d936ab6a62f13063a8d241c967a1578b6d2295a43c26275edf47"
);
let score_set: crate::metrics::ScoreSet =
serde_json::from_str(include_str!("../../../examples/fixtures/score_set.json"))
.unwrap();
let score_set_serialized = serde_json::to_string(&score_set).unwrap();
assert!(
!score_set_serialized.contains("variant_label"),
"absent variant_label must not serialize: {score_set_serialized}"
);
assert_eq!(
stable_json_fingerprint(&score_set).unwrap(),
"e99fa78d79ef2a2b99927276cfaf4c265210abf3cf8b3575477355264fda4a9d"
);
}
#[test]
fn choice_cannot_set_both_param_overrides_and_active_subsequence() {
let choice = GenerationChoice {
label: "both".to_string(),
value: json!("both"),
param_overrides: vec![GenerationParamOverride {
node_id: NodeId::new("model:base").unwrap(),
params: BTreeMap::from([("n_components".to_string(), json!(4))]),
}],
active_subsequence: Some("alt".to_string()),
};
let error = choice.validate("dim").unwrap_err().to_string();
assert!(
error.contains("cannot set both param_overrides and active_subsequence"),
"{error}"
);
let param_only = GenerationChoice {
active_subsequence: None,
..choice.clone()
};
param_only.validate("dim").unwrap();
let operator_only = GenerationChoice {
param_overrides: Vec::new(),
..choice.clone()
};
operator_only.validate("dim").unwrap();
let value_only = GenerationChoice {
param_overrides: Vec::new(),
active_subsequence: None,
..choice
};
value_only.validate("dim").unwrap();
}
#[test]
fn choice_rejects_empty_active_subsequence() {
for blank in ["", " "] {
let choice = GenerationChoice {
label: "op".to_string(),
value: json!("op"),
param_overrides: Vec::new(),
active_subsequence: Some(blank.to_string()),
};
let error = choice.validate("dim").unwrap_err().to_string();
assert!(error.contains("has an empty active_subsequence"), "{error}");
}
}
#[test]
fn zip_generation_requires_same_choice_count() {
let spec = GenerationSpec {
strategy: GenerationStrategy::Zip,
dimensions: vec![
GenerationDimension {
name: "a".to_string(),
choices: vec![choice("a1", json!(1))],
},
GenerationDimension {
name: "b".to_string(),
choices: vec![choice("b1", json!(1)), choice("b2", json!(2))],
},
],
max_variants: None,
constraints: GenerationConstraints::default(),
};
assert!(spec.validate().is_err());
}
#[test]
fn generation_respects_variant_limit() {
let spec = GenerationSpec {
strategy: GenerationStrategy::Cartesian,
dimensions: vec![GenerationDimension {
name: "x".to_string(),
choices: vec![choice("a", json!(1)), choice("b", json!(2))],
}],
max_variants: Some(1),
constraints: GenerationConstraints::default(),
};
assert!(enumerate_variants(&spec, None).is_err());
}
#[test]
fn variant_applies_node_param_overrides() {
let spec = GenerationSpec {
strategy: GenerationStrategy::Cartesian,
dimensions: vec![GenerationDimension {
name: "model_family".to_string(),
choices: vec![override_choice(
"pls",
"model:base",
BTreeMap::from([("n_components".to_string(), json!(8))]),
)],
}],
max_variants: Some(1),
constraints: GenerationConstraints::default(),
};
let variants = enumerate_variants(&spec, Some(7)).unwrap();
let base = BTreeMap::from([("scale".to_string(), json!(true))]);
let params = variants[0]
.effective_params_for_node(&NodeId::new("model:base").unwrap(), &base)
.unwrap();
assert_eq!(params["scale"], json!(true));
assert_eq!(params["n_components"], json!(8));
}
#[test]
fn variant_rejects_conflicting_param_overrides() {
let spec = GenerationSpec {
strategy: GenerationStrategy::Cartesian,
dimensions: vec![
GenerationDimension {
name: "family".to_string(),
choices: vec![override_choice(
"pls",
"model:base",
BTreeMap::from([("alpha".to_string(), json!(1))]),
)],
},
GenerationDimension {
name: "regularization".to_string(),
choices: vec![override_choice(
"ridge",
"model:base",
BTreeMap::from([("alpha".to_string(), json!(2))]),
)],
},
],
max_variants: Some(1),
constraints: GenerationConstraints::default(),
};
let error = enumerate_variants(&spec, None).unwrap_err().to_string();
assert!(error.contains("conflicting generation overrides"));
}
fn cref(dimension: &str, label: &str) -> ChoiceRef {
ChoiceRef {
dimension: dimension.to_string(),
label: label.to_string(),
}
}
fn two_by_three_dimensions() -> Vec<GenerationDimension> {
vec![
GenerationDimension {
name: "a".to_string(),
choices: vec![choice("a1", json!("a1")), choice("a2", json!("a2"))],
},
GenerationDimension {
name: "b".to_string(),
choices: vec![
choice("b1", json!("b1")),
choice("b2", json!("b2")),
choice("b3", json!("b3")),
],
},
]
}
fn survivor_signatures(variants: &[VariantPlan]) -> Vec<Vec<(String, String)>> {
variants
.iter()
.map(|variant| {
variant
.choices
.iter()
.map(|(dimension, choice)| (dimension.clone(), choice.label.clone()))
.collect()
})
.collect()
}
#[test]
fn constraint_mutex_prunes_pair() {
let spec = GenerationSpec {
strategy: GenerationStrategy::Cartesian,
dimensions: two_by_three_dimensions(),
max_variants: Some(6),
constraints: GenerationConstraints {
mutex: vec![vec![cref("a", "a1"), cref("b", "b1")]],
..GenerationConstraints::default()
},
};
let variants = enumerate_variants(&spec, Some(11)).unwrap();
assert_eq!(variants.len(), 5);
let signatures = survivor_signatures(&variants);
assert!(!signatures.contains(&vec![
("a".to_string(), "a1".to_string()),
("b".to_string(), "b1".to_string())
]));
assert_eq!(variants, enumerate_variants(&spec, Some(11)).unwrap());
}
#[test]
fn constraint_requires_prunes() {
let spec = GenerationSpec {
strategy: GenerationStrategy::Cartesian,
dimensions: two_by_three_dimensions(),
max_variants: Some(6),
constraints: GenerationConstraints {
requires: vec![(cref("a", "a1"), cref("b", "b1"))],
..GenerationConstraints::default()
},
};
let variants = enumerate_variants(&spec, None).unwrap();
assert_eq!(variants.len(), 4);
let signatures = survivor_signatures(&variants);
for signature in &signatures {
if signature.contains(&("a".to_string(), "a1".to_string())) {
assert!(signature.contains(&("b".to_string(), "b1".to_string())));
}
}
}
#[test]
fn constraint_exclude_prunes_pair() {
let spec = GenerationSpec {
strategy: GenerationStrategy::Cartesian,
dimensions: two_by_three_dimensions(),
max_variants: Some(6),
constraints: GenerationConstraints {
exclude: vec![(cref("a", "a1"), cref("b", "b1"))],
..GenerationConstraints::default()
},
};
let variants = enumerate_variants(&spec, None).unwrap();
assert_eq!(variants.len(), 5);
assert!(!survivor_signatures(&variants).contains(&vec![
("a".to_string(), "a1".to_string()),
("b".to_string(), "b1".to_string())
]));
}
#[test]
fn constraint_cartesian_exclude_prunes_one_of_four() {
let spec = GenerationSpec {
strategy: GenerationStrategy::Cartesian,
dimensions: vec![
GenerationDimension {
name: "a".to_string(),
choices: vec![choice("a1", json!("a1")), choice("a2", json!("a2"))],
},
GenerationDimension {
name: "b".to_string(),
choices: vec![choice("b1", json!("b1")), choice("b2", json!("b2"))],
},
],
max_variants: Some(4),
constraints: GenerationConstraints {
exclude: vec![(cref("a", "a1"), cref("b", "b1"))],
..GenerationConstraints::default()
},
};
let variants = enumerate_variants(&spec, None).unwrap();
assert_eq!(variants.len(), 3);
}
#[test]
fn constraint_combined_mutex_and_exclude() {
let spec = GenerationSpec {
strategy: GenerationStrategy::Cartesian,
dimensions: two_by_three_dimensions(),
max_variants: Some(6),
constraints: GenerationConstraints {
mutex: vec![vec![cref("a", "a1"), cref("b", "b1")]],
exclude: vec![(cref("a", "a2"), cref("b", "b2"))],
..GenerationConstraints::default()
},
};
let variants = enumerate_variants(&spec, None).unwrap();
assert_eq!(variants.len(), 4);
let signatures = survivor_signatures(&variants);
assert!(!signatures.contains(&vec![
("a".to_string(), "a1".to_string()),
("b".to_string(), "b1".to_string())
]));
assert!(!signatures.contains(&vec![
("a".to_string(), "a2".to_string()),
("b".to_string(), "b2".to_string())
]));
}
#[test]
fn constraint_mutex_group_of_three_forbids_only_full_co_occurrence() {
let spec = GenerationSpec {
strategy: GenerationStrategy::Cartesian,
dimensions: vec![
GenerationDimension {
name: "a".to_string(),
choices: vec![choice("a1", json!("a1")), choice("a2", json!("a2"))],
},
GenerationDimension {
name: "b".to_string(),
choices: vec![choice("b1", json!("b1")), choice("b2", json!("b2"))],
},
GenerationDimension {
name: "c".to_string(),
choices: vec![choice("c1", json!("c1")), choice("c2", json!("c2"))],
},
],
max_variants: Some(8),
constraints: GenerationConstraints {
mutex: vec![vec![cref("a", "a1"), cref("b", "b1"), cref("c", "c1")]],
..GenerationConstraints::default()
},
};
let variants = enumerate_variants(&spec, None).unwrap();
assert_eq!(variants.len(), 7);
let signatures = survivor_signatures(&variants);
let all_present = vec![
("a".to_string(), "a1".to_string()),
("b".to_string(), "b1".to_string()),
("c".to_string(), "c1".to_string()),
];
assert!(!signatures.contains(&all_present));
for retained in [
vec![
("a".to_string(), "a1".to_string()),
("b".to_string(), "b1".to_string()),
("c".to_string(), "c2".to_string()),
],
vec![
("a".to_string(), "a1".to_string()),
("b".to_string(), "b2".to_string()),
("c".to_string(), "c1".to_string()),
],
vec![
("a".to_string(), "a2".to_string()),
("b".to_string(), "b1".to_string()),
("c".to_string(), "c1".to_string()),
],
] {
assert!(
signatures.contains(&retained),
"proper subset {retained:?} was wrongly pruned"
);
}
assert_eq!(variants, enumerate_variants(&spec, None).unwrap());
}
#[test]
fn constraint_prunes_to_one() {
let spec = GenerationSpec {
strategy: GenerationStrategy::Cartesian,
dimensions: two_by_three_dimensions(),
max_variants: Some(6),
constraints: GenerationConstraints {
mutex: vec![
vec![cref("a", "a1"), cref("b", "b1")],
vec![cref("a", "a1"), cref("b", "b2")],
vec![cref("a", "a2"), cref("b", "b1")],
vec![cref("a", "a2"), cref("b", "b2")],
vec![cref("a", "a2"), cref("b", "b3")],
],
..GenerationConstraints::default()
},
};
let variants = enumerate_variants(&spec, None).unwrap();
assert_eq!(variants.len(), 1);
assert_eq!(
survivor_signatures(&variants),
vec![vec![
("a".to_string(), "a1".to_string()),
("b".to_string(), "b3".to_string())
]]
);
}
#[test]
fn constraint_all_pruned_is_an_error() {
let spec = GenerationSpec {
strategy: GenerationStrategy::Cartesian,
dimensions: vec![
GenerationDimension {
name: "a".to_string(),
choices: vec![choice("a1", json!("a1"))],
},
GenerationDimension {
name: "b".to_string(),
choices: vec![choice("b1", json!("b1"))],
},
],
max_variants: Some(1),
constraints: GenerationConstraints {
exclude: vec![(cref("a", "a1"), cref("b", "b1"))],
..GenerationConstraints::default()
},
};
let error = enumerate_variants(&spec, None).unwrap_err().to_string();
assert!(error.contains("pruned every variant"), "{error}");
}
#[test]
fn constraint_unknown_choice_is_rejected() {
let spec = GenerationSpec {
strategy: GenerationStrategy::Cartesian,
dimensions: two_by_three_dimensions(),
max_variants: Some(6),
constraints: GenerationConstraints {
mutex: vec![vec![cref("a", "a1"), cref("b", "nope")]],
..GenerationConstraints::default()
},
};
let error = spec.validate().unwrap_err().to_string();
assert!(error.contains("unknown choice `b:nope`"), "{error}");
}
#[test]
fn constraints_require_a_strategy() {
let spec = GenerationSpec {
strategy: GenerationStrategy::None,
dimensions: Vec::new(),
max_variants: Some(1),
constraints: GenerationConstraints {
exclude: vec![(cref("a", "a1"), cref("b", "b1"))],
..GenerationConstraints::default()
},
};
let error = spec.validate().unwrap_err().to_string();
assert!(
error.contains("constraints require cartesian or zip"),
"{error}"
);
}
#[test]
fn constraints_absent_keep_spec_byte_identical() {
let with_default = GenerationSpec {
strategy: GenerationStrategy::Cartesian,
dimensions: two_by_three_dimensions(),
max_variants: Some(6),
constraints: GenerationConstraints::default(),
};
let serialized = serde_json::to_string(&with_default).unwrap();
assert!(
!serialized.contains("constraints"),
"absent constraints must not serialize: {serialized}"
);
let reparsed: GenerationSpec = serde_json::from_str(
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}"#,
)
.unwrap();
assert_eq!(
generation_spec_fingerprint(&with_default).unwrap(),
generation_spec_fingerprint(&reparsed).unwrap()
);
assert!(reparsed.constraints.is_empty());
}
}