Skip to content

Commit b55f927

Browse files
committed
fix: address variant alias review follow-ups
1 parent 389b34d commit b55f927

6 files changed

Lines changed: 245 additions & 6 deletions

File tree

problemreductions-cli/src/commands/graph.rs

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,13 @@ pub fn list(out: &OutputConfig) -> Result<()> {
5757
.unwrap_or_default();
5858
let mut parts: Vec<String> = Vec::new();
5959
if i == 0 {
60-
parts.extend(problem_aliases.iter().map(|s| s.to_string()));
60+
for alias in &problem_aliases {
61+
push_alias_part(&mut parts, alias);
62+
}
63+
}
64+
for alias in &variant_aliases {
65+
push_alias_part(&mut parts, alias);
6166
}
62-
parts.extend(variant_aliases.iter().map(|s| s.to_string()));
6367

6468
rows_data.push(VariantRow {
6569
display,
@@ -719,6 +723,12 @@ pub fn export(out: &OutputConfig) -> Result<()> {
719723
out.emit_with_default_name("reduction_graph.json", &text, &json)
720724
}
721725

726+
fn push_alias_part(parts: &mut Vec<String>, alias: &str) {
727+
if !parts.iter().any(|part| part.eq_ignore_ascii_case(alias)) {
728+
parts.push(alias.to_string());
729+
}
730+
}
731+
722732
fn parse_direction(s: &str) -> Result<TraversalFlow> {
723733
match s {
724734
"out" => Ok(TraversalFlow::Outgoing),
@@ -809,3 +819,20 @@ fn render_tree(graph: &ReductionGraph, nodes: &[NeighborTree], text: &mut String
809819
}
810820
}
811821
}
822+
823+
#[cfg(test)]
824+
mod tests {
825+
use super::push_alias_part;
826+
827+
#[test]
828+
fn push_alias_part_deduplicates_case_insensitively_in_order() {
829+
let mut parts = Vec::new();
830+
push_alias_part(&mut parts, "KSAT");
831+
push_alias_part(&mut parts, "3SAT");
832+
push_alias_part(&mut parts, "ksat");
833+
push_alias_part(&mut parts, "2SAT");
834+
push_alias_part(&mut parts, "3sat");
835+
836+
assert_eq!(parts, vec!["KSAT", "3SAT", "2SAT"]);
837+
}
838+
}

problemreductions-cli/src/problem_name.rs

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,12 @@ pub struct ProblemSpec {
1414
///
1515
/// Searches both variant-level aliases (e.g., `"3SAT"` → `KSatisfiability`) and
1616
/// problem-level aliases (e.g., `"MIS"` → `MaximumIndependentSet`). When a
17-
/// variant-level alias is matched, only the canonical name is returned here;
18-
/// use [`parse_problem_spec`] to also recover the variant tokens.
17+
/// variant-level alias is matched, only the canonical name is returned here.
18+
/// The older pass-through behavior where `3SAT` resolved to `"3SAT"` has been
19+
/// intentionally replaced by `3SAT` resolving to `"KSatisfiability"` so aliases
20+
/// behave consistently. Callers that need variant semantics such as
21+
/// `3SAT` → `KSatisfiability { k = K3 }` should use [`parse_problem_spec`] or
22+
/// [`resolve_problem_ref`].
1923
pub fn resolve_alias(input: &str) -> String {
2024
if input.eq_ignore_ascii_case("UndirectedFlowLowerBounds") {
2125
return "UndirectedFlowLowerBounds".to_string();
@@ -418,6 +422,58 @@ mod tests {
418422
assert_eq!(spec.variant_values, vec!["K2"]);
419423
}
420424

425+
#[test]
426+
fn test_resolve_problem_ref_variant_alias_3sat() {
427+
let graph = problemreductions::rules::ReductionGraph::new();
428+
let expected = ProblemRef {
429+
name: "KSatisfiability".to_string(),
430+
variant: BTreeMap::from([("k".to_string(), "K3".to_string())]),
431+
};
432+
433+
assert_eq!(resolve_problem_ref("3SAT", &graph).unwrap(), expected);
434+
}
435+
436+
#[test]
437+
fn test_resolve_problem_ref_variant_alias_2sat() {
438+
let graph = problemreductions::rules::ReductionGraph::new();
439+
let expected = ProblemRef {
440+
name: "KSatisfiability".to_string(),
441+
variant: BTreeMap::from([("k".to_string(), "K2".to_string())]),
442+
};
443+
444+
assert_eq!(resolve_problem_ref("2SAT", &graph).unwrap(), expected);
445+
}
446+
447+
#[test]
448+
fn test_resolve_problem_ref_3sat_k2_rejects_duplicate_dimension() {
449+
let spec = parse_problem_spec("3SAT/K2").unwrap();
450+
assert_eq!(spec.name, "KSatisfiability");
451+
assert_eq!(spec.variant_values, vec!["K3", "K2"]);
452+
453+
let graph = problemreductions::rules::ReductionGraph::new();
454+
let err = resolve_problem_ref("3SAT/K2", &graph).unwrap_err();
455+
assert!(
456+
err.to_string().contains("specified more than once"),
457+
"expected duplicate-dimension error, got: {err}"
458+
);
459+
}
460+
461+
#[test]
462+
fn test_resolve_problem_ref_3sat_simple_graph_rejects_unknown_token() {
463+
let spec = parse_problem_spec("3SAT/SimpleGraph").unwrap();
464+
assert_eq!(spec.name, "KSatisfiability");
465+
assert_eq!(spec.variant_values, vec!["K3", "SimpleGraph"]);
466+
467+
let graph = problemreductions::rules::ReductionGraph::new();
468+
let err = resolve_problem_ref("3SAT/SimpleGraph", &graph).unwrap_err();
469+
assert!(
470+
err.to_string()
471+
.to_lowercase()
472+
.contains("unknown variant token"),
473+
"expected unknown-token error, got: {err}"
474+
);
475+
}
476+
421477
#[test]
422478
fn test_parse_problem_spec_max2sat_problem_level() {
423479
// MAX2SAT is a problem-level alias on Maximum2Satisfiability (standalone problem,

problemreductions-macros/src/lib.rs

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -468,20 +468,32 @@ impl syn::parse::Parse for DeclareVariantsInput {
468468

469469
// Optional: `aliases ["X", "Y", ...]`
470470
let aliases = if input.peek(syn::Ident) {
471-
let ident: syn::Ident = input.fork().parse()?;
471+
let fork = input.fork();
472+
let ident: syn::Ident = fork.parse()?;
472473
if ident == "aliases" {
473474
input.parse::<syn::Ident>()?;
474475
let content;
475476
syn::bracketed!(content in input);
476477
let mut out = Vec::new();
477478
while !content.is_empty() {
478479
let lit: syn::LitStr = content.parse()?;
480+
if lit.value().trim().is_empty() {
481+
return Err(syn::Error::new(
482+
lit.span(),
483+
"variant alias must not be empty or whitespace-only",
484+
));
485+
}
479486
out.push(lit);
480487
if content.peek(syn::Token![,]) {
481488
content.parse::<syn::Token![,]>()?;
482489
}
483490
}
484491
out
492+
} else if fork.peek(syn::token::Bracket) {
493+
return Err(syn::Error::new(
494+
ident.span(),
495+
format!("expected 'aliases', found '{ident}'"),
496+
));
485497
} else {
486498
Vec::new()
487499
}
@@ -795,6 +807,44 @@ mod tests {
795807
);
796808
}
797809

810+
#[test]
811+
fn declare_variants_rejects_empty_alias_literal() {
812+
let err =
813+
match syn::parse_str::<DeclareVariantsInput>("default Foo => \"1\" aliases [\"\"]") {
814+
Ok(_) => panic!("empty alias literal should be rejected"),
815+
Err(err) => err,
816+
};
817+
assert!(
818+
err.to_string().contains("empty or whitespace-only"),
819+
"expected empty-alias error, got: {err}"
820+
);
821+
}
822+
823+
#[test]
824+
fn declare_variants_rejects_whitespace_only_alias_literal() {
825+
let err = match syn::parse_str::<DeclareVariantsInput>(
826+
"default Foo => \"1\" aliases [\" \\t\"]",
827+
) {
828+
Ok(_) => panic!("whitespace-only alias literal should be rejected"),
829+
Err(err) => err,
830+
};
831+
assert!(
832+
err.to_string().contains("empty or whitespace-only"),
833+
"expected whitespace-only alias error, got: {err}"
834+
);
835+
}
836+
837+
#[test]
838+
fn declare_variants_rejects_unknown_alias_keyword_before_bracket() {
839+
let err = match syn::parse_str::<DeclareVariantsInput>(
840+
"default Foo => \"1\" nicknames [\"Foo\"]",
841+
) {
842+
Ok(_) => panic!("unknown aliases keyword should be rejected"),
843+
Err(err) => err,
844+
};
845+
assert_eq!(err.to_string(), "expected 'aliases', found 'nicknames'");
846+
}
847+
798848
#[test]
799849
fn declare_variants_generates_aggregate_value_and_witness_dispatch() {
800850
let input: DeclareVariantsInput = syn::parse_quote! {

src/registry/mod.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,9 @@ pub use schema::{
5959
collect_schemas, declared_size_fields, FieldInfoJson, ProblemSchemaEntry, ProblemSchemaJson,
6060
ProblemSizeFieldEntry, VariantDimension,
6161
};
62-
pub use variant::{find_variant_by_alias, find_variant_entry, VariantEntry};
62+
pub use variant::{
63+
find_variant_by_alias, find_variant_entry, validate_variant_aliases, VariantEntry,
64+
};
6365

6466
use std::any::Any;
6567
use std::collections::BTreeMap;

src/registry/variant.rs

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,98 @@ pub fn find_variant_by_alias(
8181
Some((entry, entry.variant_map()))
8282
}
8383

84+
/// Validate all variant-level aliases registered in inventory.
85+
///
86+
/// This is intended for explicit test-time or startup invocation. It rejects
87+
/// duplicate variant-level aliases, aliases that collide with canonical
88+
/// problem names or problem-level aliases, and empty aliases for manually
89+
/// constructed [`VariantEntry`] values that bypass `declare_variants!`.
90+
pub fn validate_variant_aliases() -> Result<(), Vec<String>> {
91+
let mut conflicts = Vec::new();
92+
let mut problem_names: BTreeMap<String, Vec<String>> = BTreeMap::new();
93+
94+
for problem in super::problem_type::problem_types() {
95+
problem_names
96+
.entry(problem.canonical_name.to_lowercase())
97+
.or_default()
98+
.push(format!(
99+
"canonical problem name `{}`",
100+
problem.canonical_name
101+
));
102+
103+
for alias in problem.aliases {
104+
problem_names
105+
.entry(alias.to_lowercase())
106+
.or_default()
107+
.push(format!(
108+
"problem-level alias `{alias}` for `{}`",
109+
problem.canonical_name
110+
));
111+
}
112+
}
113+
114+
let mut variant_aliases: BTreeMap<String, Vec<(String, String)>> = BTreeMap::new();
115+
for entry in inventory::iter::<VariantEntry> {
116+
let target = variant_label(entry);
117+
for alias in entry.aliases {
118+
if alias.trim().is_empty() {
119+
conflicts.push(format!(
120+
"variant-level alias on {target} is empty or whitespace-only"
121+
));
122+
continue;
123+
}
124+
125+
let lower = alias.to_lowercase();
126+
if let Some(collisions) = problem_names.get(&lower) {
127+
for collision in collisions {
128+
conflicts.push(format!(
129+
"variant-level alias `{alias}` on {target} conflicts with {collision}"
130+
));
131+
}
132+
}
133+
134+
variant_aliases
135+
.entry(lower)
136+
.or_default()
137+
.push((alias.to_string(), target.clone()));
138+
}
139+
}
140+
141+
for (lower, registrations) in variant_aliases {
142+
if registrations.len() > 1 {
143+
let details = registrations
144+
.iter()
145+
.map(|(alias, target)| format!("`{alias}` on {target}"))
146+
.collect::<Vec<_>>()
147+
.join("; ");
148+
conflicts.push(format!(
149+
"duplicate variant-level alias `{lower}` (case-insensitive): {details}"
150+
));
151+
}
152+
}
153+
154+
if conflicts.is_empty() {
155+
Ok(())
156+
} else {
157+
conflicts.sort();
158+
Err(conflicts)
159+
}
160+
}
161+
162+
fn variant_label(entry: &VariantEntry) -> String {
163+
let variant = entry.variant();
164+
if variant.is_empty() {
165+
return entry.name.to_string();
166+
}
167+
168+
let parts = variant
169+
.iter()
170+
.map(|(key, value)| format!("{key}={value}"))
171+
.collect::<Vec<_>>()
172+
.join(", ");
173+
format!("{} {{{parts}}}", entry.name)
174+
}
175+
84176
impl std::fmt::Debug for VariantEntry {
85177
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86178
f.debug_struct("VariantEntry")
@@ -92,3 +184,7 @@ impl std::fmt::Debug for VariantEntry {
92184
}
93185

94186
inventory::collect!(VariantEntry);
187+
188+
#[cfg(test)]
189+
#[path = "../unit_tests/registry/variant.rs"]
190+
mod tests;

src/unit_tests/registry/variant.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
use crate::registry::variant::validate_variant_aliases;
2+
3+
#[test]
4+
fn variant_alias_inventory_is_valid() {
5+
if let Err(conflicts) = validate_variant_aliases() {
6+
panic!("variant alias validation failed:\n{}", conflicts.join("\n"));
7+
}
8+
}

0 commit comments

Comments
 (0)