From 51d243d991e961d29056ad25d5d8f7e0bdbbe672 Mon Sep 17 00:00:00 2001
From: GiggleLiu <cacate0129@gmail.com>
Date: Wed, 18 Feb 2026 21:50:13 +0800
Subject: [PATCH 01/34] docs: add CLI v2 features design (issue #81)

Design for k-neighbor exploration, colored output, and shell completions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
 .../2026-02-18-cli-v2-features-design.md      | 227 ++++++++++++++++++
 1 file changed, 227 insertions(+)
 create mode 100644 docs/plans/2026-02-18-cli-v2-features-design.md

diff --git a/docs/plans/2026-02-18-cli-v2-features-design.md b/docs/plans/2026-02-18-cli-v2-features-design.md
new file mode 100644
index 000000000..5dcfaa308
--- /dev/null
+++ b/docs/plans/2026-02-18-cli-v2-features-design.md
@@ -0,0 +1,227 @@
+# CLI v2 Features Design
+
+Tracking issue: #81
+
+## Overview
+
+Three features for the `pred` CLI tool, building on the v1 foundation:
+
+1. **k-Neighbor Exploration** — discover nearby problems in the reduction graph
+2. **Colored Output** — terminal colors and aligned formatting
+3. **Shell Completions** — bash/zsh/fish auto-completion
+
+## Feature 1: k-Neighbor Exploration
+
+### UX
+
+Extend the existing `pred show` command with `--hops` and `--direction` flags:
+
+```
+pred show MIS --hops 2                     # 2-hop outgoing tree (default)
+pred show MIS --hops 2 --direction in      # 2-hop incoming tree
+pred show MIS --hops 2 --direction both    # both directions
+pred show MIS --hops 3 -o neighbors.json   # JSON output
+```
+
+When `--hops` is not specified, `pred show` behaves exactly as v1 (no tree, just 1-hop lists).
+
+### Output Format — Tree View
+
+```
+MaximumIndependentSet — 2-hop neighbors (outgoing)
+
+MaximumIndependentSet
+├── QUBO
+│   ├── SpinGlass
+│   └── ILP
+├── MinimumVertexCover
+│   ├── ILP
+│   └── MinimumSetCovering
+└── MaxCut
+    └── SpinGlass
+
+6 reachable problems in 2 hops
+```
+
+Tree characters: `├──`, `└──`, `│   `, `    ` (4-char indentation per level).
+
+Deduplicated: if a problem appears at multiple hop distances, show it at the shortest distance only. Mention the total unique count at the bottom.
+
+### JSON Output
+
+When `-o` is specified:
+
+```json
+{
+  "source": "MaximumIndependentSet",
+  "hops": 2,
+  "direction": "out",
+  "neighbors": [
+    {"name": "QUBO", "variant": {}, "hops": 1},
+    {"name": "SpinGlass", "variant": {}, "hops": 2},
+    {"name": "ILP", "variant": {}, "hops": 2},
+    {"name": "MinimumVertexCover", "variant": {"graph": "SimpleGraph", "weight": "One"}, "hops": 1},
+    {"name": "MinimumSetCovering", "variant": {}, "hops": 2},
+    {"name": "MaxCut", "variant": {}, "hops": 1}
+  ]
+}
+```
+
+### Library Change
+
+Add to `ReductionGraph` in `src/rules/graph.rs`:
+
+```rust
+pub struct NeighborInfo {
+    pub name: &'static str,
+    pub variant: BTreeMap<String, String>,
+    pub hops: usize,
+}
+
+pub enum TraversalDirection {
+    Outgoing,
+    Incoming,
+    Both,
+}
+
+pub fn k_neighbors(
+    &self,
+    name: &str,
+    variant: &BTreeMap<String, String>,
+    max_hops: usize,
+    direction: TraversalDirection,
+) -> Vec<NeighborInfo>
+```
+
+Implementation: BFS on the petgraph `DiGraph`, following edges in the specified direction. Track visited nodes to avoid cycles. Return nodes sorted by (hops, name).
+
+### CLI Changes
+
+In `cli.rs`, add to `Commands::Show`:
+
+```rust
+/// Explore k-hop neighbors in the reduction graph
+#[arg(long)]
+hops: Option<usize>,
+
+/// Direction for neighbor exploration: out, in, both [default: out]
+#[arg(long, default_value = "out")]
+direction: String,
+```
+
+In `commands/graph.rs`, when `hops` is `Some(k)`, call the new library method and render as a tree instead of the existing show output.
+
+## Feature 2: Colored Output
+
+### Dependency
+
+Add `owo-colors` to `problemreductions-cli/Cargo.toml`:
+
+```toml
+owo-colors = { version = "4", features = ["supports-colors"] }
+```
+
+### Color Scheme
+
+| Element | Style | Example |
+|---------|-------|---------|
+| Problem names | Bold | **MaximumIndependentSet** |
+| Section headers | Cyan | Variants (4): |
+| Outgoing arrows `→` | Green | → QUBO |
+| Incoming arrows `←` | Red | ← Satisfiability |
+| Hop distance | Yellow | (2 hops) |
+| Tree branches | Dim | `├──` `└──` `│` |
+| Aliases | Dim | (MIS, mis) |
+| Error messages | Red bold | Error: unknown problem |
+
+### Color Respect
+
+- Detect terminal capability via `owo-colors`'s `supports-color` feature
+- Respect `NO_COLOR` environment variable (https://no-color.org/)
+- JSON output (`-o`) is never colored
+- Piped output (non-TTY stdout) disables colors automatically
+
+### Aligned Columns for `pred list`
+
+Hand-format with `format!("{:<width$}")`:
+
+```
+Registered problems: 20 types, 35 reductions, 42 variant nodes
+
+  Problem                    Aliases    Variants  Reduces to
+  ─────────────────────────  ─────────  ────────  ──────────
+  BicliqueCover                              1          2
+  MaximumIndependentSet      MIS             4          5
+  QUBO                                       1          0
+```
+
+Column widths computed dynamically from the longest problem name.
+
+## Feature 3: Shell Completions
+
+### Dependency
+
+Add `clap_complete` to `problemreductions-cli/Cargo.toml`:
+
+```toml
+clap_complete = "4"
+```
+
+### UX
+
+New subcommand:
+
+```
+pred completions bash    # output bash completions to stdout
+pred completions zsh     # output zsh completions to stdout
+pred completions fish    # output fish completions to stdout
+```
+
+Users pipe to their shell config:
+
+```bash
+pred completions bash > ~/.local/share/bash-completion/completions/pred
+pred completions zsh > ~/.zfunc/_pred
+pred completions fish > ~/.config/fish/completions/pred.fish
+```
+
+### CLI Changes
+
+In `cli.rs`:
+
+```rust
+/// Generate shell completions
+Completions {
+    /// Shell type: bash, zsh, fish
+    shell: clap_complete::Shell,
+},
+```
+
+In `main.rs`, the handler calls `clap_complete::generate()` with the Cli command factory, writing to stdout.
+
+## Dependencies Summary
+
+| Crate | Version | Purpose | Transitive deps |
+|-------|---------|---------|----------------|
+| `owo-colors` | 4.x | Terminal colors | 1-2 (tiny) |
+| `clap_complete` | 4.x | Shell completions | 0 (clap already present) |
+
+## Implementation Order
+
+1. **Shell completions** — smallest, self-contained, quick win
+2. **Colored output** — add `owo-colors`, apply colors across all existing commands
+3. **k-neighbor exploration** — largest: library method + CLI flag + tree renderer
+
+Each feature is independently shippable and testable.
+
+## Testing
+
+- **Shell completions:** integration test that runs `pred completions bash` and checks output contains expected completion markers
+- **Colored output:** unit tests for the formatting functions (test the text content, not ANSI codes); manual visual verification
+- **k-neighbors:** unit test for `ReductionGraph::k_neighbors` with known graph topology; integration test for `pred show MIS --hops 2` output structure
+
+## Out of Scope
+
+- Interactive REPL mode (deferred to v3+)
+- Table library dependency (hand-formatted alignment is sufficient)
+- Dynamic problem-name completion (would require runtime invocation; deferred)

From 9bdcef95130ddae6b383cb73279b0c5516b04c39 Mon Sep 17 00:00:00 2001
From: GiggleLiu <cacate0129@gmail.com>
Date: Wed, 18 Feb 2026 21:55:29 +0800
Subject: [PATCH 02/34] docs: add CLI v2 implementation plan (issue #81)

10-task plan covering shell completions, colored output, and k-neighbor
graph exploration for the pred CLI tool.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
 .../plans/2026-02-18-cli-v2-implementation.md | 1136 +++++++++++++++++
 1 file changed, 1136 insertions(+)
 create mode 100644 docs/plans/2026-02-18-cli-v2-implementation.md

diff --git a/docs/plans/2026-02-18-cli-v2-implementation.md b/docs/plans/2026-02-18-cli-v2-implementation.md
new file mode 100644
index 000000000..127059edc
--- /dev/null
+++ b/docs/plans/2026-02-18-cli-v2-implementation.md
@@ -0,0 +1,1136 @@
+# CLI v2 Features Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Add shell completions, colored terminal output, and k-neighbor graph exploration to the `pred` CLI tool.
+
+**Architecture:** Three independent features built on the existing `problemreductions-cli` crate. Shell completions and colored output are CLI-only changes. k-neighbor exploration requires a new BFS method in the core library's `ReductionGraph` plus a tree renderer in the CLI.
+
+**Tech Stack:** `clap_complete 4` (shell completions), `owo-colors 4` with `supports-colors` feature (terminal colors), `petgraph` BFS (already a transitive dep via `ReductionGraph`)
+
+---
+
+### Task 1: Add shell completions — dependencies and CLI enum
+
+**Files:**
+- Modify: `problemreductions-cli/Cargo.toml`
+- Modify: `problemreductions-cli/src/cli.rs`
+
+**Step 1: Add `clap_complete` dependency**
+
+In `problemreductions-cli/Cargo.toml`, add to `[dependencies]`:
+
+```toml
+clap_complete = "4"
+```
+
+**Step 2: Add `Completions` variant to `Commands` enum**
+
+In `problemreductions-cli/src/cli.rs`, add a new variant to the `Commands` enum (after `Solve`):
+
+```rust
+/// Generate shell completions for bash, zsh, fish, etc.
+#[command(after_help = "\
+Examples:
+  pred completions bash > ~/.local/share/bash-completion/completions/pred
+  pred completions zsh > ~/.zfunc/_pred
+  pred completions fish > ~/.config/fish/completions/pred.fish")]
+Completions {
+    /// Shell type
+    shell: clap_complete::Shell,
+},
+```
+
+**Step 3: Wire up the handler in `main.rs`**
+
+In `problemreductions-cli/src/main.rs`, add the match arm:
+
+```rust
+Commands::Completions { shell } => {
+    let mut cmd = Cli::command();
+    clap_complete::generate(shell, &mut cmd, "pred", &mut std::io::stdout());
+    Ok(())
+}
+```
+
+Also add `use clap::CommandFactory;` at the top of main.rs (needed for `.command()`).
+
+**Step 4: Build and verify**
+
+Run: `cargo build -p problemreductions-cli`
+Expected: Compiles successfully.
+
+Run: `cargo run -p problemreductions-cli -- completions bash | head -5`
+Expected: Outputs bash completion script starting with `_pred()` or similar.
+
+**Step 5: Commit**
+
+```
+feat(cli): add shell completions command (bash/zsh/fish)
+```
+
+---
+
+### Task 2: Add shell completions — integration test
+
+**Files:**
+- Modify: `problemreductions-cli/tests/cli_tests.rs`
+
+**Step 1: Write the test**
+
+Add to `problemreductions-cli/tests/cli_tests.rs`:
+
+```rust
+#[test]
+fn test_completions_bash() {
+    let output = pred().args(["completions", "bash"]).output().unwrap();
+    assert!(
+        output.status.success(),
+        "stderr: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+    let stdout = String::from_utf8(output.stdout).unwrap();
+    // Bash completions should reference the binary name
+    assert!(stdout.contains("pred"), "completions should reference 'pred'");
+}
+
+#[test]
+fn test_completions_zsh() {
+    let output = pred().args(["completions", "zsh"]).output().unwrap();
+    assert!(output.status.success());
+    let stdout = String::from_utf8(output.stdout).unwrap();
+    assert!(stdout.contains("pred"));
+}
+
+#[test]
+fn test_completions_fish() {
+    let output = pred().args(["completions", "fish"]).output().unwrap();
+    assert!(output.status.success());
+    let stdout = String::from_utf8(output.stdout).unwrap();
+    assert!(stdout.contains("pred"));
+}
+```
+
+**Step 2: Run tests**
+
+Run: `cargo test -p problemreductions-cli test_completions`
+Expected: All 3 tests pass.
+
+**Step 3: Commit**
+
+```
+test(cli): add integration tests for shell completions
+```
+
+---
+
+### Task 3: Add colored output — dependency and color helper module
+
+**Files:**
+- Modify: `problemreductions-cli/Cargo.toml`
+- Modify: `problemreductions-cli/src/output.rs`
+
+**Step 1: Add `owo-colors` dependency**
+
+In `problemreductions-cli/Cargo.toml`, add to `[dependencies]`:
+
+```toml
+owo-colors = { version = "4", features = ["supports-colors"] }
+```
+
+**Step 2: Add color helper functions to `output.rs`**
+
+In `problemreductions-cli/src/output.rs`, add color formatting helpers after the existing `OutputConfig` impl:
+
+```rust
+use owo_colors::OwoColorize;
+use std::io::IsTerminal;
+
+/// Whether colored output should be used (TTY + not NO_COLOR).
+pub fn use_color() -> bool {
+    std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none()
+}
+
+/// Format a problem name (bold when color is enabled).
+pub fn fmt_problem_name(name: &str) -> String {
+    if use_color() {
+        format!("{}", name.bold())
+    } else {
+        name.to_string()
+    }
+}
+
+/// Format a section header (cyan when color is enabled).
+pub fn fmt_section(text: &str) -> String {
+    if use_color() {
+        format!("{}", text.cyan())
+    } else {
+        text.to_string()
+    }
+}
+
+/// Format an outgoing arrow (green when color is enabled).
+pub fn fmt_arrow_out() -> &'static str {
+    // We return static str, so we use ANSI directly for the arrow
+    "→"
+}
+
+pub fn fmt_outgoing(text: &str) -> String {
+    if use_color() {
+        format!("{}", text.green())
+    } else {
+        text.to_string()
+    }
+}
+
+pub fn fmt_incoming(text: &str) -> String {
+    if use_color() {
+        format!("{}", text.red())
+    } else {
+        text.to_string()
+    }
+}
+
+/// Format dim text (for aliases, tree branches).
+pub fn fmt_dim(text: &str) -> String {
+    if use_color() {
+        format!("{}", text.dimmed())
+    } else {
+        text.to_string()
+    }
+}
+```
+
+**Step 3: Build**
+
+Run: `cargo build -p problemreductions-cli`
+Expected: Compiles.
+
+**Step 4: Commit**
+
+```
+feat(cli): add owo-colors dependency and color helper functions
+```
+
+---
+
+### Task 4: Apply colors to `pred list` with aligned columns
+
+**Files:**
+- Modify: `problemreductions-cli/src/commands/graph.rs`
+
+**Step 1: Rewrite `list()` to use aligned columns and colors**
+
+Replace the body of `pub fn list(out: &OutputConfig)` in `problemreductions-cli/src/commands/graph.rs`. The new version:
+- Computes column widths dynamically
+- Shows a header row with separator line
+- Shows variant count and outgoing reduction count per problem
+- Uses `fmt_problem_name`, `fmt_section`, `fmt_dim` from `output.rs`
+
+```rust
+pub fn list(out: &OutputConfig) -> Result<()> {
+    let graph = ReductionGraph::new();
+
+    let mut types = graph.problem_types();
+    types.sort();
+
+    // Collect data for each problem
+    struct Row {
+        name: String,
+        aliases: Vec<&'static str>,
+        num_variants: usize,
+        num_reduces_to: usize,
+    }
+    let rows: Vec<Row> = types
+        .iter()
+        .map(|name| {
+            let aliases = aliases_for(name);
+            let num_variants = graph.variants_for(name).len();
+            let num_reduces_to = graph.outgoing_reductions(name).len();
+            Row {
+                name: name.to_string(),
+                aliases,
+                num_variants,
+                num_reduces_to,
+            }
+        })
+        .collect();
+
+    // Compute column widths
+    let name_width = rows.iter().map(|r| r.name.len()).max().unwrap_or(7).max(7);
+    let alias_width = rows
+        .iter()
+        .map(|r| {
+            if r.aliases.is_empty() {
+                0
+            } else {
+                r.aliases.join(", ").len()
+            }
+        })
+        .max()
+        .unwrap_or(7)
+        .max(7);
+
+    let summary = format!(
+        "Registered problems: {} types, {} reductions, {} variant nodes\n",
+        graph.num_types(),
+        graph.num_reductions(),
+        graph.num_variant_nodes(),
+    );
+
+    let mut text = String::new();
+    text.push_str(&crate::output::fmt_section(&summary));
+    text.push_str(&format!(
+        "\n  {:<name_w$}  {:<alias_w$}  {:>8}  {:>10}\n",
+        "Problem",
+        "Aliases",
+        "Variants",
+        "Reduces to",
+        name_w = name_width,
+        alias_w = alias_width,
+    ));
+    text.push_str(&format!(
+        "  {:<name_w$}  {:<alias_w$}  {:>8}  {:>10}\n",
+        "─".repeat(name_width),
+        "─".repeat(alias_width),
+        "────────",
+        "──────────",
+        name_w = name_width,
+        alias_w = alias_width,
+    ));
+
+    for row in &rows {
+        let alias_str = if row.aliases.is_empty() {
+            String::new()
+        } else {
+            row.aliases.join(", ")
+        };
+        text.push_str(&format!(
+            "  {:<name_w$}  {:<alias_w$}  {:>8}  {:>10}\n",
+            crate::output::fmt_problem_name(&row.name),
+            crate::output::fmt_dim(&alias_str),
+            row.num_variants,
+            row.num_reduces_to,
+            name_w = name_width,
+            alias_w = alias_width,
+        ));
+    }
+
+    text.push_str(&format!(
+        "\nUse `pred show <problem>` to see variants, reductions, and fields.\n"
+    ));
+
+    let json = serde_json::json!({
+        "num_types": graph.num_types(),
+        "num_reductions": graph.num_reductions(),
+        "num_variant_nodes": graph.num_variant_nodes(),
+        "problems": rows.iter().map(|r| {
+            serde_json::json!({
+                "name": r.name,
+                "aliases": r.aliases,
+                "num_variants": r.num_variants,
+                "num_reduces_to": r.num_reduces_to,
+            })
+        }).collect::<Vec<_>>(),
+    });
+
+    out.emit_with_default_name("pred_graph_list.json", &text, &json)
+}
+```
+
+Note: When colors are enabled, ANSI escape codes in `fmt_problem_name` will make the string longer than the visible width. To handle this correctly, compute the padding based on the raw name length, not the colored string length. The approach above passes `name_w` based on the raw `name_width`, and `format!("{:<name_w$}")` will pad based on byte length. Since ANSI codes add invisible bytes, the colored name will already be wider — so we need to adjust. A simpler approach: format the padding with the raw name, then replace the raw name with the colored version. Or: only apply color after padding.
+
+**Refined approach for color+padding:** Format the plain-text column first, then apply color to the name portion only:
+
+```rust
+let padded_name = format!("{:<name_w$}", row.name, name_w = name_width);
+let colored_name = crate::output::fmt_problem_name(&padded_name);
+```
+
+This works because `fmt_problem_name` wraps the already-padded string in bold ANSI codes.
+
+**Step 2: Build and run**
+
+Run: `cargo run -p problemreductions-cli -- list`
+Expected: Aligned columns with bold problem names (if terminal supports color).
+
+**Step 3: Commit**
+
+```
+feat(cli): add aligned columns and color to pred list
+```
+
+---
+
+### Task 5: Apply colors to `pred show`
+
+**Files:**
+- Modify: `problemreductions-cli/src/commands/graph.rs`
+
+**Step 1: Add colors to `show()` function**
+
+In the `show()` function in `problemreductions-cli/src/commands/graph.rs`, apply color helpers:
+- Problem name line: `fmt_problem_name`
+- Section headers ("Variants", "Fields", "Size fields", "Reduces to", "Reduces from"): `fmt_section`
+- Outgoing reduction lines: `fmt_outgoing` on the arrow/target
+- Incoming reduction lines: `fmt_incoming` on the arrow/source
+- Alias list: `fmt_dim`
+
+The changes are straightforward string formatting replacements. For example:
+
+```rust
+// Before:
+let mut text = format!("{}\n", spec.name);
+// After:
+let mut text = format!("{}\n", crate::output::fmt_problem_name(&spec.name));
+
+// Before:
+text.push_str(&format!("\nVariants ({}):\n", variants.len()));
+// After:
+text.push_str(&format!("\n{}\n", crate::output::fmt_section(&format!("Variants ({}):", variants.len()))));
+
+// Before (outgoing):
+text.push_str(&format!(
+    "  {} {} -> {} {}\n",
+    e.source_name, ..., e.target_name, ...
+));
+// After:
+text.push_str(&format!(
+    "  {} {} {} {} {}\n",
+    e.source_name,
+    format_variant(&e.source_variant),
+    crate::output::fmt_outgoing("→"),
+    crate::output::fmt_problem_name(e.target_name),
+    format_variant(&e.target_variant),
+));
+```
+
+Similar pattern for incoming reductions using `fmt_incoming`.
+
+**Step 2: Build and verify**
+
+Run: `cargo run -p problemreductions-cli -- show MIS`
+Expected: Colored output with bold name, cyan headers, green outgoing arrows, red incoming arrows.
+
+**Step 3: Run existing tests**
+
+Run: `cargo test -p problemreductions-cli`
+Expected: All existing tests still pass. (Tests check for content like "MaximumIndependentSet" and "Reduces to" — these strings are still present, possibly wrapped in ANSI codes. Since tests run in a non-TTY pipe, `use_color()` returns false and no ANSI codes are emitted.)
+
+**Step 4: Commit**
+
+```
+feat(cli): add colors to pred show output
+```
+
+---
+
+### Task 6: Apply colors to `pred path`
+
+**Files:**
+- Modify: `problemreductions-cli/src/commands/graph.rs`
+
+**Step 1: Color the `format_path_text` function**
+
+In `format_path_text()`:
+- Step labels ("Step 1:", "Step 2:"): `fmt_section`
+- Problem names in steps: `fmt_problem_name`
+- Arrow `→`: `fmt_outgoing`
+
+```rust
+fn format_path_text(
+    graph: &ReductionGraph,
+    reduction_path: &problemreductions::rules::ReductionPath,
+) -> String {
+    let mut text = format!(
+        "Path ({} steps): {}\n",
+        reduction_path.len(),
+        reduction_path
+    );
+
+    let overheads = graph.path_overheads(reduction_path);
+    let steps = &reduction_path.steps;
+    for i in 0..steps.len().saturating_sub(1) {
+        let from = &steps[i];
+        let to = &steps[i + 1];
+        text.push_str(&format!(
+            "\n  {}: {} {} {}\n",
+            crate::output::fmt_section(&format!("Step {}", i + 1)),
+            crate::output::fmt_problem_name(&from.to_string()),
+            crate::output::fmt_outgoing("→"),
+            crate::output::fmt_problem_name(&to.to_string()),
+        ));
+        let oh = &overheads[i];
+        for (field, poly) in &oh.output_size {
+            text.push_str(&format!("    {field} = {poly}\n"));
+        }
+    }
+
+    text
+}
+```
+
+**Step 2: Run existing path tests**
+
+Run: `cargo test -p problemreductions-cli test_path`
+Expected: All path tests pass (non-TTY = no ANSI codes).
+
+**Step 3: Commit**
+
+```
+feat(cli): add colors to pred path output
+```
+
+---
+
+### Task 7: k-neighbor BFS — library method with tests
+
+**Files:**
+- Modify: `src/rules/graph.rs`
+- Modify: `src/unit_tests/reduction_graph.rs`
+
+**Step 1: Add types and method to `ReductionGraph`**
+
+In `src/rules/graph.rs`, add the public types before the `ReductionGraph` struct:
+
+```rust
+/// Information about a neighbor in the reduction graph.
+#[derive(Debug, Clone)]
+pub struct NeighborInfo {
+    /// Problem name.
+    pub name: &'static str,
+    /// Variant attributes.
+    pub variant: BTreeMap<String, String>,
+    /// Hop distance from the source.
+    pub hops: usize,
+}
+
+/// Direction for graph traversal.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum TraversalDirection {
+    /// Follow outgoing edges (what can this reduce to?).
+    Outgoing,
+    /// Follow incoming edges (what can reduce to this?).
+    Incoming,
+    /// Follow edges in both directions.
+    Both,
+}
+```
+
+Add the BFS method to the `impl ReductionGraph` block (after `incoming_reductions`):
+
+```rust
+/// Find all problems reachable within `max_hops` edges from a starting node.
+///
+/// Returns neighbors sorted by (hops, name). The starting node itself is excluded.
+/// If a node is reachable at multiple distances, it appears at the shortest distance only.
+pub fn k_neighbors(
+    &self,
+    name: &str,
+    variant: &BTreeMap<String, String>,
+    max_hops: usize,
+    direction: TraversalDirection,
+) -> Vec<NeighborInfo> {
+    use petgraph::Direction;
+    use std::collections::VecDeque;
+
+    // Find the starting node index
+    let start = self.name_to_nodes.get(name).and_then(|indices| {
+        indices.iter().find(|&&idx| {
+            let node = &self.nodes[self.graph[idx]];
+            node.variant == *variant
+        }).copied()
+    });
+
+    let Some(start_idx) = start else {
+        return vec![];
+    };
+
+    let mut visited: HashSet<NodeIndex> = HashSet::new();
+    visited.insert(start_idx);
+    let mut queue: VecDeque<(NodeIndex, usize)> = VecDeque::new();
+    queue.push_back((start_idx, 0));
+    let mut results: Vec<NeighborInfo> = Vec::new();
+
+    while let Some((node_idx, hops)) = queue.pop_front() {
+        if hops >= max_hops {
+            continue;
+        }
+
+        let directions: Vec<Direction> = match direction {
+            TraversalDirection::Outgoing => vec![Direction::Outgoing],
+            TraversalDirection::Incoming => vec![Direction::Incoming],
+            TraversalDirection::Both => vec![Direction::Outgoing, Direction::Incoming],
+        };
+
+        for dir in directions {
+            for neighbor_idx in self.graph.neighbors_directed(node_idx, dir) {
+                if visited.insert(neighbor_idx) {
+                    let neighbor_node = &self.nodes[self.graph[neighbor_idx]];
+                    results.push(NeighborInfo {
+                        name: neighbor_node.name,
+                        variant: neighbor_node.variant.clone(),
+                        hops: hops + 1,
+                    });
+                    queue.push_back((neighbor_idx, hops + 1));
+                }
+            }
+        }
+    }
+
+    results.sort_by(|a, b| a.hops.cmp(&b.hops).then_with(|| a.name.cmp(&b.name)));
+    results
+}
+```
+
+**Step 2: Export the new types from `src/rules/mod.rs`**
+
+In `src/rules/mod.rs`, update the `pub use graph::` line:
+
+```rust
+pub use graph::{
+    NeighborInfo, ReductionChain, ReductionEdgeInfo, ReductionGraph, ReductionPath,
+    ReductionStep, TraversalDirection,
+};
+```
+
+**Step 3: Write unit tests**
+
+In `src/unit_tests/reduction_graph.rs`, add:
+
+```rust
+#[test]
+fn test_k_neighbors_outgoing() {
+    let graph = ReductionGraph::new();
+    let variants = graph.variants_for("MaximumIndependentSet");
+    assert!(!variants.is_empty());
+    let default_variant = &variants[0];
+
+    // 1-hop outgoing: should include direct reduction targets
+    let neighbors = graph.k_neighbors(
+        "MaximumIndependentSet",
+        default_variant,
+        1,
+        TraversalDirection::Outgoing,
+    );
+    assert!(!neighbors.is_empty());
+    assert!(neighbors.iter().all(|n| n.hops == 1));
+
+    // 2-hop outgoing: should include more problems
+    let neighbors_2 = graph.k_neighbors(
+        "MaximumIndependentSet",
+        default_variant,
+        2,
+        TraversalDirection::Outgoing,
+    );
+    assert!(neighbors_2.len() >= neighbors.len());
+}
+
+#[test]
+fn test_k_neighbors_incoming() {
+    let graph = ReductionGraph::new();
+    let variants = graph.variants_for("QUBO");
+    assert!(!variants.is_empty());
+
+    let neighbors = graph.k_neighbors(
+        "QUBO",
+        &variants[0],
+        1,
+        TraversalDirection::Incoming,
+    );
+    // QUBO is a common target — should have incoming reductions
+    assert!(!neighbors.is_empty());
+}
+
+#[test]
+fn test_k_neighbors_both() {
+    let graph = ReductionGraph::new();
+    let variants = graph.variants_for("MaximumIndependentSet");
+    let default_variant = &variants[0];
+
+    let out_only = graph.k_neighbors(
+        "MaximumIndependentSet", default_variant, 1, TraversalDirection::Outgoing,
+    );
+    let in_only = graph.k_neighbors(
+        "MaximumIndependentSet", default_variant, 1, TraversalDirection::Incoming,
+    );
+    let both = graph.k_neighbors(
+        "MaximumIndependentSet", default_variant, 1, TraversalDirection::Both,
+    );
+    // Both should be >= max of either direction
+    assert!(both.len() >= out_only.len());
+    assert!(both.len() >= in_only.len());
+}
+
+#[test]
+fn test_k_neighbors_unknown_problem() {
+    let graph = ReductionGraph::new();
+    let empty = BTreeMap::new();
+    let neighbors = graph.k_neighbors("NonExistent", &empty, 2, TraversalDirection::Outgoing);
+    assert!(neighbors.is_empty());
+}
+
+#[test]
+fn test_k_neighbors_zero_hops() {
+    let graph = ReductionGraph::new();
+    let variants = graph.variants_for("MaximumIndependentSet");
+    let default_variant = &variants[0];
+    let neighbors = graph.k_neighbors(
+        "MaximumIndependentSet", default_variant, 0, TraversalDirection::Outgoing,
+    );
+    assert!(neighbors.is_empty());
+}
+```
+
+**Step 4: Run tests**
+
+Run: `cargo test -p problemreductions test_k_neighbors`
+Expected: All 5 tests pass.
+
+**Step 5: Commit**
+
+```
+feat(lib): add k_neighbors BFS method to ReductionGraph
+```
+
+---
+
+### Task 8: k-neighbor CLI — tree renderer and show integration
+
+**Files:**
+- Modify: `problemreductions-cli/src/cli.rs`
+- Modify: `problemreductions-cli/src/commands/graph.rs`
+
+**Step 1: Add `--hops` and `--direction` flags to `Commands::Show`**
+
+In `problemreductions-cli/src/cli.rs`, change `Show` from a simple struct variant to a named-fields variant:
+
+```rust
+/// Show details for a problem type (variants, fields, reductions)
+#[command(after_help = "\
+Examples:
+  pred show MIS                   # using alias
+  pred show MaximumIndependentSet # full name
+  pred show MIS/UnitDiskGraph     # specific graph variant
+  pred show MIS --hops 2          # 2-hop outgoing neighbor tree
+  pred show MIS --hops 2 --direction in  # incoming neighbors
+
+Use `pred list` to see all available problem types and aliases.")]
+Show {
+    /// Problem name or alias (e.g., MIS, QUBO, MIS/UnitDiskGraph)
+    problem: String,
+    /// Explore k-hop neighbors in the reduction graph
+    #[arg(long)]
+    hops: Option<usize>,
+    /// Direction for neighbor exploration: out, in, both [default: out]
+    #[arg(long, default_value = "out")]
+    direction: String,
+},
+```
+
+**Step 2: Update `main.rs` dispatch**
+
+In `problemreductions-cli/src/main.rs`, update the `Show` match arm:
+
+```rust
+Commands::Show { problem, hops, direction } => {
+    commands::graph::show(&problem, hops, &direction, &out)
+}
+```
+
+**Step 3: Update `show()` signature and add branching**
+
+In `problemreductions-cli/src/commands/graph.rs`, update the `show` function:
+
+```rust
+pub fn show(problem: &str, hops: Option<usize>, direction: &str, out: &OutputConfig) -> Result<()> {
+    let spec = parse_problem_spec(problem)?;
+    let graph = ReductionGraph::new();
+
+    let variants = graph.variants_for(&spec.name);
+    if variants.is_empty() {
+        anyhow::bail!("Unknown problem: {}", spec.name);
+    }
+
+    if let Some(max_hops) = hops {
+        return show_neighbors(&graph, &spec, &variants, max_hops, direction, out);
+    }
+
+    // ... existing show logic unchanged ...
+}
+```
+
+**Step 4: Implement `show_neighbors` with tree rendering**
+
+Add a new function in `commands/graph.rs`:
+
+```rust
+use problemreductions::rules::{NeighborInfo, TraversalDirection};
+
+fn parse_direction(s: &str) -> Result<TraversalDirection> {
+    match s {
+        "out" => Ok(TraversalDirection::Outgoing),
+        "in" => Ok(TraversalDirection::Incoming),
+        "both" => Ok(TraversalDirection::Both),
+        _ => anyhow::bail!(
+            "Unknown direction: {}. Use 'out', 'in', or 'both'.",
+            s
+        ),
+    }
+}
+
+fn show_neighbors(
+    graph: &ReductionGraph,
+    spec: &crate::problem_name::ProblemSpec,
+    variants: &[BTreeMap<String, String>],
+    max_hops: usize,
+    direction_str: &str,
+    out: &OutputConfig,
+) -> Result<()> {
+    let direction = parse_direction(direction_str)?;
+
+    let variant = if spec.variant_values.is_empty() {
+        variants[0].clone()
+    } else {
+        resolve_variant(spec, variants)?
+    };
+
+    let neighbors = graph.k_neighbors(&spec.name, &variant, max_hops, direction);
+
+    let dir_label = match direction {
+        TraversalDirection::Outgoing => "outgoing",
+        TraversalDirection::Incoming => "incoming",
+        TraversalDirection::Both => "both directions",
+    };
+
+    // Build tree structure: group by parent chain
+    // For a tree view, we do a fresh BFS that tracks parent relationships
+    let tree = build_neighbor_tree(graph, &spec.name, &variant, max_hops, direction);
+
+    let mut text = format!(
+        "{} — {}-hop neighbors ({})\n\n",
+        crate::output::fmt_problem_name(&spec.name),
+        max_hops,
+        dir_label,
+    );
+
+    text.push_str(&crate::output::fmt_problem_name(&spec.name));
+    text.push('\n');
+    render_tree(&tree, &mut text, "", true);
+
+    // 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(),
+        max_hops,
+    ));
+
+    let json = serde_json::json!({
+        "source": spec.name,
+        "hops": max_hops,
+        "direction": direction_str,
+        "neighbors": neighbors.iter().map(|n| {
+            serde_json::json!({
+                "name": n.name,
+                "variant": n.variant,
+                "hops": n.hops,
+            })
+        }).collect::<Vec<_>>(),
+    });
+
+    let default_name = format!("pred_show_{}_hops{}.json", spec.name, max_hops);
+    out.emit_with_default_name(&default_name, &text, &json)
+}
+
+/// Tree node for neighbor rendering.
+struct TreeNode {
+    name: String,
+    children: Vec<TreeNode>,
+}
+
+/// Build a tree of neighbors via BFS, tracking parent relationships.
+fn build_neighbor_tree(
+    graph: &ReductionGraph,
+    name: &str,
+    variant: &BTreeMap<String, String>,
+    max_hops: usize,
+    direction: TraversalDirection,
+) -> Vec<TreeNode> {
+    use petgraph::Direction;
+    use std::collections::VecDeque;
+
+    let start = graph.find_node_index(name, variant);
+    let Some(start_idx) = start else {
+        return vec![];
+    };
+
+    // BFS with parent tracking to build a tree
+    let mut visited: HashSet<petgraph::graph::NodeIndex> = HashSet::new();
+    visited.insert(start_idx);
+
+    // (node_idx, depth) -> children to fill
+    struct BfsItem {
+        node_idx: petgraph::graph::NodeIndex,
+        depth: usize,
+    }
+
+    let mut queue: VecDeque<BfsItem> = VecDeque::new();
+    queue.push_back(BfsItem { node_idx: start_idx, depth: 0 });
+
+    // Map from node_idx -> TreeNode
+    let mut node_children: HashMap<petgraph::graph::NodeIndex, Vec<petgraph::graph::NodeIndex>> =
+        HashMap::new();
+
+    while let Some(item) = queue.pop_front() {
+        if item.depth >= max_hops {
+            continue;
+        }
+
+        let directions: Vec<Direction> = match direction {
+            TraversalDirection::Outgoing => vec![Direction::Outgoing],
+            TraversalDirection::Incoming => vec![Direction::Incoming],
+            TraversalDirection::Both => vec![Direction::Outgoing, Direction::Incoming],
+        };
+
+        let mut children = Vec::new();
+        for dir in directions {
+            for neighbor_idx in graph.neighbor_indices(item.node_idx, dir) {
+                if visited.insert(neighbor_idx) {
+                    children.push(neighbor_idx);
+                    queue.push_back(BfsItem { node_idx: neighbor_idx, depth: item.depth + 1 });
+                }
+            }
+        }
+        children.sort_by(|a, b| {
+            let na = graph.node_name(*a);
+            let nb = graph.node_name(*b);
+            na.cmp(&nb)
+        });
+        node_children.insert(item.node_idx, children);
+    }
+
+    // Recursively build TreeNode from start's children
+    fn build_tree(
+        idx: petgraph::graph::NodeIndex,
+        node_children: &HashMap<petgraph::graph::NodeIndex, Vec<petgraph::graph::NodeIndex>>,
+        graph: &ReductionGraph,
+    ) -> TreeNode {
+        let children = node_children
+            .get(&idx)
+            .map(|cs| cs.iter().map(|&c| build_tree(c, node_children, graph)).collect())
+            .unwrap_or_default();
+        TreeNode {
+            name: graph.node_name(idx).to_string(),
+            children,
+        }
+    }
+
+    node_children
+        .get(&start_idx)
+        .map(|cs| cs.iter().map(|&c| build_tree(c, &node_children, graph)).collect())
+        .unwrap_or_default()
+}
+
+/// Render a tree with box-drawing characters.
+fn render_tree(nodes: &[TreeNode], text: &mut String, prefix: &str, is_root: bool) {
+    for (i, node) in nodes.iter().enumerate() {
+        let is_last = i == nodes.len() - 1;
+        let connector = if is_last { "└── " } else { "├── " };
+        let child_prefix = if is_last { "    " } else { "│   " };
+
+        text.push_str(&format!(
+            "{}{}{}\n",
+            crate::output::fmt_dim(prefix),
+            crate::output::fmt_dim(connector),
+            crate::output::fmt_problem_name(&node.name),
+        ));
+
+        if !node.children.is_empty() {
+            let new_prefix = format!("{}{}", prefix, child_prefix);
+            render_tree(&node.children, text, &new_prefix, false);
+        }
+    }
+}
+```
+
+**Step 5: Add helper methods to `ReductionGraph`**
+
+The tree builder needs `find_node_index`, `neighbor_indices`, and `node_name` on `ReductionGraph`. Add these to `src/rules/graph.rs`:
+
+```rust
+/// Find the NodeIndex for a specific (name, variant) pair.
+pub fn find_node_index(&self, name: &str, variant: &BTreeMap<String, String>) -> Option<NodeIndex> {
+    self.name_to_nodes.get(name).and_then(|indices| {
+        indices.iter().find(|&&idx| {
+            let node = &self.nodes[self.graph[idx]];
+            node.variant == *variant
+        }).copied()
+    })
+}
+
+/// Get neighbors of a node in a specific direction.
+pub fn neighbor_indices(&self, idx: NodeIndex, dir: petgraph::Direction) -> Vec<NodeIndex> {
+    self.graph.neighbors_directed(idx, dir).collect()
+}
+
+/// Get the problem name for a node index.
+pub fn node_name(&self, idx: NodeIndex) -> &str {
+    self.nodes[self.graph[idx]].name
+}
+```
+
+Also export them from `src/rules/mod.rs` (they're inherent methods, so just exporting `ReductionGraph` is enough).
+
+**Step 6: Build and test**
+
+Run: `cargo run -p problemreductions-cli -- show MIS --hops 2`
+Expected: Tree output showing 2-hop outgoing neighbors of MIS.
+
+Run: `cargo run -p problemreductions-cli -- show MIS --hops 2 --direction in`
+Expected: Tree output showing 2-hop incoming neighbors.
+
+**Step 7: Commit**
+
+```
+feat(cli): add --hops and --direction flags to pred show for neighbor exploration
+```
+
+---
+
+### Task 9: k-neighbor CLI — integration tests
+
+**Files:**
+- Modify: `problemreductions-cli/tests/cli_tests.rs`
+
+**Step 1: Write integration tests**
+
+Add to `problemreductions-cli/tests/cli_tests.rs`:
+
+```rust
+#[test]
+fn test_show_hops_outgoing() {
+    let output = pred()
+        .args(["show", "MIS", "--hops", "2"])
+        .output()
+        .unwrap();
+    assert!(
+        output.status.success(),
+        "stderr: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+    let stdout = String::from_utf8(output.stdout).unwrap();
+    assert!(stdout.contains("MaximumIndependentSet"));
+    assert!(stdout.contains("reachable problems"));
+    // Should contain tree characters
+    assert!(stdout.contains("├── ") || stdout.contains("└── "));
+}
+
+#[test]
+fn test_show_hops_incoming() {
+    let output = pred()
+        .args(["show", "QUBO", "--hops", "1", "--direction", "in"])
+        .output()
+        .unwrap();
+    assert!(
+        output.status.success(),
+        "stderr: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+    let stdout = String::from_utf8(output.stdout).unwrap();
+    assert!(stdout.contains("QUBO"));
+    assert!(stdout.contains("incoming"));
+}
+
+#[test]
+fn test_show_hops_both() {
+    let output = pred()
+        .args(["show", "MIS", "--hops", "1", "--direction", "both"])
+        .output()
+        .unwrap();
+    assert!(
+        output.status.success(),
+        "stderr: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+    let stdout = String::from_utf8(output.stdout).unwrap();
+    assert!(stdout.contains("both directions"));
+}
+
+#[test]
+fn test_show_hops_json() {
+    let tmp = std::env::temp_dir().join("pred_test_show_hops.json");
+    let output = pred()
+        .args(["-o", tmp.to_str().unwrap(), "show", "MIS", "--hops", "2"])
+        .output()
+        .unwrap();
+    assert!(output.status.success());
+    assert!(tmp.exists());
+    let content = std::fs::read_to_string(&tmp).unwrap();
+    let json: serde_json::Value = serde_json::from_str(&content).unwrap();
+    assert_eq!(json["source"], "MaximumIndependentSet");
+    assert_eq!(json["hops"], 2);
+    assert!(json["neighbors"].is_array());
+    std::fs::remove_file(&tmp).ok();
+}
+
+#[test]
+fn test_show_hops_bad_direction() {
+    let output = pred()
+        .args(["show", "MIS", "--hops", "1", "--direction", "bad"])
+        .output()
+        .unwrap();
+    assert!(!output.status.success());
+    let stderr = String::from_utf8_lossy(&output.stderr);
+    assert!(stderr.contains("Unknown direction"));
+}
+```
+
+**Step 2: Run all CLI tests**
+
+Run: `cargo test -p problemreductions-cli`
+Expected: All tests pass, including both new and existing ones.
+
+**Step 3: Commit**
+
+```
+test(cli): add integration tests for k-neighbor exploration
+```
+
+---
+
+### Task 10: Final verification and cleanup
+
+**Files:**
+- None (verification only)
+
+**Step 1: Run full test suite**
+
+Run: `make test`
+Expected: All tests pass.
+
+**Step 2: Run clippy**
+
+Run: `make clippy`
+Expected: No warnings.
+
+**Step 3: Run fmt check**
+
+Run: `make fmt-check`
+Expected: No formatting issues.
+
+**Step 4: Update issue #81**
+
+Check off the completed items in issue #81:
+- [x] Shell completions
+- [x] Colored/table output
+- [x] k-neighbor exploration
+
+Note that ILP solver integration and All paths were already implemented in v1.
+
+**Step 5: Commit any final cleanup**
+
+If any cleanup was needed, commit with:
+```
+chore(cli): cleanup for v2 features
+```

From fd4721c6ce6c2aa8d49df806850c567e06ca3914 Mon Sep 17 00:00:00 2001
From: GiggleLiu <cacate0129@gmail.com>
Date: Wed, 18 Feb 2026 22:15:20 +0800
Subject: [PATCH 03/34] feat(cli): add shell completions command
 (bash/zsh/fish)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
 problemreductions-cli/Cargo.toml  |  1 +
 problemreductions-cli/src/cli.rs  | 10 ++++++++++
 problemreductions-cli/src/main.rs |  7 ++++++-
 3 files changed, 17 insertions(+), 1 deletion(-)

diff --git a/problemreductions-cli/Cargo.toml b/problemreductions-cli/Cargo.toml
index dffcf5d22..7f82098cb 100644
--- a/problemreductions-cli/Cargo.toml
+++ b/problemreductions-cli/Cargo.toml
@@ -24,3 +24,4 @@ clap = { version = "4", features = ["derive"] }
 anyhow = "1"
 serde = { version = "1", features = ["derive"] }
 serde_json = "1"
+clap_complete = "4"
diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs
index 724859e48..ef34f7b54 100644
--- a/problemreductions-cli/src/cli.rs
+++ b/problemreductions-cli/src/cli.rs
@@ -86,6 +86,16 @@ Example:
     Reduce(ReduceArgs),
     /// Solve a problem instance
     Solve(SolveArgs),
+    /// Generate shell completions for bash, zsh, fish, etc.
+    #[command(after_help = "\
+Examples:
+  pred completions bash > ~/.local/share/bash-completion/completions/pred
+  pred completions zsh > ~/.zfunc/_pred
+  pred completions fish > ~/.config/fish/completions/pred.fish")]
+    Completions {
+        /// Shell type
+        shell: clap_complete::Shell,
+    },
 }
 
 #[derive(clap::Args)]
diff --git a/problemreductions-cli/src/main.rs b/problemreductions-cli/src/main.rs
index aac972d18..2dec26e3a 100644
--- a/problemreductions-cli/src/main.rs
+++ b/problemreductions-cli/src/main.rs
@@ -5,7 +5,7 @@ mod output;
 mod problem_name;
 
 use cli::{Cli, Commands};
-use clap::Parser;
+use clap::{CommandFactory, Parser};
 use output::OutputConfig;
 
 fn main() -> anyhow::Result<()> {
@@ -46,5 +46,10 @@ fn main() -> anyhow::Result<()> {
             commands::reduce::reduce(&args.input, &args.to, args.via.as_deref(), &out)
         }
         Commands::Evaluate(args) => commands::evaluate::evaluate(&args.input, &args.config, &out),
+        Commands::Completions { shell } => {
+            let mut cmd = Cli::command();
+            clap_complete::generate(shell, &mut cmd, "pred", &mut std::io::stdout());
+            Ok(())
+        }
     }
 }

From aa0978a2e27831114fa70ea184c45740c8b03e5b Mon Sep 17 00:00:00 2001
From: GiggleLiu <cacate0129@gmail.com>
Date: Wed, 18 Feb 2026 22:17:09 +0800
Subject: [PATCH 04/34] test(cli): add integration tests for shell completions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
 problemreductions-cli/tests/cli_tests.rs | 31 ++++++++++++++++++++++++
 1 file changed, 31 insertions(+)

diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs
index 06fc53409..9f075fed0 100644
--- a/problemreductions-cli/tests/cli_tests.rs
+++ b/problemreductions-cli/tests/cli_tests.rs
@@ -1210,3 +1210,34 @@ fn test_subcommand_help() {
     assert!(stdout.contains("brute-force"));
     assert!(stdout.contains("pred create"));
 }
+
+// ---- Shell completions tests ----
+
+#[test]
+fn test_completions_bash() {
+    let output = pred().args(["completions", "bash"]).output().unwrap();
+    assert!(
+        output.status.success(),
+        "stderr: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+    let stdout = String::from_utf8(output.stdout).unwrap();
+    // Bash completions should reference the binary name
+    assert!(stdout.contains("pred"), "completions should reference 'pred'");
+}
+
+#[test]
+fn test_completions_zsh() {
+    let output = pred().args(["completions", "zsh"]).output().unwrap();
+    assert!(output.status.success());
+    let stdout = String::from_utf8(output.stdout).unwrap();
+    assert!(stdout.contains("pred"));
+}
+
+#[test]
+fn test_completions_fish() {
+    let output = pred().args(["completions", "fish"]).output().unwrap();
+    assert!(output.status.success());
+    let stdout = String::from_utf8(output.stdout).unwrap();
+    assert!(stdout.contains("pred"));
+}

From 94fab2d0a241db6510ff25d39344b3bc5ff032cc Mon Sep 17 00:00:00 2001
From: GiggleLiu <cacate0129@gmail.com>
Date: Wed, 18 Feb 2026 22:18:36 +0800
Subject: [PATCH 05/34] feat(cli): add owo-colors dependency and color helper
 functions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
 problemreductions-cli/Cargo.toml    |  1 +
 problemreductions-cli/src/output.rs | 56 +++++++++++++++++++++++++++++
 2 files changed, 57 insertions(+)

diff --git a/problemreductions-cli/Cargo.toml b/problemreductions-cli/Cargo.toml
index 7f82098cb..ab83efe08 100644
--- a/problemreductions-cli/Cargo.toml
+++ b/problemreductions-cli/Cargo.toml
@@ -25,3 +25,4 @@ anyhow = "1"
 serde = { version = "1", features = ["derive"] }
 serde_json = "1"
 clap_complete = "4"
+owo-colors = { version = "4", features = ["supports-colors"] }
diff --git a/problemreductions-cli/src/output.rs b/problemreductions-cli/src/output.rs
index 17a7297a4..6114cfc4c 100644
--- a/problemreductions-cli/src/output.rs
+++ b/problemreductions-cli/src/output.rs
@@ -1,4 +1,6 @@
 use anyhow::Context;
+use owo_colors::OwoColorize;
+use std::io::IsTerminal;
 use std::path::PathBuf;
 
 /// Output configuration derived from CLI flags.
@@ -28,3 +30,57 @@ impl OutputConfig {
         Ok(())
     }
 }
+
+/// Whether colored output should be used (TTY + not NO_COLOR).
+pub fn use_color() -> bool {
+    std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none()
+}
+
+/// Format a problem name (bold when color is enabled).
+pub fn fmt_problem_name(name: &str) -> String {
+    if use_color() {
+        format!("{}", name.bold())
+    } else {
+        name.to_string()
+    }
+}
+
+/// Format a section header (cyan when color is enabled).
+pub fn fmt_section(text: &str) -> String {
+    if use_color() {
+        format!("{}", text.cyan())
+    } else {
+        text.to_string()
+    }
+}
+
+/// Format an outgoing arrow (green when color is enabled).
+pub fn fmt_arrow_out() -> &'static str {
+    // We return static str, so we use ANSI directly for the arrow
+    "\u{2192}"
+}
+
+pub fn fmt_outgoing(text: &str) -> String {
+    if use_color() {
+        format!("{}", text.green())
+    } else {
+        text.to_string()
+    }
+}
+
+pub fn fmt_incoming(text: &str) -> String {
+    if use_color() {
+        format!("{}", text.red())
+    } else {
+        text.to_string()
+    }
+}
+
+/// Format dim text (for aliases, tree branches).
+pub fn fmt_dim(text: &str) -> String {
+    if use_color() {
+        format!("{}", text.dimmed())
+    } else {
+        text.to_string()
+    }
+}

From 240821bfebcfa1faf95de390c9114cca3b9d99cb Mon Sep 17 00:00:00 2001
From: GiggleLiu <cacate0129@gmail.com>
Date: Wed, 18 Feb 2026 22:20:20 +0800
Subject: [PATCH 06/34] feat(cli): add aligned columns and color to pred list

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
 problemreductions-cli/src/commands/graph.rs | 99 ++++++++++++++++++---
 1 file changed, 87 insertions(+), 12 deletions(-)

diff --git a/problemreductions-cli/src/commands/graph.rs b/problemreductions-cli/src/commands/graph.rs
index f8e31bdb1..f47744ea4 100644
--- a/problemreductions-cli/src/commands/graph.rs
+++ b/problemreductions-cli/src/commands/graph.rs
@@ -13,31 +13,106 @@ pub fn list(out: &OutputConfig) -> Result<()> {
     let mut types = graph.problem_types();
     types.sort();
 
-    let mut text = format!(
-        "Registered problems: {} types, {} reductions, {} variant nodes\n\n",
+    // Collect data for each problem
+    struct Row {
+        name: String,
+        aliases: Vec<&'static str>,
+        num_variants: usize,
+        num_reduces_to: usize,
+    }
+    let rows: Vec<Row> = types
+        .iter()
+        .map(|name| {
+            let aliases = aliases_for(name);
+            let num_variants = graph.variants_for(name).len();
+            let num_reduces_to = graph.outgoing_reductions(name).len();
+            Row {
+                name: name.to_string(),
+                aliases,
+                num_variants,
+                num_reduces_to,
+            }
+        })
+        .collect();
+
+    // Compute column widths
+    let name_width = rows.iter().map(|r| r.name.len()).max().unwrap_or(7).max(7);
+    let alias_width = rows
+        .iter()
+        .map(|r| {
+            if r.aliases.is_empty() {
+                0
+            } else {
+                r.aliases.join(", ").len()
+            }
+        })
+        .max()
+        .unwrap_or(7)
+        .max(7);
+
+    let summary = format!(
+        "Registered problems: {} types, {} reductions, {} variant nodes\n",
         graph.num_types(),
         graph.num_reductions(),
         graph.num_variant_nodes(),
     );
 
-    for name in &types {
-        let aliases = aliases_for(name);
-        if aliases.is_empty() {
-            text.push_str(&format!("  {name}\n"));
+    let mut text = String::new();
+    text.push_str(&crate::output::fmt_section(&summary));
+    text.push_str(&format!(
+        "\n  {:<name_w$}  {:<alias_w$}  {:>8}  {:>10}\n",
+        "Problem",
+        "Aliases",
+        "Variants",
+        "Reduces to",
+        name_w = name_width,
+        alias_w = alias_width,
+    ));
+    text.push_str(&format!(
+        "  {:<name_w$}  {:<alias_w$}  {:>8}  {:>10}\n",
+        "─".repeat(name_width),
+        "─".repeat(alias_width),
+        "────────",
+        "──────────",
+        name_w = name_width,
+        alias_w = alias_width,
+    ));
+
+    for row in &rows {
+        let alias_str = if row.aliases.is_empty() {
+            String::new()
         } else {
-            text.push_str(&format!("  {name} ({})\n", aliases.join(", ")));
-        }
+            row.aliases.join(", ")
+        };
+        // Refined approach: pad first, then colorize
+        let padded_name = format!("{:<name_w$}", row.name, name_w = name_width);
+        let colored_name = crate::output::fmt_problem_name(&padded_name);
+        let padded_alias = format!("{:<alias_w$}", alias_str, alias_w = alias_width);
+        let colored_alias = crate::output::fmt_dim(&padded_alias);
+        text.push_str(&format!(
+            "  {}  {}  {:>8}  {:>10}\n",
+            colored_name,
+            colored_alias,
+            row.num_variants,
+            row.num_reduces_to,
+        ));
     }
 
-    text.push_str("\nUse `pred show <problem>` to see variants, reductions, and fields.\n");
+    text.push_str(&format!(
+        "\nUse `pred show <problem>` to see variants, reductions, and fields.\n"
+    ));
 
     let json = serde_json::json!({
         "num_types": graph.num_types(),
         "num_reductions": graph.num_reductions(),
         "num_variant_nodes": graph.num_variant_nodes(),
-        "problems": types.iter().map(|name| {
-            let aliases = aliases_for(name);
-            serde_json::json!({ "name": name, "aliases": aliases })
+        "problems": rows.iter().map(|r| {
+            serde_json::json!({
+                "name": r.name,
+                "aliases": r.aliases,
+                "num_variants": r.num_variants,
+                "num_reduces_to": r.num_reduces_to,
+            })
         }).collect::<Vec<_>>(),
     });
 

From d36c0fe8930f2435799e1b06d327cb7b9f740ab6 Mon Sep 17 00:00:00 2001
From: GiggleLiu <cacate0129@gmail.com>
Date: Wed, 18 Feb 2026 22:22:29 +0800
Subject: [PATCH 07/34] feat(cli): add colors to pred show output

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
 problemreductions-cli/src/commands/graph.rs | 22 +++++++++++----------
 1 file changed, 12 insertions(+), 10 deletions(-)

diff --git a/problemreductions-cli/src/commands/graph.rs b/problemreductions-cli/src/commands/graph.rs
index f47744ea4..ad4bd190e 100644
--- a/problemreductions-cli/src/commands/graph.rs
+++ b/problemreductions-cli/src/commands/graph.rs
@@ -128,7 +128,7 @@ pub fn show(problem: &str, out: &OutputConfig) -> Result<()> {
         anyhow::bail!("Unknown problem: {}", spec.name);
     }
 
-    let mut text = format!("{}\n", spec.name);
+    let mut text = format!("{}\n", crate::output::fmt_problem_name(&spec.name));
 
     // Show description from schema
     let schemas = collect_schemas();
@@ -140,14 +140,14 @@ pub fn show(problem: &str, out: &OutputConfig) -> Result<()> {
     }
 
     // Show variants
-    text.push_str(&format!("\nVariants ({}):\n", variants.len()));
+    text.push_str(&format!("\n{}\n", crate::output::fmt_section(&format!("Variants ({}):", variants.len()))));
     for v in &variants {
         text.push_str(&format!("  {}\n", format_variant(v)));
     }
 
     // Show fields from schema (right after variants)
     if let Some(s) = schema {
-        text.push_str(&format!("\nFields ({}):\n", s.fields.len()));
+        text.push_str(&format!("\n{}\n", crate::output::fmt_section(&format!("Fields ({}):", s.fields.len()))));
         for field in &s.fields {
             text.push_str(&format!("  {} ({})", field.name, field.type_name));
             if !field.description.is_empty() {
@@ -160,7 +160,7 @@ pub fn show(problem: &str, out: &OutputConfig) -> Result<()> {
     // Show size fields (used with `pred path --cost minimize:<field>`)
     let size_fields = graph.size_field_names(&spec.name);
     if !size_fields.is_empty() {
-        text.push_str(&format!("\nSize fields ({}):\n", size_fields.len()));
+        text.push_str(&format!("\n{}\n", crate::output::fmt_section(&format!("Size fields ({}):", size_fields.len()))));
         for f in size_fields {
             text.push_str(&format!("  {f}\n"));
         }
@@ -170,23 +170,25 @@ pub fn show(problem: &str, out: &OutputConfig) -> Result<()> {
     let outgoing = graph.outgoing_reductions(&spec.name);
     let incoming = graph.incoming_reductions(&spec.name);
 
-    text.push_str(&format!("\nReduces to ({}):\n", outgoing.len()));
+    text.push_str(&format!("\n{}\n", crate::output::fmt_section(&format!("Reduces to ({}):", outgoing.len()))));
     for e in &outgoing {
         text.push_str(&format!(
-            "  {} {} -> {} {}\n",
+            "  {} {} {} {} {}\n",
             e.source_name,
             format_variant(&e.source_variant),
-            e.target_name,
+            crate::output::fmt_outgoing("\u{2192}"),
+            crate::output::fmt_problem_name(e.target_name),
             format_variant(&e.target_variant),
         ));
     }
 
-    text.push_str(&format!("\nReduces from ({}):\n", incoming.len()));
+    text.push_str(&format!("\n{}\n", crate::output::fmt_section(&format!("Reduces from ({}):", incoming.len()))));
     for e in &incoming {
         text.push_str(&format!(
-            "  {} {} -> {} {}\n",
-            e.source_name,
+            "  {} {} {} {} {}\n",
+            crate::output::fmt_problem_name(e.source_name),
             format_variant(&e.source_variant),
+            crate::output::fmt_incoming("\u{2192}"),
             e.target_name,
             format_variant(&e.target_variant),
         ));

From 0006ca075bee07a3a3e06c89b965162d213d93bc Mon Sep 17 00:00:00 2001
From: GiggleLiu <cacate0129@gmail.com>
Date: Wed, 18 Feb 2026 22:24:06 +0800
Subject: [PATCH 08/34] feat(cli): add colors to pred path output

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
 problemreductions-cli/src/commands/graph.rs | 8 +++++++-
 1 file changed, 7 insertions(+), 1 deletion(-)

diff --git a/problemreductions-cli/src/commands/graph.rs b/problemreductions-cli/src/commands/graph.rs
index ad4bd190e..a2441317d 100644
--- a/problemreductions-cli/src/commands/graph.rs
+++ b/problemreductions-cli/src/commands/graph.rs
@@ -239,7 +239,13 @@ fn format_path_text(
     for i in 0..steps.len().saturating_sub(1) {
         let from = &steps[i];
         let to = &steps[i + 1];
-        text.push_str(&format!("\n  Step {}: {} → {}\n", i + 1, from, to));
+        text.push_str(&format!(
+            "\n  {}: {} {} {}\n",
+            crate::output::fmt_section(&format!("Step {}", i + 1)),
+            crate::output::fmt_problem_name(&from.to_string()),
+            crate::output::fmt_outgoing("→"),
+            crate::output::fmt_problem_name(&to.to_string()),
+        ));
         let oh = &overheads[i];
         for (field, poly) in &oh.output_size {
             text.push_str(&format!("    {field} = {poly}\n"));

From 1800fbe920dcd9b3edf1136fc8727e744b553112 Mon Sep 17 00:00:00 2001
From: GiggleLiu <cacate0129@gmail.com>
Date: Wed, 18 Feb 2026 22:29:35 +0800
Subject: [PATCH 09/34] feat(lib): add k_neighbors BFS method to ReductionGraph

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
 src/rules/graph.rs                | 95 +++++++++++++++++++++++++++++++
 src/rules/mod.rs                  |  3 +-
 src/unit_tests/reduction_graph.rs | 87 +++++++++++++++++++++++++++-
 3 files changed, 183 insertions(+), 2 deletions(-)

diff --git a/src/rules/graph.rs b/src/rules/graph.rs
index b3d3405e9..01122a86d 100644
--- a/src/rules/graph.rs
+++ b/src/rules/graph.rs
@@ -210,6 +210,28 @@ struct VariantNode {
     variant: BTreeMap<String, String>,
 }
 
+/// Information about a neighbor in the reduction graph.
+#[derive(Debug, Clone)]
+pub struct NeighborInfo {
+    /// Problem name.
+    pub name: &'static str,
+    /// Variant attributes.
+    pub variant: BTreeMap<String, String>,
+    /// Hop distance from the source.
+    pub hops: usize,
+}
+
+/// Direction for graph traversal.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum TraversalDirection {
+    /// Follow outgoing edges (what can this reduce to?).
+    Outgoing,
+    /// Follow incoming edges (what can reduce to this?).
+    Incoming,
+    /// Follow edges in both directions.
+    Both,
+}
+
 /// Validate that a reduction's overhead variables are consistent with source/target size names.
 ///
 /// Checks:
@@ -706,6 +728,79 @@ impl ReductionGraph {
             })
             .collect()
     }
+
+    /// Find the NodeIndex for a specific (name, variant) pair.
+    pub fn find_node_index(&self, name: &str, variant: &BTreeMap<String, String>) -> Option<NodeIndex> {
+        self.name_to_nodes.get(name).and_then(|indices| {
+            indices.iter().find(|&&idx| {
+                let node = &self.nodes[self.graph[idx]];
+                node.variant == *variant
+            }).copied()
+        })
+    }
+
+    /// Get neighbors of a node in a specific direction.
+    pub fn neighbor_indices(&self, idx: NodeIndex, dir: petgraph::Direction) -> Vec<NodeIndex> {
+        self.graph.neighbors_directed(idx, dir).collect()
+    }
+
+    /// Get the problem name for a node index.
+    pub fn node_name(&self, idx: NodeIndex) -> &str {
+        self.nodes[self.graph[idx]].name
+    }
+
+    /// Find all problems reachable within `max_hops` edges from a starting node.
+    ///
+    /// Returns neighbors sorted by (hops, name). The starting node itself is excluded.
+    /// If a node is reachable at multiple distances, it appears at the shortest distance only.
+    pub fn k_neighbors(
+        &self,
+        name: &str,
+        variant: &BTreeMap<String, String>,
+        max_hops: usize,
+        direction: TraversalDirection,
+    ) -> Vec<NeighborInfo> {
+        use std::collections::VecDeque;
+
+        let Some(start_idx) = self.find_node_index(name, variant) else {
+            return vec![];
+        };
+
+        let mut visited: HashSet<NodeIndex> = HashSet::new();
+        visited.insert(start_idx);
+        let mut queue: VecDeque<(NodeIndex, usize)> = VecDeque::new();
+        queue.push_back((start_idx, 0));
+        let mut results: Vec<NeighborInfo> = Vec::new();
+
+        while let Some((node_idx, hops)) = queue.pop_front() {
+            if hops >= max_hops {
+                continue;
+            }
+
+            let directions: Vec<petgraph::Direction> = match direction {
+                TraversalDirection::Outgoing => vec![petgraph::Direction::Outgoing],
+                TraversalDirection::Incoming => vec![petgraph::Direction::Incoming],
+                TraversalDirection::Both => vec![petgraph::Direction::Outgoing, petgraph::Direction::Incoming],
+            };
+
+            for dir in directions {
+                for neighbor_idx in self.graph.neighbors_directed(node_idx, dir) {
+                    if visited.insert(neighbor_idx) {
+                        let neighbor_node = &self.nodes[self.graph[neighbor_idx]];
+                        results.push(NeighborInfo {
+                            name: neighbor_node.name,
+                            variant: neighbor_node.variant.clone(),
+                            hops: hops + 1,
+                        });
+                        queue.push_back((neighbor_idx, hops + 1));
+                    }
+                }
+            }
+        }
+
+        results.sort_by(|a, b| a.hops.cmp(&b.hops).then_with(|| a.name.cmp(b.name)));
+        results
+    }
 }
 
 impl Default for ReductionGraph {
diff --git a/src/rules/mod.rs b/src/rules/mod.rs
index 678cd0a46..36a4a83eb 100644
--- a/src/rules/mod.rs
+++ b/src/rules/mod.rs
@@ -59,7 +59,8 @@ mod minimumvertexcover_ilp;
 mod travelingsalesman_ilp;
 
 pub use graph::{
-    ReductionChain, ReductionEdgeInfo, ReductionGraph, ReductionPath, ReductionStep,
+    NeighborInfo, ReductionChain, ReductionEdgeInfo, ReductionGraph, ReductionPath,
+    ReductionStep, TraversalDirection,
 };
 #[cfg(test)]
 pub(crate) use graph::validate_overhead_variables;
diff --git a/src/unit_tests/reduction_graph.rs b/src/unit_tests/reduction_graph.rs
index df8ed6c9c..1884e753e 100644
--- a/src/unit_tests/reduction_graph.rs
+++ b/src/unit_tests/reduction_graph.rs
@@ -3,8 +3,9 @@
 use crate::models::satisfiability::KSatisfiability;
 use crate::poly;
 use crate::prelude::*;
-use crate::rules::{MinimizeSteps, ReductionGraph};
+use crate::rules::{MinimizeSteps, ReductionGraph, TraversalDirection};
 use crate::topology::{SimpleGraph, TriangularSubgraph};
+use std::collections::BTreeMap;
 use crate::traits::problem_size;
 use crate::types::ProblemSize;
 use crate::variant::K3;
@@ -445,3 +446,87 @@ fn test_validate_overhead_variables_identity() {
     let overhead = ReductionOverhead::identity(names);
     validate_overhead_variables("A", "B", &overhead, names, names);
 }
+
+// ---- k-neighbor BFS ----
+
+#[test]
+fn test_k_neighbors_outgoing() {
+    let graph = ReductionGraph::new();
+    let variants = graph.variants_for("MaximumIndependentSet");
+    assert!(!variants.is_empty());
+    let default_variant = &variants[0];
+
+    // 1-hop outgoing: should include direct reduction targets
+    let neighbors = graph.k_neighbors(
+        "MaximumIndependentSet",
+        default_variant,
+        1,
+        TraversalDirection::Outgoing,
+    );
+    assert!(!neighbors.is_empty());
+    assert!(neighbors.iter().all(|n| n.hops == 1));
+
+    // 2-hop outgoing: should include more problems
+    let neighbors_2 = graph.k_neighbors(
+        "MaximumIndependentSet",
+        default_variant,
+        2,
+        TraversalDirection::Outgoing,
+    );
+    assert!(neighbors_2.len() >= neighbors.len());
+}
+
+#[test]
+fn test_k_neighbors_incoming() {
+    let graph = ReductionGraph::new();
+    let variants = graph.variants_for("QUBO");
+    assert!(!variants.is_empty());
+
+    let neighbors = graph.k_neighbors(
+        "QUBO",
+        &variants[0],
+        1,
+        TraversalDirection::Incoming,
+    );
+    // QUBO is a common target — should have incoming reductions
+    assert!(!neighbors.is_empty());
+}
+
+#[test]
+fn test_k_neighbors_both() {
+    let graph = ReductionGraph::new();
+    let variants = graph.variants_for("MaximumIndependentSet");
+    let default_variant = &variants[0];
+
+    let out_only = graph.k_neighbors(
+        "MaximumIndependentSet", default_variant, 1, TraversalDirection::Outgoing,
+    );
+    let in_only = graph.k_neighbors(
+        "MaximumIndependentSet", default_variant, 1, TraversalDirection::Incoming,
+    );
+    let both = graph.k_neighbors(
+        "MaximumIndependentSet", default_variant, 1, TraversalDirection::Both,
+    );
+    // Both should be >= max of either direction
+    assert!(both.len() >= out_only.len());
+    assert!(both.len() >= in_only.len());
+}
+
+#[test]
+fn test_k_neighbors_unknown_problem() {
+    let graph = ReductionGraph::new();
+    let empty = BTreeMap::new();
+    let neighbors = graph.k_neighbors("NonExistent", &empty, 2, TraversalDirection::Outgoing);
+    assert!(neighbors.is_empty());
+}
+
+#[test]
+fn test_k_neighbors_zero_hops() {
+    let graph = ReductionGraph::new();
+    let variants = graph.variants_for("MaximumIndependentSet");
+    let default_variant = &variants[0];
+    let neighbors = graph.k_neighbors(
+        "MaximumIndependentSet", default_variant, 0, TraversalDirection::Outgoing,
+    );
+    assert!(neighbors.is_empty());
+}

From 3ed034b17d3feab32c3bd4897baab3cfd1456f80 Mon Sep 17 00:00:00 2001
From: GiggleLiu <cacate0129@gmail.com>
Date: Wed, 18 Feb 2026 22:36:10 +0800
Subject: [PATCH 10/34] feat(cli): add --hops and --direction flags to pred
 show for neighbor exploration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
 problemreductions-cli/Cargo.toml            |   1 +
 problemreductions-cli/src/cli.rs            |   8 +
 problemreductions-cli/src/commands/graph.rs | 196 +++++++++++++++++++-
 problemreductions-cli/src/main.rs           |   4 +-
 4 files changed, 205 insertions(+), 4 deletions(-)

diff --git a/problemreductions-cli/Cargo.toml b/problemreductions-cli/Cargo.toml
index ab83efe08..652361e49 100644
--- a/problemreductions-cli/Cargo.toml
+++ b/problemreductions-cli/Cargo.toml
@@ -26,3 +26,4 @@ serde = { version = "1", features = ["derive"] }
 serde_json = "1"
 clap_complete = "4"
 owo-colors = { version = "4", features = ["supports-colors"] }
+petgraph = "0.8"
diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs
index ef34f7b54..31ebc5f2f 100644
--- a/problemreductions-cli/src/cli.rs
+++ b/problemreductions-cli/src/cli.rs
@@ -39,11 +39,19 @@ Examples:
   pred show MIS                   # using alias
   pred show MaximumIndependentSet # full name
   pred show MIS/UnitDiskGraph     # specific graph variant
+  pred show MIS --hops 2          # 2-hop outgoing neighbor tree
+  pred show MIS --hops 2 --direction in  # incoming neighbors
 
 Use `pred list` to see all available problem types and aliases.")]
     Show {
         /// Problem name or alias (e.g., MIS, QUBO, MIS/UnitDiskGraph)
         problem: String,
+        /// Explore k-hop neighbors in the reduction graph
+        #[arg(long)]
+        hops: Option<usize>,
+        /// Direction for neighbor exploration: out, in, both [default: out]
+        #[arg(long, default_value = "out")]
+        direction: String,
     },
 
     /// Find the cheapest reduction path between two problems
diff --git a/problemreductions-cli/src/commands/graph.rs b/problemreductions-cli/src/commands/graph.rs
index a2441317d..100479734 100644
--- a/problemreductions-cli/src/commands/graph.rs
+++ b/problemreductions-cli/src/commands/graph.rs
@@ -2,9 +2,9 @@ use crate::output::OutputConfig;
 use crate::problem_name::{aliases_for, parse_problem_spec, resolve_variant};
 use anyhow::{Context, Result};
 use problemreductions::registry::collect_schemas;
-use problemreductions::rules::{Minimize, MinimizeSteps, ReductionGraph};
+use problemreductions::rules::{Minimize, MinimizeSteps, ReductionGraph, TraversalDirection};
 use problemreductions::types::ProblemSize;
-use std::collections::BTreeMap;
+use std::collections::{BTreeMap, HashMap, HashSet};
 use std::path::Path;
 
 pub fn list(out: &OutputConfig) -> Result<()> {
@@ -119,7 +119,7 @@ pub fn list(out: &OutputConfig) -> Result<()> {
     out.emit_with_default_name("pred_graph_list.json", &text, &json)
 }
 
-pub fn show(problem: &str, out: &OutputConfig) -> Result<()> {
+pub fn show(problem: &str, hops: Option<usize>, direction: &str, out: &OutputConfig) -> Result<()> {
     let spec = parse_problem_spec(problem)?;
     let graph = ReductionGraph::new();
 
@@ -128,6 +128,10 @@ pub fn show(problem: &str, out: &OutputConfig) -> Result<()> {
         anyhow::bail!("Unknown problem: {}", spec.name);
     }
 
+    if let Some(max_hops) = hops {
+        return show_neighbors(&graph, &spec, &variants, max_hops, direction, out);
+    }
+
     let mut text = format!("{}\n", crate::output::fmt_problem_name(&spec.name));
 
     // Show description from schema
@@ -485,3 +489,189 @@ pub fn export(output: &Path) -> Result<()> {
 
     Ok(())
 }
+
+fn parse_direction(s: &str) -> Result<TraversalDirection> {
+    match s {
+        "out" => Ok(TraversalDirection::Outgoing),
+        "in" => Ok(TraversalDirection::Incoming),
+        "both" => Ok(TraversalDirection::Both),
+        _ => anyhow::bail!(
+            "Unknown direction: {}. Use 'out', 'in', or 'both'.",
+            s
+        ),
+    }
+}
+
+fn show_neighbors(
+    graph: &ReductionGraph,
+    spec: &crate::problem_name::ProblemSpec,
+    variants: &[BTreeMap<String, String>],
+    max_hops: usize,
+    direction_str: &str,
+    out: &OutputConfig,
+) -> Result<()> {
+    let direction = parse_direction(direction_str)?;
+
+    let variant = if spec.variant_values.is_empty() {
+        variants[0].clone()
+    } else {
+        resolve_variant(spec, variants)?
+    };
+
+    let neighbors = graph.k_neighbors(&spec.name, &variant, max_hops, direction);
+
+    let dir_label = match direction {
+        TraversalDirection::Outgoing => "outgoing",
+        TraversalDirection::Incoming => "incoming",
+        TraversalDirection::Both => "both directions",
+    };
+
+    // Build tree structure via BFS with parent tracking
+    let tree = build_neighbor_tree(graph, &spec.name, &variant, max_hops, direction);
+
+    let mut text = format!(
+        "{} — {}-hop neighbors ({})\n\n",
+        crate::output::fmt_problem_name(&spec.name),
+        max_hops,
+        dir_label,
+    );
+
+    text.push_str(&crate::output::fmt_problem_name(&spec.name));
+    text.push('\n');
+    render_tree(&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(),
+        max_hops,
+    ));
+
+    let json = serde_json::json!({
+        "source": spec.name,
+        "hops": max_hops,
+        "direction": direction_str,
+        "neighbors": neighbors.iter().map(|n| {
+            serde_json::json!({
+                "name": n.name,
+                "variant": n.variant,
+                "hops": n.hops,
+            })
+        }).collect::<Vec<_>>(),
+    });
+
+    let default_name = format!("pred_show_{}_hops{}.json", spec.name, max_hops);
+    out.emit_with_default_name(&default_name, &text, &json)
+}
+
+/// Tree node for neighbor rendering.
+struct TreeNode {
+    name: String,
+    children: Vec<TreeNode>,
+}
+
+/// Build a tree of neighbors via BFS, tracking parent relationships.
+fn build_neighbor_tree(
+    graph: &ReductionGraph,
+    name: &str,
+    variant: &BTreeMap<String, String>,
+    max_hops: usize,
+    direction: TraversalDirection,
+) -> Vec<TreeNode> {
+    use std::collections::VecDeque;
+
+    let Some(start_idx) = graph.find_node_index(name, variant) else {
+        return vec![];
+    };
+
+    let mut visited: HashSet<petgraph::graph::NodeIndex> = HashSet::new();
+    visited.insert(start_idx);
+
+    let mut queue: VecDeque<(petgraph::graph::NodeIndex, usize)> = VecDeque::new();
+    queue.push_back((start_idx, 0));
+
+    // Map from node_idx -> children node indices
+    let mut node_children: HashMap<petgraph::graph::NodeIndex, Vec<petgraph::graph::NodeIndex>> =
+        HashMap::new();
+
+    while let Some((node_idx, depth)) = queue.pop_front() {
+        if depth >= max_hops {
+            continue;
+        }
+
+        let directions: Vec<petgraph::Direction> = match direction {
+            TraversalDirection::Outgoing => vec![petgraph::Direction::Outgoing],
+            TraversalDirection::Incoming => vec![petgraph::Direction::Incoming],
+            TraversalDirection::Both => {
+                vec![petgraph::Direction::Outgoing, petgraph::Direction::Incoming]
+            }
+        };
+
+        let mut children = Vec::new();
+        for dir in directions {
+            for neighbor_idx in graph.neighbor_indices(node_idx, dir) {
+                if visited.insert(neighbor_idx) {
+                    children.push(neighbor_idx);
+                    queue.push_back((neighbor_idx, depth + 1));
+                }
+            }
+        }
+        children.sort_by(|a, b| {
+            let na = graph.node_name(*a);
+            let nb = graph.node_name(*b);
+            na.cmp(nb)
+        });
+        node_children.insert(node_idx, children);
+    }
+
+    // Recursively build TreeNode from start's children
+    fn build_tree(
+        idx: petgraph::graph::NodeIndex,
+        node_children: &HashMap<petgraph::graph::NodeIndex, Vec<petgraph::graph::NodeIndex>>,
+        graph: &ReductionGraph,
+    ) -> TreeNode {
+        let children = node_children
+            .get(&idx)
+            .map(|cs| {
+                cs.iter()
+                    .map(|&c| build_tree(c, node_children, graph))
+                    .collect()
+            })
+            .unwrap_or_default();
+        TreeNode {
+            name: graph.node_name(idx).to_string(),
+            children,
+        }
+    }
+
+    node_children
+        .get(&start_idx)
+        .map(|cs| {
+            cs.iter()
+                .map(|&c| build_tree(c, &node_children, graph))
+                .collect()
+        })
+        .unwrap_or_default()
+}
+
+/// Render a tree with box-drawing characters.
+fn render_tree(nodes: &[TreeNode], 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 { "├── " };
+        let child_prefix = if is_last { "    " } else { "│   " };
+
+        text.push_str(&format!(
+            "{}{}{}\n",
+            crate::output::fmt_dim(prefix),
+            crate::output::fmt_dim(connector),
+            crate::output::fmt_problem_name(&node.name),
+        ));
+
+        if !node.children.is_empty() {
+            let new_prefix = format!("{}{}", prefix, child_prefix);
+            render_tree(&node.children, text, &new_prefix);
+        }
+    }
+}
diff --git a/problemreductions-cli/src/main.rs b/problemreductions-cli/src/main.rs
index 2dec26e3a..b7d9041e5 100644
--- a/problemreductions-cli/src/main.rs
+++ b/problemreductions-cli/src/main.rs
@@ -32,7 +32,9 @@ fn main() -> anyhow::Result<()> {
 
     match cli.command {
         Commands::List => commands::graph::list(&out),
-        Commands::Show { problem } => commands::graph::show(&problem, &out),
+        Commands::Show { problem, hops, direction } => {
+            commands::graph::show(&problem, hops, &direction, &out)
+        }
         Commands::Path {
             source,
             target,

From 82714f35043c7a260a5bcac071f8b0e069253902 Mon Sep 17 00:00:00 2001
From: GiggleLiu <cacate0129@gmail.com>
Date: Wed, 18 Feb 2026 22:38:27 +0800
Subject: [PATCH 11/34] test(cli): add integration tests for k-neighbor
 exploration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
 problemreductions-cli/tests/cli_tests.rs | 79 ++++++++++++++++++++++++
 1 file changed, 79 insertions(+)

diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs
index 9f075fed0..875dd6afd 100644
--- a/problemreductions-cli/tests/cli_tests.rs
+++ b/problemreductions-cli/tests/cli_tests.rs
@@ -1241,3 +1241,82 @@ fn test_completions_fish() {
     let stdout = String::from_utf8(output.stdout).unwrap();
     assert!(stdout.contains("pred"));
 }
+
+// ---- k-neighbor exploration tests ----
+
+#[test]
+fn test_show_hops_outgoing() {
+    let output = pred()
+        .args(["show", "MIS", "--hops", "2"])
+        .output()
+        .unwrap();
+    assert!(
+        output.status.success(),
+        "stderr: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+    let stdout = String::from_utf8(output.stdout).unwrap();
+    assert!(stdout.contains("MaximumIndependentSet"));
+    assert!(stdout.contains("reachable problems"));
+    // Should contain tree characters
+    assert!(stdout.contains("├── ") || stdout.contains("└── "));
+}
+
+#[test]
+fn test_show_hops_incoming() {
+    let output = pred()
+        .args(["show", "QUBO", "--hops", "1", "--direction", "in"])
+        .output()
+        .unwrap();
+    assert!(
+        output.status.success(),
+        "stderr: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+    let stdout = String::from_utf8(output.stdout).unwrap();
+    assert!(stdout.contains("QUBO"));
+    assert!(stdout.contains("incoming"));
+}
+
+#[test]
+fn test_show_hops_both() {
+    let output = pred()
+        .args(["show", "MIS", "--hops", "1", "--direction", "both"])
+        .output()
+        .unwrap();
+    assert!(
+        output.status.success(),
+        "stderr: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+    let stdout = String::from_utf8(output.stdout).unwrap();
+    assert!(stdout.contains("both directions"));
+}
+
+#[test]
+fn test_show_hops_json() {
+    let tmp = std::env::temp_dir().join("pred_test_show_hops.json");
+    let output = pred()
+        .args(["-o", tmp.to_str().unwrap(), "show", "MIS", "--hops", "2"])
+        .output()
+        .unwrap();
+    assert!(output.status.success());
+    assert!(tmp.exists());
+    let content = std::fs::read_to_string(&tmp).unwrap();
+    let json: serde_json::Value = serde_json::from_str(&content).unwrap();
+    assert_eq!(json["source"], "MaximumIndependentSet");
+    assert_eq!(json["hops"], 2);
+    assert!(json["neighbors"].is_array());
+    std::fs::remove_file(&tmp).ok();
+}
+
+#[test]
+fn test_show_hops_bad_direction() {
+    let output = pred()
+        .args(["show", "MIS", "--hops", "1", "--direction", "bad"])
+        .output()
+        .unwrap();
+    assert!(!output.status.success());
+    let stderr = String::from_utf8_lossy(&output.stderr);
+    assert!(stderr.contains("Unknown direction"));
+}

From 2c60ce41d8afde0f49b98cbb183a8dcee4763e35 Mon Sep 17 00:00:00 2001
From: GiggleLiu <cacate0129@gmail.com>
Date: Wed, 18 Feb 2026 22:40:27 +0800
Subject: [PATCH 12/34] chore: apply rustfmt formatting

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
 ...hained_reduction_factoring_to_spinglass.rs |  10 +-
 examples/reduction_ilp_to_qubo.rs             |   2 +-
 problemreductions-cli/src/commands/create.rs  |  16 +--
 problemreductions-cli/src/commands/graph.rs   |  62 +++++++----
 problemreductions-cli/src/commands/reduce.rs  |  13 +--
 problemreductions-cli/src/commands/solve.rs   |  21 ++--
 problemreductions-cli/src/dispatch.rs         |  22 ++--
 problemreductions-cli/src/main.rs             |  14 +--
 problemreductions-cli/tests/cli_tests.rs      |  49 ++-------
 src/lib.rs                                    |   2 +-
 src/models/graph/kcoloring.rs                 |   6 +-
 src/models/set/minimum_set_covering.rs        |   3 +-
 src/models/specialized/biclique_cover.rs      |   7 +-
 src/models/specialized/circuit.rs             |   5 +-
 src/rules/graph.rs                            |  41 ++++---
 src/rules/mod.rs                              |   9 +-
 src/unit_tests/models/graph/max_cut.rs        |   5 +-
 .../models/graph/maximum_independent_set.rs   |   3 +-
 .../models/graph/minimum_dominating_set.rs    |   3 +-
 src/unit_tests/reduction_graph.rs             | 104 ++++++++++--------
 src/unit_tests/rules/reduction_path_parity.rs |   5 +-
 src/unit_tests/trait_consistency.rs           |   5 +-
 tests/suites/integration.rs                   |   5 +-
 tests/suites/reductions.rs                    |   2 +-
 24 files changed, 221 insertions(+), 193 deletions(-)

diff --git a/examples/chained_reduction_factoring_to_spinglass.rs b/examples/chained_reduction_factoring_to_spinglass.rs
index ed88ef5b7..8b0e9d857 100644
--- a/examples/chained_reduction_factoring_to_spinglass.rs
+++ b/examples/chained_reduction_factoring_to_spinglass.rs
@@ -20,12 +20,12 @@ pub fn run() {
     let dst_var = ReductionGraph::variant_to_map(&SpinGlass::<SimpleGraph, f64>::variant()); // {graph: "SimpleGraph", weight: "f64"}
     let rpath = graph
         .find_cheapest_path(
-            "Factoring",              // source problem name
-            &src_var,                 // source variant map
-            "SpinGlass",              // target problem name
-            &dst_var,                 // target variant map
+            "Factoring",               // source problem name
+            &src_var,                  // source variant map
+            "SpinGlass",               // target problem name
+            &dst_var,                  // target variant map
             &ProblemSize::new(vec![]), // input size (empty = unknown)
-            &MinimizeSteps,           // cost function: fewest hops
+            &MinimizeSteps,            // cost function: fewest hops
         )
         .unwrap();
     println!("  {}", rpath);
diff --git a/examples/reduction_ilp_to_qubo.rs b/examples/reduction_ilp_to_qubo.rs
index 22588b3a8..2c2ff115f 100644
--- a/examples/reduction_ilp_to_qubo.rs
+++ b/examples/reduction_ilp_to_qubo.rs
@@ -36,7 +36,7 @@
 // ```
 
 use problemreductions::export::*;
-use problemreductions::models::optimization::{ILP, LinearConstraint, ObjectiveSense};
+use problemreductions::models::optimization::{LinearConstraint, ObjectiveSense, ILP};
 use problemreductions::prelude::*;
 
 pub fn run() {
diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs
index d8c044f59..c566fe366 100644
--- a/problemreductions-cli/src/commands/create.rs
+++ b/problemreductions-cli/src/commands/create.rs
@@ -14,7 +14,9 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> {
 
     let (data, variant) = match canonical.as_str() {
         // Graph problems with vertex weights
-        "MaximumIndependentSet" | "MinimumVertexCover" | "MaximumClique"
+        "MaximumIndependentSet"
+        | "MinimumVertexCover"
+        | "MaximumClique"
         | "MinimumDominatingSet" => {
             let (graph, n) = parse_graph(args).map_err(|e| {
                 anyhow::anyhow!(
@@ -25,16 +27,10 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> {
             let weights = parse_vertex_weights(args, n)?;
             let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]);
             let data = match canonical.as_str() {
-                "MaximumIndependentSet" => {
-                    ser(MaximumIndependentSet::new(graph, weights))?
-                }
-                "MinimumVertexCover" => {
-                    ser(MinimumVertexCover::new(graph, weights))?
-                }
+                "MaximumIndependentSet" => ser(MaximumIndependentSet::new(graph, weights))?,
+                "MinimumVertexCover" => ser(MinimumVertexCover::new(graph, weights))?,
                 "MaximumClique" => ser(MaximumClique::new(graph, weights))?,
-                "MinimumDominatingSet" => {
-                    ser(MinimumDominatingSet::new(graph, weights))?
-                }
+                "MinimumDominatingSet" => ser(MinimumDominatingSet::new(graph, weights))?,
                 _ => unreachable!(),
             };
             (data, variant)
diff --git a/problemreductions-cli/src/commands/graph.rs b/problemreductions-cli/src/commands/graph.rs
index 100479734..bc80aadcb 100644
--- a/problemreductions-cli/src/commands/graph.rs
+++ b/problemreductions-cli/src/commands/graph.rs
@@ -91,10 +91,7 @@ pub fn list(out: &OutputConfig) -> Result<()> {
         let colored_alias = crate::output::fmt_dim(&padded_alias);
         text.push_str(&format!(
             "  {}  {}  {:>8}  {:>10}\n",
-            colored_name,
-            colored_alias,
-            row.num_variants,
-            row.num_reduces_to,
+            colored_name, colored_alias, row.num_variants, row.num_reduces_to,
         ));
     }
 
@@ -144,14 +141,20 @@ pub fn show(problem: &str, hops: Option<usize>, direction: &str, out: &OutputCon
     }
 
     // Show variants
-    text.push_str(&format!("\n{}\n", crate::output::fmt_section(&format!("Variants ({}):", variants.len()))));
+    text.push_str(&format!(
+        "\n{}\n",
+        crate::output::fmt_section(&format!("Variants ({}):", variants.len()))
+    ));
     for v in &variants {
         text.push_str(&format!("  {}\n", format_variant(v)));
     }
 
     // Show fields from schema (right after variants)
     if let Some(s) = schema {
-        text.push_str(&format!("\n{}\n", crate::output::fmt_section(&format!("Fields ({}):", s.fields.len()))));
+        text.push_str(&format!(
+            "\n{}\n",
+            crate::output::fmt_section(&format!("Fields ({}):", s.fields.len()))
+        ));
         for field in &s.fields {
             text.push_str(&format!("  {} ({})", field.name, field.type_name));
             if !field.description.is_empty() {
@@ -164,7 +167,10 @@ pub fn show(problem: &str, hops: Option<usize>, direction: &str, out: &OutputCon
     // Show size fields (used with `pred path --cost minimize:<field>`)
     let size_fields = graph.size_field_names(&spec.name);
     if !size_fields.is_empty() {
-        text.push_str(&format!("\n{}\n", crate::output::fmt_section(&format!("Size fields ({}):", size_fields.len()))));
+        text.push_str(&format!(
+            "\n{}\n",
+            crate::output::fmt_section(&format!("Size fields ({}):", size_fields.len()))
+        ));
         for f in size_fields {
             text.push_str(&format!("  {f}\n"));
         }
@@ -174,7 +180,10 @@ pub fn show(problem: &str, hops: Option<usize>, direction: &str, out: &OutputCon
     let outgoing = graph.outgoing_reductions(&spec.name);
     let incoming = graph.incoming_reductions(&spec.name);
 
-    text.push_str(&format!("\n{}\n", crate::output::fmt_section(&format!("Reduces to ({}):", outgoing.len()))));
+    text.push_str(&format!(
+        "\n{}\n",
+        crate::output::fmt_section(&format!("Reduces to ({}):", outgoing.len()))
+    ));
     for e in &outgoing {
         text.push_str(&format!(
             "  {} {} {} {} {}\n",
@@ -186,7 +195,10 @@ pub fn show(problem: &str, hops: Option<usize>, direction: &str, out: &OutputCon
         ));
     }
 
-    text.push_str(&format!("\n{}\n", crate::output::fmt_section(&format!("Reduces from ({}):", incoming.len()))));
+    text.push_str(&format!(
+        "\n{}\n",
+        crate::output::fmt_section(&format!("Reduces from ({}):", incoming.len()))
+    ));
     for e in &incoming {
         text.push_str(&format!(
             "  {} {} {} {} {}\n",
@@ -365,10 +377,20 @@ pub fn path(source: &str, target: &str, cost: &str, all: bool, out: &OutputConfi
         for dv in &dst_resolved {
             let found = match cost_choice {
                 CostChoice::Steps => graph.find_cheapest_path(
-                    &src_spec.name, sv, &dst_spec.name, dv, &input_size, &MinimizeSteps,
+                    &src_spec.name,
+                    sv,
+                    &dst_spec.name,
+                    dv,
+                    &input_size,
+                    &MinimizeSteps,
                 ),
                 CostChoice::Field(f) => graph.find_cheapest_path(
-                    &src_spec.name, sv, &dst_spec.name, dv, &input_size, &Minimize(f),
+                    &src_spec.name,
+                    sv,
+                    &dst_spec.name,
+                    dv,
+                    &input_size,
+                    &Minimize(f),
                 ),
             };
 
@@ -437,7 +459,12 @@ fn path_all(
     // Sort by path length (shortest first)
     all_paths.sort_by_key(|p| p.len());
 
-    let mut text = format!("Found {} paths from {} to {}:\n", all_paths.len(), src_name, dst_name);
+    let mut text = format!(
+        "Found {} paths from {} to {}:\n",
+        all_paths.len(),
+        src_name,
+        dst_name
+    );
     for (idx, p) in all_paths.iter().enumerate() {
         text.push_str(&format!("\n--- Path {} ---\n", idx + 1));
         text.push_str(&format_path_text(graph, p));
@@ -456,11 +483,7 @@ fn path_all(
             std::fs::write(&file, &content)
                 .with_context(|| format!("Failed to write {}", file.display()))?;
         }
-        eprintln!(
-            "Wrote {} path files to {}",
-            all_paths.len(),
-            dir.display()
-        );
+        eprintln!("Wrote {} path files to {}", all_paths.len(), dir.display());
     } else {
         println!("{text}");
     }
@@ -495,10 +518,7 @@ fn parse_direction(s: &str) -> Result<TraversalDirection> {
         "out" => Ok(TraversalDirection::Outgoing),
         "in" => Ok(TraversalDirection::Incoming),
         "both" => Ok(TraversalDirection::Both),
-        _ => anyhow::bail!(
-            "Unknown direction: {}. Use 'out', 'in', or 'both'.",
-            s
-        ),
+        _ => anyhow::bail!("Unknown direction: {}. Use 'out', 'in', or 'both'.", s),
     }
 }
 
diff --git a/problemreductions-cli/src/commands/reduce.rs b/problemreductions-cli/src/commands/reduce.rs
index f23640ed6..951beee54 100644
--- a/problemreductions-cli/src/commands/reduce.rs
+++ b/problemreductions-cli/src/commands/reduce.rs
@@ -11,8 +11,7 @@ use std::path::Path;
 
 /// Parse a path JSON file (produced by `pred path ... -o`) into a ReductionPath.
 fn load_path_file(path_file: &Path) -> Result<ReductionPath> {
-    let content =
-        std::fs::read_to_string(path_file).context("Failed to read path file")?;
+    let content = std::fs::read_to_string(path_file).context("Failed to read path file")?;
     let json: serde_json::Value =
         serde_json::from_str(&content).context("Failed to parse path file")?;
 
@@ -49,12 +48,7 @@ fn parse_path_node(node: &serde_json::Value) -> Result<ReductionStep> {
     Ok(ReductionStep { name, variant })
 }
 
-pub fn reduce(
-    input: &Path,
-    target: &str,
-    via: Option<&Path>,
-    out: &OutputConfig,
-) -> Result<()> {
+pub fn reduce(input: &Path, target: &str, via: Option<&Path>, out: &OutputConfig) -> Result<()> {
     // 1. Load source problem
     let content = std::fs::read_to_string(input)?;
     let problem_json: ProblemJson = serde_json::from_str(&content)?;
@@ -177,8 +171,7 @@ pub fn reduce(
     let json = serde_json::to_value(&bundle)?;
 
     if let Some(ref path) = out.output {
-        let content =
-            serde_json::to_string_pretty(&json).context("Failed to serialize JSON")?;
+        let content = serde_json::to_string_pretty(&json).context("Failed to serialize JSON")?;
         std::fs::write(path, &content)
             .with_context(|| format!("Failed to write {}", path.display()))?;
         eprintln!(
diff --git a/problemreductions-cli/src/commands/solve.rs b/problemreductions-cli/src/commands/solve.rs
index 607b53332..15149c0ee 100644
--- a/problemreductions-cli/src/commands/solve.rs
+++ b/problemreductions-cli/src/commands/solve.rs
@@ -15,8 +15,7 @@ enum SolveInput {
 fn parse_input(path: &Path) -> Result<SolveInput> {
     let content = std::fs::read_to_string(path)
         .with_context(|| format!("Failed to read {}", path.display()))?;
-    let json: serde_json::Value =
-        serde_json::from_str(&content).context("Failed to parse JSON")?;
+    let json: serde_json::Value = serde_json::from_str(&content).context("Failed to parse JSON")?;
 
     // Reduction bundles have "source", "target", and "path" fields
     if json.get("source").is_some() && json.get("target").is_some() && json.get("path").is_some() {
@@ -41,12 +40,14 @@ pub fn solve(input: &Path, solver_name: &str, out: &OutputConfig) -> Result<()>
     let parsed = parse_input(input)?;
 
     match parsed {
-        SolveInput::Problem(problem_json) => {
-            solve_problem(&problem_json.problem_type, &problem_json.variant, problem_json.data, solver_name, out)
-        }
-        SolveInput::Bundle(bundle) => {
-            solve_bundle(bundle, solver_name, out)
-        }
+        SolveInput::Problem(problem_json) => solve_problem(
+            &problem_json.problem_type,
+            &problem_json.variant,
+            problem_json.data,
+            solver_name,
+            out,
+        ),
+        SolveInput::Bundle(bundle) => solve_bundle(bundle, solver_name, out),
     }
 }
 
@@ -146,7 +147,9 @@ fn solve_bundle(bundle: ReductionBundle, solver_name: &str, out: &OutputConfig)
 
     let chain = graph
         .reduce_along_path(&reduction_path, source.as_any())
-        .ok_or_else(|| anyhow::anyhow!("Failed to re-execute reduction chain for solution extraction"))?;
+        .ok_or_else(|| {
+            anyhow::anyhow!("Failed to re-execute reduction chain for solution extraction")
+        })?;
 
     // 4. Extract solution back to source problem space
     let source_config = chain.extract_solution(&target_result.config);
diff --git a/problemreductions-cli/src/dispatch.rs b/problemreductions-cli/src/dispatch.rs
index a5bb57d45..73d7728cd 100644
--- a/problemreductions-cli/src/dispatch.rs
+++ b/problemreductions-cli/src/dispatch.rs
@@ -1,11 +1,9 @@
 use anyhow::{bail, Result};
-use problemreductions::prelude::*;
 use problemreductions::models::optimization::ILP;
+use problemreductions::prelude::*;
 use problemreductions::rules::{MinimizeSteps, ReductionGraph};
 use problemreductions::solvers::{BruteForce, ILPSolver, Solver};
-use problemreductions::topology::{
-    KingsSubgraph, SimpleGraph, TriangularSubgraph, UnitDiskGraph,
-};
+use problemreductions::topology::{KingsSubgraph, SimpleGraph, TriangularSubgraph, UnitDiskGraph};
 use problemreductions::types::ProblemSize;
 use problemreductions::variant::{K2, K3, KN};
 use serde::Serialize;
@@ -135,7 +133,12 @@ impl LoadedProblem {
         let mut best_path = None;
         for dv in &ilp_variants {
             if let Some(p) = graph.find_cheapest_path(
-                name, &source_variant, "ILP", dv, &input_size, &MinimizeSteps,
+                name,
+                &source_variant,
+                "ILP",
+                dv,
+                &input_size,
+                &MinimizeSteps,
             ) {
                 let is_better = best_path
                     .as_ref()
@@ -146,8 +149,8 @@ impl LoadedProblem {
             }
         }
 
-        let reduction_path = best_path
-            .ok_or_else(|| anyhow::anyhow!("No reduction path from {} to ILP", name))?;
+        let reduction_path =
+            best_path.ok_or_else(|| anyhow::anyhow!("No reduction path from {} to ILP", name))?;
 
         let chain = graph
             .reduce_along_path(&reduction_path, self.as_any())
@@ -177,7 +180,9 @@ pub fn load_problem(
     match canonical.as_str() {
         "MaximumIndependentSet" => match graph_variant(variant) {
             "KingsSubgraph" => deser_opt::<MaximumIndependentSet<KingsSubgraph, i32>>(data),
-            "TriangularSubgraph" => deser_opt::<MaximumIndependentSet<TriangularSubgraph, i32>>(data),
+            "TriangularSubgraph" => {
+                deser_opt::<MaximumIndependentSet<TriangularSubgraph, i32>>(data)
+            }
             "UnitDiskGraph" => deser_opt::<MaximumIndependentSet<UnitDiskGraph, i32>>(data),
             _ => deser_opt::<MaximumIndependentSet<SimpleGraph, i32>>(data),
         },
@@ -323,4 +328,3 @@ fn solve_ilp(any: &dyn Any) -> Result<SolveResult> {
     let evaluation = format!("{:?}", problem.evaluate(&config));
     Ok(SolveResult { config, evaluation })
 }
-
diff --git a/problemreductions-cli/src/main.rs b/problemreductions-cli/src/main.rs
index b7d9041e5..64100277c 100644
--- a/problemreductions-cli/src/main.rs
+++ b/problemreductions-cli/src/main.rs
@@ -4,8 +4,8 @@ mod dispatch;
 mod output;
 mod problem_name;
 
-use cli::{Cli, Commands};
 use clap::{CommandFactory, Parser};
+use cli::{Cli, Commands};
 use output::OutputConfig;
 
 fn main() -> anyhow::Result<()> {
@@ -26,15 +26,15 @@ fn main() -> anyhow::Result<()> {
         }
     };
 
-    let out = OutputConfig {
-        output: cli.output,
-    };
+    let out = OutputConfig { output: cli.output };
 
     match cli.command {
         Commands::List => commands::graph::list(&out),
-        Commands::Show { problem, hops, direction } => {
-            commands::graph::show(&problem, hops, &direction, &out)
-        }
+        Commands::Show {
+            problem,
+            hops,
+            direction,
+        } => commands::graph::show(&problem, hops, &direction, &out),
         Commands::Path {
             source,
             target,
diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs
index 875dd6afd..70f81c156 100644
--- a/problemreductions-cli/tests/cli_tests.rs
+++ b/problemreductions-cli/tests/cli_tests.rs
@@ -79,14 +79,7 @@ fn test_path_all_save() {
     let dir = std::env::temp_dir().join("pred_test_all_paths");
     let _ = std::fs::remove_dir_all(&dir);
     let output = pred()
-        .args([
-            "path",
-            "MIS",
-            "QUBO",
-            "--all",
-            "-o",
-            dir.to_str().unwrap(),
-        ])
+        .args(["path", "MIS", "QUBO", "--all", "-o", dir.to_str().unwrap()])
         .output()
         .unwrap();
     assert!(
@@ -262,13 +255,7 @@ fn test_reduce_via_path() {
     // 2. Generate path file
     let path_file = std::env::temp_dir().join("pred_test_reduce_via_path.json");
     let path_out = pred()
-        .args([
-            "path",
-            "MIS",
-            "QUBO",
-            "-o",
-            path_file.to_str().unwrap(),
-        ])
+        .args(["path", "MIS", "QUBO", "-o", path_file.to_str().unwrap()])
         .output()
         .unwrap();
     assert!(path_out.status.success());
@@ -681,12 +668,7 @@ fn test_solve_bundle_ilp() {
     );
 
     let output = pred()
-        .args([
-            "solve",
-            bundle_file.to_str().unwrap(),
-            "--solver",
-            "ilp",
-        ])
+        .args(["solve", bundle_file.to_str().unwrap(), "--solver", "ilp"])
         .output()
         .unwrap();
     assert!(
@@ -1061,13 +1043,7 @@ fn test_path_unknown_target() {
 #[test]
 fn test_path_with_cost_minimize_field() {
     let output = pred()
-        .args([
-            "path",
-            "MIS",
-            "QUBO",
-            "--cost",
-            "minimize:num_variables",
-        ])
+        .args(["path", "MIS", "QUBO", "--cost", "minimize:num_variables"])
         .output()
         .unwrap();
     assert!(
@@ -1163,12 +1139,7 @@ fn test_reduce_stdout() {
     assert!(create_out.status.success());
 
     let output = pred()
-        .args([
-            "reduce",
-            problem_file.to_str().unwrap(),
-            "--to",
-            "QUBO",
-        ])
+        .args(["reduce", problem_file.to_str().unwrap(), "--to", "QUBO"])
         .output()
         .unwrap();
     assert!(
@@ -1189,10 +1160,7 @@ fn test_reduce_stdout() {
 #[test]
 fn test_incorrect_command_shows_help() {
     // Missing required arguments should show after_help
-    let output = pred()
-        .args(["solve"])
-        .output()
-        .unwrap();
+    let output = pred().args(["solve"]).output().unwrap();
     assert!(!output.status.success());
     let stderr = String::from_utf8_lossy(&output.stderr);
     // The subcommand help hint should be shown
@@ -1223,7 +1191,10 @@ fn test_completions_bash() {
     );
     let stdout = String::from_utf8(output.stdout).unwrap();
     // Bash completions should reference the binary name
-    assert!(stdout.contains("pred"), "completions should reference 'pred'");
+    assert!(
+        stdout.contains("pred"),
+        "completions should reference 'pred'"
+    );
 }
 
 #[test]
diff --git a/src/lib.rs b/src/lib.rs
index 8cee6ab2b..30dd2a65d 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -44,7 +44,7 @@ pub mod prelude {
     pub use crate::models::optimization::{SpinGlass, QUBO};
     pub use crate::models::satisfiability::{CNFClause, KSatisfiability, Satisfiability};
     pub use crate::models::set::{MaximumSetPacking, MinimumSetCovering};
-    pub use crate::models::specialized::{BicliqueCover, BMF, CircuitSAT, Factoring, PaintShop};
+    pub use crate::models::specialized::{BicliqueCover, CircuitSAT, Factoring, PaintShop, BMF};
 
     // Core traits
     pub use crate::rules::{ReduceTo, ReductionResult};
diff --git a/src/models/graph/kcoloring.rs b/src/models/graph/kcoloring.rs
index a3bc7bfb4..3b02fedd8 100644
--- a/src/models/graph/kcoloring.rs
+++ b/src/models/graph/kcoloring.rs
@@ -155,7 +155,11 @@ impl<K: KValue, G: Graph + VariantParam> SatisfactionProblem for KColoring<K, G>
 ///
 /// # Panics
 /// Panics if `coloring.len() != graph.num_vertices()`.
-pub(crate) fn is_valid_coloring<G: Graph>(graph: &G, coloring: &[usize], num_colors: usize) -> bool {
+pub(crate) fn is_valid_coloring<G: Graph>(
+    graph: &G,
+    coloring: &[usize],
+    num_colors: usize,
+) -> bool {
     assert_eq!(
         coloring.len(),
         graph.num_vertices(),
diff --git a/src/models/set/minimum_set_covering.rs b/src/models/set/minimum_set_covering.rs
index ed10981dd..3867574b1 100644
--- a/src/models/set/minimum_set_covering.rs
+++ b/src/models/set/minimum_set_covering.rs
@@ -118,8 +118,7 @@ impl<W: Clone + Default> MinimumSetCovering<W> {
     /// Check if a configuration is a valid set cover.
     pub fn is_valid_solution(&self, config: &[usize]) -> bool {
         let covered = self.covered_elements(config);
-        covered.len() == self.universe_size
-            && (0..self.universe_size).all(|e| covered.contains(&e))
+        covered.len() == self.universe_size && (0..self.universe_size).all(|e| covered.contains(&e))
     }
 
     /// Check which elements are covered by selected sets.
diff --git a/src/models/specialized/biclique_cover.rs b/src/models/specialized/biclique_cover.rs
index 31164a7bc..f7397950b 100644
--- a/src/models/specialized/biclique_cover.rs
+++ b/src/models/specialized/biclique_cover.rs
@@ -233,7 +233,12 @@ impl Problem for BicliqueCover {
         &["left_size", "right_size", "num_edges", "rank"]
     }
     fn problem_size_values(&self) -> Vec<usize> {
-        vec![self.left_size(), self.right_size(), self.num_edges(), self.k()]
+        vec![
+            self.left_size(),
+            self.right_size(),
+            self.num_edges(),
+            self.k(),
+        ]
     }
 }
 
diff --git a/src/models/specialized/circuit.rs b/src/models/specialized/circuit.rs
index d1dbeab6d..a33346c5d 100644
--- a/src/models/specialized/circuit.rs
+++ b/src/models/specialized/circuit.rs
@@ -265,7 +265,10 @@ impl CircuitSAT {
 
 /// Check if a circuit assignment is satisfying.
 #[cfg(test)]
-pub(crate) fn is_circuit_satisfying(circuit: &Circuit, assignments: &HashMap<String, bool>) -> bool {
+pub(crate) fn is_circuit_satisfying(
+    circuit: &Circuit,
+    assignments: &HashMap<String, bool>,
+) -> bool {
     circuit
         .assignments
         .iter()
diff --git a/src/rules/graph.rs b/src/rules/graph.rs
index 01122a86d..43825d2de 100644
--- a/src/rules/graph.rs
+++ b/src/rules/graph.rs
@@ -21,7 +21,6 @@ use std::any::Any;
 use std::cmp::Reverse;
 use std::collections::{BTreeMap, BinaryHeap, HashMap, HashSet};
 
-
 /// A source/target pair from the reduction graph, returned by
 /// [`ReductionGraph::outgoing_reductions`] and [`ReductionGraph::incoming_reductions`].
 #[derive(Debug, Clone)]
@@ -627,17 +626,14 @@ impl ReductionGraph {
         node_indices
             .windows(2)
             .map(|pair| {
-                let edge_idx = self
-                    .graph
-                    .find_edge(pair[0], pair[1])
-                    .unwrap_or_else(|| {
-                        let src = &self.nodes[self.graph[pair[0]]];
-                        let dst = &self.nodes[self.graph[pair[1]]];
-                        panic!(
-                            "No edge from {} {:?} to {} {:?}",
-                            src.name, src.variant, dst.name, dst.variant
-                        )
-                    });
+                let edge_idx = self.graph.find_edge(pair[0], pair[1]).unwrap_or_else(|| {
+                    let src = &self.nodes[self.graph[pair[0]]];
+                    let dst = &self.nodes[self.graph[pair[1]]];
+                    panic!(
+                        "No edge from {} {:?} to {} {:?}",
+                        src.name, src.variant, dst.name, dst.variant
+                    )
+                });
                 self.graph[edge_idx].overhead.clone()
             })
             .collect()
@@ -730,12 +726,19 @@ impl ReductionGraph {
     }
 
     /// Find the NodeIndex for a specific (name, variant) pair.
-    pub fn find_node_index(&self, name: &str, variant: &BTreeMap<String, String>) -> Option<NodeIndex> {
+    pub fn find_node_index(
+        &self,
+        name: &str,
+        variant: &BTreeMap<String, String>,
+    ) -> Option<NodeIndex> {
         self.name_to_nodes.get(name).and_then(|indices| {
-            indices.iter().find(|&&idx| {
-                let node = &self.nodes[self.graph[idx]];
-                node.variant == *variant
-            }).copied()
+            indices
+                .iter()
+                .find(|&&idx| {
+                    let node = &self.nodes[self.graph[idx]];
+                    node.variant == *variant
+                })
+                .copied()
         })
     }
 
@@ -780,7 +783,9 @@ impl ReductionGraph {
             let directions: Vec<petgraph::Direction> = match direction {
                 TraversalDirection::Outgoing => vec![petgraph::Direction::Outgoing],
                 TraversalDirection::Incoming => vec![petgraph::Direction::Incoming],
-                TraversalDirection::Both => vec![petgraph::Direction::Outgoing, petgraph::Direction::Incoming],
+                TraversalDirection::Both => {
+                    vec![petgraph::Direction::Outgoing, petgraph::Direction::Incoming]
+                }
             };
 
             for dir in directions {
diff --git a/src/rules/mod.rs b/src/rules/mod.rs
index 36a4a83eb..e778f78e1 100644
--- a/src/rules/mod.rs
+++ b/src/rules/mod.rs
@@ -58,12 +58,12 @@ mod minimumvertexcover_ilp;
 #[cfg(feature = "ilp-solver")]
 mod travelingsalesman_ilp;
 
-pub use graph::{
-    NeighborInfo, ReductionChain, ReductionEdgeInfo, ReductionGraph, ReductionPath,
-    ReductionStep, TraversalDirection,
-};
 #[cfg(test)]
 pub(crate) use graph::validate_overhead_variables;
+pub use graph::{
+    NeighborInfo, ReductionChain, ReductionEdgeInfo, ReductionGraph, ReductionPath, ReductionStep,
+    TraversalDirection,
+};
 pub use traits::{ReduceTo, ReductionAutoCast, ReductionResult};
 
 /// Generates a variant-cast `ReduceTo` impl with `#[reduction]` registration.
@@ -113,4 +113,3 @@ macro_rules! impl_variant_reduction {
         }
     };
 }
-
diff --git a/src/unit_tests/models/graph/max_cut.rs b/src/unit_tests/models/graph/max_cut.rs
index 3d7059c62..32891f08b 100644
--- a/src/unit_tests/models/graph/max_cut.rs
+++ b/src/unit_tests/models/graph/max_cut.rs
@@ -131,7 +131,10 @@ fn test_jl_parity_evaluation() {
 
 #[test]
 fn test_cut_size_method() {
-    let problem = MaxCut::new(SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]), vec![1, 2, 3]);
+    let problem = MaxCut::new(
+        SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]),
+        vec![1, 2, 3],
+    );
     // Partition {0} vs {1, 2}: cuts edges (0,1)=1 and (0,2)=3
     assert_eq!(problem.cut_size(&[0, 1, 1]), 4);
     // All same partition: no edges cut
diff --git a/src/unit_tests/models/graph/maximum_independent_set.rs b/src/unit_tests/models/graph/maximum_independent_set.rs
index 842870f14..5f3ab2f9e 100644
--- a/src/unit_tests/models/graph/maximum_independent_set.rs
+++ b/src/unit_tests/models/graph/maximum_independent_set.rs
@@ -165,7 +165,8 @@ fn test_jl_parity_evaluation() {
 #[test]
 fn test_is_valid_solution() {
     // Path graph: 0-1-2
-    let problem = MaximumIndependentSet::new(SimpleGraph::new(3, vec![(0, 1), (1, 2)]), vec![1i32; 3]);
+    let problem =
+        MaximumIndependentSet::new(SimpleGraph::new(3, vec![(0, 1), (1, 2)]), vec![1i32; 3]);
     // Valid: {0, 2} is independent
     assert!(problem.is_valid_solution(&[1, 0, 1]));
     // Invalid: {0, 1} are adjacent
diff --git a/src/unit_tests/models/graph/minimum_dominating_set.rs b/src/unit_tests/models/graph/minimum_dominating_set.rs
index 432f4a280..9945289db 100644
--- a/src/unit_tests/models/graph/minimum_dominating_set.rs
+++ b/src/unit_tests/models/graph/minimum_dominating_set.rs
@@ -162,7 +162,8 @@ fn test_jl_parity_evaluation() {
 #[test]
 fn test_is_valid_solution() {
     // Path graph: 0-1-2
-    let problem = MinimumDominatingSet::new(SimpleGraph::new(3, vec![(0, 1), (1, 2)]), vec![1i32; 3]);
+    let problem =
+        MinimumDominatingSet::new(SimpleGraph::new(3, vec![(0, 1), (1, 2)]), vec![1i32; 3]);
     // Valid: {1} dominates all vertices (0 and 2 are neighbors of 1)
     assert!(problem.is_valid_solution(&[0, 1, 0]));
     // Invalid: {0} doesn't dominate vertex 2
diff --git a/src/unit_tests/reduction_graph.rs b/src/unit_tests/reduction_graph.rs
index 1884e753e..ed29b5f4c 100644
--- a/src/unit_tests/reduction_graph.rs
+++ b/src/unit_tests/reduction_graph.rs
@@ -5,10 +5,10 @@ use crate::poly;
 use crate::prelude::*;
 use crate::rules::{MinimizeSteps, ReductionGraph, TraversalDirection};
 use crate::topology::{SimpleGraph, TriangularSubgraph};
-use std::collections::BTreeMap;
 use crate::traits::problem_size;
 use crate::types::ProblemSize;
 use crate::variant::K3;
+use std::collections::BTreeMap;
 
 // ---- Discovery and registration ----
 
@@ -318,11 +318,7 @@ fn test_3sat_to_mis_triangular_overhead() {
     // Path: K3SAT → SAT → MIS{SimpleGraph,i32} → MIS{TriangularSubgraph,i32}
     assert_eq!(
         path.type_names(),
-        vec![
-            "KSatisfiability",
-            "Satisfiability",
-            "MaximumIndependentSet"
-        ]
+        vec!["KSatisfiability", "Satisfiability", "MaximumIndependentSet"]
     );
     assert_eq!(path.len(), 3);
 
@@ -331,17 +327,38 @@ fn test_3sat_to_mis_triangular_overhead() {
     assert_eq!(edges.len(), 3);
 
     // Edge 0: K3SAT → SAT (identity)
-    assert_eq!(edges[0].get("num_vars").unwrap().normalized(), poly!(num_vars));
-    assert_eq!(edges[0].get("num_clauses").unwrap().normalized(), poly!(num_clauses));
-    assert_eq!(edges[0].get("num_literals").unwrap().normalized(), poly!(num_literals));
+    assert_eq!(
+        edges[0].get("num_vars").unwrap().normalized(),
+        poly!(num_vars)
+    );
+    assert_eq!(
+        edges[0].get("num_clauses").unwrap().normalized(),
+        poly!(num_clauses)
+    );
+    assert_eq!(
+        edges[0].get("num_literals").unwrap().normalized(),
+        poly!(num_literals)
+    );
 
     // Edge 1: SAT → MIS{SimpleGraph,i32}
-    assert_eq!(edges[1].get("num_vertices").unwrap().normalized(), poly!(num_literals));
-    assert_eq!(edges[1].get("num_edges").unwrap().normalized(), poly!(num_literals ^ 2));
+    assert_eq!(
+        edges[1].get("num_vertices").unwrap().normalized(),
+        poly!(num_literals)
+    );
+    assert_eq!(
+        edges[1].get("num_edges").unwrap().normalized(),
+        poly!(num_literals ^ 2)
+    );
 
     // Edge 2: MIS{SimpleGraph,i32} → MIS{TriangularSubgraph,i32}
-    assert_eq!(edges[2].get("num_vertices").unwrap().normalized(), poly!(num_vertices ^ 2));
-    assert_eq!(edges[2].get("num_edges").unwrap().normalized(), poly!(num_vertices ^ 2));
+    assert_eq!(
+        edges[2].get("num_vertices").unwrap().normalized(),
+        poly!(num_vertices ^ 2)
+    );
+    assert_eq!(
+        edges[2].get("num_edges").unwrap().normalized(),
+        poly!(num_vertices ^ 2)
+    );
 
     // Compose overheads symbolically along the path.
     // The composed overhead maps 3-SAT input variables to final MIS{Triangular} output.
@@ -366,8 +383,8 @@ fn test_3sat_to_mis_triangular_overhead() {
 
 #[test]
 fn test_validate_overhead_variables_valid() {
-    use crate::rules::validate_overhead_variables;
     use crate::rules::registry::ReductionOverhead;
+    use crate::rules::validate_overhead_variables;
 
     let overhead = ReductionOverhead::new(vec![
         ("num_vertices", poly!(num_vars)),
@@ -386,17 +403,15 @@ fn test_validate_overhead_variables_valid() {
 #[test]
 #[should_panic(expected = "overhead references input variables")]
 fn test_validate_overhead_variables_missing_input() {
-    use crate::rules::validate_overhead_variables;
     use crate::rules::registry::ReductionOverhead;
+    use crate::rules::validate_overhead_variables;
 
-    let overhead = ReductionOverhead::new(vec![
-        ("num_vertices", poly!(num_colors)),
-    ]);
+    let overhead = ReductionOverhead::new(vec![("num_vertices", poly!(num_colors))]);
     validate_overhead_variables(
         "Source",
         "Target",
         &overhead,
-        &["num_vars", "num_clauses"],  // no "num_colors"
+        &["num_vars", "num_clauses"], // no "num_colors"
         &["num_vertices"],
     );
 }
@@ -404,43 +419,33 @@ fn test_validate_overhead_variables_missing_input() {
 #[test]
 #[should_panic(expected = "overhead output fields")]
 fn test_validate_overhead_variables_missing_output() {
-    use crate::rules::validate_overhead_variables;
     use crate::rules::registry::ReductionOverhead;
+    use crate::rules::validate_overhead_variables;
 
-    let overhead = ReductionOverhead::new(vec![
-        ("num_gates", poly!(num_vars)),
-    ]);
+    let overhead = ReductionOverhead::new(vec![("num_gates", poly!(num_vars))]);
     validate_overhead_variables(
         "Source",
         "Target",
         &overhead,
         &["num_vars"],
-        &["num_vertices", "num_edges"],  // no "num_gates"
+        &["num_vertices", "num_edges"], // no "num_gates"
     );
 }
 
 #[test]
 fn test_validate_overhead_variables_skips_output_when_empty() {
-    use crate::rules::validate_overhead_variables;
     use crate::rules::registry::ReductionOverhead;
+    use crate::rules::validate_overhead_variables;
 
-    let overhead = ReductionOverhead::new(vec![
-        ("anything", poly!(num_vars)),
-    ]);
+    let overhead = ReductionOverhead::new(vec![("anything", poly!(num_vars))]);
     // Should not panic: target_size_names is empty so output check is skipped
-    validate_overhead_variables(
-        "Source",
-        "Target",
-        &overhead,
-        &["num_vars"],
-        &[],
-    );
+    validate_overhead_variables("Source", "Target", &overhead, &["num_vars"], &[]);
 }
 
 #[test]
 fn test_validate_overhead_variables_identity() {
-    use crate::rules::validate_overhead_variables;
     use crate::rules::registry::ReductionOverhead;
+    use crate::rules::validate_overhead_variables;
 
     let names = &["num_vertices", "num_edges"];
     let overhead = ReductionOverhead::identity(names);
@@ -482,12 +487,7 @@ fn test_k_neighbors_incoming() {
     let variants = graph.variants_for("QUBO");
     assert!(!variants.is_empty());
 
-    let neighbors = graph.k_neighbors(
-        "QUBO",
-        &variants[0],
-        1,
-        TraversalDirection::Incoming,
-    );
+    let neighbors = graph.k_neighbors("QUBO", &variants[0], 1, TraversalDirection::Incoming);
     // QUBO is a common target — should have incoming reductions
     assert!(!neighbors.is_empty());
 }
@@ -499,13 +499,22 @@ fn test_k_neighbors_both() {
     let default_variant = &variants[0];
 
     let out_only = graph.k_neighbors(
-        "MaximumIndependentSet", default_variant, 1, TraversalDirection::Outgoing,
+        "MaximumIndependentSet",
+        default_variant,
+        1,
+        TraversalDirection::Outgoing,
     );
     let in_only = graph.k_neighbors(
-        "MaximumIndependentSet", default_variant, 1, TraversalDirection::Incoming,
+        "MaximumIndependentSet",
+        default_variant,
+        1,
+        TraversalDirection::Incoming,
     );
     let both = graph.k_neighbors(
-        "MaximumIndependentSet", default_variant, 1, TraversalDirection::Both,
+        "MaximumIndependentSet",
+        default_variant,
+        1,
+        TraversalDirection::Both,
     );
     // Both should be >= max of either direction
     assert!(both.len() >= out_only.len());
@@ -526,7 +535,10 @@ fn test_k_neighbors_zero_hops() {
     let variants = graph.variants_for("MaximumIndependentSet");
     let default_variant = &variants[0];
     let neighbors = graph.k_neighbors(
-        "MaximumIndependentSet", default_variant, 0, TraversalDirection::Outgoing,
+        "MaximumIndependentSet",
+        default_variant,
+        0,
+        TraversalDirection::Outgoing,
     );
     assert!(neighbors.is_empty());
 }
diff --git a/src/unit_tests/rules/reduction_path_parity.rs b/src/unit_tests/rules/reduction_path_parity.rs
index 7ca5ca44a..c3813a286 100644
--- a/src/unit_tests/rules/reduction_path_parity.rs
+++ b/src/unit_tests/rules/reduction_path_parity.rs
@@ -152,7 +152,10 @@ fn test_jl_parity_factoring_to_spinglass_path() {
     let target: &SpinGlass<SimpleGraph, f64> = chain.target_problem();
 
     // Verify reduction produces a valid SpinGlass problem
-    assert!(target.num_variables() > 0, "SpinGlass should have variables");
+    assert!(
+        target.num_variables() > 0,
+        "SpinGlass should have variables"
+    );
 
     // Solve Factoring directly via ILP (fast) and verify path solution extraction
     let ilp_solver = ILPSolver::new();
diff --git a/src/unit_tests/trait_consistency.rs b/src/unit_tests/trait_consistency.rs
index 2e95df5b1..7c3fa2e23 100644
--- a/src/unit_tests/trait_consistency.rs
+++ b/src/unit_tests/trait_consistency.rs
@@ -72,7 +72,10 @@ fn test_all_problems_implement_trait_correctly() {
     );
     check_problem_trait(&PaintShop::new(vec!["a", "a"]), "PaintShop");
     check_problem_trait(&BMF::new(vec![vec![true]], 1), "BMF");
-    check_problem_trait(&BicliqueCover::new(BipartiteGraph::new(2, 2, vec![(0, 0)]), 1), "BicliqueCover");
+    check_problem_trait(
+        &BicliqueCover::new(BipartiteGraph::new(2, 2, vec![(0, 0)]), 1),
+        "BicliqueCover",
+    );
     check_problem_trait(&Factoring::new(6, 2, 2), "Factoring");
 
     let circuit = Circuit::new(vec![Assignment::new(
diff --git a/tests/suites/integration.rs b/tests/suites/integration.rs
index 5de6cd295..94ebd5be1 100644
--- a/tests/suites/integration.rs
+++ b/tests/suites/integration.rs
@@ -216,7 +216,10 @@ mod all_problems_solvable {
     #[test]
     fn test_biclique_cover_solvable() {
         // Left vertices: 0, 1; Right vertices: 2, 3
-        let problem = BicliqueCover::new(BipartiteGraph::new(2, 2, vec![(0, 0), (0, 1), (1, 0), (1, 1)]), 1);
+        let problem = BicliqueCover::new(
+            BipartiteGraph::new(2, 2, vec![(0, 0), (0, 1), (1, 0), (1, 1)]),
+            1,
+        );
         let solver = BruteForce::new();
         let solutions = solver.find_all_best(&problem);
         assert!(!solutions.is_empty());
diff --git a/tests/suites/reductions.rs b/tests/suites/reductions.rs
index 19fd2ec1e..d757f2a1f 100644
--- a/tests/suites/reductions.rs
+++ b/tests/suites/reductions.rs
@@ -3,7 +3,7 @@
 //! These tests verify that reduction chains work correctly and
 //! solutions can be properly extracted through the reduction pipeline.
 
-use problemreductions::models::optimization::{ILP, LinearConstraint, ObjectiveSense};
+use problemreductions::models::optimization::{LinearConstraint, ObjectiveSense, ILP};
 use problemreductions::prelude::*;
 use problemreductions::topology::{Graph, SimpleGraph};
 use problemreductions::variant::{K2, K3};

From 9db9b289382544ddc46b0f72cbb08febb55135c9 Mon Sep 17 00:00:00 2001
From: GiggleLiu <cacate0129@gmail.com>
Date: Thu, 19 Feb 2026 11:08:59 +0800
Subject: [PATCH 13/34] fix: address PR review comments

- Remove unused `fmt_arrow_out()` dead code (Copilot review)
- Deduplicate `find_node_index()` by delegating to existing `lookup_node()`
- Extract table formatting into reusable `format_table()` helper in output.rs
- Remove `completions` command and `clap_complete` dependency (manual setup not useful)
- Update CLI documentation with new table output and k-neighbor exploration examples

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
 docs/src/cli.md                             | 72 ++++++++++-----
 problemreductions-cli/Cargo.toml            |  1 -
 problemreductions-cli/src/cli.rs            | 10 ---
 problemreductions-cli/src/commands/graph.rs | 97 +++++++++------------
 problemreductions-cli/src/main.rs           |  7 +-
 problemreductions-cli/src/output.rs         | 86 ++++++++++++++++--
 problemreductions-cli/tests/cli_tests.rs    | 34 --------
 src/rules/graph.rs                          | 10 +--
 8 files changed, 171 insertions(+), 146 deletions(-)

diff --git a/docs/src/cli.md b/docs/src/cli.md
index 744f115b8..ca59c6671 100644
--- a/docs/src/cli.md
+++ b/docs/src/cli.md
@@ -66,23 +66,25 @@ Lists all registered problem types with their short aliases.
 $ pred list
 Registered problems: 17 types, 48 reductions, 25 variant nodes
 
-  CircuitSAT
-  Factoring
-  ILP
-  KColoring
-  KSatisfiability (3SAT, KSAT)
-  MaxCut
-  MaximumClique
-  MaximumIndependentSet (MIS)
-  MaximumMatching
-  MaximumSetPacking
-  MinimumDominatingSet
-  MinimumSetCovering
-  MinimumVertexCover (MVC)
-  QUBO
-  Satisfiability (SAT)
-  SpinGlass
-  TravelingSalesman (TSP)
+  Problem                Aliases     Variants  Reduces to
+  ─────────────────────  ──────────  ────────  ──────────
+  CircuitSAT                                1           1
+  Factoring                                 1           2
+  ILP                                       1           1
+  KColoring                                 2           3
+  KSatisfiability        3SAT, KSAT         3           7
+  MaxCut                                    1           1
+  MaximumClique                             1           1
+  MaximumIndependentSet  MIS                4          10
+  MaximumMatching                           1           2
+  MaximumSetPacking                         2           4
+  MinimumDominatingSet                      1           1
+  MinimumSetCovering                        1           1
+  MinimumVertexCover     MVC                1           4
+  QUBO                                      1           1
+  Satisfiability         SAT                1           5
+  SpinGlass                                 2           3
+  TravelingSalesman      TSP                1           1
 
 Use `pred show <problem>` to see variants, reductions, and fields.
 ```
@@ -111,17 +113,43 @@ Size fields (2):
   num_edges
 
 Reduces to (10):
-  MaximumIndependentSet {graph=SimpleGraph, weight=i32} -> MinimumVertexCover ...
-  MaximumIndependentSet {graph=SimpleGraph, weight=i32} -> ILP (default)
-  MaximumIndependentSet {graph=SimpleGraph, weight=i32} -> QUBO {weight=f64}
+  MaximumIndependentSet {graph=SimpleGraph, weight=i32} → MinimumVertexCover ...
+  MaximumIndependentSet {graph=SimpleGraph, weight=i32} → ILP (default)
+  MaximumIndependentSet {graph=SimpleGraph, weight=i32} → QUBO {weight=f64}
   ...
 
 Reduces from (9):
-  MinimumVertexCover {graph=SimpleGraph, weight=i32} -> MaximumIndependentSet ...
-  Satisfiability (default) -> MaximumIndependentSet {graph=SimpleGraph, weight=i32}
+  MinimumVertexCover {graph=SimpleGraph, weight=i32} → MaximumIndependentSet ...
+  Satisfiability (default) → MaximumIndependentSet {graph=SimpleGraph, weight=i32}
   ...
 ```
 
+Explore neighbors within k hops in the reduction graph:
+
+```bash
+$ pred show MIS --hops 2
+MaximumIndependentSet — 2-hop neighbors (outgoing)
+
+MaximumIndependentSet
+├── MaximumIndependentSet
+└── MaximumIndependentSet
+    ├── ILP
+    ├── MaximumIndependentSet
+    ├── MaximumSetPacking
+    ├── MinimumVertexCover
+    └── QUBO
+
+5 reachable problems in 2 hops
+```
+
+Use `--direction` to control traversal direction:
+
+```bash
+pred show MIS --hops 2 --direction out    # outgoing neighbors (default)
+pred show QUBO --hops 1 --direction in    # incoming neighbors
+pred show MIS --hops 1 --direction both   # both directions
+```
+
 ### `pred path` — Find a reduction path
 
 Find the cheapest chain of reductions between two problems:
diff --git a/problemreductions-cli/Cargo.toml b/problemreductions-cli/Cargo.toml
index 652361e49..dd2b23f0e 100644
--- a/problemreductions-cli/Cargo.toml
+++ b/problemreductions-cli/Cargo.toml
@@ -24,6 +24,5 @@ clap = { version = "4", features = ["derive"] }
 anyhow = "1"
 serde = { version = "1", features = ["derive"] }
 serde_json = "1"
-clap_complete = "4"
 owo-colors = { version = "4", features = ["supports-colors"] }
 petgraph = "0.8"
diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs
index 31ebc5f2f..b8727708d 100644
--- a/problemreductions-cli/src/cli.rs
+++ b/problemreductions-cli/src/cli.rs
@@ -94,16 +94,6 @@ Example:
     Reduce(ReduceArgs),
     /// Solve a problem instance
     Solve(SolveArgs),
-    /// Generate shell completions for bash, zsh, fish, etc.
-    #[command(after_help = "\
-Examples:
-  pred completions bash > ~/.local/share/bash-completion/completions/pred
-  pred completions zsh > ~/.zfunc/_pred
-  pred completions fish > ~/.config/fish/completions/pred.fish")]
-    Completions {
-        /// Shell type
-        shell: clap_complete::Shell,
-    },
 }
 
 #[derive(clap::Args)]
diff --git a/problemreductions-cli/src/commands/graph.rs b/problemreductions-cli/src/commands/graph.rs
index bc80aadcb..158a0c438 100644
--- a/problemreductions-cli/src/commands/graph.rs
+++ b/problemreductions-cli/src/commands/graph.rs
@@ -8,25 +8,27 @@ use std::collections::{BTreeMap, HashMap, HashSet};
 use std::path::Path;
 
 pub fn list(out: &OutputConfig) -> Result<()> {
+    use crate::output::{format_table, Align};
+
     let graph = ReductionGraph::new();
 
     let mut types = graph.problem_types();
     types.sort();
 
     // Collect data for each problem
-    struct Row {
+    struct RowData {
         name: String,
         aliases: Vec<&'static str>,
         num_variants: usize,
         num_reduces_to: usize,
     }
-    let rows: Vec<Row> = types
+    let data: Vec<RowData> = types
         .iter()
         .map(|name| {
             let aliases = aliases_for(name);
             let num_variants = graph.variants_for(name).len();
             let num_reduces_to = graph.outgoing_reductions(name).len();
-            Row {
+            RowData {
                 name: name.to_string(),
                 aliases,
                 num_variants,
@@ -35,21 +37,6 @@ pub fn list(out: &OutputConfig) -> Result<()> {
         })
         .collect();
 
-    // Compute column widths
-    let name_width = rows.iter().map(|r| r.name.len()).max().unwrap_or(7).max(7);
-    let alias_width = rows
-        .iter()
-        .map(|r| {
-            if r.aliases.is_empty() {
-                0
-            } else {
-                r.aliases.join(", ").len()
-            }
-        })
-        .max()
-        .unwrap_or(7)
-        .max(7);
-
     let summary = format!(
         "Registered problems: {} types, {} reductions, {} variant nodes\n",
         graph.num_types(),
@@ -57,53 +44,47 @@ pub fn list(out: &OutputConfig) -> Result<()> {
         graph.num_variant_nodes(),
     );
 
-    let mut text = String::new();
-    text.push_str(&crate::output::fmt_section(&summary));
-    text.push_str(&format!(
-        "\n  {:<name_w$}  {:<alias_w$}  {:>8}  {:>10}\n",
-        "Problem",
-        "Aliases",
-        "Variants",
-        "Reduces to",
-        name_w = name_width,
-        alias_w = alias_width,
-    ));
-    text.push_str(&format!(
-        "  {:<name_w$}  {:<alias_w$}  {:>8}  {:>10}\n",
-        "─".repeat(name_width),
-        "─".repeat(alias_width),
-        "────────",
-        "──────────",
-        name_w = name_width,
-        alias_w = alias_width,
-    ));
+    let columns: Vec<(&str, Align, usize)> = vec![
+        ("Problem", Align::Left, 7),
+        ("Aliases", Align::Left, 7),
+        ("Variants", Align::Right, 8),
+        ("Reduces to", Align::Right, 10),
+    ];
 
-    for row in &rows {
-        let alias_str = if row.aliases.is_empty() {
-            String::new()
-        } else {
-            row.aliases.join(", ")
-        };
-        // Refined approach: pad first, then colorize
-        let padded_name = format!("{:<name_w$}", row.name, name_w = name_width);
-        let colored_name = crate::output::fmt_problem_name(&padded_name);
-        let padded_alias = format!("{:<alias_w$}", alias_str, alias_w = alias_width);
-        let colored_alias = crate::output::fmt_dim(&padded_alias);
-        text.push_str(&format!(
-            "  {}  {}  {:>8}  {:>10}\n",
-            colored_name, colored_alias, row.num_variants, row.num_reduces_to,
-        ));
-    }
+    let rows: Vec<Vec<String>> = data
+        .iter()
+        .map(|r| {
+            vec![
+                r.name.clone(),
+                if r.aliases.is_empty() {
+                    String::new()
+                } else {
+                    r.aliases.join(", ")
+                },
+                r.num_variants.to_string(),
+                r.num_reduces_to.to_string(),
+            ]
+        })
+        .collect();
 
-    text.push_str(&format!(
-        "\nUse `pred show <problem>` to see variants, reductions, and fields.\n"
-    ));
+    let color_fns: Vec<Option<crate::output::CellFormatter>> = vec![
+        Some(crate::output::fmt_problem_name),
+        Some(crate::output::fmt_dim),
+        None,
+        None,
+    ];
+
+    let mut text = String::new();
+    text.push_str(&crate::output::fmt_section(&summary));
+    text.push('\n');
+    text.push_str(&format_table(&columns, &rows, &color_fns));
+    text.push_str("\nUse `pred show <problem>` to see variants, reductions, and fields.\n");
 
     let json = serde_json::json!({
         "num_types": graph.num_types(),
         "num_reductions": graph.num_reductions(),
         "num_variant_nodes": graph.num_variant_nodes(),
-        "problems": rows.iter().map(|r| {
+        "problems": data.iter().map(|r| {
             serde_json::json!({
                 "name": r.name,
                 "aliases": r.aliases,
diff --git a/problemreductions-cli/src/main.rs b/problemreductions-cli/src/main.rs
index 64100277c..40c85a384 100644
--- a/problemreductions-cli/src/main.rs
+++ b/problemreductions-cli/src/main.rs
@@ -4,7 +4,7 @@ mod dispatch;
 mod output;
 mod problem_name;
 
-use clap::{CommandFactory, Parser};
+use clap::Parser;
 use cli::{Cli, Commands};
 use output::OutputConfig;
 
@@ -48,10 +48,5 @@ fn main() -> anyhow::Result<()> {
             commands::reduce::reduce(&args.input, &args.to, args.via.as_deref(), &out)
         }
         Commands::Evaluate(args) => commands::evaluate::evaluate(&args.input, &args.config, &out),
-        Commands::Completions { shell } => {
-            let mut cmd = Cli::command();
-            clap_complete::generate(shell, &mut cmd, "pred", &mut std::io::stdout());
-            Ok(())
-        }
     }
 }
diff --git a/problemreductions-cli/src/output.rs b/problemreductions-cli/src/output.rs
index 6114cfc4c..a8178213f 100644
--- a/problemreductions-cli/src/output.rs
+++ b/problemreductions-cli/src/output.rs
@@ -54,12 +54,6 @@ pub fn fmt_section(text: &str) -> String {
     }
 }
 
-/// Format an outgoing arrow (green when color is enabled).
-pub fn fmt_arrow_out() -> &'static str {
-    // We return static str, so we use ANSI directly for the arrow
-    "\u{2192}"
-}
-
 pub fn fmt_outgoing(text: &str) -> String {
     if use_color() {
         format!("{}", text.green())
@@ -84,3 +78,83 @@ pub fn fmt_dim(text: &str) -> String {
         text.to_string()
     }
 }
+
+/// Function that transforms cell text for display (e.g., adding color).
+pub type CellFormatter = fn(&str) -> String;
+
+/// Column alignment specification for table formatting.
+pub enum Align {
+    Left,
+    Right,
+}
+
+/// Format data as an aligned table.
+///
+/// Each column is defined by a `(header, alignment, width)` tuple.
+/// `width` is auto-expanded to fit the header. Rows provide one string per column.
+/// An optional `color_fn` can transform cell text for display (widths are computed
+/// on the raw text before coloring).
+pub fn format_table(
+    columns: &[(&str, Align, usize)],
+    rows: &[Vec<String>],
+    color_fns: &[Option<CellFormatter>],
+) -> String {
+    // Compute actual column widths (max of header width, specified width, and data width)
+    let widths: Vec<usize> = columns
+        .iter()
+        .enumerate()
+        .map(|(i, (header, _, min_w))| {
+            let data_max = rows.iter().map(|r| r[i].len()).max().unwrap_or(0);
+            data_max.max(*min_w).max(header.len())
+        })
+        .collect();
+
+    let mut text = String::new();
+
+    // Header
+    text.push_str("  ");
+    for (i, (header, align, _)) in columns.iter().enumerate() {
+        if i > 0 {
+            text.push_str("  ");
+        }
+        match align {
+            Align::Left => text.push_str(&format!("{:<w$}", header, w = widths[i])),
+            Align::Right => text.push_str(&format!("{:>w$}", header, w = widths[i])),
+        }
+    }
+    text.push('\n');
+
+    // Separator
+    text.push_str("  ");
+    for (i, _) in columns.iter().enumerate() {
+        if i > 0 {
+            text.push_str("  ");
+        }
+        text.push_str(&"─".repeat(widths[i]));
+    }
+    text.push('\n');
+
+    // Data rows
+    for row in rows {
+        text.push_str("  ");
+        for (i, (_, align, _)) in columns.iter().enumerate() {
+            if i > 0 {
+                text.push_str("  ");
+            }
+            let cell = &row[i];
+            let padded = match align {
+                Align::Left => format!("{:<w$}", cell, w = widths[i]),
+                Align::Right => format!("{:>w$}", cell, w = widths[i]),
+            };
+            if let Some(Some(f)) = color_fns.get(i) {
+                // Pad first, then colorize (so ANSI codes don't affect width)
+                text.push_str(&f(&padded));
+            } else {
+                text.push_str(&padded);
+            }
+        }
+        text.push('\n');
+    }
+
+    text
+}
diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs
index 70f81c156..1eae20e1c 100644
--- a/problemreductions-cli/tests/cli_tests.rs
+++ b/problemreductions-cli/tests/cli_tests.rs
@@ -1179,40 +1179,6 @@ fn test_subcommand_help() {
     assert!(stdout.contains("pred create"));
 }
 
-// ---- Shell completions tests ----
-
-#[test]
-fn test_completions_bash() {
-    let output = pred().args(["completions", "bash"]).output().unwrap();
-    assert!(
-        output.status.success(),
-        "stderr: {}",
-        String::from_utf8_lossy(&output.stderr)
-    );
-    let stdout = String::from_utf8(output.stdout).unwrap();
-    // Bash completions should reference the binary name
-    assert!(
-        stdout.contains("pred"),
-        "completions should reference 'pred'"
-    );
-}
-
-#[test]
-fn test_completions_zsh() {
-    let output = pred().args(["completions", "zsh"]).output().unwrap();
-    assert!(output.status.success());
-    let stdout = String::from_utf8(output.stdout).unwrap();
-    assert!(stdout.contains("pred"));
-}
-
-#[test]
-fn test_completions_fish() {
-    let output = pred().args(["completions", "fish"]).output().unwrap();
-    assert!(output.status.success());
-    let stdout = String::from_utf8(output.stdout).unwrap();
-    assert!(stdout.contains("pred"));
-}
-
 // ---- k-neighbor exploration tests ----
 
 #[test]
diff --git a/src/rules/graph.rs b/src/rules/graph.rs
index 43825d2de..2c1642a79 100644
--- a/src/rules/graph.rs
+++ b/src/rules/graph.rs
@@ -731,15 +731,7 @@ impl ReductionGraph {
         name: &str,
         variant: &BTreeMap<String, String>,
     ) -> Option<NodeIndex> {
-        self.name_to_nodes.get(name).and_then(|indices| {
-            indices
-                .iter()
-                .find(|&&idx| {
-                    let node = &self.nodes[self.graph[idx]];
-                    node.variant == *variant
-                })
-                .copied()
-        })
+        self.lookup_node(name, variant)
     }
 
     /// Get neighbors of a node in a specific direction.

From 4e040f7f7e30e17201f5bf1b2b25a6a8caf66067 Mon Sep 17 00:00:00 2001
From: GiggleLiu <cacate0129@gmail.com>
Date: Thu, 19 Feb 2026 11:14:57 +0800
Subject: [PATCH 14/34] feat(cli): re-add completions with auto-detection and
 eval setup
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Shell argument is now optional — auto-detects from $SHELL env.
Setup is just one line: eval "$(pred completions)"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
 docs/src/cli.md                          | 17 +++++++++++++++
 problemreductions-cli/Cargo.toml         |  1 +
 problemreductions-cli/src/cli.rs         | 16 ++++++++++++++
 problemreductions-cli/src/main.rs        | 10 ++++++++-
 problemreductions-cli/tests/cli_tests.rs | 27 ++++++++++++++++++++++++
 5 files changed, 70 insertions(+), 1 deletion(-)

diff --git a/docs/src/cli.md b/docs/src/cli.md
index ca59c6671..4adbc5e95 100644
--- a/docs/src/cli.md
+++ b/docs/src/cli.md
@@ -303,6 +303,23 @@ Source evaluation: Valid(2)
 > Some problems (e.g., QUBO, SpinGlass, MaxCut, CircuitSAT) do not have this path yet.
 > Use `--solver brute-force` for these, or reduce to a problem that supports ILP first.
 
+## Shell Completions
+
+Enable tab completion by adding one line to your shell config:
+
+```bash
+# bash (~/.bashrc)
+eval "$(pred completions bash)"
+
+# zsh (~/.zshrc)
+eval "$(pred completions zsh)"
+
+# fish (~/.config/fish/config.fish)
+pred completions fish | source
+```
+
+If the shell argument is omitted, `pred completions` auto-detects your current shell.
+
 ## JSON Output
 
 All commands support `-o` to write JSON output to a file:
diff --git a/problemreductions-cli/Cargo.toml b/problemreductions-cli/Cargo.toml
index dd2b23f0e..652361e49 100644
--- a/problemreductions-cli/Cargo.toml
+++ b/problemreductions-cli/Cargo.toml
@@ -24,5 +24,6 @@ clap = { version = "4", features = ["derive"] }
 anyhow = "1"
 serde = { version = "1", features = ["derive"] }
 serde_json = "1"
+clap_complete = "4"
 owo-colors = { version = "4", features = ["supports-colors"] }
 petgraph = "0.8"
diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs
index b8727708d..ca09a34cd 100644
--- a/problemreductions-cli/src/cli.rs
+++ b/problemreductions-cli/src/cli.rs
@@ -94,6 +94,22 @@ Example:
     Reduce(ReduceArgs),
     /// Solve a problem instance
     Solve(SolveArgs),
+    /// Print shell completions to stdout (auto-detects shell)
+    #[command(after_help = "\
+Setup: add one line to your shell rc file:
+
+  # bash (~/.bashrc)
+  eval \"$(pred completions bash)\"
+
+  # zsh (~/.zshrc)
+  eval \"$(pred completions zsh)\"
+
+  # fish (~/.config/fish/config.fish)
+  pred completions fish | source")]
+    Completions {
+        /// Shell type (bash, zsh, fish, etc.). Auto-detected if omitted.
+        shell: Option<clap_complete::Shell>,
+    },
 }
 
 #[derive(clap::Args)]
diff --git a/problemreductions-cli/src/main.rs b/problemreductions-cli/src/main.rs
index 40c85a384..2c898e820 100644
--- a/problemreductions-cli/src/main.rs
+++ b/problemreductions-cli/src/main.rs
@@ -4,7 +4,7 @@ mod dispatch;
 mod output;
 mod problem_name;
 
-use clap::Parser;
+use clap::{CommandFactory, Parser};
 use cli::{Cli, Commands};
 use output::OutputConfig;
 
@@ -48,5 +48,13 @@ fn main() -> anyhow::Result<()> {
             commands::reduce::reduce(&args.input, &args.to, args.via.as_deref(), &out)
         }
         Commands::Evaluate(args) => commands::evaluate::evaluate(&args.input, &args.config, &out),
+        Commands::Completions { shell } => {
+            let shell = shell
+                .or_else(clap_complete::Shell::from_env)
+                .unwrap_or(clap_complete::Shell::Bash);
+            let mut cmd = Cli::command();
+            clap_complete::generate(shell, &mut cmd, "pred", &mut std::io::stdout());
+            Ok(())
+        }
     }
 }
diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs
index 1eae20e1c..c37f21fa7 100644
--- a/problemreductions-cli/tests/cli_tests.rs
+++ b/problemreductions-cli/tests/cli_tests.rs
@@ -1179,6 +1179,33 @@ fn test_subcommand_help() {
     assert!(stdout.contains("pred create"));
 }
 
+// ---- Shell completions tests ----
+
+#[test]
+fn test_completions_bash() {
+    let output = pred().args(["completions", "bash"]).output().unwrap();
+    assert!(
+        output.status.success(),
+        "stderr: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+    let stdout = String::from_utf8(output.stdout).unwrap();
+    assert!(stdout.contains("pred"), "completions should reference 'pred'");
+}
+
+#[test]
+fn test_completions_auto_detect() {
+    // Without explicit shell arg, should still succeed (falls back to bash)
+    let output = pred().args(["completions"]).output().unwrap();
+    assert!(
+        output.status.success(),
+        "stderr: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+    let stdout = String::from_utf8(output.stdout).unwrap();
+    assert!(stdout.contains("pred"));
+}
+
 // ---- k-neighbor exploration tests ----
 
 #[test]

From 33f8e903b64623b25a7cd343a8517dabf82fa3a4 Mon Sep 17 00:00:00 2001
From: GiggleLiu <cacate0129@gmail.com>
Date: Thu, 19 Feb 2026 11:19:47 +0800
Subject: [PATCH 15/34] feat(cli): add problem name completion for shell
 tab-completion

Custom ProblemNameParser provides all registered problem names and
aliases as completion candidates while still accepting any string
(so MIS/UnitDiskGraph variant syntax works).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
 problemreductions-cli/src/cli.rs          |  6 ++++-
 problemreductions-cli/src/problem_name.rs | 32 +++++++++++++++++++++++
 2 files changed, 37 insertions(+), 1 deletion(-)

diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs
index ca09a34cd..21ae275c2 100644
--- a/problemreductions-cli/src/cli.rs
+++ b/problemreductions-cli/src/cli.rs
@@ -45,6 +45,7 @@ Examples:
 Use `pred list` to see all available problem types and aliases.")]
     Show {
         /// Problem name or alias (e.g., MIS, QUBO, MIS/UnitDiskGraph)
+        #[arg(value_parser = crate::problem_name::ProblemNameParser)]
         problem: String,
         /// Explore k-hop neighbors in the reduction graph
         #[arg(long)]
@@ -66,8 +67,10 @@ Examples:
 Use `pred list` to see available problems.")]
     Path {
         /// Source problem (e.g., MIS, MIS/UnitDiskGraph)
+        #[arg(value_parser = crate::problem_name::ProblemNameParser)]
         source: String,
         /// Target problem (e.g., QUBO)
+        #[arg(value_parser = crate::problem_name::ProblemNameParser)]
         target: String,
         /// Cost function [default: minimize-steps]
         #[arg(long, default_value = "minimize-steps")]
@@ -138,6 +141,7 @@ Output (`-o`) uses the standard problem JSON format:
   {\"type\": \"...\", \"variant\": {...}, \"data\": {...}}")]
 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)
     #[arg(long)]
@@ -206,7 +210,7 @@ pub struct ReduceArgs {
     /// Problem JSON file (from `pred create`)
     pub input: PathBuf,
     /// Target problem type (e.g., QUBO, SpinGlass)
-    #[arg(long)]
+    #[arg(long, value_parser = crate::problem_name::ProblemNameParser)]
     pub to: String,
     /// Reduction route file (from `pred path ... -o`)
     #[arg(long)]
diff --git a/problemreductions-cli/src/problem_name.rs b/problemreductions-cli/src/problem_name.rs
index 99ee3d8a9..490324a1d 100644
--- a/problemreductions-cli/src/problem_name.rs
+++ b/problemreductions-cli/src/problem_name.rs
@@ -1,4 +1,5 @@
 use std::collections::BTreeMap;
+use std::ffi::OsStr;
 
 /// A parsed problem specification: name + optional variant values.
 #[derive(Debug, Clone)]
@@ -125,6 +126,37 @@ pub fn resolve_variant(
     Ok(result)
 }
 
+/// A value parser that accepts any string but provides problem names as
+/// completion candidates for shell completion scripts.
+#[derive(Clone)]
+pub struct ProblemNameParser;
+
+impl clap::builder::TypedValueParser for ProblemNameParser {
+    type Value = String;
+
+    fn parse_ref(
+        &self,
+        _cmd: &clap::Command,
+        _arg: Option<&clap::Arg>,
+        value: &OsStr,
+    ) -> Result<String, clap::Error> {
+        Ok(value.to_string_lossy().to_string())
+    }
+
+    fn possible_values(&self) -> Option<Box<dyn Iterator<Item = clap::builder::PossibleValue>>> {
+        let graph = problemreductions::rules::ReductionGraph::new();
+        let mut names: Vec<&'static str> = graph.problem_types();
+        for (alias, _) in ALIASES {
+            names.push(alias);
+        }
+        names.sort();
+        names.dedup();
+        Some(Box::new(
+            names.into_iter().map(clap::builder::PossibleValue::new),
+        ))
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;

From 85df827dc1210ec9a309ac171645ecdadadef891 Mon Sep 17 00:00:00 2001
From: GiggleLiu <cacate0129@gmail.com>
Date: Thu, 19 Feb 2026 11:34:35 +0800
Subject: [PATCH 16/34] fix(cli): use left arrow for "Reduces from" and green
 for all arrows
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Makes direction immediately clear: → for outgoing, ← for incoming.
Both arrows use green for visual consistency. Removes unused
fmt_incoming() function.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
 problemreductions-cli/src/commands/graph.rs | 6 +++---
 problemreductions-cli/src/output.rs         | 8 --------
 2 files changed, 3 insertions(+), 11 deletions(-)

diff --git a/problemreductions-cli/src/commands/graph.rs b/problemreductions-cli/src/commands/graph.rs
index 158a0c438..8aff0087d 100644
--- a/problemreductions-cli/src/commands/graph.rs
+++ b/problemreductions-cli/src/commands/graph.rs
@@ -183,11 +183,11 @@ pub fn show(problem: &str, hops: Option<usize>, direction: &str, out: &OutputCon
     for e in &incoming {
         text.push_str(&format!(
             "  {} {} {} {} {}\n",
-            crate::output::fmt_problem_name(e.source_name),
-            format_variant(&e.source_variant),
-            crate::output::fmt_incoming("\u{2192}"),
             e.target_name,
             format_variant(&e.target_variant),
+            crate::output::fmt_outgoing("\u{2190}"),
+            crate::output::fmt_problem_name(e.source_name),
+            format_variant(&e.source_variant),
         ));
     }
 
diff --git a/problemreductions-cli/src/output.rs b/problemreductions-cli/src/output.rs
index a8178213f..baf927eae 100644
--- a/problemreductions-cli/src/output.rs
+++ b/problemreductions-cli/src/output.rs
@@ -62,14 +62,6 @@ pub fn fmt_outgoing(text: &str) -> String {
     }
 }
 
-pub fn fmt_incoming(text: &str) -> String {
-    if use_color() {
-        format!("{}", text.red())
-    } else {
-        text.to_string()
-    }
-}
-
 /// Format dim text (for aliases, tree branches).
 pub fn fmt_dim(text: &str) -> String {
     if use_color() {

From d6047c9c906b963f7bb6c6f50ef0c36fca1a8bdc Mon Sep 17 00:00:00 2001
From: GiggleLiu <cacate0129@gmail.com>
Date: Thu, 19 Feb 2026 12:03:15 +0800
Subject: [PATCH 17/34] fix(cli): add tab completion hint to pred --help

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
 problemreductions-cli/src/cli.rs | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs
index 21ae275c2..c5ec6c0bb 100644
--- a/problemreductions-cli/src/cli.rs
+++ b/problemreductions-cli/src/cli.rs
@@ -13,7 +13,10 @@ Typical workflow:
   pred evaluate problem.json --config 1,0,1,0
 
 Use `pred <command> --help` for detailed usage of each command.
-Use `pred list` to see all available problem types."
+Use `pred list` to see all available problem types.
+
+Enable tab completion:
+  eval \"$(pred completions)\"     # add to ~/.bashrc or ~/.zshrc"
 )]
 pub struct Cli {
     /// Output file path (implies JSON output)

From e9bb554970e7696763a0d04142571ffa22ef5548 Mon Sep 17 00:00:00 2001
From: GiggleLiu <cacate0129@gmail.com>
Date: Thu, 19 Feb 2026 12:25:39 +0800
Subject: [PATCH 18/34] feat(cli): make --to optional when --via is given in
 pred reduce

The target problem is already in the path file, so --to is redundant.
Now: pred reduce problem.json --via path.json -o reduced.json
Either --to or --via must be provided; error if neither is given.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
 docs/src/cli.md                              |  4 +-
 problemreductions-cli/src/cli.rs             |  7 +-
 problemreductions-cli/src/commands/reduce.rs | 51 ++++++++-----
 problemreductions-cli/src/main.rs            |  2 +-
 problemreductions-cli/tests/cli_tests.rs     | 79 ++++++++++++++++++++
 5 files changed, 119 insertions(+), 24 deletions(-)

diff --git a/docs/src/cli.md b/docs/src/cli.md
index 4adbc5e95..706cb4c88 100644
--- a/docs/src/cli.md
+++ b/docs/src/cli.md
@@ -243,10 +243,10 @@ Reduce a problem to a target type. Outputs a reduction bundle containing source,
 pred reduce problem.json --to QUBO -o reduced.json
 ```
 
-Use a specific reduction path (from `pred path -o`):
+Use a specific reduction path (from `pred path -o`). The target is inferred from the path file, so `--to` is not needed:
 
 ```bash
-pred reduce problem.json --to QUBO --via path.json -o reduced.json
+pred reduce problem.json --via path.json -o reduced.json
 ```
 
 Without `-o`, the bundle JSON is printed to stdout:
diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs
index c5ec6c0bb..0b338f8bc 100644
--- a/problemreductions-cli/src/cli.rs
+++ b/problemreductions-cli/src/cli.rs
@@ -203,18 +203,19 @@ pub struct SolveArgs {
 Examples:
   pred reduce problem.json --to QUBO -o reduced.json
   pred reduce problem.json --to ILP -o reduced.json
-  pred reduce problem.json --to QUBO --via path.json -o reduced.json
+  pred reduce problem.json --via path.json -o reduced.json
 
 Input: a problem JSON from `pred create`.
 The --via path file is from `pred path <SRC> <DST> -o path.json`.
+When --via is given, --to is inferred from the path file.
 Output is a reduction bundle with source, target, and path.
 Use `pred solve reduced.json` to solve and map the solution back.")]
 pub struct ReduceArgs {
     /// Problem JSON file (from `pred create`)
     pub input: PathBuf,
-    /// Target problem type (e.g., QUBO, SpinGlass)
+    /// Target problem type (e.g., QUBO, SpinGlass). Inferred from --via if omitted.
     #[arg(long, value_parser = crate::problem_name::ProblemNameParser)]
-    pub to: String,
+    pub to: Option<String>,
     /// Reduction route file (from `pred path ... -o`)
     #[arg(long)]
     pub via: Option<PathBuf>,
diff --git a/problemreductions-cli/src/commands/reduce.rs b/problemreductions-cli/src/commands/reduce.rs
index 951beee54..8e40094f5 100644
--- a/problemreductions-cli/src/commands/reduce.rs
+++ b/problemreductions-cli/src/commands/reduce.rs
@@ -48,7 +48,12 @@ fn parse_path_node(node: &serde_json::Value) -> Result<ReductionStep> {
     Ok(ReductionStep { name, variant })
 }
 
-pub fn reduce(input: &Path, target: &str, via: Option<&Path>, out: &OutputConfig) -> Result<()> {
+pub fn reduce(
+    input: &Path,
+    target: Option<&str>,
+    via: Option<&Path>,
+    out: &OutputConfig,
+) -> Result<()> {
     // 1. Load source problem
     let content = std::fs::read_to_string(input)?;
     let problem_json: ProblemJson = serde_json::from_str(&content)?;
@@ -61,20 +66,12 @@ pub fn reduce(input: &Path, target: &str, via: Option<&Path>, out: &OutputConfig
 
     let source_name = source.problem_name();
     let source_variant = source.variant_map();
-
-    // 2. Parse target spec
-    let dst_spec = parse_problem_spec(target)?;
     let graph = ReductionGraph::new();
 
-    let dst_variants = graph.variants_for(&dst_spec.name);
-    if dst_variants.is_empty() {
-        anyhow::bail!("Unknown target problem: {}", dst_spec.name);
-    }
-
     // 3. Get reduction path: from --via file or auto-discover
     let reduction_path = if let Some(path_file) = via {
         let path = load_path_file(path_file)?;
-        // Validate that the path starts with the source and ends with the target
+        // Validate that the path starts with the source
         let first = path.steps.first().unwrap();
         let last = path.steps.last().unwrap();
         if first.name != source_name || first.variant != source_variant {
@@ -86,15 +83,34 @@ pub fn reduce(input: &Path, target: &str, via: Option<&Path>, out: &OutputConfig
                 format_variant(&source_variant),
             );
         }
-        if last.name != dst_spec.name {
-            anyhow::bail!(
-                "Path file ends with {} but target is {}",
-                last.name,
-                dst_spec.name,
-            );
+        // If --to is given, validate it matches the path's target
+        if let Some(target) = target {
+            let dst_spec = parse_problem_spec(target)?;
+            if last.name != dst_spec.name {
+                anyhow::bail!(
+                    "Path file ends with {} but --to specifies {}",
+                    last.name,
+                    dst_spec.name,
+                );
+            }
         }
         path
     } else {
+        // --to is required when --via is not given
+        let target = target.ok_or_else(|| {
+            anyhow::anyhow!(
+                "Either --to or --via is required.\n\n\
+                 Usage:\n\
+                   pred reduce problem.json --to QUBO\n\
+                   pred reduce problem.json --via path.json"
+            )
+        })?;
+        let dst_spec = parse_problem_spec(target)?;
+        let dst_variants = graph.variants_for(&dst_spec.name);
+        if dst_variants.is_empty() {
+            anyhow::bail!("Unknown target problem: {}", dst_spec.name);
+        }
+
         // Auto-discover cheapest path
         let input_size = ProblemSize::new(vec![]);
         let mut best_path = None;
@@ -122,13 +138,12 @@ pub fn reduce(input: &Path, target: &str, via: Option<&Path>, out: &OutputConfig
                 "No reduction path from {} to {}\n\n\
                  Hint: generate a path file first, then pass it with --via:\n\
                    pred path {} {} -o path.json\n\
-                   pred reduce {} --to {} --via path.json -o reduced.json",
+                   pred reduce {} --via path.json -o reduced.json",
                 source_name,
                 dst_spec.name,
                 source_name,
                 dst_spec.name,
                 input.display(),
-                dst_spec.name,
             )
         })?
     };
diff --git a/problemreductions-cli/src/main.rs b/problemreductions-cli/src/main.rs
index 2c898e820..97289cc85 100644
--- a/problemreductions-cli/src/main.rs
+++ b/problemreductions-cli/src/main.rs
@@ -45,7 +45,7 @@ fn main() -> anyhow::Result<()> {
         Commands::Create(args) => commands::create::create(&args, &out),
         Commands::Solve(args) => commands::solve::solve(&args.input, &args.solver, &out),
         Commands::Reduce(args) => {
-            commands::reduce::reduce(&args.input, &args.to, args.via.as_deref(), &out)
+            commands::reduce::reduce(&args.input, args.to.as_deref(), args.via.as_deref(), &out)
         }
         Commands::Evaluate(args) => commands::evaluate::evaluate(&args.input, &args.config, &out),
         Commands::Completions { shell } => {
diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs
index c37f21fa7..c12308004 100644
--- a/problemreductions-cli/tests/cli_tests.rs
+++ b/problemreductions-cli/tests/cli_tests.rs
@@ -292,6 +292,85 @@ fn test_reduce_via_path() {
     std::fs::remove_file(&output_file).ok();
 }
 
+#[test]
+fn test_reduce_via_infer_target() {
+    // --via without --to: target is inferred from the path file
+    let problem_file = std::env::temp_dir().join("pred_test_reduce_via_infer_in.json");
+    let create_out = pred()
+        .args([
+            "-o",
+            problem_file.to_str().unwrap(),
+            "create",
+            "MIS",
+            "--edges",
+            "0-1,1-2,2-3",
+        ])
+        .output()
+        .unwrap();
+    assert!(create_out.status.success());
+
+    let path_file = std::env::temp_dir().join("pred_test_reduce_via_infer_path.json");
+    let path_out = pred()
+        .args(["path", "MIS", "QUBO", "-o", path_file.to_str().unwrap()])
+        .output()
+        .unwrap();
+    assert!(path_out.status.success());
+
+    let output_file = std::env::temp_dir().join("pred_test_reduce_via_infer_out.json");
+    let reduce_out = pred()
+        .args([
+            "-o",
+            output_file.to_str().unwrap(),
+            "reduce",
+            problem_file.to_str().unwrap(),
+            "--via",
+            path_file.to_str().unwrap(),
+        ])
+        .output()
+        .unwrap();
+    assert!(
+        reduce_out.status.success(),
+        "stderr: {}",
+        String::from_utf8_lossy(&reduce_out.stderr)
+    );
+
+    let content = std::fs::read_to_string(&output_file).unwrap();
+    let bundle: serde_json::Value = serde_json::from_str(&content).unwrap();
+    assert_eq!(bundle["source"]["type"], "MaximumIndependentSet");
+    assert_eq!(bundle["target"]["type"], "QUBO");
+
+    std::fs::remove_file(&problem_file).ok();
+    std::fs::remove_file(&path_file).ok();
+    std::fs::remove_file(&output_file).ok();
+}
+
+#[test]
+fn test_reduce_missing_to_and_via() {
+    let problem_file = std::env::temp_dir().join("pred_test_reduce_missing.json");
+    let create_out = pred()
+        .args([
+            "-o",
+            problem_file.to_str().unwrap(),
+            "create",
+            "MIS",
+            "--edges",
+            "0-1",
+        ])
+        .output()
+        .unwrap();
+    assert!(create_out.status.success());
+
+    let output = pred()
+        .args(["reduce", problem_file.to_str().unwrap()])
+        .output()
+        .unwrap();
+    assert!(!output.status.success());
+    let stderr = String::from_utf8_lossy(&output.stderr);
+    assert!(stderr.contains("--to") || stderr.contains("--via"));
+
+    std::fs::remove_file(&problem_file).ok();
+}
+
 #[test]
 fn test_create_mis() {
     let output_file = std::env::temp_dir().join("pred_test_create_mis.json");

From 8a2585f19d9ef9d782892ee5514893899f6fba48 Mon Sep 17 00:00:00 2001
From: GiggleLiu <cacate0129@gmail.com>
Date: Thu, 19 Feb 2026 12:41:40 +0800
Subject: [PATCH 19/34] feat(cli): unify solve output format for direct and
 bundle solving

Use consistent Problem/Solver/Solution/Evaluation format for both
direct problem solving and reduction bundle solving. Bundle solver
shows "via <target>" in solver description. Add hint about -o flag
for JSON output with intermediate results.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
 problemreductions-cli/src/commands/solve.rs | 51 ++++++++++-----------
 problemreductions-cli/tests/cli_tests.rs    | 10 ++--
 2 files changed, 28 insertions(+), 33 deletions(-)

diff --git a/problemreductions-cli/src/commands/solve.rs b/problemreductions-cli/src/commands/solve.rs
index 15149c0ee..ce9c548b9 100644
--- a/problemreductions-cli/src/commands/solve.rs
+++ b/problemreductions-cli/src/commands/solve.rs
@@ -75,31 +75,29 @@ fn solve_problem(
                 "solution": result.config,
                 "evaluation": result.evaluation,
             });
+            if out.output.is_none() {
+                eprintln!("\nHint: use -o to save full solution details as JSON.");
+            }
             out.emit_with_default_name("", &text, &json)
         }
         "ilp" => {
             let result = problem.solve_with_ilp()?;
-            let reduced = name != "ILP";
-            let text = if reduced {
-                format!(
-                    "Problem: {} (reduced to ILP)\nSolver: ilp\nSolution: {:?}\nEvaluation: {}",
-                    name, result.config, result.evaluation,
-                )
-            } else {
-                format!(
-                    "Problem: ILP\nSolver: ilp\nSolution: {:?}\nEvaluation: {}",
-                    result.config, result.evaluation,
-                )
-            };
+            let text = format!(
+                "Problem: {}\nSolver: ilp\nSolution: {:?}\nEvaluation: {}",
+                name, result.config, result.evaluation,
+            );
             let mut json = serde_json::json!({
                 "problem": name,
                 "solver": "ilp",
                 "solution": result.config,
                 "evaluation": result.evaluation,
             });
-            if reduced {
+            if name != "ILP" {
                 json["reduced_to"] = serde_json::json!("ILP");
             }
+            if out.output.is_none() {
+                eprintln!("\nHint: use -o to save full solution details as JSON.");
+            }
             out.emit_with_default_name("", &text, &json)
         }
         _ => unreachable!(),
@@ -155,30 +153,27 @@ fn solve_bundle(bundle: ReductionBundle, solver_name: &str, out: &OutputConfig)
     let source_config = chain.extract_solution(&target_result.config);
     let source_eval = source.evaluate_dyn(&source_config);
 
+    let solver_desc = format!("{} (via {})", solver_name, target_name);
     let text = format!(
-        "Source: {}\nTarget: {} (solved with {})\nTarget solution: {:?}\nTarget evaluation: {}\nSource solution: {:?}\nSource evaluation: {}",
-        source_name,
-        target_name,
-        solver_name,
-        target_result.config,
-        target_result.evaluation,
-        source_config,
-        source_eval,
+        "Problem: {}\nSolver: {}\nSolution: {:?}\nEvaluation: {}",
+        source_name, solver_desc, source_config, source_eval,
     );
 
     let json = serde_json::json!({
-        "source": {
-            "problem": source_name,
-            "solution": source_config,
-            "evaluation": source_eval,
-        },
-        "target": {
+        "problem": source_name,
+        "solver": solver_name,
+        "reduced_to": target_name,
+        "solution": source_config,
+        "evaluation": source_eval,
+        "intermediate": {
             "problem": target_name,
-            "solver": solver_name,
             "solution": target_result.config,
             "evaluation": target_result.evaluation,
         },
     });
 
+    if out.output.is_none() {
+        eprintln!("\nHint: use -o to save full solution details (including intermediate results) as JSON.");
+    }
     out.emit_with_default_name("", &text, &json)
 }
diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs
index c12308004..28cf64539 100644
--- a/problemreductions-cli/tests/cli_tests.rs
+++ b/problemreductions-cli/tests/cli_tests.rs
@@ -601,7 +601,7 @@ fn test_solve_ilp_default() {
         String::from_utf8_lossy(&output.stderr)
     );
     let stdout = String::from_utf8(output.stdout).unwrap();
-    assert!(stdout.contains("reduced to ILP"));
+    assert!(stdout.contains("Solver: ilp"));
 
     std::fs::remove_file(&problem_file).ok();
 }
@@ -702,8 +702,8 @@ fn test_solve_bundle() {
         String::from_utf8_lossy(&output.stderr)
     );
     let stdout = String::from_utf8(output.stdout).unwrap();
-    assert!(stdout.contains("Source"));
-    assert!(stdout.contains("Target"));
+    assert!(stdout.contains("Problem"));
+    assert!(stdout.contains("via"));
 
     std::fs::remove_file(&problem_file).ok();
     std::fs::remove_file(&bundle_file).ok();
@@ -756,8 +756,8 @@ fn test_solve_bundle_ilp() {
         String::from_utf8_lossy(&output.stderr)
     );
     let stdout = String::from_utf8(output.stdout).unwrap();
-    assert!(stdout.contains("Source"));
-    assert!(stdout.contains("Target"));
+    assert!(stdout.contains("Problem"));
+    assert!(stdout.contains("via"));
 
     std::fs::remove_file(&problem_file).ok();
     std::fs::remove_file(&bundle_file).ok();

From 7b4734cbcb8c07f6e730c1f7c8e78e3dab4ecce8 Mon Sep 17 00:00:00 2001
From: GiggleLiu <cacate0129@gmail.com>
Date: Thu, 19 Feb 2026 12:43:25 +0800
Subject: [PATCH 20/34] fix(cli): print solve hint after output, not before

Move the -o hint eprintln to after emit_with_default_name so the hint
appears at the last line of terminal output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
 problemreductions-cli/src/commands/solve.rs | 9 ++++++---
 1 file changed, 6 insertions(+), 3 deletions(-)

diff --git a/problemreductions-cli/src/commands/solve.rs b/problemreductions-cli/src/commands/solve.rs
index ce9c548b9..9791cc297 100644
--- a/problemreductions-cli/src/commands/solve.rs
+++ b/problemreductions-cli/src/commands/solve.rs
@@ -75,10 +75,11 @@ fn solve_problem(
                 "solution": result.config,
                 "evaluation": result.evaluation,
             });
+            let result = out.emit_with_default_name("", &text, &json);
             if out.output.is_none() {
                 eprintln!("\nHint: use -o to save full solution details as JSON.");
             }
-            out.emit_with_default_name("", &text, &json)
+            result
         }
         "ilp" => {
             let result = problem.solve_with_ilp()?;
@@ -95,10 +96,11 @@ fn solve_problem(
             if name != "ILP" {
                 json["reduced_to"] = serde_json::json!("ILP");
             }
+            let result = out.emit_with_default_name("", &text, &json);
             if out.output.is_none() {
                 eprintln!("\nHint: use -o to save full solution details as JSON.");
             }
-            out.emit_with_default_name("", &text, &json)
+            result
         }
         _ => unreachable!(),
     }
@@ -172,8 +174,9 @@ fn solve_bundle(bundle: ReductionBundle, solver_name: &str, out: &OutputConfig)
         },
     });
 
+    let result = out.emit_with_default_name("", &text, &json);
     if out.output.is_none() {
         eprintln!("\nHint: use -o to save full solution details (including intermediate results) as JSON.");
     }
-    out.emit_with_default_name("", &text, &json)
+    result
 }

From b56362b283182f33a9fe78130533c1026ca7cebe Mon Sep 17 00:00:00 2001
From: GiggleLiu <cacate0129@gmail.com>
Date: Thu, 19 Feb 2026 14:40:35 +0800
Subject: [PATCH 21/34] fix(cli): only show solve hint when stderr is a TTY

Suppress the "Hint: use -o to save full solution details as JSON."
message when stderr is piped, so scripts and non-interactive usage
are not cluttered with the hint. Interactive terminal users still
see it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
 problemreductions-cli/src/commands/solve.rs |   6 +-
 problemreductions-cli/src/output.rs         |   5 +
 problemreductions-cli/tests/cli_tests.rs    | 116 ++++++++++++++++++++
 3 files changed, 124 insertions(+), 3 deletions(-)

diff --git a/problemreductions-cli/src/commands/solve.rs b/problemreductions-cli/src/commands/solve.rs
index 9791cc297..d2226ac2b 100644
--- a/problemreductions-cli/src/commands/solve.rs
+++ b/problemreductions-cli/src/commands/solve.rs
@@ -76,7 +76,7 @@ fn solve_problem(
                 "evaluation": result.evaluation,
             });
             let result = out.emit_with_default_name("", &text, &json);
-            if out.output.is_none() {
+            if out.output.is_none() && crate::output::stderr_is_tty() {
                 eprintln!("\nHint: use -o to save full solution details as JSON.");
             }
             result
@@ -97,7 +97,7 @@ fn solve_problem(
                 json["reduced_to"] = serde_json::json!("ILP");
             }
             let result = out.emit_with_default_name("", &text, &json);
-            if out.output.is_none() {
+            if out.output.is_none() && crate::output::stderr_is_tty() {
                 eprintln!("\nHint: use -o to save full solution details as JSON.");
             }
             result
@@ -175,7 +175,7 @@ fn solve_bundle(bundle: ReductionBundle, solver_name: &str, out: &OutputConfig)
     });
 
     let result = out.emit_with_default_name("", &text, &json);
-    if out.output.is_none() {
+    if out.output.is_none() && crate::output::stderr_is_tty() {
         eprintln!("\nHint: use -o to save full solution details (including intermediate results) as JSON.");
     }
     result
diff --git a/problemreductions-cli/src/output.rs b/problemreductions-cli/src/output.rs
index baf927eae..2e1591df2 100644
--- a/problemreductions-cli/src/output.rs
+++ b/problemreductions-cli/src/output.rs
@@ -36,6 +36,11 @@ pub fn use_color() -> bool {
     std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none()
 }
 
+/// Whether stderr is connected to a TTY (used to suppress hints in piped output).
+pub fn stderr_is_tty() -> bool {
+    std::io::stderr().is_terminal()
+}
+
 /// Format a problem name (bold when color is enabled).
 pub fn fmt_problem_name(name: &str) -> String {
     if use_color() {
diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs
index 28cf64539..4fd49d6a5 100644
--- a/problemreductions-cli/tests/cli_tests.rs
+++ b/problemreductions-cli/tests/cli_tests.rs
@@ -1234,6 +1234,122 @@ fn test_reduce_stdout() {
     std::fs::remove_file(&problem_file).ok();
 }
 
+// ---- Hint suppression tests ----
+
+#[test]
+fn test_solve_no_hint_when_piped() {
+    // When stderr is a pipe (not a TTY), the solve hint should be suppressed.
+    // In tests, subprocess stderr is captured via pipe, so it's not a TTY.
+    let problem_file = std::env::temp_dir().join("pred_test_solve_no_hint.json");
+    let create_out = pred()
+        .args([
+            "-o",
+            problem_file.to_str().unwrap(),
+            "create",
+            "MIS",
+            "--edges",
+            "0-1,1-2",
+        ])
+        .output()
+        .unwrap();
+    assert!(create_out.status.success());
+
+    // Solve without -o (brute-force)
+    let output = pred()
+        .args([
+            "solve",
+            problem_file.to_str().unwrap(),
+            "--solver",
+            "brute-force",
+        ])
+        .output()
+        .unwrap();
+    assert!(
+        output.status.success(),
+        "stderr: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+    let stderr = String::from_utf8_lossy(&output.stderr);
+    assert!(
+        !stderr.contains("Hint:"),
+        "Hint should not appear when stderr is piped, got: {stderr}"
+    );
+
+    // Solve without -o (ilp)
+    let output = pred()
+        .args(["solve", problem_file.to_str().unwrap(), "--solver", "ilp"])
+        .output()
+        .unwrap();
+    assert!(
+        output.status.success(),
+        "stderr: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+    let stderr = String::from_utf8_lossy(&output.stderr);
+    assert!(
+        !stderr.contains("Hint:"),
+        "Hint should not appear when stderr is piped, got: {stderr}"
+    );
+
+    std::fs::remove_file(&problem_file).ok();
+}
+
+#[test]
+fn test_solve_bundle_no_hint_when_piped() {
+    // Bundle solve path: hint should also be suppressed when piped.
+    let problem_file = std::env::temp_dir().join("pred_test_solve_bundle_no_hint.json");
+    let bundle_file = std::env::temp_dir().join("pred_test_solve_bundle_no_hint_bundle.json");
+
+    let create_out = pred()
+        .args([
+            "-o",
+            problem_file.to_str().unwrap(),
+            "create",
+            "MIS",
+            "--edges",
+            "0-1,1-2",
+        ])
+        .output()
+        .unwrap();
+    assert!(create_out.status.success());
+
+    let reduce_out = pred()
+        .args([
+            "-o",
+            bundle_file.to_str().unwrap(),
+            "reduce",
+            problem_file.to_str().unwrap(),
+            "--to",
+            "QUBO",
+        ])
+        .output()
+        .unwrap();
+    assert!(reduce_out.status.success());
+
+    let output = pred()
+        .args([
+            "solve",
+            bundle_file.to_str().unwrap(),
+            "--solver",
+            "brute-force",
+        ])
+        .output()
+        .unwrap();
+    assert!(
+        output.status.success(),
+        "stderr: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+    let stderr = String::from_utf8_lossy(&output.stderr);
+    assert!(
+        !stderr.contains("Hint:"),
+        "Hint should not appear when stderr is piped, got: {stderr}"
+    );
+
+    std::fs::remove_file(&problem_file).ok();
+    std::fs::remove_file(&bundle_file).ok();
+}
+
 // ---- Help message tests ----
 
 #[test]

From 2cb41f22259c6b361eba795fe5d01dde702ab8c0 Mon Sep 17 00:00:00 2001
From: GiggleLiu <cacate0129@gmail.com>
Date: Thu, 19 Feb 2026 14:43:43 +0800
Subject: [PATCH 22/34] feat(cli): show auto-reduction info in solve human
 output

When solving a non-ILP problem with the ILP solver, the human text
output now shows "Solver: ilp (via ILP)" to indicate that auto-reduction
occurred. Previously this information was only visible in JSON output.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
 problemreductions-cli/src/commands/solve.rs |  9 ++++-
 problemreductions-cli/tests/cli_tests.rs    | 45 ++++++++++++++++++++-
 2 files changed, 51 insertions(+), 3 deletions(-)

diff --git a/problemreductions-cli/src/commands/solve.rs b/problemreductions-cli/src/commands/solve.rs
index d2226ac2b..7fc302fde 100644
--- a/problemreductions-cli/src/commands/solve.rs
+++ b/problemreductions-cli/src/commands/solve.rs
@@ -83,9 +83,14 @@ fn solve_problem(
         }
         "ilp" => {
             let result = problem.solve_with_ilp()?;
+            let solver_desc = if name == "ILP" {
+                "ilp".to_string()
+            } else {
+                "ilp (via ILP)".to_string()
+            };
             let text = format!(
-                "Problem: {}\nSolver: ilp\nSolution: {:?}\nEvaluation: {}",
-                name, result.config, result.evaluation,
+                "Problem: {}\nSolver: {}\nSolution: {:?}\nEvaluation: {}",
+                name, solver_desc, result.config, result.evaluation,
             );
             let mut json = serde_json::json!({
                 "problem": name,
diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs
index 4fd49d6a5..cd7024f9f 100644
--- a/problemreductions-cli/tests/cli_tests.rs
+++ b/problemreductions-cli/tests/cli_tests.rs
@@ -570,6 +570,10 @@ fn test_solve_ilp() {
     let stdout = String::from_utf8(output.stdout).unwrap();
     assert!(stdout.contains("ilp"));
     assert!(stdout.contains("Solution"));
+    assert!(
+        stdout.contains("via ILP"),
+        "MIS solved with ILP should show auto-reduction: {stdout}"
+    );
 
     std::fs::remove_file(&problem_file).ok();
 }
@@ -601,7 +605,46 @@ fn test_solve_ilp_default() {
         String::from_utf8_lossy(&output.stderr)
     );
     let stdout = String::from_utf8(output.stdout).unwrap();
-    assert!(stdout.contains("Solver: ilp"));
+    assert!(
+        stdout.contains("Solver: ilp (via ILP)"),
+        "MIS with default solver should show auto-reduction: {stdout}"
+    );
+
+    std::fs::remove_file(&problem_file).ok();
+}
+
+#[test]
+fn test_solve_ilp_shows_via_ilp() {
+    // When solving a non-ILP problem with ILP solver, output should show "via ILP"
+    let problem_file = std::env::temp_dir().join("pred_test_solve_via_ilp.json");
+    let create_out = pred()
+        .args([
+            "-o",
+            problem_file.to_str().unwrap(),
+            "create",
+            "MIS",
+            "--edges",
+            "0-1,1-2",
+        ])
+        .output()
+        .unwrap();
+    assert!(create_out.status.success());
+
+    let output = pred()
+        .args(["solve", problem_file.to_str().unwrap(), "--solver", "ilp"])
+        .output()
+        .unwrap();
+    assert!(
+        output.status.success(),
+        "stderr: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+    let stdout = String::from_utf8(output.stdout).unwrap();
+    assert!(
+        stdout.contains("Solver: ilp (via ILP)"),
+        "Non-ILP problem solved with ILP should show auto-reduction indicator, got: {stdout}"
+    );
+    assert!(stdout.contains("Problem: MaximumIndependentSet"));
 
     std::fs::remove_file(&problem_file).ok();
 }

From 5a37b19f90bde433552c1b89c30ae4db172f9576 Mon Sep 17 00:00:00 2001
From: GiggleLiu <cacate0129@gmail.com>
Date: Thu, 19 Feb 2026 14:47:41 +0800
Subject: [PATCH 23/34] feat(cli): add global -q/--quiet flag to suppress
 stderr info

Add a -q/--quiet global flag that suppresses all informational messages
on stderr (hints, "Wrote..." messages, summary messages). Only errors
still go to stderr in quiet mode. This enables clean scripting usage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
 problemreductions-cli/src/cli.rs             |   4 +
 problemreductions-cli/src/commands/graph.rs  |  14 ++-
 problemreductions-cli/src/commands/reduce.rs |   4 +-
 problemreductions-cli/src/commands/solve.rs  |   6 +-
 problemreductions-cli/src/main.rs            |   7 +-
 problemreductions-cli/src/output.rs          |  11 +-
 problemreductions-cli/tests/cli_tests.rs     | 117 +++++++++++++++++++
 7 files changed, 150 insertions(+), 13 deletions(-)

diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs
index 0b338f8bc..6a4c83e21 100644
--- a/problemreductions-cli/src/cli.rs
+++ b/problemreductions-cli/src/cli.rs
@@ -23,6 +23,10 @@ pub struct Cli {
     #[arg(long, short, global = true)]
     pub output: Option<PathBuf>,
 
+    /// Suppress informational messages on stderr
+    #[arg(long, short, global = true)]
+    pub quiet: bool,
+
     #[command(subcommand)]
     pub command: Commands,
 }
diff --git a/problemreductions-cli/src/commands/graph.rs b/problemreductions-cli/src/commands/graph.rs
index 8aff0087d..82f3f2e32 100644
--- a/problemreductions-cli/src/commands/graph.rs
+++ b/problemreductions-cli/src/commands/graph.rs
@@ -393,7 +393,7 @@ pub fn path(source: &str, target: &str, cost: &str, all: bool, out: &OutputConfi
                     serde_json::to_string_pretty(&json).context("Failed to serialize JSON")?;
                 std::fs::write(path, &content)
                     .with_context(|| format!("Failed to write {}", path.display()))?;
-                eprintln!("Wrote {}", path.display());
+                out.info(&format!("Wrote {}", path.display()));
             } else {
                 println!("{text}");
             }
@@ -464,7 +464,11 @@ fn path_all(
             std::fs::write(&file, &content)
                 .with_context(|| format!("Failed to write {}", file.display()))?;
         }
-        eprintln!("Wrote {} path files to {}", all_paths.len(), dir.display());
+        out.info(&format!(
+            "Wrote {} path files to {}",
+            all_paths.len(),
+            dir.display()
+        ));
     } else {
         println!("{text}");
     }
@@ -472,7 +476,7 @@ fn path_all(
     Ok(())
 }
 
-pub fn export(output: &Path) -> Result<()> {
+pub fn export(output: &Path, out: &OutputConfig) -> Result<()> {
     let graph = ReductionGraph::new();
 
     if let Some(parent) = output.parent() {
@@ -483,13 +487,13 @@ pub fn export(output: &Path) -> Result<()> {
         .to_json_file(output)
         .map_err(|e| anyhow::anyhow!("Failed to export: {}", e))?;
 
-    eprintln!(
+    out.info(&format!(
         "Exported reduction graph ({} types, {} reductions, {} variant nodes) to {}",
         graph.num_types(),
         graph.num_reductions(),
         graph.num_variant_nodes(),
         output.display()
-    );
+    ));
 
     Ok(())
 }
diff --git a/problemreductions-cli/src/commands/reduce.rs b/problemreductions-cli/src/commands/reduce.rs
index 8e40094f5..26909051d 100644
--- a/problemreductions-cli/src/commands/reduce.rs
+++ b/problemreductions-cli/src/commands/reduce.rs
@@ -189,13 +189,13 @@ pub fn reduce(
         let content = serde_json::to_string_pretty(&json).context("Failed to serialize JSON")?;
         std::fs::write(path, &content)
             .with_context(|| format!("Failed to write {}", path.display()))?;
-        eprintln!(
+        out.info(&format!(
             "Reduced {} to {} ({} steps)\nWrote {}",
             source_name,
             target_step.name,
             reduction_path.len(),
             path.display(),
-        );
+        ));
     } else {
         println!("{}", serde_json::to_string_pretty(&json)?);
     }
diff --git a/problemreductions-cli/src/commands/solve.rs b/problemreductions-cli/src/commands/solve.rs
index 7fc302fde..fb68e378c 100644
--- a/problemreductions-cli/src/commands/solve.rs
+++ b/problemreductions-cli/src/commands/solve.rs
@@ -77,7 +77,7 @@ fn solve_problem(
             });
             let result = out.emit_with_default_name("", &text, &json);
             if out.output.is_none() && crate::output::stderr_is_tty() {
-                eprintln!("\nHint: use -o to save full solution details as JSON.");
+                out.info("\nHint: use -o to save full solution details as JSON.");
             }
             result
         }
@@ -103,7 +103,7 @@ fn solve_problem(
             }
             let result = out.emit_with_default_name("", &text, &json);
             if out.output.is_none() && crate::output::stderr_is_tty() {
-                eprintln!("\nHint: use -o to save full solution details as JSON.");
+                out.info("\nHint: use -o to save full solution details as JSON.");
             }
             result
         }
@@ -181,7 +181,7 @@ fn solve_bundle(bundle: ReductionBundle, solver_name: &str, out: &OutputConfig)
 
     let result = out.emit_with_default_name("", &text, &json);
     if out.output.is_none() && crate::output::stderr_is_tty() {
-        eprintln!("\nHint: use -o to save full solution details (including intermediate results) as JSON.");
+        out.info("\nHint: use -o to save full solution details (including intermediate results) as JSON.");
     }
     result
 }
diff --git a/problemreductions-cli/src/main.rs b/problemreductions-cli/src/main.rs
index 97289cc85..f188e8e51 100644
--- a/problemreductions-cli/src/main.rs
+++ b/problemreductions-cli/src/main.rs
@@ -26,7 +26,10 @@ fn main() -> anyhow::Result<()> {
         }
     };
 
-    let out = OutputConfig { output: cli.output };
+    let out = OutputConfig {
+        output: cli.output,
+        quiet: cli.quiet,
+    };
 
     match cli.command {
         Commands::List => commands::graph::list(&out),
@@ -41,7 +44,7 @@ fn main() -> anyhow::Result<()> {
             cost,
             all,
         } => commands::graph::path(&source, &target, &cost, all, &out),
-        Commands::ExportGraph { output } => commands::graph::export(&output),
+        Commands::ExportGraph { output } => commands::graph::export(&output, &out),
         Commands::Create(args) => commands::create::create(&args, &out),
         Commands::Solve(args) => commands::solve::solve(&args.input, &args.solver, &out),
         Commands::Reduce(args) => {
diff --git a/problemreductions-cli/src/output.rs b/problemreductions-cli/src/output.rs
index 2e1591df2..f14e20a56 100644
--- a/problemreductions-cli/src/output.rs
+++ b/problemreductions-cli/src/output.rs
@@ -8,9 +8,18 @@ use std::path::PathBuf;
 pub struct OutputConfig {
     /// Output file path. When set, output is saved as JSON.
     pub output: Option<PathBuf>,
+    /// Suppress informational messages on stderr.
+    pub quiet: bool,
 }
 
 impl OutputConfig {
+    /// Print an informational message to stderr, unless quiet mode is on.
+    pub fn info(&self, msg: &str) {
+        if !self.quiet {
+            eprintln!("{msg}");
+        }
+    }
+
     /// Emit output: if `-o` is set, save as JSON; otherwise print human text.
     pub fn emit_with_default_name(
         &self,
@@ -23,7 +32,7 @@ impl OutputConfig {
                 serde_json::to_string_pretty(json_value).context("Failed to serialize JSON")?;
             std::fs::write(path, &content)
                 .with_context(|| format!("Failed to write {}", path.display()))?;
-            eprintln!("Wrote {}", path.display());
+            self.info(&format!("Wrote {}", path.display()));
         } else {
             println!("{human_text}");
         }
diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs
index cd7024f9f..9ecf13d3d 100644
--- a/problemreductions-cli/tests/cli_tests.rs
+++ b/problemreductions-cli/tests/cli_tests.rs
@@ -1522,3 +1522,120 @@ fn test_show_hops_bad_direction() {
     let stderr = String::from_utf8_lossy(&output.stderr);
     assert!(stderr.contains("Unknown direction"));
 }
+
+// ---- Quiet mode tests ----
+
+#[test]
+fn test_quiet_suppresses_hints() {
+    // Solve with -q: even if stderr were a TTY, quiet suppresses hints.
+    // In tests stderr is a pipe so hints are already suppressed by TTY check,
+    // but we verify -q is accepted and doesn't break anything.
+    let problem_file = std::env::temp_dir().join("pred_test_quiet_hint.json");
+    let create_out = pred()
+        .args([
+            "-o",
+            problem_file.to_str().unwrap(),
+            "create",
+            "MIS",
+            "--edges",
+            "0-1,1-2",
+        ])
+        .output()
+        .unwrap();
+    assert!(create_out.status.success());
+
+    let output = pred()
+        .args([
+            "-q",
+            "solve",
+            problem_file.to_str().unwrap(),
+            "--solver",
+            "brute-force",
+        ])
+        .output()
+        .unwrap();
+    assert!(
+        output.status.success(),
+        "stderr: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+    let stderr = String::from_utf8_lossy(&output.stderr);
+    assert!(
+        !stderr.contains("Hint:"),
+        "Hint should be suppressed with -q, got: {stderr}"
+    );
+
+    std::fs::remove_file(&problem_file).ok();
+}
+
+#[test]
+fn test_quiet_suppresses_wrote() {
+    // Create with -q -o: the "Wrote ..." message should be suppressed.
+    let output_file = std::env::temp_dir().join("pred_test_quiet_wrote.json");
+    let output = pred()
+        .args([
+            "-q",
+            "-o",
+            output_file.to_str().unwrap(),
+            "create",
+            "MIS",
+            "--edges",
+            "0-1,1-2",
+        ])
+        .output()
+        .unwrap();
+    assert!(
+        output.status.success(),
+        "stderr: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+    let stderr = String::from_utf8_lossy(&output.stderr);
+    assert!(
+        !stderr.contains("Wrote"),
+        "\"Wrote\" message should be suppressed with -q, got: {stderr}"
+    );
+    assert!(output_file.exists());
+
+    std::fs::remove_file(&output_file).ok();
+}
+
+#[test]
+fn test_quiet_still_shows_stdout() {
+    // Solve with -q: stdout should still contain the solution output.
+    let problem_file = std::env::temp_dir().join("pred_test_quiet_stdout.json");
+    let create_out = pred()
+        .args([
+            "-o",
+            problem_file.to_str().unwrap(),
+            "create",
+            "MIS",
+            "--edges",
+            "0-1,1-2",
+        ])
+        .output()
+        .unwrap();
+    assert!(create_out.status.success());
+
+    let output = pred()
+        .args([
+            "-q",
+            "solve",
+            problem_file.to_str().unwrap(),
+            "--solver",
+            "brute-force",
+        ])
+        .output()
+        .unwrap();
+    assert!(
+        output.status.success(),
+        "stderr: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+    let stdout = String::from_utf8(output.stdout).unwrap();
+    assert!(
+        stdout.contains("Solution"),
+        "stdout should still contain solution with -q, got: {stdout}"
+    );
+
+    std::fs::remove_file(&problem_file).ok();
+}

From e37aa4714b58024f3ff46c472b5c42be5fe58383 Mon Sep 17 00:00:00 2001
From: GiggleLiu <cacate0129@gmail.com>
Date: Thu, 19 Feb 2026 14:53:10 +0800
Subject: [PATCH 24/34] feat(cli): print JSON to stdout when create is used
 without -o

Previously `pred create MIS --edges 0-1,1-2` printed "Created
MaximumIndependentSet instance" but discarded the actual data. Now it
prints the problem JSON to stdout, consistent with how `reduce` works
and enabling piping to other commands.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
 problemreductions-cli/src/commands/create.rs | 17 ++++++++++----
 problemreductions-cli/tests/cli_tests.rs     | 24 +++++++++++++++++---
 2 files changed, 34 insertions(+), 7 deletions(-)

diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs
index c566fe366..57bb06c82 100644
--- a/problemreductions-cli/src/commands/create.rs
+++ b/problemreductions-cli/src/commands/create.rs
@@ -2,7 +2,7 @@ use crate::cli::CreateArgs;
 use crate::dispatch::ProblemJsonOutput;
 use crate::output::OutputConfig;
 use crate::problem_name::resolve_alias;
-use anyhow::{bail, Result};
+use anyhow::{bail, Context, Result};
 use problemreductions::prelude::*;
 use problemreductions::topology::{Graph, SimpleGraph};
 use problemreductions::variant::{K2, K3, KN};
@@ -166,9 +166,18 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> {
     };
 
     let json = serde_json::to_value(&output)?;
-    let text = format!("Created {} instance", canonical);
-    let default_name = format!("pred_{}.json", canonical.to_lowercase());
-    out.emit_with_default_name(&default_name, &text, &json)
+
+    if let Some(ref path) = out.output {
+        let content =
+            serde_json::to_string_pretty(&json).context("Failed to serialize JSON")?;
+        std::fs::write(path, &content)
+            .with_context(|| format!("Failed to write {}", path.display()))?;
+        out.info(&format!("Wrote {}", path.display()));
+    } else {
+        // Print JSON to stdout so data is not lost (consistent with reduce)
+        println!("{}", serde_json::to_string_pretty(&json)?);
+    }
+    Ok(())
 }
 
 fn ser<T: Serialize>(problem: T) -> Result<serde_json::Value> {
diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs
index 9ecf13d3d..26749c28c 100644
--- a/problemreductions-cli/tests/cli_tests.rs
+++ b/problemreductions-cli/tests/cli_tests.rs
@@ -104,7 +104,7 @@ fn test_path_all_save() {
 fn test_export() {
     let tmp = std::env::temp_dir().join("pred_test_export.json");
     let output = pred()
-        .args(["export-graph", tmp.to_str().unwrap()])
+        .args(["export-graph", "-o", tmp.to_str().unwrap()])
         .output()
         .unwrap();
     assert!(output.status.success());
@@ -115,6 +115,22 @@ fn test_export() {
     std::fs::remove_file(&tmp).ok();
 }
 
+#[test]
+fn test_export_stdout() {
+    let output = pred().args(["export-graph"]).output().unwrap();
+    assert!(
+        output.status.success(),
+        "stderr: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+    let stdout = String::from_utf8(output.stdout).unwrap();
+    // Without -o, export-graph prints human-readable summary to stdout
+    assert!(
+        stdout.contains("Reduction graph:"),
+        "stdout should contain summary, got: {stdout}"
+    );
+}
+
 #[test]
 fn test_show_includes_fields() {
     let output = pred().args(["show", "MIS"]).output().unwrap();
@@ -1022,7 +1038,7 @@ fn test_create_with_edge_weights() {
 
 #[test]
 fn test_create_without_output() {
-    // Create without -o prints to stdout
+    // Create without -o prints JSON to stdout (not just "Created ...")
     let output = pred()
         .args(["create", "MIS", "--edges", "0-1,1-2"])
         .output()
@@ -1033,7 +1049,9 @@ fn test_create_without_output() {
         String::from_utf8_lossy(&output.stderr)
     );
     let stdout = String::from_utf8(output.stdout).unwrap();
-    assert!(stdout.contains("Created"));
+    let json: serde_json::Value = serde_json::from_str(&stdout).unwrap();
+    assert_eq!(json["type"], "MaximumIndependentSet");
+    assert!(json["data"].is_object());
 }
 
 // ---- Error cases ----

From 67f7288138b6dabea2739e62f96eeedf67de682f Mon Sep 17 00:00:00 2001
From: GiggleLiu <cacate0129@gmail.com>
Date: Thu, 19 Feb 2026 15:10:26 +0800
Subject: [PATCH 25/34] feat(cli): add fuzzy matching for unknown problem names

Add "Did you mean ...?" suggestions when a problem name is not
recognized, using Levenshtein edit distance. All "Unknown problem"
error sites now also suggest running `pred list`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
 problemreductions-cli/src/commands/create.rs |   4 +-
 problemreductions-cli/src/commands/graph.rs  |  39 +-
 problemreductions-cli/src/commands/reduce.rs |  25 +-
 problemreductions-cli/src/dispatch.rs        |  34 +-
 problemreductions-cli/src/problem_name.rs    | 108 +++++
 problemreductions-cli/tests/cli_tests.rs     | 423 ++++++++++++++++++-
 6 files changed, 597 insertions(+), 36 deletions(-)

diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs
index 57bb06c82..bbbb121d7 100644
--- a/problemreductions-cli/src/commands/create.rs
+++ b/problemreductions-cli/src/commands/create.rs
@@ -154,8 +154,8 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> {
         }
 
         _ => bail!(
-            "Unknown or unsupported problem type for create: {}",
-            canonical
+            "{}",
+            crate::problem_name::unknown_problem_error(&canonical)
         ),
     };
 
diff --git a/problemreductions-cli/src/commands/graph.rs b/problemreductions-cli/src/commands/graph.rs
index 82f3f2e32..f9b91e6f2 100644
--- a/problemreductions-cli/src/commands/graph.rs
+++ b/problemreductions-cli/src/commands/graph.rs
@@ -5,7 +5,6 @@ use problemreductions::registry::collect_schemas;
 use problemreductions::rules::{Minimize, MinimizeSteps, ReductionGraph, TraversalDirection};
 use problemreductions::types::ProblemSize;
 use std::collections::{BTreeMap, HashMap, HashSet};
-use std::path::Path;
 
 pub fn list(out: &OutputConfig) -> Result<()> {
     use crate::output::{format_table, Align};
@@ -103,7 +102,7 @@ pub fn show(problem: &str, hops: Option<usize>, direction: &str, out: &OutputCon
 
     let variants = graph.variants_for(&spec.name);
     if variants.is_empty() {
-        anyhow::bail!("Unknown problem: {}", spec.name);
+        anyhow::bail!("{}", crate::problem_name::unknown_problem_error(&spec.name));
     }
 
     if let Some(max_hops) = hops {
@@ -290,20 +289,14 @@ pub fn path(source: &str, target: &str, cost: &str, all: bool, out: &OutputConfi
 
     if src_variants.is_empty() {
         anyhow::bail!(
-            "Unknown source problem: {}\n\n\
-             Usage: pred path <SOURCE> <TARGET>\n\
-             Example: pred path MIS QUBO\n\n\
-             Run `pred list` to see all available problems.",
-            src_spec.name
+            "{}\n\nUsage: pred path <SOURCE> <TARGET>\nExample: pred path MIS QUBO",
+            crate::problem_name::unknown_problem_error(&src_spec.name)
         );
     }
     if dst_variants.is_empty() {
         anyhow::bail!(
-            "Unknown target problem: {}\n\n\
-             Usage: pred path <SOURCE> <TARGET>\n\
-             Example: pred path MIS QUBO\n\n\
-             Run `pred list` to see all available problems.",
-            dst_spec.name
+            "{}\n\nUsage: pred path <SOURCE> <TARGET>\nExample: pred path MIS QUBO",
+            crate::problem_name::unknown_problem_error(&dst_spec.name)
         );
     }
 
@@ -476,26 +469,24 @@ fn path_all(
     Ok(())
 }
 
-pub fn export(output: &Path, out: &OutputConfig) -> Result<()> {
+pub fn export(out: &OutputConfig) -> Result<()> {
     let graph = ReductionGraph::new();
 
-    if let Some(parent) = output.parent() {
-        std::fs::create_dir_all(parent)?;
-    }
-
-    graph
-        .to_json_file(output)
+    let json_str = graph
+        .to_json_string()
         .map_err(|e| anyhow::anyhow!("Failed to export: {}", e))?;
+    let json: serde_json::Value =
+        serde_json::from_str(&json_str).map_err(|e| anyhow::anyhow!("Failed to parse: {}", e))?;
 
-    out.info(&format!(
-        "Exported reduction graph ({} types, {} reductions, {} variant nodes) to {}",
+    let text = format!(
+        "Reduction graph: {} types, {} reductions, {} variant nodes\n\
+         Use -o to save as JSON.",
         graph.num_types(),
         graph.num_reductions(),
         graph.num_variant_nodes(),
-        output.display()
-    ));
+    );
 
-    Ok(())
+    out.emit_with_default_name("reduction_graph.json", &text, &json)
 }
 
 fn parse_direction(s: &str) -> Result<TraversalDirection> {
diff --git a/problemreductions-cli/src/commands/reduce.rs b/problemreductions-cli/src/commands/reduce.rs
index 26909051d..052951736 100644
--- a/problemreductions-cli/src/commands/reduce.rs
+++ b/problemreductions-cli/src/commands/reduce.rs
@@ -1,5 +1,6 @@
 use crate::dispatch::{
-    load_problem, serialize_any_problem, PathStep, ProblemJson, ProblemJsonOutput, ReductionBundle,
+    load_problem, read_input, serialize_any_problem, PathStep, ProblemJson, ProblemJsonOutput,
+    ReductionBundle,
 };
 use crate::output::OutputConfig;
 use crate::problem_name::parse_problem_spec;
@@ -52,10 +53,11 @@ pub fn reduce(
     input: &Path,
     target: Option<&str>,
     via: Option<&Path>,
+    json_output: bool,
     out: &OutputConfig,
 ) -> Result<()> {
     // 1. Load source problem
-    let content = std::fs::read_to_string(input)?;
+    let content = read_input(input)?;
     let problem_json: ProblemJson = serde_json::from_str(&content)?;
 
     let source = load_problem(
@@ -108,7 +110,7 @@ pub fn reduce(
         let dst_spec = parse_problem_spec(target)?;
         let dst_variants = graph.variants_for(&dst_spec.name);
         if dst_variants.is_empty() {
-            anyhow::bail!("Unknown target problem: {}", dst_spec.name);
+            anyhow::bail!("{}", crate::problem_name::unknown_problem_error(&dst_spec.name));
         }
 
         // Auto-discover cheapest path
@@ -186,6 +188,7 @@ pub fn reduce(
     let json = serde_json::to_value(&bundle)?;
 
     if let Some(ref path) = out.output {
+        // -o given: write JSON to file
         let content = serde_json::to_string_pretty(&json).context("Failed to serialize JSON")?;
         std::fs::write(path, &content)
             .with_context(|| format!("Failed to write {}", path.display()))?;
@@ -196,8 +199,22 @@ pub fn reduce(
             reduction_path.len(),
             path.display(),
         ));
-    } else {
+    } else if json_output {
+        // --json given: print raw JSON to stdout
         println!("{}", serde_json::to_string_pretty(&json)?);
+    } else {
+        // Default: human-readable summary
+        let mut text = format!(
+            "Reduced {} to {} ({} steps)\n",
+            source_name,
+            target_step.name,
+            reduction_path.len(),
+        );
+        text.push_str(&format!("\nPath: {}\n", reduction_path));
+        text.push_str(
+            "\nUse -o to save the reduction bundle as JSON, or --json to print JSON to stdout.",
+        );
+        println!("{text}");
     }
 
     Ok(())
diff --git a/problemreductions-cli/src/dispatch.rs b/problemreductions-cli/src/dispatch.rs
index 73d7728cd..3b61a6bcc 100644
--- a/problemreductions-cli/src/dispatch.rs
+++ b/problemreductions-cli/src/dispatch.rs
@@ -1,4 +1,4 @@
-use anyhow::{bail, Result};
+use anyhow::{bail, Context, Result};
 use problemreductions::models::optimization::ILP;
 use problemreductions::prelude::*;
 use problemreductions::rules::{MinimizeSteps, ReductionGraph};
@@ -12,9 +12,25 @@ use std::any::Any;
 use std::collections::BTreeMap;
 use std::fmt;
 use std::ops::Deref;
+use std::path::Path;
 
 use crate::problem_name::resolve_alias;
 
+/// Read input from a file, or from stdin if the path is "-".
+pub fn read_input(path: &Path) -> Result<String> {
+    if path.as_os_str() == "-" {
+        use std::io::Read;
+        let mut buf = String::new();
+        std::io::stdin()
+            .read_to_string(&mut buf)
+            .context("Failed to read from stdin")?;
+        Ok(buf)
+    } else {
+        std::fs::read_to_string(path)
+            .with_context(|| format!("Failed to read {}", path.display()))
+    }
+}
+
 /// Type-erased problem for CLI dispatch.
 #[allow(dead_code)]
 pub trait DynProblem: Any {
@@ -24,6 +40,9 @@ pub trait DynProblem: Any {
     fn dims_dyn(&self) -> Vec<usize>;
     fn problem_name(&self) -> &'static str;
     fn variant_map(&self) -> BTreeMap<String, String>;
+    fn problem_size_names_dyn(&self) -> &'static [&'static str];
+    fn problem_size_values_dyn(&self) -> Vec<usize>;
+    fn num_variables_dyn(&self) -> usize;
 }
 
 impl<T> DynProblem for T
@@ -52,6 +71,15 @@ where
             .map(|(k, v)| (k.to_string(), v.to_string()))
             .collect()
     }
+    fn problem_size_names_dyn(&self) -> &'static [&'static str] {
+        T::problem_size_names()
+    }
+    fn problem_size_values_dyn(&self) -> Vec<usize> {
+        self.problem_size_values()
+    }
+    fn num_variables_dyn(&self) -> usize {
+        self.num_variables()
+    }
 }
 
 fn deser_opt<T>(data: Value) -> Result<LoadedProblem>
@@ -215,7 +243,7 @@ pub fn load_problem(
         "BicliqueCover" => deser_opt::<BicliqueCover>(data),
         "BMF" => deser_opt::<BMF>(data),
         "PaintShop" => deser_opt::<PaintShop>(data),
-        _ => bail!("Unknown problem type: {canonical}"),
+        _ => bail!("{}", crate::problem_name::unknown_problem_error(&canonical)),
     }
 }
 
@@ -265,7 +293,7 @@ pub fn serialize_any_problem(
         "BicliqueCover" => try_ser::<BicliqueCover>(any),
         "BMF" => try_ser::<BMF>(any),
         "PaintShop" => try_ser::<PaintShop>(any),
-        _ => bail!("Unknown problem type: {canonical}"),
+        _ => bail!("{}", crate::problem_name::unknown_problem_error(&canonical)),
     }
 }
 
diff --git a/problemreductions-cli/src/problem_name.rs b/problemreductions-cli/src/problem_name.rs
index 490324a1d..7b1bc2351 100644
--- a/problemreductions-cli/src/problem_name.rs
+++ b/problemreductions-cli/src/problem_name.rs
@@ -157,6 +157,75 @@ impl clap::builder::TypedValueParser for ProblemNameParser {
     }
 }
 
+/// Find the closest matching problem names using edit distance.
+pub fn suggest_problem_name(input: &str) -> Vec<String> {
+    let graph = problemreductions::rules::ReductionGraph::new();
+    let all_names = graph.problem_types();
+
+    let input_lower = input.to_lowercase();
+    let mut suggestions: Vec<(String, usize)> = Vec::new();
+
+    for name in all_names {
+        let dist = edit_distance(&input_lower, &name.to_lowercase());
+        if dist <= 3 {
+            suggestions.push((name.to_string(), dist));
+        }
+    }
+
+    // Also check aliases
+    for (alias, canonical) in ALIASES {
+        let dist = edit_distance(&input_lower, &alias.to_lowercase());
+        if dist <= 2 {
+            suggestions.push((canonical.to_string(), dist));
+        }
+    }
+
+    suggestions.sort_by_key(|(_, d)| *d);
+    suggestions.dedup_by_key(|(n, _)| n.clone());
+    suggestions.into_iter().map(|(n, _)| n).take(3).collect()
+}
+
+/// Simple Levenshtein edit distance.
+fn edit_distance(a: &str, b: &str) -> usize {
+    let a: Vec<char> = a.chars().collect();
+    let b: Vec<char> = b.chars().collect();
+    let n = a.len();
+    let m = b.len();
+    let mut dp = vec![vec![0usize; m + 1]; n + 1];
+
+    for (i, row) in dp.iter_mut().enumerate().take(n + 1) {
+        row[0] = i;
+    }
+    for j in 0..=m {
+        dp[0][j] = j;
+    }
+
+    for i in 1..=n {
+        for j in 1..=m {
+            let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
+            dp[i][j] = (dp[i - 1][j] + 1)
+                .min(dp[i][j - 1] + 1)
+                .min(dp[i - 1][j - 1] + cost);
+        }
+    }
+
+    dp[n][m]
+}
+
+/// Format an error message for an unknown problem name with suggestions.
+pub fn unknown_problem_error(input: &str) -> String {
+    let suggestions = suggest_problem_name(input);
+    let mut msg = format!("Unknown problem: {input}");
+    if !suggestions.is_empty() {
+        msg.push_str(&format!(
+            "\n\nDid you mean: {}?",
+            suggestions.join(", ")
+        ));
+    }
+    msg.push_str("\n\nRun `pred list` to see all available problems.");
+    msg
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -204,4 +273,43 @@ mod tests {
         assert_eq!(spec.name, "KSatisfiability");
         assert_eq!(spec.variant_values, vec!["K3"]);
     }
+
+    #[test]
+    fn test_suggest_problem_name_close() {
+        // "MISs" is 1 edit from "MIS" alias -> should suggest MaximumIndependentSet
+        let suggestions = suggest_problem_name("MISs");
+        assert!(!suggestions.is_empty());
+    }
+
+    #[test]
+    fn test_suggest_problem_name_far() {
+        // Totally unrelated name should not match anything
+        let suggestions = suggest_problem_name("xyzxyzxyz");
+        assert!(suggestions.is_empty());
+    }
+
+    #[test]
+    fn test_unknown_problem_error_with_suggestions() {
+        let msg = unknown_problem_error("MISs");
+        assert!(msg.contains("Unknown problem: MISs"));
+        assert!(msg.contains("Did you mean"));
+        assert!(msg.contains("pred list"));
+    }
+
+    #[test]
+    fn test_unknown_problem_error_no_suggestions() {
+        let msg = unknown_problem_error("xyzxyzxyz");
+        assert!(msg.contains("Unknown problem: xyzxyzxyz"));
+        assert!(!msg.contains("Did you mean"));
+        assert!(msg.contains("pred list"));
+    }
+
+    #[test]
+    fn test_edit_distance() {
+        assert_eq!(edit_distance("", ""), 0);
+        assert_eq!(edit_distance("abc", "abc"), 0);
+        assert_eq!(edit_distance("abc", "ab"), 1);
+        assert_eq!(edit_distance("abc", "axc"), 1);
+        assert_eq!(edit_distance("kitten", "sitting"), 3);
+    }
 }
diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs
index 26749c28c..aef9ba223 100644
--- a/problemreductions-cli/tests/cli_tests.rs
+++ b/problemreductions-cli/tests/cli_tests.rs
@@ -160,6 +160,39 @@ fn test_list_json() {
 fn test_unknown_problem() {
     let output = pred().args(["show", "NonExistent"]).output().unwrap();
     assert!(!output.status.success());
+    let stderr = String::from_utf8_lossy(&output.stderr);
+    assert!(
+        stderr.contains("pred list"),
+        "Unknown problem error should suggest `pred list`, got: {stderr}"
+    );
+}
+
+#[test]
+fn test_unknown_problem_suggests() {
+    // "MISs" is close to "MIS" alias -> should suggest MaximumIndependentSet
+    let output = pred().args(["show", "MISs"]).output().unwrap();
+    assert!(!output.status.success());
+    let stderr = String::from_utf8_lossy(&output.stderr);
+    assert!(
+        stderr.contains("Did you mean"),
+        "Close misspelling should trigger 'Did you mean', got: {stderr}"
+    );
+    assert!(
+        stderr.contains("pred list"),
+        "Should always suggest `pred list`, got: {stderr}"
+    );
+}
+
+#[test]
+fn test_unknown_problem_no_match() {
+    // Totally unrelated name should still suggest pred list
+    let output = pred().args(["show", "xyzxyzxyz"]).output().unwrap();
+    assert!(!output.status.success());
+    let stderr = String::from_utf8_lossy(&output.stderr);
+    assert!(
+        stderr.contains("pred list"),
+        "Should suggest `pred list` even with no fuzzy matches, got: {stderr}"
+    );
 }
 
 #[test]
@@ -1166,7 +1199,14 @@ fn test_path_unknown_source() {
         .unwrap();
     assert!(!output.status.success());
     let stderr = String::from_utf8_lossy(&output.stderr);
-    assert!(stderr.contains("Unknown source"));
+    assert!(
+        stderr.contains("Unknown problem"),
+        "stderr should contain 'Unknown problem', got: {stderr}"
+    );
+    assert!(
+        stderr.contains("pred list"),
+        "stderr should suggest `pred list`, got: {stderr}"
+    );
 }
 
 #[test]
@@ -1177,7 +1217,14 @@ fn test_path_unknown_target() {
         .unwrap();
     assert!(!output.status.success());
     let stderr = String::from_utf8_lossy(&output.stderr);
-    assert!(stderr.contains("Unknown target"));
+    assert!(
+        stderr.contains("Unknown problem"),
+        "stderr should contain 'Unknown problem', got: {stderr}"
+    );
+    assert!(
+        stderr.contains("pred list"),
+        "stderr should suggest `pred list`, got: {stderr}"
+    );
 }
 
 #[test]
@@ -1279,7 +1326,7 @@ fn test_reduce_stdout() {
     assert!(create_out.status.success());
 
     let output = pred()
-        .args(["reduce", problem_file.to_str().unwrap(), "--to", "QUBO"])
+        .args(["reduce", problem_file.to_str().unwrap(), "--to", "QUBO", "--json"])
         .output()
         .unwrap();
     assert!(
@@ -1295,6 +1342,54 @@ fn test_reduce_stdout() {
     std::fs::remove_file(&problem_file).ok();
 }
 
+#[test]
+fn test_reduce_human_output() {
+    // Without --json or -o, reduce shows human-readable summary
+    let problem_file = std::env::temp_dir().join("pred_test_reduce_human.json");
+    let create_out = pred()
+        .args([
+            "-o",
+            problem_file.to_str().unwrap(),
+            "create",
+            "MIS",
+            "--edges",
+            "0-1,1-2",
+        ])
+        .output()
+        .unwrap();
+    assert!(create_out.status.success());
+
+    let output = pred()
+        .args(["reduce", problem_file.to_str().unwrap(), "--to", "QUBO"])
+        .output()
+        .unwrap();
+    assert!(
+        output.status.success(),
+        "stderr: {}",
+        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}"
+    );
+    assert!(
+        stdout.contains("QUBO"),
+        "expected 'QUBO' in stdout, got: {stdout}"
+    );
+    // Should NOT be parseable as JSON
+    assert!(
+        serde_json::from_str::<serde_json::Value>(&stdout).is_err(),
+        "stdout should not be valid JSON in human-readable mode, got: {stdout}"
+    );
+
+    std::fs::remove_file(&problem_file).ok();
+}
+
 // ---- Hint suppression tests ----
 
 #[test]
@@ -1657,3 +1752,325 @@ fn test_quiet_still_shows_stdout() {
 
     std::fs::remove_file(&problem_file).ok();
 }
+
+// ---- Stdin/pipe support tests ----
+
+#[test]
+fn test_create_pipe_to_solve() {
+    // pred create MIS --edges 0-1,1-2 | pred solve - --solver brute-force
+    let create_out = pred()
+        .args(["create", "MIS", "--edges", "0-1,1-2"])
+        .output()
+        .unwrap();
+    assert!(
+        create_out.status.success(),
+        "create stderr: {}",
+        String::from_utf8_lossy(&create_out.stderr)
+    );
+
+    use std::io::Write;
+    let mut child = pred()
+        .args(["solve", "-", "--solver", "brute-force"])
+        .stdin(std::process::Stdio::piped())
+        .stdout(std::process::Stdio::piped())
+        .stderr(std::process::Stdio::piped())
+        .spawn()
+        .unwrap();
+    child
+        .stdin
+        .take()
+        .unwrap()
+        .write_all(&create_out.stdout)
+        .unwrap();
+    let solve_result = child.wait_with_output().unwrap();
+    assert!(
+        solve_result.status.success(),
+        "stderr: {}",
+        String::from_utf8_lossy(&solve_result.stderr)
+    );
+    let stdout = String::from_utf8(solve_result.stdout).unwrap();
+    assert!(
+        stdout.contains("Solution"),
+        "stdout should contain Solution, got: {stdout}"
+    );
+}
+
+#[test]
+fn test_create_pipe_to_evaluate() {
+    // pred create MIS --edges 0-1,1-2 | pred evaluate - --config 1,0,1
+    let create_out = pred()
+        .args(["create", "MIS", "--edges", "0-1,1-2"])
+        .output()
+        .unwrap();
+    assert!(
+        create_out.status.success(),
+        "create stderr: {}",
+        String::from_utf8_lossy(&create_out.stderr)
+    );
+
+    use std::io::Write;
+    let mut child = pred()
+        .args(["evaluate", "-", "--config", "1,0,1"])
+        .stdin(std::process::Stdio::piped())
+        .stdout(std::process::Stdio::piped())
+        .stderr(std::process::Stdio::piped())
+        .spawn()
+        .unwrap();
+    child
+        .stdin
+        .take()
+        .unwrap()
+        .write_all(&create_out.stdout)
+        .unwrap();
+    let eval_result = child.wait_with_output().unwrap();
+    assert!(
+        eval_result.status.success(),
+        "stderr: {}",
+        String::from_utf8_lossy(&eval_result.stderr)
+    );
+    let stdout = String::from_utf8(eval_result.stdout).unwrap();
+    assert!(
+        stdout.contains("Valid"),
+        "stdout should contain Valid, got: {stdout}"
+    );
+}
+
+#[test]
+fn test_create_pipe_to_reduce() {
+    // pred create MIS --edges 0-1,1-2 | pred reduce - --to QUBO
+    let create_out = pred()
+        .args(["create", "MIS", "--edges", "0-1,1-2"])
+        .output()
+        .unwrap();
+    assert!(
+        create_out.status.success(),
+        "create stderr: {}",
+        String::from_utf8_lossy(&create_out.stderr)
+    );
+
+    use std::io::Write;
+    let mut child = pred()
+        .args(["reduce", "-", "--to", "QUBO", "--json"])
+        .stdin(std::process::Stdio::piped())
+        .stdout(std::process::Stdio::piped())
+        .stderr(std::process::Stdio::piped())
+        .spawn()
+        .unwrap();
+    child
+        .stdin
+        .take()
+        .unwrap()
+        .write_all(&create_out.stdout)
+        .unwrap();
+    let reduce_result = child.wait_with_output().unwrap();
+    assert!(
+        reduce_result.status.success(),
+        "stderr: {}",
+        String::from_utf8_lossy(&reduce_result.stderr)
+    );
+    let stdout = String::from_utf8(reduce_result.stdout).unwrap();
+    let json: serde_json::Value = serde_json::from_str(&stdout).unwrap();
+    assert!(
+        json["source"].is_object(),
+        "expected source object in reduction bundle, got: {stdout}"
+    );
+}
+
+// ---- Inspect command tests ----
+
+#[test]
+fn test_inspect_problem() {
+    let problem_file = std::env::temp_dir().join("pred_test_inspect.json");
+    let create_out = pred()
+        .args([
+            "-o",
+            problem_file.to_str().unwrap(),
+            "create",
+            "MIS",
+            "--edges",
+            "0-1,1-2,2-3",
+        ])
+        .output()
+        .unwrap();
+    assert!(create_out.status.success());
+
+    let output = pred()
+        .args(["inspect", problem_file.to_str().unwrap()])
+        .output()
+        .unwrap();
+    assert!(
+        output.status.success(),
+        "stderr: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+    let stdout = String::from_utf8(output.stdout).unwrap();
+    assert!(
+        stdout.contains("Type: MaximumIndependentSet"),
+        "expected 'Type: MaximumIndependentSet', got: {stdout}"
+    );
+    assert!(
+        stdout.contains("Size:"),
+        "expected 'Size:', got: {stdout}"
+    );
+    assert!(
+        stdout.contains("Variables:"),
+        "expected 'Variables:', got: {stdout}"
+    );
+    assert!(
+        stdout.contains("Solvers:"),
+        "expected 'Solvers:', got: {stdout}"
+    );
+    assert!(
+        stdout.contains("Reduces to:"),
+        "expected 'Reduces to:', got: {stdout}"
+    );
+
+    std::fs::remove_file(&problem_file).ok();
+}
+
+#[test]
+fn test_inspect_bundle() {
+    let problem_file = std::env::temp_dir().join("pred_test_inspect_bundle_p.json");
+    let bundle_file = std::env::temp_dir().join("pred_test_inspect_bundle.json");
+
+    let create_out = pred()
+        .args([
+            "-o",
+            problem_file.to_str().unwrap(),
+            "create",
+            "MIS",
+            "--edges",
+            "0-1,1-2",
+        ])
+        .output()
+        .unwrap();
+    assert!(create_out.status.success());
+
+    let reduce_out = pred()
+        .args([
+            "-o",
+            bundle_file.to_str().unwrap(),
+            "reduce",
+            problem_file.to_str().unwrap(),
+            "--to",
+            "QUBO",
+        ])
+        .output()
+        .unwrap();
+    assert!(
+        reduce_out.status.success(),
+        "reduce stderr: {}",
+        String::from_utf8_lossy(&reduce_out.stderr)
+    );
+
+    let output = pred()
+        .args(["inspect", bundle_file.to_str().unwrap()])
+        .output()
+        .unwrap();
+    assert!(
+        output.status.success(),
+        "stderr: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+    let stdout = String::from_utf8(output.stdout).unwrap();
+    assert!(
+        stdout.contains("Bundle"),
+        "expected 'Bundle' in output, got: {stdout}"
+    );
+    assert!(
+        stdout.contains("Source:"),
+        "expected 'Source:' in output, got: {stdout}"
+    );
+    assert!(
+        stdout.contains("Target:"),
+        "expected 'Target:' in output, got: {stdout}"
+    );
+    assert!(
+        stdout.contains("Path:"),
+        "expected 'Path:' in output, got: {stdout}"
+    );
+
+    std::fs::remove_file(&problem_file).ok();
+    std::fs::remove_file(&bundle_file).ok();
+}
+
+#[test]
+fn test_inspect_stdin() {
+    // Test pipe: create | inspect -
+    let create_out = pred()
+        .args(["create", "MIS", "--edges", "0-1,1-2"])
+        .output()
+        .unwrap();
+    assert!(create_out.status.success());
+
+    use std::io::Write;
+    let mut child = pred()
+        .args(["inspect", "-"])
+        .stdin(std::process::Stdio::piped())
+        .stdout(std::process::Stdio::piped())
+        .stderr(std::process::Stdio::piped())
+        .spawn()
+        .unwrap();
+    child
+        .stdin
+        .take()
+        .unwrap()
+        .write_all(&create_out.stdout)
+        .unwrap();
+    let result = child.wait_with_output().unwrap();
+    assert!(
+        result.status.success(),
+        "stderr: {}",
+        String::from_utf8_lossy(&result.stderr)
+    );
+    let stdout = String::from_utf8(result.stdout).unwrap();
+    assert!(
+        stdout.contains("MaximumIndependentSet"),
+        "expected 'MaximumIndependentSet', got: {stdout}"
+    );
+}
+
+#[test]
+fn test_inspect_json_output() {
+    let problem_file = std::env::temp_dir().join("pred_test_inspect_json_in.json");
+    let result_file = std::env::temp_dir().join("pred_test_inspect_json_out.json");
+    let create_out = pred()
+        .args([
+            "-o",
+            problem_file.to_str().unwrap(),
+            "create",
+            "MIS",
+            "--edges",
+            "0-1,1-2,2-3",
+        ])
+        .output()
+        .unwrap();
+    assert!(create_out.status.success());
+
+    let output = pred()
+        .args([
+            "-o",
+            result_file.to_str().unwrap(),
+            "inspect",
+            problem_file.to_str().unwrap(),
+        ])
+        .output()
+        .unwrap();
+    assert!(
+        output.status.success(),
+        "stderr: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+    assert!(result_file.exists());
+
+    let content = std::fs::read_to_string(&result_file).unwrap();
+    let json: serde_json::Value = serde_json::from_str(&content).unwrap();
+    assert_eq!(json["kind"], "problem");
+    assert_eq!(json["type"], "MaximumIndependentSet");
+    assert!(json["size"].is_array());
+    assert!(json["solvers"].is_array());
+    assert!(json["reduces_to"].is_array());
+
+    std::fs::remove_file(&problem_file).ok();
+    std::fs::remove_file(&result_file).ok();
+}

From dae30a0d5c7821bea7cd8cebc8715c907d9c755c Mon Sep 17 00:00:00 2001
From: GiggleLiu <cacate0129@gmail.com>
Date: Thu, 19 Feb 2026 15:15:14 +0800
Subject: [PATCH 26/34] feat(cli): add --random flag for random problem
 instance generation

Support random Erdos-Renyi graph generation for graph-based problems
via `pred create <PROBLEM> --random --num-vertices N [--edge-prob P] [--seed S]`.
Uses a simple LCG PRNG with no external dependencies. Supported for
MIS, MVC, MaxCut, MaxClique, MaximumMatching, MinimumDominatingSet,
SpinGlass, KColoring, and TravelingSalesman.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
 problemreductions-cli/src/cli.rs             |  65 ++++-
 problemreductions-cli/src/commands/create.rs | 145 ++++++++++-
 problemreductions-cli/tests/cli_tests.rs     | 258 ++++++++++++++++++-
 3 files changed, 445 insertions(+), 23 deletions(-)

diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs
index 6a4c83e21..12ff47c52 100644
--- a/problemreductions-cli/src/cli.rs
+++ b/problemreductions-cli/src/cli.rs
@@ -12,6 +12,11 @@ Typical workflow:
   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
+
 Use `pred <command> --help` for detailed usage of each command.
 Use `pred list` to see all available problem types.
 
@@ -89,12 +94,10 @@ Use `pred list` to see available problems.")]
 
     /// Export the reduction graph to JSON
     #[command(after_help = "\
-Example:
-  pred export-graph reduction_graph.json")]
-    ExportGraph {
-        /// Output file path
-        output: PathBuf,
-    },
+Examples:
+  pred export-graph                           # print to stdout
+  pred export-graph -o reduction_graph.json   # save to file")]
+    ExportGraph,
 
     /// Create a problem instance and save as JSON
     Create(CreateArgs),
@@ -102,6 +105,13 @@ Example:
     Evaluate(EvaluateArgs),
     /// Reduce a problem instance to a target type
     Reduce(ReduceArgs),
+    /// Inspect a problem JSON or reduction bundle
+    #[command(after_help = "\
+Examples:
+  pred inspect problem.json
+  pred inspect bundle.json
+  pred create MIS --edges 0-1,1-2 | pred inspect -")]
+    Inspect(InspectArgs),
     /// Solve a problem instance
     Solve(SolveArgs),
     /// Print shell completions to stdout (auto-detects shell)
@@ -137,12 +147,20 @@ Options by problem type:
     --edges       Edge list [required]
     --k           Number of colors [required]
 
+Random generation (graph-based problems only):
+  --random        Generate a random Erdos-Renyi graph instance
+  --num-vertices  Number of vertices [required with --random]
+  --edge-prob     Edge probability (0.0 to 1.0) [default: 0.5]
+  --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 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 MIS --random --num-vertices 10 --edge-prob 0.3
+  pred create MIS --random --num-vertices 10 --seed 42 -o big.json
 
 Output (`-o`) uses the standard problem JSON format:
   {\"type\": \"...\", \"variant\": {...}, \"data\": {...}}")]
@@ -168,6 +186,18 @@ pub struct CreateArgs {
     /// Number of colors for KColoring
     #[arg(long)]
     pub k: Option<usize>,
+    /// Generate a random instance (graph-based problems only)
+    #[arg(long)]
+    pub random: bool,
+    /// Number of vertices for random graph generation
+    #[arg(long)]
+    pub num_vertices: Option<usize>,
+    /// Edge probability for random graph generation (0.0 to 1.0) [default: 0.5]
+    #[arg(long)]
+    pub edge_prob: Option<f64>,
+    /// Random seed for reproducibility
+    #[arg(long)]
+    pub seed: Option<u64>,
 }
 
 #[derive(clap::Args)]
@@ -177,6 +207,7 @@ 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
 
 Typical workflow:
   pred create MIS --edges 0-1,1-2,2-3 -o problem.json
@@ -195,7 +226,7 @@ ILP backend (default: HiGHS). To use a different backend:
   cargo install problemreductions-cli --features scip
   cargo install problemreductions-cli --no-default-features --features clarabel")]
 pub struct SolveArgs {
-    /// Problem JSON file (from `pred create`) or reduction bundle (from `pred reduce`)
+    /// Problem JSON file (from `pred create`) or reduction bundle (from `pred reduce`). Use - for stdin.
     pub input: PathBuf,
     /// Solver: ilp (default) or brute-force
     #[arg(long, default_value = "ilp")]
@@ -208,14 +239,15 @@ 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
 
-Input: a problem JSON from `pred create`.
+Input: a problem JSON from `pred create`. Use - to read from stdin.
 The --via path file is from `pred path <SRC> <DST> -o path.json`.
 When --via is given, --to is inferred from the path file.
 Output is a reduction bundle with source, target, and path.
 Use `pred solve reduced.json` to solve and map the solution back.")]
 pub struct ReduceArgs {
-    /// Problem JSON file (from `pred create`)
+    /// Problem JSON file (from `pred create`). Use - for stdin.
     pub input: PathBuf,
     /// Target problem type (e.g., QUBO, SpinGlass). Inferred from --via if omitted.
     #[arg(long, value_parser = crate::problem_name::ProblemNameParser)]
@@ -223,6 +255,15 @@ pub struct ReduceArgs {
     /// Reduction route file (from `pred path ... -o`)
     #[arg(long)]
     pub via: Option<PathBuf>,
+    /// Output raw JSON to stdout instead of human-readable summary
+    #[arg(long)]
+    pub json: bool,
+}
+
+#[derive(clap::Args)]
+pub struct InspectArgs {
+    /// Problem JSON file or reduction bundle. Use - for stdin.
+    pub input: PathBuf,
 }
 
 #[derive(clap::Args)]
@@ -230,10 +271,11 @@ pub struct ReduceArgs {
 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
 
-Input: a problem JSON from `pred create`.")]
+Input: a problem JSON from `pred create`. Use - to read from stdin.")]
 pub struct EvaluateArgs {
-    /// Problem JSON file (from `pred create`)
+    /// Problem JSON file (from `pred create`). Use - for stdin.
     pub input: PathBuf,
     /// Configuration to evaluate (comma-separated, e.g., 1,0,1,0)
     #[arg(long)]
@@ -247,6 +289,7 @@ pub fn print_subcommand_help_hint(error_msg: &str) {
         ("pred reduce", "reduce"),
         ("pred create", "create"),
         ("pred evaluate", "evaluate"),
+        ("pred inspect", "inspect"),
         ("pred path", "path"),
         ("pred show", "show"),
         ("pred export-graph", "export-graph"),
diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs
index bbbb121d7..9776a3544 100644
--- a/problemreductions-cli/src/commands/create.rs
+++ b/problemreductions-cli/src/commands/create.rs
@@ -12,6 +12,10 @@ use std::collections::BTreeMap;
 pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> {
     let canonical = resolve_alias(&args.problem);
 
+    if args.random {
+        return create_random(args, &canonical, out);
+    }
+
     let (data, variant) = match canonical.as_str() {
         // Graph problems with vertex weights
         "MaximumIndependentSet"
@@ -153,10 +157,7 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> {
             bail!("Factoring requires complex construction — use a JSON file instead");
         }
 
-        _ => bail!(
-            "{}",
-            crate::problem_name::unknown_problem_error(&canonical)
-        ),
+        _ => bail!("{}", crate::problem_name::unknown_problem_error(&canonical)),
     };
 
     let output = ProblemJsonOutput {
@@ -168,8 +169,7 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> {
     let json = serde_json::to_value(&output)?;
 
     if let Some(ref path) = out.output {
-        let content =
-            serde_json::to_string_pretty(&json).context("Failed to serialize JSON")?;
+        let content = serde_json::to_string_pretty(&json).context("Failed to serialize JSON")?;
         std::fs::write(path, &content)
             .with_context(|| format!("Failed to write {}", path.display()))?;
         out.info(&format!("Wrote {}", path.display()));
@@ -306,3 +306,136 @@ fn parse_matrix(args: &CreateArgs) -> Result<Vec<Vec<f64>>> {
         })
         .collect()
 }
+
+/// Generate a random Erdos-Renyi graph using a simple LCG PRNG (no external dependency).
+fn create_random_graph(num_vertices: usize, edge_prob: f64, seed: Option<u64>) -> SimpleGraph {
+    let mut state: u64 = seed.unwrap_or_else(|| {
+        std::time::SystemTime::now()
+            .duration_since(std::time::UNIX_EPOCH)
+            .unwrap()
+            .as_nanos() as u64
+    });
+
+    let mut edges = Vec::new();
+    for i in 0..num_vertices {
+        for j in (i + 1)..num_vertices {
+            // LCG step
+            state = state
+                .wrapping_mul(6364136223846793005)
+                .wrapping_add(1442695040888963407);
+            let rand_val = (state >> 33) as f64 / (1u64 << 31) as f64;
+            if rand_val < edge_prob {
+                edges.push((i, j));
+            }
+        }
+    }
+
+    SimpleGraph::new(num_vertices, edges)
+}
+
+/// Handle `pred create <PROBLEM> --random ...`
+fn create_random(args: &CreateArgs, canonical: &str, out: &OutputConfig) -> Result<()> {
+    let num_vertices = args.num_vertices.ok_or_else(|| {
+        anyhow::anyhow!(
+            "--random requires --num-vertices\n\n\
+             Usage: pred create {} --random --num-vertices 10 [--edge-prob 0.3] [--seed 42]",
+            args.problem
+        )
+    })?;
+    let edge_prob = args.edge_prob.unwrap_or(0.5);
+    if !(0.0..=1.0).contains(&edge_prob) {
+        bail!("--edge-prob must be between 0.0 and 1.0");
+    }
+
+    let graph = create_random_graph(num_vertices, edge_prob, args.seed);
+    let num_edges = graph.num_edges();
+
+    let (data, variant) = match canonical {
+        // Graph problems with vertex weights
+        "MaximumIndependentSet"
+        | "MinimumVertexCover"
+        | "MaximumClique"
+        | "MinimumDominatingSet" => {
+            let weights = vec![1i32; num_vertices];
+            let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]);
+            let data = match canonical {
+                "MaximumIndependentSet" => ser(MaximumIndependentSet::new(graph, weights))?,
+                "MinimumVertexCover" => ser(MinimumVertexCover::new(graph, weights))?,
+                "MaximumClique" => ser(MaximumClique::new(graph, weights))?,
+                "MinimumDominatingSet" => ser(MinimumDominatingSet::new(graph, weights))?,
+                _ => unreachable!(),
+            };
+            (data, variant)
+        }
+
+        // Graph problems with edge weights
+        "MaxCut" | "MaximumMatching" | "TravelingSalesman" => {
+            let edge_weights = vec![1i32; num_edges];
+            let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]);
+            let data = match canonical {
+                "MaxCut" => ser(MaxCut::new(graph, edge_weights))?,
+                "MaximumMatching" => ser(MaximumMatching::new(graph, edge_weights))?,
+                "TravelingSalesman" => ser(TravelingSalesman::new(graph, edge_weights))?,
+                _ => unreachable!(),
+            };
+            (data, variant)
+        }
+
+        // SpinGlass
+        "SpinGlass" => {
+            let couplings = vec![1i32; num_edges];
+            let fields = vec![0i32; num_vertices];
+            let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]);
+            (
+                ser(SpinGlass::from_graph(graph, couplings, fields))?,
+                variant,
+            )
+        }
+
+        // KColoring
+        "KColoring" => {
+            let k = args.k.unwrap_or(3);
+            let variant;
+            let data;
+            match k {
+                2 => {
+                    variant = variant_map(&[("k", "K2"), ("graph", "SimpleGraph")]);
+                    data = ser(KColoring::<K2, SimpleGraph>::new(graph))?;
+                }
+                3 => {
+                    variant = variant_map(&[("k", "K3"), ("graph", "SimpleGraph")]);
+                    data = ser(KColoring::<K3, SimpleGraph>::new(graph))?;
+                }
+                _ => {
+                    variant = variant_map(&[("k", "KN"), ("graph", "SimpleGraph")]);
+                    data = ser(KColoring::<KN, SimpleGraph>::with_k(graph, k))?;
+                }
+            }
+            (data, variant)
+        }
+
+        _ => bail!(
+            "Random generation is not supported for {canonical}. \
+             Supported: graph-based problems (MIS, MVC, MaxCut, MaxClique, \
+             MaximumMatching, MinimumDominatingSet, SpinGlass, KColoring, TravelingSalesman)"
+        ),
+    };
+
+    let output = ProblemJsonOutput {
+        problem_type: canonical.to_string(),
+        variant,
+        data,
+    };
+
+    let json = serde_json::to_value(&output)?;
+
+    if let Some(ref path) = out.output {
+        let content = serde_json::to_string_pretty(&json).context("Failed to serialize JSON")?;
+        std::fs::write(path, &content)
+            .with_context(|| format!("Failed to write {}", path.display()))?;
+        out.info(&format!("Wrote {}", path.display()));
+    } else {
+        println!("{}", serde_json::to_string_pretty(&json)?);
+    }
+    Ok(())
+}
diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs
index aef9ba223..6a3813317 100644
--- a/problemreductions-cli/tests/cli_tests.rs
+++ b/problemreductions-cli/tests/cli_tests.rs
@@ -1326,7 +1326,13 @@ fn test_reduce_stdout() {
     assert!(create_out.status.success());
 
     let output = pred()
-        .args(["reduce", problem_file.to_str().unwrap(), "--to", "QUBO", "--json"])
+        .args([
+            "reduce",
+            problem_file.to_str().unwrap(),
+            "--to",
+            "QUBO",
+            "--json",
+        ])
         .output()
         .unwrap();
     assert!(
@@ -1541,7 +1547,10 @@ fn test_completions_bash() {
         String::from_utf8_lossy(&output.stderr)
     );
     let stdout = String::from_utf8(output.stdout).unwrap();
-    assert!(stdout.contains("pred"), "completions should reference 'pred'");
+    assert!(
+        stdout.contains("pred"),
+        "completions should reference 'pred'"
+    );
 }
 
 #[test]
@@ -1908,10 +1917,7 @@ fn test_inspect_problem() {
         stdout.contains("Type: MaximumIndependentSet"),
         "expected 'Type: MaximumIndependentSet', got: {stdout}"
     );
-    assert!(
-        stdout.contains("Size:"),
-        "expected 'Size:', got: {stdout}"
-    );
+    assert!(stdout.contains("Size:"), "expected 'Size:', got: {stdout}");
     assert!(
         stdout.contains("Variables:"),
         "expected 'Variables:', got: {stdout}"
@@ -2074,3 +2080,243 @@ fn test_inspect_json_output() {
     std::fs::remove_file(&problem_file).ok();
     std::fs::remove_file(&result_file).ok();
 }
+
+// ---- Random generation tests ----
+
+#[test]
+fn test_create_random_mis() {
+    let output = pred()
+        .args([
+            "create",
+            "MIS",
+            "--random",
+            "--num-vertices",
+            "10",
+            "--edge-prob",
+            "0.3",
+            "--seed",
+            "42",
+        ])
+        .output()
+        .unwrap();
+    assert!(
+        output.status.success(),
+        "stderr: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+    let stdout = String::from_utf8(output.stdout).unwrap();
+    let json: serde_json::Value = serde_json::from_str(&stdout).unwrap();
+    assert_eq!(json["type"], "MaximumIndependentSet");
+    assert!(json["data"].is_object());
+}
+
+#[test]
+fn test_create_random_deterministic() {
+    // Same seed should produce identical output
+    let out1 = pred()
+        .args([
+            "create",
+            "MIS",
+            "--random",
+            "--num-vertices",
+            "5",
+            "--seed",
+            "123",
+        ])
+        .output()
+        .unwrap();
+    let out2 = pred()
+        .args([
+            "create",
+            "MIS",
+            "--random",
+            "--num-vertices",
+            "5",
+            "--seed",
+            "123",
+        ])
+        .output()
+        .unwrap();
+    assert!(out1.status.success());
+    assert!(out2.status.success());
+    assert_eq!(out1.stdout, out2.stdout);
+}
+
+#[test]
+fn test_create_random_missing_num_vertices() {
+    let output = pred().args(["create", "MIS", "--random"]).output().unwrap();
+    assert!(!output.status.success());
+    let stderr = String::from_utf8_lossy(&output.stderr);
+    assert!(
+        stderr.contains("--num-vertices"),
+        "expected '--num-vertices' in error, got: {stderr}"
+    );
+}
+
+#[test]
+fn test_create_random_maxcut() {
+    let output = pred()
+        .args([
+            "create",
+            "MaxCut",
+            "--random",
+            "--num-vertices",
+            "5",
+            "--seed",
+            "42",
+        ])
+        .output()
+        .unwrap();
+    assert!(
+        output.status.success(),
+        "stderr: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+    let stdout = String::from_utf8(output.stdout).unwrap();
+    let json: serde_json::Value = serde_json::from_str(&stdout).unwrap();
+    assert_eq!(json["type"], "MaxCut");
+}
+
+#[test]
+fn test_create_random_unsupported() {
+    let output = pred()
+        .args(["create", "SAT", "--random", "--num-vertices", "5"])
+        .output()
+        .unwrap();
+    assert!(!output.status.success());
+    let stderr = String::from_utf8_lossy(&output.stderr);
+    assert!(
+        stderr.contains("not supported"),
+        "expected 'not supported' in error, got: {stderr}"
+    );
+}
+
+#[test]
+fn test_create_random_invalid_edge_prob() {
+    let output = pred()
+        .args([
+            "create",
+            "MIS",
+            "--random",
+            "--num-vertices",
+            "5",
+            "--edge-prob",
+            "1.5",
+        ])
+        .output()
+        .unwrap();
+    assert!(!output.status.success());
+    let stderr = String::from_utf8_lossy(&output.stderr);
+    assert!(
+        stderr.contains("--edge-prob must be between"),
+        "expected edge-prob validation error, got: {stderr}"
+    );
+}
+
+#[test]
+fn test_create_random_spinglass() {
+    let output = pred()
+        .args([
+            "create",
+            "SpinGlass",
+            "--random",
+            "--num-vertices",
+            "5",
+            "--seed",
+            "42",
+        ])
+        .output()
+        .unwrap();
+    assert!(
+        output.status.success(),
+        "stderr: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+    let stdout = String::from_utf8(output.stdout).unwrap();
+    let json: serde_json::Value = serde_json::from_str(&stdout).unwrap();
+    assert_eq!(json["type"], "SpinGlass");
+}
+
+#[test]
+fn test_create_random_kcoloring() {
+    let output = pred()
+        .args([
+            "create",
+            "KColoring",
+            "--random",
+            "--num-vertices",
+            "5",
+            "--seed",
+            "42",
+            "--k",
+            "3",
+        ])
+        .output()
+        .unwrap();
+    assert!(
+        output.status.success(),
+        "stderr: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+    let stdout = String::from_utf8(output.stdout).unwrap();
+    let json: serde_json::Value = serde_json::from_str(&stdout).unwrap();
+    assert_eq!(json["type"], "KColoring");
+}
+
+#[test]
+fn test_create_random_to_file() {
+    let output_file = std::env::temp_dir().join("pred_test_create_random.json");
+    let output = pred()
+        .args([
+            "-o",
+            output_file.to_str().unwrap(),
+            "create",
+            "MIS",
+            "--random",
+            "--num-vertices",
+            "8",
+            "--edge-prob",
+            "0.4",
+            "--seed",
+            "99",
+        ])
+        .output()
+        .unwrap();
+    assert!(
+        output.status.success(),
+        "stderr: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+    assert!(output_file.exists());
+
+    let content = std::fs::read_to_string(&output_file).unwrap();
+    let json: serde_json::Value = serde_json::from_str(&content).unwrap();
+    assert_eq!(json["type"], "MaximumIndependentSet");
+
+    std::fs::remove_file(&output_file).ok();
+}
+
+#[test]
+fn test_create_random_default_edge_prob() {
+    // Without --edge-prob, defaults to 0.5
+    let output = pred()
+        .args([
+            "create",
+            "MIS",
+            "--random",
+            "--num-vertices",
+            "5",
+            "--seed",
+            "42",
+        ])
+        .output()
+        .unwrap();
+    assert!(
+        output.status.success(),
+        "stderr: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+    let stdout = String::from_utf8(output.stdout).unwrap();
+    let json: serde_json::Value = serde_json::from_str(&stdout).unwrap();
+    assert_eq!(json["type"], "MaximumIndependentSet");
+}

From b5e7ac57bebb82908276c7cb4a84e25f057a7b81 Mon Sep 17 00:00:00 2001
From: GiggleLiu <cacate0129@gmail.com>
Date: Thu, 19 Feb 2026 15:17:59 +0800
Subject: [PATCH 27/34] feat(cli): add inspect, stdin support, --json reduce,
 export-graph consistency

Remaining changes from CLI UX improvement plan:
- P4: export-graph uses global -o flag instead of positional arg
- P7: accept - for stdin in solve, evaluate, reduce
- P2: human-readable reduce output by default, --json flag for raw JSON
- F1: new inspect command for problem/bundle introspection

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
 .../src/commands/evaluate.rs                  |  4 +-
 problemreductions-cli/src/commands/inspect.rs | 99 +++++++++++++++++++
 problemreductions-cli/src/commands/mod.rs     |  1 +
 problemreductions-cli/src/commands/reduce.rs  |  5 +-
 problemreductions-cli/src/commands/solve.rs   |  5 +-
 problemreductions-cli/src/dispatch.rs         |  3 +-
 problemreductions-cli/src/main.rs             | 13 ++-
 problemreductions-cli/src/problem_name.rs     |  5 +-
 8 files changed, 119 insertions(+), 16 deletions(-)
 create mode 100644 problemreductions-cli/src/commands/inspect.rs

diff --git a/problemreductions-cli/src/commands/evaluate.rs b/problemreductions-cli/src/commands/evaluate.rs
index 13a7b34cc..348663ba3 100644
--- a/problemreductions-cli/src/commands/evaluate.rs
+++ b/problemreductions-cli/src/commands/evaluate.rs
@@ -1,10 +1,10 @@
-use crate::dispatch::{load_problem, ProblemJson};
+use crate::dispatch::{load_problem, read_input, ProblemJson};
 use crate::output::OutputConfig;
 use anyhow::Result;
 use std::path::Path;
 
 pub fn evaluate(input: &Path, config_str: &str, out: &OutputConfig) -> Result<()> {
-    let content = std::fs::read_to_string(input)?;
+    let content = read_input(input)?;
     let problem_json: ProblemJson = serde_json::from_str(&content)?;
 
     let problem = load_problem(
diff --git a/problemreductions-cli/src/commands/inspect.rs b/problemreductions-cli/src/commands/inspect.rs
new file mode 100644
index 000000000..16717414f
--- /dev/null
+++ b/problemreductions-cli/src/commands/inspect.rs
@@ -0,0 +1,99 @@
+use crate::dispatch::{load_problem, read_input, ProblemJson, ReductionBundle};
+use crate::output::OutputConfig;
+use anyhow::Result;
+use problemreductions::rules::ReductionGraph;
+use std::path::Path;
+
+pub fn inspect(input: &Path, out: &OutputConfig) -> Result<()> {
+    let content = read_input(input)?;
+    let json: serde_json::Value = serde_json::from_str(&content)?;
+
+    // Detect if it's a bundle or a problem
+    if json.get("source").is_some() && json.get("target").is_some() && json.get("path").is_some() {
+        let bundle: ReductionBundle = serde_json::from_value(json)?;
+        inspect_bundle(&bundle, out)
+    } else {
+        let problem_json: ProblemJson = serde_json::from_value(json)?;
+        inspect_problem(&problem_json, out)
+    }
+}
+
+fn inspect_problem(pj: &ProblemJson, out: &OutputConfig) -> Result<()> {
+    let problem = load_problem(&pj.problem_type, &pj.variant, pj.data.clone())?;
+    let name = problem.problem_name();
+    let variant = problem.variant_map();
+    let graph = ReductionGraph::new();
+
+    let variant_str = if variant.is_empty() {
+        String::new()
+    } else {
+        let pairs: Vec<String> = variant.iter().map(|(k, v)| format!("{k}={v}")).collect();
+        format!(" {{{}}}", pairs.join(", "))
+    };
+
+    let mut text = format!("Type: {}{}\n", name, variant_str);
+
+    // Size info
+    let size_names = problem.problem_size_names_dyn();
+    let size_values = problem.problem_size_values_dyn();
+    if !size_names.is_empty() {
+        let sizes: Vec<String> = size_names
+            .iter()
+            .zip(size_values.iter())
+            .map(|(n, v)| format!("{} {}", v, n))
+            .collect();
+        text.push_str(&format!("Size: {}\n", sizes.join(", ")));
+    }
+    text.push_str(&format!("Variables: {}\n", problem.num_variables_dyn()));
+
+    // Solvers
+    text.push_str("Solvers: ilp (default), brute-force\n");
+
+    // Reductions
+    let outgoing = graph.outgoing_reductions(name);
+    let targets = targets_deduped(&outgoing);
+    if !targets.is_empty() {
+        text.push_str(&format!("Reduces to: {}\n", targets.join(", ")));
+    }
+
+    let json_val = serde_json::json!({
+        "kind": "problem",
+        "type": name,
+        "variant": variant,
+        "size": size_names.iter().zip(size_values.iter())
+            .map(|(n, v)| serde_json::json!({"field": n, "value": v}))
+            .collect::<Vec<_>>(),
+        "num_variables": problem.num_variables_dyn(),
+        "solvers": ["ilp", "brute-force"],
+        "reduces_to": targets,
+    });
+
+    out.emit_with_default_name("", &text, &json_val)
+}
+
+fn inspect_bundle(bundle: &ReductionBundle, out: &OutputConfig) -> Result<()> {
+    let mut text = String::from("Kind: Reduction Bundle\n");
+    text.push_str(&format!("Source: {}\n", bundle.source.problem_type));
+    text.push_str(&format!("Target: {}\n", bundle.target.problem_type));
+    text.push_str(&format!("Steps: {}\n", bundle.path.len().saturating_sub(1)));
+
+    let path_str: Vec<&str> = bundle.path.iter().map(|s| s.name.as_str()).collect();
+    text.push_str(&format!("Path: {}\n", path_str.join(" -> ")));
+
+    let json_val = serde_json::json!({
+        "kind": "bundle",
+        "source": bundle.source.problem_type,
+        "target": bundle.target.problem_type,
+        "steps": bundle.path.len().saturating_sub(1),
+        "path": path_str,
+    });
+
+    out.emit_with_default_name("", &text, &json_val)
+}
+
+fn targets_deduped(outgoing: &[problemreductions::rules::ReductionEdgeInfo]) -> Vec<String> {
+    let mut targets: Vec<String> = outgoing.iter().map(|e| e.target_name.to_string()).collect();
+    targets.sort();
+    targets.dedup();
+    targets
+}
diff --git a/problemreductions-cli/src/commands/mod.rs b/problemreductions-cli/src/commands/mod.rs
index 2bc2e8f3e..f42382f20 100644
--- a/problemreductions-cli/src/commands/mod.rs
+++ b/problemreductions-cli/src/commands/mod.rs
@@ -1,5 +1,6 @@
 pub mod create;
 pub mod evaluate;
 pub mod graph;
+pub mod inspect;
 pub mod reduce;
 pub mod solve;
diff --git a/problemreductions-cli/src/commands/reduce.rs b/problemreductions-cli/src/commands/reduce.rs
index 052951736..e061718e2 100644
--- a/problemreductions-cli/src/commands/reduce.rs
+++ b/problemreductions-cli/src/commands/reduce.rs
@@ -110,7 +110,10 @@ pub fn reduce(
         let dst_spec = parse_problem_spec(target)?;
         let dst_variants = graph.variants_for(&dst_spec.name);
         if dst_variants.is_empty() {
-            anyhow::bail!("{}", crate::problem_name::unknown_problem_error(&dst_spec.name));
+            anyhow::bail!(
+                "{}",
+                crate::problem_name::unknown_problem_error(&dst_spec.name)
+            );
         }
 
         // Auto-discover cheapest path
diff --git a/problemreductions-cli/src/commands/solve.rs b/problemreductions-cli/src/commands/solve.rs
index fb68e378c..c8a9279e1 100644
--- a/problemreductions-cli/src/commands/solve.rs
+++ b/problemreductions-cli/src/commands/solve.rs
@@ -1,4 +1,4 @@
-use crate::dispatch::{load_problem, ProblemJson, ReductionBundle};
+use crate::dispatch::{load_problem, read_input, ProblemJson, ReductionBundle};
 use crate::output::OutputConfig;
 use anyhow::{Context, Result};
 use problemreductions::rules::ReductionGraph;
@@ -13,8 +13,7 @@ enum SolveInput {
 }
 
 fn parse_input(path: &Path) -> Result<SolveInput> {
-    let content = std::fs::read_to_string(path)
-        .with_context(|| format!("Failed to read {}", path.display()))?;
+    let content = read_input(path)?;
     let json: serde_json::Value = serde_json::from_str(&content).context("Failed to parse JSON")?;
 
     // Reduction bundles have "source", "target", and "path" fields
diff --git a/problemreductions-cli/src/dispatch.rs b/problemreductions-cli/src/dispatch.rs
index 3b61a6bcc..e1431be05 100644
--- a/problemreductions-cli/src/dispatch.rs
+++ b/problemreductions-cli/src/dispatch.rs
@@ -26,8 +26,7 @@ pub fn read_input(path: &Path) -> Result<String> {
             .context("Failed to read from stdin")?;
         Ok(buf)
     } else {
-        std::fs::read_to_string(path)
-            .with_context(|| format!("Failed to read {}", path.display()))
+        std::fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))
     }
 }
 
diff --git a/problemreductions-cli/src/main.rs b/problemreductions-cli/src/main.rs
index f188e8e51..e9f96c4e8 100644
--- a/problemreductions-cli/src/main.rs
+++ b/problemreductions-cli/src/main.rs
@@ -44,12 +44,17 @@ fn main() -> anyhow::Result<()> {
             cost,
             all,
         } => commands::graph::path(&source, &target, &cost, all, &out),
-        Commands::ExportGraph { output } => commands::graph::export(&output, &out),
+        Commands::ExportGraph => commands::graph::export(&out),
+        Commands::Inspect(args) => commands::inspect::inspect(&args.input, &out),
         Commands::Create(args) => commands::create::create(&args, &out),
         Commands::Solve(args) => commands::solve::solve(&args.input, &args.solver, &out),
-        Commands::Reduce(args) => {
-            commands::reduce::reduce(&args.input, args.to.as_deref(), args.via.as_deref(), &out)
-        }
+        Commands::Reduce(args) => commands::reduce::reduce(
+            &args.input,
+            args.to.as_deref(),
+            args.via.as_deref(),
+            args.json,
+            &out,
+        ),
         Commands::Evaluate(args) => commands::evaluate::evaluate(&args.input, &args.config, &out),
         Commands::Completions { shell } => {
             let shell = shell
diff --git a/problemreductions-cli/src/problem_name.rs b/problemreductions-cli/src/problem_name.rs
index 7b1bc2351..e40fac2ab 100644
--- a/problemreductions-cli/src/problem_name.rs
+++ b/problemreductions-cli/src/problem_name.rs
@@ -217,10 +217,7 @@ pub fn unknown_problem_error(input: &str) -> String {
     let suggestions = suggest_problem_name(input);
     let mut msg = format!("Unknown problem: {input}");
     if !suggestions.is_empty() {
-        msg.push_str(&format!(
-            "\n\nDid you mean: {}?",
-            suggestions.join(", ")
-        ));
+        msg.push_str(&format!("\n\nDid you mean: {}?", suggestions.join(", ")));
     }
     msg.push_str("\n\nRun `pred list` to see all available problems.");
     msg

From 9ef8e63529e910b00956e0fa936931b86c153e7c Mon Sep 17 00:00:00 2001
From: GiggleLiu <cacate0129@gmail.com>
Date: Thu, 19 Feb 2026 15:18:17 +0800
Subject: [PATCH 28/34] feat: add cli-demo Makefile target for end-to-end CLI
 testing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
 Makefile | 138 ++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 1 file changed, 137 insertions(+), 1 deletion(-)

diff --git a/Makefile b/Makefile
index 50dc838a1..da1b3fb15 100644
--- a/Makefile
+++ b/Makefile
@@ -1,6 +1,6 @@
 # Makefile for problemreductions
 
-.PHONY: help build test fmt clippy doc mdbook paper examples clean coverage rust-export compare qubo-testdata export-schemas release run-plan diagrams jl-testdata cli
+.PHONY: help build test fmt clippy doc mdbook paper examples clean coverage rust-export compare qubo-testdata export-schemas release run-plan diagrams jl-testdata cli cli-demo
 
 # Default target
 help:
@@ -25,6 +25,7 @@ help:
 	@echo "  jl-testdata  - Regenerate Julia parity test data (requires julia)"
 	@echo "  release V=x.y.z - Tag and push a new release (triggers CI publish)"
 	@echo "  cli          - Build the pred CLI tool"
+	@echo "  cli-demo     - Run closed-loop CLI demo (build + exercise all commands)"
 	@echo "  run-plan   - Execute a plan with Claude autorun (latest plan in docs/plans/)"
 
 # Build the project
@@ -196,3 +197,138 @@ run-plan:
 		--verbose \
 		--max-turns 500 \
 		-p "$$PROMPT" 2>&1 | tee "$(OUTPUT)"
+
+# Closed-loop CLI demo: exercises all commands end-to-end
+PRED := cargo run -p problemreductions-cli --release --
+CLI_DEMO_DIR := /tmp/pred-cli-demo
+cli-demo: cli
+	@echo "=== pred CLI closed-loop demo ==="
+	@rm -rf $(CLI_DEMO_DIR) && mkdir -p $(CLI_DEMO_DIR)
+	@set -e; \
+	PRED="./target/release/pred"; \
+	\
+	echo ""; \
+	echo "--- 1. list: all registered problems ---"; \
+	$$PRED list; \
+	$$PRED list -o $(CLI_DEMO_DIR)/problems.json; \
+	\
+	echo ""; \
+	echo "--- 2. show: inspect MIS (variants, fields, reductions) ---"; \
+	$$PRED show MIS; \
+	$$PRED show MIS -o $(CLI_DEMO_DIR)/mis_info.json; \
+	\
+	echo ""; \
+	echo "--- 3. show --hops: explore 2-hop neighborhood ---"; \
+	$$PRED show MIS --hops 2; \
+	$$PRED show MIS --hops 2 -o $(CLI_DEMO_DIR)/mis_hops.json; \
+	\
+	echo ""; \
+	echo "--- 4. show --direction: incoming neighbors ---"; \
+	$$PRED show QUBO --hops 1 --direction in; \
+	\
+	echo ""; \
+	echo "--- 5. path: find reduction paths ---"; \
+	$$PRED path MIS QUBO; \
+	$$PRED path MIS QUBO -o $(CLI_DEMO_DIR)/path_mis_qubo.json; \
+	$$PRED path Factoring SpinGlass; \
+	$$PRED path MIS QUBO --cost minimize:num_variables; \
+	\
+	echo ""; \
+	echo "--- 6. path --all: enumerate all paths ---"; \
+	$$PRED path MIS QUBO --all; \
+	$$PRED path MIS QUBO --all -o $(CLI_DEMO_DIR)/all_paths/; \
+	\
+	echo ""; \
+	echo "--- 7. export-graph: full reduction graph ---"; \
+	$$PRED export-graph -o $(CLI_DEMO_DIR)/graph.json; \
+	\
+	echo ""; \
+	echo "--- 8. create: build problem instances ---"; \
+	$$PRED create MIS --edges 0-1,1-2,2-3,3-4,4-0 -o $(CLI_DEMO_DIR)/mis.json; \
+	$$PRED create MIS --edges 0-1,1-2,2-3 --weights 2,1,3,1 -o $(CLI_DEMO_DIR)/mis_weighted.json; \
+	$$PRED create SAT --num-vars 3 --clauses "1,2;-1,3;2,-3" -o $(CLI_DEMO_DIR)/sat.json; \
+	$$PRED create 3SAT --num-vars 4 --clauses "1,2,3;-1,2,-3;1,-2,3" -o $(CLI_DEMO_DIR)/3sat.json; \
+	$$PRED create QUBO --matrix "1,-0.5;-0.5,2" -o $(CLI_DEMO_DIR)/qubo.json; \
+	$$PRED create KColoring --k 3 --edges 0-1,1-2,2-0 -o $(CLI_DEMO_DIR)/kcol.json; \
+	$$PRED create SpinGlass --edges 0-1,1-2 -o $(CLI_DEMO_DIR)/sg.json; \
+	$$PRED create MaxCut --edges 0-1,1-2,2-0 -o $(CLI_DEMO_DIR)/maxcut.json; \
+	$$PRED create MVC --edges 0-1,1-2,2-3 -o $(CLI_DEMO_DIR)/mvc.json; \
+	$$PRED create MaximumMatching --edges 0-1,1-2,2-3 -o $(CLI_DEMO_DIR)/matching.json; \
+	\
+	echo ""; \
+	echo "--- 9. evaluate: test configurations ---"; \
+	$$PRED evaluate $(CLI_DEMO_DIR)/mis.json --config 1,0,1,0,0; \
+	$$PRED evaluate $(CLI_DEMO_DIR)/mis.json --config 1,1,0,0,0; \
+	$$PRED evaluate $(CLI_DEMO_DIR)/sat.json --config 0,1,1; \
+	$$PRED evaluate $(CLI_DEMO_DIR)/mis.json --config 1,0,1,0,0 -o $(CLI_DEMO_DIR)/eval.json; \
+	\
+	echo ""; \
+	echo "--- 10. solve: direct ILP (auto-reduces to ILP) ---"; \
+	$$PRED solve $(CLI_DEMO_DIR)/mis.json; \
+	$$PRED solve $(CLI_DEMO_DIR)/mis.json -o $(CLI_DEMO_DIR)/sol_ilp.json; \
+	\
+	echo ""; \
+	echo "--- 11. solve: brute-force ---"; \
+	$$PRED solve $(CLI_DEMO_DIR)/mis.json --solver brute-force; \
+	\
+	echo ""; \
+	echo "--- 12. solve: weighted MIS ---"; \
+	$$PRED solve $(CLI_DEMO_DIR)/mis_weighted.json; \
+	\
+	echo ""; \
+	echo "--- 13. reduce: MIS → QUBO (auto-discover path) ---"; \
+	$$PRED reduce $(CLI_DEMO_DIR)/mis.json --to QUBO -o $(CLI_DEMO_DIR)/bundle_qubo.json; \
+	\
+	echo ""; \
+	echo "--- 14. solve bundle: brute-force on reduced QUBO ---"; \
+	$$PRED solve $(CLI_DEMO_DIR)/bundle_qubo.json --solver brute-force; \
+	\
+	echo ""; \
+	echo "--- 15. reduce --via: use explicit path file ---"; \
+	$$PRED reduce $(CLI_DEMO_DIR)/mis.json --via $(CLI_DEMO_DIR)/path_mis_qubo.json -o $(CLI_DEMO_DIR)/bundle_via.json; \
+	\
+	echo ""; \
+	echo "--- 16. solve bundle with ILP: MIS → MVC → ILP ---"; \
+	$$PRED reduce $(CLI_DEMO_DIR)/mis.json --to MVC -o $(CLI_DEMO_DIR)/bundle_mvc.json; \
+	$$PRED solve $(CLI_DEMO_DIR)/bundle_mvc.json --solver ilp; \
+	\
+	echo ""; \
+	echo "--- 17. solve: other problem types ---"; \
+	$$PRED solve $(CLI_DEMO_DIR)/sat.json --solver brute-force; \
+	$$PRED solve $(CLI_DEMO_DIR)/kcol.json --solver brute-force; \
+	$$PRED solve $(CLI_DEMO_DIR)/maxcut.json --solver brute-force; \
+	$$PRED solve $(CLI_DEMO_DIR)/mvc.json; \
+	\
+	echo ""; \
+	echo "--- 18. closed-loop: create → reduce → solve → verify ---"; \
+	echo "Creating a 6-vertex graph..."; \
+	$$PRED create MIS --edges 0-1,1-2,2-3,3-4,4-5,0-5,1-4 -o $(CLI_DEMO_DIR)/big.json; \
+	echo "Solving with ILP..."; \
+	$$PRED solve $(CLI_DEMO_DIR)/big.json -o $(CLI_DEMO_DIR)/big_sol.json; \
+	echo "Reducing to QUBO and solving with brute-force..."; \
+	$$PRED reduce $(CLI_DEMO_DIR)/big.json --to QUBO -o $(CLI_DEMO_DIR)/big_qubo.json; \
+	$$PRED solve $(CLI_DEMO_DIR)/big_qubo.json --solver brute-force -o $(CLI_DEMO_DIR)/big_qubo_sol.json; \
+	echo "Verifying both solutions have the same evaluation..."; \
+	ILP_EVAL=$$(jq -r '.evaluation' $(CLI_DEMO_DIR)/big_sol.json); \
+	BF_EVAL=$$(jq -r '.evaluation' $(CLI_DEMO_DIR)/big_qubo_sol.json); \
+	echo "  ILP solution evaluation:         $$ILP_EVAL"; \
+	echo "  Brute-force (via QUBO) evaluation: $$BF_EVAL"; \
+	if [ "$$ILP_EVAL" = "$$BF_EVAL" ]; then \
+		echo "  ✅ Solutions agree!"; \
+	else \
+		echo "  ❌ Solutions disagree!" && exit 1; \
+	fi; \
+	\
+	echo ""; \
+	echo "--- 19. show with alias and variant slash syntax ---"; \
+	$$PRED show MIS/UnitDiskGraph; \
+	\
+	echo ""; \
+	echo "--- 20. completions: generate shell completions ---"; \
+	$$PRED completions bash > /dev/null && echo "bash completions: OK"; \
+	$$PRED completions zsh > /dev/null && echo "zsh completions:  OK"; \
+	$$PRED completions fish > /dev/null && echo "fish completions: OK"; \
+	\
+	echo ""; \
+	echo "=== Demo complete: $$(ls $(CLI_DEMO_DIR)/*.json | wc -l | tr -d ' ') JSON files in $(CLI_DEMO_DIR) ==="
+	@echo "=== All 20 steps passed ✅ ==="

From b375e100a8234e21c31b50400f34759124a48d2d Mon Sep 17 00:00:00 2001
From: GiggleLiu <cacate0129@gmail.com>
Date: Thu, 19 Feb 2026 15:19:25 +0800
Subject: [PATCH 29/34] chore: apply rustfmt formatting

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
 src/rules/qubo_ilp.rs | 18 +++++++++---------
 1 file changed, 9 insertions(+), 9 deletions(-)

diff --git a/src/rules/qubo_ilp.rs b/src/rules/qubo_ilp.rs
index 9f7c3100a..d43ccc930 100644
--- a/src/rules/qubo_ilp.rs
+++ b/src/rules/qubo_ilp.rs
@@ -88,15 +88,9 @@ impl ReduceTo<ILP> for QUBO<f64> {
         for (k, &(i, j, _)) in off_diag.iter().enumerate() {
             let y_k = n + k;
             // y_k ≤ x_i
-            constraints.push(LinearConstraint::le(
-                vec![(y_k, 1.0), (i, -1.0)],
-                0.0,
-            ));
+            constraints.push(LinearConstraint::le(vec![(y_k, 1.0), (i, -1.0)], 0.0));
             // y_k ≤ x_j
-            constraints.push(LinearConstraint::le(
-                vec![(y_k, 1.0), (j, -1.0)],
-                0.0,
-            ));
+            constraints.push(LinearConstraint::le(vec![(y_k, 1.0), (j, -1.0)], 0.0));
             // y_k ≥ x_i + x_j - 1
             constraints.push(LinearConstraint::ge(
                 vec![(y_k, 1.0), (i, -1.0), (j, -1.0)],
@@ -104,7 +98,13 @@ impl ReduceTo<ILP> for QUBO<f64> {
             ));
         }
 
-        let target = ILP::new(total_vars, bounds, constraints, objective, ObjectiveSense::Minimize);
+        let target = ILP::new(
+            total_vars,
+            bounds,
+            constraints,
+            objective,
+            ObjectiveSense::Minimize,
+        );
         ReductionQUBOToILP {
             target,
             num_original: n,

From eeb1968989b1e90247029f17ae554286970fcdb9 Mon Sep 17 00:00:00 2001
From: GiggleLiu <cacate0129@gmail.com>
Date: Thu, 19 Feb 2026 19:34:29 +0800
Subject: [PATCH 30/34] feat(cli): refactor show --hops --direction into pred
 to / pred from commands

Split neighbor exploration out of `pred show` into dedicated subcommands:
- `pred to MIS --hops 2` for outgoing neighbors
- `pred from QUBO --hops 1` for incoming neighbors

Tree output now shows variant-level information (e.g., `MIS {graph=SimpleGraph, weight=i32}`)
instead of just problem names. The `pred show` command is simplified to only inspect
problem details (variants, fields, reductions).

Resolves H2 from the CLI UX improvements audit.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
 .claude/CLAUDE.md                            |   3 +
 Makefile                                     |  10 +-
 docs/plans/2026-02-19-cli-ux-improvements.md | 168 +++++++++++++++++++
 docs/src/cli.md                              | 125 +++++++++++---
 problemreductions-cli/src/cli.rs             |  48 +++++-
 problemreductions-cli/src/commands/graph.rs  |  40 +++--
 problemreductions-cli/src/main.rs            |  10 +-
 problemreductions-cli/tests/cli_tests.rs     |  80 ++++++---
 src/rules/graph.rs                           |   5 +
 9 files changed, 409 insertions(+), 80 deletions(-)
 create mode 100644 docs/plans/2026-02-19-cli-ux-improvements.md

diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md
index 78094db36..3210ce553 100644
--- a/.claude/CLAUDE.md
+++ b/.claude/CLAUDE.md
@@ -27,6 +27,8 @@ make diagrams      # Generate SVG diagrams from Typst (light + dark)
 make examples      # Generate example JSON for paper
 make compare       # Generate and compare Rust mapping exports
 make jl-testdata   # Regenerate Julia parity test data (requires julia)
+make cli           # Build the pred CLI tool (release mode)
+make cli-demo      # Run closed-loop CLI demo (exercises all commands)
 make run-plan      # Execute a plan with Claude autorun
 make release V=x.y.z  # Tag and push a new release (CI publishes to crates.io)
 ```
@@ -48,6 +50,7 @@ make test clippy  # Must pass before PR
 - `src/traits.rs` - `Problem`, `OptimizationProblem`, `SatisfactionProblem` traits
 - `src/rules/traits.rs` - `ReduceTo<T>`, `ReductionResult` traits
 - `src/registry/` - Compile-time reduction metadata collection
+- `problemreductions-cli/` - `pred` CLI tool (separate crate in workspace)
 - `src/unit_tests/` - Unit test files (mirroring `src/` structure, referenced via `#[path]`)
 - `tests/main.rs` - Integration tests (modules in `tests/suites/`); example tests use `include!` for direct invocation (no subprocess)
 - `tests/data/` - Ground truth JSON for integration tests
diff --git a/Makefile b/Makefile
index da1b3fb15..ce0fe49d9 100644
--- a/Makefile
+++ b/Makefile
@@ -218,13 +218,13 @@ cli-demo: cli
 	$$PRED show MIS -o $(CLI_DEMO_DIR)/mis_info.json; \
 	\
 	echo ""; \
-	echo "--- 3. show --hops: explore 2-hop neighborhood ---"; \
-	$$PRED show MIS --hops 2; \
-	$$PRED show MIS --hops 2 -o $(CLI_DEMO_DIR)/mis_hops.json; \
+	echo "--- 3. to: explore 2-hop outgoing neighborhood ---"; \
+	$$PRED to MIS --hops 2; \
+	$$PRED to MIS --hops 2 -o $(CLI_DEMO_DIR)/mis_hops.json; \
 	\
 	echo ""; \
-	echo "--- 4. show --direction: incoming neighbors ---"; \
-	$$PRED show QUBO --hops 1 --direction in; \
+	echo "--- 4. from: incoming neighbors ---"; \
+	$$PRED from QUBO --hops 1; \
 	\
 	echo ""; \
 	echo "--- 5. path: find reduction paths ---"; \
diff --git a/docs/plans/2026-02-19-cli-ux-improvements.md b/docs/plans/2026-02-19-cli-ux-improvements.md
new file mode 100644
index 000000000..e13dae138
--- /dev/null
+++ b/docs/plans/2026-02-19-cli-ux-improvements.md
@@ -0,0 +1,168 @@
+# CLI UX Improvements
+
+Systematic analysis of pitfalls, missing features, and HCI violations.
+Edit this file to approve/reject/modify each item before implementation.
+
+Status legend: `[x]` = approved, `[ ]` = pending, `[-]` = rejected
+
+---
+
+## Pitfalls
+
+### P1. `create` without `-o` discards data
+- [x] approve
+
+**Problem:** `pred create MIS --edges 0-1,1-2` prints `"Created MaximumIndependentSet instance"` but the JSON data is lost. Every other command without `-o` shows useful output. Here the user created something and got nothing back.
+
+**Proposed fix:** Print the problem JSON to stdout when `-o` is not given (consistent with `reduce`'s behavior of printing JSON to stdout).
+
+---
+
+### P2. `reduce` without `-o` outputs raw JSON (inconsistent)
+- [x] approve
+
+**Problem:** Every other command without `-o` shows human-readable text. But `reduce` dumps raw JSON to stdout. This breaks output mode consistency.
+
+**Proposed fix:** Show human-readable summary by default (source, target, steps). Print JSON only when `-o` is given. If users need raw JSON to stdout, add a `--json` flag.
+
+**Comment**: We should allow commands to take JSON inputs from CLI directly to make json output more useful.
+
+---
+
+### P3. `-o` means directory for `path --all`
+- [ ] approve
+
+**Problem:** With `--all`, `-o` is treated as a directory and creates `path_1.json`, `path_2.json`, etc. Every other command treats `-o` as a single file.
+
+**Proposed fix:** When `--all` is used, always write a single JSON file containing an array of all paths. Drop the directory behavior.
+
+**Comment**: What if a user want to specify a specific reduction path?
+
+---
+
+### P4. `export-graph` uses positional arg instead of `-o`
+- [x] approve
+
+**Problem:** Every other command uses `-o` for output files. `export-graph reduction_graph.json` uses a positional arg. Inconsistent.
+
+**Proposed fix:** Change to `pred export-graph -o reduction_graph.json` or `pred export-graph` (defaults to stdout like other commands).
+
+---
+
+### P5. `solve` hint prints every time
+- [x] approve
+
+**Problem:** `"Hint: use -o to save full solution details as JSON."` prints on every invocation without `-o`. Annoying for experienced users and noisy for scripts.
+
+**Proposed fix:** Only show hint when stderr is a TTY (same check as color). Scripts piping stderr won't see it, interactive users still get it.
+
+---
+
+### P6. `solve` ILP auto-reduction is invisible in human output
+- [x] approve
+
+**Problem:** When solving MIS with ILP, the output says `"Solver: ilp"` but doesn't mention it was auto-reduced to ILP. Only the JSON (with `-o`) shows `"reduced_to": "ILP"`. The user has no indication a reduction happened.
+
+**Proposed fix:** Show `"Solver: ilp (via ILP)"` or `"Solver: ilp (auto-reduced to ILP)"` in the human text output when auto-reduction occurs.
+
+---
+
+### P7. No stdin/pipe support
+- [x] approve
+
+**Problem:** `solve`, `evaluate`, `reduce` all require file paths. The Unix idiom of `cmd1 | cmd2` doesn't work. Users must always create intermediate files.
+
+**Proposed fix:** Accept `-` as input to read from stdin: `pred create MIS --edges 0-1,1-2 | pred solve -`. This is a standard Unix convention.
+
+---
+
+### P8. `create Factoring` is a dead end
+- [ ] approve
+
+**Problem:** `pred create Factoring` gives `"Factoring requires complex construction — use a JSON file instead"` but doesn't say what the JSON format should be.
+
+**Proposed fix:** Add a `pred show Factoring` schema reference in the error message: `"See pred show Factoring for the expected JSON format, or check the documentation."`.
+
+**Comment**: We should allow users to create complex problems from CLI directly.
+
+---
+
+## Features
+
+### F1. `pred inspect <file>`
+- [x] approve
+
+**Problem:** Given a problem JSON or bundle, users must manually read the JSON to know what's inside.
+
+**Proposed feature:** Show what type it is, its size (number of variables, edges, etc.), variant, available solvers, and possible reductions. Example:
+```
+$ pred inspect problem.json
+Type: MaximumIndependentSet {graph=SimpleGraph, weight=i32}
+Size: 5 vertices, 5 edges
+Solvers: ilp (default), brute-force
+Reduces to: ILP, MinimumVertexCover, QUBO, MaximumSetPacking
+```
+
+---
+
+### F2. Quiet mode (`-q`)
+- [x] approve
+
+**Problem:** No way to suppress hints and informational stderr messages for scripting.
+
+**Proposed feature:** Add `-q` / `--quiet` global flag that suppresses hints and informational messages on stderr. Only errors go to stderr in quiet mode.
+
+---
+
+### F3. `pred solve --reduce-via <target>`
+- [ ] approve
+
+**Problem:** Solving via a specific reduction requires two commands and an intermediate file: `pred reduce ... -o bundle.json && pred solve bundle.json`.
+
+**Proposed feature:** `pred solve problem.json --reduce-via QUBO --solver brute-force` combines reduce + solve in one step, avoiding the intermediate file.
+
+**Comment**: Reject, automated find path finding is tricky, not useful to automate at the current stage.
+
+---
+
+### F4. `pred create --random`
+- [x] approve
+
+**Problem:** No way to generate random problem instances for benchmarking or testing.
+
+**Proposed feature:** `pred create MIS --random --num-vertices 100 --edge-prob 0.3 -o big.json`. Support random generation for graph-based problems with configurable size and density.
+
+---
+
+## HCI Violations
+
+### H1. Inconsistent error guidance
+- [x] approve
+
+**Problem:** Some errors give excellent guidance (`path` no-path-found shows `pred show` hints), while others are bare (`show Foobar` gives just `"Error: Unknown problem: Foobar"` without suggesting `pred list` or fuzzy matches).
+
+**Proposed fix:** Add `"Did you mean ...?"` fuzzy matching for unknown problem names. Always suggest `pred list` when a problem name is not recognized.
+
+---
+
+### H2. `--direction` is a raw string, not a clap enum
+- [x] approve
+
+**Problem:** `--direction` accepts any string and validates at runtime. Invalid values give a custom error, but clap's built-in validation (with auto-completion and help listing) would be better.
+
+**Comment**: Redesign. Maybe use `pred from MIS --hops 2` and `pred to MIS --hops 2` to make it more clear.
+
+**Resolution:** Replaced `pred show --hops --direction` with dedicated `pred to` and `pred from` subcommands. Direction is now determined by the command itself (no runtime string validation). Tree output shows variant-level information (`ProblemName {key=val}`).
+
+---
+
+### H3. No progress feedback for long operations
+- [ ] approve
+
+**Problem:** Brute-force on large instances or multi-step reductions give no feedback until completion. The user doesn't know if the tool is working or stuck.
+
+**Proposed fix:** Show a brief progress line on stderr for brute-force (e.g., `"Exploring 2^20 configurations..."`) and for multi-step reductions (e.g., `"Step 1/3: MIS → MVC..."`).
+
+**Comment**: Not very useful. Consider allowing users to add a time limit for job.
+
+---
diff --git a/docs/src/cli.md b/docs/src/cli.md
index 706cb4c88..9a52ddf63 100644
--- a/docs/src/cli.md
+++ b/docs/src/cli.md
@@ -54,8 +54,19 @@ pred evaluate problem.json --config 1,0,1,0
 # Reduce to another problem type and solve via brute-force
 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 -
 ```
 
+## Global Flags
+
+| Flag | Description |
+|------|-------------|
+| `-o, --output <FILE>` | Save JSON output to a file |
+| `-q, --quiet` | Suppress informational messages on stderr |
+
 ## Commands
 
 ### `pred list` — List all problem types
@@ -124,30 +135,42 @@ Reduces from (9):
   ...
 ```
 
-Explore neighbors within k hops in the reduction graph:
+### `pred to` — Explore outgoing neighbors
 
-```bash
-$ pred show MIS --hops 2
-MaximumIndependentSet — 2-hop neighbors (outgoing)
+Explore which problems a given problem can reduce **to** within k hops. Each node in the tree shows its variant (graph type, weight type, etc.).
 
-MaximumIndependentSet
-├── MaximumIndependentSet
-└── MaximumIndependentSet
-    ├── ILP
-    ├── MaximumIndependentSet
-    ├── MaximumSetPacking
-    ├── MinimumVertexCover
-    └── QUBO
+```bash
+$ pred to MIS --hops 2
+MaximumIndependentSet {graph=SimpleGraph, weight=i32} — 2-hop neighbors (outgoing)
+
+MaximumIndependentSet {graph=SimpleGraph, weight=i32}
+├── ILP (default)
+├── MaximumIndependentSet {graph=KingsSubgraph, weight=i32}
+│   └── MaximumIndependentSet {graph=SimpleGraph, weight=i32}
+├── MaximumIndependentSet {graph=TriangularSubgraph, weight=i32}
+│   └── MaximumIndependentSet {graph=SimpleGraph, weight=i32}
+├── MinimumVertexCover {graph=SimpleGraph, weight=i32}
+│   ├── ILP (default)
+│   └── MaximumIndependentSet {graph=SimpleGraph, weight=i32}
+└── QUBO {weight=f64}
 
 5 reachable problems in 2 hops
 ```
 
-Use `--direction` to control traversal direction:
+### `pred from` — Explore incoming neighbors
+
+Explore which problems can reduce **from** (i.e., reduce into) the given problem:
 
 ```bash
-pred show MIS --hops 2 --direction out    # outgoing neighbors (default)
-pred show QUBO --hops 1 --direction in    # incoming neighbors
-pred show MIS --hops 1 --direction both   # both directions
+$ pred from QUBO --hops 1
+QUBO {weight=f64} — 1-hop neighbors (incoming)
+
+QUBO {weight=f64}
+├── MaximumIndependentSet {graph=SimpleGraph, weight=i32}
+├── MinimumVertexCover {graph=SimpleGraph, weight=i32}
+└── SpinGlass {graph=SimpleGraph, weight=f64}
+
+3 reachable problems in 1 hops
 ```
 
 ### `pred path` — Find a reduction path
@@ -199,7 +222,8 @@ Use `pred show <problem>` to see which size fields are available.
 Export the full reduction graph as JSON:
 
 ```bash
-pred export-graph reduction_graph.json
+pred export-graph                           # print to stdout
+pred export-graph -o reduction_graph.json   # save to file
 ```
 
 ### `pred create` — Create a problem instance
@@ -216,6 +240,21 @@ pred create SpinGlass --edges 0-1,1-2 -o sg.json
 pred create MaxCut --edges 0-1,1-2,2-0 -o maxcut.json
 ```
 
+Generate random instances for graph-based problems:
+
+```bash
+pred create MIS --random --num-vertices 10 --edge-prob 0.3
+pred create MIS --random --num-vertices 100 --seed 42 -o big.json
+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 --random --num-vertices 10 | pred inspect -
+```
+
 The output file uses a standard wrapper format:
 
 ```json
@@ -235,6 +274,29 @@ $ pred evaluate problem.json --config 1,0,1,0
 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 inspect` — Inspect a problem file
+
+Show a summary of what's inside a problem JSON or reduction bundle:
+
+```bash
+$ pred inspect problem.json
+Type: MaximumIndependentSet {graph=SimpleGraph, weight=i32}
+Size: 5 vertices, 5 edges
+```
+
+Works with reduction bundles and stdin:
+
+```bash
+pred inspect bundle.json
+pred create MIS --edges 0-1,1-2 | pred inspect -
+```
+
 ### `pred reduce` — Reduce a problem
 
 Reduce a problem to a target type. Outputs a reduction bundle containing source, target, and path:
@@ -249,10 +311,17 @@ Use a specific reduction path (from `pred path -o`). The target is inferred from
 pred reduce problem.json --via path.json -o reduced.json
 ```
 
-Without `-o`, the bundle JSON is printed to stdout:
+Without `-o`, a human-readable summary is shown. Use `--json` to output raw JSON to stdout:
 
 ```bash
-pred reduce problem.json --to QUBO
+pred reduce problem.json --to QUBO          # human-readable summary
+pred reduce problem.json --to QUBO --json   # raw JSON to stdout
+```
+
+Stdin is supported with `-`:
+
+```bash
+pred create MIS --edges 0-1,1-2,2-3 | pred reduce - --to QUBO
 ```
 
 The bundle contains everything needed to map solutions back:
@@ -277,7 +346,14 @@ pred solve problem.json                         # ILP solver (default)
 pred solve problem.json --solver brute-force    # brute-force solver
 ```
 
-When the problem is not ILP, the solver automatically reduces it to ILP, solves, and maps the solution back:
+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
+```
+
+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:
 
 ```bash
 $ pred solve problem.json
@@ -344,3 +420,12 @@ You can use short aliases instead of full problem names (shown in `pred list`):
 | `TSP` | `TravelingSalesman` |
 
 You can also specify variants with a slash: `MIS/UnitDiskGraph`, `SpinGlass/SimpleGraph`.
+
+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.
+```
diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs
index 12ff47c52..225b476eb 100644
--- a/problemreductions-cli/src/cli.rs
+++ b/problemreductions-cli/src/cli.rs
@@ -51,20 +51,48 @@ Examples:
   pred show MIS                   # using alias
   pred show MaximumIndependentSet # full name
   pred show MIS/UnitDiskGraph     # specific graph variant
-  pred show MIS --hops 2          # 2-hop outgoing neighbor tree
-  pred show MIS --hops 2 --direction in  # incoming neighbors
 
-Use `pred list` to see all available problem types and aliases.")]
+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.")]
     Show {
         /// Problem name or alias (e.g., MIS, QUBO, MIS/UnitDiskGraph)
         #[arg(value_parser = crate::problem_name::ProblemNameParser)]
         problem: String,
-        /// Explore k-hop neighbors in the reduction graph
-        #[arg(long)]
-        hops: Option<usize>,
-        /// Direction for neighbor exploration: out, in, both [default: out]
-        #[arg(long, default_value = "out")]
-        direction: String,
+    },
+
+    /// Explore outgoing neighbors in the reduction graph (problems this reduces TO)
+    #[command(after_help = "\
+Examples:
+  pred to MIS              # 1-hop outgoing neighbors
+  pred to MIS --hops 2     # 2-hop outgoing neighbors
+  pred to MIS -o out.json  # save as JSON
+
+Use `pred from <problem>` for incoming neighbors.")]
+    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]
+        #[arg(long, default_value = "1")]
+        hops: usize,
+    },
+
+    /// Explore incoming neighbors in the reduction graph (problems that reduce FROM this)
+    #[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
+
+Use `pred to <problem>` for outgoing neighbors.")]
+    From {
+        /// 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]
+        #[arg(long, default_value = "1")]
+        hops: usize,
     },
 
     /// Find the cheapest reduction path between two problems
@@ -292,6 +320,8 @@ pub fn print_subcommand_help_hint(error_msg: &str) {
         ("pred inspect", "inspect"),
         ("pred path", "path"),
         ("pred show", "show"),
+        ("pred to", "to"),
+        ("pred from", "from"),
         ("pred export-graph", "export-graph"),
     ];
     let cmd = Cli::command();
diff --git a/problemreductions-cli/src/commands/graph.rs b/problemreductions-cli/src/commands/graph.rs
index f9b91e6f2..82fef2d9c 100644
--- a/problemreductions-cli/src/commands/graph.rs
+++ b/problemreductions-cli/src/commands/graph.rs
@@ -96,7 +96,7 @@ pub fn list(out: &OutputConfig) -> Result<()> {
     out.emit_with_default_name("pred_graph_list.json", &text, &json)
 }
 
-pub fn show(problem: &str, hops: Option<usize>, direction: &str, out: &OutputConfig) -> Result<()> {
+pub fn show(problem: &str, out: &OutputConfig) -> Result<()> {
     let spec = parse_problem_spec(problem)?;
     let graph = ReductionGraph::new();
 
@@ -105,10 +105,6 @@ pub fn show(problem: &str, hops: Option<usize>, direction: &str, out: &OutputCon
         anyhow::bail!("{}", crate::problem_name::unknown_problem_error(&spec.name));
     }
 
-    if let Some(max_hops) = hops {
-        return show_neighbors(&graph, &spec, &variants, max_hops, direction, out);
-    }
-
     let mut text = format!("{}\n", crate::output::fmt_problem_name(&spec.name));
 
     // Show description from schema
@@ -498,20 +494,26 @@ fn parse_direction(s: &str) -> Result<TraversalDirection> {
     }
 }
 
-fn show_neighbors(
-    graph: &ReductionGraph,
-    spec: &crate::problem_name::ProblemSpec,
-    variants: &[BTreeMap<String, String>],
+pub fn neighbors(
+    problem: &str,
     max_hops: usize,
     direction_str: &str,
     out: &OutputConfig,
 ) -> Result<()> {
+    let spec = parse_problem_spec(problem)?;
+    let graph = ReductionGraph::new();
+
+    let variants = graph.variants_for(&spec.name);
+    if variants.is_empty() {
+        anyhow::bail!("{}", crate::problem_name::unknown_problem_error(&spec.name));
+    }
+
     let direction = parse_direction(direction_str)?;
 
     let variant = if spec.variant_values.is_empty() {
         variants[0].clone()
     } else {
-        resolve_variant(spec, variants)?
+        resolve_variant(&spec, &variants)?
     };
 
     let neighbors = graph.k_neighbors(&spec.name, &variant, max_hops, direction);
@@ -523,7 +525,13 @@ fn show_neighbors(
     };
 
     // Build tree structure via BFS with parent tracking
-    let tree = build_neighbor_tree(graph, &spec.name, &variant, max_hops, direction);
+    let tree = build_neighbor_tree(&graph, &spec.name, &variant, max_hops, direction);
+
+    let root_label = format!(
+        "{} {}",
+        crate::output::fmt_problem_name(&spec.name),
+        crate::output::fmt_dim(&format_variant(&variant)),
+    );
 
     let mut text = format!(
         "{} — {}-hop neighbors ({})\n\n",
@@ -532,7 +540,7 @@ fn show_neighbors(
         dir_label,
     );
 
-    text.push_str(&crate::output::fmt_problem_name(&spec.name));
+    text.push_str(&root_label);
     text.push('\n');
     render_tree(&tree, &mut text, "");
 
@@ -557,13 +565,14 @@ fn show_neighbors(
         }).collect::<Vec<_>>(),
     });
 
-    let default_name = format!("pred_show_{}_hops{}.json", spec.name, max_hops);
+    let default_name = format!("pred_{}_{}_{}.json", direction_str, spec.name, max_hops);
     out.emit_with_default_name(&default_name, &text, &json)
 }
 
 /// Tree node for neighbor rendering.
 struct TreeNode {
     name: String,
+    variant: BTreeMap<String, String>,
     children: Vec<TreeNode>,
 }
 
@@ -637,6 +646,7 @@ fn build_neighbor_tree(
             .unwrap_or_default();
         TreeNode {
             name: graph.node_name(idx).to_string(),
+            variant: graph.node_variant(idx).clone(),
             children,
         }
     }
@@ -658,11 +668,13 @@ fn render_tree(nodes: &[TreeNode], text: &mut String, prefix: &str) {
         let connector = if is_last { "└── " } else { "├── " };
         let child_prefix = if is_last { "    " } else { "│   " };
 
+        let variant_str = format_variant(&node.variant);
         text.push_str(&format!(
-            "{}{}{}\n",
+            "{}{}{} {}\n",
             crate::output::fmt_dim(prefix),
             crate::output::fmt_dim(connector),
             crate::output::fmt_problem_name(&node.name),
+            crate::output::fmt_dim(&variant_str),
         ));
 
         if !node.children.is_empty() {
diff --git a/problemreductions-cli/src/main.rs b/problemreductions-cli/src/main.rs
index e9f96c4e8..b98738ae1 100644
--- a/problemreductions-cli/src/main.rs
+++ b/problemreductions-cli/src/main.rs
@@ -33,11 +33,11 @@ fn main() -> anyhow::Result<()> {
 
     match cli.command {
         Commands::List => commands::graph::list(&out),
-        Commands::Show {
-            problem,
-            hops,
-            direction,
-        } => commands::graph::show(&problem, hops, &direction, &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::Path {
             source,
             target,
diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs
index 6a3813317..1ca5d6d30 100644
--- a/problemreductions-cli/tests/cli_tests.rs
+++ b/problemreductions-cli/tests/cli_tests.rs
@@ -1566,12 +1566,12 @@ fn test_completions_auto_detect() {
     assert!(stdout.contains("pred"));
 }
 
-// ---- k-neighbor exploration tests ----
+// ---- k-neighbor exploration tests (pred to / pred from) ----
 
 #[test]
-fn test_show_hops_outgoing() {
+fn test_to_outgoing() {
     let output = pred()
-        .args(["show", "MIS", "--hops", "2"])
+        .args(["to", "MIS", "--hops", "2"])
         .output()
         .unwrap();
     assert!(
@@ -1587,9 +1587,9 @@ fn test_show_hops_outgoing() {
 }
 
 #[test]
-fn test_show_hops_incoming() {
+fn test_from_incoming() {
     let output = pred()
-        .args(["show", "QUBO", "--hops", "1", "--direction", "in"])
+        .args(["from", "QUBO", "--hops", "1"])
         .output()
         .unwrap();
     assert!(
@@ -1603,9 +1603,26 @@ fn test_show_hops_incoming() {
 }
 
 #[test]
-fn test_show_hops_both() {
+fn test_to_json() {
+    let tmp = std::env::temp_dir().join("pred_test_to_hops.json");
     let output = pred()
-        .args(["show", "MIS", "--hops", "1", "--direction", "both"])
+        .args(["-o", tmp.to_str().unwrap(), "to", "MIS", "--hops", "2"])
+        .output()
+        .unwrap();
+    assert!(output.status.success());
+    assert!(tmp.exists());
+    let content = std::fs::read_to_string(&tmp).unwrap();
+    let json: serde_json::Value = serde_json::from_str(&content).unwrap();
+    assert_eq!(json["source"], "MaximumIndependentSet");
+    assert_eq!(json["hops"], 2);
+    assert!(json["neighbors"].is_array());
+    std::fs::remove_file(&tmp).ok();
+}
+
+#[test]
+fn test_to_shows_variant_info() {
+    let output = pred()
+        .args(["to", "MIS", "--hops", "1"])
         .output()
         .unwrap();
     assert!(
@@ -1614,35 +1631,44 @@ fn test_show_hops_both() {
         String::from_utf8_lossy(&output.stderr)
     );
     let stdout = String::from_utf8(output.stdout).unwrap();
-    assert!(stdout.contains("both directions"));
+    // Variant info should appear in the tree output
+    assert!(
+        stdout.contains("{graph=") || stdout.contains("(default)"),
+        "expected variant info in tree output, got: {stdout}"
+    );
 }
 
 #[test]
-fn test_show_hops_json() {
-    let tmp = std::env::temp_dir().join("pred_test_show_hops.json");
+fn test_from_shows_variant_info() {
     let output = pred()
-        .args(["-o", tmp.to_str().unwrap(), "show", "MIS", "--hops", "2"])
+        .args(["from", "QUBO", "--hops", "1"])
         .output()
         .unwrap();
-    assert!(output.status.success());
-    assert!(tmp.exists());
-    let content = std::fs::read_to_string(&tmp).unwrap();
-    let json: serde_json::Value = serde_json::from_str(&content).unwrap();
-    assert_eq!(json["source"], "MaximumIndependentSet");
-    assert_eq!(json["hops"], 2);
-    assert!(json["neighbors"].is_array());
-    std::fs::remove_file(&tmp).ok();
+    assert!(
+        output.status.success(),
+        "stderr: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+    let stdout = String::from_utf8(output.stdout).unwrap();
+    // Variant info should appear in the tree output
+    assert!(
+        stdout.contains("{graph=") || stdout.contains("{weight=") || stdout.contains("(default)"),
+        "expected variant info in tree output, got: {stdout}"
+    );
 }
 
 #[test]
-fn test_show_hops_bad_direction() {
-    let output = pred()
-        .args(["show", "MIS", "--hops", "1", "--direction", "bad"])
-        .output()
-        .unwrap();
-    assert!(!output.status.success());
-    let stderr = String::from_utf8_lossy(&output.stderr);
-    assert!(stderr.contains("Unknown direction"));
+fn test_to_default_hops() {
+    // Default --hops is 1
+    let output = pred().args(["to", "MIS"]).output().unwrap();
+    assert!(
+        output.status.success(),
+        "stderr: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+    let stdout = String::from_utf8(output.stdout).unwrap();
+    assert!(stdout.contains("1-hop"));
+    assert!(stdout.contains("reachable problems"));
 }
 
 // ---- Quiet mode tests ----
diff --git a/src/rules/graph.rs b/src/rules/graph.rs
index 2c1642a79..618ce6c99 100644
--- a/src/rules/graph.rs
+++ b/src/rules/graph.rs
@@ -744,6 +744,11 @@ impl ReductionGraph {
         self.nodes[self.graph[idx]].name
     }
 
+    /// Get the variant map for a node index.
+    pub fn node_variant(&self, idx: NodeIndex) -> &BTreeMap<String, String> {
+        &self.nodes[self.graph[idx]].variant
+    }
+
     /// Find all problems reachable within `max_hops` edges from a starting node.
     ///
     /// Returns neighbors sorted by (hops, name). The starting node itself is excluded.

From ae52ee6b711f7a32264b3e5e39c29f8900718698 Mon Sep 17 00:00:00 2001
From: GiggleLiu <cacate0129@gmail.com>
Date: Thu, 19 Feb 2026 20:56:10 +0800
Subject: [PATCH 31/34] feat(cli): add create Factoring and solve --timeout

- P8: Support `pred create Factoring --target 15 --bits-m 4 --bits-n 4`
  with all three flags required (replaces unhelpful bail message)
- H3: Add `--timeout <seconds>` to `pred solve` using thread+channel
  pattern (default 0 = no limit, process exits on timeout)
- Add 6 CLI tests for Factoring creation and timeout behavior
- Update cli-demo, docs, and audit file

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
 Makefile                                     |   2 +
 docs/plans/2026-02-19-cli-ux-improvements.md |   8 +-
 docs/src/cli.md                              |   3 +
 problemreductions-cli/src/cli.rs             |  19 +++
 problemreductions-cli/src/commands/create.rs |  13 +-
 problemreductions-cli/src/commands/solve.rs  |  40 +++--
 problemreductions-cli/src/main.rs            |   4 +-
 problemreductions-cli/tests/cli_tests.rs     | 166 +++++++++++++++++++
 8 files changed, 241 insertions(+), 14 deletions(-)

diff --git a/Makefile b/Makefile
index ce0fe49d9..d6e048a55 100644
--- a/Makefile
+++ b/Makefile
@@ -254,6 +254,8 @@ cli-demo: cli
 	$$PRED create MaxCut --edges 0-1,1-2,2-0 -o $(CLI_DEMO_DIR)/maxcut.json; \
 	$$PRED create MVC --edges 0-1,1-2,2-3 -o $(CLI_DEMO_DIR)/mvc.json; \
 	$$PRED create MaximumMatching --edges 0-1,1-2,2-3 -o $(CLI_DEMO_DIR)/matching.json; \
+	$$PRED create Factoring --target 15 --bits-m 4 --bits-n 4 -o $(CLI_DEMO_DIR)/factoring.json; \
+	$$PRED create Factoring --target 21 --bits-m 3 --bits-n 3 -o $(CLI_DEMO_DIR)/factoring2.json; \
 	\
 	echo ""; \
 	echo "--- 9. evaluate: test configurations ---"; \
diff --git a/docs/plans/2026-02-19-cli-ux-improvements.md b/docs/plans/2026-02-19-cli-ux-improvements.md
index e13dae138..cc0e60c86 100644
--- a/docs/plans/2026-02-19-cli-ux-improvements.md
+++ b/docs/plans/2026-02-19-cli-ux-improvements.md
@@ -77,7 +77,7 @@ Status legend: `[x]` = approved, `[ ]` = pending, `[-]` = rejected
 ---
 
 ### P8. `create Factoring` is a dead end
-- [ ] approve
+- [x] approve
 
 **Problem:** `pred create Factoring` gives `"Factoring requires complex construction — use a JSON file instead"` but doesn't say what the JSON format should be.
 
@@ -85,6 +85,8 @@ Status legend: `[x]` = approved, `[ ]` = pending, `[-]` = rejected
 
 **Comment**: We should allow users to create complex problems from CLI directly.
 
+**Resolution:** Added `--target`, `--bits-m`, `--bits-n` flags to `pred create Factoring`. All three are required. Usage: `pred create Factoring --target 15 --bits-m 4 --bits-n 4`.
+
 ---
 
 ## Features
@@ -157,7 +159,7 @@ Reduces to: ILP, MinimumVertexCover, QUBO, MaximumSetPacking
 ---
 
 ### H3. No progress feedback for long operations
-- [ ] approve
+- [x] approve
 
 **Problem:** Brute-force on large instances or multi-step reductions give no feedback until completion. The user doesn't know if the tool is working or stuck.
 
@@ -165,4 +167,6 @@ Reduces to: ILP, MinimumVertexCover, QUBO, MaximumSetPacking
 
 **Comment**: Not very useful. Consider allowing users to add a time limit for job.
 
+**Resolution:** Added `--timeout <seconds>` flag to `pred solve`. When set, the solver is run on a separate thread with a timeout. Default is 0 (no limit). Usage: `pred solve problem.json --timeout 30`.
+
 ---
diff --git a/docs/src/cli.md b/docs/src/cli.md
index 9a52ddf63..56c998e51 100644
--- a/docs/src/cli.md
+++ b/docs/src/cli.md
@@ -238,6 +238,8 @@ 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 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
 ```
 
 Generate random instances for graph-based problems:
@@ -344,6 +346,7 @@ Solve a problem instance using ILP (default) or brute-force:
 ```bash
 pred solve problem.json                         # ILP solver (default)
 pred solve problem.json --solver brute-force    # brute-force solver
+pred solve problem.json --timeout 30            # abort after 30 seconds
 ```
 
 Stdin is supported with `-`:
diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs
index 225b476eb..4a727ca01 100644
--- a/problemreductions-cli/src/cli.rs
+++ b/problemreductions-cli/src/cli.rs
@@ -175,6 +175,11 @@ Options by problem type:
     --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]
+
 Random generation (graph-based problems only):
   --random        Generate a random Erdos-Renyi graph instance
   --num-vertices  Number of vertices [required with --random]
@@ -189,6 +194,7 @@ Examples:
   pred create KColoring --k 3 --edges 0-1,1-2,2-0 -o kcol.json
   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
 
 Output (`-o`) uses the standard problem JSON format:
   {\"type\": \"...\", \"variant\": {...}, \"data\": {...}}")]
@@ -226,6 +232,15 @@ pub struct CreateArgs {
     /// Random seed for reproducibility
     #[arg(long)]
     pub seed: Option<u64>,
+    /// Target number to factor (for Factoring)
+    #[arg(long)]
+    pub target: Option<u64>,
+    /// Bits for first factor (for Factoring)
+    #[arg(long)]
+    pub bits_m: Option<usize>,
+    /// Bits for second factor (for Factoring)
+    #[arg(long)]
+    pub bits_n: Option<usize>,
 }
 
 #[derive(clap::Args)]
@@ -236,6 +251,7 @@ Examples:
   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 solve problem.json --timeout 10           # abort after 10 seconds
 
 Typical workflow:
   pred create MIS --edges 0-1,1-2,2-3 -o problem.json
@@ -259,6 +275,9 @@ pub struct SolveArgs {
     /// Solver: ilp (default) or brute-force
     #[arg(long, default_value = "ilp")]
     pub solver: String,
+    /// Timeout in seconds (0 = no limit) [default: 0]
+    #[arg(long, default_value = "0")]
+    pub timeout: u64,
 }
 
 #[derive(clap::Args)]
diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs
index 9776a3544..8ee6b6fc2 100644
--- a/problemreductions-cli/src/commands/create.rs
+++ b/problemreductions-cli/src/commands/create.rs
@@ -154,7 +154,18 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> {
 
         // Factoring
         "Factoring" => {
-            bail!("Factoring requires complex construction — use a JSON file instead");
+            let usage = "Usage: pred create Factoring --target 15 --bits-m 4 --bits-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}")
+            })?;
+            let n = args.bits_n.ok_or_else(|| {
+                anyhow::anyhow!("Factoring requires --bits-n\n\n{usage}")
+            })?;
+            let variant = BTreeMap::new();
+            (ser(Factoring::new(m, n, target))?, variant)
         }
 
         _ => bail!("{}", crate::problem_name::unknown_problem_error(&canonical)),
diff --git a/problemreductions-cli/src/commands/solve.rs b/problemreductions-cli/src/commands/solve.rs
index c8a9279e1..1a06352c4 100644
--- a/problemreductions-cli/src/commands/solve.rs
+++ b/problemreductions-cli/src/commands/solve.rs
@@ -3,6 +3,7 @@ use crate::output::OutputConfig;
 use anyhow::{Context, Result};
 use problemreductions::rules::ReductionGraph;
 use std::path::Path;
+use std::time::Duration;
 
 /// Input can be either a problem JSON or a reduction bundle JSON.
 enum SolveInput {
@@ -28,7 +29,7 @@ fn parse_input(path: &Path) -> Result<SolveInput> {
     }
 }
 
-pub fn solve(input: &Path, solver_name: &str, out: &OutputConfig) -> Result<()> {
+pub fn solve(input: &Path, solver_name: &str, timeout: u64, out: &OutputConfig) -> Result<()> {
     if solver_name != "brute-force" && solver_name != "ilp" {
         anyhow::bail!(
             "Unknown solver: {}. Available solvers: brute-force, ilp",
@@ -38,15 +39,34 @@ pub fn solve(input: &Path, solver_name: &str, out: &OutputConfig) -> Result<()>
 
     let parsed = parse_input(input)?;
 
-    match parsed {
-        SolveInput::Problem(problem_json) => solve_problem(
-            &problem_json.problem_type,
-            &problem_json.variant,
-            problem_json.data,
-            solver_name,
-            out,
-        ),
-        SolveInput::Bundle(bundle) => solve_bundle(bundle, solver_name, out),
+    if timeout > 0 {
+        let solver_name = solver_name.to_string();
+        let out = out.clone();
+        let (tx, rx) = std::sync::mpsc::channel();
+        std::thread::spawn(move || {
+            let result = match parsed {
+                SolveInput::Problem(pj) => solve_problem(
+                    &pj.problem_type,
+                    &pj.variant,
+                    pj.data,
+                    &solver_name,
+                    &out,
+                ),
+                SolveInput::Bundle(b) => solve_bundle(b, &solver_name, &out),
+            };
+            tx.send(result).ok();
+        });
+        match rx.recv_timeout(Duration::from_secs(timeout)) {
+            Ok(result) => result,
+            Err(_) => anyhow::bail!("Solve timed out after {} seconds", timeout),
+        }
+    } else {
+        match parsed {
+            SolveInput::Problem(pj) => {
+                solve_problem(&pj.problem_type, &pj.variant, pj.data, solver_name, out)
+            }
+            SolveInput::Bundle(b) => solve_bundle(b, solver_name, out),
+        }
     }
 }
 
diff --git a/problemreductions-cli/src/main.rs b/problemreductions-cli/src/main.rs
index b98738ae1..41a7b66ae 100644
--- a/problemreductions-cli/src/main.rs
+++ b/problemreductions-cli/src/main.rs
@@ -47,7 +47,9 @@ fn main() -> anyhow::Result<()> {
         Commands::ExportGraph => commands::graph::export(&out),
         Commands::Inspect(args) => commands::inspect::inspect(&args.input, &out),
         Commands::Create(args) => commands::create::create(&args, &out),
-        Commands::Solve(args) => commands::solve::solve(&args.input, &args.solver, &out),
+        Commands::Solve(args) => {
+            commands::solve::solve(&args.input, &args.solver, args.timeout, &out)
+        }
         Commands::Reduce(args) => commands::reduce::reduce(
             &args.input,
             args.to.as_deref(),
diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs
index 1ca5d6d30..15388da52 100644
--- a/problemreductions-cli/tests/cli_tests.rs
+++ b/problemreductions-cli/tests/cli_tests.rs
@@ -2346,3 +2346,169 @@ fn test_create_random_default_edge_prob() {
     let json: serde_json::Value = serde_json::from_str(&stdout).unwrap();
     assert_eq!(json["type"], "MaximumIndependentSet");
 }
+
+// ---- Factoring create tests (P8) ----
+
+#[test]
+fn test_create_factoring() {
+    let output = pred()
+        .args([
+            "create",
+            "Factoring",
+            "--target",
+            "15",
+            "--bits-m",
+            "4",
+            "--bits-n",
+            "4",
+        ])
+        .output()
+        .unwrap();
+    assert!(
+        output.status.success(),
+        "stderr: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+    let stdout = String::from_utf8(output.stdout).unwrap();
+    let json: serde_json::Value = serde_json::from_str(&stdout).unwrap();
+    assert_eq!(json["type"], "Factoring");
+    assert!(json["data"].is_object());
+}
+
+#[test]
+fn test_create_factoring_with_bits() {
+    let output_file = std::env::temp_dir().join("pred_test_create_factoring.json");
+    let output = pred()
+        .args([
+            "-o",
+            output_file.to_str().unwrap(),
+            "create",
+            "Factoring",
+            "--target",
+            "15",
+            "--bits-m",
+            "4",
+            "--bits-n",
+            "4",
+        ])
+        .output()
+        .unwrap();
+    assert!(
+        output.status.success(),
+        "stderr: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+    assert!(output_file.exists());
+    let content = std::fs::read_to_string(&output_file).unwrap();
+    let json: serde_json::Value = serde_json::from_str(&content).unwrap();
+    assert_eq!(json["type"], "Factoring");
+    std::fs::remove_file(&output_file).ok();
+}
+
+#[test]
+fn test_create_factoring_missing_target() {
+    let output = pred().args(["create", "Factoring"]).output().unwrap();
+    assert!(!output.status.success());
+    let stderr = String::from_utf8_lossy(&output.stderr);
+    assert!(
+        stderr.contains("--target"),
+        "expected '--target' in error, got: {stderr}"
+    );
+}
+
+#[test]
+fn test_create_factoring_missing_bits() {
+    let output = pred()
+        .args(["create", "Factoring", "--target", "15"])
+        .output()
+        .unwrap();
+    assert!(!output.status.success());
+    let stderr = String::from_utf8_lossy(&output.stderr);
+    assert!(
+        stderr.contains("--bits-m"),
+        "expected '--bits-m' in error, got: {stderr}"
+    );
+}
+
+// ---- Timeout tests (H3) ----
+
+#[test]
+fn test_solve_timeout_succeeds() {
+    // Small problem with generous timeout should succeed
+    let problem_file = std::env::temp_dir().join("pred_test_solve_timeout.json");
+    let create_out = pred()
+        .args([
+            "-o",
+            problem_file.to_str().unwrap(),
+            "create",
+            "MIS",
+            "--edges",
+            "0-1,1-2",
+        ])
+        .output()
+        .unwrap();
+    assert!(create_out.status.success());
+
+    let output = pred()
+        .args([
+            "solve",
+            problem_file.to_str().unwrap(),
+            "--solver",
+            "brute-force",
+            "--timeout",
+            "30",
+        ])
+        .output()
+        .unwrap();
+    assert!(
+        output.status.success(),
+        "stderr: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+    let stdout = String::from_utf8(output.stdout).unwrap();
+    assert!(
+        stdout.contains("Solution"),
+        "expected Solution in stdout, got: {stdout}"
+    );
+
+    std::fs::remove_file(&problem_file).ok();
+}
+
+#[test]
+fn test_solve_timeout_zero_means_no_limit() {
+    // --timeout 0 is the default (no limit), should work normally
+    let problem_file = std::env::temp_dir().join("pred_test_solve_timeout0.json");
+    let create_out = pred()
+        .args([
+            "-o",
+            problem_file.to_str().unwrap(),
+            "create",
+            "MIS",
+            "--edges",
+            "0-1,1-2",
+        ])
+        .output()
+        .unwrap();
+    assert!(create_out.status.success());
+
+    let output = pred()
+        .args([
+            "solve",
+            problem_file.to_str().unwrap(),
+            "--solver",
+            "brute-force",
+            "--timeout",
+            "0",
+        ])
+        .output()
+        .unwrap();
+    assert!(
+        output.status.success(),
+        "stderr: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+    let stdout = String::from_utf8(output.stdout).unwrap();
+    assert!(stdout.contains("Solution"));
+
+    std::fs::remove_file(&problem_file).ok();
+}

From 902d31c493b76e82e7339a78335551c016898a5a Mon Sep 17 00:00:00 2001
From: GiggleLiu <cacate0129@gmail.com>
Date: Thu, 19 Feb 2026 21:36:53 +0800
Subject: [PATCH 32/34] refactor: unify name+variant formatting and move
 tree-building to core

- Add fmt_node() as single source of truth for "bold name + plain variant"
- Move neighbor tree BFS from CLI into ReductionGraph::k_neighbor_tree()
- Add NeighborTree type to core library
- Remove petgraph-exposing helpers (find_node_index, neighbor_indices, etc.)
- Remove petgraph dependency from CLI crate
- Fix path header not formatting names as bold

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
 problemreductions-cli/Cargo.toml            |   1 -
 problemreductions-cli/src/commands/graph.rs | 161 +++++---------------
 src/rules/graph.rs                          | 122 ++++++++++++---
 src/rules/mod.rs                            |   4 +-
 4 files changed, 139 insertions(+), 149 deletions(-)

diff --git a/problemreductions-cli/Cargo.toml b/problemreductions-cli/Cargo.toml
index 652361e49..ab83efe08 100644
--- a/problemreductions-cli/Cargo.toml
+++ b/problemreductions-cli/Cargo.toml
@@ -26,4 +26,3 @@ serde = { version = "1", features = ["derive"] }
 serde_json = "1"
 clap_complete = "4"
 owo-colors = { version = "4", features = ["supports-colors"] }
-petgraph = "0.8"
diff --git a/problemreductions-cli/src/commands/graph.rs b/problemreductions-cli/src/commands/graph.rs
index 82fef2d9c..ef2a79126 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, HashMap, HashSet};
+use std::collections::{BTreeMap, HashSet};
 
 pub fn list(out: &OutputConfig) -> Result<()> {
     use crate::output::{format_table, Align};
@@ -68,7 +68,7 @@ pub fn list(out: &OutputConfig) -> Result<()> {
 
     let color_fns: Vec<Option<crate::output::CellFormatter>> = vec![
         Some(crate::output::fmt_problem_name),
-        Some(crate::output::fmt_dim),
+        None,
         None,
         None,
     ];
@@ -162,12 +162,10 @@ pub fn show(problem: &str, out: &OutputConfig) -> Result<()> {
     ));
     for e in &outgoing {
         text.push_str(&format!(
-            "  {} {} {} {} {}\n",
-            e.source_name,
-            format_variant(&e.source_variant),
+            "  {} {} {}\n",
+            fmt_node(e.source_name, &e.source_variant),
             crate::output::fmt_outgoing("\u{2192}"),
-            crate::output::fmt_problem_name(e.target_name),
-            format_variant(&e.target_variant),
+            fmt_node(e.target_name, &e.target_variant),
         ));
     }
 
@@ -177,12 +175,10 @@ pub fn show(problem: &str, out: &OutputConfig) -> Result<()> {
     ));
     for e in &incoming {
         text.push_str(&format!(
-            "  {} {} {} {} {}\n",
-            e.target_name,
-            format_variant(&e.target_variant),
+            "  {} {} {}\n",
+            fmt_node(e.target_name, &e.target_variant),
             crate::output::fmt_outgoing("\u{2190}"),
-            crate::output::fmt_problem_name(e.source_name),
-            format_variant(&e.source_variant),
+            fmt_node(e.source_name, &e.source_variant),
         ));
     }
 
@@ -216,15 +212,34 @@ fn format_variant(v: &BTreeMap<String, String>) -> String {
     }
 }
 
+/// 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, String>) -> String {
+    format!(
+        "{} {}",
+        crate::output::fmt_problem_name(name),
+        format_variant(variant),
+    )
+}
+
 fn format_path_text(
     graph: &ReductionGraph,
     reduction_path: &problemreductions::rules::ReductionPath,
 ) -> String {
-    let mut text = format!(
-        "Path ({} steps): {}\n",
-        reduction_path.len(),
-        reduction_path
-    );
+    // Build formatted path header: Name {v} → Name {v} → ...
+    let path_summary = {
+        let steps = &reduction_path.steps;
+        let mut parts = Vec::new();
+        let mut prev_name = "";
+        for step in steps {
+            if step.name != prev_name {
+                parts.push(fmt_node(&step.name, &step.variant));
+                prev_name = &step.name;
+            }
+        }
+        parts.join(&format!(" {} ", crate::output::fmt_outgoing("→")))
+    };
+    let mut text = format!("Path ({} steps): {}\n", reduction_path.len(), path_summary);
 
     let overheads = graph.path_overheads(reduction_path);
     let steps = &reduction_path.steps;
@@ -234,9 +249,9 @@ fn format_path_text(
         text.push_str(&format!(
             "\n  {}: {} {} {}\n",
             crate::output::fmt_section(&format!("Step {}", i + 1)),
-            crate::output::fmt_problem_name(&from.to_string()),
+            fmt_node(&from.name, &from.variant),
             crate::output::fmt_outgoing("→"),
-            crate::output::fmt_problem_name(&to.to_string()),
+            fmt_node(&to.name, &to.variant),
         ));
         let oh = &overheads[i];
         for (field, poly) in &oh.output_size {
@@ -525,13 +540,9 @@ pub fn neighbors(
     };
 
     // Build tree structure via BFS with parent tracking
-    let tree = build_neighbor_tree(&graph, &spec.name, &variant, max_hops, direction);
+    let tree = graph.k_neighbor_tree(&spec.name, &variant, max_hops, direction);
 
-    let root_label = format!(
-        "{} {}",
-        crate::output::fmt_problem_name(&spec.name),
-        crate::output::fmt_dim(&format_variant(&variant)),
-    );
+    let root_label = fmt_node(&spec.name, &variant);
 
     let mut text = format!(
         "{} — {}-hop neighbors ({})\n\n",
@@ -569,112 +580,20 @@ pub fn neighbors(
     out.emit_with_default_name(&default_name, &text, &json)
 }
 
-/// Tree node for neighbor rendering.
-struct TreeNode {
-    name: String,
-    variant: BTreeMap<String, String>,
-    children: Vec<TreeNode>,
-}
-
-/// Build a tree of neighbors via BFS, tracking parent relationships.
-fn build_neighbor_tree(
-    graph: &ReductionGraph,
-    name: &str,
-    variant: &BTreeMap<String, String>,
-    max_hops: usize,
-    direction: TraversalDirection,
-) -> Vec<TreeNode> {
-    use std::collections::VecDeque;
-
-    let Some(start_idx) = graph.find_node_index(name, variant) else {
-        return vec![];
-    };
-
-    let mut visited: HashSet<petgraph::graph::NodeIndex> = HashSet::new();
-    visited.insert(start_idx);
-
-    let mut queue: VecDeque<(petgraph::graph::NodeIndex, usize)> = VecDeque::new();
-    queue.push_back((start_idx, 0));
-
-    // Map from node_idx -> children node indices
-    let mut node_children: HashMap<petgraph::graph::NodeIndex, Vec<petgraph::graph::NodeIndex>> =
-        HashMap::new();
-
-    while let Some((node_idx, depth)) = queue.pop_front() {
-        if depth >= max_hops {
-            continue;
-        }
-
-        let directions: Vec<petgraph::Direction> = match direction {
-            TraversalDirection::Outgoing => vec![petgraph::Direction::Outgoing],
-            TraversalDirection::Incoming => vec![petgraph::Direction::Incoming],
-            TraversalDirection::Both => {
-                vec![petgraph::Direction::Outgoing, petgraph::Direction::Incoming]
-            }
-        };
-
-        let mut children = Vec::new();
-        for dir in directions {
-            for neighbor_idx in graph.neighbor_indices(node_idx, dir) {
-                if visited.insert(neighbor_idx) {
-                    children.push(neighbor_idx);
-                    queue.push_back((neighbor_idx, depth + 1));
-                }
-            }
-        }
-        children.sort_by(|a, b| {
-            let na = graph.node_name(*a);
-            let nb = graph.node_name(*b);
-            na.cmp(nb)
-        });
-        node_children.insert(node_idx, children);
-    }
-
-    // Recursively build TreeNode from start's children
-    fn build_tree(
-        idx: petgraph::graph::NodeIndex,
-        node_children: &HashMap<petgraph::graph::NodeIndex, Vec<petgraph::graph::NodeIndex>>,
-        graph: &ReductionGraph,
-    ) -> TreeNode {
-        let children = node_children
-            .get(&idx)
-            .map(|cs| {
-                cs.iter()
-                    .map(|&c| build_tree(c, node_children, graph))
-                    .collect()
-            })
-            .unwrap_or_default();
-        TreeNode {
-            name: graph.node_name(idx).to_string(),
-            variant: graph.node_variant(idx).clone(),
-            children,
-        }
-    }
-
-    node_children
-        .get(&start_idx)
-        .map(|cs| {
-            cs.iter()
-                .map(|&c| build_tree(c, &node_children, graph))
-                .collect()
-        })
-        .unwrap_or_default()
-}
+use problemreductions::rules::NeighborTree;
 
 /// Render a tree with box-drawing characters.
-fn render_tree(nodes: &[TreeNode], text: &mut String, prefix: &str) {
+fn render_tree(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 { "├── " };
         let child_prefix = if is_last { "    " } else { "│   " };
 
-        let variant_str = format_variant(&node.variant);
         text.push_str(&format!(
-            "{}{}{} {}\n",
+            "{}{}{}\n",
             crate::output::fmt_dim(prefix),
             crate::output::fmt_dim(connector),
-            crate::output::fmt_problem_name(&node.name),
-            crate::output::fmt_dim(&variant_str),
+            fmt_node(&node.name, &node.variant),
         ));
 
         if !node.children.is_empty() {
diff --git a/src/rules/graph.rs b/src/rules/graph.rs
index 618ce6c99..a1c5c7464 100644
--- a/src/rules/graph.rs
+++ b/src/rules/graph.rs
@@ -231,6 +231,17 @@ pub enum TraversalDirection {
     Both,
 }
 
+/// A tree node for neighbor traversal results.
+#[derive(Debug, Clone)]
+pub struct NeighborTree {
+    /// Problem name.
+    pub name: String,
+    /// Variant attributes.
+    pub variant: BTreeMap<String, String>,
+    /// Child nodes (sorted by name).
+    pub children: Vec<NeighborTree>,
+}
+
 /// Validate that a reduction's overhead variables are consistent with source/target size names.
 ///
 /// Checks:
@@ -725,30 +736,6 @@ impl ReductionGraph {
             .collect()
     }
 
-    /// Find the NodeIndex for a specific (name, variant) pair.
-    pub fn find_node_index(
-        &self,
-        name: &str,
-        variant: &BTreeMap<String, String>,
-    ) -> Option<NodeIndex> {
-        self.lookup_node(name, variant)
-    }
-
-    /// Get neighbors of a node in a specific direction.
-    pub fn neighbor_indices(&self, idx: NodeIndex, dir: petgraph::Direction) -> Vec<NodeIndex> {
-        self.graph.neighbors_directed(idx, dir).collect()
-    }
-
-    /// Get the problem name for a node index.
-    pub fn node_name(&self, idx: NodeIndex) -> &str {
-        self.nodes[self.graph[idx]].name
-    }
-
-    /// Get the variant map for a node index.
-    pub fn node_variant(&self, idx: NodeIndex) -> &BTreeMap<String, String> {
-        &self.nodes[self.graph[idx]].variant
-    }
-
     /// Find all problems reachable within `max_hops` edges from a starting node.
     ///
     /// Returns neighbors sorted by (hops, name). The starting node itself is excluded.
@@ -762,7 +749,7 @@ impl ReductionGraph {
     ) -> Vec<NeighborInfo> {
         use std::collections::VecDeque;
 
-        let Some(start_idx) = self.find_node_index(name, variant) else {
+        let Some(start_idx) = self.lookup_node(name, variant) else {
             return vec![];
         };
 
@@ -803,6 +790,91 @@ impl ReductionGraph {
         results.sort_by(|a, b| a.hops.cmp(&b.hops).then_with(|| a.name.cmp(b.name)));
         results
     }
+
+    /// Build a tree of neighbors via BFS with parent tracking.
+    ///
+    /// Returns the children of the starting node as a forest of `NeighborTree` nodes.
+    /// Each node appears at most once (shortest-path tree). Children are sorted by name.
+    pub fn k_neighbor_tree(
+        &self,
+        name: &str,
+        variant: &BTreeMap<String, String>,
+        max_hops: usize,
+        direction: TraversalDirection,
+    ) -> Vec<NeighborTree> {
+        use std::collections::VecDeque;
+
+        let Some(start_idx) = self.lookup_node(name, variant) else {
+            return vec![];
+        };
+
+        let mut visited: HashSet<NodeIndex> = HashSet::new();
+        visited.insert(start_idx);
+
+        let mut queue: VecDeque<(NodeIndex, usize)> = VecDeque::new();
+        queue.push_back((start_idx, 0));
+
+        // Map from node_idx -> children node indices
+        let mut node_children: HashMap<NodeIndex, Vec<NodeIndex>> = HashMap::new();
+
+        while let Some((node_idx, depth)) = queue.pop_front() {
+            if depth >= max_hops {
+                continue;
+            }
+
+            let directions: Vec<petgraph::Direction> = match direction {
+                TraversalDirection::Outgoing => vec![petgraph::Direction::Outgoing],
+                TraversalDirection::Incoming => vec![petgraph::Direction::Incoming],
+                TraversalDirection::Both => {
+                    vec![petgraph::Direction::Outgoing, petgraph::Direction::Incoming]
+                }
+            };
+
+            let mut children = Vec::new();
+            for dir in directions {
+                for neighbor_idx in self.graph.neighbors_directed(node_idx, dir) {
+                    if visited.insert(neighbor_idx) {
+                        children.push(neighbor_idx);
+                        queue.push_back((neighbor_idx, depth + 1));
+                    }
+                }
+            }
+            children.sort_by(|a, b| {
+                self.nodes[self.graph[*a]]
+                    .name
+                    .cmp(self.nodes[self.graph[*b]].name)
+            });
+            node_children.insert(node_idx, children);
+        }
+
+        // Recursively build NeighborTree from BFS parent map.
+        fn build(
+            idx: NodeIndex,
+            node_children: &HashMap<NodeIndex, Vec<NodeIndex>>,
+            nodes: &[VariantNode],
+            graph: &DiGraph<usize, ReductionEdgeData>,
+        ) -> NeighborTree {
+            let children = node_children
+                .get(&idx)
+                .map(|cs| cs.iter().map(|&c| build(c, node_children, nodes, graph)).collect())
+                .unwrap_or_default();
+            let node = &nodes[graph[idx]];
+            NeighborTree {
+                name: node.name.to_string(),
+                variant: node.variant.clone(),
+                children,
+            }
+        }
+
+        node_children
+            .get(&start_idx)
+            .map(|cs| {
+                cs.iter()
+                    .map(|&c| build(c, &node_children, &self.nodes, &self.graph))
+                    .collect()
+            })
+            .unwrap_or_default()
+    }
 }
 
 impl Default for ReductionGraph {
diff --git a/src/rules/mod.rs b/src/rules/mod.rs
index aaa2664d3..146a537fe 100644
--- a/src/rules/mod.rs
+++ b/src/rules/mod.rs
@@ -65,8 +65,8 @@ mod travelingsalesman_ilp;
 #[cfg(test)]
 pub(crate) use graph::validate_overhead_variables;
 pub use graph::{
-    NeighborInfo, ReductionChain, ReductionEdgeInfo, ReductionGraph, ReductionPath, ReductionStep,
-    TraversalDirection,
+    NeighborInfo, NeighborTree, ReductionChain, ReductionEdgeInfo, ReductionGraph, ReductionPath,
+    ReductionStep, TraversalDirection,
 };
 pub use traits::{ReduceTo, ReductionAutoCast, ReductionResult};
 

From 2d7d31141c0519960e8aabc3360df62b80032487 Mon Sep 17 00:00:00 2001
From: GiggleLiu <cacate0129@gmail.com>
Date: Thu, 19 Feb 2026 22:02:37 +0800
Subject: [PATCH 33/34] chore: remove completed CLI plan files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
 .../2026-02-18-cli-v2-features-design.md      |  227 ----
 .../plans/2026-02-18-cli-v2-implementation.md | 1136 -----------------
 docs/plans/2026-02-19-cli-ux-improvements.md  |  172 ---
 3 files changed, 1535 deletions(-)
 delete mode 100644 docs/plans/2026-02-18-cli-v2-features-design.md
 delete mode 100644 docs/plans/2026-02-18-cli-v2-implementation.md
 delete mode 100644 docs/plans/2026-02-19-cli-ux-improvements.md

diff --git a/docs/plans/2026-02-18-cli-v2-features-design.md b/docs/plans/2026-02-18-cli-v2-features-design.md
deleted file mode 100644
index 5dcfaa308..000000000
--- a/docs/plans/2026-02-18-cli-v2-features-design.md
+++ /dev/null
@@ -1,227 +0,0 @@
-# CLI v2 Features Design
-
-Tracking issue: #81
-
-## Overview
-
-Three features for the `pred` CLI tool, building on the v1 foundation:
-
-1. **k-Neighbor Exploration** — discover nearby problems in the reduction graph
-2. **Colored Output** — terminal colors and aligned formatting
-3. **Shell Completions** — bash/zsh/fish auto-completion
-
-## Feature 1: k-Neighbor Exploration
-
-### UX
-
-Extend the existing `pred show` command with `--hops` and `--direction` flags:
-
-```
-pred show MIS --hops 2                     # 2-hop outgoing tree (default)
-pred show MIS --hops 2 --direction in      # 2-hop incoming tree
-pred show MIS --hops 2 --direction both    # both directions
-pred show MIS --hops 3 -o neighbors.json   # JSON output
-```
-
-When `--hops` is not specified, `pred show` behaves exactly as v1 (no tree, just 1-hop lists).
-
-### Output Format — Tree View
-
-```
-MaximumIndependentSet — 2-hop neighbors (outgoing)
-
-MaximumIndependentSet
-├── QUBO
-│   ├── SpinGlass
-│   └── ILP
-├── MinimumVertexCover
-│   ├── ILP
-│   └── MinimumSetCovering
-└── MaxCut
-    └── SpinGlass
-
-6 reachable problems in 2 hops
-```
-
-Tree characters: `├──`, `└──`, `│   `, `    ` (4-char indentation per level).
-
-Deduplicated: if a problem appears at multiple hop distances, show it at the shortest distance only. Mention the total unique count at the bottom.
-
-### JSON Output
-
-When `-o` is specified:
-
-```json
-{
-  "source": "MaximumIndependentSet",
-  "hops": 2,
-  "direction": "out",
-  "neighbors": [
-    {"name": "QUBO", "variant": {}, "hops": 1},
-    {"name": "SpinGlass", "variant": {}, "hops": 2},
-    {"name": "ILP", "variant": {}, "hops": 2},
-    {"name": "MinimumVertexCover", "variant": {"graph": "SimpleGraph", "weight": "One"}, "hops": 1},
-    {"name": "MinimumSetCovering", "variant": {}, "hops": 2},
-    {"name": "MaxCut", "variant": {}, "hops": 1}
-  ]
-}
-```
-
-### Library Change
-
-Add to `ReductionGraph` in `src/rules/graph.rs`:
-
-```rust
-pub struct NeighborInfo {
-    pub name: &'static str,
-    pub variant: BTreeMap<String, String>,
-    pub hops: usize,
-}
-
-pub enum TraversalDirection {
-    Outgoing,
-    Incoming,
-    Both,
-}
-
-pub fn k_neighbors(
-    &self,
-    name: &str,
-    variant: &BTreeMap<String, String>,
-    max_hops: usize,
-    direction: TraversalDirection,
-) -> Vec<NeighborInfo>
-```
-
-Implementation: BFS on the petgraph `DiGraph`, following edges in the specified direction. Track visited nodes to avoid cycles. Return nodes sorted by (hops, name).
-
-### CLI Changes
-
-In `cli.rs`, add to `Commands::Show`:
-
-```rust
-/// Explore k-hop neighbors in the reduction graph
-#[arg(long)]
-hops: Option<usize>,
-
-/// Direction for neighbor exploration: out, in, both [default: out]
-#[arg(long, default_value = "out")]
-direction: String,
-```
-
-In `commands/graph.rs`, when `hops` is `Some(k)`, call the new library method and render as a tree instead of the existing show output.
-
-## Feature 2: Colored Output
-
-### Dependency
-
-Add `owo-colors` to `problemreductions-cli/Cargo.toml`:
-
-```toml
-owo-colors = { version = "4", features = ["supports-colors"] }
-```
-
-### Color Scheme
-
-| Element | Style | Example |
-|---------|-------|---------|
-| Problem names | Bold | **MaximumIndependentSet** |
-| Section headers | Cyan | Variants (4): |
-| Outgoing arrows `→` | Green | → QUBO |
-| Incoming arrows `←` | Red | ← Satisfiability |
-| Hop distance | Yellow | (2 hops) |
-| Tree branches | Dim | `├──` `└──` `│` |
-| Aliases | Dim | (MIS, mis) |
-| Error messages | Red bold | Error: unknown problem |
-
-### Color Respect
-
-- Detect terminal capability via `owo-colors`'s `supports-color` feature
-- Respect `NO_COLOR` environment variable (https://no-color.org/)
-- JSON output (`-o`) is never colored
-- Piped output (non-TTY stdout) disables colors automatically
-
-### Aligned Columns for `pred list`
-
-Hand-format with `format!("{:<width$}")`:
-
-```
-Registered problems: 20 types, 35 reductions, 42 variant nodes
-
-  Problem                    Aliases    Variants  Reduces to
-  ─────────────────────────  ─────────  ────────  ──────────
-  BicliqueCover                              1          2
-  MaximumIndependentSet      MIS             4          5
-  QUBO                                       1          0
-```
-
-Column widths computed dynamically from the longest problem name.
-
-## Feature 3: Shell Completions
-
-### Dependency
-
-Add `clap_complete` to `problemreductions-cli/Cargo.toml`:
-
-```toml
-clap_complete = "4"
-```
-
-### UX
-
-New subcommand:
-
-```
-pred completions bash    # output bash completions to stdout
-pred completions zsh     # output zsh completions to stdout
-pred completions fish    # output fish completions to stdout
-```
-
-Users pipe to their shell config:
-
-```bash
-pred completions bash > ~/.local/share/bash-completion/completions/pred
-pred completions zsh > ~/.zfunc/_pred
-pred completions fish > ~/.config/fish/completions/pred.fish
-```
-
-### CLI Changes
-
-In `cli.rs`:
-
-```rust
-/// Generate shell completions
-Completions {
-    /// Shell type: bash, zsh, fish
-    shell: clap_complete::Shell,
-},
-```
-
-In `main.rs`, the handler calls `clap_complete::generate()` with the Cli command factory, writing to stdout.
-
-## Dependencies Summary
-
-| Crate | Version | Purpose | Transitive deps |
-|-------|---------|---------|----------------|
-| `owo-colors` | 4.x | Terminal colors | 1-2 (tiny) |
-| `clap_complete` | 4.x | Shell completions | 0 (clap already present) |
-
-## Implementation Order
-
-1. **Shell completions** — smallest, self-contained, quick win
-2. **Colored output** — add `owo-colors`, apply colors across all existing commands
-3. **k-neighbor exploration** — largest: library method + CLI flag + tree renderer
-
-Each feature is independently shippable and testable.
-
-## Testing
-
-- **Shell completions:** integration test that runs `pred completions bash` and checks output contains expected completion markers
-- **Colored output:** unit tests for the formatting functions (test the text content, not ANSI codes); manual visual verification
-- **k-neighbors:** unit test for `ReductionGraph::k_neighbors` with known graph topology; integration test for `pred show MIS --hops 2` output structure
-
-## Out of Scope
-
-- Interactive REPL mode (deferred to v3+)
-- Table library dependency (hand-formatted alignment is sufficient)
-- Dynamic problem-name completion (would require runtime invocation; deferred)
diff --git a/docs/plans/2026-02-18-cli-v2-implementation.md b/docs/plans/2026-02-18-cli-v2-implementation.md
deleted file mode 100644
index 127059edc..000000000
--- a/docs/plans/2026-02-18-cli-v2-implementation.md
+++ /dev/null
@@ -1,1136 +0,0 @@
-# CLI v2 Features Implementation Plan
-
-> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
-
-**Goal:** Add shell completions, colored terminal output, and k-neighbor graph exploration to the `pred` CLI tool.
-
-**Architecture:** Three independent features built on the existing `problemreductions-cli` crate. Shell completions and colored output are CLI-only changes. k-neighbor exploration requires a new BFS method in the core library's `ReductionGraph` plus a tree renderer in the CLI.
-
-**Tech Stack:** `clap_complete 4` (shell completions), `owo-colors 4` with `supports-colors` feature (terminal colors), `petgraph` BFS (already a transitive dep via `ReductionGraph`)
-
----
-
-### Task 1: Add shell completions — dependencies and CLI enum
-
-**Files:**
-- Modify: `problemreductions-cli/Cargo.toml`
-- Modify: `problemreductions-cli/src/cli.rs`
-
-**Step 1: Add `clap_complete` dependency**
-
-In `problemreductions-cli/Cargo.toml`, add to `[dependencies]`:
-
-```toml
-clap_complete = "4"
-```
-
-**Step 2: Add `Completions` variant to `Commands` enum**
-
-In `problemreductions-cli/src/cli.rs`, add a new variant to the `Commands` enum (after `Solve`):
-
-```rust
-/// Generate shell completions for bash, zsh, fish, etc.
-#[command(after_help = "\
-Examples:
-  pred completions bash > ~/.local/share/bash-completion/completions/pred
-  pred completions zsh > ~/.zfunc/_pred
-  pred completions fish > ~/.config/fish/completions/pred.fish")]
-Completions {
-    /// Shell type
-    shell: clap_complete::Shell,
-},
-```
-
-**Step 3: Wire up the handler in `main.rs`**
-
-In `problemreductions-cli/src/main.rs`, add the match arm:
-
-```rust
-Commands::Completions { shell } => {
-    let mut cmd = Cli::command();
-    clap_complete::generate(shell, &mut cmd, "pred", &mut std::io::stdout());
-    Ok(())
-}
-```
-
-Also add `use clap::CommandFactory;` at the top of main.rs (needed for `.command()`).
-
-**Step 4: Build and verify**
-
-Run: `cargo build -p problemreductions-cli`
-Expected: Compiles successfully.
-
-Run: `cargo run -p problemreductions-cli -- completions bash | head -5`
-Expected: Outputs bash completion script starting with `_pred()` or similar.
-
-**Step 5: Commit**
-
-```
-feat(cli): add shell completions command (bash/zsh/fish)
-```
-
----
-
-### Task 2: Add shell completions — integration test
-
-**Files:**
-- Modify: `problemreductions-cli/tests/cli_tests.rs`
-
-**Step 1: Write the test**
-
-Add to `problemreductions-cli/tests/cli_tests.rs`:
-
-```rust
-#[test]
-fn test_completions_bash() {
-    let output = pred().args(["completions", "bash"]).output().unwrap();
-    assert!(
-        output.status.success(),
-        "stderr: {}",
-        String::from_utf8_lossy(&output.stderr)
-    );
-    let stdout = String::from_utf8(output.stdout).unwrap();
-    // Bash completions should reference the binary name
-    assert!(stdout.contains("pred"), "completions should reference 'pred'");
-}
-
-#[test]
-fn test_completions_zsh() {
-    let output = pred().args(["completions", "zsh"]).output().unwrap();
-    assert!(output.status.success());
-    let stdout = String::from_utf8(output.stdout).unwrap();
-    assert!(stdout.contains("pred"));
-}
-
-#[test]
-fn test_completions_fish() {
-    let output = pred().args(["completions", "fish"]).output().unwrap();
-    assert!(output.status.success());
-    let stdout = String::from_utf8(output.stdout).unwrap();
-    assert!(stdout.contains("pred"));
-}
-```
-
-**Step 2: Run tests**
-
-Run: `cargo test -p problemreductions-cli test_completions`
-Expected: All 3 tests pass.
-
-**Step 3: Commit**
-
-```
-test(cli): add integration tests for shell completions
-```
-
----
-
-### Task 3: Add colored output — dependency and color helper module
-
-**Files:**
-- Modify: `problemreductions-cli/Cargo.toml`
-- Modify: `problemreductions-cli/src/output.rs`
-
-**Step 1: Add `owo-colors` dependency**
-
-In `problemreductions-cli/Cargo.toml`, add to `[dependencies]`:
-
-```toml
-owo-colors = { version = "4", features = ["supports-colors"] }
-```
-
-**Step 2: Add color helper functions to `output.rs`**
-
-In `problemreductions-cli/src/output.rs`, add color formatting helpers after the existing `OutputConfig` impl:
-
-```rust
-use owo_colors::OwoColorize;
-use std::io::IsTerminal;
-
-/// Whether colored output should be used (TTY + not NO_COLOR).
-pub fn use_color() -> bool {
-    std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none()
-}
-
-/// Format a problem name (bold when color is enabled).
-pub fn fmt_problem_name(name: &str) -> String {
-    if use_color() {
-        format!("{}", name.bold())
-    } else {
-        name.to_string()
-    }
-}
-
-/// Format a section header (cyan when color is enabled).
-pub fn fmt_section(text: &str) -> String {
-    if use_color() {
-        format!("{}", text.cyan())
-    } else {
-        text.to_string()
-    }
-}
-
-/// Format an outgoing arrow (green when color is enabled).
-pub fn fmt_arrow_out() -> &'static str {
-    // We return static str, so we use ANSI directly for the arrow
-    "→"
-}
-
-pub fn fmt_outgoing(text: &str) -> String {
-    if use_color() {
-        format!("{}", text.green())
-    } else {
-        text.to_string()
-    }
-}
-
-pub fn fmt_incoming(text: &str) -> String {
-    if use_color() {
-        format!("{}", text.red())
-    } else {
-        text.to_string()
-    }
-}
-
-/// Format dim text (for aliases, tree branches).
-pub fn fmt_dim(text: &str) -> String {
-    if use_color() {
-        format!("{}", text.dimmed())
-    } else {
-        text.to_string()
-    }
-}
-```
-
-**Step 3: Build**
-
-Run: `cargo build -p problemreductions-cli`
-Expected: Compiles.
-
-**Step 4: Commit**
-
-```
-feat(cli): add owo-colors dependency and color helper functions
-```
-
----
-
-### Task 4: Apply colors to `pred list` with aligned columns
-
-**Files:**
-- Modify: `problemreductions-cli/src/commands/graph.rs`
-
-**Step 1: Rewrite `list()` to use aligned columns and colors**
-
-Replace the body of `pub fn list(out: &OutputConfig)` in `problemreductions-cli/src/commands/graph.rs`. The new version:
-- Computes column widths dynamically
-- Shows a header row with separator line
-- Shows variant count and outgoing reduction count per problem
-- Uses `fmt_problem_name`, `fmt_section`, `fmt_dim` from `output.rs`
-
-```rust
-pub fn list(out: &OutputConfig) -> Result<()> {
-    let graph = ReductionGraph::new();
-
-    let mut types = graph.problem_types();
-    types.sort();
-
-    // Collect data for each problem
-    struct Row {
-        name: String,
-        aliases: Vec<&'static str>,
-        num_variants: usize,
-        num_reduces_to: usize,
-    }
-    let rows: Vec<Row> = types
-        .iter()
-        .map(|name| {
-            let aliases = aliases_for(name);
-            let num_variants = graph.variants_for(name).len();
-            let num_reduces_to = graph.outgoing_reductions(name).len();
-            Row {
-                name: name.to_string(),
-                aliases,
-                num_variants,
-                num_reduces_to,
-            }
-        })
-        .collect();
-
-    // Compute column widths
-    let name_width = rows.iter().map(|r| r.name.len()).max().unwrap_or(7).max(7);
-    let alias_width = rows
-        .iter()
-        .map(|r| {
-            if r.aliases.is_empty() {
-                0
-            } else {
-                r.aliases.join(", ").len()
-            }
-        })
-        .max()
-        .unwrap_or(7)
-        .max(7);
-
-    let summary = format!(
-        "Registered problems: {} types, {} reductions, {} variant nodes\n",
-        graph.num_types(),
-        graph.num_reductions(),
-        graph.num_variant_nodes(),
-    );
-
-    let mut text = String::new();
-    text.push_str(&crate::output::fmt_section(&summary));
-    text.push_str(&format!(
-        "\n  {:<name_w$}  {:<alias_w$}  {:>8}  {:>10}\n",
-        "Problem",
-        "Aliases",
-        "Variants",
-        "Reduces to",
-        name_w = name_width,
-        alias_w = alias_width,
-    ));
-    text.push_str(&format!(
-        "  {:<name_w$}  {:<alias_w$}  {:>8}  {:>10}\n",
-        "─".repeat(name_width),
-        "─".repeat(alias_width),
-        "────────",
-        "──────────",
-        name_w = name_width,
-        alias_w = alias_width,
-    ));
-
-    for row in &rows {
-        let alias_str = if row.aliases.is_empty() {
-            String::new()
-        } else {
-            row.aliases.join(", ")
-        };
-        text.push_str(&format!(
-            "  {:<name_w$}  {:<alias_w$}  {:>8}  {:>10}\n",
-            crate::output::fmt_problem_name(&row.name),
-            crate::output::fmt_dim(&alias_str),
-            row.num_variants,
-            row.num_reduces_to,
-            name_w = name_width,
-            alias_w = alias_width,
-        ));
-    }
-
-    text.push_str(&format!(
-        "\nUse `pred show <problem>` to see variants, reductions, and fields.\n"
-    ));
-
-    let json = serde_json::json!({
-        "num_types": graph.num_types(),
-        "num_reductions": graph.num_reductions(),
-        "num_variant_nodes": graph.num_variant_nodes(),
-        "problems": rows.iter().map(|r| {
-            serde_json::json!({
-                "name": r.name,
-                "aliases": r.aliases,
-                "num_variants": r.num_variants,
-                "num_reduces_to": r.num_reduces_to,
-            })
-        }).collect::<Vec<_>>(),
-    });
-
-    out.emit_with_default_name("pred_graph_list.json", &text, &json)
-}
-```
-
-Note: When colors are enabled, ANSI escape codes in `fmt_problem_name` will make the string longer than the visible width. To handle this correctly, compute the padding based on the raw name length, not the colored string length. The approach above passes `name_w` based on the raw `name_width`, and `format!("{:<name_w$}")` will pad based on byte length. Since ANSI codes add invisible bytes, the colored name will already be wider — so we need to adjust. A simpler approach: format the padding with the raw name, then replace the raw name with the colored version. Or: only apply color after padding.
-
-**Refined approach for color+padding:** Format the plain-text column first, then apply color to the name portion only:
-
-```rust
-let padded_name = format!("{:<name_w$}", row.name, name_w = name_width);
-let colored_name = crate::output::fmt_problem_name(&padded_name);
-```
-
-This works because `fmt_problem_name` wraps the already-padded string in bold ANSI codes.
-
-**Step 2: Build and run**
-
-Run: `cargo run -p problemreductions-cli -- list`
-Expected: Aligned columns with bold problem names (if terminal supports color).
-
-**Step 3: Commit**
-
-```
-feat(cli): add aligned columns and color to pred list
-```
-
----
-
-### Task 5: Apply colors to `pred show`
-
-**Files:**
-- Modify: `problemreductions-cli/src/commands/graph.rs`
-
-**Step 1: Add colors to `show()` function**
-
-In the `show()` function in `problemreductions-cli/src/commands/graph.rs`, apply color helpers:
-- Problem name line: `fmt_problem_name`
-- Section headers ("Variants", "Fields", "Size fields", "Reduces to", "Reduces from"): `fmt_section`
-- Outgoing reduction lines: `fmt_outgoing` on the arrow/target
-- Incoming reduction lines: `fmt_incoming` on the arrow/source
-- Alias list: `fmt_dim`
-
-The changes are straightforward string formatting replacements. For example:
-
-```rust
-// Before:
-let mut text = format!("{}\n", spec.name);
-// After:
-let mut text = format!("{}\n", crate::output::fmt_problem_name(&spec.name));
-
-// Before:
-text.push_str(&format!("\nVariants ({}):\n", variants.len()));
-// After:
-text.push_str(&format!("\n{}\n", crate::output::fmt_section(&format!("Variants ({}):", variants.len()))));
-
-// Before (outgoing):
-text.push_str(&format!(
-    "  {} {} -> {} {}\n",
-    e.source_name, ..., e.target_name, ...
-));
-// After:
-text.push_str(&format!(
-    "  {} {} {} {} {}\n",
-    e.source_name,
-    format_variant(&e.source_variant),
-    crate::output::fmt_outgoing("→"),
-    crate::output::fmt_problem_name(e.target_name),
-    format_variant(&e.target_variant),
-));
-```
-
-Similar pattern for incoming reductions using `fmt_incoming`.
-
-**Step 2: Build and verify**
-
-Run: `cargo run -p problemreductions-cli -- show MIS`
-Expected: Colored output with bold name, cyan headers, green outgoing arrows, red incoming arrows.
-
-**Step 3: Run existing tests**
-
-Run: `cargo test -p problemreductions-cli`
-Expected: All existing tests still pass. (Tests check for content like "MaximumIndependentSet" and "Reduces to" — these strings are still present, possibly wrapped in ANSI codes. Since tests run in a non-TTY pipe, `use_color()` returns false and no ANSI codes are emitted.)
-
-**Step 4: Commit**
-
-```
-feat(cli): add colors to pred show output
-```
-
----
-
-### Task 6: Apply colors to `pred path`
-
-**Files:**
-- Modify: `problemreductions-cli/src/commands/graph.rs`
-
-**Step 1: Color the `format_path_text` function**
-
-In `format_path_text()`:
-- Step labels ("Step 1:", "Step 2:"): `fmt_section`
-- Problem names in steps: `fmt_problem_name`
-- Arrow `→`: `fmt_outgoing`
-
-```rust
-fn format_path_text(
-    graph: &ReductionGraph,
-    reduction_path: &problemreductions::rules::ReductionPath,
-) -> String {
-    let mut text = format!(
-        "Path ({} steps): {}\n",
-        reduction_path.len(),
-        reduction_path
-    );
-
-    let overheads = graph.path_overheads(reduction_path);
-    let steps = &reduction_path.steps;
-    for i in 0..steps.len().saturating_sub(1) {
-        let from = &steps[i];
-        let to = &steps[i + 1];
-        text.push_str(&format!(
-            "\n  {}: {} {} {}\n",
-            crate::output::fmt_section(&format!("Step {}", i + 1)),
-            crate::output::fmt_problem_name(&from.to_string()),
-            crate::output::fmt_outgoing("→"),
-            crate::output::fmt_problem_name(&to.to_string()),
-        ));
-        let oh = &overheads[i];
-        for (field, poly) in &oh.output_size {
-            text.push_str(&format!("    {field} = {poly}\n"));
-        }
-    }
-
-    text
-}
-```
-
-**Step 2: Run existing path tests**
-
-Run: `cargo test -p problemreductions-cli test_path`
-Expected: All path tests pass (non-TTY = no ANSI codes).
-
-**Step 3: Commit**
-
-```
-feat(cli): add colors to pred path output
-```
-
----
-
-### Task 7: k-neighbor BFS — library method with tests
-
-**Files:**
-- Modify: `src/rules/graph.rs`
-- Modify: `src/unit_tests/reduction_graph.rs`
-
-**Step 1: Add types and method to `ReductionGraph`**
-
-In `src/rules/graph.rs`, add the public types before the `ReductionGraph` struct:
-
-```rust
-/// Information about a neighbor in the reduction graph.
-#[derive(Debug, Clone)]
-pub struct NeighborInfo {
-    /// Problem name.
-    pub name: &'static str,
-    /// Variant attributes.
-    pub variant: BTreeMap<String, String>,
-    /// Hop distance from the source.
-    pub hops: usize,
-}
-
-/// Direction for graph traversal.
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum TraversalDirection {
-    /// Follow outgoing edges (what can this reduce to?).
-    Outgoing,
-    /// Follow incoming edges (what can reduce to this?).
-    Incoming,
-    /// Follow edges in both directions.
-    Both,
-}
-```
-
-Add the BFS method to the `impl ReductionGraph` block (after `incoming_reductions`):
-
-```rust
-/// Find all problems reachable within `max_hops` edges from a starting node.
-///
-/// Returns neighbors sorted by (hops, name). The starting node itself is excluded.
-/// If a node is reachable at multiple distances, it appears at the shortest distance only.
-pub fn k_neighbors(
-    &self,
-    name: &str,
-    variant: &BTreeMap<String, String>,
-    max_hops: usize,
-    direction: TraversalDirection,
-) -> Vec<NeighborInfo> {
-    use petgraph::Direction;
-    use std::collections::VecDeque;
-
-    // Find the starting node index
-    let start = self.name_to_nodes.get(name).and_then(|indices| {
-        indices.iter().find(|&&idx| {
-            let node = &self.nodes[self.graph[idx]];
-            node.variant == *variant
-        }).copied()
-    });
-
-    let Some(start_idx) = start else {
-        return vec![];
-    };
-
-    let mut visited: HashSet<NodeIndex> = HashSet::new();
-    visited.insert(start_idx);
-    let mut queue: VecDeque<(NodeIndex, usize)> = VecDeque::new();
-    queue.push_back((start_idx, 0));
-    let mut results: Vec<NeighborInfo> = Vec::new();
-
-    while let Some((node_idx, hops)) = queue.pop_front() {
-        if hops >= max_hops {
-            continue;
-        }
-
-        let directions: Vec<Direction> = match direction {
-            TraversalDirection::Outgoing => vec![Direction::Outgoing],
-            TraversalDirection::Incoming => vec![Direction::Incoming],
-            TraversalDirection::Both => vec![Direction::Outgoing, Direction::Incoming],
-        };
-
-        for dir in directions {
-            for neighbor_idx in self.graph.neighbors_directed(node_idx, dir) {
-                if visited.insert(neighbor_idx) {
-                    let neighbor_node = &self.nodes[self.graph[neighbor_idx]];
-                    results.push(NeighborInfo {
-                        name: neighbor_node.name,
-                        variant: neighbor_node.variant.clone(),
-                        hops: hops + 1,
-                    });
-                    queue.push_back((neighbor_idx, hops + 1));
-                }
-            }
-        }
-    }
-
-    results.sort_by(|a, b| a.hops.cmp(&b.hops).then_with(|| a.name.cmp(&b.name)));
-    results
-}
-```
-
-**Step 2: Export the new types from `src/rules/mod.rs`**
-
-In `src/rules/mod.rs`, update the `pub use graph::` line:
-
-```rust
-pub use graph::{
-    NeighborInfo, ReductionChain, ReductionEdgeInfo, ReductionGraph, ReductionPath,
-    ReductionStep, TraversalDirection,
-};
-```
-
-**Step 3: Write unit tests**
-
-In `src/unit_tests/reduction_graph.rs`, add:
-
-```rust
-#[test]
-fn test_k_neighbors_outgoing() {
-    let graph = ReductionGraph::new();
-    let variants = graph.variants_for("MaximumIndependentSet");
-    assert!(!variants.is_empty());
-    let default_variant = &variants[0];
-
-    // 1-hop outgoing: should include direct reduction targets
-    let neighbors = graph.k_neighbors(
-        "MaximumIndependentSet",
-        default_variant,
-        1,
-        TraversalDirection::Outgoing,
-    );
-    assert!(!neighbors.is_empty());
-    assert!(neighbors.iter().all(|n| n.hops == 1));
-
-    // 2-hop outgoing: should include more problems
-    let neighbors_2 = graph.k_neighbors(
-        "MaximumIndependentSet",
-        default_variant,
-        2,
-        TraversalDirection::Outgoing,
-    );
-    assert!(neighbors_2.len() >= neighbors.len());
-}
-
-#[test]
-fn test_k_neighbors_incoming() {
-    let graph = ReductionGraph::new();
-    let variants = graph.variants_for("QUBO");
-    assert!(!variants.is_empty());
-
-    let neighbors = graph.k_neighbors(
-        "QUBO",
-        &variants[0],
-        1,
-        TraversalDirection::Incoming,
-    );
-    // QUBO is a common target — should have incoming reductions
-    assert!(!neighbors.is_empty());
-}
-
-#[test]
-fn test_k_neighbors_both() {
-    let graph = ReductionGraph::new();
-    let variants = graph.variants_for("MaximumIndependentSet");
-    let default_variant = &variants[0];
-
-    let out_only = graph.k_neighbors(
-        "MaximumIndependentSet", default_variant, 1, TraversalDirection::Outgoing,
-    );
-    let in_only = graph.k_neighbors(
-        "MaximumIndependentSet", default_variant, 1, TraversalDirection::Incoming,
-    );
-    let both = graph.k_neighbors(
-        "MaximumIndependentSet", default_variant, 1, TraversalDirection::Both,
-    );
-    // Both should be >= max of either direction
-    assert!(both.len() >= out_only.len());
-    assert!(both.len() >= in_only.len());
-}
-
-#[test]
-fn test_k_neighbors_unknown_problem() {
-    let graph = ReductionGraph::new();
-    let empty = BTreeMap::new();
-    let neighbors = graph.k_neighbors("NonExistent", &empty, 2, TraversalDirection::Outgoing);
-    assert!(neighbors.is_empty());
-}
-
-#[test]
-fn test_k_neighbors_zero_hops() {
-    let graph = ReductionGraph::new();
-    let variants = graph.variants_for("MaximumIndependentSet");
-    let default_variant = &variants[0];
-    let neighbors = graph.k_neighbors(
-        "MaximumIndependentSet", default_variant, 0, TraversalDirection::Outgoing,
-    );
-    assert!(neighbors.is_empty());
-}
-```
-
-**Step 4: Run tests**
-
-Run: `cargo test -p problemreductions test_k_neighbors`
-Expected: All 5 tests pass.
-
-**Step 5: Commit**
-
-```
-feat(lib): add k_neighbors BFS method to ReductionGraph
-```
-
----
-
-### Task 8: k-neighbor CLI — tree renderer and show integration
-
-**Files:**
-- Modify: `problemreductions-cli/src/cli.rs`
-- Modify: `problemreductions-cli/src/commands/graph.rs`
-
-**Step 1: Add `--hops` and `--direction` flags to `Commands::Show`**
-
-In `problemreductions-cli/src/cli.rs`, change `Show` from a simple struct variant to a named-fields variant:
-
-```rust
-/// Show details for a problem type (variants, fields, reductions)
-#[command(after_help = "\
-Examples:
-  pred show MIS                   # using alias
-  pred show MaximumIndependentSet # full name
-  pred show MIS/UnitDiskGraph     # specific graph variant
-  pred show MIS --hops 2          # 2-hop outgoing neighbor tree
-  pred show MIS --hops 2 --direction in  # incoming neighbors
-
-Use `pred list` to see all available problem types and aliases.")]
-Show {
-    /// Problem name or alias (e.g., MIS, QUBO, MIS/UnitDiskGraph)
-    problem: String,
-    /// Explore k-hop neighbors in the reduction graph
-    #[arg(long)]
-    hops: Option<usize>,
-    /// Direction for neighbor exploration: out, in, both [default: out]
-    #[arg(long, default_value = "out")]
-    direction: String,
-},
-```
-
-**Step 2: Update `main.rs` dispatch**
-
-In `problemreductions-cli/src/main.rs`, update the `Show` match arm:
-
-```rust
-Commands::Show { problem, hops, direction } => {
-    commands::graph::show(&problem, hops, &direction, &out)
-}
-```
-
-**Step 3: Update `show()` signature and add branching**
-
-In `problemreductions-cli/src/commands/graph.rs`, update the `show` function:
-
-```rust
-pub fn show(problem: &str, hops: Option<usize>, direction: &str, out: &OutputConfig) -> Result<()> {
-    let spec = parse_problem_spec(problem)?;
-    let graph = ReductionGraph::new();
-
-    let variants = graph.variants_for(&spec.name);
-    if variants.is_empty() {
-        anyhow::bail!("Unknown problem: {}", spec.name);
-    }
-
-    if let Some(max_hops) = hops {
-        return show_neighbors(&graph, &spec, &variants, max_hops, direction, out);
-    }
-
-    // ... existing show logic unchanged ...
-}
-```
-
-**Step 4: Implement `show_neighbors` with tree rendering**
-
-Add a new function in `commands/graph.rs`:
-
-```rust
-use problemreductions::rules::{NeighborInfo, TraversalDirection};
-
-fn parse_direction(s: &str) -> Result<TraversalDirection> {
-    match s {
-        "out" => Ok(TraversalDirection::Outgoing),
-        "in" => Ok(TraversalDirection::Incoming),
-        "both" => Ok(TraversalDirection::Both),
-        _ => anyhow::bail!(
-            "Unknown direction: {}. Use 'out', 'in', or 'both'.",
-            s
-        ),
-    }
-}
-
-fn show_neighbors(
-    graph: &ReductionGraph,
-    spec: &crate::problem_name::ProblemSpec,
-    variants: &[BTreeMap<String, String>],
-    max_hops: usize,
-    direction_str: &str,
-    out: &OutputConfig,
-) -> Result<()> {
-    let direction = parse_direction(direction_str)?;
-
-    let variant = if spec.variant_values.is_empty() {
-        variants[0].clone()
-    } else {
-        resolve_variant(spec, variants)?
-    };
-
-    let neighbors = graph.k_neighbors(&spec.name, &variant, max_hops, direction);
-
-    let dir_label = match direction {
-        TraversalDirection::Outgoing => "outgoing",
-        TraversalDirection::Incoming => "incoming",
-        TraversalDirection::Both => "both directions",
-    };
-
-    // Build tree structure: group by parent chain
-    // For a tree view, we do a fresh BFS that tracks parent relationships
-    let tree = build_neighbor_tree(graph, &spec.name, &variant, max_hops, direction);
-
-    let mut text = format!(
-        "{} — {}-hop neighbors ({})\n\n",
-        crate::output::fmt_problem_name(&spec.name),
-        max_hops,
-        dir_label,
-    );
-
-    text.push_str(&crate::output::fmt_problem_name(&spec.name));
-    text.push('\n');
-    render_tree(&tree, &mut text, "", true);
-
-    // 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(),
-        max_hops,
-    ));
-
-    let json = serde_json::json!({
-        "source": spec.name,
-        "hops": max_hops,
-        "direction": direction_str,
-        "neighbors": neighbors.iter().map(|n| {
-            serde_json::json!({
-                "name": n.name,
-                "variant": n.variant,
-                "hops": n.hops,
-            })
-        }).collect::<Vec<_>>(),
-    });
-
-    let default_name = format!("pred_show_{}_hops{}.json", spec.name, max_hops);
-    out.emit_with_default_name(&default_name, &text, &json)
-}
-
-/// Tree node for neighbor rendering.
-struct TreeNode {
-    name: String,
-    children: Vec<TreeNode>,
-}
-
-/// Build a tree of neighbors via BFS, tracking parent relationships.
-fn build_neighbor_tree(
-    graph: &ReductionGraph,
-    name: &str,
-    variant: &BTreeMap<String, String>,
-    max_hops: usize,
-    direction: TraversalDirection,
-) -> Vec<TreeNode> {
-    use petgraph::Direction;
-    use std::collections::VecDeque;
-
-    let start = graph.find_node_index(name, variant);
-    let Some(start_idx) = start else {
-        return vec![];
-    };
-
-    // BFS with parent tracking to build a tree
-    let mut visited: HashSet<petgraph::graph::NodeIndex> = HashSet::new();
-    visited.insert(start_idx);
-
-    // (node_idx, depth) -> children to fill
-    struct BfsItem {
-        node_idx: petgraph::graph::NodeIndex,
-        depth: usize,
-    }
-
-    let mut queue: VecDeque<BfsItem> = VecDeque::new();
-    queue.push_back(BfsItem { node_idx: start_idx, depth: 0 });
-
-    // Map from node_idx -> TreeNode
-    let mut node_children: HashMap<petgraph::graph::NodeIndex, Vec<petgraph::graph::NodeIndex>> =
-        HashMap::new();
-
-    while let Some(item) = queue.pop_front() {
-        if item.depth >= max_hops {
-            continue;
-        }
-
-        let directions: Vec<Direction> = match direction {
-            TraversalDirection::Outgoing => vec![Direction::Outgoing],
-            TraversalDirection::Incoming => vec![Direction::Incoming],
-            TraversalDirection::Both => vec![Direction::Outgoing, Direction::Incoming],
-        };
-
-        let mut children = Vec::new();
-        for dir in directions {
-            for neighbor_idx in graph.neighbor_indices(item.node_idx, dir) {
-                if visited.insert(neighbor_idx) {
-                    children.push(neighbor_idx);
-                    queue.push_back(BfsItem { node_idx: neighbor_idx, depth: item.depth + 1 });
-                }
-            }
-        }
-        children.sort_by(|a, b| {
-            let na = graph.node_name(*a);
-            let nb = graph.node_name(*b);
-            na.cmp(&nb)
-        });
-        node_children.insert(item.node_idx, children);
-    }
-
-    // Recursively build TreeNode from start's children
-    fn build_tree(
-        idx: petgraph::graph::NodeIndex,
-        node_children: &HashMap<petgraph::graph::NodeIndex, Vec<petgraph::graph::NodeIndex>>,
-        graph: &ReductionGraph,
-    ) -> TreeNode {
-        let children = node_children
-            .get(&idx)
-            .map(|cs| cs.iter().map(|&c| build_tree(c, node_children, graph)).collect())
-            .unwrap_or_default();
-        TreeNode {
-            name: graph.node_name(idx).to_string(),
-            children,
-        }
-    }
-
-    node_children
-        .get(&start_idx)
-        .map(|cs| cs.iter().map(|&c| build_tree(c, &node_children, graph)).collect())
-        .unwrap_or_default()
-}
-
-/// Render a tree with box-drawing characters.
-fn render_tree(nodes: &[TreeNode], text: &mut String, prefix: &str, is_root: bool) {
-    for (i, node) in nodes.iter().enumerate() {
-        let is_last = i == nodes.len() - 1;
-        let connector = if is_last { "└── " } else { "├── " };
-        let child_prefix = if is_last { "    " } else { "│   " };
-
-        text.push_str(&format!(
-            "{}{}{}\n",
-            crate::output::fmt_dim(prefix),
-            crate::output::fmt_dim(connector),
-            crate::output::fmt_problem_name(&node.name),
-        ));
-
-        if !node.children.is_empty() {
-            let new_prefix = format!("{}{}", prefix, child_prefix);
-            render_tree(&node.children, text, &new_prefix, false);
-        }
-    }
-}
-```
-
-**Step 5: Add helper methods to `ReductionGraph`**
-
-The tree builder needs `find_node_index`, `neighbor_indices`, and `node_name` on `ReductionGraph`. Add these to `src/rules/graph.rs`:
-
-```rust
-/// Find the NodeIndex for a specific (name, variant) pair.
-pub fn find_node_index(&self, name: &str, variant: &BTreeMap<String, String>) -> Option<NodeIndex> {
-    self.name_to_nodes.get(name).and_then(|indices| {
-        indices.iter().find(|&&idx| {
-            let node = &self.nodes[self.graph[idx]];
-            node.variant == *variant
-        }).copied()
-    })
-}
-
-/// Get neighbors of a node in a specific direction.
-pub fn neighbor_indices(&self, idx: NodeIndex, dir: petgraph::Direction) -> Vec<NodeIndex> {
-    self.graph.neighbors_directed(idx, dir).collect()
-}
-
-/// Get the problem name for a node index.
-pub fn node_name(&self, idx: NodeIndex) -> &str {
-    self.nodes[self.graph[idx]].name
-}
-```
-
-Also export them from `src/rules/mod.rs` (they're inherent methods, so just exporting `ReductionGraph` is enough).
-
-**Step 6: Build and test**
-
-Run: `cargo run -p problemreductions-cli -- show MIS --hops 2`
-Expected: Tree output showing 2-hop outgoing neighbors of MIS.
-
-Run: `cargo run -p problemreductions-cli -- show MIS --hops 2 --direction in`
-Expected: Tree output showing 2-hop incoming neighbors.
-
-**Step 7: Commit**
-
-```
-feat(cli): add --hops and --direction flags to pred show for neighbor exploration
-```
-
----
-
-### Task 9: k-neighbor CLI — integration tests
-
-**Files:**
-- Modify: `problemreductions-cli/tests/cli_tests.rs`
-
-**Step 1: Write integration tests**
-
-Add to `problemreductions-cli/tests/cli_tests.rs`:
-
-```rust
-#[test]
-fn test_show_hops_outgoing() {
-    let output = pred()
-        .args(["show", "MIS", "--hops", "2"])
-        .output()
-        .unwrap();
-    assert!(
-        output.status.success(),
-        "stderr: {}",
-        String::from_utf8_lossy(&output.stderr)
-    );
-    let stdout = String::from_utf8(output.stdout).unwrap();
-    assert!(stdout.contains("MaximumIndependentSet"));
-    assert!(stdout.contains("reachable problems"));
-    // Should contain tree characters
-    assert!(stdout.contains("├── ") || stdout.contains("└── "));
-}
-
-#[test]
-fn test_show_hops_incoming() {
-    let output = pred()
-        .args(["show", "QUBO", "--hops", "1", "--direction", "in"])
-        .output()
-        .unwrap();
-    assert!(
-        output.status.success(),
-        "stderr: {}",
-        String::from_utf8_lossy(&output.stderr)
-    );
-    let stdout = String::from_utf8(output.stdout).unwrap();
-    assert!(stdout.contains("QUBO"));
-    assert!(stdout.contains("incoming"));
-}
-
-#[test]
-fn test_show_hops_both() {
-    let output = pred()
-        .args(["show", "MIS", "--hops", "1", "--direction", "both"])
-        .output()
-        .unwrap();
-    assert!(
-        output.status.success(),
-        "stderr: {}",
-        String::from_utf8_lossy(&output.stderr)
-    );
-    let stdout = String::from_utf8(output.stdout).unwrap();
-    assert!(stdout.contains("both directions"));
-}
-
-#[test]
-fn test_show_hops_json() {
-    let tmp = std::env::temp_dir().join("pred_test_show_hops.json");
-    let output = pred()
-        .args(["-o", tmp.to_str().unwrap(), "show", "MIS", "--hops", "2"])
-        .output()
-        .unwrap();
-    assert!(output.status.success());
-    assert!(tmp.exists());
-    let content = std::fs::read_to_string(&tmp).unwrap();
-    let json: serde_json::Value = serde_json::from_str(&content).unwrap();
-    assert_eq!(json["source"], "MaximumIndependentSet");
-    assert_eq!(json["hops"], 2);
-    assert!(json["neighbors"].is_array());
-    std::fs::remove_file(&tmp).ok();
-}
-
-#[test]
-fn test_show_hops_bad_direction() {
-    let output = pred()
-        .args(["show", "MIS", "--hops", "1", "--direction", "bad"])
-        .output()
-        .unwrap();
-    assert!(!output.status.success());
-    let stderr = String::from_utf8_lossy(&output.stderr);
-    assert!(stderr.contains("Unknown direction"));
-}
-```
-
-**Step 2: Run all CLI tests**
-
-Run: `cargo test -p problemreductions-cli`
-Expected: All tests pass, including both new and existing ones.
-
-**Step 3: Commit**
-
-```
-test(cli): add integration tests for k-neighbor exploration
-```
-
----
-
-### Task 10: Final verification and cleanup
-
-**Files:**
-- None (verification only)
-
-**Step 1: Run full test suite**
-
-Run: `make test`
-Expected: All tests pass.
-
-**Step 2: Run clippy**
-
-Run: `make clippy`
-Expected: No warnings.
-
-**Step 3: Run fmt check**
-
-Run: `make fmt-check`
-Expected: No formatting issues.
-
-**Step 4: Update issue #81**
-
-Check off the completed items in issue #81:
-- [x] Shell completions
-- [x] Colored/table output
-- [x] k-neighbor exploration
-
-Note that ILP solver integration and All paths were already implemented in v1.
-
-**Step 5: Commit any final cleanup**
-
-If any cleanup was needed, commit with:
-```
-chore(cli): cleanup for v2 features
-```
diff --git a/docs/plans/2026-02-19-cli-ux-improvements.md b/docs/plans/2026-02-19-cli-ux-improvements.md
deleted file mode 100644
index cc0e60c86..000000000
--- a/docs/plans/2026-02-19-cli-ux-improvements.md
+++ /dev/null
@@ -1,172 +0,0 @@
-# CLI UX Improvements
-
-Systematic analysis of pitfalls, missing features, and HCI violations.
-Edit this file to approve/reject/modify each item before implementation.
-
-Status legend: `[x]` = approved, `[ ]` = pending, `[-]` = rejected
-
----
-
-## Pitfalls
-
-### P1. `create` without `-o` discards data
-- [x] approve
-
-**Problem:** `pred create MIS --edges 0-1,1-2` prints `"Created MaximumIndependentSet instance"` but the JSON data is lost. Every other command without `-o` shows useful output. Here the user created something and got nothing back.
-
-**Proposed fix:** Print the problem JSON to stdout when `-o` is not given (consistent with `reduce`'s behavior of printing JSON to stdout).
-
----
-
-### P2. `reduce` without `-o` outputs raw JSON (inconsistent)
-- [x] approve
-
-**Problem:** Every other command without `-o` shows human-readable text. But `reduce` dumps raw JSON to stdout. This breaks output mode consistency.
-
-**Proposed fix:** Show human-readable summary by default (source, target, steps). Print JSON only when `-o` is given. If users need raw JSON to stdout, add a `--json` flag.
-
-**Comment**: We should allow commands to take JSON inputs from CLI directly to make json output more useful.
-
----
-
-### P3. `-o` means directory for `path --all`
-- [ ] approve
-
-**Problem:** With `--all`, `-o` is treated as a directory and creates `path_1.json`, `path_2.json`, etc. Every other command treats `-o` as a single file.
-
-**Proposed fix:** When `--all` is used, always write a single JSON file containing an array of all paths. Drop the directory behavior.
-
-**Comment**: What if a user want to specify a specific reduction path?
-
----
-
-### P4. `export-graph` uses positional arg instead of `-o`
-- [x] approve
-
-**Problem:** Every other command uses `-o` for output files. `export-graph reduction_graph.json` uses a positional arg. Inconsistent.
-
-**Proposed fix:** Change to `pred export-graph -o reduction_graph.json` or `pred export-graph` (defaults to stdout like other commands).
-
----
-
-### P5. `solve` hint prints every time
-- [x] approve
-
-**Problem:** `"Hint: use -o to save full solution details as JSON."` prints on every invocation without `-o`. Annoying for experienced users and noisy for scripts.
-
-**Proposed fix:** Only show hint when stderr is a TTY (same check as color). Scripts piping stderr won't see it, interactive users still get it.
-
----
-
-### P6. `solve` ILP auto-reduction is invisible in human output
-- [x] approve
-
-**Problem:** When solving MIS with ILP, the output says `"Solver: ilp"` but doesn't mention it was auto-reduced to ILP. Only the JSON (with `-o`) shows `"reduced_to": "ILP"`. The user has no indication a reduction happened.
-
-**Proposed fix:** Show `"Solver: ilp (via ILP)"` or `"Solver: ilp (auto-reduced to ILP)"` in the human text output when auto-reduction occurs.
-
----
-
-### P7. No stdin/pipe support
-- [x] approve
-
-**Problem:** `solve`, `evaluate`, `reduce` all require file paths. The Unix idiom of `cmd1 | cmd2` doesn't work. Users must always create intermediate files.
-
-**Proposed fix:** Accept `-` as input to read from stdin: `pred create MIS --edges 0-1,1-2 | pred solve -`. This is a standard Unix convention.
-
----
-
-### P8. `create Factoring` is a dead end
-- [x] approve
-
-**Problem:** `pred create Factoring` gives `"Factoring requires complex construction — use a JSON file instead"` but doesn't say what the JSON format should be.
-
-**Proposed fix:** Add a `pred show Factoring` schema reference in the error message: `"See pred show Factoring for the expected JSON format, or check the documentation."`.
-
-**Comment**: We should allow users to create complex problems from CLI directly.
-
-**Resolution:** Added `--target`, `--bits-m`, `--bits-n` flags to `pred create Factoring`. All three are required. Usage: `pred create Factoring --target 15 --bits-m 4 --bits-n 4`.
-
----
-
-## Features
-
-### F1. `pred inspect <file>`
-- [x] approve
-
-**Problem:** Given a problem JSON or bundle, users must manually read the JSON to know what's inside.
-
-**Proposed feature:** Show what type it is, its size (number of variables, edges, etc.), variant, available solvers, and possible reductions. Example:
-```
-$ pred inspect problem.json
-Type: MaximumIndependentSet {graph=SimpleGraph, weight=i32}
-Size: 5 vertices, 5 edges
-Solvers: ilp (default), brute-force
-Reduces to: ILP, MinimumVertexCover, QUBO, MaximumSetPacking
-```
-
----
-
-### F2. Quiet mode (`-q`)
-- [x] approve
-
-**Problem:** No way to suppress hints and informational stderr messages for scripting.
-
-**Proposed feature:** Add `-q` / `--quiet` global flag that suppresses hints and informational messages on stderr. Only errors go to stderr in quiet mode.
-
----
-
-### F3. `pred solve --reduce-via <target>`
-- [ ] approve
-
-**Problem:** Solving via a specific reduction requires two commands and an intermediate file: `pred reduce ... -o bundle.json && pred solve bundle.json`.
-
-**Proposed feature:** `pred solve problem.json --reduce-via QUBO --solver brute-force` combines reduce + solve in one step, avoiding the intermediate file.
-
-**Comment**: Reject, automated find path finding is tricky, not useful to automate at the current stage.
-
----
-
-### F4. `pred create --random`
-- [x] approve
-
-**Problem:** No way to generate random problem instances for benchmarking or testing.
-
-**Proposed feature:** `pred create MIS --random --num-vertices 100 --edge-prob 0.3 -o big.json`. Support random generation for graph-based problems with configurable size and density.
-
----
-
-## HCI Violations
-
-### H1. Inconsistent error guidance
-- [x] approve
-
-**Problem:** Some errors give excellent guidance (`path` no-path-found shows `pred show` hints), while others are bare (`show Foobar` gives just `"Error: Unknown problem: Foobar"` without suggesting `pred list` or fuzzy matches).
-
-**Proposed fix:** Add `"Did you mean ...?"` fuzzy matching for unknown problem names. Always suggest `pred list` when a problem name is not recognized.
-
----
-
-### H2. `--direction` is a raw string, not a clap enum
-- [x] approve
-
-**Problem:** `--direction` accepts any string and validates at runtime. Invalid values give a custom error, but clap's built-in validation (with auto-completion and help listing) would be better.
-
-**Comment**: Redesign. Maybe use `pred from MIS --hops 2` and `pred to MIS --hops 2` to make it more clear.
-
-**Resolution:** Replaced `pred show --hops --direction` with dedicated `pred to` and `pred from` subcommands. Direction is now determined by the command itself (no runtime string validation). Tree output shows variant-level information (`ProblemName {key=val}`).
-
----
-
-### H3. No progress feedback for long operations
-- [x] approve
-
-**Problem:** Brute-force on large instances or multi-step reductions give no feedback until completion. The user doesn't know if the tool is working or stuck.
-
-**Proposed fix:** Show a brief progress line on stderr for brute-force (e.g., `"Exploring 2^20 configurations..."`) and for multi-step reductions (e.g., `"Step 1/3: MIS → MVC..."`).
-
-**Comment**: Not very useful. Consider allowing users to add a time limit for job.
-
-**Resolution:** Added `--timeout <seconds>` flag to `pred solve`. When set, the solver is run on a separate thread with a timeout. Default is 0 (no limit). Usage: `pred solve problem.json --timeout 30`.
-
----

From 6767d567c6c0177eff1ebafdc23499963e85033c Mon Sep 17 00:00:00 2001
From: GiggleLiu <cacate0129@gmail.com>
Date: Thu, 19 Feb 2026 22:21:58 +0800
Subject: [PATCH 34/34] feat(cli): add global --json flag for JSON output to
 stdout

All commands now support --json to print JSON to stdout. Previously
only `create` (always JSON) and `reduce` (per-command --json) did.

- Add `--json` as a global CLI flag on OutputConfig
- Update emit_with_default_name to handle -o / --json / human-text
- Remove per-command --json from ReduceArgs (now global)
- Wire path and path --all through emit or --json branch
- Update docs and after_help

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
 docs/src/cli.md                              | 26 ++++++------
 problemreductions-cli/src/cli.rs             | 11 +++--
 problemreductions-cli/src/commands/graph.rs  | 28 +++++++------
 problemreductions-cli/src/commands/reduce.rs | 42 ++++++--------------
 problemreductions-cli/src/main.rs            | 11 ++---
 problemreductions-cli/src/output.rs          | 10 ++++-
 6 files changed, 62 insertions(+), 66 deletions(-)

diff --git a/docs/src/cli.md b/docs/src/cli.md
index 56c998e51..87bde611d 100644
--- a/docs/src/cli.md
+++ b/docs/src/cli.md
@@ -65,6 +65,7 @@ pred create MIS --edges 0-1,1-2,2-3 | pred reduce - --to QUBO | pred solve -
 | Flag | Description |
 |------|-------------|
 | `-o, --output <FILE>` | Save JSON output to a file |
+| `--json` | Output JSON to stdout instead of human-readable text |
 | `-q, --quiet` | Suppress informational messages on stderr |
 
 ## Commands
@@ -313,13 +314,6 @@ Use a specific reduction path (from `pred path -o`). The target is inferred from
 pred reduce problem.json --via path.json -o reduced.json
 ```
 
-Without `-o`, a human-readable summary is shown. Use `--json` to output raw JSON to stdout:
-
-```bash
-pred reduce problem.json --to QUBO          # human-readable summary
-pred reduce problem.json --to QUBO --json   # raw JSON to stdout
-```
-
 Stdin is supported with `-`:
 
 ```bash
@@ -401,13 +395,21 @@ If the shell argument is omitted, `pred completions` auto-detects your current s
 
 ## JSON Output
 
-All commands support `-o` to write JSON output to a file:
+All commands support `-o` to write JSON to a file and `--json` to print JSON to stdout:
+
+```bash
+pred list -o problems.json       # save to file
+pred list --json                 # print JSON to stdout
+pred show MIS --json             # works on any command
+pred path MIS QUBO --json
+pred solve problem.json --json
+```
+
+This is useful for scripting and piping:
 
 ```bash
-pred list -o problems.json
-pred show MIS -o mis.json
-pred path MIS QUBO -o path.json
-pred solve problem.json -o solution.json
+pred list --json | jq '.problems[].name'
+pred path MIS QUBO --json | jq '.path'
 ```
 
 ## Problem Name Aliases
diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs
index 4a727ca01..3f8c70957 100644
--- a/problemreductions-cli/src/cli.rs
+++ b/problemreductions-cli/src/cli.rs
@@ -17,6 +17,10 @@ Piping (use - to read from stdin):
   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
 
+JSON output (any command):
+  pred list --json                 # JSON to stdout
+  pred show MIS --json | jq '.'   # pipe to jq
+
 Use `pred <command> --help` for detailed usage of each command.
 Use `pred list` to see all available problem types.
 
@@ -32,6 +36,10 @@ pub struct Cli {
     #[arg(long, short, global = true)]
     pub quiet: bool,
 
+    /// Output JSON to stdout instead of human-readable text
+    #[arg(long, global = true)]
+    pub json: bool,
+
     #[command(subcommand)]
     pub command: Commands,
 }
@@ -302,9 +310,6 @@ pub struct ReduceArgs {
     /// Reduction route file (from `pred path ... -o`)
     #[arg(long)]
     pub via: Option<PathBuf>,
-    /// Output raw JSON to stdout instead of human-readable summary
-    #[arg(long)]
-    pub json: bool,
 }
 
 #[derive(clap::Args)]
diff --git a/problemreductions-cli/src/commands/graph.rs b/problemreductions-cli/src/commands/graph.rs
index ef2a79126..8c366d9a7 100644
--- a/problemreductions-cli/src/commands/graph.rs
+++ b/problemreductions-cli/src/commands/graph.rs
@@ -391,17 +391,8 @@ pub fn path(source: &str, target: &str, cost: &str, all: bool, out: &OutputConfi
     match best_path {
         Some(ref reduction_path) => {
             let text = format_path_text(&graph, reduction_path);
-            if let Some(ref path) = out.output {
-                let json = format_path_json(&graph, reduction_path);
-                let content =
-                    serde_json::to_string_pretty(&json).context("Failed to serialize JSON")?;
-                std::fs::write(path, &content)
-                    .with_context(|| format!("Failed to write {}", path.display()))?;
-                out.info(&format!("Wrote {}", path.display()));
-            } else {
-                println!("{text}");
-            }
-            Ok(())
+            let json = format_path_json(&graph, reduction_path);
+            out.emit_with_default_name("", &text, &json)
         }
         None => {
             anyhow::bail!(
@@ -455,16 +446,22 @@ fn path_all(
         text.push_str(&format_path_text(graph, p));
     }
 
+    let json: serde_json::Value = all_paths
+        .iter()
+        .map(|p| format_path_json(graph, p))
+        .collect::<Vec<_>>()
+        .into();
+
     if let Some(ref dir) = out.output {
         // -o specifies the output folder; save each path as a separate JSON file
         std::fs::create_dir_all(dir)
             .with_context(|| format!("Failed to create directory {}", dir.display()))?;
 
         for (idx, p) in all_paths.iter().enumerate() {
-            let json = format_path_json(graph, p);
+            let path_json = format_path_json(graph, p);
             let file = dir.join(format!("path_{}.json", idx + 1));
             let content =
-                serde_json::to_string_pretty(&json).context("Failed to serialize JSON")?;
+                serde_json::to_string_pretty(&path_json).context("Failed to serialize JSON")?;
             std::fs::write(&file, &content)
                 .with_context(|| format!("Failed to write {}", file.display()))?;
         }
@@ -473,6 +470,11 @@ fn path_all(
             all_paths.len(),
             dir.display()
         ));
+    } else if out.json {
+        println!(
+            "{}",
+            serde_json::to_string_pretty(&json).context("Failed to serialize JSON")?
+        );
     } else {
         println!("{text}");
     }
diff --git a/problemreductions-cli/src/commands/reduce.rs b/problemreductions-cli/src/commands/reduce.rs
index e061718e2..211560d82 100644
--- a/problemreductions-cli/src/commands/reduce.rs
+++ b/problemreductions-cli/src/commands/reduce.rs
@@ -53,7 +53,6 @@ pub fn reduce(
     input: &Path,
     target: Option<&str>,
     via: Option<&Path>,
-    json_output: bool,
     out: &OutputConfig,
 ) -> Result<()> {
     // 1. Load source problem
@@ -190,35 +189,18 @@ pub fn reduce(
 
     let json = serde_json::to_value(&bundle)?;
 
-    if let Some(ref path) = out.output {
-        // -o given: write JSON to file
-        let content = serde_json::to_string_pretty(&json).context("Failed to serialize JSON")?;
-        std::fs::write(path, &content)
-            .with_context(|| format!("Failed to write {}", path.display()))?;
-        out.info(&format!(
-            "Reduced {} to {} ({} steps)\nWrote {}",
-            source_name,
-            target_step.name,
-            reduction_path.len(),
-            path.display(),
-        ));
-    } else if json_output {
-        // --json given: print raw JSON to stdout
-        println!("{}", serde_json::to_string_pretty(&json)?);
-    } else {
-        // Default: human-readable summary
-        let mut text = format!(
-            "Reduced {} to {} ({} steps)\n",
-            source_name,
-            target_step.name,
-            reduction_path.len(),
-        );
-        text.push_str(&format!("\nPath: {}\n", reduction_path));
-        text.push_str(
-            "\nUse -o to save the reduction bundle as JSON, or --json to print JSON to stdout.",
-        );
-        println!("{text}");
-    }
+    let mut text = format!(
+        "Reduced {} to {} ({} steps)\n",
+        source_name,
+        target_step.name,
+        reduction_path.len(),
+    );
+    text.push_str(&format!("\nPath: {}\n", reduction_path));
+    text.push_str(
+        "\nHint: use -o to save the reduction bundle as JSON, or --json to print JSON to stdout.",
+    );
+
+    out.emit_with_default_name("", &text, &json)?;
 
     Ok(())
 }
diff --git a/problemreductions-cli/src/main.rs b/problemreductions-cli/src/main.rs
index 41a7b66ae..06054d659 100644
--- a/problemreductions-cli/src/main.rs
+++ b/problemreductions-cli/src/main.rs
@@ -29,6 +29,7 @@ fn main() -> anyhow::Result<()> {
     let out = OutputConfig {
         output: cli.output,
         quiet: cli.quiet,
+        json: cli.json,
     };
 
     match cli.command {
@@ -50,13 +51,9 @@ fn main() -> anyhow::Result<()> {
         Commands::Solve(args) => {
             commands::solve::solve(&args.input, &args.solver, args.timeout, &out)
         }
-        Commands::Reduce(args) => commands::reduce::reduce(
-            &args.input,
-            args.to.as_deref(),
-            args.via.as_deref(),
-            args.json,
-            &out,
-        ),
+        Commands::Reduce(args) => {
+            commands::reduce::reduce(&args.input, args.to.as_deref(), args.via.as_deref(), &out)
+        }
         Commands::Evaluate(args) => commands::evaluate::evaluate(&args.input, &args.config, &out),
         Commands::Completions { shell } => {
             let shell = shell
diff --git a/problemreductions-cli/src/output.rs b/problemreductions-cli/src/output.rs
index f14e20a56..893042022 100644
--- a/problemreductions-cli/src/output.rs
+++ b/problemreductions-cli/src/output.rs
@@ -10,6 +10,8 @@ pub struct OutputConfig {
     pub output: Option<PathBuf>,
     /// Suppress informational messages on stderr.
     pub quiet: bool,
+    /// Output JSON to stdout instead of human-readable text.
+    pub json: bool,
 }
 
 impl OutputConfig {
@@ -20,7 +22,8 @@ impl OutputConfig {
         }
     }
 
-    /// Emit output: if `-o` is set, save as JSON; otherwise print human text.
+    /// Emit output: `-o` saves JSON to file, `--json` prints JSON to stdout,
+    /// otherwise prints human-readable text.
     pub fn emit_with_default_name(
         &self,
         _default_name: &str,
@@ -33,6 +36,11 @@ 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 {
+            println!(
+                "{}",
+                serde_json::to_string_pretty(json_value).context("Failed to serialize JSON")?
+            );
         } else {
             println!("{human_text}");
         }
