Skip to content

Commit 88fb8f7

Browse files
GiggleLiuclaude
andauthored
feat: add CustomizedSolver with structure-exploiting backends (#766)
* feat: add shared ILP linearization helpers (McCormick, MTZ, flow, big-M, abs-diff, minimax, one-hot) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add customized solver scaffolding Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: expose customized solver in cli dispatch Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add fd subset backend and wire four FD models in customized solver Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add partial feedback edge set backend Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add rooted tree arrangement backend Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: document customized solver behavior and add CLI tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: handle customized solver CLI and MCP paths * test: harden customized solver parity coverage --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent bc974d1 commit 88fb8f7

File tree

15 files changed

+1712
-23
lines changed

15 files changed

+1712
-23
lines changed

problemreductions-cli/src/cli.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -738,6 +738,7 @@ pub struct CreateArgs {
738738
Examples:
739739
pred solve problem.json # ILP solver (default, auto-reduces to ILP)
740740
pred solve problem.json --solver brute-force # brute-force (exhaustive search)
741+
pred solve problem.json --solver customized # customized (structure-exploiting exact solver)
741742
pred solve reduced.json # solve a reduction bundle
742743
pred solve reduced.json -o solution.json # save result to file
743744
pred create MIS --graph 0-1,1-2 | pred solve - # read from stdin when an ILP path exists
@@ -761,14 +762,18 @@ Problems without an ILP reduction path, such as `GroupingBySwapping`,
761762
`LengthBoundedDisjointPaths`, `MinMaxMulticenter`, and `StringToStringCorrection`,
762763
currently need `--solver brute-force`.
763764
765+
Customized solver: exact witness recovery for select problems via structure-exploiting
766+
backends. Currently supports MinimumCardinalityKey, AdditionalKey, PrimeAttributeName,
767+
BoyceCoddNormalFormViolation, PartialFeedbackEdgeSet, and RootedTreeArrangement.
768+
764769
ILP backend (default: HiGHS). To use a different backend:
765770
cargo install problemreductions-cli --features coin-cbc
766771
cargo install problemreductions-cli --features scip
767772
cargo install problemreductions-cli --no-default-features --features clarabel")]
768773
pub struct SolveArgs {
769774
/// Problem JSON file (from `pred create`) or reduction bundle (from `pred reduce`). Use - for stdin.
770775
pub input: PathBuf,
771-
/// Solver: ilp (default) or brute-force
776+
/// Solver: ilp (default), brute-force, or customized
772777
#[arg(long, default_value = "ilp")]
773778
pub solver: String,
774779
/// Timeout in seconds (0 = no limit)

problemreductions-cli/src/commands/inspect.rs

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -40,16 +40,18 @@ fn inspect_problem(pj: &ProblemJson, out: &OutputConfig) -> Result<()> {
4040
}
4141
text.push_str(&format!("Variables: {}\n", problem.num_variables_dyn()));
4242

43-
let solvers = if problem.supports_ilp_solver() {
44-
vec!["ilp", "brute-force"]
45-
} else {
46-
vec!["brute-force"]
47-
};
48-
let solver_summary = if solvers.first() == Some(&"ilp") {
49-
"ilp (default), brute-force".to_string()
50-
} else {
51-
"brute-force".to_string()
52-
};
43+
let solvers = problem.available_solvers();
44+
let solver_summary = solvers
45+
.iter()
46+
.map(|solver| {
47+
if *solver == "ilp" {
48+
"ilp (default)".to_string()
49+
} else {
50+
(*solver).to_string()
51+
}
52+
})
53+
.collect::<Vec<_>>()
54+
.join(", ");
5355
text.push_str(&format!("Solvers: {solver_summary}\n"));
5456

5557
// Reductions

problemreductions-cli/src/commands/solve.rs

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,9 @@ fn plain_problem_output(
6666
}
6767

6868
pub fn solve(input: &Path, solver_name: &str, timeout: u64, out: &OutputConfig) -> Result<()> {
69-
if solver_name != "brute-force" && solver_name != "ilp" {
69+
if solver_name != "brute-force" && solver_name != "ilp" && solver_name != "customized" {
7070
anyhow::bail!(
71-
"Unknown solver: {}. Available solvers: brute-force, ilp",
71+
"Unknown solver: {}. Available solvers: brute-force, ilp, customized",
7272
solver_name
7373
);
7474
}
@@ -145,6 +145,21 @@ fn solve_problem(
145145
}
146146
result
147147
}
148+
"customized" => {
149+
let result = problem
150+
.solve_with_customized()
151+
.map_err(add_customized_solver_hint)?;
152+
let result = crate::dispatch::SolveResult {
153+
config: Some(result.config),
154+
evaluation: result.evaluation,
155+
};
156+
let (text, json) = plain_problem_output(name, "customized", &result);
157+
let result = out.emit_with_default_name("", &text, &json);
158+
if out.output.is_none() && crate::output::stderr_is_tty() {
159+
out.info("\nHint: use -o to save full solution details as JSON.");
160+
}
161+
result
162+
}
148163
_ => unreachable!(),
149164
}
150165
}
@@ -168,6 +183,9 @@ fn solve_bundle(bundle: ReductionBundle, solver_name: &str, out: &OutputConfig)
168183
)
169184
})?,
170185
"ilp" => target.solve_with_ilp().map_err(add_ilp_solver_hint)?,
186+
"customized" => target
187+
.solve_with_customized()
188+
.map_err(add_customized_solver_hint)?,
171189
_ => unreachable!(),
172190
};
173191

