Skip to content

Commit 223d23a

Browse files
isPANNclaude
andcommitted
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>
1 parent fbaed09 commit 223d23a

2 files changed

Lines changed: 142 additions & 11 deletions

File tree

problemreductions-cli/src/dispatch.rs

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -120,20 +120,25 @@ impl LoadedProblem {
120120
/// (`pred solve <bundle>`, `pred extract <bundle>`, MCP `solve_problem`)
121121
/// share this setup so validation and error text stay in sync.
122122
pub struct BundleReplay {
123-
pub source: LoadedProblem,
124-
pub source_name: String,
125-
pub target: LoadedProblem,
126-
pub target_name: String,
127-
pub chain: problemreductions::rules::ReductionChain,
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,
128128
}
129129

130130
impl BundleReplay {
131131
/// Validate the bundle and replay the reduction chain.
132132
///
133-
/// Checks: `path` has at least two steps; `path[0]` matches `source`;
134-
/// `path[-1]` matches `target`. Then loads both problems, reconstructs
135-
/// the `ReductionPath`, and calls `reduce_along_path`. Returns an error
136-
/// (not a panic) for malformed bundles or aggregate-only paths.
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.
137142
pub fn prepare(bundle: &ReductionBundle) -> Result<Self> {
138143
if bundle.path.len() < 2 {
139144
anyhow::bail!(
@@ -190,6 +195,20 @@ impl BundleReplay {
190195
"Bundle requires a witness-capable reduction path; this bundle cannot map a target solution back to the source."
191196
))?;
192197

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+
193212
Ok(Self {
194213
source,
195214
source_name,

problemreductions-cli/tests/cli_tests.rs

Lines changed: 114 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8830,8 +8830,34 @@ fn test_extract_roundtrip_mis_to_qubo() {
88308830
// extract on pred-solve's own target config must round-trip to the same source evaluation.
88318831
assert_eq!(json["evaluation"].as_str().unwrap(), expected_source_eval);
88328832
assert_eq!(json["intermediate"]["problem"].as_str().unwrap(), "QUBO");
8833-
assert!(json["solution"].is_array());
8834-
assert!(json["intermediate"]["solution"].is_array());
8833+
8834+
// intermediate.solution must be exactly the target config we passed in
8835+
// (extract echoes the input target config unchanged).
8836+
let expected_target: Vec<serde_json::Value> = target_cfg
8837+
.split(',')
8838+
.map(|s| serde_json::json!(s.parse::<u64>().unwrap()))
8839+
.collect();
8840+
assert_eq!(
8841+
json["intermediate"]["solution"].as_array().unwrap(),
8842+
&expected_target
8843+
);
8844+
8845+
// Source config is over 4 MIS variables and must describe an independent set
8846+
// whose size matches `expected_source_eval` (e.g. "Max(2)" -> 2 ones).
8847+
let source_sol: Vec<u64> = json["solution"]
8848+
.as_array()
8849+
.unwrap()
8850+
.iter()
8851+
.map(|v| v.as_u64().unwrap())
8852+
.collect();
8853+
assert_eq!(source_sol.len(), 4);
8854+
assert!(source_sol.iter().all(|b| *b == 0 || *b == 1));
8855+
let ones = source_sol.iter().filter(|b| **b == 1).count();
8856+
assert_eq!(
8857+
expected_source_eval,
8858+
format!("Max({ones})"),
8859+
"MIS size in solution should match declared evaluation"
8860+
);
88358861

88368862
std::fs::remove_file(&problem_file).ok();
88378863
std::fs::remove_file(&bundle_file).ok();
@@ -9034,6 +9060,92 @@ fn test_extract_rejects_malformed_bundle_path_source_mismatch() {
90349060
std::fs::remove_file(&tampered_file).ok();
90359061
}
90369062

9063+
#[test]
9064+
fn test_extract_rejects_tampered_target_data() {
9065+
use std::io::Write;
9066+
9067+
let problem_file = std::env::temp_dir().join("pred_test_extract_tampered_target_in.json");
9068+
let bundle_file = std::env::temp_dir().join("pred_test_extract_tampered_target_bundle.json");
9069+
let tampered_file =
9070+
std::env::temp_dir().join("pred_test_extract_tampered_target_tampered.json");
9071+
9072+
pred()
9073+
.args([
9074+
"-o",
9075+
problem_file.to_str().unwrap(),
9076+
"create",
9077+
"MIS",
9078+
"--graph",
9079+
"0-1,1-2,2-3",
9080+
])
9081+
.output()
9082+
.unwrap();
9083+
pred()
9084+
.args([
9085+
"-o",
9086+
bundle_file.to_str().unwrap(),
9087+
"reduce",
9088+
problem_file.to_str().unwrap(),
9089+
"--to",
9090+
"QUBO",
9091+
])
9092+
.output()
9093+
.unwrap();
9094+
9095+
// Tamper: flip one QUBO matrix entry so target.data no longer matches
9096+
// what the reduction chain actually produces.
9097+
let bundle_text = std::fs::read_to_string(&bundle_file).unwrap();
9098+
let mut bundle: serde_json::Value = serde_json::from_str(&bundle_text).unwrap();
9099+
bundle["target"]["data"]["matrix"][0][0] = serde_json::json!(999.0);
9100+
let mut f = std::fs::File::create(&tampered_file).unwrap();
9101+
f.write_all(bundle.to_string().as_bytes()).unwrap();
9102+
9103+
// Any config long enough to reach the coherence check; it must fail before
9104+
// config validation kicks in because prepare() runs first.
9105+
let (target_cfg, _) = extract_test_solve_bundle(&bundle_file);
9106+
let extract_out = pred()
9107+
.args([
9108+
"extract",
9109+
tampered_file.to_str().unwrap(),
9110+
"--config",
9111+
&target_cfg,
9112+
])
9113+
.output()
9114+
.unwrap();
9115+
assert!(
9116+
!extract_out.status.success(),
9117+
"expected failure on tampered target.data; stdout: {}",
9118+
String::from_utf8_lossy(&extract_out.stdout)
9119+
);
9120+
let stderr = String::from_utf8(extract_out.stderr).unwrap();
9121+
assert!(
9122+
stderr.contains("`target.data` does not match"),
9123+
"unexpected stderr: {stderr}"
9124+
);
9125+
9126+
// Same check must also fire through `pred solve` on the tampered bundle —
9127+
// BundleReplay::prepare is the shared gate.
9128+
let solve_out = pred()
9129+
.args([
9130+
"solve",
9131+
tampered_file.to_str().unwrap(),
9132+
"--solver",
9133+
"brute-force",
9134+
])
9135+
.output()
9136+
.unwrap();
9137+
assert!(!solve_out.status.success());
9138+
let solve_err = String::from_utf8(solve_out.stderr).unwrap();
9139+
assert!(
9140+
solve_err.contains("`target.data` does not match"),
9141+
"pred solve should also reject tampered bundles; got: {solve_err}"
9142+
);
9143+
9144+
std::fs::remove_file(&problem_file).ok();
9145+
std::fs::remove_file(&bundle_file).ok();
9146+
std::fs::remove_file(&tampered_file).ok();
9147+
}
9148+
90379149
#[test]
90389150
fn test_extract_reads_bundle_from_stdin() {
90399151
use std::io::Write;

0 commit comments

Comments
 (0)