Skip to content

Commit 5c79557

Browse files
authored
Merge pull request #1214 from CodingThrust/feat/direct-scheduling-qubo
Add Phase 1 direct QUBO encodings for scheduling and assignment
2 parents 55823fb + 004ad17 commit 5c79557

174 files changed

Lines changed: 6376 additions & 1115 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎docs/paper/reductions.typ‎

Lines changed: 494 additions & 48 deletions
Large diffs are not rendered by default.

‎docs/paper/references.bib‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2332,3 +2332,14 @@ @article{axler2019
23322332
year = {2019},
23332333
url = {https://www.maths.tcd.ie/EMIS/journals/JIS/VOL22/Axler/axler17.pdf}
23342334
}
2335+
2336+
@article{een2006,
2337+
author = {Niklas E{\'e}n and Niklas S{\"o}rensson},
2338+
title = {Translating Pseudo-Boolean Constraints into {SAT}},
2339+
journal = {Journal on Satisfiability, Boolean Modeling and Computation},
2340+
year = {2006},
2341+
volume = {2},
2342+
number = {1--4},
2343+
pages = {1--26},
2344+
doi = {10.3233/SAT190014},
2345+
}

‎problemreductions-cli/src/cli.rs‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -507,17 +507,17 @@ mod tests {
507507
let cli = Cli::try_parse_from([
508508
"pred",
509509
"create",
510-
"3SAT",
510+
"2SAT",
511511
"--num-vars",
512512
"3",
513513
"--clauses",
514-
"1,2,3",
514+
"1,2",
515515
])
516516
.expect("variant alias parses");
517517
let Commands::Create(args) = cli.command else {
518518
panic!("expected create command");
519519
};
520-
assert_eq!(args.problem.as_deref(), Some("KSatisfiability/K3"));
520+
assert_eq!(args.problem.as_deref(), Some("KSatisfiability/K2"));
521521
assert_eq!(args.raw("num-vars"), Some("3"));
522522
}
523523

‎problemreductions-cli/src/commands/create/tests.rs‎

Lines changed: 4 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -747,7 +747,7 @@ fn test_create_path_constrained_network_flow_rejects_invalid_paths() {
747747
}
748748

749749
#[test]
750-
fn test_create_staff_scheduling_reports_invalid_schedule_without_panic() {
750+
fn test_create_staff_scheduling_rejects_ragged_schedule() {
751751
let cli = Cli::try_parse_from([
752752
"pred",
753753
"create",
@@ -775,14 +775,7 @@ fn test_create_staff_scheduling_reports_invalid_schedule_without_panic() {
775775
auto_json: false,
776776
};
777777

778-
let result = std::panic::catch_unwind(|| create(&args, &out));
779-
assert!(result.is_ok(), "create should return an error, not panic");
780-
let err = result.unwrap().unwrap_err().to_string();
781-
// parse_bool_rows catches ragged rows before validate_staff_scheduling_args
782-
assert!(
783-
err.contains("All rows") || err.contains("schedule 1 has 6 periods, expected 7"),
784-
"expected row-length validation error, got: {err}"
785-
);
778+
assert!(create(&args, &out).is_err());
786779
}
787780

788781
#[test]
@@ -955,7 +948,7 @@ fn test_create_timetable_design_outputs_problem_json() {
955948
}
956949

957950
#[test]
958-
fn test_create_timetable_design_reports_invalid_matrix_without_panic() {
951+
fn test_create_timetable_design_rejects_invalid_matrix() {
959952
let cli = Cli::try_parse_from([
960953
"pred",
961954
"create",
@@ -987,13 +980,7 @@ fn test_create_timetable_design_reports_invalid_matrix_without_panic() {
987980
auto_json: false,
988981
};
989982

990-
let result = std::panic::catch_unwind(|| create(&args, &out));
991-
assert!(result.is_ok(), "create should return an error, not panic");
992-
let err = result.unwrap().unwrap_err().to_string();
993-
assert!(
994-
err.contains("--craftsman-avail"),
995-
"expected timetable matrix validation error, got: {err}"
996-
);
983+
assert!(create(&args, &out).is_err());
997984
}
998985

999986
#[test]

‎problemreductions-cli/tests/cli_tests.rs‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@ fn path_text_preserves_variant_conversion_steps() {
5151

5252
#[test]
5353
fn polynomial_comparison_default_layout_and_opt_in_proofs() {
54-
let args = ["path", "MIS", "QUBO", "--compare", "--limit", "3"];
54+
// The feedback-arc route adds a quadratic-size candidate; retain both equivalent set-packing routes.
55+
let args = ["path", "MIS", "QUBO", "--compare", "--limit", "4"];
5556
let output = pred().args(args).output().unwrap();
5657
assert!(output.status.success());
5758
let text = String::from_utf8(output.stdout).unwrap();
@@ -63,14 +64,14 @@ fn polynomial_comparison_default_layout_and_opt_in_proofs() {
6364
assert!(text.contains("Path 1") && text.contains("num_vars"));
6465
assert!(!text.contains("[upper bound]") && !text.contains("[exact]"));
6566
assert!(text[pairs..unresolved]
66-
.contains("0 dominated, 1 equivalent, 2 proven incomparable, 0 unresolved"));
67+
.contains("1 dominated, 1 equivalent, 4 proven incomparable, 0 unresolved"));
6768
assert!(!text.contains("worst case:") && !text.contains("Generic candidate front"));
6869
assert!(text.contains("Paths 1, 2: proven incomparable"));
69-
assert!(text.contains("Paths 2, 3: Path 2 ≡ Path 3"));
70+
assert!(text.contains("Paths 2, 4: Path 2 ≡ Path 4"));
7071
assert!(
7172
text.contains("Ignores exact-cancellation families") && text.contains("bounded strata")
7273
);
73-
assert!(text.contains("2 proven incomparable, 0 unresolved"));
74+
assert!(text.contains("4 proven incomparable, 0 unresolved"));
7475
assert!(
7576
!text.contains("Field proofs") && !text.contains("Witness {") && !text.contains("f/g → ∞")
7677
);
@@ -80,7 +81,7 @@ fn polynomial_comparison_default_layout_and_opt_in_proofs() {
8081
let text = String::from_utf8(output.stdout).unwrap();
8182
assert!(
8283
text.contains("Paths 1, 2: proven incomparable")
83-
&& text.contains("Paths 2, 3: Path 2 ≡ Path 3")
84+
&& text.contains("Paths 2, 4: Path 2 ≡ Path 4")
8485
);
8586
assert!(text.contains("Field proofs") && text.contains("f/g → ∞"));
8687
assert!(text.contains("f ≡ g after expansion"));
@@ -92,14 +93,14 @@ fn polynomial_comparison_default_layout_and_opt_in_proofs() {
9293
let view = &json["polynomial_comparison"];
9394
assert_eq!(view["schema_version"], 5);
9495
assert_eq!(view["mode"], "generic");
95-
assert_eq!(view["counts"]["Incomparable"], 2);
96+
assert_eq!(view["counts"]["Incomparable"], 4);
9697
assert!(view.get("generic_counts").is_none());
9798
assert!(view.get("generic_candidate_front").is_none());
9899
assert!(view["pairs"][0]["fields"]["num_vars"]["forward"]["certificate"].is_object());
99-
let identity = &view["pairs"][2]["fields"]["num_vars"]["forward"]["certificate"];
100+
let identity = &view["pairs"][4]["fields"]["num_vars"]["forward"]["certificate"];
100101
assert_eq!(identity["kind"], "identity");
101102
assert_eq!(identity.as_object().unwrap().len(), 1);
102-
assert!(view["pairs"][2]["fields"]["num_vars"]
103+
assert!(view["pairs"][4]["fields"]["num_vars"]
103104
.get("generic")
104105
.is_none());
105106
let output = pred()

‎problemreductions-macros/src/lib.rs‎

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1340,38 +1340,6 @@ mod tests {
13401340
assert_eq!(error.to_string(), "duplicate `create` clause");
13411341
}
13421342

1343-
#[test]
1344-
fn declare_variants_generates_model_dispatch_without_solver_dispatch() {
1345-
let input: DeclareVariantsInput = syn::parse_quote! {
1346-
default Foo => "1",
1347-
};
1348-
let tokens = generate_declare_variants(&input).unwrap().to_string();
1349-
assert!(tokens.contains("factory :"), "expected factory field");
1350-
assert!(
1351-
tokens.contains("serialize_fn :"),
1352-
"expected serialize_fn field"
1353-
);
1354-
assert!(!tokens.contains("solve_typed_fn :"));
1355-
assert!(!tokens.contains("solve_fn :"));
1356-
assert!(
1357-
!tokens.contains("factory : None"),
1358-
"factory should not be None"
1359-
);
1360-
assert!(
1361-
!tokens.contains("serialize_fn : None"),
1362-
"serialize_fn should not be None"
1363-
);
1364-
assert!(!tokens.contains("find_cartesian"));
1365-
assert!(
1366-
!tokens.contains("find_best"),
1367-
"did not expect legacy find_best in tokens"
1368-
);
1369-
assert!(
1370-
!tokens.contains("SolutionSize :: Invalid"),
1371-
"did not expect legacy invalid fallback in tokens"
1372-
);
1373-
}
1374-
13751343
#[test]
13761344
fn reduction_rejects_unexpected_attribute() {
13771345
let extra_attr = syn::Ident::new("extra", proc_macro2::Span::call_site());

‎src/models/algebraic/consecutive_ones_submatrix.rs‎

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ inventory::submit! {
2121
description: "Find K columns of a binary matrix that can be permuted to have the consecutive ones property",
2222
fields: &[
2323
FieldInfo { name: "matrix", type_name: "Vec<Vec<bool>>", description: "m×n binary matrix A" },
24-
FieldInfo { name: "bound", type_name: "i64", description: "Required number of columns K" },
24+
FieldInfo { name: "bound", type_name: "usize", description: "Required number of columns K" },
2525
],
2626
}
2727
}
@@ -62,13 +62,13 @@ inventory::submit! {
6262
#[serde(try_from = "ConsecutiveOnesSubmatrixData")]
6363
pub struct ConsecutiveOnesSubmatrix {
6464
matrix: Vec<Vec<bool>>,
65-
bound: i64,
65+
bound: usize,
6666
}
6767

6868
#[derive(Deserialize)]
6969
struct ConsecutiveOnesSubmatrixData {
7070
matrix: Vec<Vec<bool>>,
71-
bound: i64,
71+
bound: usize,
7272
}
7373

7474
impl TryFrom<ConsecutiveOnesSubmatrixData> for ConsecutiveOnesSubmatrix {
@@ -85,19 +85,19 @@ impl ConsecutiveOnesSubmatrix {
8585
/// # Panics
8686
///
8787
/// Panics if `bound > n`, or if rows have inconsistent lengths.
88-
pub fn new(matrix: Vec<Vec<bool>>, bound: i64) -> Self {
88+
pub fn new(matrix: Vec<Vec<bool>>, bound: usize) -> Self {
8989
Self::try_new(matrix, bound).unwrap_or_else(|error| panic!("{error}"))
9090
}
9191

9292
fn try_new(
9393
matrix: Vec<Vec<bool>>,
94-
bound: i64,
94+
bound: usize,
9595
) -> Result<Self, crate::registry::ConstructionError> {
9696
let n = matrix.first().map_or(0, Vec::len);
9797
if matrix.iter().any(|row| row.len() != n) {
9898
return Err("all matrix rows must have the same length".into());
9999
}
100-
if !(bound < 0 || usize::try_from(bound).is_ok_and(|bound| bound <= n)) {
100+
if bound > n {
101101
return Err(format!("bound ({bound}) must be <= number of columns ({n})").into());
102102
}
103103
Ok(Self { matrix, bound })
@@ -109,7 +109,7 @@ impl ConsecutiveOnesSubmatrix {
109109
}
110110

111111
/// Returns the bound (the required number of columns).
112-
pub fn bound(&self) -> i64 {
112+
pub fn bound(&self) -> usize {
113113
self.bound
114114
}
115115

@@ -222,7 +222,7 @@ impl Problem for ConsecutiveOnesSubmatrix {
222222
.filter(|(_, &v)| v)
223223
.map(|(i, _)| i)
224224
.collect();
225-
if usize::try_from(self.bound) != Ok(selected.len()) {
225+
if self.bound != selected.len() {
226226
return Ok(crate::types::Or(false));
227227
}
228228
self.any_permutation_has_c1p(&selected)

‎src/models/algebraic/ilp.rs‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -690,9 +690,41 @@ crate::declare_variants! {
690690
ILP<i64, f64> => "num_vars^num_vars",
691691
}
692692

693+
impl crate::solvers::BruteForceProblem for ILP<bool> {
694+
fn dimensions(&self) -> Vec<usize> {
695+
vec![2; self.num_vars()]
696+
}
697+
}
698+
699+
crate::decision_problem_meta!(ILP<bool>, "DecisionILP");
700+
crate::register_decision_variant!(without_turing;
701+
ILP<bool>, "DecisionILP", "2^num_vars", &[],
702+
"Does a binary ILP have a feasible solution meeting its objective bound?",
703+
category: crate::registry::ProblemCategory::Algebraic,
704+
dims: [
705+
VariantDimension::new("variable", "bool", &["bool"]),
706+
VariantDimension::new("coefficient", "i64", &["i64"]),
707+
VariantDimension::new("bounds", "general", &["general"]),
708+
],
709+
fields: [
710+
FieldInfo { name: "variables", type_name: "Vec<IntegerVariable>", description: "Binary variable bounds [0, 1]" },
711+
FieldInfo { name: "constraints", type_name: "Vec<LinearConstraint<i64>>", description: "Sparse integer linear constraints" },
712+
FieldInfo { name: "objective", type_name: "Vec<(usize, i64)>", description: "Sparse integer objective coefficients" },
713+
FieldInfo { name: "sense", type_name: "ObjectiveSense", description: "Optimization direction" },
714+
FieldInfo { name: "bound", type_name: "i64", description: "Upper bound for minimization, lower bound for maximization" },
715+
],
716+
decode: |_, indices: Vec<usize>| indices.into_iter().map(|bit| i64::try_from(bit).expect("binary coordinate fits i64")).collect::<Vec<_>>()
717+
);
718+
693719
#[cfg(feature = "example-db")]
694720
pub(crate) fn canonical_model_example_specs() -> Vec<crate::example_db::specs::ModelExampleSpec> {
695721
vec![
722+
crate::example_db::specs::ModelExampleSpec {
723+
id: "decision_ilp",
724+
instance: Box::new(canonical_decision_example()),
725+
optimal_config: serde_json::json!([0, 0, 1]),
726+
optimal_value: serde_json::json!(true),
727+
},
696728
crate::example_db::specs::ModelExampleSpec {
697729
id: "ilp",
698730
instance: Box::new(
@@ -733,3 +765,17 @@ pub(crate) fn canonical_model_example_specs() -> Vec<crate::example_db::specs::M
733765
#[cfg(test)]
734766
#[path = "../../unit_tests/models/algebraic/ilp.rs"]
735767
mod tests;
768+
769+
#[cfg(feature = "example-db")]
770+
pub(crate) fn canonical_decision_example() -> crate::models::Decision<ILP<bool>> {
771+
crate::models::Decision::new(
772+
ILP::new(
773+
3,
774+
vec![LinearConstraint::eq(vec![(0, 1), (1, 1), (2, 1)], 1)],
775+
vec![(0, 3), (1, 2), (2, 1)],
776+
ObjectiveSense::Minimize,
777+
)
778+
.unwrap(),
779+
1,
780+
)
781+
}

‎src/models/decision.rs‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,16 @@ macro_rules! decision_problem_meta {
3131
/// variant's construction fields and decoder without adding another schema or default.
3232
/// Both decision/optimization edges derive their identity parameter transforms directly
3333
/// from the inner problem's canonical parameter schema.
34+
/// Prefix with `without_turing;` to omit the reverse optimization-to-decision Turing edge.
3435
#[macro_export]
3536
macro_rules! register_decision_variant {
36-
(
37+
(without_turing; $($body:tt)*) => {
38+
$crate::register_decision_variant!(@register witness_edge; $($body)*);
39+
};
40+
($inner:ty, $($body:tt)*) => {
41+
$crate::register_decision_variant!(@register edges; $inner, $($body)*);
42+
};
43+
(@register $edges:ident;
3744
$inner:ty,
3845
$name:literal,
3946
$complexity:literal,
@@ -61,16 +68,16 @@ macro_rules! register_decision_variant {
6168
}
6269
}
6370

64-
$crate::register_decision_variant!(@edges $inner, $name);
71+
$crate::register_decision_variant!(@$edges $inner, $name);
6572
$($(
6673
$crate::register_brute_force! {
6774
$crate::models::decision::Decision<$additional> decode $decoder,
6875
}
69-
$crate::register_decision_variant!(@edges $additional, $name);
76+
$crate::register_decision_variant!(@$edges $additional, $name);
7077
)*)?
7178
};
7279

73-
(@edges $inner:ty, $name:literal) => {
80+
(@witness_edge $inner:ty, $name:literal) => {
7481
// Decision<P> → P: both witness (identity config) and aggregate (solve + compare)
7582
$crate::inventory::submit! {
7683
$crate::rules::ReductionEntry {
@@ -114,7 +121,10 @@ macro_rules! register_decision_variant {
114121
turing: false,
115122
}
116123
}
124+
};
117125

126+
(@edges $inner:ty, $name:literal) => {
127+
$crate::register_decision_variant!(@witness_edge $inner, $name);
118128
// Reverse edge: P → Decision<P> (Turing/multi-query reduction via binary search)
119129
$crate::inventory::submit! {
120130
$crate::rules::ReductionEntry {

0 commit comments

Comments
 (0)