@@ -229,6 +247,17 @@ fn solve_bundle(bundle: ReductionBundle, solver_name: &str, out: &OutputConfig)
229247
result
230248
}
231249

250+
fn add_customized_solver_hint(err: anyhow::Error) -> anyhow::Error {
251+
let message = err.to_string();
252+
if message.contains("unsupported by customized solver") {
253+
anyhow::anyhow!(
254+
"{message}\n\nHint: the customized solver only supports select problems (FD-based models, PartialFeedbackEdgeSet, RootedTreeArrangement).\nTry `--solver brute-force` or `--solver ilp` instead."
255+
)
256+
} else {
257+
err
258+
}
259+
}
260+
232261
fn add_ilp_solver_hint(err: anyhow::Error) -> anyhow::Error {
233262
let message = err.to_string();
234263
if (message.starts_with("No reduction path from ") && message.ends_with(" to ILP"))

problemreductions-cli/src/dispatch.rs

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use anyhow::{Context, Result};
22
use problemreductions::registry::{DynProblem, LoadedDynProblem};
33
use problemreductions::rules::{MinimizeSteps, ReductionGraph, ReductionMode};
4-
use problemreductions::solvers::ILPSolver;
4+
use problemreductions::solvers::{CustomizedSolver, ILPSolver};
55
use problemreductions::types::ProblemSize;
66
use serde_json::Value;
77
use std::any::Any;
@@ -75,12 +75,29 @@ impl LoadedProblem {
7575
}
7676
}
7777

78+
pub fn supports_customized_solver(&self) -> bool {
79+
CustomizedSolver::supports_problem(self.as_any())
80+
}
81+
82+
pub fn solve_with_customized(&self) -> Result<WitnessSolveResult> {
83+
let solver = CustomizedSolver::new();
84+
let config = solver
85+
.solve_dyn(self.as_any())
86+
.ok_or_else(|| anyhow::anyhow!("Problem unsupported by customized solver"))?;
87+
let evaluation = self.evaluate_dyn(&config);
88+
Ok(WitnessSolveResult { config, evaluation })
89+
}
90+
7891
#[cfg_attr(not(feature = "mcp"), allow(dead_code))]
7992
pub fn available_solvers(&self) -> Vec<&'static str> {
80-
let mut solvers = vec!["brute-force"];
93+
let mut solvers = Vec::new();
8194
if self.supports_ilp_solver() {
8295
solvers.push("ilp");
8396
}
97+
solvers.push("brute-force");
98+
if self.supports_customized_solver() {
99+
solvers.push("customized");
100+
}
84101
solvers
85102
}
86103

@@ -270,6 +287,34 @@ mod tests {
270287
assert_eq!(result.evaluation, "Sum(56)");
271288
}
272289

