Skip to content

Commit 26ac230

Browse files
isPANNclaude
andauthored
Fix #1059: add pred extract for lifting external target-space solutions (#1060)
* Add `pred extract` for lifting external target-space solutions to source Fixes #1059. External solvers (QUBO samplers, neutral-atom platforms, QAOA runtimes, etc.) can now map a target-space configuration back to the source problem space via `pred extract <bundle> --config <target-config>`, without having to shell back through `pred solve` and re-solve from scratch. Per issue #1059 discussion, this is direction (2) (subcommand) with the name `extract` rather than `lift` (GiggleLiu's suggestion). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Address codex review on #1060: bundle validation, empty-config, schema alignment - Validate bundle self-consistency (path.len >= 2, endpoints match source/target) before calling reduce_along_path — turns previously panicking malformed bundles into normal CLI errors. - Allow empty --config to represent a zero-variable target configuration. - Align extract's JSON output schema with `pred solve` on a bundle: add `reduced_to`, add `solver: "external"`, rename intermediate.config to intermediate.solution — so downstream consumers don't need separate parsers for two nearly identical workflows. - Add 3 new integration tests: out-of-range config value, malformed bundle path/source mismatch, stdin bundle input. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Factor bundle replay into BundleReplay helper; unify solve/extract/MCP Codex review item 4. Before: `solve_bundle` (CLI), `solve_bundle_inner` (MCP), and `extract` each had their own copy of the "load bundle, validate, reconstruct ReductionPath, call reduce_along_path" flow — three places to drift out of sync, and only `extract` had the endpoint-vs-source/target validation added in the previous commit. Now `BundleReplay::prepare` is the single entry point: validates bundle.path length and endpoint consistency with source/target, loads both problems, rebuilds the path, and replays to a `ReductionChain`. Callers just pick their own way to produce a target config (solver vs external input) and call `replay.extract(target_config)`. Benefit: the malformed-bundle check now protects `pred solve` and the MCP bundle-solve tool too, not just `pred extract`. Also: tests derive the target config from `pred solve --solver brute-force` instead of hardcoding it, so they pass under both default and `--features mcp` builds (which pick different reduction paths, MIS->...->ILP->QUBO vs MIS->...->MaxSetPacking->QUBO). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Address codex xhigh review: target.data coherence, tighter tests, pub(crate) Addresses remaining items from codex xhigh review on #1060 that this PR introduced (or whose scope this PR widened): Must-fix (correctness hole introduced by claiming BundleReplay "validates" bundles without fully doing so): - `BundleReplay::prepare` now serializes the chain's replayed target and checks it byte-equals `bundle.target.data`. Previously a tampered bundle where `target.data` disagreed with what `reduce_along_path` actually produced would silently pass prepare(): callers solved/validated against the bundle's stated target but extracted through a different chain target. Now rejected with a "`target.data` does not match" error, consistently across `pred solve`, `pred extract`, and the MCP solve tool. Tests: - Tighten `test_extract_roundtrip_mis_to_qubo` to assert `intermediate.solution` echoes the input target config exactly, and that the source solution is a binary vector of the right length whose ones-count matches the declared source evaluation. - New `test_extract_rejects_tampered_target_data` regression test covering the coherence check, asserting it fires on both `pred extract` and `pred solve` (verifying the shared gate). Nit: - Narrow `BundleReplay` field visibility from `pub` to `pub(crate)` — this helper is an internal CLI abstraction, not an external API. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3ab4f20 commit 26ac230

8 files changed

Lines changed: 741 additions & 93 deletions

File tree

