Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 20 additions & 18 deletions docs/src/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
```
Expand All @@ -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 -
```

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.
```
4 changes: 2 additions & 2 deletions problemreductions-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ Use `pred from <problem>` 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,
},
Expand All @@ -98,7 +98,7 @@ Use `pred to <problem>` 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,
},
Expand Down
65 changes: 59 additions & 6 deletions problemreductions-cli/src/commands/create.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -486,6 +500,45 @@ fn parse_fields(args: &CreateArgs, num_vertices: usize) -> Result<Vec<i32>> {
}
}

/// Check if a CLI string value contains float syntax (a decimal point).
fn has_float_syntax(value: &Option<String>) -> 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<Vec<f64>> {
match &args.couplings {
Some(w) => {
let vals: Vec<f64> = w
.split(',')
.map(|s| s.trim().parse::<f64>())
.collect::<std::result::Result<Vec<_>, _>>()?;
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<Vec<f64>> {
match &args.fields {
Some(w) => {
let vals: Vec<f64> = w
.split(',')
.map(|s| s.trim().parse::<f64>())
.collect::<std::result::Result<Vec<_>, _>>()?;
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<Vec<CNFClause>> {
Expand Down
37 changes: 6 additions & 31 deletions problemreductions-cli/src/commands/graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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, String>) -> String {
pub(crate) fn variant_to_full_slash(variant: &BTreeMap<String, String>) -> String {
if variant.is_empty() {
String::new()
} else {
Expand All @@ -259,34 +259,11 @@ fn variant_to_full_slash(variant: &BTreeMap<String, String>) -> 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<String, String>,
default: &BTreeMap<String, String>,
) -> 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, String>) -> 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, String>) -> String {
let slash = variant_to_full_slash(variant);
crate::output::fmt_problem_name(&format!("{name}{slash}"))
}

Expand Down Expand Up @@ -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,
));

Expand Down
13 changes: 3 additions & 10 deletions problemreductions-cli/src/commands/reduce.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -205,11 +205,4 @@ pub fn reduce(
Ok(())
}

fn format_variant(v: &BTreeMap<String, String>) -> 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;
7 changes: 7 additions & 0 deletions problemreductions-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 4 additions & 1 deletion problemreductions-cli/src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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")?
Expand Down
Loading