diff --git a/problemreductions-cli/src/commands/create/schema_support.rs b/problemreductions-cli/src/commands/create/schema_support.rs index ce4b6269e..4d6587bc4 100644 --- a/problemreductions-cli/src/commands/create/schema_support.rs +++ b/problemreductions-cli/src/commands/create/schema_support.rs @@ -180,9 +180,7 @@ pub(super) fn create_schema_driven( // KColoring/KN stores the number of colors at runtime in `num_colors`. // The schema only declares `graph`, so inject `num_colors` from --k for KN. - if canonical == "KColoring" - && resolved_variant.get("k").map(|s| s.as_str()) == Some("KN") - { + if canonical == "KColoring" && resolved_variant.get("k").map(|s| s.as_str()) == Some("KN") { if let Some(k) = args.k { json_map.insert("num_colors".to_string(), serde_json::json!(k)); } diff --git a/problemreductions-cli/src/commands/graph.rs b/problemreductions-cli/src/commands/graph.rs index 5468a9aec..f37940725 100644 --- a/problemreductions-cli/src/commands/graph.rs +++ b/problemreductions-cli/src/commands/graph.rs @@ -33,12 +33,7 @@ pub fn list(out: &OutputConfig) -> Result<()> { for name in &types { let variants = graph.variants_for(name); let default_variant = graph.default_variant_for(name); - let aliases = aliases_for(name); - let alias_str = if aliases.is_empty() { - String::new() - } else { - aliases.join(", ") - }; + let problem_aliases = aliases_for(name); for (i, v) in variants.iter().enumerate() { let slash = variant_to_full_slash(v); @@ -53,13 +48,26 @@ pub fn list(out: &OutputConfig) -> Result<()> { .variant_complexity(name, v) .map(|c| big_o_of(&Expr::parse(c))) .unwrap_or_default(); + + // Per-row aliases: problem-level aliases on the first row, plus any + // variant-level aliases attached to the specific reduction-graph node. + let variant_aliases: Vec<&'static str> = + problemreductions::registry::find_variant_entry(name, v) + .map(|entry| entry.aliases.to_vec()) + .unwrap_or_default(); + let mut parts: Vec = Vec::new(); + if i == 0 { + for alias in &problem_aliases { + push_alias_part(&mut parts, alias); + } + } + for alias in &variant_aliases { + push_alias_part(&mut parts, alias); + } + rows_data.push(VariantRow { display, - aliases: if i == 0 { - alias_str.clone() - } else { - String::new() - }, + aliases: parts.join(", "), is_default, rules: if i == 0 { rules } else { 0 }, complexity, @@ -715,6 +723,12 @@ pub fn export(out: &OutputConfig) -> Result<()> { out.emit_with_default_name("reduction_graph.json", &text, &json) } +fn push_alias_part(parts: &mut Vec, alias: &str) { + if !parts.iter().any(|part| part.eq_ignore_ascii_case(alias)) { + parts.push(alias.to_string()); + } +} + fn parse_direction(s: &str) -> Result { match s { "out" => Ok(TraversalFlow::Outgoing), @@ -805,3 +819,20 @@ fn render_tree(graph: &ReductionGraph, nodes: &[NeighborTree], text: &mut String } } } + +#[cfg(test)] +mod tests { + use super::push_alias_part; + + #[test] + fn push_alias_part_deduplicates_case_insensitively_in_order() { + let mut parts = Vec::new(); + push_alias_part(&mut parts, "KSAT"); + push_alias_part(&mut parts, "3SAT"); + push_alias_part(&mut parts, "ksat"); + push_alias_part(&mut parts, "2SAT"); + push_alias_part(&mut parts, "3sat"); + + assert_eq!(parts, vec!["KSAT", "3SAT", "2SAT"]); + } +} diff --git a/problemreductions-cli/src/problem_name.rs b/problemreductions-cli/src/problem_name.rs index 453bfe6b6..173d877b5 100644 --- a/problemreductions-cli/src/problem_name.rs +++ b/problemreductions-cli/src/problem_name.rs @@ -12,7 +12,14 @@ pub struct ProblemSpec { /// Resolve a short alias to the canonical problem name. /// -/// Uses the catalog for both aliases and canonical names. +/// Searches both variant-level aliases (e.g., `"3SAT"` → `KSatisfiability`) and +/// problem-level aliases (e.g., `"MIS"` → `MaximumIndependentSet`). When a +/// variant-level alias is matched, only the canonical name is returned here. +/// The older pass-through behavior where `3SAT` resolved to `"3SAT"` has been +/// intentionally replaced by `3SAT` resolving to `"KSatisfiability"` so aliases +/// behave consistently. Callers that need variant semantics such as +/// `3SAT` → `KSatisfiability { k = K3 }` should use [`parse_problem_spec`] or +/// [`resolve_problem_ref`]. pub fn resolve_alias(input: &str) -> String { if input.eq_ignore_ascii_case("UndirectedFlowLowerBounds") { return "UndirectedFlowLowerBounds".to_string(); @@ -38,6 +45,9 @@ pub fn resolve_alias(input: &str) -> String { if input.eq_ignore_ascii_case("GraphPartitioning") { return "GraphPartitioning".to_string(); } + if let Some((entry, _)) = problemreductions::registry::find_variant_by_alias(input) { + return entry.name.to_string(); + } if let Some(pt) = problemreductions::registry::find_problem_type_by_alias(input) { return pt.canonical_name.to_string(); } @@ -64,16 +74,34 @@ pub fn resolve_catalog_problem_ref( } /// Parse a problem spec string like "MIS/UnitDiskGraph/i32" into name + variant values. +/// +/// Resolution order: +/// 1. **Variant-level alias** (`"3SAT"` → `KSatisfiability` + variant tokens `["K3"]`): +/// injects the variant tokens *before* any user-supplied tokens from the slash spec. +/// 2. **Problem-level alias** (`"MIS"` → `MaximumIndependentSet`): canonical-name only; +/// downstream default-variant resolution fills in the variant dimensions. pub fn parse_problem_spec(input: &str) -> anyhow::Result { let parts: Vec<&str> = input.split('/').collect(); let raw_name = parts[0]; - let variant_values: Vec = parts[1..].iter().map(|s| s.to_string()).collect(); + let user_tokens: Vec = parts[1..].iter().map(|s| s.to_string()).collect(); + + if let Some((entry, variant_map)) = problemreductions::registry::find_variant_by_alias(raw_name) + { + // Prepend the alias's own variant values; the slash-spec resolver handles + // additional user tokens (and errors on dimension collisions). + let mut variant_values: Vec = variant_map.into_values().collect(); + variant_values.extend(user_tokens); + return Ok(ProblemSpec { + name: entry.name.to_string(), + variant_values, + }); + } let name = resolve_alias(raw_name); Ok(ProblemSpec { name, - variant_values, + variant_values: user_tokens, }) } @@ -301,8 +329,11 @@ mod tests { assert_eq!(resolve_alias("X3C"), "ExactCoverBy3Sets"); assert_eq!(resolve_alias("3Partition"), "ThreePartition"); assert_eq!(resolve_alias("3-partition"), "ThreePartition"); - // 3SAT is no longer a registered alias (removed to avoid confusion with KSatisfiability/KN) - assert_eq!(resolve_alias("3SAT"), "3SAT"); // pass-through + // Variant-level aliases: resolve_alias only returns the canonical name; + // parse_problem_spec recovers the variant tokens (see tests below). + assert_eq!(resolve_alias("3SAT"), "KSatisfiability"); + assert_eq!(resolve_alias("3sat"), "KSatisfiability"); + assert_eq!(resolve_alias("2SAT"), "KSatisfiability"); assert_eq!(resolve_alias("QUBO"), "QUBO"); assert_eq!(resolve_alias("MaxCut"), "MaxCut"); assert_eq!( @@ -372,6 +403,86 @@ mod tests { assert_eq!(spec.variant_values, vec!["K3"]); } + #[test] + fn test_parse_problem_spec_variant_alias_3sat() { + // Variant-level alias: "3SAT" injects the K3 variant token. + let spec = parse_problem_spec("3SAT").unwrap(); + assert_eq!(spec.name, "KSatisfiability"); + assert_eq!(spec.variant_values, vec!["K3"]); + + let spec = parse_problem_spec("3sat").unwrap(); + assert_eq!(spec.name, "KSatisfiability"); + assert_eq!(spec.variant_values, vec!["K3"]); + } + + #[test] + fn test_parse_problem_spec_variant_alias_2sat() { + let spec = parse_problem_spec("2SAT").unwrap(); + assert_eq!(spec.name, "KSatisfiability"); + assert_eq!(spec.variant_values, vec!["K2"]); + } + + #[test] + fn test_resolve_problem_ref_variant_alias_3sat() { + let graph = problemreductions::rules::ReductionGraph::new(); + let expected = ProblemRef { + name: "KSatisfiability".to_string(), + variant: BTreeMap::from([("k".to_string(), "K3".to_string())]), + }; + + assert_eq!(resolve_problem_ref("3SAT", &graph).unwrap(), expected); + } + + #[test] + fn test_resolve_problem_ref_variant_alias_2sat() { + let graph = problemreductions::rules::ReductionGraph::new(); + let expected = ProblemRef { + name: "KSatisfiability".to_string(), + variant: BTreeMap::from([("k".to_string(), "K2".to_string())]), + }; + + assert_eq!(resolve_problem_ref("2SAT", &graph).unwrap(), expected); + } + + #[test] + fn test_resolve_problem_ref_3sat_k2_rejects_duplicate_dimension() { + let spec = parse_problem_spec("3SAT/K2").unwrap(); + assert_eq!(spec.name, "KSatisfiability"); + assert_eq!(spec.variant_values, vec!["K3", "K2"]); + + let graph = problemreductions::rules::ReductionGraph::new(); + let err = resolve_problem_ref("3SAT/K2", &graph).unwrap_err(); + assert!( + err.to_string().contains("specified more than once"), + "expected duplicate-dimension error, got: {err}" + ); + } + + #[test] + fn test_resolve_problem_ref_3sat_simple_graph_rejects_unknown_token() { + let spec = parse_problem_spec("3SAT/SimpleGraph").unwrap(); + assert_eq!(spec.name, "KSatisfiability"); + assert_eq!(spec.variant_values, vec!["K3", "SimpleGraph"]); + + let graph = problemreductions::rules::ReductionGraph::new(); + let err = resolve_problem_ref("3SAT/SimpleGraph", &graph).unwrap_err(); + assert!( + err.to_string() + .to_lowercase() + .contains("unknown variant token"), + "expected unknown-token error, got: {err}" + ); + } + + #[test] + fn test_parse_problem_spec_max2sat_problem_level() { + // MAX2SAT is a problem-level alias on Maximum2Satisfiability (standalone problem, + // no K variants) — no variant tokens injected. + let spec = parse_problem_spec("MAX2SAT").unwrap(); + assert_eq!(spec.name, "Maximum2Satisfiability"); + assert!(spec.variant_values.is_empty()); + } + #[test] fn test_suggest_problem_name_close() { // "MISs" is 1 edit from "MIS" alias -> should suggest MaximumIndependentSet diff --git a/problemreductions-cli/src/test_support.rs b/problemreductions-cli/src/test_support.rs index 3b4e55d8f..23f6f9d9f 100644 --- a/problemreductions-cli/src/test_support.rs +++ b/problemreductions-cli/src/test_support.rs @@ -126,6 +126,7 @@ problemreductions::inventory::submit! { complexity: "2^num_values", complexity_eval_fn: |_| 1.0, is_default: true, + aliases: &[], factory: |data| { let problem: AggregateValueSource = serde_json::from_value(data)?; Ok(Box::new(problem)) @@ -146,6 +147,7 @@ problemreductions::inventory::submit! { complexity: "2", complexity_eval_fn: |_| 1.0, is_default: true, + aliases: &[], factory: |data| { let problem: AggregateValueTarget = serde_json::from_value(data)?; Ok(Box::new(problem)) diff --git a/problemreductions-macros/src/lib.rs b/problemreductions-macros/src/lib.rs index 8cdcda052..ac141e1bc 100644 --- a/problemreductions-macros/src/lib.rs +++ b/problemreductions-macros/src/lib.rs @@ -444,11 +444,12 @@ struct DeclareVariantsInput { entries: Vec, } -/// A single entry: `[default] Type => "complexity_string"`. +/// A single entry: `[default] Type => "complexity_string" [aliases ["X", ...]]`. struct DeclareVariantEntry { is_default: bool, ty: Type, complexity: syn::LitStr, + aliases: Vec, } impl syn::parse::Parse for DeclareVariantsInput { @@ -464,10 +465,47 @@ impl syn::parse::Parse for DeclareVariantsInput { let ty: Type = input.parse()?; input.parse::]>()?; let complexity: syn::LitStr = input.parse()?; + + // Optional: `aliases ["X", "Y", ...]` + let aliases = if input.peek(syn::Ident) { + let fork = input.fork(); + let ident: syn::Ident = fork.parse()?; + if ident == "aliases" { + input.parse::()?; + let content; + syn::bracketed!(content in input); + let mut out = Vec::new(); + while !content.is_empty() { + let lit: syn::LitStr = content.parse()?; + if lit.value().trim().is_empty() { + return Err(syn::Error::new( + lit.span(), + "variant alias must not be empty or whitespace-only", + )); + } + out.push(lit); + if content.peek(syn::Token![,]) { + content.parse::()?; + } + } + out + } else if fork.peek(syn::token::Bracket) { + return Err(syn::Error::new( + ident.span(), + format!("expected 'aliases', found '{ident}'"), + )); + } else { + Vec::new() + } + } else { + Vec::new() + }; + entries.push(DeclareVariantEntry { is_default, ty, complexity, + aliases, }); if input.peek(syn::Token![,]) { @@ -552,6 +590,7 @@ fn generate_declare_variants(input: &DeclareVariantsInput) -> syn::Result = entry.aliases.iter().map(|s| s.value()).collect(); // Parse the complexity expression to validate syntax let parsed = parser::parse_expr(&complexity_str).map_err(|e| { @@ -633,6 +672,7 @@ fn generate_declare_variants(input: &DeclareVariantsInput) -> syn::Result("default Foo => \"1\" aliases [\"\"]") { + Ok(_) => panic!("empty alias literal should be rejected"), + Err(err) => err, + }; + assert!( + err.to_string().contains("empty or whitespace-only"), + "expected empty-alias error, got: {err}" + ); + } + + #[test] + fn declare_variants_rejects_whitespace_only_alias_literal() { + let err = match syn::parse_str::( + "default Foo => \"1\" aliases [\" \\t\"]", + ) { + Ok(_) => panic!("whitespace-only alias literal should be rejected"), + Err(err) => err, + }; + assert!( + err.to_string().contains("empty or whitespace-only"), + "expected whitespace-only alias error, got: {err}" + ); + } + + #[test] + fn declare_variants_rejects_unknown_alias_keyword_before_bracket() { + let err = match syn::parse_str::( + "default Foo => \"1\" nicknames [\"Foo\"]", + ) { + Ok(_) => panic!("unknown aliases keyword should be rejected"), + Err(err) => err, + }; + assert_eq!(err.to_string(), "expected 'aliases', found 'nicknames'"); + } + #[test] fn declare_variants_generates_aggregate_value_and_witness_dispatch() { let input: DeclareVariantsInput = syn::parse_quote! { diff --git a/src/models/formula/ksat.rs b/src/models/formula/ksat.rs index 7cf13427e..1dc118638 100644 --- a/src/models/formula/ksat.rs +++ b/src/models/formula/ksat.rs @@ -241,8 +241,8 @@ impl Problem for KSatisfiability { crate::declare_variants! { default KSatisfiability => "2^num_variables", - KSatisfiability => "num_variables + num_clauses", - KSatisfiability => "1.307^num_variables", + KSatisfiability => "num_variables + num_clauses" aliases ["2SAT"], + KSatisfiability => "1.307^num_variables" aliases ["3SAT"], } #[cfg(feature = "example-db")] diff --git a/src/models/graph/minimum_dominating_set.rs b/src/models/graph/minimum_dominating_set.rs index 0e1d52235..d1c7e63e8 100644 --- a/src/models/graph/minimum_dominating_set.rs +++ b/src/models/graph/minimum_dominating_set.rs @@ -244,6 +244,7 @@ inventory::submit! { 1.4969_f64.powf(problem.num_vertices() as f64) }, is_default: false, + aliases: &[], factory: |data| { serde_json::from_value::>>(data) .map(|problem| Box::new(problem) as Box) diff --git a/src/registry/mod.rs b/src/registry/mod.rs index 24e6271c2..d253d4c4a 100644 --- a/src/registry/mod.rs +++ b/src/registry/mod.rs @@ -59,7 +59,9 @@ pub use schema::{ collect_schemas, declared_size_fields, FieldInfoJson, ProblemSchemaEntry, ProblemSchemaJson, ProblemSizeFieldEntry, VariantDimension, }; -pub use variant::{find_variant_entry, VariantEntry}; +pub use variant::{ + find_variant_by_alias, find_variant_entry, validate_variant_aliases, VariantEntry, +}; use std::any::Any; use std::collections::BTreeMap; diff --git a/src/registry/variant.rs b/src/registry/variant.rs index 4bca0369e..254fd0539 100644 --- a/src/registry/variant.rs +++ b/src/registry/variant.rs @@ -22,6 +22,12 @@ pub struct VariantEntry { pub complexity_eval_fn: fn(&dyn Any) -> f64, /// Whether this entry is the declared default variant for its problem. pub is_default: bool, + /// Variant-level aliases (e.g., `&["3SAT"]` for `KSatisfiability`). + /// + /// Unlike problem-level aliases (on `ProblemSchemaEntry`), these resolve to a + /// specific reduction-graph node, not just to a canonical problem name. The CLI + /// resolver tries variant-level aliases first and falls back to problem-level. + pub aliases: &'static [&'static str], /// Factory: deserialize JSON into a boxed dynamic problem. pub factory: fn(serde_json::Value) -> Result, serde_json::Error>, /// Serialize: downcast `&dyn Any` and serialize to JSON. @@ -58,6 +64,130 @@ pub fn find_variant_entry( .find(|entry| entry.name == name && entry.variant_map() == *variant) } +/// Find a variant entry by a variant-level alias (case-insensitive). +/// +/// A variant-level alias points at a specific reduction-graph node (e.g., `"3SAT"` → +/// `KSatisfiability` with variant `{k: "K3"}`), unlike problem-level aliases which +/// resolve only to a canonical problem name. +/// +/// Returns the matched entry along with its variant map. The first match in registration +/// order wins — duplicate variant-level aliases across problems are a declaration bug. +pub fn find_variant_by_alias( + input: &str, +) -> Option<(&'static VariantEntry, BTreeMap)> { + let lower = input.to_lowercase(); + let entry = inventory::iter::() + .find(|entry| entry.aliases.iter().any(|a| a.to_lowercase() == lower))?; + Some((entry, entry.variant_map())) +} + +/// Validate all variant-level aliases registered in inventory. +/// +/// This is intended for explicit test-time or startup invocation. It rejects +/// duplicate variant-level aliases, aliases that collide with canonical +/// problem names or problem-level aliases, and empty aliases for manually +/// constructed [`VariantEntry`] values that bypass `declare_variants!`. +pub fn validate_variant_aliases() -> Result<(), Vec> { + let mut problem_names: BTreeMap> = BTreeMap::new(); + + for problem in super::problem_type::problem_types() { + problem_names + .entry(problem.canonical_name.to_lowercase()) + .or_default() + .push(format!( + "canonical problem name `{}`", + problem.canonical_name + )); + + for alias in problem.aliases { + problem_names + .entry(alias.to_lowercase()) + .or_default() + .push(format!( + "problem-level alias `{alias}` for `{}`", + problem.canonical_name + )); + } + } + + let entries: Vec<_> = inventory::iter::() + .map(|e| (variant_label(e), e.aliases)) + .collect(); + + validate_aliases_inner(&problem_names, &entries) +} + +/// Core validation logic, separated for testability with mock data. +/// +/// - `problem_names`: lowercase key → list of human-readable sources (canonical names + problem-level aliases). +/// - `entries`: `(variant_label, aliases_slice)` per variant entry. +pub fn validate_aliases_inner( + problem_names: &BTreeMap>, + entries: &[(String, &[&str])], +) -> Result<(), Vec> { + let mut conflicts = Vec::new(); + let mut variant_aliases: BTreeMap> = BTreeMap::new(); + + for (target, aliases) in entries { + for alias in *aliases { + if alias.trim().is_empty() { + conflicts.push(format!( + "variant-level alias on {target} is empty or whitespace-only" + )); + continue; + } + + let lower = alias.to_lowercase(); + if let Some(collisions) = problem_names.get(&lower) { + for collision in collisions { + conflicts.push(format!( + "variant-level alias `{alias}` on {target} conflicts with {collision}" + )); + } + } + + variant_aliases + .entry(lower) + .or_default() + .push((alias.to_string(), target.clone())); + } + } + + for (lower, registrations) in variant_aliases { + if registrations.len() > 1 { + let details = registrations + .iter() + .map(|(alias, target)| format!("`{alias}` on {target}")) + .collect::>() + .join("; "); + conflicts.push(format!( + "duplicate variant-level alias `{lower}` (case-insensitive): {details}" + )); + } + } + + if conflicts.is_empty() { + Ok(()) + } else { + conflicts.sort(); + Err(conflicts) + } +} + +pub fn variant_label(entry: &VariantEntry) -> String { + let variant = entry.variant(); + if variant.is_empty() { + return entry.name.to_string(); + } + + let parts = variant + .iter() + .map(|(key, value)| format!("{key}={value}")) + .collect::>() + .join(", "); + format!("{} {{{parts}}}", entry.name) +} + impl std::fmt::Debug for VariantEntry { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("VariantEntry") @@ -69,3 +199,7 @@ impl std::fmt::Debug for VariantEntry { } inventory::collect!(VariantEntry); + +#[cfg(test)] +#[path = "../unit_tests/registry/variant.rs"] +mod tests; diff --git a/src/unit_tests/registry/variant.rs b/src/unit_tests/registry/variant.rs new file mode 100644 index 000000000..f8ec9d944 --- /dev/null +++ b/src/unit_tests/registry/variant.rs @@ -0,0 +1,124 @@ +use crate::registry::variant::{validate_variant_aliases, variant_label}; +use std::collections::BTreeMap; + +#[test] +fn variant_alias_inventory_is_valid() { + if let Err(conflicts) = validate_variant_aliases() { + panic!("variant alias validation failed:\n{}", conflicts.join("\n")); + } +} + +// --- validate_aliases_inner unit tests --- + +use crate::registry::variant::validate_aliases_inner; + +fn empty_problem_names() -> BTreeMap> { + BTreeMap::new() +} + +#[test] +fn validate_inner_accepts_valid_aliases() { + let entries = vec![ + ("Foo {k=K3}".to_string(), &["3FOO"][..]), + ("Foo {k=K2}".to_string(), &["2FOO"][..]), + ]; + assert!(validate_aliases_inner(&empty_problem_names(), &entries).is_ok()); +} + +#[test] +fn validate_inner_rejects_empty_alias() { + let entries = vec![("Foo {k=K3}".to_string(), &[""][..])]; + let err = validate_aliases_inner(&empty_problem_names(), &entries).unwrap_err(); + assert_eq!(err.len(), 1); + assert!( + err[0].contains("empty or whitespace-only"), + "expected empty alias error, got: {}", + err[0] + ); +} + +#[test] +fn validate_inner_rejects_whitespace_only_alias() { + let entries = vec![("Foo".to_string(), &[" \t"][..])]; + let err = validate_aliases_inner(&empty_problem_names(), &entries).unwrap_err(); + assert!(err[0].contains("empty or whitespace-only")); +} + +#[test] +fn validate_inner_rejects_collision_with_canonical_name() { + let mut names = BTreeMap::new(); + names + .entry("bar".to_string()) + .or_insert_with(Vec::new) + .push("canonical problem name `Bar`".to_string()); + + let entries = vec![("Foo {k=K3}".to_string(), &["BAR"][..])]; + let err = validate_aliases_inner(&names, &entries).unwrap_err(); + assert_eq!(err.len(), 1); + assert!(err[0].contains("conflicts with canonical problem name")); +} + +#[test] +fn validate_inner_rejects_collision_with_problem_level_alias() { + let mut names = BTreeMap::new(); + names + .entry("baz".to_string()) + .or_insert_with(Vec::new) + .push("problem-level alias `BAZ` for `Bazinga`".to_string()); + + let entries = vec![("Foo".to_string(), &["baz"][..])]; + let err = validate_aliases_inner(&names, &entries).unwrap_err(); + assert_eq!(err.len(), 1); + assert!(err[0].contains("conflicts with problem-level alias")); +} + +#[test] +fn validate_inner_rejects_duplicate_variant_aliases() { + let entries = vec![ + ("Foo {k=K3}".to_string(), &["DUP"][..]), + ("Bar {k=K2}".to_string(), &["dup"][..]), + ]; + let err = validate_aliases_inner(&empty_problem_names(), &entries).unwrap_err(); + assert_eq!(err.len(), 1); + assert!( + err[0].contains("duplicate variant-level alias"), + "expected duplicate error, got: {}", + err[0] + ); +} + +#[test] +fn validate_inner_reports_multiple_conflicts() { + let entries = vec![ + ("A".to_string(), &[""][..]), + ("B".to_string(), &["X"][..]), + ("C".to_string(), &["x"][..]), + ]; + let err = validate_aliases_inner(&empty_problem_names(), &entries).unwrap_err(); + assert_eq!(err.len(), 2, "expected 2 conflicts, got: {err:?}"); +} + +// --- variant_label unit tests --- + +#[test] +fn variant_label_bare_problem() { + // Find a VariantEntry with no variant dimensions (empty variant list). + // QUBO is a standalone problem with no variants. + let entry = inventory::iter::() + .find(|e| e.variant().is_empty()) + .expect("expected at least one VariantEntry with empty variant"); + let label = variant_label(entry); + assert_eq!(label, entry.name); +} + +#[test] +fn variant_label_with_variant_dimensions() { + let entry = inventory::iter::() + .find(|e| e.name == "KSatisfiability" && e.aliases.contains(&"3SAT")) + .expect("expected KSatisfiability VariantEntry"); + let label = variant_label(entry); + assert!( + label.contains("k=K3"), + "expected label to include k=K3, got: {label}" + ); +}