problemreductions-cli/src/cli.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,21 @@ Examples:
159159
Inspect(InspectArgs),
160160
/// Solve a problem instance
161161
Solve(SolveArgs),
162+
/// Extract a source-space solution from a reduction bundle and a target-space config
163+
#[command(after_help = "\
164+
Examples:
165+
pred extract bundle.json --config 1,0,1,0
166+
pred extract bundle.json --config 1,0,1,0 -o source.json
167+
cat bundle.json | pred extract - --config 1,0,1,0
168+
169+
Use this when an external solver has solved the bundle's target problem
170+
(e.g. a QUBO sampler, a neutral-atom platform, a QAOA runtime) and you want
171+
the corresponding solution in the original source problem space without
172+
having to shell back into `pred solve`.
173+
174+
Input: a reduction bundle JSON (from `pred reduce`). Use - to read from stdin.
175+
--config is the target-space configuration (comma-separated, e.g. 1,0,1,0).")]
176+
Extract(ExtractArgs),
162177
/// Start MCP (Model Context Protocol) server for AI assistant integration
163178
#[cfg(feature = "mcp")]
164179
#[command(after_help = "\
@@ -1209,6 +1224,15 @@ pub struct ReduceArgs {
12091224
pub via: Option<PathBuf>,
12101225
}
12111226

