Skip to content

Commit 7d55e3d

Browse files
GiggleLiuclaude
andcommitted
feat: add witness edge for Decision<P> → P, enabling cheaper ILP paths
Previously Decision<P> → P was aggregate-only, forcing witness-mode path search through expensive chains (e.g., DecisionMVC → HC → QA → ILP). Now the edge supports both witness and aggregate modes — a P-witness meeting the bound is directly a Decision<P> witness (identity config). - Add DecisionToOptimizationWitnessResult and ReduceTo<P> for Decision<P> - Merge register_decision_variant! macro to emit single both() edge - Add witness edge + canonical example for Decision<MDS<SG,One>> variant - Update dominated rules allow-list (KSat→MVC now dominated via DecisionMVC) - Fix CLI truncation test (MIS→QUBO path count changed due to new edges) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 38e377d commit 7d55e3d

8 files changed

Lines changed: 149 additions & 46 deletions

File tree

problemreductions-cli/tests/cli_tests.rs

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7642,7 +7642,15 @@ fn test_show_ksat_works() {
76427642
fn test_path_all_max_paths_truncates() {
76437643
// With --max-paths 3, should limit to 3 paths and indicate truncation
76447644
let output = pred()
7645-
.args(["path", "MIS", "QUBO", "--all", "--max-paths", "3", "--json"])
7645+
.args([
7646+
"path",
7647+
"KSat",
7648+
"QUBO",
7649+
"--all",
7650+
"--max-paths",
7651+
"3",
7652+
"--json",
7653+
])
76467654
.output()
76477655
.unwrap();
76487656
assert!(
@@ -7661,17 +7669,17 @@ fn test_path_all_max_paths_truncates() {
76617669
paths.len()
76627670
);
76637671
assert_eq!(envelope["max_paths"], 3);
7664-
// MIS -> QUBO has many paths, so truncation is expected
7672+
// KSat -> QUBO has many paths, so truncation is expected
76657673
assert_eq!(
76667674
envelope["truncated"], true,
7667-
"should be truncated since MIS->QUBO has many paths"
7675+
"should be truncated since KSat->QUBO has many paths"
76687676
);
76697677
}
76707678

76717679
#[test]
76727680
fn test_path_all_max_paths_text_truncation_note() {
76737681
let output = pred()
7674-
.args(["path", "MIS", "QUBO", "--all", "--max-paths", "2"])
7682+
.args(["path", "KSat", "QUBO", "--all", "--max-paths", "2"])
76757683
.output()
76767684
.unwrap();
76777685
assert!(output.status.success());

src/models/decision.rs

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
//! Generic decision wrapper for optimization problems.
22
3-
use crate::rules::{AggregateReductionResult, ReduceToAggregate};
3+
use crate::rules::{AggregateReductionResult, ReduceTo, ReduceToAggregate, ReductionResult};
44
use crate::traits::Problem;
55
use crate::types::{OptimizationValue, Or};
66
use serde::de::DeserializeOwned;
@@ -62,6 +62,7 @@ macro_rules! register_decision_variant {
6262
}
6363
}
6464

65+
// Decision<P> → P: both witness (identity config) and aggregate (solve + compare)
6566
$crate::inventory::submit! {
6667
$crate::rules::ReductionEntry {
6768
source_name: $name,
@@ -70,7 +71,14 @@ macro_rules! register_decision_variant {
7071
target_variant_fn: <$inner as $crate::traits::Problem>::variant,
7172
overhead_fn: || $crate::rules::ReductionOverhead::identity(&[$($sg_name),*]),
7273
module_path: module_path!(),
73-
reduce_fn: None,
74+
reduce_fn: Some(|any| {
75+
let source = any
76+
.downcast_ref::<$crate::models::decision::Decision<$inner>>()
77+
.expect(concat!($name, " witness reduction source type mismatch"));
78+
Box::new(
79+
<$crate::models::decision::Decision<$inner> as $crate::rules::ReduceTo<$inner>>::reduce_to(source),
80+
)
81+
}),
7482
reduce_aggregate_fn: Some(|any| {
7583
let source = any
7684
.downcast_ref::<$crate::models::decision::Decision<$inner>>()
@@ -79,7 +87,7 @@ macro_rules! register_decision_variant {
7987
<$crate::models::decision::Decision<$inner> as $crate::rules::ReduceToAggregate<$inner>>::reduce_to_aggregate(source),
8088
)
8189
}),
82-
capabilities: $crate::rules::EdgeCapabilities::aggregate_only(),
90+
capabilities: $crate::rules::EdgeCapabilities::both(),
8391
overhead_eval_fn: |any| {
8492
let source = any
8593
.downcast_ref::<$crate::models::decision::Decision<$inner>>()
@@ -245,6 +253,51 @@ where
245253
}
246254
}
247255

256+
/// Witness reduction result for `Decision<P> -> P`.
257+
///
258+
/// The configuration spaces are identical — a config that is optimal for
259+
/// `P` and meets the bound is a valid `Decision<P>` witness. The
260+
/// `extract_solution` is the identity function.
261+
#[derive(Debug, Clone)]
262+
pub struct DecisionToOptimizationWitnessResult<P>
263+
where
264+
P: Problem,
265+
P::Value: OptimizationValue,
266+
{
267+
target: P,
268+
}
269+
270+
impl<P> ReductionResult for DecisionToOptimizationWitnessResult<P>
271+
where
272+
P: DecisionProblemMeta + 'static,
273+
P::Value: OptimizationValue + Serialize + DeserializeOwned,
274+
{
275+
type Source = Decision<P>;
276+
type Target = P;
277+
278+
fn target_problem(&self) -> &Self::Target {
279+
&self.target
280+
}
281+
282+
fn extract_solution(&self, target_solution: &[usize]) -> Vec<usize> {
283+
target_solution.to_vec()
284+
}
285+
}
286+
287+
impl<P> ReduceTo<P> for Decision<P>
288+
where
289+
P: DecisionProblemMeta + Clone + 'static,
290+
P::Value: OptimizationValue + Serialize + DeserializeOwned,
291+
{
292+
type Result = DecisionToOptimizationWitnessResult<P>;
293+
294+
fn reduce_to(&self) -> Self::Result {
295+
DecisionToOptimizationWitnessResult {
296+
target: self.inner.clone(),
297+
}
298+
}
299+
}
300+
248301
#[cfg(test)]
249302
#[path = "../unit_tests/models/decision.rs"]
250303
mod tests;

src/models/graph/minimum_dominating_set.rs

Lines changed: 69 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,7 @@ inventory::submit! {
272272
}
273273
}
274274

275+
// Decision<MDS<SG, One>> → MDS<SG, One>: both witness (identity config) and aggregate (solve + compare)
275276
inventory::submit! {
276277
crate::rules::ReductionEntry {
277278
source_name: "DecisionMinimumDominatingSet",
@@ -280,7 +281,16 @@ inventory::submit! {
280281
target_variant_fn: <MinimumDominatingSet<SimpleGraph, One> as Problem>::variant,
281282
overhead_fn: || crate::rules::ReductionOverhead::identity(&["num_vertices", "num_edges"]),
282283
module_path: module_path!(),
283-
reduce_fn: None,
284+
reduce_fn: Some(|any| {
285+
let source = any
286+
.downcast_ref::<Decision<MinimumDominatingSet<SimpleGraph, One>>>()
287+
.expect("DecisionMinimumDominatingSet witness reduction source type mismatch");
288+
Box::new(
289+
<Decision<MinimumDominatingSet<SimpleGraph, One>> as crate::rules::ReduceTo<
290+
MinimumDominatingSet<SimpleGraph, One>,
291+
>>::reduce_to(source),
292+
)
293+
}),
284294
reduce_aggregate_fn: Some(|any| {
285295
let source = any
286296
.downcast_ref::<Decision<MinimumDominatingSet<SimpleGraph, One>>>()
@@ -291,7 +301,7 @@ inventory::submit! {
291301
>>::reduce_to_aggregate(source),
292302
)
293303
}),
294-
capabilities: crate::rules::EdgeCapabilities::aggregate_only(),
304+
capabilities: crate::rules::EdgeCapabilities::both(),
295305
overhead_eval_fn: |any| {
296306
let source = any
297307
.downcast_ref::<Decision<MinimumDominatingSet<SimpleGraph, One>>>()
@@ -314,6 +324,7 @@ inventory::submit! {
314324
}
315325
}
316326

327+
// Reverse edge: MDS<SG, One> → Decision<MDS<SG, One>> (Turing)
317328
inventory::submit! {
318329
crate::rules::ReductionEntry {
319330
source_name: "MinimumDominatingSet",
@@ -396,33 +407,63 @@ pub(crate) fn decision_canonical_model_example_specs(
396407
#[cfg(feature = "example-db")]
397408
pub(crate) fn decision_canonical_rule_example_specs(
398409
) -> Vec<crate::example_db::specs::RuleExampleSpec> {
399-
vec![crate::example_db::specs::RuleExampleSpec {
400-
id: "decision_minimum_dominating_set_to_minimum_dominating_set",
401-
build: || {
402-
use crate::example_db::specs::assemble_rule_example;
403-
use crate::export::SolutionPair;
404-
use crate::rules::{AggregateReductionResult, ReduceToAggregate};
405-
406-
let source = crate::models::decision::Decision::new(
407-
MinimumDominatingSet::new(
408-
SimpleGraph::new(5, vec![(0, 1), (0, 2), (1, 3), (2, 3), (2, 4), (3, 4)]),
409-
vec![1i32; 5],
410-
),
411-
2,
412-
);
413-
let result = source.reduce_to_aggregate();
414-
let target = result.target_problem();
415-
let config = vec![0, 0, 1, 1, 0];
416-
assemble_rule_example(
417-
&source,
418-
target,
419-
vec![SolutionPair {
420-
source_config: config.clone(),
421-
target_config: config,
422-
}],
423-
)
410+
vec![
411+
crate::example_db::specs::RuleExampleSpec {
412+
id: "decision_minimum_dominating_set_to_minimum_dominating_set",
413+
build: || {
414+
use crate::example_db::specs::assemble_rule_example;
415+
use crate::export::SolutionPair;
416+
use crate::rules::{AggregateReductionResult, ReduceToAggregate};
417+
418+
let source = crate::models::decision::Decision::new(
419+
MinimumDominatingSet::new(
420+
SimpleGraph::new(5, vec![(0, 1), (0, 2), (1, 3), (2, 3), (2, 4), (3, 4)]),
421+
vec![1i32; 5],
422+
),
423+
2,
424+
);
425+
let result = source.reduce_to_aggregate();
426+
let target = result.target_problem();
427+
let config = vec![0, 0, 1, 1, 0];
428+
assemble_rule_example(
429+
&source,
430+
target,
431+
vec![SolutionPair {
432+
source_config: config.clone(),
433+
target_config: config,
434+
}],
435+
)
436+
},
424437
},
425-
}]
438+
// One-weight variant: Decision<MDS<SG, One>> → MDS<SG, One> (aggregate)
439+
crate::example_db::specs::RuleExampleSpec {
440+
id: "decision_minimum_dominating_set_one_to_minimum_dominating_set_one",
441+
build: || {
442+
use crate::example_db::specs::assemble_rule_example;
443+
use crate::export::SolutionPair;
444+
use crate::rules::{AggregateReductionResult, ReduceToAggregate};
445+
446+
let source = crate::models::decision::Decision::new(
447+
MinimumDominatingSet::new(
448+
SimpleGraph::new(5, vec![(0, 1), (0, 2), (1, 3), (2, 3), (2, 4), (3, 4)]),
449+
vec![One; 5],
450+
),
451+
2,
452+
);
453+
let result = source.reduce_to_aggregate();
454+
let target = result.target_problem();
455+
let config = vec![0, 0, 1, 1, 0];
456+
assemble_rule_example(
457+
&source,
458+
target,
459+
vec![SolutionPair {
460+
source_config: config.clone(),
461+
target_config: config,
462+
}],
463+
)
464+
},
465+
},
466+
]
426467
}
427468

428469
/// Check if a set of vertices is a dominating set.

src/rules/decisionminimumvertexcover_hamiltoniancircuit.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -439,7 +439,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec<crate::example_db::specs::Ru
439439
1,
440440
);
441441
let source_config = vec![0, 1, 0];
442-
let reduction = source.reduce_to();
442+
let reduction = ReduceTo::<HamiltonianCircuit<SimpleGraph>>::reduce_to(&source);
443443
let target_config = reduction.build_target_witness(&source_config);
444444
assemble_rule_example(
445445
&source,

src/rules/ksatisfiability_registersufficiency.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -391,7 +391,6 @@ pub(crate) fn canonical_rule_example_specs() -> Vec<crate::example_db::specs::Ru
391391
}]
392392
}
393393

394-
395394
#[cfg(test)]
396395
#[path = "../unit_tests/rules/ksatisfiability_registersufficiency.rs"]
397396
mod tests;

src/unit_tests/example_db.rs

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -524,12 +524,9 @@ fn model_specs_are_optimal() {
524524
find_variant_entry(name, &variant)
525525
.and_then(|entry| (entry.solve_witness_fn)(spec.instance.as_any()))
526526
.map(|(config, _)| config)
527-
.or_else(|| {
528-
ilp_solver.solve_via_reduction(name, &variant, spec.instance.as_any())
529-
})
527+
.or_else(|| ilp_solver.solve_via_reduction(name, &variant, spec.instance.as_any()))
530528
} else {
531-
ilp_solver
532-
.solve_via_reduction(name, &variant, spec.instance.as_any())
529+
ilp_solver.solve_via_reduction(name, &variant, spec.instance.as_any())
533530
};
534531

535532
if let Some(best_config) = best_config {

src/unit_tests/reduction_graph.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -827,31 +827,31 @@ fn test_find_all_paths_mode_aggregate_rejects_witness_only() {
827827
}
828828

829829
#[test]
830-
fn test_decision_minimum_vertex_cover_has_direct_aggregate_edge() {
830+
fn test_decision_minimum_vertex_cover_has_both_edges() {
831831
let graph = ReductionGraph::new();
832832

833833
assert!(graph.has_direct_reduction_by_name_mode(
834834
"DecisionMinimumVertexCover",
835835
"MinimumVertexCover",
836836
ReductionMode::Aggregate,
837837
));
838-
assert!(!graph.has_direct_reduction_by_name_mode(
838+
assert!(graph.has_direct_reduction_by_name_mode(
839839
"DecisionMinimumVertexCover",
840840
"MinimumVertexCover",
841841
ReductionMode::Witness,
842842
));
843843
}
844844

845845
#[test]
846-
fn test_decision_minimum_dominating_set_has_direct_aggregate_edge() {
846+
fn test_decision_minimum_dominating_set_has_both_edges() {
847847
let graph = ReductionGraph::new();
848848

849849
assert!(graph.has_direct_reduction_by_name_mode(
850850
"DecisionMinimumDominatingSet",
851851
"MinimumDominatingSet",
852852
ReductionMode::Aggregate,
853853
));
854-
assert!(!graph.has_direct_reduction_by_name_mode(
854+
assert!(graph.has_direct_reduction_by_name_mode(
855855
"DecisionMinimumDominatingSet",
856856
"MinimumDominatingSet",
857857
ReductionMode::Witness,

src/unit_tests/rules/analysis.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,11 @@ fn test_find_dominated_rules_returns_known_set() {
266266
"GraphPartitioning {graph: \"SimpleGraph\"}",
267267
"QUBO {weight: \"f64\"}",
268268
),
269+
// KSat → DecisionMVC → MVC (via witness edge) dominates direct KSat → MVC
270+
(
271+
"KSatisfiability {k: \"K3\"}",
272+
"MinimumVertexCover {graph: \"SimpleGraph\", weight: \"i32\"}",
273+
),
269274
]
270275
.into_iter()
271276
.collect();

0 commit comments

Comments
 (0)