From 4f032778a33f7085fe9c3bccd3ea0cdf67287a10 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Thu, 26 Feb 2026 19:21:35 +0800 Subject: [PATCH 1/3] fix: CLI UX improvements from issue #86 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Swap `pred to`/`pred from` direction semantics: `pred to X` now shows incoming neighbors (what reduces TO X), `pred from X` shows outgoing (what X reduces FROM) - Rename section labels: "Reduces to" → "Outgoing reductions", "Reduces from" → "Incoming reductions"; use consistent → arrow - Replace key-value variant display with slash notation (e.g., MIS/UnitDiskGraph instead of {graph=UnitDiskGraph, weight=i32}) - Switch variant resolution from positional to value-based matching - Rename CLI flags: --edges → --graph, --bits-m → --m, --bits-n → --n, add --edge-weights for edge-weight problems - Add schema-driven help: `pred create MIS` (no flags) shows problem-specific parameters, types, and examples Closes #86 Co-Authored-By: Claude Opus 4.6 --- problemreductions-cli/src/cli.rs | 79 ++++----- problemreductions-cli/src/commands/create.rs | 116 ++++++++++--- problemreductions-cli/src/commands/graph.rs | 80 +++++---- problemreductions-cli/src/commands/reduce.rs | 6 +- problemreductions-cli/src/main.rs | 4 +- problemreductions-cli/src/problem_name.rs | 58 +++---- problemreductions-cli/tests/cli_tests.rs | 168 +++++++++++-------- 7 files changed, 309 insertions(+), 202 deletions(-) diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 3bb5a2f06..a68ff855c 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -8,14 +8,14 @@ use std::path::PathBuf; version, after_help = "\ Typical workflow: - pred create MIS --edges 0-1,1-2,2-3 -o problem.json + pred create MIS --graph 0-1,1-2,2-3 -o problem.json pred solve problem.json pred evaluate problem.json --config 1,0,1,0 Piping (use - to read from stdin): - pred create MIS --edges 0-1,1-2 | pred solve - - pred create MIS --edges 0-1,1-2 | pred evaluate - --config 1,0,1 - pred create MIS --edges 0-1,1-2 | pred reduce - --to QUBO + pred create MIS --graph 0-1,1-2 | pred solve - + pred create MIS --graph 0-1,1-2 | pred evaluate - --config 1,0,1 + pred create MIS --graph 0-1,1-2 | pred reduce - --to QUBO JSON output (any command): pred list --json # JSON to stdout @@ -61,22 +61,22 @@ Examples: pred show MIS/UnitDiskGraph # specific graph variant Use `pred list` to see all available problem types and aliases. -Use `pred to MIS --hops 2` to explore outgoing neighbors. -Use `pred from QUBO --hops 1` to explore incoming neighbors.")] +Use `pred to MIS --hops 2` to explore what reduces to MIS. +Use `pred from QUBO --hops 1` to explore what QUBO reduces to.")] Show { /// Problem name or alias (e.g., MIS, QUBO, MIS/UnitDiskGraph) #[arg(value_parser = crate::problem_name::ProblemNameParser)] problem: String, }, - /// Explore outgoing neighbors in the reduction graph (problems this reduces TO) + /// Explore problems that reduce TO this one (incoming neighbors) #[command(after_help = "\ Examples: - pred to MIS # 1-hop outgoing neighbors - pred to MIS --hops 2 # 2-hop outgoing neighbors + pred to MIS # what reduces to MIS? (1 hop) + pred to MIS --hops 2 # 2-hop incoming neighbors pred to MIS -o out.json # save as JSON -Use `pred from ` for incoming neighbors.")] +Use `pred from ` for outgoing neighbors (what this reduces to).")] To { /// Problem name or alias (e.g., MIS, QUBO, MIS/UnitDiskGraph) #[arg(value_parser = crate::problem_name::ProblemNameParser)] @@ -86,14 +86,14 @@ Use `pred from ` for incoming neighbors.")] hops: usize, }, - /// Explore incoming neighbors in the reduction graph (problems that reduce FROM this) + /// Explore problems this reduces to, starting FROM it (outgoing neighbors) #[command(after_help = "\ Examples: - pred from QUBO # 1-hop incoming neighbors - pred from QUBO --hops 2 # 2-hop incoming neighbors - pred from QUBO -o in.json # save as JSON + pred from MIS # what does MIS reduce to? (1 hop) + pred from MIS --hops 2 # 2-hop outgoing neighbors + pred from MIS -o out.json # save as JSON -Use `pred to ` for outgoing neighbors.")] +Use `pred to ` for incoming neighbors (what reduces to this).")] From { /// Problem name or alias (e.g., MIS, QUBO, MIS/UnitDiskGraph) #[arg(value_parser = crate::problem_name::ProblemNameParser)] @@ -146,7 +146,7 @@ Examples: Examples: pred inspect problem.json pred inspect bundle.json - pred create MIS --edges 0-1,1-2 | pred inspect -")] + pred create MIS --graph 0-1,1-2 | pred inspect -")] Inspect(InspectArgs), /// Solve a problem instance Solve(SolveArgs), @@ -197,23 +197,7 @@ Setup: add one line to your shell rc file: #[derive(clap::Args)] #[command(after_help = "\ -Options by problem type: - Graph problems (MIS, MVC, MaxCut, MaxClique, ...): - --edges Edge list, e.g., 0-1,1-2,2-3 [required] - --weights Vertex weights, e.g., 2,1,3,1 [default: all 1s] - SAT problems (SAT, 3SAT, KSAT): - --num-vars Number of variables [required] - --clauses Semicolon-separated clauses, e.g., \"1,2;-1,3\" [required] - QUBO: - --matrix Semicolon-separated rows, e.g., \"1,0.5;0.5,2\" [required] - KColoring: - --edges Edge list [required] - --k Number of colors [required] - -Factoring: - --target Number to factor [required] - --bits-m Bits for first factor [required] - --bits-n Bits for second factor [required] +Run `pred create ` without arguments to see problem-specific parameters. Random generation (graph-based problems only): --random Generate a random Erdos-Renyi graph instance @@ -222,14 +206,14 @@ Random generation (graph-based problems only): --seed Random seed for reproducibility Examples: - pred create MIS --edges 0-1,1-2,2-3 -o problem.json - pred create MIS --edges 0-1,1-2 --weights 2,1,3 -o weighted.json + pred create MIS --graph 0-1,1-2,2-3 -o problem.json + pred create MIS --graph 0-1,1-2 --weights 2,1,3 -o weighted.json pred create SAT --num-vars 3 --clauses \"1,2;-1,3\" -o sat.json pred create QUBO --matrix \"1,0.5;0.5,2\" -o qubo.json - pred create KColoring --k 3 --edges 0-1,1-2,2-0 -o kcol.json + pred create KColoring --k 3 --graph 0-1,1-2,2-0 -o kcol.json + pred create MaxCut --graph 0-1,1-2 --edge-weights 2,3 pred create MIS --random --num-vertices 10 --edge-prob 0.3 - pred create MIS --random --num-vertices 10 --seed 42 -o big.json - pred create Factoring --target 15 --bits-m 4 --bits-n 4 + pred create Factoring --target 15 --m 4 --n 4 Output (`-o`) uses the standard problem JSON format: {\"type\": \"...\", \"variant\": {...}, \"data\": {...}}")] @@ -237,12 +221,15 @@ pub struct CreateArgs { /// Problem type (e.g., MIS, QUBO, SAT) #[arg(value_parser = crate::problem_name::ProblemNameParser)] pub problem: String, - /// Edges for graph problems (e.g., 0-1,1-2,2-3) + /// Graph edge list (e.g., 0-1,1-2,2-3) #[arg(long)] - pub edges: Option, + pub graph: Option, /// Vertex weights (e.g., 1,1,1,1) [default: all 1s] #[arg(long)] pub weights: Option, + /// Edge weights (e.g., 2,3,1) [default: all 1s] + #[arg(long)] + pub edge_weights: Option, /// Clauses for SAT problems (semicolon-separated, e.g., "1,2;-1,3") #[arg(long)] pub clauses: Option, @@ -272,10 +259,10 @@ pub struct CreateArgs { pub target: Option, /// Bits for first factor (for Factoring) #[arg(long)] - pub bits_m: Option, + pub m: Option, /// Bits for second factor (for Factoring) #[arg(long)] - pub bits_n: Option, + pub n: Option, } #[derive(clap::Args)] @@ -285,11 +272,11 @@ Examples: pred solve problem.json --solver brute-force # brute-force (exhaustive search) pred solve reduced.json # solve a reduction bundle pred solve reduced.json -o solution.json # save result to file - pred create MIS --edges 0-1,1-2 | pred solve - # read from stdin + pred create MIS --graph 0-1,1-2 | pred solve - # read from stdin pred solve problem.json --timeout 10 # abort after 10 seconds Typical workflow: - pred create MIS --edges 0-1,1-2,2-3 -o problem.json + pred create MIS --graph 0-1,1-2,2-3 -o problem.json pred solve problem.json Solve via explicit reduction: @@ -321,7 +308,7 @@ Examples: pred reduce problem.json --to QUBO -o reduced.json pred reduce problem.json --to ILP -o reduced.json pred reduce problem.json --via path.json -o reduced.json - pred create MIS --edges 0-1,1-2 | pred reduce - --to QUBO # read from stdin + pred create MIS --graph 0-1,1-2 | pred reduce - --to QUBO # read from stdin Input: a problem JSON from `pred create`. Use - to read from stdin. The --via path file is from `pred path -o path.json`. @@ -350,7 +337,7 @@ pub struct InspectArgs { Examples: pred evaluate problem.json --config 1,0,1,0 pred evaluate problem.json --config 1,0,1,0 -o result.json - pred create MIS --edges 0-1,1-2 | pred evaluate - --config 1,0,1 # read from stdin + pred create MIS --graph 0-1,1-2 | pred evaluate - --config 1,0,1 # read from stdin Input: a problem JSON from `pred create`. Use - to read from stdin.")] pub struct EvaluateArgs { diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 139bbcc21..93f3f0821 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -4,11 +4,86 @@ use crate::output::OutputConfig; use crate::problem_name::resolve_alias; use anyhow::{bail, Context, Result}; use problemreductions::prelude::*; +use problemreductions::registry::collect_schemas; use problemreductions::topology::{Graph, SimpleGraph}; use problemreductions::variant::{K2, K3, KN}; use serde::Serialize; use std::collections::BTreeMap; +/// Check if all data flags are None (no problem-specific input provided). +fn all_data_flags_empty(args: &CreateArgs) -> bool { + args.graph.is_none() + && args.weights.is_none() + && args.edge_weights.is_none() + && args.clauses.is_none() + && args.num_vars.is_none() + && args.matrix.is_none() + && args.k.is_none() + && args.target.is_none() + && args.m.is_none() + && args.n.is_none() +} + +fn type_format_hint(type_name: &str) -> &'static str { + match type_name { + "G" => "edge list: 0-1,1-2,2-3", + "Vec" => "comma-separated: 1,2,3", + "Vec" => "semicolon-separated clauses: \"1,2;-1,3\"", + "Vec>" => "semicolon-separated rows: \"1,0.5;0.5,2\"", + "usize" => "integer", + "u64" => "integer", + _ => "value", + } +} + +fn example_for(canonical: &str) -> &'static str { + match canonical { + "MaximumIndependentSet" + | "MinimumVertexCover" + | "MaximumClique" + | "MinimumDominatingSet" => "--graph 0-1,1-2,2-3 --weights 1,1,1,1", + "MaxCut" | "MaximumMatching" | "TravelingSalesman" => { + "--graph 0-1,1-2,2-3 --edge-weights 1,1,1" + } + "Satisfiability" => "--num-vars 3 --clauses \"1,2;-1,3\"", + "KSatisfiability" => "--num-vars 3 --clauses \"1,2,3;-1,2,-3\" --k 3", + "QUBO" => "--matrix \"1,0.5;0.5,2\"", + "SpinGlass" => "--graph 0-1,1-2 --edge-weights 1,1", + "KColoring" => "--graph 0-1,1-2,2-0 --k 3", + "Factoring" => "--target 15 --m 4 --n 4", + _ => "", + } +} + +fn print_problem_help(canonical: &str) -> Result<()> { + let schemas = collect_schemas(); + let schema = schemas.iter().find(|s| s.name == canonical); + + if let Some(s) = schema { + eprintln!("{}\n {}\n", canonical, s.description); + eprintln!("Parameters:"); + for field in &s.fields { + let hint = type_format_hint(&field.type_name); + eprintln!( + " --{:<16} {} ({})", + field.name.replace('_', "-"), + field.description, + hint + ); + } + } else { + eprintln!("{canonical}\n"); + eprintln!("No schema information available."); + } + + let example = example_for(canonical); + if !example.is_empty() { + eprintln!("\nExample:"); + eprintln!(" pred create {} {}", canonical, example); + } + Ok(()) +} + pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { let canonical = resolve_alias(&args.problem); @@ -16,6 +91,11 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { return create_random(args, &canonical, out); } + // Show schema-driven help when no data flags are provided + if all_data_flags_empty(args) { + return print_problem_help(&canonical); + } + let (data, variant) = match canonical.as_str() { // Graph problems with vertex weights "MaximumIndependentSet" @@ -24,7 +104,7 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { | "MinimumDominatingSet" => { let (graph, n) = parse_graph(args).map_err(|e| { anyhow::anyhow!( - "{e}\n\nUsage: pred create {} --edges 0-1,1-2,2-3 [--weights 1,1,1,1] --json -o problem.json", + "{e}\n\nUsage: pred create {} --graph 0-1,1-2,2-3 [--weights 1,1,1,1]", args.problem ) })?; @@ -44,7 +124,7 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { "MaxCut" | "MaximumMatching" | "TravelingSalesman" => { let (graph, _) = parse_graph(args).map_err(|e| { anyhow::anyhow!( - "{e}\n\nUsage: pred create {} --edges 0-1,1-2,2-3 [--weights 1,1,1] --json -o problem.json", + "{e}\n\nUsage: pred create {} --graph 0-1,1-2,2-3 [--edge-weights 1,1,1]", args.problem ) })?; @@ -62,9 +142,7 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { // KColoring "KColoring" => { let (graph, _) = parse_graph(args).map_err(|e| { - anyhow::anyhow!( - "{e}\n\nUsage: pred create KColoring --edges 0-1,1-2,2-0 --k 3 --json -o problem.json" - ) + anyhow::anyhow!("{e}\n\nUsage: pred create KColoring --graph 0-1,1-2,2-0 --k 3") })?; let variant; let data; @@ -83,7 +161,7 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { } None => bail!( "KColoring requires --k \n\n\ - Usage: pred create KColoring --edges 0-1,1-2,2-0 --k 3 --json -o problem.json" + Usage: pred create KColoring --graph 0-1,1-2,2-0 --k 3" ), } (data, variant) @@ -94,7 +172,7 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { let num_vars = args.num_vars.ok_or_else(|| { anyhow::anyhow!( "Satisfiability requires --num-vars\n\n\ - Usage: pred create SAT --num-vars 3 --clauses \"1,2;-1,3\" --json -o problem.json" + Usage: pred create SAT --num-vars 3 --clauses \"1,2;-1,3\"" ) })?; let clauses = parse_clauses(args)?; @@ -105,7 +183,7 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { let num_vars = args.num_vars.ok_or_else(|| { anyhow::anyhow!( "KSatisfiability requires --num-vars\n\n\ - Usage: pred create 3SAT --num-vars 3 --clauses \"1,2,3;-1,2,-3\" --json -o problem.json" + Usage: pred create 3SAT --num-vars 3 --clauses \"1,2,3;-1,2,-3\"" ) })?; let clauses = parse_clauses(args)?; @@ -139,7 +217,7 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { "SpinGlass" => { let (graph, n) = parse_graph(args).map_err(|e| { anyhow::anyhow!( - "{e}\n\nUsage: pred create SpinGlass --edges 0-1,1-2 [--weights 1,1] --json -o problem.json" + "{e}\n\nUsage: pred create SpinGlass --graph 0-1,1-2 [--edge-weights 1,1]" ) })?; let edge_weights = parse_edge_weights(args, graph.num_edges())?; @@ -154,16 +232,16 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { // Factoring "Factoring" => { - let usage = "Usage: pred create Factoring --target 15 --bits-m 4 --bits-n 4"; + let usage = "Usage: pred create Factoring --target 15 --m 4 --n 4"; let target = args .target .ok_or_else(|| anyhow::anyhow!("Factoring requires --target\n\n{usage}"))?; let m = args - .bits_m - .ok_or_else(|| anyhow::anyhow!("Factoring requires --bits-m\n\n{usage}"))?; + .m + .ok_or_else(|| anyhow::anyhow!("Factoring requires --m\n\n{usage}"))?; let n = args - .bits_n - .ok_or_else(|| anyhow::anyhow!("Factoring requires --bits-n\n\n{usage}"))?; + .n + .ok_or_else(|| anyhow::anyhow!("Factoring requires --n\n\n{usage}"))?; let variant = BTreeMap::new(); (ser(Factoring::new(m, n, target))?, variant) } @@ -202,12 +280,12 @@ fn variant_map(pairs: &[(&str, &str)]) -> BTreeMap { .collect() } -/// Parse `--edges` into a SimpleGraph, inferring num_vertices from max index. +/// Parse `--graph` into a SimpleGraph, inferring num_vertices from max index. fn parse_graph(args: &CreateArgs) -> Result<(SimpleGraph, usize)> { let edges_str = args - .edges + .graph .as_deref() - .ok_or_else(|| anyhow::anyhow!("This problem requires --edges (e.g., 0-1,1-2,2-3)"))?; + .ok_or_else(|| anyhow::anyhow!("This problem requires --graph (e.g., 0-1,1-2,2-3)"))?; let edges: Vec<(usize, usize)> = edges_str .split(',') @@ -253,9 +331,9 @@ fn parse_vertex_weights(args: &CreateArgs, num_vertices: usize) -> Result Result> { - match &args.weights { + match &args.edge_weights { Some(w) => { let weights: Vec = w .split(',') diff --git a/problemreductions-cli/src/commands/graph.rs b/problemreductions-cli/src/commands/graph.rs index 65378b84b..f80438dcc 100644 --- a/problemreductions-cli/src/commands/graph.rs +++ b/problemreductions-cli/src/commands/graph.rs @@ -117,8 +117,18 @@ pub fn show(problem: &str, out: &OutputConfig) -> Result<()> { "\n{}\n", crate::output::fmt_section(&format!("Variants ({}):", variants.len())) )); + let default_variant = variants.first().cloned().unwrap_or_default(); for v in &variants { - text.push_str(&format!(" {}\n", format_variant(v))); + let slash = variant_to_slash(v, &default_variant); + let label = if slash.is_empty() { + format!(" {}", crate::output::fmt_problem_name(&spec.name)) + } else { + format!( + " {}", + crate::output::fmt_problem_name(&format!("{}{}", spec.name, slash)) + ) + }; + text.push_str(&format!("{label}\n")); } // Show fields from schema (right after variants) @@ -154,27 +164,27 @@ pub fn show(problem: &str, out: &OutputConfig) -> Result<()> { text.push_str(&format!( "\n{}\n", - crate::output::fmt_section(&format!("Reduces to ({}):", outgoing.len())) + crate::output::fmt_section(&format!("Outgoing reductions ({}):", outgoing.len())) )); for e in &outgoing { text.push_str(&format!( " {} {} {}\n", - fmt_node(e.source_name, &e.source_variant), + fmt_node(&graph, e.source_name, &e.source_variant), crate::output::fmt_outgoing("\u{2192}"), - fmt_node(e.target_name, &e.target_variant), + fmt_node(&graph, e.target_name, &e.target_variant), )); } text.push_str(&format!( "\n{}\n", - crate::output::fmt_section(&format!("Reduces from ({}):", incoming.len())) + crate::output::fmt_section(&format!("Incoming reductions ({}):", incoming.len())) )); for e in &incoming { text.push_str(&format!( " {} {} {}\n", - fmt_node(e.target_name, &e.target_variant), - crate::output::fmt_outgoing("\u{2190}"), - fmt_node(e.source_name, &e.source_variant), + fmt_node(&graph, e.source_name, &e.source_variant), + crate::output::fmt_outgoing("\u{2192}"), + fmt_node(&graph, e.target_name, &e.target_variant), )); } @@ -199,23 +209,35 @@ pub fn show(problem: &str, out: &OutputConfig) -> Result<()> { out.emit_with_default_name(&default_name, &text, &json) } -fn format_variant(v: &BTreeMap) -> String { - if v.is_empty() { - "(default)".to_string() +/// Convert a variant BTreeMap to slash notation showing only non-default values. +/// Given default {graph: "SimpleGraph", weight: "i32"} and variant {graph: "UnitDiskGraph", weight: "i32"}, +/// returns "/UnitDiskGraph". +fn variant_to_slash( + variant: &BTreeMap, + default: &BTreeMap, +) -> String { + let diffs: Vec<&str> = variant + .iter() + .filter(|(k, v)| default.get(*k).map_or(true, |dv| dv != *v)) + .map(|(_, v)| v.as_str()) + .collect(); + if diffs.is_empty() { + String::new() } else { - let pairs: Vec = v.iter().map(|(k, val)| format!("{k}={val}")).collect(); - format!("{{{}}}", pairs.join(", ")) + format!("/{}", diffs.join("/")) } } -/// Format a problem node as **bold name** + plain variant. -/// This is the single source of truth for "name {variant}" display. -fn fmt_node(name: &str, variant: &BTreeMap) -> String { - format!( - "{} {}", - crate::output::fmt_problem_name(name), - format_variant(variant), - ) +/// Format a problem node as **bold name/variant** in slash notation. +/// This is the single source of truth for "name/variant" display. +fn fmt_node(graph: &ReductionGraph, name: &str, variant: &BTreeMap) -> String { + let default = graph + .variants_for(name) + .first() + .cloned() + .unwrap_or_default(); + let slash = variant_to_slash(variant, &default); + crate::output::fmt_problem_name(&format!("{name}{slash}")) } fn format_path_text( @@ -229,7 +251,7 @@ fn format_path_text( let mut prev_name = ""; for step in steps { if step.name != prev_name { - parts.push(fmt_node(&step.name, &step.variant)); + parts.push(fmt_node(graph, &step.name, &step.variant)); prev_name = &step.name; } } @@ -245,9 +267,9 @@ fn format_path_text( text.push_str(&format!( "\n {}: {} {} {}\n", crate::output::fmt_section(&format!("Step {}", i + 1)), - fmt_node(&from.name, &from.variant), + fmt_node(graph, &from.name, &from.variant), crate::output::fmt_outgoing("→"), - fmt_node(&to.name, &to.variant), + fmt_node(graph, &to.name, &to.variant), )); let oh = &overheads[i]; for (field, poly) in &oh.output_size { @@ -540,7 +562,7 @@ pub fn neighbors( // Build tree structure via BFS with parent tracking let tree = graph.k_neighbor_tree(&spec.name, &variant, max_hops, direction); - let root_label = fmt_node(&spec.name, &variant); + let root_label = fmt_node(&graph, &spec.name, &variant); let mut text = format!( "{} — {}-hop neighbors ({})\n\n", @@ -551,7 +573,7 @@ pub fn neighbors( text.push_str(&root_label); text.push('\n'); - render_tree(&tree, &mut text, ""); + render_tree(&graph, &tree, &mut text, ""); // Count unique problem names let unique_names: HashSet<&str> = neighbors.iter().map(|n| n.name).collect(); @@ -581,7 +603,7 @@ pub fn neighbors( use problemreductions::rules::NeighborTree; /// Render a tree with box-drawing characters. -fn render_tree(nodes: &[NeighborTree], text: &mut String, prefix: &str) { +fn render_tree(graph: &ReductionGraph, nodes: &[NeighborTree], text: &mut String, prefix: &str) { for (i, node) in nodes.iter().enumerate() { let is_last = i == nodes.len() - 1; let connector = if is_last { "└── " } else { "├── " }; @@ -591,12 +613,12 @@ fn render_tree(nodes: &[NeighborTree], text: &mut String, prefix: &str) { "{}{}{}\n", crate::output::fmt_dim(prefix), crate::output::fmt_dim(connector), - fmt_node(&node.name, &node.variant), + fmt_node(graph, &node.name, &node.variant), )); if !node.children.is_empty() { let new_prefix = format!("{}{}", prefix, child_prefix); - render_tree(&node.children, text, &new_prefix); + render_tree(graph, &node.children, text, &new_prefix); } } } diff --git a/problemreductions-cli/src/commands/reduce.rs b/problemreductions-cli/src/commands/reduce.rs index 211560d82..f849f0e43 100644 --- a/problemreductions-cli/src/commands/reduce.rs +++ b/problemreductions-cli/src/commands/reduce.rs @@ -207,9 +207,9 @@ pub fn reduce( fn format_variant(v: &BTreeMap) -> String { if v.is_empty() { - "(default)".to_string() + String::new() } else { - let pairs: Vec = v.iter().map(|(k, val)| format!("{k}={val}")).collect(); - format!("{{{}}}", pairs.join(", ")) + let vals: Vec<&str> = v.values().map(|v| v.as_str()).collect(); + format!("/{}", vals.join("/")) } } diff --git a/problemreductions-cli/src/main.rs b/problemreductions-cli/src/main.rs index 0bab1cb3d..e2386c829 100644 --- a/problemreductions-cli/src/main.rs +++ b/problemreductions-cli/src/main.rs @@ -37,8 +37,8 @@ fn main() -> anyhow::Result<()> { match cli.command { Commands::List => commands::graph::list(&out), Commands::Show { problem } => commands::graph::show(&problem, &out), - Commands::To { problem, hops } => commands::graph::neighbors(&problem, hops, "out", &out), - Commands::From { problem, hops } => commands::graph::neighbors(&problem, hops, "in", &out), + Commands::To { problem, hops } => commands::graph::neighbors(&problem, hops, "in", &out), + Commands::From { problem, hops } => commands::graph::neighbors(&problem, hops, "out", &out), Commands::Path { source, target, diff --git a/problemreductions-cli/src/problem_name.rs b/problemreductions-cli/src/problem_name.rs index e40fac2ab..ad64e7bba 100644 --- a/problemreductions-cli/src/problem_name.rs +++ b/problemreductions-cli/src/problem_name.rs @@ -79,8 +79,9 @@ pub fn parse_problem_spec(input: &str) -> anyhow::Result { }) } -/// Build a variant BTreeMap by matching positional values against a problem's -/// known variant keys from the reduction graph. +/// Build a variant BTreeMap by matching specified values against a problem's +/// known variants from the reduction graph. Uses value-based matching: +/// each specified value must appear as a value in the variant map. pub fn resolve_variant( spec: &ProblemSpec, known_variants: &[BTreeMap], @@ -90,40 +91,31 @@ pub fn resolve_variant( return Ok(known_variants.first().cloned().unwrap_or_default()); } - // Get the variant keys from the first known variant - let keys: Vec = known_variants - .first() - .map(|v| v.keys().cloned().collect()) - .unwrap_or_default(); - - if spec.variant_values.len() > keys.len() { - anyhow::bail!( - "Too many variant values for {}: expected at most {} but got {}", - spec.name, - keys.len(), - spec.variant_values.len() - ); - } - - // Build the variant map: fill specified positions, use defaults for the rest - let mut result = known_variants.first().cloned().unwrap_or_default(); - for (i, value) in spec.variant_values.iter().enumerate() { - if let Some(key) = keys.get(i) { - result.insert(key.clone(), value.clone()); - } - } - - // Verify this variant exists - if !known_variants.contains(&result) { - anyhow::bail!( - "Unknown variant for {}: {:?}. Known variants: {:?}", + // Value-based matching: find variant containing ALL specified values + let matches: Vec<_> = known_variants + .iter() + .filter(|v| { + spec.variant_values + .iter() + .all(|sv| v.values().any(|vv| vv == sv)) + }) + .collect(); + + match matches.len() { + 1 => Ok(matches[0].clone()), + 0 => anyhow::bail!( + "No variant of {} matches values {:?}. Known variants: {:?}", spec.name, - result, + spec.variant_values, known_variants - ); + ), + _ => anyhow::bail!( + "Ambiguous variant for {} with values {:?}. Matches: {:?}", + spec.name, + spec.variant_values, + matches + ), } - - Ok(result) } /// A value parser that accepts any string but provides problem names as diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index 54bcfa5d7..dd0395cf1 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -27,7 +27,7 @@ fn test_show() { assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); assert!(stdout.contains("MaximumIndependentSet")); - assert!(stdout.contains("Reduces to")); + assert!(stdout.contains("Outgoing reductions")); } #[test] @@ -294,7 +294,7 @@ fn test_reduce_via_path() { problem_file.to_str().unwrap(), "create", "MIS", - "--edges", + "--graph", "0-1,1-2,2-3", ]) .output() @@ -351,7 +351,7 @@ fn test_reduce_via_infer_target() { problem_file.to_str().unwrap(), "create", "MIS", - "--edges", + "--graph", "0-1,1-2,2-3", ]) .output() @@ -402,7 +402,7 @@ fn test_reduce_missing_to_and_via() { problem_file.to_str().unwrap(), "create", "MIS", - "--edges", + "--graph", "0-1", ]) .output() @@ -429,7 +429,7 @@ fn test_create_mis() { output_file.to_str().unwrap(), "create", "MIS", - "--edges", + "--graph", "0-1,1-2,2-3", ]) .output() @@ -459,7 +459,7 @@ fn test_create_then_evaluate() { problem_file.to_str().unwrap(), "create", "MIS", - "--edges", + "--graph", "0-1,1-2,2-3", "--weights", "1,1,1,1", @@ -563,7 +563,7 @@ fn test_solve_brute_force() { problem_file.to_str().unwrap(), "create", "MIS", - "--edges", + "--graph", "0-1,1-2", ]) .output() @@ -600,7 +600,7 @@ fn test_solve_ilp() { problem_file.to_str().unwrap(), "create", "MIS", - "--edges", + "--graph", "0-1,1-2", ]) .output() @@ -637,7 +637,7 @@ fn test_solve_ilp_default() { problem_file.to_str().unwrap(), "create", "MIS", - "--edges", + "--graph", "0-1,1-2", ]) .output() @@ -672,7 +672,7 @@ fn test_solve_ilp_shows_via_ilp() { problem_file.to_str().unwrap(), "create", "MIS", - "--edges", + "--graph", "0-1,1-2", ]) .output() @@ -708,7 +708,7 @@ fn test_solve_json_output() { problem_file.to_str().unwrap(), "create", "MIS", - "--edges", + "--graph", "0-1,1-2", ]) .output() @@ -754,7 +754,7 @@ fn test_solve_bundle() { problem_file.to_str().unwrap(), "create", "MIS", - "--edges", + "--graph", "0-1,1-2", ]) .output() @@ -814,7 +814,7 @@ fn test_solve_bundle_ilp() { problem_file.to_str().unwrap(), "create", "MIS", - "--edges", + "--graph", "0-1,1-2", ]) .output() @@ -864,7 +864,7 @@ fn test_solve_unknown_solver() { problem_file.to_str().unwrap(), "create", "MIS", - "--edges", + "--graph", "0-1,1-2", ]) .output() @@ -898,7 +898,7 @@ fn test_create_maxcut() { output_file.to_str().unwrap(), "create", "MaxCut", - "--edges", + "--graph", "0-1,1-2,2-0", ]) .output() @@ -923,7 +923,7 @@ fn test_create_mvc() { output_file.to_str().unwrap(), "create", "MVC", - "--edges", + "--graph", "0-1,1-2", ]) .output() @@ -948,7 +948,7 @@ fn test_create_kcoloring() { output_file.to_str().unwrap(), "create", "KColoring", - "--edges", + "--graph", "0-1,1-2,2-0", "--k", "3", @@ -975,7 +975,7 @@ fn test_create_spinglass() { output_file.to_str().unwrap(), "create", "SpinGlass", - "--edges", + "--graph", "0-1,1-2", ]) .output() @@ -1029,7 +1029,7 @@ fn test_create_maximum_matching() { output_file.to_str().unwrap(), "create", "MaximumMatching", - "--edges", + "--graph", "0-1,1-2,2-3", ]) .output() @@ -1054,9 +1054,9 @@ fn test_create_with_edge_weights() { output_file.to_str().unwrap(), "create", "MaxCut", - "--edges", + "--graph", "0-1,1-2,2-0", - "--weights", + "--edge-weights", "2,3,1", ]) .output() @@ -1073,7 +1073,7 @@ fn test_create_with_edge_weights() { fn test_create_without_output() { // Create without -o prints JSON to stdout (not just "Created ...") let output = pred() - .args(["create", "MIS", "--edges", "0-1,1-2"]) + .args(["create", "MIS", "--graph", "0-1,1-2"]) .output() .unwrap(); assert!( @@ -1092,24 +1092,40 @@ fn test_create_without_output() { #[test] fn test_create_unknown_problem() { let output = pred() - .args(["create", "NonExistent", "--edges", "0-1"]) + .args(["create", "NonExistent", "--graph", "0-1"]) .output() .unwrap(); assert!(!output.status.success()); } #[test] -fn test_create_missing_edges() { +fn test_create_no_flags_shows_help() { + // pred create MIS with no data flags shows schema-driven help let output = pred().args(["create", "MIS"]).output().unwrap(); - assert!(!output.status.success()); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("--edges")); + assert!( + stderr.contains("--graph"), + "expected '--graph' in help output, got: {stderr}" + ); + assert!( + stderr.contains("--weights"), + "expected '--weights' in help output, got: {stderr}" + ); + assert!( + stderr.contains("Example:"), + "expected 'Example:' in help output, got: {stderr}" + ); } #[test] fn test_create_kcoloring_missing_k() { let output = pred() - .args(["create", "KColoring", "--edges", "0-1,1-2"]) + .args(["create", "KColoring", "--graph", "0-1,1-2"]) .output() .unwrap(); assert!(!output.status.success()); @@ -1126,7 +1142,7 @@ fn test_evaluate_wrong_config_length() { problem_file.to_str().unwrap(), "create", "MIS", - "--edges", + "--graph", "0-1,1-2", ]) .output() @@ -1159,7 +1175,7 @@ fn test_evaluate_json_output() { problem_file.to_str().unwrap(), "create", "MIS", - "--edges", + "--graph", "0-1,1-2", ]) .output() @@ -1287,7 +1303,7 @@ fn test_reduce_unknown_target() { problem_file.to_str().unwrap(), "create", "MIS", - "--edges", + "--graph", "0-1", ]) .output() @@ -1318,7 +1334,7 @@ fn test_reduce_stdout() { problem_file.to_str().unwrap(), "create", "MIS", - "--edges", + "--graph", "0-1,1-2", ]) .output() @@ -1358,7 +1374,7 @@ fn test_reduce_human_output() { problem_file.to_str().unwrap(), "create", "MIS", - "--edges", + "--graph", "0-1,1-2", ]) .output() @@ -1409,7 +1425,7 @@ fn test_solve_no_hint_when_piped() { problem_file.to_str().unwrap(), "create", "MIS", - "--edges", + "--graph", "0-1,1-2", ]) .output() @@ -1468,7 +1484,7 @@ fn test_solve_bundle_no_hint_when_piped() { problem_file.to_str().unwrap(), "create", "MIS", - "--edges", + "--graph", "0-1,1-2", ]) .output() @@ -1569,7 +1585,8 @@ fn test_completions_auto_detect() { // ---- k-neighbor exploration tests (pred to / pred from) ---- #[test] -fn test_to_outgoing() { +fn test_to_incoming() { + // `pred to MIS` shows what reduces TO MIS (incoming neighbors) let output = pred().args(["to", "MIS", "--hops", "2"]).output().unwrap(); assert!( output.status.success(), @@ -1578,15 +1595,17 @@ fn test_to_outgoing() { ); let stdout = String::from_utf8(output.stdout).unwrap(); assert!(stdout.contains("MaximumIndependentSet")); + assert!(stdout.contains("incoming")); assert!(stdout.contains("reachable problems")); // Should contain tree characters assert!(stdout.contains("├── ") || stdout.contains("└── ")); } #[test] -fn test_from_incoming() { +fn test_from_outgoing() { + // `pred from MIS` shows what MIS reduces to (outgoing neighbors) let output = pred() - .args(["from", "QUBO", "--hops", "1"]) + .args(["from", "MIS", "--hops", "1"]) .output() .unwrap(); assert!( @@ -1595,8 +1614,8 @@ fn test_from_incoming() { String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8(output.stdout).unwrap(); - assert!(stdout.contains("QUBO")); - assert!(stdout.contains("incoming")); + assert!(stdout.contains("MaximumIndependentSet")); + assert!(stdout.contains("outgoing")); } #[test] @@ -1625,17 +1644,17 @@ fn test_to_shows_variant_info() { String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8(output.stdout).unwrap(); - // Variant info should appear in the tree output + // Slash notation: either base name or Name/Variant assert!( - stdout.contains("{graph=") || stdout.contains("(default)"), - "expected variant info in tree output, got: {stdout}" + stdout.contains("MaximumIndependentSet"), + "expected problem name in tree output, got: {stdout}" ); } #[test] fn test_from_shows_variant_info() { let output = pred() - .args(["from", "QUBO", "--hops", "1"]) + .args(["from", "MIS", "--hops", "1"]) .output() .unwrap(); assert!( @@ -1644,10 +1663,10 @@ fn test_from_shows_variant_info() { String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8(output.stdout).unwrap(); - // Variant info should appear in the tree output + // Slash notation: either base name or Name/Variant assert!( - stdout.contains("{graph=") || stdout.contains("{weight=") || stdout.contains("(default)"), - "expected variant info in tree output, got: {stdout}" + stdout.contains("MaximumIndependentSet"), + "expected problem name in tree output, got: {stdout}" ); } @@ -1679,7 +1698,7 @@ fn test_quiet_suppresses_hints() { problem_file.to_str().unwrap(), "create", "MIS", - "--edges", + "--graph", "0-1,1-2", ]) .output() @@ -1721,7 +1740,7 @@ fn test_quiet_suppresses_wrote() { output_file.to_str().unwrap(), "create", "MIS", - "--edges", + "--graph", "0-1,1-2", ]) .output() @@ -1751,7 +1770,7 @@ fn test_quiet_still_shows_stdout() { problem_file.to_str().unwrap(), "create", "MIS", - "--edges", + "--graph", "0-1,1-2", ]) .output() @@ -1786,9 +1805,9 @@ fn test_quiet_still_shows_stdout() { #[test] fn test_create_pipe_to_solve() { - // pred create MIS --edges 0-1,1-2 | pred solve - --solver brute-force + // pred create MIS --graph 0-1,1-2 | pred solve - --solver brute-force let create_out = pred() - .args(["create", "MIS", "--edges", "0-1,1-2"]) + .args(["create", "MIS", "--graph", "0-1,1-2"]) .output() .unwrap(); assert!( @@ -1826,9 +1845,9 @@ fn test_create_pipe_to_solve() { #[test] fn test_create_pipe_to_evaluate() { - // pred create MIS --edges 0-1,1-2 | pred evaluate - --config 1,0,1 + // pred create MIS --graph 0-1,1-2 | pred evaluate - --config 1,0,1 let create_out = pred() - .args(["create", "MIS", "--edges", "0-1,1-2"]) + .args(["create", "MIS", "--graph", "0-1,1-2"]) .output() .unwrap(); assert!( @@ -1866,9 +1885,9 @@ fn test_create_pipe_to_evaluate() { #[test] fn test_create_pipe_to_reduce() { - // pred create MIS --edges 0-1,1-2 | pred reduce - --to QUBO + // pred create MIS --graph 0-1,1-2 | pred reduce - --to QUBO let create_out = pred() - .args(["create", "MIS", "--edges", "0-1,1-2"]) + .args(["create", "MIS", "--graph", "0-1,1-2"]) .output() .unwrap(); assert!( @@ -1916,7 +1935,7 @@ fn test_inspect_problem() { problem_file.to_str().unwrap(), "create", "MIS", - "--edges", + "--graph", "0-1,1-2,2-3", ]) .output() @@ -1968,7 +1987,7 @@ fn test_inspect_bundle() { problem_file.to_str().unwrap(), "create", "MIS", - "--edges", + "--graph", "0-1,1-2", ]) .output() @@ -2027,7 +2046,7 @@ fn test_inspect_bundle() { fn test_inspect_stdin() { // Test pipe: create | inspect - let create_out = pred() - .args(["create", "MIS", "--edges", "0-1,1-2"]) + .args(["create", "MIS", "--graph", "0-1,1-2"]) .output() .unwrap(); assert!(create_out.status.success()); @@ -2069,7 +2088,7 @@ fn test_inspect_json_output() { problem_file.to_str().unwrap(), "create", "MIS", - "--edges", + "--graph", "0-1,1-2,2-3", ]) .output() @@ -2369,9 +2388,9 @@ fn test_create_factoring() { "Factoring", "--target", "15", - "--bits-m", + "--m", "4", - "--bits-n", + "--n", "4", ]) .output() @@ -2398,9 +2417,9 @@ fn test_create_factoring_with_bits() { "Factoring", "--target", "15", - "--bits-m", + "--m", "4", - "--bits-n", + "--n", "4", ]) .output() @@ -2418,13 +2437,22 @@ fn test_create_factoring_with_bits() { } #[test] -fn test_create_factoring_missing_target() { +fn test_create_factoring_no_flags_shows_help() { + // pred create Factoring with no data flags shows schema-driven help let output = pred().args(["create", "Factoring"]).output().unwrap(); - assert!(!output.status.success()); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); let stderr = String::from_utf8_lossy(&output.stderr); assert!( stderr.contains("--target"), - "expected '--target' in error, got: {stderr}" + "expected '--target' in help output, got: {stderr}" + ); + assert!( + stderr.contains("--m"), + "expected '--m' in help output, got: {stderr}" ); } @@ -2437,8 +2465,8 @@ fn test_create_factoring_missing_bits() { assert!(!output.status.success()); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("--bits-m"), - "expected '--bits-m' in error, got: {stderr}" + stderr.contains("--m"), + "expected '--m' in error, got: {stderr}" ); } @@ -2454,7 +2482,7 @@ fn test_solve_timeout_succeeds() { problem_file.to_str().unwrap(), "create", "MIS", - "--edges", + "--graph", "0-1,1-2", ]) .output() @@ -2496,7 +2524,7 @@ fn test_solve_timeout_zero_means_no_limit() { problem_file.to_str().unwrap(), "create", "MIS", - "--edges", + "--graph", "0-1,1-2", ]) .output() From b074d421944e0b883b70a7911edce76df28c210d Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Fri, 27 Feb 2026 02:52:21 +0800 Subject: [PATCH 2/3] fix: address PR review - include random flags in emptiness check, map schema to CLI flags, fix double spaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Include --num-vertices, --edge-prob, --seed in all_data_flags_empty() so `pred create MIS --num-vertices 10` (without --random) errors instead of showing help - Add schema_to_cli_flag() mapping so schema-driven help shows correct CLI flag names (e.g., SpinGlass "couplings" → --edge-weights) - Remove space before format_variant() in reduce.rs error message to avoid double spaces when variant is empty Co-Authored-By: Claude Opus 4.6 --- problemreductions-cli/src/commands/create.rs | 30 +++++++++++++++----- problemreductions-cli/src/commands/reduce.rs | 2 +- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 93f3f0821..e7de08913 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -22,6 +22,9 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool { && args.target.is_none() && args.m.is_none() && args.n.is_none() + && args.num_vertices.is_none() + && args.edge_prob.is_none() + && args.seed.is_none() } fn type_format_hint(type_name: &str) -> &'static str { @@ -55,6 +58,17 @@ fn example_for(canonical: &str) -> &'static str { } } +/// Map schema field names to CLI flag names where they differ. +/// Returns None to skip a field (no corresponding CLI flag). +fn schema_to_cli_flag<'a>(problem: &str, field_name: &'a str) -> Option<&'a str> { + match (problem, field_name) { + // SpinGlass schema uses "couplings"/"fields" but CLI uses graph + edge-weights + ("SpinGlass", "couplings") => Some("edge-weights"), + ("SpinGlass", "fields") => None, // no CLI flag for external fields + _ => Some(field_name), + } +} + fn print_problem_help(canonical: &str) -> Result<()> { let schemas = collect_schemas(); let schema = schemas.iter().find(|s| s.name == canonical); @@ -63,13 +77,15 @@ fn print_problem_help(canonical: &str) -> Result<()> { eprintln!("{}\n {}\n", canonical, s.description); eprintln!("Parameters:"); for field in &s.fields { - let hint = type_format_hint(&field.type_name); - eprintln!( - " --{:<16} {} ({})", - field.name.replace('_', "-"), - field.description, - hint - ); + if let Some(flag) = schema_to_cli_flag(canonical, &field.name) { + let hint = type_format_hint(&field.type_name); + eprintln!( + " --{:<16} {} ({})", + flag.replace('_', "-"), + field.description, + hint + ); + } } } else { eprintln!("{canonical}\n"); diff --git a/problemreductions-cli/src/commands/reduce.rs b/problemreductions-cli/src/commands/reduce.rs index f849f0e43..2f4bc21fd 100644 --- a/problemreductions-cli/src/commands/reduce.rs +++ b/problemreductions-cli/src/commands/reduce.rs @@ -77,7 +77,7 @@ pub fn reduce( let last = path.steps.last().unwrap(); if first.name != source_name || first.variant != source_variant { anyhow::bail!( - "Path file starts with {} {} but source problem is {} {}", + "Path file starts with {}{} but source problem is {}{}", first.name, format_variant(&first.variant), source_name, From ecc2d9a5d540185aff2772b07fb881d883a42497 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Fri, 27 Feb 2026 03:00:29 +0800 Subject: [PATCH 3/3] fix: align CLI flags with schema - add --couplings/--fields for SpinGlass Replace schema_to_cli_flag mapping with proper per-problem CLI flags: - Add --couplings and --fields flags to CreateArgs for SpinGlass - Add parse_couplings() and parse_fields() parsers - Remove schema_to_cli_flag() workaround; schema field names now match CLI flags directly for all problems - Update SpinGlass example in after_help Co-Authored-By: Claude Opus 4.6 --- problemreductions-cli/src/cli.rs | 7 ++ problemreductions-cli/src/commands/create.rs | 72 +++++++++++++------- 2 files changed, 54 insertions(+), 25 deletions(-) diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index a68ff855c..089c977f4 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -212,6 +212,7 @@ Examples: pred create QUBO --matrix \"1,0.5;0.5,2\" -o qubo.json pred create KColoring --k 3 --graph 0-1,1-2,2-0 -o kcol.json pred create MaxCut --graph 0-1,1-2 --edge-weights 2,3 + pred create SpinGlass --graph 0-1,1-2 --couplings 1,-1 pred create MIS --random --num-vertices 10 --edge-prob 0.3 pred create Factoring --target 15 --m 4 --n 4 @@ -230,6 +231,12 @@ pub struct CreateArgs { /// Edge weights (e.g., 2,3,1) [default: all 1s] #[arg(long)] pub edge_weights: Option, + /// Pairwise couplings J_ij for SpinGlass (e.g., 1,-1,1) [default: all 1s] + #[arg(long)] + pub couplings: Option, + /// On-site fields h_i for SpinGlass (e.g., 0,0,1) [default: all 0s] + #[arg(long)] + pub fields: Option, /// Clauses for SAT problems (semicolon-separated, e.g., "1,2;-1,3") #[arg(long)] pub clauses: Option, diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index e7de08913..ffc57252d 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -15,6 +15,8 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool { args.graph.is_none() && args.weights.is_none() && args.edge_weights.is_none() + && args.couplings.is_none() + && args.fields.is_none() && args.clauses.is_none() && args.num_vars.is_none() && args.matrix.is_none() @@ -51,24 +53,13 @@ fn example_for(canonical: &str) -> &'static str { "Satisfiability" => "--num-vars 3 --clauses \"1,2;-1,3\"", "KSatisfiability" => "--num-vars 3 --clauses \"1,2,3;-1,2,-3\" --k 3", "QUBO" => "--matrix \"1,0.5;0.5,2\"", - "SpinGlass" => "--graph 0-1,1-2 --edge-weights 1,1", + "SpinGlass" => "--graph 0-1,1-2 --couplings 1,1", "KColoring" => "--graph 0-1,1-2,2-0 --k 3", "Factoring" => "--target 15 --m 4 --n 4", _ => "", } } -/// Map schema field names to CLI flag names where they differ. -/// Returns None to skip a field (no corresponding CLI flag). -fn schema_to_cli_flag<'a>(problem: &str, field_name: &'a str) -> Option<&'a str> { - match (problem, field_name) { - // SpinGlass schema uses "couplings"/"fields" but CLI uses graph + edge-weights - ("SpinGlass", "couplings") => Some("edge-weights"), - ("SpinGlass", "fields") => None, // no CLI flag for external fields - _ => Some(field_name), - } -} - fn print_problem_help(canonical: &str) -> Result<()> { let schemas = collect_schemas(); let schema = schemas.iter().find(|s| s.name == canonical); @@ -77,15 +68,13 @@ fn print_problem_help(canonical: &str) -> Result<()> { eprintln!("{}\n {}\n", canonical, s.description); eprintln!("Parameters:"); for field in &s.fields { - if let Some(flag) = schema_to_cli_flag(canonical, &field.name) { - let hint = type_format_hint(&field.type_name); - eprintln!( - " --{:<16} {} ({})", - flag.replace('_', "-"), - field.description, - hint - ); - } + let hint = type_format_hint(&field.type_name); + eprintln!( + " --{:<16} {} ({})", + field.name.replace('_', "-"), + field.description, + hint + ); } } else { eprintln!("{canonical}\n"); @@ -233,12 +222,11 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { "SpinGlass" => { let (graph, n) = parse_graph(args).map_err(|e| { anyhow::anyhow!( - "{e}\n\nUsage: pred create SpinGlass --graph 0-1,1-2 [--edge-weights 1,1]" + "{e}\n\nUsage: pred create SpinGlass --graph 0-1,1-2 [--couplings 1,1] [--fields 0,0,0]" ) })?; - let edge_weights = parse_edge_weights(args, graph.num_edges())?; - let fields = vec![0i32; n]; - let couplings = edge_weights; + let couplings = parse_couplings(args, graph.num_edges())?; + let fields = parse_fields(args, n)?; let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]); ( ser(SpinGlass::from_graph(graph, couplings, fields))?, @@ -368,6 +356,40 @@ fn parse_edge_weights(args: &CreateArgs, num_edges: usize) -> Result> { } } +/// Parse `--couplings` as SpinGlass pairwise couplings (i32), defaulting to all 1s. +fn parse_couplings(args: &CreateArgs, num_edges: usize) -> Result> { + match &args.couplings { + Some(w) => { + let vals: Vec = w + .split(',') + .map(|s| s.trim().parse::()) + .collect::, _>>()?; + if vals.len() != num_edges { + bail!("Expected {} couplings but got {}", num_edges, vals.len()); + } + Ok(vals) + } + None => Ok(vec![1i32; num_edges]), + } +} + +/// Parse `--fields` as SpinGlass on-site fields (i32), defaulting to all 0s. +fn parse_fields(args: &CreateArgs, num_vertices: usize) -> Result> { + match &args.fields { + Some(w) => { + let vals: Vec = w + .split(',') + .map(|s| s.trim().parse::()) + .collect::, _>>()?; + if vals.len() != num_vertices { + bail!("Expected {} fields but got {}", num_vertices, vals.len()); + } + Ok(vals) + } + None => Ok(vec![0i32; num_vertices]), + } +} + /// Parse `--clauses` as semicolon-separated clauses of comma-separated literals. /// E.g., "1,2;-1,3;2,-3" fn parse_clauses(args: &CreateArgs) -> Result> {