290+
#[test]
291+
fn test_available_solvers_excludes_customized_for_unsupported_problem() {
292+
let loaded = load_problem(
293+
AGGREGATE_SOURCE_NAME,
294+
&BTreeMap::new(),
295+
serde_json::to_value(AggregateValueSource::sample()).unwrap(),
296+
)
297+
.unwrap();
298+
299+
assert!(!loaded.available_solvers().contains(&"customized"));
300+
}
301+
302+
#[test]
303+
fn test_solve_with_customized_rejects_unsupported_problem() {
304+
let loaded = load_problem(
305+
AGGREGATE_SOURCE_NAME,
306+
&BTreeMap::new(),
307+
serde_json::to_value(AggregateValueSource::sample()).unwrap(),
308+
)
309+
.unwrap();
310+
311+
let err = loaded.solve_with_customized().unwrap_err();
312+
assert!(
313+
err.to_string().contains("unsupported by customized solver"),
314+
"unexpected error: {err}"
315+
);
316+
}
317+
273318
#[test]
274319
fn test_solve_with_ilp_rejects_aggregate_only_problem() {
275320
let loaded = load_problem(

problemreductions-cli/src/mcp/tests.rs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,27 @@ mod tests {
353353
assert!(json["solution"].is_array());
354354
}
355355

356+
#[test]
357+
fn test_solve_customized_supported_problem() {
358+
let server = McpServer::new();
359+
let problem_json = serde_json::json!({
360+
"type": "MinimumCardinalityKey",
361+
"variant": {},
362+
"data": {
363+
"num_attributes": 4,
364+
"dependencies": [[[0], [1, 2]], [[1, 2], [3]]],
365+
"bound": 2
366+
}
367+
})
368+
.to_string();
369+
370+
let result = server.solve_inner(&problem_json, Some("customized"), None);
371+
assert!(result.is_ok(), "solve failed: {:?}", result);
372+
let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
373+
assert_eq!(json["solver"], "customized");
374+
assert!(json["solution"].is_array(), "{json}");
375+
}
376+
356377
#[test]
357378
fn test_solve_unknown_solver() {
358379
let server = McpServer::new();
@@ -374,6 +395,20 @@ mod tests {
374395
assert_eq!(json["problem"], "MaximumIndependentSet");
375396
}
376397

398+
#[test]
399+
fn test_solve_customized_bundle_rejects_unsupported_target_without_panicking() {
400+
let server = McpServer::new();
401+
let problem_json = create_test_mis(&server);
402+
let bundle_json = server.reduce_inner(&problem_json, "QUBO").unwrap();
403+
let result = server.solve_inner(&bundle_json, Some("customized"), None);
404+
assert!(result.is_err());
405+
let err = result.unwrap_err().to_string();
406+
assert!(
407+
err.contains("unsupported by customized solver"),
408+
"unexpected error: {err}"
409+
);
410+
}
411+
377412
#[test]
378413
fn test_inspect_bundle() {
379414
let server = McpServer::new();
@@ -421,6 +456,35 @@ mod tests {
421456
assert_eq!(solvers, vec!["brute-force"]);
422457
}
423458

459+
#[test]
460+
fn test_inspect_minimum_cardinality_key_lists_customized_solver() {
461+
let server = McpServer::new();
462+
let problem_json = serde_json::json!({
463+
"type": "MinimumCardinalityKey",
464+
"variant": {},
465+
"data": {
466+
"num_attributes": 4,
467+
"dependencies": [[[0], [1, 2]], [[1, 2], [3]]],
468+
"bound": 2
469+
}
470+
})
471+
.to_string();
472+
473+
let result = server.inspect_problem_inner(&problem_json);
474+
assert!(result.is_ok(), "inspect failed: {:?}", result);
475+
let json: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
476+
let solvers: Vec<&str> = json["solvers"]
477+
.as_array()
478+
.unwrap()
479+
.iter()
480+
.map(|v| v.as_str().unwrap())
481+
.collect();
482+
assert!(
483+
solvers.contains(&"customized"),
484+
"inspect should list customized when supported, got: {json}"
485+
);
486+
}
487+
424488
#[test]
425489
fn test_solve_sat_problem() {
426490
let server = McpServer::new();

problemreductions-cli/src/mcp/tools.rs

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ pub struct ReduceParams {
103103
pub struct SolveParams {
104104
#[schemars(description = "Problem JSON string (from create_problem or reduce)")]
105105
pub problem_json: String,
106-
#[schemars(description = "Solver: 'ilp' (default) or 'brute-force'")]
106+
#[schemars(description = "Solver: 'ilp' (default), 'brute-force', or 'customized'")]
107107
pub solver: Option<String>,
108108
#[schemars(description = "Timeout in seconds (0 = no limit, default: 0)")]
109109
pub timeout: Option<u64>,
@@ -880,9 +880,9 @@ impl McpServer {
880880
timeout: Option<u64>,
881881
) -> anyhow::Result<String> {
882882
let solver_name = solver.unwrap_or("ilp");
883-
if solver_name != "brute-force" && solver_name != "ilp" {
883+
if solver_name != "brute-force" && solver_name != "ilp" && solver_name != "customized" {
884884
anyhow::bail!(
885-
"Unknown solver: {}. Available solvers: brute-force, ilp",
885+
"Unknown solver: {}. Available solvers: brute-force, ilp, customized",
886886
solver_name
887887
);
888888
}
@@ -1040,7 +1040,7 @@ impl McpServer {
10401040
.map_err(|e| e.to_string())
10411041
}
10421042

1043-
/// Solve a problem instance using brute-force or ILP solver
1043+
/// Solve a problem instance using brute-force, ILP, or customized solver
10441044
#[tool(
10451045
name = "solve",
10461046
annotations(read_only_hint = true, open_world_hint = false)
@@ -1488,6 +1488,15 @@ fn solve_problem_inner(
14881488
}
14891489
Ok(serde_json::to_string_pretty(&json)?)
14901490
}
1491+
"customized" => {
1492+
let result = problem.solve_with_customized()?;
1493+
let result = crate::dispatch::SolveResult {
1494+
config: Some(result.config),
1495+
evaluation: result.evaluation,
1496+
};
1497+
let json = solve_result_json(name, "customized", &result);
1498+
Ok(serde_json::to_string_pretty(&json)?)
1499+
}
14911500
_ => unreachable!(),
14921501
}
14931502
}
@@ -1509,6 +1518,7 @@ fn solve_bundle_inner(bundle: ReductionBundle, solver_name: &str) -> anyhow::Res
15091518
)
15101519
})?,
15111520
"ilp" => target.solve_with_ilp()?,
1521+
"customized" => target.solve_with_customized()?,
15121522
_ => unreachable!(),
15131523
};
15141524

0 commit comments

Comments
 (0)