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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions problemreductions-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,21 @@ Examples:
Inspect(InspectArgs),
/// Solve a problem instance
Solve(SolveArgs),
/// Extract a source-space solution from a reduction bundle and a target-space config
#[command(after_help = "\
Examples:
pred extract bundle.json --config 1,0,1,0
pred extract bundle.json --config 1,0,1,0 -o source.json
cat bundle.json | pred extract - --config 1,0,1,0

Use this when an external solver has solved the bundle's target problem
(e.g. a QUBO sampler, a neutral-atom platform, a QAOA runtime) and you want
the corresponding solution in the original source problem space without
having to shell back into `pred solve`.

Input: a reduction bundle JSON (from `pred reduce`). Use - to read from stdin.
--config is the target-space configuration (comma-separated, e.g. 1,0,1,0).")]
Extract(ExtractArgs),
/// Start MCP (Model Context Protocol) server for AI assistant integration
#[cfg(feature = "mcp")]
#[command(after_help = "\
Expand Down Expand Up @@ -1209,6 +1224,15 @@ pub struct ReduceArgs {
pub via: Option<PathBuf>,
}

#[derive(clap::Args)]
pub struct ExtractArgs {
/// Reduction bundle JSON (from `pred reduce`). Use - for stdin.
pub input: PathBuf,
/// Target-space configuration to map back (comma-separated, e.g. 1,0,1,0)
#[arg(long)]
pub config: String,
}

