Skip to content

Commit 389b34d

Browse files
GiggleLiuclaude
andcommitted
Add variant-level aliases; introduce 2SAT and 3SAT
Variant-level aliases attach to a specific reduction-graph node rather than a canonical problem name, so shorthand like `3SAT` can resolve to `KSatisfiability<K3>` directly instead of going through the problem's default variant (which is `KN`, not what `3SAT` means in the literature). - `VariantEntry` gains an `aliases: &'static [&'static str]` field. - `declare_variants!` accepts optional `aliases ["X", ...]` trailing each entry and emits the field. - `registry::find_variant_by_alias()` returns both the entry and its variant map. - CLI `parse_problem_spec` and `resolve_alias` try variant-level aliases before problem-level ones, injecting the alias's variant tokens into the spec; problem-level resolution is unchanged for `MIS`, `SAT`, etc. - Adds `2SAT` on `KSatisfiability<K2>` and `3SAT` on `KSatisfiability<K3>`. - `pred list` now shows variant-level aliases on their own rows. - `MAX2SAT` remains a problem-level alias on `Maximum2Satisfiability` (standalone problem, no variants). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e38b1b5 commit 389b34d

9 files changed

Lines changed: 134 additions & 23 deletions

File tree

problemreductions-cli/src/commands/create/schema_support.rs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -180,9 +180,7 @@ pub(super) fn create_schema_driven(
180180

181181
// KColoring/KN stores the number of colors at runtime in `num_colors`.
182182
// The schema only declares `graph`, so inject `num_colors` from --k for KN.
183-
if canonical == "KColoring"
184-
&& resolved_variant.get("k").map(|s| s.as_str()) == Some("KN")
185-
{
183+
if canonical == "KColoring" && resolved_variant.get("k").map(|s| s.as_str()) == Some("KN") {
186184
if let Some(k) = args.k {
187185
json_map.insert("num_colors".to_string(), serde_json::json!(k));
188186
}

problemreductions-cli/src/commands/graph.rs

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,7 @@ pub fn list(out: &OutputConfig) -> Result<()> {
3333
for name in &types {
3434
let variants = graph.variants_for(name);
3535
let default_variant = graph.default_variant_for(name);
36-
let aliases = aliases_for(name);
37-
let alias_str = if aliases.is_empty() {
38-
String::new()
39-
} else {
40-
aliases.join(", ")
41-
};
36+
let problem_aliases = aliases_for(name);
4237

4338
for (i, v) in variants.iter().enumerate() {
4439
let slash = variant_to_full_slash(v);
@@ -53,13 +48,22 @@ pub fn list(out: &OutputConfig) -> Result<()> {
5348
.variant_complexity(name, v)
5449
.map(|c| big_o_of(&Expr::parse(c)))
5550
.unwrap_or_default();
51+
52+
// Per-row aliases: problem-level aliases on the first row, plus any
53+
// variant-level aliases attached to the specific reduction-graph node.
54+
let variant_aliases: Vec<&'static str> =
55+
problemreductions::registry::find_variant_entry(name, v)
56+
.map(|entry| entry.aliases.to_vec())
57+
.unwrap_or_default();
58+
let mut parts: Vec<String> = Vec::new();
59+
if i == 0 {
60+
parts.extend(problem_aliases.iter().map(|s| s.to_string()));
61+
}
62+
parts.extend(variant_aliases.iter().map(|s| s.to_string()));
63+
5664
rows_data.push(VariantRow {
5765
display,
58-
aliases: if i == 0 {
59-
alias_str.clone()
60-
} else {
61-
String::new()
62-
},
66+
aliases: parts.join(", "),
6367
is_default,
6468
rules: if i == 0 { rules } else { 0 },
6569
complexity,

problemreductions-cli/src/problem_name.rs

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ pub struct ProblemSpec {
1212

1313
/// Resolve a short alias to the canonical problem name.
1414
///
15-
/// Uses the catalog for both aliases and canonical names.
15+
/// Searches both variant-level aliases (e.g., `"3SAT"` → `KSatisfiability`) and
16+
/// 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.
1619
pub fn resolve_alias(input: &str) -> String {
1720
if input.eq_ignore_ascii_case("UndirectedFlowLowerBounds") {
1821
return "UndirectedFlowLowerBounds".to_string();
@@ -38,6 +41,9 @@ pub fn resolve_alias(input: &str) -> String {
3841
if input.eq_ignore_ascii_case("GraphPartitioning") {
3942
return "GraphPartitioning".to_string();
4043
}
44+
if let Some((entry, _)) = problemreductions::registry::find_variant_by_alias(input) {
45+
return entry.name.to_string();
46+
}
4147
if let Some(pt) = problemreductions::registry::find_problem_type_by_alias(input) {
4248
return pt.canonical_name.to_string();
4349
}
@@ -64,16 +70,34 @@ pub fn resolve_catalog_problem_ref(
6470
}
6571

6672
/// Parse a problem spec string like "MIS/UnitDiskGraph/i32" into name + variant values.
73+
///
74+
/// Resolution order:
75+
/// 1. **Variant-level alias** (`"3SAT"` → `KSatisfiability` + variant tokens `["K3"]`):
76+
/// injects the variant tokens *before* any user-supplied tokens from the slash spec.
77+
/// 2. **Problem-level alias** (`"MIS"` → `MaximumIndependentSet`): canonical-name only;
78+
/// downstream default-variant resolution fills in the variant dimensions.
6779
pub fn parse_problem_spec(input: &str) -> anyhow::Result<ProblemSpec> {
6880
let parts: Vec<&str> = input.split('/').collect();
6981
let raw_name = parts[0];
70-
let variant_values: Vec<String> = parts[1..].iter().map(|s| s.to_string()).collect();
82+
let user_tokens: Vec<String> = parts[1..].iter().map(|s| s.to_string()).collect();
83+
84+
if let Some((entry, variant_map)) = problemreductions::registry::find_variant_by_alias(raw_name)
85+
{
86+
// Prepend the alias's own variant values; the slash-spec resolver handles
87+
// additional user tokens (and errors on dimension collisions).
88+
let mut variant_values: Vec<String> = variant_map.into_values().collect();
89+
variant_values.extend(user_tokens);
90+
return Ok(ProblemSpec {
91+
name: entry.name.to_string(),
92+
variant_values,
93+
});
94+
}
7195

7296
let name = resolve_alias(raw_name);
7397

7498
Ok(ProblemSpec {
7599
name,
76-
variant_values,
100+
variant_values: user_tokens,
77101
})
78102
}
79103

@@ -301,8 +325,11 @@ mod tests {
301325
assert_eq!(resolve_alias("X3C"), "ExactCoverBy3Sets");
302326
assert_eq!(resolve_alias("3Partition"), "ThreePartition");
303327
assert_eq!(resolve_alias("3-partition"), "ThreePartition");
304-
// 3SAT is no longer a registered alias (removed to avoid confusion with KSatisfiability/KN)
305-
assert_eq!(resolve_alias("3SAT"), "3SAT"); // pass-through
328+
// Variant-level aliases: resolve_alias only returns the canonical name;
329+
// parse_problem_spec recovers the variant tokens (see tests below).
330+
assert_eq!(resolve_alias("3SAT"), "KSatisfiability");
331+
assert_eq!(resolve_alias("3sat"), "KSatisfiability");
332+
assert_eq!(resolve_alias("2SAT"), "KSatisfiability");
306333
assert_eq!(resolve_alias("QUBO"), "QUBO");
307334
assert_eq!(resolve_alias("MaxCut"), "MaxCut");
308335
assert_eq!(
@@ -372,6 +399,34 @@ mod tests {
372399
assert_eq!(spec.variant_values, vec!["K3"]);
373400
}
374401

402+
#[test]
403+
fn test_parse_problem_spec_variant_alias_3sat() {
404+
// Variant-level alias: "3SAT" injects the K3 variant token.
405+
let spec = parse_problem_spec("3SAT").unwrap();
406+
assert_eq!(spec.name, "KSatisfiability");
407+
assert_eq!(spec.variant_values, vec!["K3"]);
408+
409+
let spec = parse_problem_spec("3sat").unwrap();
410+
assert_eq!(spec.name, "KSatisfiability");
411+
assert_eq!(spec.variant_values, vec!["K3"]);
412+
}
413+
414+
#[test]
415+
fn test_parse_problem_spec_variant_alias_2sat() {
416+
let spec = parse_problem_spec("2SAT").unwrap();
417+
assert_eq!(spec.name, "KSatisfiability");
418+
assert_eq!(spec.variant_values, vec!["K2"]);
419+
}
420+
421+
#[test]
422+
fn test_parse_problem_spec_max2sat_problem_level() {
423+
// MAX2SAT is a problem-level alias on Maximum2Satisfiability (standalone problem,
424+
// no K variants) — no variant tokens injected.
425+
let spec = parse_problem_spec("MAX2SAT").unwrap();
426+
assert_eq!(spec.name, "Maximum2Satisfiability");
427+
assert!(spec.variant_values.is_empty());
428+
}
429+
375430
#[test]
376431
fn test_suggest_problem_name_close() {
377432
// "MISs" is 1 edit from "MIS" alias -> should suggest MaximumIndependentSet

problemreductions-cli/src/test_support.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ problemreductions::inventory::submit! {
126126
complexity: "2^num_values",
127127
complexity_eval_fn: |_| 1.0,
128128
is_default: true,
129+
aliases: &[],
129130
factory: |data| {
130131
let problem: AggregateValueSource = serde_json::from_value(data)?;
131132
Ok(Box::new(problem))
@@ -146,6 +147,7 @@ problemreductions::inventory::submit! {
146147
complexity: "2",
147148
complexity_eval_fn: |_| 1.0,
148149
is_default: true,
150+
aliases: &[],
149151
factory: |data| {
150152
let problem: AggregateValueTarget = serde_json::from_value(data)?;
151153
Ok(Box::new(problem))

problemreductions-macros/src/lib.rs

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -444,11 +444,12 @@ struct DeclareVariantsInput {
444444
entries: Vec<DeclareVariantEntry>,
445445
}
446446

447-
/// A single entry: `[default] Type => "complexity_string"`.
447+
/// A single entry: `[default] Type => "complexity_string" [aliases ["X", ...]]`.
448448
struct DeclareVariantEntry {
449449
is_default: bool,
450450
ty: Type,
451451
complexity: syn::LitStr,
452+
aliases: Vec<syn::LitStr>,
452453
}
453454

454455
impl syn::parse::Parse for DeclareVariantsInput {
@@ -464,10 +465,35 @@ impl syn::parse::Parse for DeclareVariantsInput {
464465
let ty: Type = input.parse()?;
465466
input.parse::<syn::Token![=>]>()?;
466467
let complexity: syn::LitStr = input.parse()?;
468+
469+
// Optional: `aliases ["X", "Y", ...]`
470+
let aliases = if input.peek(syn::Ident) {
471+
let ident: syn::Ident = input.fork().parse()?;
472+
if ident == "aliases" {
473+
input.parse::<syn::Ident>()?;
474+
let content;
475+
syn::bracketed!(content in input);
476+
let mut out = Vec::new();
477+
while !content.is_empty() {
478+
let lit: syn::LitStr = content.parse()?;
479+
out.push(lit);
480+
if content.peek(syn::Token![,]) {
481+
content.parse::<syn::Token![,]>()?;
482+
}
483+
}
484+
out
485+
} else {
486+
Vec::new()
487+
}
488+
} else {
489+
Vec::new()
490+
};
491+
467492
entries.push(DeclareVariantEntry {
468493
is_default,
469494
ty,
470495
complexity,
496+
aliases,
471497
});
472498

473499
if input.peek(syn::Token![,]) {
@@ -552,6 +578,7 @@ fn generate_declare_variants(input: &DeclareVariantsInput) -> syn::Result<TokenS
552578
let ty = &entry.ty;
553579
let complexity_str = entry.complexity.value();
554580
let is_default = entry.is_default;
581+
let alias_lits: Vec<_> = entry.aliases.iter().map(|s| s.value()).collect();
555582

556583
// Parse the complexity expression to validate syntax
557584
let parsed = parser::parse_expr(&complexity_str).map_err(|e| {
@@ -633,6 +660,7 @@ fn generate_declare_variants(input: &DeclareVariantsInput) -> syn::Result<TokenS
633660
complexity: #complexity_str,
634661
complexity_eval_fn: #complexity_eval_fn,
635662
is_default: #is_default,
663+
aliases: &[#(#alias_lits),*],
636664
#dispatch_fields
637665
}
638666
}

src/models/formula/ksat.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -241,8 +241,8 @@ impl<K: KValue> Problem for KSatisfiability<K> {
241241

242242
crate::declare_variants! {
243243
default KSatisfiability<KN> => "2^num_variables",
244-
KSatisfiability<K2> => "num_variables + num_clauses",
245-
KSatisfiability<K3> => "1.307^num_variables",
244+
KSatisfiability<K2> => "num_variables + num_clauses" aliases ["2SAT"],
245+
KSatisfiability<K3> => "1.307^num_variables" aliases ["3SAT"],
246246
}
247247

248248
#[cfg(feature = "example-db")]

src/models/graph/minimum_dominating_set.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,7 @@ inventory::submit! {
244244
1.4969_f64.powf(problem.num_vertices() as f64)
245245
},
246246
is_default: false,
247+
aliases: &[],
247248
factory: |data| {
248249
serde_json::from_value::<Decision<MinimumDominatingSet<SimpleGraph, One>>>(data)
249250
.map(|problem| Box::new(problem) as Box<dyn crate::registry::DynProblem>)

src/registry/mod.rs

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

6464
use std::any::Any;
6565
use std::collections::BTreeMap;

src/registry/variant.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ pub struct VariantEntry {
2222
pub complexity_eval_fn: fn(&dyn Any) -> f64,
2323
/// Whether this entry is the declared default variant for its problem.
2424
pub is_default: bool,
25+
/// Variant-level aliases (e.g., `&["3SAT"]` for `KSatisfiability<K3>`).
26+
///
27+
/// Unlike problem-level aliases (on `ProblemSchemaEntry`), these resolve to a
28+
/// specific reduction-graph node, not just to a canonical problem name. The CLI
29+
/// resolver tries variant-level aliases first and falls back to problem-level.
30+
pub aliases: &'static [&'static str],
2531
/// Factory: deserialize JSON into a boxed dynamic problem.
2632
pub factory: fn(serde_json::Value) -> Result<Box<dyn DynProblem>, serde_json::Error>,
2733
/// Serialize: downcast `&dyn Any` and serialize to JSON.
@@ -58,6 +64,23 @@ pub fn find_variant_entry(
5864
.find(|entry| entry.name == name && entry.variant_map() == *variant)
5965
}
6066

67+
/// Find a variant entry by a variant-level alias (case-insensitive).
68+
///
69+
/// A variant-level alias points at a specific reduction-graph node (e.g., `"3SAT"` →
70+
/// `KSatisfiability` with variant `{k: "K3"}`), unlike problem-level aliases which
71+
/// resolve only to a canonical problem name.
72+
///
73+
/// Returns the matched entry along with its variant map. The first match in registration
74+
/// order wins — duplicate variant-level aliases across problems are a declaration bug.
75+
pub fn find_variant_by_alias(
76+
input: &str,
77+
) -> Option<(&'static VariantEntry, BTreeMap<String, String>)> {
78+
let lower = input.to_lowercase();
79+
let entry = inventory::iter::<VariantEntry>()
80+
.find(|entry| entry.aliases.iter().any(|a| a.to_lowercase() == lower))?;
81+
Some((entry, entry.variant_map()))
82+
}
83+
6184
impl std::fmt::Debug for VariantEntry {
6285
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
6386
f.debug_struct("VariantEntry")

0 commit comments

Comments
 (0)