1227+
#[derive(clap::Args)]
1228+
pub struct ExtractArgs {
1229+
/// Reduction bundle JSON (from `pred reduce`). Use - for stdin.
1230+
pub input: PathBuf,
1231+
/// Target-space configuration to map back (comma-separated, e.g. 1,0,1,0)
1232+
#[arg(long)]
1233+
pub config: String,
1234+
}
1235+
12121236
#[derive(clap::Args)]
12131237
pub struct InspectArgs {
12141238
/// Problem JSON file or reduction bundle. Use - for stdin.
@@ -1242,6 +1266,7 @@ pub fn print_subcommand_help_hint(error_msg: &str) {
12421266
let subcmds = [
12431267
("pred solve", "solve"),
12441268
("pred reduce", "reduce"),
1269+
("pred extract", "extract"),
12451270
("pred create", "create"),
12461271
("pred evaluate", "evaluate"),
12471272
("pred inspect", "inspect"),
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
use crate::dispatch::{read_input, BundleReplay, ReductionBundle};
2+
use crate::output::OutputConfig;
3+
use anyhow::{Context, Result};
4+
use std::path::Path;
5+
6+
/// Extract a source-space configuration from a target-space configuration and a reduction bundle.
7+
///
8+
/// This lets external solvers (that solved the bundle's target problem on their own)
9+
/// recover a solution in the original source problem space without having to
10+
/// re-solve through `pred solve`.
11+
pub fn extract(input: &Path, config_str: &str, out: &OutputConfig) -> Result<()> {
12+
let content = read_input(input)?;
13+
let json: serde_json::Value =
14+
serde_json::from_str(&content).context("Input is not valid JSON")?;
15+
16+
if !(json.get("source").is_some() && json.get("target").is_some() && json.get("path").is_some())
17+
{
18+
anyhow::bail!(
19+
"Input is not a reduction bundle.\n\
20+
`pred extract` requires a bundle produced by `pred reduce`.\n\
21+
Got a plain problem file; did you mean `pred evaluate`?"
22+
);
23+
}
24+
25+
let bundle: ReductionBundle =
26+
serde_json::from_value(json).context("Failed to parse reduction bundle")?;
27+
28+
// An empty --config means an empty target configuration (zero-variable target problem).
29+
let target_config: Vec<usize> = if config_str.trim().is_empty() {
30+
Vec::new()
31+
} else {
32+
config_str
33+
.split(',')
34+
.map(|s| {
35+
s.trim()
36+
.parse::<usize>()
37+
.map_err(|e| anyhow::anyhow!("Invalid config value '{}': {}", s.trim(), e))
38+
})
39+
.collect::<Result<Vec<_>>>()?
40+
};
41+
42+
let replay = BundleReplay::prepare(&bundle)?;
43+
44+
let target_dims = replay.target.dims_dyn();
45+
if target_config.len() != target_dims.len() {
46+
anyhow::bail!(
47+
"Target config has {} values but target problem {} has {} variables",
48+
target_config.len(),
49+
replay.target_name,
50+
target_dims.len()
51+
);
52+
}
53+
for (i, (val, dim)) in target_config.iter().zip(target_dims.iter()).enumerate() {
54+
if *val >= *dim {
55+
anyhow::bail!(
56+
"Target config value {} at position {} is out of range: variable {} has {} possible values (0..{})",
57+
val, i, i, dim, dim.saturating_sub(1)
58+
);
59+
}
60+
}
61+
let target_eval = replay.target.evaluate_dyn(&target_config);
62+
63+
let (source_config, source_eval) = replay.extract(&target_config);
64+
65+
let text = format!(
66+
"Problem: {}\nSolver: external (via {})\nSolution: {:?}\nEvaluation: {}",
67+
replay.source_name, replay.target_name, source_config, source_eval,
68+
);
69+
70+
// Schema aligned with `pred solve` on a bundle: `problem`, `reduced_to`, `solution`,
71+
// `evaluation`, `intermediate { problem, solution, evaluation }`. `solver` is "external"
72+
// to signal that pred did not run a solver — the target config came from outside.
73+
let json = serde_json::json!({
74+
"problem": replay.source_name,
75+
"solver": "external",
76+
"reduced_to": replay.target_name,
77+
"solution": source_config,
78+
"evaluation": source_eval,
79+
"intermediate": {
80+
"problem": replay.target_name,
81+
"solution": target_config,
82+
"evaluation": target_eval,
83+
},
84+
});
85+
86+
out.emit_with_default_name("pred_extract.json", &text, &json)
87+
}

problemreductions-cli/src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
pub mod create;
22
pub mod evaluate;
3+
pub mod extract;
34
pub mod graph;
45
pub mod inspect;
56
pub mod reduce;

problemreductions-cli/src/commands/solve.rs

Lines changed: 13 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
use crate::dispatch::{load_problem, read_input, ProblemJson, ReductionBundle};
1+
use crate::dispatch::{load_problem, read_input, BundleReplay, ProblemJson, ReductionBundle};
22
use crate::output::OutputConfig;
33
use anyhow::{Context, Result};
4-
use problemreductions::rules::ReductionGraph;
54
use std::path::Path;
65
use std::time::Duration;
76

@@ -166,75 +165,39 @@ fn solve_problem(
166165

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

177-
// 2. Solve the target problem
178170
let target_result = match solver_name {
179-
"brute-force" => target.solve_brute_force_witness().ok_or_else(|| {
171+
"brute-force" => replay.target.solve_brute_force_witness().ok_or_else(|| {
180172
anyhow::anyhow!(
181173
"Bundle solving requires a witness-capable target problem and witness-capable reduction path; {} only supports aggregate-value solving.",
182-
target_name
174+
replay.target_name
183175
)
184176
})?,
185-
"ilp" => target.solve_with_ilp().map_err(add_ilp_solver_hint)?,
186-
"customized" => target
177+
"ilp" => replay.target.solve_with_ilp().map_err(add_ilp_solver_hint)?,
178+
"customized" => replay
179+
.target
187180
.solve_with_customized()
188181
.map_err(add_customized_solver_hint)?,
189182
_ => unreachable!(),
190183
};
191184

192-
// 3. Load source problem and re-execute the reduction chain to get extract_solution
193-
let source = load_problem(
194-
&bundle.source.problem_type,
195-
&bundle.source.variant,
196-
bundle.source.data.clone(),
197-
)?;
198-
let source_name = source.problem_name();
185+
let (source_config, source_eval) = replay.extract(&target_result.config);
199186

200-
let graph = ReductionGraph::new();
201-
202-
// Reconstruct the ReductionPath from the bundle's path steps
203-
let reduction_path = problemreductions::rules::ReductionPath {
204-
steps: bundle
205-
.path
206-
.iter()
207-
.map(|s| problemreductions::rules::ReductionStep {
208-
name: s.name.clone(),
209-
variant: s.variant.clone(),
210-
})
211-
.collect(),
212-
};
213-
214-
let chain = graph
215-
.reduce_along_path(&reduction_path, source.as_any())
216-
.ok_or_else(|| anyhow::anyhow!(
217-
"Bundle solving requires a witness-capable reduction path; this bundle cannot recover a source solution."
218-
))?;
219-
220-
// 4. Extract solution back to source problem space
221-
let source_config = chain.extract_solution(&target_result.config);
222-
let source_eval = source.evaluate_dyn(&source_config);
223-
224-
let solver_desc = format!("{} (via {})", solver_name, target_name);
187+
let solver_desc = format!("{} (via {})", solver_name, replay.target_name);
225188
let text = format!(
226189
"Problem: {}\nSolver: {}\nSolution: {:?}\nEvaluation: {}",
227-
source_name, solver_desc, source_config, source_eval,
190+
replay.source_name, solver_desc, source_config, source_eval,
228191
);
229192

230193
let json = serde_json::json!({
231-
"problem": source_name,
194+
"problem": replay.source_name,
232195
"solver": solver_name,
233-
"reduced_to": target_name,
196+
"reduced_to": replay.target_name,
234197
"solution": source_config,
235198
"evaluation": source_eval,
236199
"intermediate": {
237-
"problem": target_name,
200+
"problem": replay.target_name,
238201
"solution": target_result.config,
239202
"evaluation": target_result.evaluation,
240203
},

problemreductions-cli/src/dispatch.rs

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,130 @@ impl LoadedProblem {
114114
}
115115
}
116116

117+
/// A validated reduction bundle ready to replay:
118+
/// source, target, and the reconstructed reduction chain. Construct via
119+
/// [`BundleReplay::prepare`]. All three CLI/MCP bundle workflows
120+
/// (`pred solve <bundle>`, `pred extract <bundle>`, MCP `solve_problem`)
121+
/// share this setup so validation and error text stay in sync.
122+
pub struct BundleReplay {
123+
pub(crate) source: LoadedProblem,
124+
pub(crate) source_name: String,
125+
pub(crate) target: LoadedProblem,
126+
pub(crate) target_name: String,
127+
pub(crate) chain: problemreductions::rules::ReductionChain,
128+
}
129+
130+
impl BundleReplay {
131+
/// Validate the bundle and replay the reduction chain.
132+
///
133+
/// Checks:
134+
/// - `path` has at least two steps
135+
/// - `path[0]` matches `source` (name + variant)
136+
/// - `path[-1]` matches `target` (name + variant)
137+
/// - serializing the chain's replayed target equals `bundle.target.data`
138+
/// (tampered/stale bundles where `target.data` disagrees with what
139+
/// `reduce_along_path` actually produced are rejected)
140+
///
141+
/// Returns an error (not a panic) for malformed bundles or aggregate-only paths.
142+
pub fn prepare(bundle: &ReductionBundle) -> Result<Self> {
143+
if bundle.path.len() < 2 {
144+
anyhow::bail!(
145+
"Malformed bundle: `path` must contain at least two steps (source and target), got {}",
146+
bundle.path.len()
147+
);
148+
}
149+
let first = bundle.path.first().unwrap();
150+
let last = bundle.path.last().unwrap();
151+
if first.name != bundle.source.problem_type || first.variant != bundle.source.variant {
152+
anyhow::bail!(
153+
"Malformed bundle: path starts with {} but source is {}",
154+
format_step(&first.name, &first.variant),
155+
format_step(&bundle.source.problem_type, &bundle.source.variant),
156+
);
157+
}
158+
if last.name != bundle.target.problem_type || last.variant != bundle.target.variant {
159+
anyhow::bail!(
160+
"Malformed bundle: path ends with {} but target is {}",
161+
format_step(&last.name, &last.variant),
162+
format_step(&bundle.target.problem_type, &bundle.target.variant),
163+
);
164+
}
165+
166+
let source = load_problem(
167+
&bundle.source.problem_type,
168+
&bundle.source.variant,
169+
bundle.source.data.clone(),
170+
)?;
171+
let source_name = source.problem_name().to_string();
172+
173+
let target = load_problem(
174+
&bundle.target.problem_type,
175+
&bundle.target.variant,
176+
bundle.target.data.clone(),
177+
)?;
178+
let target_name = target.problem_name().to_string();
179+
180+
let reduction_path = problemreductions::rules::ReductionPath {
181+
steps: bundle
182+
.path
183+
.iter()
184+
.map(|s| problemreductions::rules::ReductionStep {
185+
name: s.name.clone(),
186+
variant: s.variant.clone(),
187+
})
188+
.collect(),
189+
};
190+
191+
let graph = ReductionGraph::new();
192+
let chain = graph
193+
.reduce_along_path(&reduction_path, source.as_any())
194+
.ok_or_else(|| anyhow::anyhow!(
195+
"Bundle requires a witness-capable reduction path; this bundle cannot map a target solution back to the source."
196+
))?;
197+
198+
// Coherence check: `bundle.target.data` must equal what replaying
199+
// `source` along `path` actually produces. Without this, a caller
200+
// could solve/validate against the bundle's stated target but then
201+
// extract through a completely different chain target.
202+
let replayed_target_data =
203+
serialize_any_problem(&last.name, &last.variant, chain.target_problem_any())?;
204+
if replayed_target_data != bundle.target.data {
205+
anyhow::bail!(
206+
"Malformed bundle: `target.data` does not match the result of replaying \
207+
`source` along `path`. The bundle is tampered or was produced by \
208+
incompatible code."
209+
);
210+
}
211+
212+
Ok(Self {
213+
source,
214+
source_name,
215+
target,
216+
target_name,
217+
chain,
218+
})
219+
}
220+
221+
/// Map a target-space configuration back to the source space and evaluate it.
222+
pub fn extract(&self, target_config: &[usize]) -> (Vec<usize>, String) {
223+
let source_config = self.chain.extract_solution(target_config);
224+
let source_eval = self.source.evaluate_dyn(&source_config);
225+
(source_config, source_eval)
226+
}
227+
}
228+
229+
fn format_step(name: &str, variant: &BTreeMap<String, String>) -> String {
230+
if variant.is_empty() {
231+
name.to_string()
232+
} else {
233+
let parts: Vec<String> = variant
234+
.iter()
235+
.map(|(k, v)| format!("{}={}", k, v))
236+
.collect();
237+
format!("{}{{{}}}", name, parts.join(", "))
238+
}
239+
}
240+
117241
/// Load a problem from JSON type/variant/data.
118242
pub fn load_problem(
119243
name: &str,

problemreductions-cli/src/main.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,11 @@ fn main() -> anyhow::Result<()> {
3434
// Data-producing commands auto-output JSON when piped
3535
let auto_json = matches!(
3636
cli.command,
37-
Commands::Reduce(_) | Commands::Solve(_) | Commands::Evaluate(_) | Commands::Inspect(_)
37+
Commands::Reduce(_)
38+
| Commands::Solve(_)
39+
| Commands::Evaluate(_)
40+
| Commands::Inspect(_)
41+
| Commands::Extract(_)
3842
);
3943

4044
let out = OutputConfig {
@@ -72,6 +76,7 @@ fn main() -> anyhow::Result<()> {
7276
commands::reduce::reduce(&args.input, args.to.as_deref(), args.via.as_deref(), &out)
7377
}
7478
Commands::Evaluate(args) => commands::evaluate::evaluate(&args.input, &args.config, &out),
79+
Commands::Extract(args) => commands::extract::extract(&args.input, &args.config, &out),
7580
#[cfg(feature = "mcp")]
7681
Commands::Mcp => mcp::run(),
7782
Commands::Completions { shell } => {

0 commit comments

Comments
 (0)