#[derive(clap::Args)]
pub struct InspectArgs {
/// Problem JSON file or reduction bundle. Use - for stdin.
Expand Down Expand Up @@ -1242,6 +1266,7 @@ pub fn print_subcommand_help_hint(error_msg: &str) {
let subcmds = [
("pred solve", "solve"),
("pred reduce", "reduce"),
("pred extract", "extract"),
("pred create", "create"),
("pred evaluate", "evaluate"),
("pred inspect", "inspect"),
Expand Down
87 changes: 87 additions & 0 deletions problemreductions-cli/src/commands/extract.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
use crate::dispatch::{read_input, BundleReplay, ReductionBundle};
use crate::output::OutputConfig;
use anyhow::{Context, Result};
use std::path::Path;

/// Extract a source-space configuration from a target-space configuration and a reduction bundle.
///
/// This lets external solvers (that solved the bundle's target problem on their own)
/// recover a solution in the original source problem space without having to
/// re-solve through `pred solve`.
pub fn extract(input: &Path, config_str: &str, out: &OutputConfig) -> Result<()> {
let content = read_input(input)?;
let json: serde_json::Value =
serde_json::from_str(&content).context("Input is not valid JSON")?;

if !(json.get("source").is_some() && json.get("target").is_some() && json.get("path").is_some())
{
anyhow::bail!(
"Input is not a reduction bundle.\n\
`pred extract` requires a bundle produced by `pred reduce`.\n\
Got a plain problem file; did you mean `pred evaluate`?"
);
}

let bundle: ReductionBundle =
serde_json::from_value(json).context("Failed to parse reduction bundle")?;

// An empty --config means an empty target configuration (zero-variable target problem).
let target_config: Vec<usize> = if config_str.trim().is_empty() {
Vec::new()
} else {
config_str
.split(',')
.map(|s| {
s.trim()
.parse::<usize>()
.map_err(|e| anyhow::anyhow!("Invalid config value '{}': {}", s.trim(), e))
})
.collect::<Result<Vec<_>>>()?
};

let replay = BundleReplay::prepare(&bundle)?;

let target_dims = replay.target.dims_dyn();
if target_config.len() != target_dims.len() {
anyhow::bail!(
"Target config has {} values but target problem {} has {} variables",
target_config.len(),
replay.target_name,
target_dims.len()
);
}
for (i, (val, dim)) in target_config.iter().zip(target_dims.iter()).enumerate() {
if *val >= *dim {
anyhow::bail!(
"Target config value {} at position {} is out of range: variable {} has {} possible values (0..{})",
val, i, i, dim, dim.saturating_sub(1)
);
}
}
let target_eval = replay.target.evaluate_dyn(&target_config);

let (source_config, source_eval) = replay.extract(&target_config);

let text = format!(
"Problem: {}\nSolver: external (via {})\nSolution: {:?}\nEvaluation: {}",
replay.source_name, replay.target_name, source_config, source_eval,
);

// Schema aligned with `pred solve` on a bundle: `problem`, `reduced_to`, `solution`,
// `evaluation`, `intermediate { problem, solution, evaluation }`. `solver` is "external"
// to signal that pred did not run a solver — the target config came from outside.
let json = serde_json::json!({
"problem": replay.source_name,
"solver": "external",
"reduced_to": replay.target_name,
"solution": source_config,
"evaluation": source_eval,
"intermediate": {
"problem": replay.target_name,
"solution": target_config,
"evaluation": target_eval,
},
});

out.emit_with_default_name("pred_extract.json", &text, &json)
}
1 change: 1 addition & 0 deletions problemreductions-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pub mod create;
pub mod evaluate;
pub mod extract;
pub mod graph;
pub mod inspect;
pub mod reduce;
Expand Down
63 changes: 13 additions & 50 deletions problemreductions-cli/src/commands/solve.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
use crate::dispatch::{load_problem, read_input, ProblemJson, ReductionBundle};
use crate::dispatch::{load_problem, read_input, BundleReplay, ProblemJson, ReductionBundle};
use crate::output::OutputConfig;
use anyhow::{Context, Result};
use problemreductions::rules::ReductionGraph;
use std::path::Path;
use std::time::Duration;

Expand Down Expand Up @@ -166,75 +165,39 @@ fn solve_problem(

/// Solve a reduction bundle: solve the target problem, then map the solution back.
fn solve_bundle(bundle: ReductionBundle, solver_name: &str, out: &OutputConfig) -> Result<()> {
// 1. Load the target problem from the bundle
let target = load_problem(
&bundle.target.problem_type,
&bundle.target.variant,
bundle.target.data.clone(),
)?;
let target_name = target.problem_name();
let replay = BundleReplay::prepare(&bundle)?;

// 2. Solve the target problem
let target_result = match solver_name {
"brute-force" => target.solve_brute_force_witness().ok_or_else(|| {
"brute-force" => replay.target.solve_brute_force_witness().ok_or_else(|| {
anyhow::anyhow!(
"Bundle solving requires a witness-capable target problem and witness-capable reduction path; {} only supports aggregate-value solving.",
target_name
replay.target_name
)
})?,
"ilp" => target.solve_with_ilp().map_err(add_ilp_solver_hint)?,
"customized" => target
"ilp" => replay.target.solve_with_ilp().map_err(add_ilp_solver_hint)?,
"customized" => replay
.target
.solve_with_customized()
.map_err(add_customized_solver_hint)?,
_ => unreachable!(),
};

// 3. Load source problem and re-execute the reduction chain to get extract_solution
let source = load_problem(
&bundle.source.problem_type,
&bundle.source.variant,
bundle.source.data.clone(),
)?;
let source_name = source.problem_name();
let (source_config, source_eval) = replay.extract(&target_result.config);

let graph = ReductionGraph::new();

// Reconstruct the ReductionPath from the bundle's path steps
let reduction_path = problemreductions::rules::ReductionPath {
steps: bundle
.path
.iter()
.map(|s| problemreductions::rules::ReductionStep {
name: s.name.clone(),
variant: s.variant.clone(),
})
.collect(),
};

let chain = graph
.reduce_along_path(&reduction_path, source.as_any())
.ok_or_else(|| anyhow::anyhow!(
"Bundle solving requires a witness-capable reduction path; this bundle cannot recover a source solution."
))?;

// 4. Extract solution back to source problem space
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 solver_desc = format!("{} (via {})", solver_name, replay.target_name);
let text = format!(
"Problem: {}\nSolver: {}\nSolution: {:?}\nEvaluation: {}",
source_name, solver_desc, source_config, source_eval,
replay.source_name, solver_desc, source_config, source_eval,
);

let json = serde_json::json!({
"problem": source_name,
"problem": replay.source_name,
"solver": solver_name,
"reduced_to": target_name,
"reduced_to": replay.target_name,
"solution": source_config,
"evaluation": source_eval,
"intermediate": {
"problem": target_name,
"problem": replay.target_name,
"solution": target_result.config,
"evaluation": target_result.evaluation,
},
Expand Down
124 changes: 124 additions & 0 deletions problemreductions-cli/src/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,130 @@ impl LoadedProblem {
}
}

/// A validated reduction bundle ready to replay:
/// source, target, and the reconstructed reduction chain. Construct via
/// [`BundleReplay::prepare`]. All three CLI/MCP bundle workflows
/// (`pred solve <bundle>`, `pred extract <bundle>`, MCP `solve_problem`)
/// share this setup so validation and error text stay in sync.
pub struct BundleReplay {
pub(crate) source: LoadedProblem,
pub(crate) source_name: String,
pub(crate) target: LoadedProblem,
pub(crate) target_name: String,
pub(crate) chain: problemreductions::rules::ReductionChain,
}

impl BundleReplay {
/// Validate the bundle and replay the reduction chain.
///
/// Checks:
/// - `path` has at least two steps
/// - `path[0]` matches `source` (name + variant)
/// - `path[-1]` matches `target` (name + variant)
/// - serializing the chain's replayed target equals `bundle.target.data`
/// (tampered/stale bundles where `target.data` disagrees with what
/// `reduce_along_path` actually produced are rejected)
///
/// Returns an error (not a panic) for malformed bundles or aggregate-only paths.
pub fn prepare(bundle: &ReductionBundle) -> Result<Self> {
if bundle.path.len() < 2 {
anyhow::bail!(
"Malformed bundle: `path` must contain at least two steps (source and target), got {}",
bundle.path.len()
);
}
let first = bundle.path.first().unwrap();
let last = bundle.path.last().unwrap();
if first.name != bundle.source.problem_type || first.variant != bundle.source.variant {
anyhow::bail!(
"Malformed bundle: path starts with {} but source is {}",
format_step(&first.name, &first.variant),
format_step(&bundle.source.problem_type, &bundle.source.variant),
);
}
if last.name != bundle.target.problem_type || last.variant != bundle.target.variant {
anyhow::bail!(
"Malformed bundle: path ends with {} but target is {}",
format_step(&last.name, &last.variant),
format_step(&bundle.target.problem_type, &bundle.target.variant),
);
}

let source = load_problem(
&bundle.source.problem_type,
&bundle.source.variant,
bundle.source.data.clone(),
)?;
let source_name = source.problem_name().to_string();

let target = load_problem(
&bundle.target.problem_type,
&bundle.target.variant,
bundle.target.data.clone(),
)?;
let target_name = target.problem_name().to_string();

let reduction_path = problemreductions::rules::ReductionPath {
steps: bundle
.path
.iter()
.map(|s| problemreductions::rules::ReductionStep {
name: s.name.clone(),
variant: s.variant.clone(),
})
.collect(),
};

let graph = ReductionGraph::new();
let chain = graph
.reduce_along_path(&reduction_path, source.as_any())
.ok_or_else(|| anyhow::anyhow!(
"Bundle requires a witness-capable reduction path; this bundle cannot map a target solution back to the source."
))?;

// Coherence check: `bundle.target.data` must equal what replaying
// `source` along `path` actually produces. Without this, a caller
// could solve/validate against the bundle's stated target but then
// extract through a completely different chain target.
let replayed_target_data =
serialize_any_problem(&last.name, &last.variant, chain.target_problem_any())?;
if replayed_target_data != bundle.target.data {
anyhow::bail!(
"Malformed bundle: `target.data` does not match the result of replaying \
`source` along `path`. The bundle is tampered or was produced by \
incompatible code."
);
}

Ok(Self {
source,
source_name,
target,
target_name,
chain,
})
}

/// Map a target-space configuration back to the source space and evaluate it.
pub fn extract(&self, target_config: &[usize]) -> (Vec<usize>, String) {
let source_config = self.chain.extract_solution(target_config);
let source_eval = self.source.evaluate_dyn(&source_config);
(source_config, source_eval)
}
}

fn format_step(name: &str, variant: &BTreeMap<String, String>) -> String {
if variant.is_empty() {
name.to_string()
} else {
let parts: Vec<String> = variant
.iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect();
format!("{}{{{}}}", name, parts.join(", "))
}
}

/// Load a problem from JSON type/variant/data.
pub fn load_problem(
name: &str,
Expand Down
7 changes: 6 additions & 1 deletion problemreductions-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,11 @@ fn main() -> anyhow::Result<()> {
// Data-producing commands auto-output JSON when piped
let auto_json = matches!(
cli.command,
Commands::Reduce(_) | Commands::Solve(_) | Commands::Evaluate(_) | Commands::Inspect(_)
Commands::Reduce(_)
| Commands::Solve(_)
| Commands::Evaluate(_)
| Commands::Inspect(_)
| Commands::Extract(_)
);

let out = OutputConfig {
Expand Down Expand Up @@ -72,6 +76,7 @@ fn main() -> anyhow::Result<()> {
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::Extract(args) => commands::extract::extract(&args.input, &args.config, &out),
#[cfg(feature = "mcp")]
Commands::Mcp => mcp::run(),
Commands::Completions { shell } => {
Expand Down
Loading
Loading