diff --git a/docs/src/cli.md b/docs/src/cli.md index fd5aea98..af1c6237 100644 --- a/docs/src/cli.md +++ b/docs/src/cli.md @@ -40,7 +40,7 @@ Available backends: `highs` (default), `coin-cbc`, `clarabel`, `scip`, `lpsolve` ```bash # Create a Maximum Independent Set problem -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 # Solve it (auto-reduces to ILP) pred solve problem.json @@ -56,8 +56,8 @@ pred reduce problem.json --to QUBO -o reduced.json pred solve reduced.json --solver brute-force # Pipe commands together (use - to read from stdin) -pred create MIS --edges 0-1,1-2,2-3 | pred solve - -pred create MIS --edges 0-1,1-2,2-3 | pred reduce - --to QUBO | pred solve - +pred create MIS --graph 0-1,1-2,2-3 | pred solve - +pred create MIS --graph 0-1,1-2,2-3 | pred reduce - --to QUBO | pred solve - ``` ## Global Flags @@ -232,13 +232,13 @@ pred export-graph -o reduction_graph.json # save to file Construct a problem instance from CLI arguments and save as JSON: ```bash -pred create MIS --edges 0-1,1-2,2-3 -o problem.json -pred create MIS --edges 0-1,1-2,2-3 --weights 2,1,3,1 -o problem.json +pred create MIS --graph 0-1,1-2,2-3 -o problem.json +pred create MIS --graph 0-1,1-2,2-3 --weights 2,1,3,1 -o problem.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 SpinGlass --edges 0-1,1-2 -o sg.json -pred create MaxCut --edges 0-1,1-2,2-0 -o maxcut.json +pred create KColoring --k 3 --graph 0-1,1-2,2-0 -o kcol.json +pred create SpinGlass --graph 0-1,1-2 -o sg.json +pred create MaxCut --graph 0-1,1-2,2-0 -o maxcut.json pred create Factoring --target 15 --bits-m 4 --bits-n 4 -o factoring.json pred create Factoring --target 21 --bits-m 3 --bits-n 3 -o factoring2.json ``` @@ -254,7 +254,7 @@ pred create MaxCut --random --num-vertices 20 --edge-prob 0.5 -o maxcut.json Without `-o`, the problem JSON is printed to stdout, which can be piped to other commands: ```bash -pred create MIS --edges 0-1,1-2,2-3 | pred solve - +pred create MIS --graph 0-1,1-2,2-3 | pred solve - pred create MIS --random --num-vertices 10 | pred inspect - ``` @@ -280,7 +280,7 @@ Valid(2) Stdin is supported with `-`: ```bash -pred create MIS --edges 0-1,1-2,2-3 | pred evaluate - --config 1,0,1,0 +pred create MIS --graph 0-1,1-2,2-3 | pred evaluate - --config 1,0,1,0 ``` ### `pred inspect` — Inspect a problem file @@ -297,7 +297,7 @@ Works with reduction bundles and stdin: ```bash pred inspect bundle.json -pred create MIS --edges 0-1,1-2 | pred inspect - +pred create MIS --graph 0-1,1-2 | pred inspect - ``` ### `pred reduce` — Reduce a problem @@ -317,7 +317,7 @@ pred reduce problem.json --via path.json -o reduced.json Stdin is supported with `-`: ```bash -pred create MIS --edges 0-1,1-2,2-3 | pred reduce - --to QUBO +pred create MIS --graph 0-1,1-2,2-3 | pred reduce - --to QUBO ``` The bundle contains everything needed to map solutions back: @@ -346,8 +346,8 @@ pred solve problem.json --timeout 30 # abort after 30 seconds Stdin is supported with `-`: ```bash -pred create MIS --edges 0-1,1-2,2-3 | pred solve - -pred create MIS --edges 0-1,1-2,2-3 | pred solve - --solver brute-force +pred create MIS --graph 0-1,1-2,2-3 | pred solve - +pred create MIS --graph 0-1,1-2,2-3 | pred solve - --solver brute-force ``` When the problem is not ILP, the solver automatically reduces it to ILP, solves, and maps the solution back. The auto-reduction is shown in the output: @@ -429,8 +429,10 @@ You can also specify variants with a slash: `MIS/UnitDiskGraph`, `SpinGlass/Simp If you mistype a problem name, `pred` will suggest the closest match: ```bash -$ pred show MaxIndependentSet -Error: Unknown problem: MaxIndependentSet - Did you mean: MaximumIndependentSet? - Run `pred list` to see all available problem types. +$ pred show MaximumIndependentSe +Error: Unknown problem: MaximumIndependentSe + +Did you mean: MaximumIndependentSet? + +Run `pred list` to see all available problems. ``` diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 62060979..1aacf242 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -81,7 +81,7 @@ Use `pred from ` for outgoing neighbors (what this reduces to).")] /// Problem name or alias (e.g., MIS, QUBO, MIS/UnitDiskGraph) #[arg(value_parser = crate::problem_name::ProblemNameParser)] problem: String, - /// Number of hops to explore [default: 1] + /// Number of hops to explore #[arg(long, default_value = "1")] hops: usize, }, @@ -98,7 +98,7 @@ Use `pred to ` for incoming neighbors (what reduces to this).")] /// Problem name or alias (e.g., MIS, QUBO, MIS/UnitDiskGraph) #[arg(value_parser = crate::problem_name::ProblemNameParser)] problem: String, - /// Number of hops to explore [default: 1] + /// Number of hops to explore #[arg(long, default_value = "1")] hops: usize, }, diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 350983eb..c9a66874 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -235,12 +235,26 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { "{e}\n\nUsage: pred create SpinGlass --graph 0-1,1-2 [--couplings 1,1] [--fields 0,0,0]" ) })?; - let couplings = parse_couplings(args, graph.num_edges())?; - let fields = parse_fields(args, n)?; - ( - ser(SpinGlass::from_graph(graph, couplings, fields))?, - resolved_variant.clone(), - ) + let use_f64 = resolved_variant.get("weight").is_some_and(|w| w == "f64") + || has_float_syntax(&args.couplings) + || has_float_syntax(&args.fields); + if use_f64 { + let couplings = parse_couplings_f64(args, graph.num_edges())?; + let fields = parse_fields_f64(args, n)?; + let mut variant = resolved_variant.clone(); + variant.insert("weight".to_string(), "f64".to_string()); + ( + ser(SpinGlass::from_graph(graph, couplings, fields))?, + variant, + ) + } else { + let couplings = parse_couplings(args, graph.num_edges())?; + let fields = parse_fields(args, n)?; + ( + ser(SpinGlass::from_graph(graph, couplings, fields))?, + resolved_variant.clone(), + ) + } } // Factoring @@ -486,6 +500,45 @@ fn parse_fields(args: &CreateArgs, num_vertices: usize) -> Result> { } } +/// Check if a CLI string value contains float syntax (a decimal point). +fn has_float_syntax(value: &Option) -> bool { + value.as_ref().is_some_and(|s| s.contains('.')) +} + +/// Parse `--couplings` as SpinGlass pairwise couplings (f64), defaulting to all 1.0. +fn parse_couplings_f64(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![1.0f64; num_edges]), + } +} + +/// Parse `--fields` as SpinGlass on-site fields (f64), defaulting to all 0.0. +fn parse_fields_f64(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![0.0f64; 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> { diff --git a/problemreductions-cli/src/commands/graph.rs b/problemreductions-cli/src/commands/graph.rs index 1c707b02..05bc9f28 100644 --- a/problemreductions-cli/src/commands/graph.rs +++ b/problemreductions-cli/src/commands/graph.rs @@ -4,7 +4,7 @@ use anyhow::{Context, Result}; use problemreductions::registry::collect_schemas; use problemreductions::rules::{Minimize, MinimizeSteps, ReductionGraph, TraversalDirection}; use problemreductions::types::ProblemSize; -use std::collections::{BTreeMap, HashSet}; +use std::collections::BTreeMap; pub fn list(out: &OutputConfig) -> Result<()> { use crate::output::{format_table, Align}; @@ -250,7 +250,7 @@ pub fn show(problem: &str, out: &OutputConfig) -> Result<()> { /// Convert a variant BTreeMap to slash notation showing ALL values. /// E.g., {graph: "SimpleGraph", weight: "i32"} → "/SimpleGraph/i32". -fn variant_to_full_slash(variant: &BTreeMap) -> String { +pub(crate) fn variant_to_full_slash(variant: &BTreeMap) -> String { if variant.is_empty() { String::new() } else { @@ -259,34 +259,11 @@ fn variant_to_full_slash(variant: &BTreeMap) -> 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) != Some(*v)) - .map(|(_, v)| v.as_str()) - .collect(); - if diffs.is_empty() { - String::new() - } else { - format!("/{}", diffs.join("/")) - } -} /// 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); +fn fmt_node(_graph: &ReductionGraph, name: &str, variant: &BTreeMap) -> String { + let slash = variant_to_full_slash(variant); crate::output::fmt_problem_name(&format!("{name}{slash}")) } @@ -624,11 +601,9 @@ pub fn neighbors( text.push('\n'); render_tree(&graph, &tree, &mut text, ""); - // Count unique problem names - let unique_names: HashSet<&str> = neighbors.iter().map(|n| n.name).collect(); text.push_str(&format!( - "\n{} reachable problems in {} hops\n", - unique_names.len(), + "\n{} reachable nodes in {} hops\n", + neighbors.len(), max_hops, )); diff --git a/problemreductions-cli/src/commands/reduce.rs b/problemreductions-cli/src/commands/reduce.rs index 2f4bc21f..6a7949f3 100644 --- a/problemreductions-cli/src/commands/reduce.rs +++ b/problemreductions-cli/src/commands/reduce.rs @@ -79,9 +79,9 @@ pub fn reduce( anyhow::bail!( "Path file starts with {}{} but source problem is {}{}", first.name, - format_variant(&first.variant), + variant_to_full_slash(&first.variant), source_name, - format_variant(&source_variant), + variant_to_full_slash(&source_variant), ); } // If --to is given, validate it matches the path's target @@ -205,11 +205,4 @@ pub fn reduce( Ok(()) } -fn format_variant(v: &BTreeMap) -> String { - if v.is_empty() { - String::new() - } else { - let vals: Vec<&str> = v.values().map(|v| v.as_str()).collect(); - format!("/{}", vals.join("/")) - } -} +use super::graph::variant_to_full_slash; diff --git a/problemreductions-cli/src/main.rs b/problemreductions-cli/src/main.rs index dfddcf2d..b9e83221 100644 --- a/problemreductions-cli/src/main.rs +++ b/problemreductions-cli/src/main.rs @@ -29,10 +29,17 @@ fn main() -> anyhow::Result<()> { } }; + // Data-producing commands auto-output JSON when piped + let auto_json = matches!( + cli.command, + Commands::Reduce(_) | Commands::Solve(_) | Commands::Evaluate(_) | Commands::Inspect(_) + ); + let out = OutputConfig { output: cli.output, quiet: cli.quiet, json: cli.json, + auto_json, }; match cli.command { diff --git a/problemreductions-cli/src/output.rs b/problemreductions-cli/src/output.rs index 89304202..eff2a64f 100644 --- a/problemreductions-cli/src/output.rs +++ b/problemreductions-cli/src/output.rs @@ -12,6 +12,9 @@ pub struct OutputConfig { pub quiet: bool, /// Output JSON to stdout instead of human-readable text. pub json: bool, + /// When true, auto-output JSON if stdout is not a TTY (piped). + /// Used for data-producing commands (reduce, solve, evaluate, inspect). + pub auto_json: bool, } impl OutputConfig { @@ -36,7 +39,7 @@ impl OutputConfig { std::fs::write(path, &content) .with_context(|| format!("Failed to write {}", path.display()))?; self.info(&format!("Wrote {}", path.display())); - } else if self.json { + } else if self.json || (self.auto_json && !std::io::stdout().is_terminal()) { println!( "{}", serde_json::to_string_pretty(json_value).context("Failed to serialize JSON")? diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index 6434bd4d..5dbcef62 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -585,8 +585,9 @@ fn test_solve_brute_force() { String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8(output.stdout).unwrap(); - assert!(stdout.contains("brute-force")); - assert!(stdout.contains("Solution")); + // auto_json: data commands output JSON when stdout is not a TTY (as in tests) + assert!(stdout.contains("\"solver\": \"brute-force\"")); + assert!(stdout.contains("\"solution\"")); std::fs::remove_file(&problem_file).ok(); } @@ -617,10 +618,10 @@ fn test_solve_ilp() { String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8(output.stdout).unwrap(); - assert!(stdout.contains("ilp")); - assert!(stdout.contains("Solution")); + assert!(stdout.contains("\"solver\": \"ilp\"")); + assert!(stdout.contains("\"solution\"")); assert!( - stdout.contains("via ILP"), + stdout.contains("\"reduced_to\": \"ILP\""), "MIS solved with ILP should show auto-reduction: {stdout}" ); @@ -654,8 +655,9 @@ fn test_solve_ilp_default() { String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8(output.stdout).unwrap(); + // auto_json: data commands output JSON when stdout is not a TTY assert!( - stdout.contains("Solver: ilp (via ILP)"), + stdout.contains("\"solver\": \"ilp\"") && stdout.contains("\"reduced_to\": \"ILP\""), "MIS with default solver should show auto-reduction: {stdout}" ); @@ -689,11 +691,12 @@ fn test_solve_ilp_shows_via_ilp() { String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8(output.stdout).unwrap(); + // auto_json: data commands output JSON when stdout is not a TTY assert!( - stdout.contains("Solver: ilp (via ILP)"), + stdout.contains("\"reduced_to\": \"ILP\""), "Non-ILP problem solved with ILP should show auto-reduction indicator, got: {stdout}" ); - assert!(stdout.contains("Problem: MaximumIndependentSet")); + assert!(stdout.contains("\"problem\": \"MaximumIndependentSet\"")); std::fs::remove_file(&problem_file).ok(); } @@ -794,8 +797,9 @@ fn test_solve_bundle() { String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8(output.stdout).unwrap(); - assert!(stdout.contains("Problem")); - assert!(stdout.contains("via")); + // auto_json: data commands output JSON when stdout is not a TTY + assert!(stdout.contains("\"problem\"")); + assert!(stdout.contains("\"solution\"")); std::fs::remove_file(&problem_file).ok(); std::fs::remove_file(&bundle_file).ok(); @@ -848,8 +852,9 @@ fn test_solve_bundle_ilp() { String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8(output.stdout).unwrap(); - assert!(stdout.contains("Problem")); - assert!(stdout.contains("via")); + // auto_json: data commands output JSON when stdout is not a TTY + assert!(stdout.contains("\"problem\"")); + assert!(stdout.contains("\"solution\"")); std::fs::remove_file(&problem_file).ok(); std::fs::remove_file(&bundle_file).ok(); @@ -1365,8 +1370,8 @@ fn test_reduce_stdout() { } #[test] -fn test_reduce_human_output() { - // Without --json or -o, reduce shows human-readable summary +fn test_reduce_auto_json_output() { + // auto_json: reduce outputs JSON when stdout is not a TTY (as in tests) let problem_file = std::env::temp_dir().join("pred_test_reduce_human.json"); let create_out = pred() .args([ @@ -1391,10 +1396,6 @@ fn test_reduce_human_output() { String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8(output.stdout).unwrap(); - assert!( - stdout.contains("Reduced"), - "expected 'Reduced' in stdout, got: {stdout}" - ); assert!( stdout.contains("MaximumIndependentSet"), "expected 'MaximumIndependentSet' in stdout, got: {stdout}" @@ -1403,10 +1404,10 @@ fn test_reduce_human_output() { stdout.contains("QUBO"), "expected 'QUBO' in stdout, got: {stdout}" ); - // Should NOT be parseable as JSON + // auto_json: should be valid JSON when stdout is not a TTY assert!( - serde_json::from_str::(&stdout).is_err(), - "stdout should not be valid JSON in human-readable mode, got: {stdout}" + serde_json::from_str::(&stdout).is_ok(), + "stdout should be valid JSON with auto_json, got: {stdout}" ); std::fs::remove_file(&problem_file).ok(); @@ -1596,7 +1597,7 @@ fn test_to_incoming() { let stdout = String::from_utf8(output.stdout).unwrap(); assert!(stdout.contains("MaximumIndependentSet")); assert!(stdout.contains("incoming")); - assert!(stdout.contains("reachable problems")); + assert!(stdout.contains("reachable nodes")); // Should contain tree characters assert!(stdout.contains("├── ") || stdout.contains("└── ")); } @@ -1681,7 +1682,7 @@ fn test_to_default_hops() { ); let stdout = String::from_utf8(output.stdout).unwrap(); assert!(stdout.contains("1-hop")); - assert!(stdout.contains("reachable problems")); + assert!(stdout.contains("reachable nodes")); } // ---- Quiet mode tests ---- @@ -1793,8 +1794,9 @@ fn test_quiet_still_shows_stdout() { String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8(output.stdout).unwrap(); + // auto_json: data commands output JSON when stdout is not a TTY assert!( - stdout.contains("Solution"), + stdout.contains("\"solution\""), "stdout should still contain solution with -q, got: {stdout}" ); @@ -1837,9 +1839,10 @@ fn test_create_pipe_to_solve() { String::from_utf8_lossy(&solve_result.stderr) ); let stdout = String::from_utf8(solve_result.stdout).unwrap(); + // auto_json: data commands output JSON when stdout is not a TTY assert!( - stdout.contains("Solution"), - "stdout should contain Solution, got: {stdout}" + stdout.contains("\"solution\""), + "stdout should contain solution, got: {stdout}" ); } @@ -1952,25 +1955,18 @@ fn test_inspect_problem() { String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8(output.stdout).unwrap(); + // auto_json: data commands output JSON when stdout is not a TTY assert!( - stdout.contains("Type: MaximumIndependentSet"), - "expected 'Type: MaximumIndependentSet', got: {stdout}" - ); - assert!( - stdout.contains("Size fields:"), - "expected 'Size fields:', got: {stdout}" - ); - assert!( - stdout.contains("Variables:"), - "expected 'Variables:', got: {stdout}" + stdout.contains("MaximumIndependentSet"), + "expected 'MaximumIndependentSet', got: {stdout}" ); assert!( - stdout.contains("Solvers:"), - "expected 'Solvers:', got: {stdout}" + stdout.contains("\"kind\""), + "expected '\"kind\"', got: {stdout}" ); assert!( - stdout.contains("Reduces to:"), - "expected 'Reduces to:', got: {stdout}" + serde_json::from_str::(&stdout).is_ok(), + "expected valid JSON, got: {stdout}" ); std::fs::remove_file(&problem_file).ok(); @@ -2021,21 +2017,18 @@ fn test_inspect_bundle() { String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8(output.stdout).unwrap(); + // auto_json: data commands output JSON when stdout is not a TTY assert!( - stdout.contains("Bundle"), - "expected 'Bundle' in output, got: {stdout}" - ); - assert!( - stdout.contains("Source:"), - "expected 'Source:' in output, got: {stdout}" + stdout.contains("\"kind\": \"bundle\""), + "expected '\"kind\": \"bundle\"' in output, got: {stdout}" ); assert!( - stdout.contains("Target:"), - "expected 'Target:' in output, got: {stdout}" + stdout.contains("\"source\""), + "expected '\"source\"' in output, got: {stdout}" ); assert!( - stdout.contains("Path:"), - "expected 'Path:' in output, got: {stdout}" + stdout.contains("\"target\""), + "expected '\"target\"' in output, got: {stdout}" ); std::fs::remove_file(&problem_file).ok(); @@ -2506,9 +2499,10 @@ fn test_solve_timeout_succeeds() { String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8(output.stdout).unwrap(); + // auto_json: data commands output JSON when stdout is not a TTY assert!( - stdout.contains("Solution"), - "expected Solution in stdout, got: {stdout}" + stdout.contains("\"solution\""), + "expected solution in stdout, got: {stdout}" ); std::fs::remove_file(&problem_file).ok(); @@ -2548,7 +2542,8 @@ fn test_solve_timeout_zero_means_no_limit() { String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8(output.stdout).unwrap(); - assert!(stdout.contains("Solution")); + // auto_json: data commands output JSON when stdout is not a TTY + assert!(stdout.contains("\"solution\"")); std::fs::remove_file(&problem_file).ok(); }