From 6398de17d8c536232dba0c0cb2112f374bf1bf3e Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Fri, 3 Apr 2026 19:16:00 +0800 Subject: [PATCH 01/25] =?UTF-8?q?feat:=20add=20SubsetSum=20=E2=86=92=20Par?= =?UTF-8?q?tition=20reduction=20rule=20(#973)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the classical padding reduction from Garey & Johnson (SP12-SP13). Handles all three cases: Σ=2T (no padding), Σ>2T (same-side extraction), and Σ<2T (opposite-side extraction). Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/paper/reductions.typ | 61 +++++++++++ src/rules/mod.rs | 2 + src/rules/subsetsum_partition.rs | 113 ++++++++++++++++++++ src/unit_tests/rules/subsetsum_partition.rs | 87 +++++++++++++++ 4 files changed, 263 insertions(+) create mode 100644 src/rules/subsetsum_partition.rs create mode 100644 src/unit_tests/rules/subsetsum_partition.rs diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 5d6f76025..2251552f7 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -6941,6 +6941,67 @@ where $P$ is a penalty weight large enough that any constraint violation costs m _Solution extraction._ Discard slack variables: return $bold(x)' [0..n]$. ] +#{ + let ss_part = load-example("SubsetSum", "Partition") + let ss_part_sol = ss_part.solutions.at(0) + let ss_part_n = ss_part.source.instance.sizes.len() + let ss_part_sizes = ss_part.target.instance.sizes.slice(0, ss_part_n) + let ss_part_padding = ss_part.target.instance.sizes.at(ss_part.target.instance.sizes.len() - 1) + let ss_part_sigma = ss_part_sizes.fold(0, (a, b) => a + b) + let ss_part_total = ss_part.target.instance.sizes.fold(0, (a, b) => a + b) + let ss_part_half = ss_part_total / 2 + let ss_part_selected = ss_part_sol.source_config.enumerate().filter(((i, x)) => x == 1).map(((i, _)) => i) + let ss_part_selected_sizes = ss_part_selected.map(i => ss_part_sizes.at(i)) + let ss_part_selected_sum = ss_part_selected_sizes.fold(0, (a, b) => a + b) + [ + #reduction-rule("SubsetSum", "Partition", + example: true, + example-caption: [#ss_part_n elements, target sum $B = #ss_part.source.instance.target$], + extra: [ + #pred-commands( + "pred create --example " + problem-spec(ss_part.source) + " -o subsetsum.json", + "pred reduce subsetsum.json --to " + target-spec(ss_part) + " -o bundle.json", + "pred solve bundle.json", + "pred evaluate subsetsum.json --config " + ss_part_sol.source_config.map(str).join(","), + ) + + *Step 1 -- Source instance.* The canonical Subset Sum instance has sizes $(#ss_part_sizes.map(str).join(", "))$, target $B = #ss_part.source.instance.target$, and total size $Sigma = #ss_part_sigma$. + + *Step 2 -- Add the padding element.* Because $Sigma = #ss_part_sigma < 2B = #ss_part_total$, the reduction appends one padding element of size $d = 2B - Sigma = #ss_part_padding$. The resulting Partition instance has sizes $(#ss_part.target.instance.sizes.map(str).join(", "))$ and total sum $#ss_part_total$, so each side of a balanced partition must sum to $#ss_part_half$. + + *Step 3 -- Verify the canonical witness.* The stored source witness is $bold(x) = (#ss_part_sol.source_config.map(str).join(", "))$, which selects indices $\{#ss_part_selected.map(str).join(", ")\}$ with sizes $(#ss_part_selected_sizes.map(str).join(", "))$ and sum $#ss_part_selected_sum = B$. The stored target witness is $bold(y) = (#ss_part_sol.target_config.map(str).join(", "))$: it puts those same original elements on one side and the padding element on the opposite side, so both sides sum to $#ss_part_half$. + + *Witness semantics.* For this example $Sigma < 2B$, so any balanced partition whose 1-side contains the padding element must be complemented when extracting a Subset Sum witness. The example DB stores the orientation with the padding on the 0-side, so the extracted source vector is the prefix of the Partition witness. + ], + )[ + This $O(n)$ reduction#footnote[The linear-time bound follows from one pass to sum the source sizes and one pass to copy them into the Partition instance, with at most one extra appended element.] formalizes the standard padding equivalence between Subset Sum and Partition @karp1972 @garey1979. For a source instance with $n$ elements, the target Partition instance keeps the original sizes and appends at most one new size, so the overhead is at most one extra element. + ][ + _Construction._ Given positive sizes $s_0, dots, s_(n-1)$ and target $B$, let + $ Sigma = sum_(i=0)^(n-1) s_i. $ + If $Sigma = 2B$, output the Partition instance on the original multiset $(s_0, dots, s_(n-1))$. Otherwise define + $ d = |Sigma - 2B| $ + and append one padding element of size $d$, obtaining the Partition multiset $(s_0, dots, s_(n-1), d)$. + + _Correctness._ ($arrow.r.double$) Suppose $X subset.eq {0, dots, n-1}$ satisfies $sum_(i in X) s_i = B$. + If $Sigma = 2B$, then the original multiset already has a balanced partition because the selected side and its complement both sum to $B$. + If $Sigma > 2B$, then $d = Sigma - 2B$, so the side $X union {d}$ has sum $B + d = B + (Sigma - 2B) = Sigma - B$, matching the complement of $X$. + If $Sigma < 2B$, then $d = 2B - Sigma$, so the side $(overline(X)) union {d}$ has sum $(Sigma - B) + d = (Sigma - B) + (2B - Sigma) = B$, matching the side $X$. + Hence every satisfying Subset Sum witness yields a balanced partition. + + ($arrow.l.double$) Conversely, let the constructed Partition instance be balanced. + If $Sigma = 2B$, either side of the partition already has sum $Sigma / 2 = B$, so its indicator vector restricted to the original elements is a Subset Sum witness. + If $Sigma > 2B$, the side containing the padding element has total sum $Sigma - B$; removing $d = Sigma - 2B$ leaves a subset of the original elements with sum $B$. + If $Sigma < 2B$, each side sums to $B$, and the side opposite the padding element is already a subset of the original elements with sum $B$. + Therefore any balanced Partition witness induces a satisfying Subset Sum witness. If the source Subset Sum instance is infeasible, no balanced Partition witness can exist. + + _Solution extraction._ Let $bold(c)$ be a Partition witness on the constructed target and let $c_n$ denote the padding bit when padding exists. + If no padding was added ($Sigma = 2B$), return the prefix $(c_0, dots, c_(n-1))$. + If $Sigma > 2B$, the desired Subset Sum side is the one containing the padding element, so return $(c_0, dots, c_(n-1))$ when $c_n = 1$ and its bitwise complement when $c_n = 0$. + If $Sigma < 2B$, the desired Subset Sum side is opposite the padding element, so return the prefix when $c_n = 0$ and its bitwise complement when $c_n = 1$. + ] + ] +} + #let part_ks = load-example("Partition", "Knapsack") #let part_ks_sol = part_ks.solutions.at(0) #let part_ks_sizes = part_ks.source.instance.sizes diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 31b4f163e..2fb5f3e74 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -42,6 +42,7 @@ mod spinglass_casts; pub(crate) mod spinglass_maxcut; pub(crate) mod spinglass_qubo; pub(crate) mod subsetsum_closestvectorproblem; +pub(crate) mod subsetsum_partition; #[cfg(test)] pub(crate) mod test_helpers; mod traits; @@ -263,6 +264,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec &Self::Target { + &self.target + } + + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + let source_bits = &target_solution[..self.source_len]; + + match self.padding_relation { + PaddingRelation::None => source_bits.to_vec(), + PaddingRelation::SameSide => { + let padding_is_selected = target_solution[self.source_len] == 1; + source_bits + .iter() + .map(|&bit| if padding_is_selected { bit } else { 1 - bit }) + .collect() + } + PaddingRelation::OppositeSide => { + let padding_is_selected = target_solution[self.source_len] == 1; + source_bits + .iter() + .map(|&bit| if padding_is_selected { 1 - bit } else { bit }) + .collect() + } + } + } +} + +fn biguint_to_u64(value: &BigUint) -> u64 { + value + .to_u64() + .expect("SubsetSum -> Partition requires all sizes and padding to fit in u64") +} + +#[reduction(overhead = { + num_elements = "num_elements + 1", +})] +impl ReduceTo for SubsetSum { + type Result = ReductionSubsetSumToPartition; + + fn reduce_to(&self) -> Self::Result { + let total: BigUint = self.sizes().iter().cloned().sum(); + let double_target = self.target() * 2u32; + let relation = total.cmp(&double_target); + let padding_relation = match relation { + Ordering::Equal => PaddingRelation::None, + Ordering::Greater => PaddingRelation::SameSide, + Ordering::Less => PaddingRelation::OppositeSide, + }; + + let mut sizes: Vec = self.sizes().iter().map(biguint_to_u64).collect(); + match relation { + Ordering::Equal => {} + Ordering::Greater => sizes.push(biguint_to_u64(&(total - double_target))), + Ordering::Less => sizes.push(biguint_to_u64(&(double_target - total))), + } + + ReductionSubsetSumToPartition { + target: Partition::new(sizes), + source_len: self.num_elements(), + padding_relation, + } + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + use crate::export::SolutionPair; + + vec![crate::example_db::specs::RuleExampleSpec { + id: "subsetsum_to_partition", + build: || { + crate::example_db::specs::rule_example_with_witness::<_, Partition>( + SubsetSum::new(vec![1u32, 5, 6, 8], 11u32), + SolutionPair { + source_config: vec![0, 1, 1, 0], + target_config: vec![0, 1, 1, 0, 0], + }, + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/subsetsum_partition.rs"] +mod tests; diff --git a/src/unit_tests/rules/subsetsum_partition.rs b/src/unit_tests/rules/subsetsum_partition.rs new file mode 100644 index 000000000..cda81b51d --- /dev/null +++ b/src/unit_tests/rules/subsetsum_partition.rs @@ -0,0 +1,87 @@ +#[cfg(feature = "example-db")] +use super::canonical_rule_example_specs; +use crate::models::misc::{Partition, SubsetSum}; +use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; +use crate::rules::traits::ReductionResult; +use crate::rules::ReduceTo; +use crate::solvers::BruteForce; +#[cfg(feature = "example-db")] +use crate::traits::Problem; + +#[test] +fn test_subsetsum_to_partition_closed_loop() { + let source = SubsetSum::new(vec![1u32, 5, 6, 8], 11u32); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + assert_eq!(target.sizes(), &[1, 5, 6, 8, 2]); + assert_eq!(target.num_elements(), source.num_elements() + 1); + + assert_satisfaction_round_trip_from_satisfaction_target( + &source, + &reduction, + "SubsetSum -> Partition closed loop", + ); +} + +#[test] +fn test_subsetsum_to_partition_sigma_greater_than_two_t_extraction() { + let source = SubsetSum::new(vec![10u32, 20, 30], 10u32); + let reduction = ReduceTo::::reduce_to(&source); + + assert_eq!(reduction.target_problem().sizes(), &[10, 20, 30, 40]); + assert_eq!(reduction.extract_solution(&[1, 0, 0, 1]), vec![1, 0, 0]); + assert_eq!(reduction.extract_solution(&[0, 1, 1, 0]), vec![1, 0, 0]); +} + +#[test] +fn test_subsetsum_to_partition_sigma_equals_two_t_extraction() { + let source = SubsetSum::new(vec![3u32, 5, 2, 6], 8u32); + let reduction = ReduceTo::::reduce_to(&source); + + assert_eq!(reduction.target_problem().sizes(), &[3, 5, 2, 6]); + assert_eq!(reduction.extract_solution(&[1, 1, 0, 0]), vec![1, 1, 0, 0]); +} + +#[test] +fn test_subsetsum_to_partition_unsatisfiable_instance_stays_unsatisfiable() { + let source = SubsetSum::new(vec![3u32, 7, 11], 5u32); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + assert_eq!(target.sizes(), &[3, 7, 11, 11]); + assert!(BruteForce::new().find_witness(&source).is_none()); + assert!(BruteForce::new().find_witness(target).is_none()); +} + +#[cfg(feature = "example-db")] +#[test] +fn test_subsetsum_to_partition_canonical_example_spec() { + let example = (canonical_rule_example_specs() + .into_iter() + .find(|spec| spec.id == "subsetsum_to_partition") + .expect("missing canonical SubsetSum -> Partition example spec") + .build)(); + + assert_eq!(example.source.problem, "SubsetSum"); + assert_eq!(example.target.problem, "Partition"); + assert_eq!( + example.target.instance["sizes"], + serde_json::json!([1, 5, 6, 8, 2]) + ); + assert_eq!(example.solutions.len(), 1); + assert_eq!(example.solutions[0].source_config, vec![0, 1, 1, 0]); + assert_eq!(example.solutions[0].target_config, vec![0, 1, 1, 0, 0]); + + let source: SubsetSum = serde_json::from_value(example.source.instance.clone()) + .expect("source example deserializes"); + let target: Partition = serde_json::from_value(example.target.instance.clone()) + .expect("target example deserializes"); + + assert!(source + .evaluate(&example.solutions[0].source_config) + .is_valid()); + assert!(target + .evaluate(&example.solutions[0].target_config) + .is_valid()); +} From 1784af77626d6f6e34347cf347d847bc9117d298 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Fri, 3 Apr 2026 19:45:23 +0800 Subject: [PATCH 02/25] =?UTF-8?q?feat:=20add=20NonTautology=20model=20and?= =?UTF-8?q?=20Satisfiability=20=E2=86=92=20NonTautology=20rule=20(#868)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the NonTautology (DNF falsifiability) model with Value=Or, and the De Morgan negation reduction from Satisfiability. Each CNF clause becomes a DNF disjunct with flipped literal signs; solution extraction is identity. Includes CLI/MCP support for NonTautology creation and example-db hooks. Co-Authored-By: Claude Opus 4.6 (1M context) --- problemreductions-cli/src/cli.rs | 4 + problemreductions-cli/src/commands/create.rs | 95 +++++++++++++++ problemreductions-cli/src/mcp/tools.rs | 36 +++++- problemreductions-cli/tests/cli_tests.rs | 4 +- src/lib.rs | 4 +- src/models/formula/mod.rs | 4 + src/models/formula/non_tautology.rs | 110 ++++++++++++++++++ src/models/mod.rs | 4 +- src/rules/mod.rs | 2 + src/rules/satisfiability_nontautology.rs | 77 ++++++++++++ .../models/formula/non_tautology.rs | 49 ++++++++ .../rules/satisfiability_nontautology.rs | 69 +++++++++++ src/unit_tests/solvers/customized/solver.rs | 7 +- 13 files changed, 455 insertions(+), 10 deletions(-) create mode 100644 src/models/formula/non_tautology.rs create mode 100644 src/rules/satisfiability_nontautology.rs create mode 100644 src/unit_tests/models/formula/non_tautology.rs create mode 100644 src/unit_tests/rules/satisfiability_nontautology.rs diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 1128f913b..41e196984 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -221,6 +221,7 @@ Flags by problem type: ShortestWeightConstrainedPath --graph, --edge-lengths, --edge-weights, --source-vertex, --target-vertex, --length-bound, --weight-bound MaximalIS --graph, --weights SAT, NAESAT --num-vars, --clauses + NonTautology --num-vars, --disjuncts KSAT --num-vars, --clauses [--k] QUBO --matrix SpinGlass --graph, --couplings, --fields @@ -417,6 +418,9 @@ pub struct CreateArgs { /// Clauses for SAT problems (semicolon-separated, e.g., "1,2;-1,3") #[arg(long)] pub clauses: Option, + /// Disjuncts for DNF problems (semicolon-separated, e.g., "1,2;-1,3") + #[arg(long)] + pub disjuncts: Option, /// Number of variables (for SAT/KSAT) #[arg(long)] pub num_vars: Option, diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 21183fb7d..866e124c9 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -70,6 +70,7 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool { && args.couplings.is_none() && args.fields.is_none() && args.clauses.is_none() + && args.disjuncts.is_none() && args.num_vars.is_none() && args.matrix.is_none() && args.k.is_none() @@ -513,6 +514,7 @@ fn type_format_hint(type_name: &str, graph_type: Option<&str>) -> &'static str { } "Vec>" => "semicolon-separated sets: \"0,1;1,2;0,2\"", "Vec" => "semicolon-separated clauses: \"1,2;-1,3\"", + "Vec>" => "semicolon-separated terms: \"1,2;-1,3\"", "Vec>" => "JSON 2D bool array: '[[true,false],[false,true]]'", "Vec>" => "semicolon-separated rows: \"1,0.5;0.5,2\"", "usize" => "integer", @@ -594,6 +596,7 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { } "Satisfiability" => "--num-vars 3 --clauses \"1,2;-1,3\"", "NAESatisfiability" => "--num-vars 3 --clauses \"1,2,-3;-1,2,3\"", + "NonTautology" => "--num-vars 2 --disjuncts \"1,2;-1,-2\"", "QuantifiedBooleanFormulas" => { "--num-vars 3 --clauses \"1,2;-1,3\" --quantifiers \"E,A,E\"" } @@ -2057,6 +2060,19 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { resolved_variant.clone(), ) } + "NonTautology" => { + let num_vars = args.num_vars.ok_or_else(|| { + anyhow::anyhow!( + "NonTautology requires --num-vars\n\n\ + Usage: pred create NonTautology --num-vars 2 --disjuncts \"1,2;-1,-2\"" + ) + })?; + let disjuncts = parse_disjuncts(args)?; + ( + ser(NonTautology::new(num_vars, disjuncts))?, + resolved_variant.clone(), + ) + } "KSatisfiability" => { let num_vars = args.num_vars.ok_or_else(|| { anyhow::anyhow!( @@ -5106,6 +5122,27 @@ fn parse_clauses(args: &CreateArgs) -> Result> { .collect() } +/// Parse `--disjuncts` as semicolon-separated conjunctions of comma-separated literals. +/// E.g., "1,2;-1,3" +fn parse_disjuncts(args: &CreateArgs) -> Result>> { + let disjuncts_str = args + .disjuncts + .as_deref() + .ok_or_else(|| anyhow::anyhow!("NonTautology requires --disjuncts (e.g., \"1,2;-1,3\")"))?; + + disjuncts_str + .split(';') + .map(|disjunct| { + disjunct + .trim() + .split(',') + .map(|s| s.trim().parse::()) + .collect::, _>>() + .map_err(anyhow::Error::from) + }) + .collect() +} + /// Parse `--sets` as semicolon-separated sets of comma-separated usize. /// E.g., "0,1;1,2;0,2" fn parse_sets(args: &CreateArgs) -> Result>> { @@ -7240,6 +7277,7 @@ mod tests { couplings: None, fields: None, clauses: None, + disjuncts: None, num_vars: None, matrix: None, k: None, @@ -7375,6 +7413,23 @@ mod tests { assert!(!all_data_flags_empty(&args)); } + #[test] + fn test_all_data_flags_empty_treats_disjuncts_as_input() { + let mut args = empty_args(); + args.disjuncts = Some("1,2;-1,-2".to_string()); + assert!(!all_data_flags_empty(&args)); + } + + #[test] + fn test_parse_disjuncts() { + let mut args = empty_args(); + args.disjuncts = Some("1,2;-1,3".to_string()); + + let disjuncts = parse_disjuncts(&args).unwrap(); + + assert_eq!(disjuncts, vec![vec![1, 2], vec![-1, 3]]); + } + #[test] fn test_parse_potential_edges() { let mut args = empty_args(); @@ -7441,6 +7496,46 @@ mod tests { let _ = std::fs::remove_file(output_path); } + #[test] + fn test_create_non_tautology_json() { + let cli = Cli::try_parse_from([ + "pred", + "create", + "NonTautology", + "--num-vars", + "2", + "--disjuncts", + "1,2;-1,-2", + ]) + .expect("parse create command"); + + let args = match cli.command { + Commands::Create(args) => args, + _ => panic!("expected create command"), + }; + + let output_path = temp_output_path("non_tautology"); + let out = OutputConfig { + output: Some(output_path.clone()), + quiet: true, + json: false, + auto_json: false, + }; + + create(&args, &out).expect("create NonTautology JSON"); + + let created: serde_json::Value = + serde_json::from_str(&fs::read_to_string(&output_path).unwrap()).unwrap(); + fs::remove_file(output_path).ok(); + + assert_eq!(created["type"], "NonTautology"); + assert_eq!(created["data"]["num_vars"], 2); + assert_eq!( + created["data"]["disjuncts"], + serde_json::json!([[1, 2], [-1, -2]]) + ); + } + #[test] fn test_create_disjoint_connecting_paths_rejects_overlapping_terminal_pairs() { let mut args = empty_args(); diff --git a/problemreductions-cli/src/mcp/tools.rs b/problemreductions-cli/src/mcp/tools.rs index 5ed86c63e..bb600f9d1 100644 --- a/problemreductions-cli/src/mcp/tools.rs +++ b/problemreductions-cli/src/mcp/tools.rs @@ -1,6 +1,6 @@ use crate::util; use problemreductions::models::algebraic::QUBO; -use problemreductions::models::formula::{CNFClause, Satisfiability}; +use problemreductions::models::formula::{CNFClause, NonTautology, Satisfiability}; use problemreductions::models::graph::{ KClique, LongestCircuit, MaxCut, MaximumClique, MaximumIndependentSet, MaximumMatching, MinimumDominatingSet, MinimumSumMulticenter, MinimumVertexCover, SpinGlass, TravelingSalesman, @@ -70,7 +70,7 @@ pub struct CreateProblemParams { )] pub problem_type: String, #[schemars( - description = "Problem parameters as JSON object. Graph problems: {\"edges\": \"0-1,1-2\", \"weights\": \"1,2,3\"}. SAT: {\"num_vars\": 3, \"clauses\": \"1,2;-1,3\"}. QUBO: {\"matrix\": \"1,0.5;0.5,2\"}. KColoring: {\"edges\": \"0-1,1-2\", \"k\": 3}. KClique: {\"edges\": \"0-1,0-2,1-3,2-3,2-4,3-4\", \"k\": 3}. Factoring: {\"target\": 15, \"bits_m\": 4, \"bits_n\": 4}. Random graph: {\"random\": true, \"num_vertices\": 10, \"edge_prob\": 0.3}. Geometry graphs (use with MIS/KingsSubgraph etc.): {\"positions\": \"0,0;1,0;1,1\"}. UnitDiskGraph: {\"positions\": \"0.0,0.0;1.0,0.0\", \"radius\": 1.5}" + description = "Problem parameters as JSON object. Graph problems: {\"edges\": \"0-1,1-2\", \"weights\": \"1,2,3\"}. SAT: {\"num_vars\": 3, \"clauses\": \"1,2;-1,3\"}. NonTautology: {\"num_vars\": 2, \"disjuncts\": \"1,2;-1,-2\"}. QUBO: {\"matrix\": \"1,0.5;0.5,2\"}. KColoring: {\"edges\": \"0-1,1-2\", \"k\": 3}. KClique: {\"edges\": \"0-1,0-2,1-3,2-3,2-4,3-4\", \"k\": 3}. Factoring: {\"target\": 15, \"bits_m\": 4, \"bits_n\": 4}. Random graph: {\"random\": true, \"num_vertices\": 10, \"edge_prob\": 0.3}. Geometry graphs (use with MIS/KingsSubgraph etc.): {\"positions\": \"0,0;1,0;1,1\"}. UnitDiskGraph: {\"positions\": \"0.0,0.0;1.0,0.0\", \"radius\": 1.5}" )] pub params: serde_json::Value, } @@ -451,6 +451,16 @@ impl McpServer { let variant = BTreeMap::new(); (ser(Satisfiability::new(num_vars, clauses))?, variant) } + "NonTautology" => { + let num_vars = params + .get("num_vars") + .and_then(|v| v.as_u64()) + .map(|v| v as usize) + .ok_or_else(|| anyhow::anyhow!("NonTautology requires 'num_vars'"))?; + let disjuncts = parse_disjuncts_from_params(params)?; + let variant = BTreeMap::new(); + (ser(NonTautology::new(num_vars, disjuncts))?, variant) + } "KSatisfiability" => { let num_vars = params .get("num_vars") @@ -1436,6 +1446,28 @@ fn parse_clauses_from_params(params: &serde_json::Value) -> anyhow::Result anyhow::Result>> { + let disjuncts_str = params + .get("disjuncts") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + anyhow::anyhow!("NonTautology requires 'disjuncts' parameter (e.g., \"1,2;-1,3\")") + })?; + + disjuncts_str + .split(';') + .map(|disjunct| { + disjunct + .trim() + .split(',') + .map(|s| s.trim().parse::()) + .collect::, _>>() + .map_err(anyhow::Error::from) + }) + .collect() +} + /// Parse `matrix` field from JSON params as semicolon-separated rows. fn parse_matrix_from_params(params: &serde_json::Value) -> anyhow::Result>> { let matrix_str = params diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index 451930697..a692e32d5 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -193,7 +193,9 @@ fn test_solve_balanced_complete_bipartite_subgraph_default_solver_uses_ilp() { assert_eq!(json["reduced_to"], "ILP"); assert_eq!(json["evaluation"], "Or(true)"); assert!( - json["solution"].as_array().is_some_and(|solution| !solution.is_empty()), + json["solution"] + .as_array() + .is_some_and(|solution| !solution.is_empty()), "expected a non-empty solution array, got: {stdout}" ); diff --git a/src/lib.rs b/src/lib.rs index 2eca06109..7099fb515 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -46,8 +46,8 @@ pub mod prelude { ConsecutiveOnesMatrixAugmentation, QuadraticAssignment, SparseMatrixCompression, BMF, QUBO, }; pub use crate::models::formula::{ - CNFClause, CircuitSAT, KSatisfiability, NAESatisfiability, QuantifiedBooleanFormulas, - Satisfiability, + CNFClause, CircuitSAT, KSatisfiability, NAESatisfiability, NonTautology, + QuantifiedBooleanFormulas, Satisfiability, }; pub use crate::models::graph::{ AcyclicPartition, BalancedCompleteBipartiteSubgraph, BicliqueCover, diff --git a/src/models/formula/mod.rs b/src/models/formula/mod.rs index 5f9417872..b43ed3626 100644 --- a/src/models/formula/mod.rs +++ b/src/models/formula/mod.rs @@ -4,18 +4,21 @@ //! - [`Satisfiability`]: Boolean satisfiability (SAT) with CNF clauses //! - [`NAESatisfiability`]: Not-All-Equal satisfiability with CNF clauses //! - [`KSatisfiability`]: K-SAT where each clause has exactly K literals +//! - [`NonTautology`]: Does a DNF formula have a falsifying assignment? //! - [`CircuitSAT`]: Boolean circuit satisfiability //! - [`QuantifiedBooleanFormulas`]: Quantified Boolean Formulas (QBF) — PSPACE-complete pub(crate) mod circuit; pub(crate) mod ksat; pub(crate) mod nae_satisfiability; +pub(crate) mod non_tautology; pub(crate) mod qbf; pub(crate) mod sat; pub use circuit::{Assignment, BooleanExpr, BooleanOp, Circuit, CircuitSAT}; pub use ksat::KSatisfiability; pub use nae_satisfiability::NAESatisfiability; +pub use non_tautology::NonTautology; pub use qbf::{QuantifiedBooleanFormulas, Quantifier}; pub use sat::{CNFClause, Satisfiability}; @@ -25,6 +28,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec>", description: "DNF disjuncts, each a conjunction of signed literals" }, + ], + } +} + +/// Non-Tautology for Boolean formulas in disjunctive normal form (DNF). +/// +/// The instance asks whether there exists an assignment under which the DNF +/// formula evaluates to false. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NonTautology { + /// Number of Boolean variables. + num_vars: usize, + /// DNF disjuncts, each represented as a conjunction of signed literals. + disjuncts: Vec>, +} + +impl NonTautology { + /// Create a new NonTautology instance. + pub fn new(num_vars: usize, disjuncts: Vec>) -> Self { + Self { + num_vars, + disjuncts, + } + } + + /// Get the number of Boolean variables. + pub fn num_vars(&self) -> usize { + self.num_vars + } + + /// Get the number of disjuncts. + pub fn num_disjuncts(&self) -> usize { + self.disjuncts.len() + } + + /// Get the disjuncts. + pub fn disjuncts(&self) -> &[Vec] { + &self.disjuncts + } + + fn literal_is_true(lit: i32, config: &[usize]) -> bool { + let var = lit.unsigned_abs() as usize - 1; + let value = config.get(var).copied().unwrap_or(0); + if lit > 0 { + value == 1 + } else { + value == 0 + } + } +} + +impl Problem for NonTautology { + const NAME: &'static str = "NonTautology"; + type Value = crate::types::Or; + + fn dims(&self) -> Vec { + vec![2; self.num_vars] + } + + fn evaluate(&self, config: &[usize]) -> crate::types::Or { + let e_value = self.disjuncts.iter().any(|disjunct| { + disjunct + .iter() + .all(|&lit| Self::literal_is_true(lit, config)) + }); + crate::types::Or(!e_value) + } + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![] + } +} + +crate::declare_variants! { + default NonTautology => "1.307^num_vars", +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "non_tautology", + instance: Box::new(NonTautology::new(2, vec![vec![1, 2], vec![-1, -2]])), + optimal_config: vec![1, 0], + optimal_value: serde_json::json!(true), + }] +} + +#[cfg(test)] +#[path = "../../unit_tests/models/formula/non_tautology.rs"] +mod tests; diff --git a/src/models/mod.rs b/src/models/mod.rs index 290cb1283..3baa73f03 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -14,8 +14,8 @@ pub use algebraic::{ ConsecutiveOnesSubmatrix, QuadraticAssignment, SparseMatrixCompression, BMF, ILP, QUBO, }; pub use formula::{ - CNFClause, CircuitSAT, KSatisfiability, NAESatisfiability, QuantifiedBooleanFormulas, - Quantifier, Satisfiability, + CNFClause, CircuitSAT, KSatisfiability, NAESatisfiability, NonTautology, + QuantifiedBooleanFormulas, Quantifier, Satisfiability, }; pub use graph::{ AcyclicPartition, BalancedCompleteBipartiteSubgraph, BicliqueCover, BiconnectivityAugmentation, diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 2fb5f3e74..2d1e8db95 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -38,6 +38,7 @@ pub(crate) mod sat_coloring; pub(crate) mod sat_ksat; pub(crate) mod sat_maximumindependentset; pub(crate) mod sat_minimumdominatingset; +pub(crate) mod satisfiability_nontautology; mod spinglass_casts; pub(crate) mod spinglass_maxcut; pub(crate) mod spinglass_qubo; @@ -261,6 +262,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec &Self::Target { + &self.target + } + + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + target_solution.to_vec() + } +} + +#[reduction(overhead = { + num_vars = "num_vars", + num_disjuncts = "num_clauses", +})] +impl ReduceTo for Satisfiability { + type Result = ReductionSATToNonTautology; + + fn reduce_to(&self) -> Self::Result { + let disjuncts = self + .clauses() + .iter() + .map(|clause| clause.literals.iter().map(|&lit| -lit).collect()) + .collect(); + + ReductionSATToNonTautology { + target: NonTautology::new(self.num_vars(), disjuncts), + } + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + use crate::export::SolutionPair; + use crate::models::formula::CNFClause; + + vec![crate::example_db::specs::RuleExampleSpec { + id: "satisfiability_to_nontautology", + build: || { + crate::example_db::specs::rule_example_with_witness::<_, NonTautology>( + Satisfiability::new( + 3, + vec![ + CNFClause::new(vec![1, 2]), + CNFClause::new(vec![-1, 3]), + CNFClause::new(vec![-2, -3]), + ], + ), + SolutionPair { + source_config: vec![1, 0, 1], + target_config: vec![1, 0, 1], + }, + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/satisfiability_nontautology.rs"] +mod tests; diff --git a/src/unit_tests/models/formula/non_tautology.rs b/src/unit_tests/models/formula/non_tautology.rs new file mode 100644 index 000000000..df648187d --- /dev/null +++ b/src/unit_tests/models/formula/non_tautology.rs @@ -0,0 +1,49 @@ +use crate::models::formula::NonTautology; +use crate::solvers::BruteForce; +use crate::traits::Problem; +use crate::types::Or; + +#[test] +fn test_non_tautology_creation() { + let problem = NonTautology::new(3, vec![vec![1, -2], vec![-1, 3]]); + + assert_eq!(problem.num_vars(), 3); + assert_eq!(problem.num_disjuncts(), 2); + assert_eq!(problem.disjuncts(), &[vec![1, -2], vec![-1, 3]]); + assert_eq!(problem.dims(), vec![2, 2, 2]); + assert_eq!(problem.num_variables(), 3); +} + +#[test] +fn test_non_tautology_evaluate_marks_falsifying_assignments() { + let problem = NonTautology::new(2, vec![vec![1, 2], vec![-1, -2]]); + + assert_eq!(problem.evaluate(&[1, 0]), Or(true)); + assert_eq!(problem.evaluate(&[0, 1]), Or(true)); + assert_eq!(problem.evaluate(&[1, 1]), Or(false)); + assert_eq!(problem.evaluate(&[0, 0]), Or(false)); +} + +#[test] +fn test_non_tautology_solver() { + let solver = BruteForce::new(); + + let non_tautology = NonTautology::new(2, vec![vec![1, 2], vec![-1, -2]]); + let witnesses = solver.find_all_witnesses(&non_tautology); + assert_eq!(witnesses.len(), 2); + assert!(witnesses.contains(&vec![1, 0])); + assert!(witnesses.contains(&vec![0, 1])); + + let tautology = NonTautology::new(1, vec![vec![1], vec![-1]]); + assert_eq!(solver.find_witness(&tautology), None); +} + +#[test] +fn test_non_tautology_serialization() { + let problem = NonTautology::new(2, vec![vec![1, -2], vec![-1]]); + let json = serde_json::to_string(&problem).unwrap(); + let round_trip: NonTautology = serde_json::from_str(&json).unwrap(); + + assert_eq!(round_trip.num_vars(), 2); + assert_eq!(round_trip.disjuncts(), &[vec![1, -2], vec![-1]]); +} diff --git a/src/unit_tests/rules/satisfiability_nontautology.rs b/src/unit_tests/rules/satisfiability_nontautology.rs new file mode 100644 index 000000000..e7a2101e1 --- /dev/null +++ b/src/unit_tests/rules/satisfiability_nontautology.rs @@ -0,0 +1,69 @@ +use crate::models::formula::{CNFClause, NonTautology, Satisfiability}; +use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; +use crate::rules::{ReduceTo, ReductionGraph, ReductionResult}; +use crate::solvers::BruteForce; + +#[test] +fn test_satisfiability_to_non_tautology_structure() { + let source = Satisfiability::new( + 3, + vec![CNFClause::new(vec![1, -2]), CNFClause::new(vec![-1, 3])], + ); + + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + assert_eq!(target.num_vars(), 3); + assert_eq!(target.num_disjuncts(), 2); + assert_eq!(target.disjuncts(), &[vec![-1, 2], vec![1, -3]]); +} + +#[test] +fn test_satisfiability_to_non_tautology_closed_loop() { + let source = Satisfiability::new( + 3, + vec![ + CNFClause::new(vec![1, 2]), + CNFClause::new(vec![-1, 3]), + CNFClause::new(vec![-2, -3]), + ], + ); + + let reduction = ReduceTo::::reduce_to(&source); + assert_satisfaction_round_trip_from_satisfaction_target( + &source, + &reduction, + "SAT->NonTautology closed loop", + ); +} + +#[test] +fn test_satisfiability_to_non_tautology_unsatisfiable_source_has_no_target_witness() { + let source = Satisfiability::new(1, vec![CNFClause::new(vec![1]), CNFClause::new(vec![-1])]); + + let reduction = ReduceTo::::reduce_to(&source); + let solver = BruteForce::new(); + + assert_eq!(solver.find_witness(reduction.target_problem()), None); +} + +#[test] +fn test_satisfiability_to_non_tautology_extract_solution_is_identity() { + let source = Satisfiability::new(2, vec![CNFClause::new(vec![1]), CNFClause::new(vec![2])]); + + let reduction = ReduceTo::::reduce_to(&source); + let target_solution = BruteForce::new() + .find_witness(reduction.target_problem()) + .expect("target should have a witness"); + + assert_eq!( + reduction.extract_solution(&target_solution), + target_solution + ); +} + +#[test] +fn test_reduction_graph_registers_satisfiability_to_non_tautology() { + let graph = ReductionGraph::new(); + assert!(graph.has_direct_reduction_by_name("Satisfiability", "NonTautology")); +} diff --git a/src/unit_tests/solvers/customized/solver.rs b/src/unit_tests/solvers/customized/solver.rs index 8c966621a..cc16e8fe3 100644 --- a/src/unit_tests/solvers/customized/solver.rs +++ b/src/unit_tests/solvers/customized/solver.rs @@ -297,8 +297,7 @@ fn test_customized_solver_matches_exhaustive_search_for_small_partial_feedback_e for graph in all_simple_graphs(4) { for max_cycle_length in 3..=4 { for budget in 0..=graph.num_edges() { - let problem = - PartialFeedbackEdgeSet::new(graph.clone(), budget, max_cycle_length); + let problem = PartialFeedbackEdgeSet::new(graph.clone(), budget, max_cycle_length); let exact_feasible = exact_partial_feedback_edge_set_feasible(&graph, budget, max_cycle_length); let custom = CustomizedSolver::new().solve_dyn(&problem); @@ -380,7 +379,9 @@ fn test_customized_solver_rooted_tree_arrangement_canonical_example() { fn test_customized_solver_matches_exhaustive_search_for_small_rooted_tree_arrangement_instances() { for graph in all_simple_graphs(4) { let exact_min_stretch = exact_rooted_tree_arrangement_min_stretch(&graph); - let max_bound = graph.num_edges().saturating_mul(graph.num_vertices().saturating_sub(1)); + let max_bound = graph + .num_edges() + .saturating_mul(graph.num_vertices().saturating_sub(1)); for bound in 0..=max_bound { let problem = RootedTreeArrangement::new(graph.clone(), bound); From 188f24b8b6951e9ac56401e051633f5e5f9b2633 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Fri, 3 Apr 2026 20:07:22 +0800 Subject: [PATCH 03/25] =?UTF-8?q?feat:=20add=20PartitionIntoCliques=20mode?= =?UTF-8?q?l=20and=20KColoring=20=E2=86=92=20PartitionIntoCliques=20rule?= =?UTF-8?q?=20(#844)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the PartitionIntoCliques graph model (Value=Or, partitions vertices into K cliques) and the complement-graph reduction from KColoring. Color classes in G become cliques in the complement graph. Includes CLI support, example-db entries, paper documentation, and tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/paper/reductions.typ | 64 +++++++++ problemreductions-cli/src/cli.rs | 1 + problemreductions-cli/src/commands/create.rs | 18 +++ src/lib.rs | 7 +- src/models/graph/mod.rs | 4 + src/models/graph/partition_into_cliques.rs | 126 ++++++++++++++++++ src/models/mod.rs | 10 +- src/rules/kcoloring_partitionintocliques.rs | 90 +++++++++++++ src/rules/mod.rs | 2 + .../models/graph/partition_into_cliques.rs | 67 ++++++++++ src/unit_tests/prelude.rs | 7 + src/unit_tests/reduction_graph.rs | 9 +- .../rules/kcoloring_partitionintocliques.rs | 53 ++++++++ 13 files changed, 449 insertions(+), 9 deletions(-) create mode 100644 src/models/graph/partition_into_cliques.rs create mode 100644 src/rules/kcoloring_partitionintocliques.rs create mode 100644 src/unit_tests/models/graph/partition_into_cliques.rs create mode 100644 src/unit_tests/rules/kcoloring_partitionintocliques.rs diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 2251552f7..968dd4c18 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -167,6 +167,7 @@ "MultipleChoiceBranching": [Multiple Choice Branching], "MultipleCopyFileAllocation": [Multiple Copy File Allocation], "ExpectedRetrievalCost": [Expected Retrieval Cost], + "PartitionIntoCliques": [Partition Into Cliques], "MultiprocessorScheduling": [Multiprocessor Scheduling], "PartitionIntoPathsOfLength2": [Partition into Paths of Length 2], "PartitionIntoTriangles": [Partition Into Triangles], @@ -1586,6 +1587,36 @@ is feasible: each set induces a connected subgraph, the component weights are $2 ] ] } +#{ + let x = load-model-example("PartitionIntoCliques") + let nv = graph-num-vertices(x.instance) + let k = x.instance.num_cliques + let edges = x.instance.graph.edges + let sol = (config: x.optimal_config, metric: x.optimal_value) + let clique-groups = range(k).map(c => sol.config.enumerate().filter(((i, v)) => v == c).map(((i, _)) => i)).filter(g => g.len() > 0) + [ + #problem-def("PartitionIntoCliques")[ + Given an undirected graph $G = (V, E)$ and an integer $K$, determine whether there exists a partition $V = V_1 union dots union V_t$ with $t <= K$ such that every set $V_i$ induces a clique in $G$. + ][ + Partition Into Cliques is NP-complete by complementation with $k$-Coloring @garey1979. A partition of $G$ into at most $K$ cliques is exactly a proper $K$-coloring of the complement graph $overline(G)$. This equivalence yields an $O^*(2^n)$ exact algorithm by applying inclusion-exclusion style coloring algorithms to $overline(G)$ @bjorklund2009. + + *Example.* Consider the graph $G$ with $n = #nv$ vertices, clique bound $K = #k$, and edges #edges.map(((u, v)) => [${#u, #v}$]).join(", "). The partition #clique-groups.enumerate().map(((i, group)) => [$V_#(i + 1) = {#group.map(v => $v_#v$).join(", ")}$]).join(", ") is valid: the non-singleton groups use edges ${0, 3}$ and ${1, 2}$, and any singleton is a clique. + + #pred-commands( + "pred create --example " + problem-spec(x) + " -o partition-into-cliques.json", + "pred solve partition-into-cliques.json", + "pred evaluate partition-into-cliques.json --config " + x.optimal_config.map(str).join(","), + ) + + #figure({ + let hg = house-graph() + draw-node-colors(hg.vertices, edges, sol.config) + }, + caption: [Partition Into Cliques instance on the complement of the house graph. Clique classes are #clique-groups.enumerate().map(((i, group)) => [$V_#(i + 1) = {#group.map(v => $v_#v$).join(", ")}$]).join(", ").], + ) + ] + ] +} #{ let x = load-model-example("MinimumDominatingSet") let nv = graph-num-vertices(x.instance) @@ -6796,6 +6827,39 @@ where $P$ is a penalty weight large enough that any constraint violation costs m _Solution extraction._ For each vertex $v$, find $c$ with $x_(v,c) = 1$. ] +#let kc_pic = load-example("KColoring", "PartitionIntoCliques") +#let kc_pic_sol = kc_pic.solutions.at(0) +#let kc_pic_target_edges = kc_pic.target.instance.graph.edges +#let kc_pic_groups = range(kc_pic.source.instance.num_colors).map(c => kc_pic_sol.source_config.enumerate().filter(((i, v)) => v == c).map(((i, _)) => i)).filter(g => g.len() > 0) +#reduction-rule("KColoring", "PartitionIntoCliques", + example: true, + example-caption: [House graph: color classes in $G$ become clique classes in $overline(G)$.], + extra: [ + #pred-commands( + "pred create --example " + problem-spec(kc_pic.source) + " -o kcoloring.json", + "pred reduce kcoloring.json --to " + target-spec(kc_pic) + " -o bundle.json", + "pred solve bundle.json", + "pred evaluate kcoloring.json --config " + kc_pic_sol.source_config.map(str).join(","), + ) + + *Step 1.* Start from the proper coloring $(#kc_pic_sol.source_config.map(str).join(", "))$ of the house graph, so the color classes are #kc_pic_groups.enumerate().map(((i, group)) => [$C_#(i + 1) = {#group.map(v => $v_#v$).join(", ")}$]).join(", "). + + *Step 2.* Build the complement graph $overline(G)$ by adding exactly the non-edges of $G$. For this example the complement edges are #kc_pic_target_edges.map(((u, v)) => [${#u, #v}$]).join(", "). + + *Step 3.* Keep the same labels. Then $C_1 = {v_0, v_3}$ and $C_2 = {v_1, v_2}$ are cliques in $overline(G)$, and $C_3 = {v_4}$ is a singleton clique. + ], +)[ + A proper $k$-coloring partitions the vertices of $G$ into at most $k$ independent sets. In the complement graph $overline(G)$, those same vertex classes become cliques, so the same labeling solves Partition Into Cliques with the same bound. +][ + _Construction._ Given KColoring instance $(G = (V, E), k)$ with $n = |V|$ and $m = |E|$, construct the complement graph $overline(G) = (V, overline(E))$ where + $ overline(E) = { {u, v} : u < v, {u, v} in.not E }. $ + Set the clique bound to the same value $k$. The target instance is $(overline(G), k)$, with $n$ vertices and $n(n - 1) / 2 - m$ edges. + + _Correctness._ ($arrow.r.double$) If $c : V -> {0, dots, k - 1}$ is a proper coloring of $G$, then any two vertices $u, v$ with $c(u) = c(v)$ are non-adjacent in $G$. Hence ${u, v} in overline(E)$, so every color class induces a clique in $overline(G)$. ($arrow.l.double$) If a labeling partitions $overline(G)$ into at most $k$ cliques, then any two vertices sharing a label are adjacent in $overline(G)$ and therefore non-adjacent in $G$. Thus each label class is an independent set in $G$, which is exactly a proper $k$-coloring. + + _Solution extraction._ Identity: return the same vertex labels. +] + #reduction-rule("MaximumSetPacking", "QUBO")[ Set packing selects mutually disjoint sets of maximum total weight. Two sets conflict if and only if they share a universe element — the same adjacency structure as an independent set on the _intersection graph_. This reduction builds the intersection graph implicitly and applies the IS penalty method directly: each set becomes a QUBO variable, diagonal entries reward selection, and off-diagonal entries penalize pairs of overlapping sets with a penalty large enough to forbid any overlap. ][ diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 41e196984..f8e44ff9f 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -228,6 +228,7 @@ Flags by problem type: KColoring --graph, --k KClique --graph, --k MinimumMultiwayCut --graph, --terminals, --edge-weights + PartitionIntoCliques --graph, --k PartitionIntoTriangles --graph GraphPartitioning --graph GeneralizedHex --graph, --source, --sink diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 866e124c9..cf3e48a38 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -618,6 +618,7 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "--left 4 --right 4 --biedges 0-0,0-1,0-2,1-0,1-1,1-2,2-0,2-1,2-2,3-0,3-1,3-3 --k 3" } "PartitionIntoTriangles" => "--graph 0-1,1-2,0-2", + "PartitionIntoCliques" => "--graph 0-3,0-4,1-2,1-4 --k 3", "Factoring" => "--target 15 --m 4 --n 4", "CapacityAssignment" => { "--capacities 1,2,3 --cost-matrix \"1,3,6;2,4,7;1,2,5\" --delay-matrix \"8,4,1;7,3,1;6,3,1\" --cost-budget 10 --delay-budget 12" @@ -4095,6 +4096,23 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } + // PartitionIntoCliques + "PartitionIntoCliques" => { + let usage = "Usage: pred create PartitionIntoCliques --graph 0-3,0-4,1-2,1-4 --k 3"; + let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let num_cliques = args + .k + .ok_or_else(|| anyhow::anyhow!("PartitionIntoCliques requires --k\n\n{usage}"))?; + anyhow::ensure!( + num_cliques > 0, + "PartitionIntoCliques: --k must be positive" + ); + ( + ser(PartitionIntoCliques::new(graph, num_cliques))?, + resolved_variant.clone(), + ) + } + // ShortestCommonSupersequence "ShortestCommonSupersequence" => { let usage = "Usage: pred create SCS --strings \"0,1,2;1,2,0\" --bound 4"; diff --git a/src/lib.rs b/src/lib.rs index 7099fb515..1d26583b4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -64,9 +64,10 @@ pub mod prelude { MinimumDummyActivitiesPert, MinimumFeedbackArcSet, MinimumFeedbackVertexSet, MinimumMultiwayCut, MinimumSumMulticenter, MinimumVertexCover, MultipleChoiceBranching, MultipleCopyFileAllocation, OptimalLinearArrangement, PartialFeedbackEdgeSet, - PartitionIntoPathsOfLength2, PartitionIntoTriangles, PathConstrainedNetworkFlow, - RootedTreeArrangement, RuralPostman, ShortestWeightConstrainedPath, SteinerTreeInGraphs, - TravelingSalesman, UndirectedFlowLowerBounds, UndirectedTwoCommodityIntegralFlow, + PartitionIntoCliques, PartitionIntoPathsOfLength2, PartitionIntoTriangles, + PathConstrainedNetworkFlow, RootedTreeArrangement, RuralPostman, + ShortestWeightConstrainedPath, SteinerTreeInGraphs, TravelingSalesman, + UndirectedFlowLowerBounds, UndirectedTwoCommodityIntegralFlow, }; pub use crate::models::misc::{ AdditionalKey, BinPacking, BoyceCoddNormalFormViolation, CapacityAssignment, CbqRelation, diff --git a/src/models/graph/mod.rs b/src/models/graph/mod.rs index 71d258e05..2c9352304 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -35,6 +35,7 @@ //! - [`MultipleCopyFileAllocation`]: File-copy placement under storage and access costs //! - [`OptimalLinearArrangement`]: Optimal linear arrangement (total edge length at most K) //! - [`PartialFeedbackEdgeSet`]: Remove at most K edges to hit every short cycle +//! - [`PartitionIntoCliques`]: Partition vertices into at most K cliques //! - [`RootedTreeArrangement`]: Rooted-tree embedding with bounded total edge stretch //! - [`MinimumFeedbackArcSet`]: Minimum feedback arc set on directed graphs //! - [`MinMaxMulticenter`]: Min-max multicenter (vertex p-center, satisfaction) @@ -96,6 +97,7 @@ pub(crate) mod multiple_choice_branching; pub(crate) mod multiple_copy_file_allocation; pub(crate) mod optimal_linear_arrangement; pub(crate) mod partial_feedback_edge_set; +pub(crate) mod partition_into_cliques; pub(crate) mod partition_into_paths_of_length_2; pub(crate) mod partition_into_triangles; pub(crate) mod path_constrained_network_flow; @@ -152,6 +154,7 @@ pub use multiple_choice_branching::MultipleChoiceBranching; pub use multiple_copy_file_allocation::MultipleCopyFileAllocation; pub use optimal_linear_arrangement::OptimalLinearArrangement; pub use partial_feedback_edge_set::PartialFeedbackEdgeSet; +pub use partition_into_cliques::PartitionIntoCliques; pub use partition_into_paths_of_length_2::PartitionIntoPathsOfLength2; pub use partition_into_triangles::PartitionIntoTriangles; pub use path_constrained_network_flow::PathConstrainedNetworkFlow; @@ -206,6 +209,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec { + graph: G, + num_cliques: usize, +} + +impl PartitionIntoCliques { + /// Create a new Partition Into Cliques instance. + pub fn new(graph: G, num_cliques: usize) -> Self { + Self { graph, num_cliques } + } + + /// Get a reference to the underlying graph. + pub fn graph(&self) -> &G { + &self.graph + } + + /// Get the number of vertices in the graph. + pub fn num_vertices(&self) -> usize { + self.graph.num_vertices() + } + + /// Get the number of edges in the graph. + pub fn num_edges(&self) -> usize { + self.graph.num_edges() + } + + /// Get the clique bound K. + pub fn num_cliques(&self) -> usize { + self.num_cliques + } +} + +impl Problem for PartitionIntoCliques +where + G: Graph + VariantParam, +{ + const NAME: &'static str = "PartitionIntoCliques"; + type Value = crate::types::Or; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![G] + } + + fn dims(&self) -> Vec { + vec![self.num_cliques; self.graph.num_vertices()] + } + + fn evaluate(&self, config: &[usize]) -> crate::types::Or { + let n = self.graph.num_vertices(); + + if config.len() != n { + return crate::types::Or(false); + } + + if config.iter().any(|&group| group >= self.num_cliques) { + return crate::types::Or(false); + } + + for u in 0..n { + for v in (u + 1)..n { + if config[u] == config[v] && !self.graph.has_edge(u, v) { + return crate::types::Or(false); + } + } + } + + crate::types::Or(true) + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "partition_into_cliques_simplegraph", + instance: Box::new(PartitionIntoCliques::new( + SimpleGraph::new(5, vec![(0, 3), (0, 4), (1, 2), (1, 4)]), + 3, + )), + optimal_config: vec![0, 1, 1, 0, 2], + optimal_value: serde_json::json!(true), + }] +} + +crate::declare_variants! { + default PartitionIntoCliques => "2^num_vertices", +} + +#[cfg(test)] +#[path = "../../unit_tests/models/graph/partition_into_cliques.rs"] +mod tests; diff --git a/src/models/mod.rs b/src/models/mod.rs index 3baa73f03..cbfc6ebae 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -28,11 +28,11 @@ pub use graph::{ MinimumDummyActivitiesPert, MinimumFeedbackArcSet, MinimumFeedbackVertexSet, MinimumMultiwayCut, MinimumSumMulticenter, MinimumVertexCover, MixedChinesePostman, MultipleChoiceBranching, MultipleCopyFileAllocation, OptimalLinearArrangement, - PartialFeedbackEdgeSet, PartitionIntoPathsOfLength2, PartitionIntoTriangles, - PathConstrainedNetworkFlow, RootedTreeArrangement, RuralPostman, ShortestWeightConstrainedPath, - SpinGlass, SteinerTree, SteinerTreeInGraphs, StrongConnectivityAugmentation, - SubgraphIsomorphism, TravelingSalesman, UndirectedFlowLowerBounds, - UndirectedTwoCommodityIntegralFlow, + PartialFeedbackEdgeSet, PartitionIntoCliques, PartitionIntoPathsOfLength2, + PartitionIntoTriangles, PathConstrainedNetworkFlow, RootedTreeArrangement, RuralPostman, + ShortestWeightConstrainedPath, SpinGlass, SteinerTree, SteinerTreeInGraphs, + StrongConnectivityAugmentation, SubgraphIsomorphism, TravelingSalesman, + UndirectedFlowLowerBounds, UndirectedTwoCommodityIntegralFlow, }; pub use misc::PartiallyOrderedKnapsack; pub use misc::{ diff --git a/src/rules/kcoloring_partitionintocliques.rs b/src/rules/kcoloring_partitionintocliques.rs new file mode 100644 index 000000000..156f8c781 --- /dev/null +++ b/src/rules/kcoloring_partitionintocliques.rs @@ -0,0 +1,90 @@ +//! Reduction from KColoring to PartitionIntoCliques via complement graphs. +//! +//! A proper k-coloring of G is exactly a partition of V into k independent +//! sets, which become k cliques in the complement graph. + +use crate::models::graph::{KColoring, PartitionIntoCliques}; +use crate::reduction; +use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::topology::{Graph, SimpleGraph}; +use crate::variant::KN; + +/// Result of reducing KColoring to PartitionIntoCliques. +#[derive(Debug, Clone)] +pub struct ReductionKColoringToPartitionIntoCliques { + target: PartitionIntoCliques, +} + +impl ReductionResult for ReductionKColoringToPartitionIntoCliques { + type Source = KColoring; + type Target = PartitionIntoCliques; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + /// Solution extraction is the identity: color classes become clique classes. + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + target_solution.to_vec() + } +} + +fn complement_edges(graph: &SimpleGraph) -> Vec<(usize, usize)> { + let n = graph.num_vertices(); + let mut edges = Vec::new(); + for u in 0..n { + for v in (u + 1)..n { + if !graph.has_edge(u, v) { + edges.push((u, v)); + } + } + } + edges +} + +#[reduction( + overhead = { + num_vertices = "num_vertices", + num_edges = "num_vertices * (num_vertices - 1) / 2 - num_edges", + } +)] +impl ReduceTo> for KColoring { + type Result = ReductionKColoringToPartitionIntoCliques; + + fn reduce_to(&self) -> Self::Result { + let target = PartitionIntoCliques::new( + SimpleGraph::new(self.graph().num_vertices(), complement_edges(self.graph())), + self.num_colors(), + ); + ReductionKColoringToPartitionIntoCliques { target } + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + use crate::export::SolutionPair; + + vec![crate::example_db::specs::RuleExampleSpec { + id: "kcoloring_to_partitionintocliques", + build: || { + let source = KColoring::::with_k( + SimpleGraph::new(5, vec![(0, 1), (0, 2), (1, 3), (2, 3), (2, 4), (3, 4)]), + 3, + ); + crate::example_db::specs::rule_example_with_witness::< + _, + PartitionIntoCliques, + >( + source, + SolutionPair { + source_config: vec![0, 1, 1, 0, 2], + target_config: vec![0, 1, 1, 0, 2], + }, + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/kcoloring_partitionintocliques.rs"] +mod tests; diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 2d1e8db95..574861e28 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -15,6 +15,7 @@ pub(crate) mod graphpartitioning_maxcut; pub(crate) mod graphpartitioning_qubo; pub(crate) mod hamiltoniancircuit_travelingsalesman; mod kcoloring_casts; +pub(crate) mod kcoloring_partitionintocliques; mod knapsack_qubo; mod ksatisfiability_casts; pub(crate) mod ksatisfiability_qubo; @@ -244,6 +245,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec = serde_json::from_str(&json).unwrap(); + + assert_eq!(deserialized.num_vertices(), 3); + assert_eq!(deserialized.num_edges(), 1); + assert_eq!(deserialized.num_cliques(), 2); +} + +#[test] +fn test_partitionintocliques_paper_example() { + use crate::traits::Problem; + + let graph = SimpleGraph::new(5, vec![(0, 3), (0, 4), (1, 2), (1, 4)]); + let problem = PartitionIntoCliques::new(graph, 3); + let config = vec![0, 1, 1, 0, 2]; + + assert!(problem.evaluate(&config)); + + let solver = BruteForce::new(); + let witness = solver.find_witness(&problem); + assert!(witness.is_some()); +} diff --git a/src/unit_tests/prelude.rs b/src/unit_tests/prelude.rs index da240315d..7c4ab645d 100644 --- a/src/unit_tests/prelude.rs +++ b/src/unit_tests/prelude.rs @@ -1,7 +1,14 @@ use crate::prelude::*; +use crate::topology::SimpleGraph; #[test] fn test_prelude_exports_rectilinear_picture_compression() { let problem = RectilinearPictureCompression::new(vec![vec![true]], 1); assert_eq!(problem.bound(), 1); } + +#[test] +fn test_prelude_exports_partition_into_cliques() { + let problem = PartitionIntoCliques::new(SimpleGraph::new(2, vec![(0, 1)]), 1); + assert_eq!(problem.num_cliques(), 1); +} diff --git a/src/unit_tests/reduction_graph.rs b/src/unit_tests/reduction_graph.rs index ff9a14967..105344d5f 100644 --- a/src/unit_tests/reduction_graph.rs +++ b/src/unit_tests/reduction_graph.rs @@ -5,7 +5,7 @@ use crate::prelude::*; use crate::rules::{MinimizeSteps, ReductionGraph, ReductionMode, TraversalFlow}; use crate::topology::{KingsSubgraph, SimpleGraph, TriangularSubgraph, UnitDiskGraph}; use crate::types::ProblemSize; -use crate::variant::K3; +use crate::variant::{K3, KN}; use std::collections::BTreeMap; // ---- Discovery and registration ---- @@ -208,6 +208,13 @@ fn test_direct_reduction_exists() { assert!(graph.has_direct_reduction::, MaxCut>()); } +#[test] +fn test_kcoloring_to_partitionintocliques_smoke() { + let source = KColoring::::with_k(SimpleGraph::new(3, vec![(0, 1), (1, 2)]), 2); + let reduction = ReduceTo::>::reduce_to(&source); + assert_eq!(reduction.target_problem().num_cliques(), 2); +} + #[test] fn test_find_direct_path() { let graph = ReductionGraph::new(); diff --git a/src/unit_tests/rules/kcoloring_partitionintocliques.rs b/src/unit_tests/rules/kcoloring_partitionintocliques.rs new file mode 100644 index 000000000..c64337df2 --- /dev/null +++ b/src/unit_tests/rules/kcoloring_partitionintocliques.rs @@ -0,0 +1,53 @@ +use super::*; +use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; +use crate::solvers::BruteForce; +use crate::topology::Graph; +use crate::variant::KN; + +#[test] +fn test_kcoloring_to_partitionintocliques_closed_loop() { + let source = KColoring::::with_k( + SimpleGraph::new(5, vec![(0, 1), (0, 2), (1, 3), (2, 3), (2, 4), (3, 4)]), + 3, + ); + let reduction = ReduceTo::>::reduce_to(&source); + + assert_satisfaction_round_trip_from_satisfaction_target( + &source, + &reduction, + "KColoring->PartitionIntoCliques closed loop", + ); +} + +#[test] +fn test_kcoloring_to_partitionintocliques_complement_structure() { + let source = KColoring::::with_k(SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3)]), 2); + let reduction = ReduceTo::>::reduce_to(&source); + let target = reduction.target_problem(); + + assert_eq!(target.graph().num_vertices(), 4); + assert_eq!(target.graph().num_edges(), 3); + assert_eq!(target.num_cliques(), 2); + assert!(target.graph().has_edge(0, 2)); + assert!(target.graph().has_edge(0, 3)); + assert!(target.graph().has_edge(1, 3)); +} + +#[test] +fn test_kcoloring_to_partitionintocliques_extract_solution_identity() { + let source = KColoring::::with_k(SimpleGraph::new(3, vec![(0, 1), (1, 2)]), 2); + let reduction = ReduceTo::>::reduce_to(&source); + let config = vec![0, 1, 0]; + + assert_eq!(reduction.extract_solution(&config), config); +} + +#[test] +fn test_kcoloring_to_partitionintocliques_unsat_preserved() { + let source = KColoring::::with_k(SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]), 2); + let reduction = ReduceTo::>::reduce_to(&source); + let solver = BruteForce::new(); + + assert!(solver.find_witness(&source).is_none()); + assert!(solver.find_witness(reduction.target_problem()).is_none()); +} From 7cfb16a18b31d2ab1985452fa3a711b748e80913 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Fri, 3 Apr 2026 20:30:02 +0800 Subject: [PATCH 04/25] =?UTF-8?q?feat:=20add=20Kernel=20model=20and=203SAT?= =?UTF-8?q?=20=E2=86=92=20Kernel=20reduction=20rule=20(#882)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Kernel model for directed graphs (Value=Or, independent + absorbing vertex set) and Chvátal's 1973 reduction from KSatisfiability. Variable digons encode truth assignments; directed 3-cycles with connection arcs enforce clause satisfaction via the absorption property. Includes CLI support, example-db entries, and tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- problemreductions-cli/src/commands/create.rs | 44 +++++++ src/lib.rs | 2 +- src/models/graph/kernel.rs | 120 ++++++++++++++++++ src/models/graph/mod.rs | 3 + src/models/mod.rs | 20 +-- src/rules/ksatisfiability_kernel.rs | 109 ++++++++++++++++ src/rules/mod.rs | 2 + src/unit_tests/models/graph/kernel.rs | 50 ++++++++ .../rules/ksatisfiability_kernel.rs | 76 +++++++++++ 9 files changed, 415 insertions(+), 11 deletions(-) create mode 100644 src/models/graph/kernel.rs create mode 100644 src/rules/ksatisfiability_kernel.rs create mode 100644 src/unit_tests/models/graph/kernel.rs create mode 100644 src/unit_tests/rules/ksatisfiability_kernel.rs diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index cf3e48a38..4860ef12b 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -645,6 +645,7 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "DirectedTwoCommodityIntegralFlow" => { "--arcs \"0>2,0>3,1>2,1>3,2>4,2>5,3>4,3>5\" --capacities 1,1,1,1,1,1,1,1 --source-1 0 --sink-1 4 --source-2 1 --sink-2 5 --requirement-1 1 --requirement-2 1" } + "Kernel" => "--arcs \"0>1,1>0,0>2,1>2\" --num-vertices 3", "MinimumFeedbackArcSet" => "--arcs \"0>1,1>2,2>0\"", "MinimumDummyActivitiesPert" => "--arcs \"0>2,0>3,1>3,1>4,2>5\" --num-vertices 6", "StrongConnectivityAugmentation" => { @@ -4183,6 +4184,17 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } + "Kernel" => { + let arcs_str = args.arcs.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "Kernel requires --arcs\n\n\ + Usage: pred create Kernel --arcs \"0>1,1>0,0>2,1>2\" [--num-vertices N]" + ) + })?; + let (graph, _) = parse_directed_graph(arcs_str, args.num_vertices)?; + (ser(Kernel::new(graph))?, resolved_variant.clone()) + } + "ConjunctiveQueryFoldability" => { bail!( "ConjunctiveQueryFoldability has complex nested input.\n\n\ @@ -7980,6 +7992,38 @@ mod tests { assert!(err.contains("requires the input graph to be a DAG")); } + #[test] + fn test_create_kernel_json() { + use crate::dispatch::ProblemJsonOutput; + use problemreductions::models::graph::Kernel; + + let mut args = empty_args(); + args.problem = Some("Kernel".to_string()); + args.num_vertices = Some(3); + args.arcs = Some("0>1,1>0,0>2,1>2".to_string()); + + let output_path = temp_output_path("kernel"); + let out = OutputConfig { + output: Some(output_path.clone()), + quiet: true, + json: false, + auto_json: false, + }; + + create(&args, &out).unwrap(); + + let json = fs::read_to_string(&output_path).unwrap(); + let created: ProblemJsonOutput = serde_json::from_str(&json).unwrap(); + assert_eq!(created.problem_type, "Kernel"); + assert!(created.variant.is_empty()); + + let problem: Kernel = serde_json::from_value(created.data).unwrap(); + assert_eq!(problem.num_vertices(), 3); + assert_eq!(problem.num_arcs(), 4); + + let _ = fs::remove_file(output_path); + } + #[test] fn test_create_balanced_complete_bipartite_subgraph() { use crate::dispatch::ProblemJsonOutput; diff --git a/src/lib.rs b/src/lib.rs index 1d26583b4..ad1c6dda2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -55,7 +55,7 @@ pub mod prelude { DirectedTwoCommodityIntegralFlow, DisjointConnectingPaths, GeneralizedHex, GraphPartitioning, HamiltonianCircuit, HamiltonianPath, IntegralFlowBundles, IntegralFlowHomologousArcs, IntegralFlowWithMultipliers, IsomorphicSpanningTree, KClique, - KthBestSpanningTree, LengthBoundedDisjointPaths, LongestPath, MixedChinesePostman, + Kernel, KthBestSpanningTree, LengthBoundedDisjointPaths, LongestPath, MixedChinesePostman, SpinGlass, SteinerTree, StrongConnectivityAugmentation, SubgraphIsomorphism, }; pub use crate::models::graph::{ diff --git a/src/models/graph/kernel.rs b/src/models/graph/kernel.rs new file mode 100644 index 000000000..a95696f2b --- /dev/null +++ b/src/models/graph/kernel.rs @@ -0,0 +1,120 @@ +//! Kernel problem implementation. +//! +//! A kernel in a directed graph is a vertex set that is both independent and +//! absorbing. + +use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::topology::DirectedGraph; +use crate::traits::Problem; +use serde::{Deserialize, Serialize}; + +inventory::submit! { + ProblemSchemaEntry { + name: "Kernel", + display_name: "Kernel", + aliases: &[], + dimensions: &[], + module_path: module_path!(), + description: "Does the directed graph contain a kernel?", + fields: &[ + FieldInfo { name: "graph", type_name: "DirectedGraph", description: "The directed graph G=(V,A)" }, + ], + } +} + +/// Kernel in a directed graph. +/// +/// Given a directed graph $G = (V, A)$, determine whether there exists a set +/// $K \subseteq V$ such that: +/// - no arc has both endpoints in $K$ (independence) +/// - every vertex outside $K$ has an outgoing arc to some vertex in $K$ +/// (absorption) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Kernel { + graph: DirectedGraph, +} + +impl Kernel { + /// Create a new Kernel instance. + pub fn new(graph: DirectedGraph) -> Self { + Self { graph } + } + + /// Get the underlying directed graph. + pub fn graph(&self) -> &DirectedGraph { + &self.graph + } + + /// Get the number of vertices in the graph. + pub fn num_vertices(&self) -> usize { + self.graph.num_vertices() + } + + /// Get the number of arcs in the graph. + pub fn num_arcs(&self) -> usize { + self.graph.num_arcs() + } + + /// Check whether a configuration is a valid kernel. + pub fn is_valid_solution(&self, config: &[usize]) -> bool { + is_kernel_config(&self.graph, config) + } +} + +impl Problem for Kernel { + const NAME: &'static str = "Kernel"; + type Value = crate::types::Or; + + fn dims(&self) -> Vec { + vec![2; self.graph.num_vertices()] + } + + fn evaluate(&self, config: &[usize]) -> crate::types::Or { + crate::types::Or(is_kernel_config(&self.graph, config)) + } + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![] + } +} + +fn is_kernel_config(graph: &DirectedGraph, config: &[usize]) -> bool { + if config.len() != graph.num_vertices() || config.iter().any(|&bit| bit > 1) { + return false; + } + + for (u, v) in graph.arcs() { + if config[u] == 1 && config[v] == 1 { + return false; + } + } + + for u in 0..graph.num_vertices() { + if config[u] == 0 && !graph.successors(u).into_iter().any(|v| config[v] == 1) { + return false; + } + } + + true +} + +crate::declare_variants! { + default Kernel => "2^num_vertices", +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "kernel", + instance: Box::new(Kernel::new(DirectedGraph::new( + 3, + vec![(0, 1), (1, 0), (0, 2), (1, 2)], + ))), + optimal_config: vec![0, 0, 1], + optimal_value: serde_json::json!(true), + }] +} + +#[cfg(test)] +#[path = "../../unit_tests/models/graph/kernel.rs"] +mod tests; diff --git a/src/models/graph/mod.rs b/src/models/graph/mod.rs index 2c9352304..1c69172a3 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -74,6 +74,7 @@ pub(crate) mod integral_flow_with_multipliers; pub(crate) mod isomorphic_spanning_tree; pub(crate) mod kclique; pub(crate) mod kcoloring; +pub(crate) mod kernel; pub(crate) mod kth_best_spanning_tree; pub(crate) mod length_bounded_disjoint_paths; pub(crate) mod longest_circuit; @@ -131,6 +132,7 @@ pub use integral_flow_with_multipliers::IntegralFlowWithMultipliers; pub use isomorphic_spanning_tree::IsomorphicSpanningTree; pub use kclique::KClique; pub use kcoloring::KColoring; +pub use kernel::Kernel; pub use kth_best_spanning_tree::KthBestSpanningTree; pub use length_bounded_disjoint_paths::LengthBoundedDisjointPaths; pub use longest_circuit::LongestCircuit; @@ -185,6 +187,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec; + type Target = Kernel; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + (0..self.source_num_vars) + .map(|i| usize::from(target_solution.get(2 * i).copied().unwrap_or(0) == 1)) + .collect() + } +} + +fn literal_vertex(literal: i32) -> usize { + let variable = literal.unsigned_abs() as usize - 1; + if literal > 0 { + 2 * variable + } else { + 2 * variable + 1 + } +} + +#[reduction( + overhead = { + num_vertices = "2 * num_vars + 3 * num_clauses", + num_arcs = "2 * num_vars + 6 * num_clauses", + } +)] +impl ReduceTo for KSatisfiability { + type Result = Reduction3SatToKernel; + + fn reduce_to(&self) -> Self::Result { + let num_vars = self.num_vars(); + let num_clauses = self.num_clauses(); + let mut arcs = Vec::with_capacity(2 * num_vars + 6 * num_clauses); + + for variable in 0..num_vars { + let positive = 2 * variable; + let negative = positive + 1; + arcs.push((positive, negative)); + arcs.push((negative, positive)); + } + + for (clause_index, clause) in self.clauses().iter().enumerate() { + let clause_base = 2 * num_vars + 3 * clause_index; + arcs.push((clause_base, clause_base + 1)); + arcs.push((clause_base + 1, clause_base + 2)); + arcs.push((clause_base + 2, clause_base)); + + for (literal_index, &literal) in clause.literals.iter().enumerate() { + arcs.push((clause_base + literal_index, literal_vertex(literal))); + } + } + + Reduction3SatToKernel { + target: Kernel::new(DirectedGraph::new(2 * num_vars + 3 * num_clauses, arcs)), + source_num_vars: num_vars, + } + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + use crate::export::SolutionPair; + use crate::models::formula::CNFClause; + + vec![crate::example_db::specs::RuleExampleSpec { + id: "ksatisfiability_to_kernel", + build: || { + crate::example_db::specs::rule_example_with_witness::<_, Kernel>( + KSatisfiability::::new( + 3, + vec![ + CNFClause::new(vec![1, 2, 3]), + CNFClause::new(vec![-1, -2, 3]), + ], + ), + SolutionPair { + source_config: vec![1, 1, 1], + target_config: vec![1, 0, 1, 0, 1, 0, 0, 0, 0, 0, 1, 0], + }, + ) + }, + }] +} +#[cfg(test)] +#[path = "../unit_tests/rules/ksatisfiability_kernel.rs"] +mod tests; diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 574861e28..459532ca1 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -18,6 +18,7 @@ mod kcoloring_casts; pub(crate) mod kcoloring_partitionintocliques; mod knapsack_qubo; mod ksatisfiability_casts; +pub(crate) mod ksatisfiability_kernel; pub(crate) mod ksatisfiability_qubo; pub(crate) mod ksatisfiability_subsetsum; pub(crate) mod maximumclique_maximumindependentset; @@ -247,6 +248,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec DirectedGraph { + DirectedGraph::new(3, vec![(0, 1), (1, 0), (0, 2), (1, 2)]) +} + +#[test] +fn test_kernel_creation_and_accessors() { + let graph = canonical_kernel_graph(); + let problem = Kernel::new(graph.clone()); + + assert_eq!(problem.graph(), &graph); + assert_eq!(problem.num_vertices(), 3); + assert_eq!(problem.num_arcs(), 4); + assert_eq!(problem.dims(), vec![2; 3]); +} + +#[test] +fn test_kernel_evaluate_independence_and_absorption() { + let problem = Kernel::new(canonical_kernel_graph()); + + assert!(problem.evaluate(&[0, 0, 1]).is_valid()); + assert!(!problem.evaluate(&[1, 1, 0]).is_valid()); + assert!(!problem.evaluate(&[1, 0, 0]).is_valid()); + assert!(!problem.evaluate(&[0, 0, 0]).is_valid()); +} + +#[test] +fn test_kernel_solver_and_variant() { + let problem = Kernel::new(canonical_kernel_graph()); + let solver = BruteForce::new(); + + assert_eq!(solver.find_all_witnesses(&problem), vec![vec![0, 0, 1]]); + assert_eq!(::variant(), Vec::new()); +} + +#[test] +fn test_kernel_serialization_round_trip() { + let problem = Kernel::new(canonical_kernel_graph()); + + let json = serde_json::to_string(&problem).expect("serialization should succeed"); + let round_trip: Kernel = serde_json::from_str(&json).expect("deserialization should succeed"); + + assert_eq!(round_trip.graph(), problem.graph()); + assert_eq!(round_trip.num_vertices(), 3); + assert_eq!(round_trip.num_arcs(), 4); +} diff --git a/src/unit_tests/rules/ksatisfiability_kernel.rs b/src/unit_tests/rules/ksatisfiability_kernel.rs new file mode 100644 index 000000000..38776937e --- /dev/null +++ b/src/unit_tests/rules/ksatisfiability_kernel.rs @@ -0,0 +1,76 @@ +use crate::models::formula::{CNFClause, KSatisfiability}; +use crate::models::graph::Kernel; +use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; +use crate::rules::{ReduceTo, ReductionResult}; +use crate::solvers::BruteForce; +use crate::variant::K3; + +#[test] +fn test_ksatisfiability_to_kernel_structure() { + let source = KSatisfiability::::new(2, vec![CNFClause::new(vec![1, -2, 1])]); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + assert_eq!(target.num_vertices(), 7); + assert_eq!(target.num_arcs(), 10); + + for arc in [ + (0, 1), + (1, 0), + (2, 3), + (3, 2), + (4, 5), + (5, 6), + (6, 4), + (4, 0), + (5, 3), + (6, 0), + ] { + assert!(target.graph().has_arc(arc.0, arc.1)); + } +} + +#[test] +fn test_ksatisfiability_to_kernel_closed_loop() { + let source = KSatisfiability::::new( + 3, + vec![ + CNFClause::new(vec![1, 2, 3]), + CNFClause::new(vec![-1, -2, 3]), + ], + ); + let reduction = ReduceTo::::reduce_to(&source); + + assert_satisfaction_round_trip_from_satisfaction_target( + &source, + &reduction, + "3SAT -> Kernel closed loop", + ); +} + +#[test] +fn test_ksatisfiability_to_kernel_unsatisfiable_instance_has_no_kernel() { + let source = KSatisfiability::::new( + 1, + vec![ + CNFClause::new(vec![1, 1, 1]), + CNFClause::new(vec![-1, -1, -1]), + ], + ); + let reduction = ReduceTo::::reduce_to(&source); + + assert!(BruteForce::new() + .find_witness(reduction.target_problem()) + .is_none()); +} + +#[test] +fn test_ksatisfiability_to_kernel_extract_solution_reads_variable_gadgets() { + let source = KSatisfiability::::new(2, vec![CNFClause::new(vec![1, -2, 1])]); + let reduction = ReduceTo::::reduce_to(&source); + + assert_eq!( + reduction.extract_solution(&[1, 0, 0, 1, 0, 0, 0]), + vec![1, 0] + ); +} From 745f90a0998215f81833497fa13a9a770eeb4315 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Fri, 3 Apr 2026 21:14:48 +0800 Subject: [PATCH 05/25] =?UTF-8?q?feat:=20add=20DegreeConstrainedSpanningTr?= =?UTF-8?q?ee=20model=20and=20HamPath=20=E2=86=92=20DCST=20rule=20(#911)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds DegreeConstrainedSpanningTree graph model (Value=Or, edge-selection configs checked for spanning tree + degree bound) and the identity reduction from HamiltonianPath with K=2. A degree-2 spanning tree is exactly a Hamiltonian path. Includes CLI support, example-db entries, and tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- problemreductions-cli/src/cli.rs | 4 + problemreductions-cli/src/commands/create.rs | 49 ++++- src/lib.rs | 11 +- .../graph/degree_constrained_spanning_tree.rs | 173 +++++++++++++++++ src/models/graph/mod.rs | 4 + src/models/mod.rs | 18 +- ...onianpath_degreeconstrainedspanningtree.rs | 174 ++++++++++++++++++ src/rules/mod.rs | 2 + .../graph/degree_constrained_spanning_tree.rs | 79 ++++++++ src/unit_tests/prelude.rs | 6 + ...onianpath_degreeconstrainedspanningtree.rs | 58 ++++++ src/unit_tests/trait_consistency.rs | 4 + 12 files changed, 563 insertions(+), 19 deletions(-) create mode 100644 src/models/graph/degree_constrained_spanning_tree.rs create mode 100644 src/rules/hamiltonianpath_degreeconstrainedspanningtree.rs create mode 100644 src/unit_tests/models/graph/degree_constrained_spanning_tree.rs create mode 100644 src/unit_tests/rules/hamiltonianpath_degreeconstrainedspanningtree.rs diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index f8e44ff9f..46e513238 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -235,6 +235,7 @@ Flags by problem type: IntegralFlowWithMultipliers --arcs, --capacities, --source, --sink, --multipliers, --requirement MinimumCutIntoBoundedSets --graph, --edge-weights, --source, --sink, --size-bound, --cut-bound HamiltonianCircuit, HC --graph + DegreeConstrainedSpanningTree --graph, --max-degree LongestCircuit --graph, --edge-weights, --bound BoundedComponentSpanningForest --graph, --weights, --k, --bound UndirectedFlowLowerBounds --graph, --capacities, --lower-bounds, --source, --sink, --requirement @@ -432,6 +433,9 @@ pub struct CreateArgs { /// Shared integer parameter (use `pred create ` for the problem-specific meaning) #[arg(long)] pub k: Option, + /// Maximum allowed degree in a selected spanning tree + #[arg(long)] + pub max_degree: Option, /// Generate a random instance (graph-based problems only) #[arg(long)] pub random: bool, diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 4860ef12b..a35f5ff35 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -13,11 +13,11 @@ use problemreductions::models::algebraic::{ }; use problemreductions::models::formula::Quantifier; use problemreductions::models::graph::{ - DisjointConnectingPaths, GeneralizedHex, GraphPartitioning, HamiltonianCircuit, - HamiltonianPath, IntegralFlowBundles, LengthBoundedDisjointPaths, LongestCircuit, LongestPath, - MinimumCutIntoBoundedSets, MinimumDummyActivitiesPert, MinimumMultiwayCut, MixedChinesePostman, - MultipleChoiceBranching, PathConstrainedNetworkFlow, RootedTreeArrangement, SteinerTree, - SteinerTreeInGraphs, StrongConnectivityAugmentation, + DegreeConstrainedSpanningTree, DisjointConnectingPaths, GeneralizedHex, GraphPartitioning, + HamiltonianCircuit, HamiltonianPath, IntegralFlowBundles, LengthBoundedDisjointPaths, + LongestCircuit, LongestPath, MinimumCutIntoBoundedSets, MinimumDummyActivitiesPert, + MinimumMultiwayCut, MixedChinesePostman, MultipleChoiceBranching, PathConstrainedNetworkFlow, + RootedTreeArrangement, SteinerTree, SteinerTreeInGraphs, StrongConnectivityAugmentation, }; use problemreductions::models::misc::{ AdditionalKey, BinPacking, BoyceCoddNormalFormViolation, CapacityAssignment, CbqRelation, @@ -74,6 +74,7 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool { && args.num_vars.is_none() && args.matrix.is_none() && args.k.is_none() + && args.max_degree.is_none() && args.target.is_none() && args.m.is_none() && args.n.is_none() @@ -554,6 +555,7 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "BoundedComponentSpanningForest" => { "--graph 0-1,1-2,2-3,3-4,4-5,5-6,6-7,0-7,1-5,2-6 --weights 2,3,1,2,3,1,2,1 --k 3 --bound 6" } + "DegreeConstrainedSpanningTree" => "--graph 0-1,1-2,2-3 --max-degree 2", "HamiltonianPath" => "--graph 0-1,1-2,2-3", "LongestPath" => { "--graph 0-1,0-2,1-3,2-3,2-4,3-5,4-5,4-6,5-6,1-6 --edge-lengths 3,2,4,1,5,2,3,2,4,1 --source-vertex 0 --target-vertex 6" @@ -1442,6 +1444,20 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { (ser(HamiltonianPath::new(graph))?, resolved_variant.clone()) } + // Degree-Constrained Spanning Tree + "DegreeConstrainedSpanningTree" => { + let usage = + "Usage: pred create DegreeConstrainedSpanningTree --graph 0-1,1-2,2-3 --max-degree 2"; + let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let max_degree = args.max_degree.ok_or_else(|| { + anyhow::anyhow!("DegreeConstrainedSpanningTree requires --max-degree\n\n{usage}") + })?; + ( + ser(DegreeConstrainedSpanningTree::new(graph, max_degree))?, + resolved_variant.clone(), + ) + } + // LongestPath "LongestPath" => { let usage = "pred create LongestPath --graph 0-1,0-2,1-3,2-3,2-4,3-5,4-5,4-6,5-6,1-6 --edge-lengths 3,2,4,1,5,2,3,2,4,1 --source-vertex 0 --target-vertex 6"; @@ -6178,6 +6194,21 @@ fn create_random( (ser(HamiltonianPath::new(graph))?, variant) } + // DegreeConstrainedSpanningTree (graph only, no weights) + "DegreeConstrainedSpanningTree" => { + let usage = "Usage: pred create DegreeConstrainedSpanningTree --random --num-vertices 6 [--edge-prob 0.5] [--seed 42] --max-degree 2"; + let edge_prob = args.edge_prob.unwrap_or(0.5); + if !(0.0..=1.0).contains(&edge_prob) { + bail!("--edge-prob must be between 0.0 and 1.0"); + } + let max_degree = args.max_degree.ok_or_else(|| { + anyhow::anyhow!("DegreeConstrainedSpanningTree requires --max-degree\n\n{usage}") + })?; + let graph = util::create_random_graph(num_vertices, edge_prob, args.seed); + let variant = variant_map(&[("graph", "SimpleGraph")]); + (ser(DegreeConstrainedSpanningTree::new(graph, max_degree))?, variant) + } + // LongestCircuit (graph + unit edge lengths + positive bound) "LongestCircuit" => { let edge_prob = args.edge_prob.unwrap_or(0.5); @@ -7311,6 +7342,7 @@ mod tests { num_vars: None, matrix: None, k: None, + max_degree: None, random: false, source_vertex: None, target_vertex: None, @@ -7436,6 +7468,13 @@ mod tests { assert!(!all_data_flags_empty(&args)); } + #[test] + fn test_all_data_flags_empty_treats_max_degree_as_input() { + let mut args = empty_args(); + args.max_degree = Some(2); + assert!(!all_data_flags_empty(&args)); + } + #[test] fn test_all_data_flags_empty_treats_homologous_pairs_as_input() { let mut args = empty_args(); diff --git a/src/lib.rs b/src/lib.rs index ad1c6dda2..d7d657d04 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -52,11 +52,12 @@ pub mod prelude { pub use crate::models::graph::{ AcyclicPartition, BalancedCompleteBipartiteSubgraph, BicliqueCover, BiconnectivityAugmentation, BottleneckTravelingSalesman, BoundedComponentSpanningForest, - DirectedTwoCommodityIntegralFlow, DisjointConnectingPaths, GeneralizedHex, - GraphPartitioning, HamiltonianCircuit, HamiltonianPath, IntegralFlowBundles, - IntegralFlowHomologousArcs, IntegralFlowWithMultipliers, IsomorphicSpanningTree, KClique, - Kernel, KthBestSpanningTree, LengthBoundedDisjointPaths, LongestPath, MixedChinesePostman, - SpinGlass, SteinerTree, StrongConnectivityAugmentation, SubgraphIsomorphism, + DegreeConstrainedSpanningTree, DirectedTwoCommodityIntegralFlow, DisjointConnectingPaths, + GeneralizedHex, GraphPartitioning, HamiltonianCircuit, HamiltonianPath, + IntegralFlowBundles, IntegralFlowHomologousArcs, IntegralFlowWithMultipliers, + IsomorphicSpanningTree, KClique, Kernel, KthBestSpanningTree, LengthBoundedDisjointPaths, + LongestPath, MixedChinesePostman, SpinGlass, SteinerTree, StrongConnectivityAugmentation, + SubgraphIsomorphism, }; pub use crate::models::graph::{ KColoring, LongestCircuit, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, diff --git a/src/models/graph/degree_constrained_spanning_tree.rs b/src/models/graph/degree_constrained_spanning_tree.rs new file mode 100644 index 000000000..f58f2ca56 --- /dev/null +++ b/src/models/graph/degree_constrained_spanning_tree.rs @@ -0,0 +1,173 @@ +//! Degree-Constrained Spanning Tree problem implementation. +//! +//! Given an undirected graph, determine whether it contains a spanning tree +//! whose maximum vertex degree is at most a prescribed bound. + +use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::topology::{Graph, SimpleGraph}; +use crate::traits::Problem; +use crate::variant::VariantParam; +use serde::{Deserialize, Serialize}; +use std::collections::VecDeque; + +inventory::submit! { + ProblemSchemaEntry { + name: "DegreeConstrainedSpanningTree", + display_name: "Degree-Constrained Spanning Tree", + aliases: &[], + dimensions: &[ + VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), + ], + module_path: module_path!(), + description: "Does graph G contain a spanning tree with maximum degree at most K?", + fields: &[ + FieldInfo { name: "graph", type_name: "G", description: "The underlying graph G=(V,E)" }, + FieldInfo { name: "max_degree", type_name: "usize", description: "Upper bound K on the degree of every vertex in the spanning tree" }, + ], + } +} + +/// Degree-Constrained Spanning Tree. +/// +/// Given an undirected graph `G = (V, E)` and an integer bound `K`, determine +/// whether there exists a spanning tree `T` of `G` such that every vertex has +/// degree at most `K` in `T`. +/// +/// # Representation +/// +/// A configuration is a binary vector of length `|E|`. Entry `config[e]` is 1 +/// exactly when the corresponding edge of `G` is selected into the candidate +/// spanning tree. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(bound(deserialize = "G: serde::Deserialize<'de>"))] +pub struct DegreeConstrainedSpanningTree { + graph: G, + max_degree: usize, +} + +impl DegreeConstrainedSpanningTree { + /// Create a new DegreeConstrainedSpanningTree instance. + pub fn new(graph: G, max_degree: usize) -> Self { + Self { graph, max_degree } + } + + /// Get the underlying graph. + pub fn graph(&self) -> &G { + &self.graph + } + + /// Get the number of vertices. + pub fn num_vertices(&self) -> usize { + self.graph.num_vertices() + } + + /// Get the number of edges. + pub fn num_edges(&self) -> usize { + self.graph.num_edges() + } + + /// Get the maximum allowed degree. + pub fn max_degree(&self) -> usize { + self.max_degree + } + + /// Check whether a configuration is a valid degree-constrained spanning tree. + pub fn is_valid_solution(&self, config: &[usize]) -> bool { + is_degree_constrained_spanning_tree(&self.graph, self.max_degree, config) + } +} + +impl Problem for DegreeConstrainedSpanningTree +where + G: Graph + VariantParam, +{ + const NAME: &'static str = "DegreeConstrainedSpanningTree"; + type Value = crate::types::Or; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![G] + } + + fn dims(&self) -> Vec { + vec![2; self.graph.num_edges()] + } + + fn evaluate(&self, config: &[usize]) -> crate::types::Or { + crate::types::Or(is_degree_constrained_spanning_tree( + &self.graph, + self.max_degree, + config, + )) + } +} + +pub(crate) fn is_degree_constrained_spanning_tree( + graph: &G, + max_degree: usize, + config: &[usize], +) -> bool { + let edges = graph.edges(); + if config.len() != edges.len() || config.iter().any(|&value| value > 1) { + return false; + } + + let num_vertices = graph.num_vertices(); + let selected_count = config.iter().filter(|&&value| value == 1).count(); + if selected_count != num_vertices.saturating_sub(1) { + return false; + } + + if num_vertices <= 1 { + return true; + } + + let mut adjacency = vec![Vec::new(); num_vertices]; + let mut degree = vec![0usize; num_vertices]; + + for ((u, v), &selected) in edges.iter().copied().zip(config.iter()) { + if selected == 0 { + continue; + } + degree[u] += 1; + degree[v] += 1; + if degree[u] > max_degree || degree[v] > max_degree { + return false; + } + adjacency[u].push(v); + adjacency[v].push(u); + } + + let mut visited = vec![false; num_vertices]; + let mut queue = VecDeque::new(); + visited[0] = true; + queue.push_back(0); + + while let Some(vertex) = queue.pop_front() { + for &neighbor in &adjacency[vertex] { + if !visited[neighbor] { + visited[neighbor] = true; + queue.push_back(neighbor); + } + } + } + + visited.into_iter().all(|seen| seen) +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "degree_constrained_spanning_tree_simplegraph", + instance: Box::new(DegreeConstrainedSpanningTree::new(SimpleGraph::path(4), 2)), + optimal_config: vec![1, 1, 1], + optimal_value: serde_json::json!(true), + }] +} + +crate::declare_variants! { + default DegreeConstrainedSpanningTree => "2^num_edges", +} + +#[cfg(test)] +#[path = "../../unit_tests/models/graph/degree_constrained_spanning_tree.rs"] +mod tests; diff --git a/src/models/graph/mod.rs b/src/models/graph/mod.rs index 1c69172a3..fcd7c177b 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -51,6 +51,7 @@ //! - [`IntegralFlowBundles`]: Integral flow feasibility with overlapping bundle capacities //! - [`IntegralFlowHomologousArcs`]: Integral flow with arc-pair equality constraints //! - [`IntegralFlowWithMultipliers`]: Integral flow with vertex multipliers on a directed graph +//! - [`DegreeConstrainedSpanningTree`]: Spanning tree with maximum degree at most K //! - [`UndirectedFlowLowerBounds`]: Feasible s-t flow in an undirected graph with lower/upper bounds //! - [`UndirectedTwoCommodityIntegralFlow`]: Two-commodity integral flow on undirected graphs //! - [`StrongConnectivityAugmentation`]: Strong connectivity augmentation with weighted candidate arcs @@ -62,6 +63,7 @@ pub(crate) mod biclique_cover; pub(crate) mod biconnectivity_augmentation; pub(crate) mod bottleneck_traveling_salesman; pub(crate) mod bounded_component_spanning_forest; +pub(crate) mod degree_constrained_spanning_tree; pub(crate) mod directed_two_commodity_integral_flow; pub(crate) mod disjoint_connecting_paths; pub(crate) mod generalized_hex; @@ -120,6 +122,7 @@ pub use biclique_cover::BicliqueCover; pub use biconnectivity_augmentation::BiconnectivityAugmentation; pub use bottleneck_traveling_salesman::BottleneckTravelingSalesman; pub use bounded_component_spanning_forest::BoundedComponentSpanningForest; +pub use degree_constrained_spanning_tree::DegreeConstrainedSpanningTree; pub use directed_two_commodity_integral_flow::DirectedTwoCommodityIntegralFlow; pub use disjoint_connecting_paths::DisjointConnectingPaths; pub use generalized_hex::GeneralizedHex; @@ -220,6 +223,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec, +} + +impl ReductionResult for ReductionHamiltonianPathToDegreeConstrainedSpanningTree { + type Source = HamiltonianPath; + type Target = DegreeConstrainedSpanningTree; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + extract_hamiltonian_order(self.target.graph(), target_solution) + } +} + +#[reduction( + overhead = { + num_vertices = "num_vertices", + num_edges = "num_edges", + } +)] +impl ReduceTo> for HamiltonianPath { + type Result = ReductionHamiltonianPathToDegreeConstrainedSpanningTree; + + fn reduce_to(&self) -> Self::Result { + let target = DegreeConstrainedSpanningTree::new( + SimpleGraph::new(self.graph().num_vertices(), self.graph().edges()), + 2, + ); + ReductionHamiltonianPathToDegreeConstrainedSpanningTree { target } + } +} + +fn extract_hamiltonian_order(graph: &SimpleGraph, target_solution: &[usize]) -> Vec { + let num_vertices = graph.num_vertices(); + if num_vertices == 0 { + return vec![]; + } + if num_vertices == 1 { + return vec![0]; + } + + let edges = graph.edges(); + if target_solution.len() != edges.len() { + return vec![]; + } + + let mut adjacency = vec![Vec::new(); num_vertices]; + for ((u, v), &selected) in edges.iter().copied().zip(target_solution.iter()) { + if selected != 1 { + continue; + } + adjacency[u].push(v); + adjacency[v].push(u); + } + + let mut endpoints: Vec = adjacency + .iter() + .enumerate() + .filter_map(|(vertex, neighbors)| (neighbors.len() == 1).then_some(vertex)) + .collect(); + endpoints.sort_unstable(); + if endpoints.len() != 2 { + return vec![]; + } + + let mut order = Vec::with_capacity(num_vertices); + let mut visited = vec![false; num_vertices]; + let mut previous = None; + let mut current = endpoints[0]; + + loop { + if visited[current] { + return vec![]; + } + visited[current] = true; + order.push(current); + + let next = adjacency[current] + .iter() + .copied() + .find(|&neighbor| Some(neighbor) != previous && !visited[neighbor]); + match next { + Some(next_vertex) => { + previous = Some(current); + current = next_vertex; + } + None => break, + } + } + + if order.len() == num_vertices { + order + } else { + vec![] + } +} + +#[cfg(feature = "example-db")] +fn edge_config_for_path(graph: &SimpleGraph, path: &[usize]) -> Vec { + let selected_edges: Vec<(usize, usize)> = path + .windows(2) + .map(|window| (window[0], window[1])) + .collect(); + graph + .edges() + .into_iter() + .map(|(u, v)| { + usize::from( + selected_edges + .iter() + .any(|&(a, b)| (a == u && b == v) || (a == v && b == u)), + ) + }) + .collect() +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + fn source_example() -> HamiltonianPath { + HamiltonianPath::new(SimpleGraph::new( + 6, + vec![ + (0, 1), + (0, 2), + (1, 3), + (2, 3), + (3, 4), + (3, 5), + (4, 2), + (5, 1), + ], + )) + } + + vec![crate::example_db::specs::RuleExampleSpec { + id: "hamiltonianpath_to_degreeconstrainedspanningtree", + build: || { + let source_config = vec![0, 2, 4, 3, 1, 5]; + let source = source_example(); + let reduction = + ReduceTo::>::reduce_to(&source); + let target_config = + edge_config_for_path(reduction.target_problem().graph(), &source_config); + crate::example_db::specs::rule_example_with_witness::< + _, + DegreeConstrainedSpanningTree, + >( + source, + crate::export::SolutionPair { + source_config, + target_config, + }, + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/hamiltonianpath_degreeconstrainedspanningtree.rs"] +mod tests; diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 459532ca1..15dbd69e9 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -14,6 +14,7 @@ mod graph; pub(crate) mod graphpartitioning_maxcut; pub(crate) mod graphpartitioning_qubo; pub(crate) mod hamiltoniancircuit_travelingsalesman; +pub(crate) mod hamiltonianpath_degreeconstrainedspanningtree; mod kcoloring_casts; pub(crate) mod kcoloring_partitionintocliques; mod knapsack_qubo; @@ -246,6 +247,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec Vec { + graph + .edges() + .into_iter() + .map(|(u, v)| { + usize::from( + selected_edges + .iter() + .any(|&(a, b)| (a == u && b == v) || (a == v && b == u)), + ) + }) + .collect() +} + +#[test] +fn test_degree_constrained_spanning_tree_creation() { + let graph = SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3), (0, 2)]); + let problem = DegreeConstrainedSpanningTree::new(graph.clone(), 2); + + assert_eq!(problem.graph(), &graph); + assert_eq!(problem.num_vertices(), 4); + assert_eq!(problem.num_edges(), 4); + assert_eq!(problem.max_degree(), 2); + assert_eq!(problem.dims(), vec![2, 2, 2, 2]); + assert_eq!( + as Problem>::NAME, + "DegreeConstrainedSpanningTree" + ); +} + +#[test] +fn test_degree_constrained_spanning_tree_evaluate_accepts_path_tree() { + let graph = SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3), (0, 2)]); + let problem = DegreeConstrainedSpanningTree::new(graph.clone(), 2); + let config = edge_config(&graph, &[(0, 1), (1, 2), (2, 3)]); + + assert!(problem.evaluate(&config)); + assert!(problem.is_valid_solution(&config)); +} + +#[test] +fn test_degree_constrained_spanning_tree_evaluate_rejects_wrong_edge_count() { + let graph = SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3), (0, 2)]); + let problem = DegreeConstrainedSpanningTree::new(graph.clone(), 2); + let config = edge_config(&graph, &[(0, 1), (2, 3)]); + + assert!(!problem.evaluate(&config)); +} + +#[test] +fn test_degree_constrained_spanning_tree_evaluate_rejects_degree_bound_violation() { + let graph = SimpleGraph::new(4, vec![(0, 1), (0, 2), (0, 3)]); + let problem = DegreeConstrainedSpanningTree::new(graph.clone(), 2); + let config = edge_config(&graph, &[(0, 1), (0, 2), (0, 3)]); + + assert!(!problem.evaluate(&config)); +} + +#[test] +fn test_degree_constrained_spanning_tree_solver_and_serialization() { + let problem = DegreeConstrainedSpanningTree::new(SimpleGraph::path(4), 2); + let solver = BruteForce::new(); + + let witness = solver.find_witness(&problem).expect("expected a witness"); + assert!(problem.evaluate(&witness)); + + let json = serde_json::to_value(&problem).unwrap(); + let restored: DegreeConstrainedSpanningTree = + serde_json::from_value(json).unwrap(); + assert_eq!(restored.num_vertices(), 4); + assert_eq!(restored.num_edges(), 3); + assert_eq!(restored.max_degree(), 2); + assert!(restored.evaluate(&witness)); +} diff --git a/src/unit_tests/prelude.rs b/src/unit_tests/prelude.rs index 7c4ab645d..6e7295b53 100644 --- a/src/unit_tests/prelude.rs +++ b/src/unit_tests/prelude.rs @@ -12,3 +12,9 @@ fn test_prelude_exports_partition_into_cliques() { let problem = PartitionIntoCliques::new(SimpleGraph::new(2, vec![(0, 1)]), 1); assert_eq!(problem.num_cliques(), 1); } + +#[test] +fn test_prelude_exports_degree_constrained_spanning_tree() { + let problem = DegreeConstrainedSpanningTree::new(SimpleGraph::new(3, vec![(0, 1), (1, 2)]), 2); + assert_eq!(problem.max_degree(), 2); +} diff --git a/src/unit_tests/rules/hamiltonianpath_degreeconstrainedspanningtree.rs b/src/unit_tests/rules/hamiltonianpath_degreeconstrainedspanningtree.rs new file mode 100644 index 000000000..610a9b1d8 --- /dev/null +++ b/src/unit_tests/rules/hamiltonianpath_degreeconstrainedspanningtree.rs @@ -0,0 +1,58 @@ +use crate::models::graph::{DegreeConstrainedSpanningTree, HamiltonianPath}; +use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; +use crate::rules::{ReduceTo, ReductionResult}; +use crate::topology::{Graph, SimpleGraph}; +use crate::traits::Problem; + +fn edge_config(graph: &SimpleGraph, selected_edges: &[(usize, usize)]) -> Vec { + graph + .edges() + .into_iter() + .map(|(u, v)| { + usize::from( + selected_edges + .iter() + .any(|&(a, b)| (a == u && b == v) || (a == v && b == u)), + ) + }) + .collect() +} + +#[test] +fn test_hamiltonianpath_to_degreeconstrainedspanningtree_structure() { + let source = HamiltonianPath::new(SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3), (0, 2)])); + let reduction = ReduceTo::>::reduce_to(&source); + let target = reduction.target_problem(); + + assert_eq!(target.graph(), source.graph()); + assert_eq!(target.num_vertices(), source.num_vertices()); + assert_eq!(target.num_edges(), source.num_edges()); + assert_eq!(target.max_degree(), 2); +} + +#[test] +fn test_hamiltonianpath_to_degreeconstrainedspanningtree_closed_loop() { + let source = HamiltonianPath::new(SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3), (0, 2)])); + let reduction = ReduceTo::>::reduce_to(&source); + + assert_satisfaction_round_trip_from_satisfaction_target( + &source, + &reduction, + "HamiltonianPath->DegreeConstrainedSpanningTree closed loop", + ); +} + +#[test] +fn test_hamiltonianpath_to_degreeconstrainedspanningtree_extract_solution_reconstructs_order() { + let source = HamiltonianPath::new(SimpleGraph::path(4)); + let reduction = ReduceTo::>::reduce_to(&source); + let target_solution = edge_config( + reduction.target_problem().graph(), + &[(0, 1), (1, 2), (2, 3)], + ); + + let extracted = reduction.extract_solution(&target_solution); + + assert_eq!(extracted, vec![0, 1, 2, 3]); + assert!(source.evaluate(&extracted)); +} diff --git a/src/unit_tests/trait_consistency.rs b/src/unit_tests/trait_consistency.rs index 7e0fbba89..32fad281e 100644 --- a/src/unit_tests/trait_consistency.rs +++ b/src/unit_tests/trait_consistency.rs @@ -137,6 +137,10 @@ fn test_all_problems_implement_trait_correctly() { &HamiltonianPath::new(SimpleGraph::new(3, vec![(0, 1), (1, 2)])), "HamiltonianPath", ); + check_problem_trait( + &DegreeConstrainedSpanningTree::new(SimpleGraph::new(3, vec![(0, 1), (1, 2)]), 2), + "DegreeConstrainedSpanningTree", + ); check_problem_trait( &ShortestWeightConstrainedPath::new( SimpleGraph::new(3, vec![(0, 1), (1, 2)]), From 5e7e982d6a90a73b1e6fda962314c1e547800bcb Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Fri, 3 Apr 2026 21:54:13 +0800 Subject: [PATCH 06/25] =?UTF-8?q?feat:=20add=20SetSplitting=20model=20and?= =?UTF-8?q?=20NAE-SAT=20=E2=86=92=20SetSplitting=20rule=20(#382)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the SetSplitting (hypergraph 2-colorability) model with Value=Or, and the reduction from NAESatisfiability. Uses two elements per variable (positive/negative literal) with pairing subsets to enforce complementary assignment, plus one subset per clause to enforce non-monochromatic NAE. Includes CLI support, paper entries, and tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/paper/reductions.typ | 82 +++++++++++ problemreductions-cli/src/cli.rs | 5 +- problemreductions-cli/src/commands/create.rs | 67 +++++++++ src/lib.rs | 2 +- src/models/mod.rs | 2 +- src/models/set/mod.rs | 4 + src/models/set/set_splitting.rs | 132 ++++++++++++++++++ src/rules/mod.rs | 2 + src/rules/naesatisfiability_setsplitting.rs | 109 +++++++++++++++ src/unit_tests/models/set/set_splitting.rs | 103 ++++++++++++++ .../rules/naesatisfiability_setsplitting.rs | 87 ++++++++++++ 11 files changed, 591 insertions(+), 4 deletions(-) create mode 100644 src/models/set/set_splitting.rs create mode 100644 src/rules/naesatisfiability_setsplitting.rs create mode 100644 src/unit_tests/models/set/set_splitting.rs create mode 100644 src/unit_tests/rules/naesatisfiability_setsplitting.rs diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 968dd4c18..e828aef80 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -106,6 +106,7 @@ "MaximumSetPacking": [Maximum Set Packing], "MinimumHittingSet": [Minimum Hitting Set], "MinimumSetCovering": [Minimum Set Covering], + "SetSplitting": [Set Splitting], "ComparativeContainment": [Comparative Containment], "SetBasis": [Set Basis], "MinimumCardinalityKey": [Minimum Cardinality Key], @@ -2699,6 +2700,61 @@ A classical NP-complete problem from Garey and Johnson @garey1979[Ch.~3, p.~76], ] } +#{ + let x = load-model-example("SetSplitting") + let subsets = x.instance.subsets + let m = subsets.len() + let U-size = x.instance.universe_size + let sol = x.optimal_config + let part1 = sol.enumerate().filter(((i, v)) => v == 0).map(((i, _)) => i) + let part2 = sol.enumerate().filter(((i, v)) => v == 1).map(((i, _)) => i) + let fmt-set(s) = if s.len() == 0 { + $emptyset$ + } else { + "${" + s.map(e => str(e + 1)).join(", ") + "}$" + } + let elems = ( + (-2.0, 0.7), + (-0.9, 1.4), + (-1.2, -0.4), + (0.2, 0.1), + (1.2, 1.0), + (1.5, -0.9), + ) + [ + #problem-def("SetSplitting")[ + Given a finite universe $S$ and a collection $cal(C) = {C_1, dots, C_m}$ of subsets of $S$, determine whether there exists a partition $S = S_1 union S_2$ with $S_1 inter S_2 = emptyset$ such that $C_j inter S_1 != emptyset$ and $C_j inter S_2 != emptyset$ for every $j$. + ][ + Set Splitting is the hypergraph 2-colorability problem SP4 in Garey and Johnson @garey1979. Each universe element receives one of two colors, and the requirement is that every hyperedge contains both colors. This viewpoint makes the graph case transparent: when every subset has size at most $2$, the problem reduces to ordinary graph bipartiteness and becomes polynomial-time solvable. The direct exact baseline enumerates all $2^n$ 2-colorings of the $n = |S|$ universe elements and rejects those with a monochromatic subset#footnote[No exact worst-case algorithm improving on brute-force is claimed here for the general decision formulation registered in this catalog.]. + + *Example.* Let $S = {1, 2, dots, #U-size}$ and $cal(C) = {C_1, dots, C_#m}$ with #range(m).map(i => $C_#(i + 1) = #fmt-set(subsets.at(i))$).join(", "). The partition $S_1 = #fmt-set(part1)$ and $S_2 = #fmt-set(part2)$ splits every subset: $C_1 = #fmt-set(subsets.at(0))$, $C_2 = #fmt-set(subsets.at(1))$, $C_3 = #fmt-set(subsets.at(2))$, and $C_4 = #fmt-set(subsets.at(3))$ each contain elements from both sides. Hence the configuration $(#sol.map(str).join(", "))$ is a valid witness. + + #pred-commands( + "pred create --example " + problem-spec(x) + " -o set-splitting.json", + "pred solve set-splitting.json", + "pred evaluate set-splitting.json --config " + x.optimal_config.map(str).join(","), + ) + + #figure( + canvas(length: 1cm, { + sregion((elems.at(0), elems.at(1), elems.at(2)), pad: 0.45, label: [$C_1$], ..sregion-dimmed) + sregion((elems.at(2), elems.at(3), elems.at(4)), pad: 0.45, label: [$C_2$], ..sregion-dimmed) + sregion((elems.at(0), elems.at(4), elems.at(5)), pad: 0.48, label: [$C_3$], ..sregion-dimmed) + sregion((elems.at(1), elems.at(3), elems.at(5)), pad: 0.48, label: [$C_4$], ..sregion-dimmed) + for (k, pos) in elems.enumerate() { + selem( + pos, + label: [#(k + 1)], + fill: if part1.contains(k) { graph-colors.at(0) } else { graph-colors.at(1) }, + ) + } + }), + caption: [Set splitting: the blue elements form $S_1 = #fmt-set(part1)$, the orange elements form $S_2 = #fmt-set(part2)$, and every subset region $C_1, dots, C_#m$ contains both colors.] + ) + ] + ] +} + #{ let x = load-model-example("ConsecutiveSets") let m = x.instance.alphabet_size @@ -8321,6 +8377,32 @@ The following reductions to Integer Linear Programming are straightforward formu _Solution extraction._ Direct: $x_i = 1$ iff variable $i$ is true. ] +#let nae_ss = load-example("NAESatisfiability", "SetSplitting") +#let nae_ss_sol = nae_ss.solutions.at(0) +#reduction-rule("NAESatisfiability", "SetSplitting", + example: true, + example-caption: [2 clauses over 3 variables], + extra: [ + #pred-commands( + "pred create --example " + problem-spec(nae_ss.source) + " -o naesat.json", + "pred reduce naesat.json --to " + target-spec(nae_ss) + " -o bundle.json", + "pred solve bundle.json", + "pred evaluate naesat.json --config " + nae_ss_sol.source_config.map(str).join(","), + ) + Source: $n = #nae_ss.source.instance.num_vars$ variables, $m = #sat-num-clauses(nae_ss.source.instance)$ clauses \ + Target: $2n = #nae_ss.target.instance.universe_size$ literal-elements and $n + m = #nae_ss.target.instance.subsets.len()$ subsets \ + Canonical witness: source $(#nae_ss_sol.source_config.map(str).join(", "))$ maps to target $(#nae_ss_sol.target_config.map(str).join(", "))$ #sym.checkmark + ], +)[ + This $O(n + L)$ reduction @garey1979 replaces each signed literal by its own universe element. Each variable contributes a 2-element subset forcing $x_i$ and $not x_i$ to receive opposite colors, and each NAE clause becomes the subset of its literal-elements. A set splitting therefore encodes exactly the true/false pattern of the literals in every clause. +][ + _Construction._ For each variable $x_i$, create two universe elements $p_i$ and $n_i$, representing the positive and negative literal. Add the 2-element subset ${p_i, n_i}$ for every $i in {1, dots, n}$. For each clause $C_j$, add a subset $T_j$ that contains $p_i$ when $x_i$ appears positively in $C_j$ and $n_i$ when $not x_i$ appears. The target universe has $2n$ elements and the target collection has $n + m$ subsets. + + _Correctness._ ($arrow.r.double$) Given an NAE-satisfying assignment, place $p_i$ in $S_2$ and $n_i$ in $S_1$ when $x_i = 1$, and swap them when $x_i = 0$. Then every pair ${p_i, n_i}$ is split. Moreover, each clause contains at least one true and one false literal, so the corresponding subset $T_j$ also contains both colors and is split. ($arrow.l.double$) Given a set splitting, each pair ${p_i, n_i}$ must be split because it has size $2$. Define $x_i = 1$ exactly when $p_i in S_2$ (equivalently $n_i in S_1$). Every clause subset $T_j$ is non-monochromatic, so the literals in $C_j$ are not all equal under this assignment; hence every clause is NAE-satisfied. + + _Solution extraction._ Read the side of each positive-literal element: assign $x_i = 1$ iff $p_i in S_2$. +] + #reduction-rule("KClique", "ILP")[ A $k$-clique requires at least $k$ selected vertices with no non-edge between any pair. ][ diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 46e513238..dbea8621f 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -257,6 +257,7 @@ Flags by problem type: MaximumSetPacking --sets [--weights] MinimumHittingSet --universe, --sets MinimumSetCovering --universe, --sets [--weights] + SetSplitting --universe, --sets EnsembleComputation --universe, --sets, --budget ComparativeContainment --universe, --r-sets, --s-sets [--r-weights] [--s-weights] X3C (ExactCoverBy3Sets) --universe, --sets (3 elements each) @@ -499,7 +500,7 @@ pub struct CreateArgs { /// Car paint sequence for PaintShop (comma-separated, each label appears exactly twice, e.g., "a,b,a,c,c,b") #[arg(long)] pub sequence: Option, - /// Sets for set-system problems such as SetPacking, MinimumHittingSet, and SetCovering (semicolon-separated, e.g., "0,1;1,2;0,2") + /// Sets for set-system problems such as SetPacking, MinimumHittingSet, SetCovering, and SetSplitting (semicolon-separated, e.g., "0,1;1,2;0,2") #[arg(long)] pub sets: Option, /// R-family sets for ComparativeContainment (semicolon-separated, e.g., "0,1;1,2") @@ -520,7 +521,7 @@ pub struct CreateArgs { /// Arc bundles for IntegralFlowBundles (semicolon-separated groups of arc indices, e.g., "0,1;2,5;3,4") #[arg(long)] pub bundles: Option, - /// Universe size for set-system problems such as MinimumHittingSet, MinimumSetCovering, and ComparativeContainment + /// Universe size for set-system problems such as MinimumHittingSet, MinimumSetCovering, SetSplitting, and ComparativeContainment #[arg(long)] pub universe: Option, /// Bipartite graph edges for BicliqueCover / BalancedCompleteBipartiteSubgraph (e.g., "0-0,0-1,1-2" for left-right pairs) diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index a35f5ff35..7eb8c61c0 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -609,6 +609,7 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "KColoring" => "--graph 0-1,1-2,2-0 --k 3", "HamiltonianCircuit" => "--graph 0-1,1-2,2-3,3-0", "EnsembleComputation" => "--universe 4 --sets \"0,1,2;0,1,3\" --budget 4", + "SetSplitting" => "--universe 6 --sets \"0,1,2;2,3,4;0,4,5;1,3,5\"", "RootedTreeStorageAssignment" => "--universe 5 --sets \"0,2;1,3;0,4;2,4\" --bound 1", "MinMaxMulticenter" => { "--graph 0-1,1-2,2-3 --weights 1,1,1,1 --edge-weights 1,1,1 --k 2 --bound 2" @@ -2522,6 +2523,33 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } + // SetSplitting + "SetSplitting" => { + let universe = args.universe.ok_or_else(|| { + anyhow::anyhow!( + "SetSplitting requires --universe and --sets\n\n\ + Usage: pred create SetSplitting --universe 6 --sets \"0,1,2;2,3,4;0,4,5;1,3,5\"" + ) + })?; + let sets = parse_sets(args)?; + for (i, set) in sets.iter().enumerate() { + for &element in set { + if element >= universe { + bail!( + "Subset {} contains element {} which is outside universe of size {}", + i, + element, + universe + ); + } + } + } + ( + ser(SetSplitting::new(universe, sets))?, + resolved_variant.clone(), + ) + } + // EnsembleComputation "EnsembleComputation" => { let usage = @@ -7605,6 +7633,45 @@ mod tests { ); } + #[test] + fn test_create_set_splitting_json() { + let cli = Cli::try_parse_from([ + "pred", + "create", + "SetSplitting", + "--universe", + "4", + "--sets", + "0,1;1,2;2,3", + ]) + .expect("parse create command"); + + let args = match cli.command { + Commands::Create(args) => args, + _ => panic!("expected create command"), + }; + + let output_path = temp_output_path("set_splitting"); + let out = OutputConfig { + output: Some(output_path.clone()), + quiet: true, + json: false, + auto_json: false, + }; + + create(&args, &out).expect("create SetSplitting JSON"); + + let created: ProblemJsonOutput = + serde_json::from_str(&fs::read_to_string(&output_path).unwrap()).unwrap(); + fs::remove_file(output_path).ok(); + + assert_eq!(created.problem_type, "SetSplitting"); + + let problem: SetSplitting = serde_json::from_value(created.data).unwrap(); + assert_eq!(problem.universe_size(), 4); + assert_eq!(problem.subsets(), &[vec![0, 1], vec![1, 2], vec![2, 3]],); + } + #[test] fn test_create_disjoint_connecting_paths_rejects_overlapping_terminal_pairs() { let mut args = empty_args(); diff --git a/src/lib.rs b/src/lib.rs index d7d657d04..121e41a87 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -85,7 +85,7 @@ pub mod prelude { pub use crate::models::set::{ ComparativeContainment, ConsecutiveSets, ExactCoverBy3Sets, MaximumSetPacking, MinimumCardinalityKey, MinimumHittingSet, MinimumSetCovering, PrimeAttributeName, - RootedTreeStorageAssignment, SetBasis, + RootedTreeStorageAssignment, SetBasis, SetSplitting, }; // Core traits diff --git a/src/models/mod.rs b/src/models/mod.rs index 658263b18..2da73e753 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -50,5 +50,5 @@ pub use misc::{ pub use set::{ ComparativeContainment, ConsecutiveSets, ExactCoverBy3Sets, MaximumSetPacking, MinimumCardinalityKey, MinimumHittingSet, MinimumSetCovering, PrimeAttributeName, - RootedTreeStorageAssignment, SetBasis, TwoDimensionalConsecutiveSets, + RootedTreeStorageAssignment, SetBasis, SetSplitting, TwoDimensionalConsecutiveSets, }; diff --git a/src/models/set/mod.rs b/src/models/set/mod.rs index 136a74bb2..e0e7e7990 100644 --- a/src/models/set/mod.rs +++ b/src/models/set/mod.rs @@ -9,6 +9,7 @@ //! - [`MinimumSetCovering`]: Minimum weight set cover //! - [`PrimeAttributeName`]: Determine if an attribute belongs to any candidate key //! - [`RootedTreeStorageAssignment`]: Extend subsets to directed tree paths within a total-cost bound +//! - [`SetSplitting`]: Bipartition the universe so every subset uses both parts pub(crate) mod comparative_containment; pub(crate) mod consecutive_sets; @@ -20,6 +21,7 @@ pub(crate) mod minimum_set_covering; pub(crate) mod prime_attribute_name; pub(crate) mod rooted_tree_storage_assignment; pub(crate) mod set_basis; +pub(crate) mod set_splitting; pub(crate) mod two_dimensional_consecutive_sets; pub use comparative_containment::ComparativeContainment; @@ -32,6 +34,7 @@ pub use minimum_set_covering::MinimumSetCovering; pub use prime_attribute_name::PrimeAttributeName; pub use rooted_tree_storage_assignment::RootedTreeStorageAssignment; pub use set_basis::SetBasis; +pub use set_splitting::SetSplitting; pub use two_dimensional_consecutive_sets::TwoDimensionalConsecutiveSets; #[cfg(feature = "example-db")] @@ -46,6 +49,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec>", description: "Collection of subsets that must each contain both partition colors" }, + ], + } +} + +/// The Set Splitting decision problem. +/// +/// Given a finite universe `S` and a collection `C` of subsets of `S`, +/// determine whether `S` can be partitioned into `S_1` and `S_2` such that +/// every subset in `C` has at least one element in each part. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SetSplitting { + universe_size: usize, + subsets: Vec>, +} + +impl SetSplitting { + /// Create a new Set Splitting instance. + /// + /// # Panics + /// + /// Panics if any subset contains an element outside `0..universe_size`. + pub fn new(universe_size: usize, subsets: Vec>) -> Self { + let mut subsets = subsets; + for (subset_index, subset) in subsets.iter_mut().enumerate() { + subset.sort_unstable(); + subset.dedup(); + for &element in subset.iter() { + assert!( + element < universe_size, + "Subset {subset_index} contains element {element} which is outside universe of size {universe_size}" + ); + } + } + + Self { + universe_size, + subsets, + } + } + + /// Get the universe size. + pub fn universe_size(&self) -> usize { + self.universe_size + } + + /// Get the subsets. + pub fn subsets(&self) -> &[Vec] { + &self.subsets + } + + /// Get the number of subsets. + pub fn num_subsets(&self) -> usize { + self.subsets.len() + } + + fn config_is_binary_partition(&self, config: &[usize]) -> bool { + config.len() == self.universe_size && config.iter().all(|&value| value <= 1) + } +} + +impl Problem for SetSplitting { + const NAME: &'static str = "SetSplitting"; + type Value = Or; + + fn dims(&self) -> Vec { + vec![2; self.universe_size] + } + + fn evaluate(&self, config: &[usize]) -> Or { + if !self.config_is_binary_partition(config) { + return Or(false); + } + + for subset in &self.subsets { + let Some((&first, rest)) = subset.split_first() else { + return Or(false); + }; + let first_part = config[first]; + if rest.iter().all(|&element| config[element] == first_part) { + return Or(false); + } + } + + Or(true) + } + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![] + } +} + +crate::declare_variants! { + default SetSplitting => "2^universe_size", +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "set_splitting", + instance: Box::new(SetSplitting::new( + 6, + vec![vec![0, 1, 2], vec![2, 3, 4], vec![0, 4, 5], vec![1, 3, 5]], + )), + optimal_config: vec![0, 1, 0, 1, 1, 0], + optimal_value: serde_json::json!(true), + }] +} + +#[cfg(test)] +#[path = "../../unit_tests/models/set/set_splitting.rs"] +mod tests; diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 15dbd69e9..381a976d6 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -35,6 +35,7 @@ pub(crate) mod minimummultiwaycut_qubo; pub(crate) mod minimumvertexcover_maximumindependentset; pub(crate) mod minimumvertexcover_minimumfeedbackvertexset; pub(crate) mod minimumvertexcover_minimumsetcovering; +pub(crate) mod naesatisfiability_setsplitting; pub(crate) mod partition_knapsack; pub(crate) mod sat_circuitsat; pub(crate) mod sat_coloring; @@ -263,6 +264,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec &SetSplitting { + &self.target + } + + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + assert!( + target_solution.len() >= self.num_source_variables, + "SetSplitting solution has {} variables but source requires {}", + target_solution.len(), + self.num_source_variables, + ); + target_solution[..self.num_source_variables].to_vec() + } +} + +fn literal_element_index(lit: i32, num_vars: usize) -> usize { + let var_index = lit.unsigned_abs() as usize - 1; + if lit > 0 { + var_index + } else { + num_vars + var_index + } +} + +#[reduction( + overhead = { + universe_size = "2 * num_vars", + num_subsets = "num_vars + num_clauses", + } +)] +impl ReduceTo for NAESatisfiability { + type Result = ReductionNAESATToSetSplitting; + + fn reduce_to(&self) -> Self::Result { + let num_vars = self.num_vars(); + let mut subsets = Vec::with_capacity(num_vars + self.num_clauses()); + + for var_index in 0..num_vars { + subsets.push(vec![var_index, num_vars + var_index]); + } + + for clause in self.clauses() { + subsets.push( + clause + .literals + .iter() + .map(|&lit| literal_element_index(lit, num_vars)) + .collect(), + ); + } + + ReductionNAESATToSetSplitting { + target: SetSplitting::new(2 * num_vars, subsets), + num_source_variables: num_vars, + } + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + use crate::export::SolutionPair; + use crate::models::formula::CNFClause; + + vec![crate::example_db::specs::RuleExampleSpec { + id: "naesatisfiability_to_setsplitting", + build: || { + crate::example_db::specs::rule_example_with_witness::<_, SetSplitting>( + NAESatisfiability::new( + 3, + vec![ + CNFClause::new(vec![1, -2, 3]), + CNFClause::new(vec![-1, 2, -3]), + ], + ), + SolutionPair { + source_config: vec![1, 1, 1], + target_config: vec![1, 1, 1, 0, 0, 0], + }, + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/naesatisfiability_setsplitting.rs"] +mod tests; diff --git a/src/unit_tests/models/set/set_splitting.rs b/src/unit_tests/models/set/set_splitting.rs new file mode 100644 index 000000000..fe49501ce --- /dev/null +++ b/src/unit_tests/models/set/set_splitting.rs @@ -0,0 +1,103 @@ +use crate::models::set::SetSplitting; +use crate::solvers::BruteForce; +use crate::traits::Problem; +use crate::types::Or; +use std::collections::HashSet; + +fn issue_example_problem() -> SetSplitting { + SetSplitting::new( + 6, + vec![vec![0, 1, 2], vec![2, 3, 4], vec![0, 4, 5], vec![1, 3, 5]], + ) +} + +fn issue_example_config() -> Vec { + vec![0, 1, 0, 1, 1, 0] +} + +#[test] +fn test_set_splitting_creation_accessors_and_dimensions() { + let problem = SetSplitting::new(4, vec![vec![2, 1, 1], vec![3], vec![]]); + + assert_eq!(problem.universe_size(), 4); + assert_eq!(problem.num_subsets(), 3); + assert_eq!(problem.num_variables(), 4); + assert_eq!(problem.dims(), vec![2; 4]); + assert_eq!(problem.subsets(), &[vec![1, 2], vec![3], vec![]]); +} + +#[test] +fn test_set_splitting_evaluate_split_monochromatic_and_invalid_configs() { + let problem = SetSplitting::new(4, vec![vec![0, 1, 2], vec![1, 3], vec![2, 3]]); + + assert_eq!(problem.evaluate(&[0, 1, 1, 0]), Or(true)); + assert_eq!(problem.evaluate(&[1, 1, 0, 0]), Or(false)); + assert_eq!(problem.evaluate(&[0, 2, 0, 1]), Or(false)); + assert_eq!(problem.evaluate(&[0, 1, 0]), Or(false)); +} + +#[test] +fn test_set_splitting_empty_or_singleton_subset_is_unsplittable() { + let problem = SetSplitting::new(3, vec![vec![], vec![1]]); + + assert_eq!(problem.evaluate(&[0, 1, 0]), Or(false)); + assert_eq!(problem.evaluate(&[1, 0, 1]), Or(false)); +} + +#[test] +fn test_set_splitting_bruteforce_issue_example() { + let problem = issue_example_problem(); + let solver = BruteForce::new(); + + let solutions = solver.find_all_witnesses(&problem); + let set: HashSet> = solutions.into_iter().collect(); + + assert_eq!(set.len(), 18); + assert!(set.contains(&issue_example_config())); + assert!(set + .iter() + .all(|config| problem.evaluate(config) == Or(true))); +} + +#[test] +fn test_set_splitting_serialization_round_trip() { + let problem = SetSplitting::new(4, vec![vec![0, 1], vec![1, 2, 2], vec![3]]); + let json = serde_json::to_string(&problem).unwrap(); + let round_trip: SetSplitting = serde_json::from_str(&json).unwrap(); + + assert_eq!(round_trip.universe_size(), problem.universe_size()); + assert_eq!(round_trip.num_subsets(), problem.num_subsets()); + assert_eq!(round_trip.subsets(), problem.subsets()); + assert_eq!( + round_trip.evaluate(&[0, 1, 0, 1]), + problem.evaluate(&[0, 1, 0, 1]) + ); +} + +#[test] +fn test_set_splitting_paper_example_consistency() { + let problem = issue_example_problem(); + + assert_eq!(problem.evaluate(&issue_example_config()), Or(true)); +} + +#[cfg(feature = "example-db")] +#[test] +fn test_set_splitting_canonical_example_spec() { + let specs = crate::models::set::set_splitting::canonical_model_example_specs(); + assert_eq!(specs.len(), 1); + + let spec = &specs[0]; + assert_eq!(spec.id, "set_splitting"); + assert_eq!(spec.optimal_config, issue_example_config()); + assert_eq!(spec.optimal_value, serde_json::json!(true)); + + let problem: SetSplitting = serde_json::from_value(spec.instance.serialize_json()).unwrap(); + let solver = BruteForce::new(); + let solutions = solver.find_all_witnesses(&problem); + + assert_eq!(problem.universe_size(), 6); + assert_eq!(problem.num_subsets(), 4); + assert_eq!(solutions.len(), 18); + assert!(solutions.contains(&issue_example_config())); +} diff --git a/src/unit_tests/rules/naesatisfiability_setsplitting.rs b/src/unit_tests/rules/naesatisfiability_setsplitting.rs new file mode 100644 index 000000000..12f65ebed --- /dev/null +++ b/src/unit_tests/rules/naesatisfiability_setsplitting.rs @@ -0,0 +1,87 @@ +use crate::models::formula::{CNFClause, NAESatisfiability}; +use crate::models::set::SetSplitting; +use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; +use crate::rules::{ReduceTo, ReductionResult}; +use crate::solvers::BruteForce; +use crate::traits::Problem; + +fn rule_example_problem() -> NAESatisfiability { + NAESatisfiability::new( + 3, + vec![ + CNFClause::new(vec![1, -2, 3]), + CNFClause::new(vec![-1, 2, -3]), + ], + ) +} + +#[test] +fn test_naesatisfiability_to_setsplitting_closed_loop() { + let source = rule_example_problem(); + let reduction = ReduceTo::::reduce_to(&source); + + assert_satisfaction_round_trip_from_satisfaction_target( + &source, + &reduction, + "NAE-SAT -> SetSplitting", + ); +} + +#[test] +fn test_naesatisfiability_to_setsplitting_structure() { + let source = rule_example_problem(); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + assert_eq!(target.universe_size(), 6); + assert_eq!(target.num_subsets(), 5); + assert_eq!( + target.subsets(), + &[ + vec![0, 3], + vec![1, 4], + vec![2, 5], + vec![0, 2, 4], + vec![1, 3, 5], + ], + ); +} + +#[test] +fn test_naesatisfiability_to_setsplitting_extract_solution_uses_positive_literals() { + let source = rule_example_problem(); + let reduction = ReduceTo::::reduce_to(&source); + + assert_eq!( + reduction.extract_solution(&[1, 0, 1, 0, 1, 0]), + vec![1, 0, 1] + ); +} + +#[test] +fn test_naesatisfiability_to_setsplitting_target_witness_extracts_to_satisfying_assignment() { + let source = rule_example_problem(); + let reduction = ReduceTo::::reduce_to(&source); + let solver = BruteForce::new(); + + let target_solution = solver.find_witness(reduction.target_problem()).unwrap(); + let source_solution = reduction.extract_solution(&target_solution); + + assert!(source.evaluate(&source_solution)); +} + +#[cfg(feature = "example-db")] +#[test] +fn test_naesatisfiability_to_setsplitting_canonical_example_spec() { + let specs = crate::rules::naesatisfiability_setsplitting::canonical_rule_example_specs(); + assert_eq!(specs.len(), 1); + + let example = (specs[0].build)(); + assert_eq!(example.source.problem, "NAESatisfiability"); + assert_eq!(example.target.problem, "SetSplitting"); + assert_eq!(example.solutions.len(), 1); + + let pair = &example.solutions[0]; + assert_eq!(pair.source_config, vec![1, 1, 1]); + assert_eq!(pair.target_config, vec![1, 1, 1, 0, 0, 0]); +} From bdf8ef48d4d14b3b74613384b66bc1b6bbc6b352 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Fri, 3 Apr 2026 22:14:29 +0800 Subject: [PATCH 07/25] =?UTF-8?q?feat:=20add=20SubsetProduct=20model=20and?= =?UTF-8?q?=20X3C=20=E2=86=92=20SubsetProduct=20rule=20(#388)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds SubsetProduct model (Value=Or, binary subset selection with product check) and the prime-encoding reduction from ExactCoverBy3Sets. Each universe element gets a distinct prime; each 3-set maps to the product of its elements' primes. By FTA, subset product equals universe product iff the selected sets form an exact cover. Includes CLI support and tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- problemreductions-cli/src/cli.rs | 3 +- problemreductions-cli/src/commands/create.rs | 30 ++++- src/lib.rs | 3 +- src/models/misc/mod.rs | 4 + src/models/misc/subset_product.rs | 119 ++++++++++++++++++ src/models/mod.rs | 3 +- src/models/set/exact_cover_by_3_sets.rs | 10 ++ src/rules/exactcoverby3sets_subsetproduct.rs | 94 ++++++++++++++ src/rules/mod.rs | 2 + src/unit_tests/models/misc/subset_product.rs | 76 +++++++++++ .../models/set/exact_cover_by_3_sets.rs | 3 + .../rules/exactcoverby3sets_subsetproduct.rs | 53 ++++++++ 12 files changed, 395 insertions(+), 5 deletions(-) create mode 100644 src/models/misc/subset_product.rs create mode 100644 src/rules/exactcoverby3sets_subsetproduct.rs create mode 100644 src/unit_tests/models/misc/subset_product.rs create mode 100644 src/unit_tests/rules/exactcoverby3sets_subsetproduct.rs diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index dbea8621f..b955bb5dc 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -250,6 +250,7 @@ Flags by problem type: Factoring --target, --m, --n BinPacking --sizes, --capacity CapacityAssignment --capacities, --cost-matrix, --delay-matrix, --cost-budget, --delay-budget + SubsetProduct --values, --target SubsetSum --sizes, --target SumOfSquaresPartition --sizes, --num-groups, --bound ExpectedRetrievalCost --probabilities, --num-sectors, --latency-bound @@ -455,7 +456,7 @@ pub struct CreateArgs { /// Random seed for reproducibility #[arg(long)] pub seed: Option, - /// Target value (for Factoring and SubsetSum) + /// Target value (for Factoring, SubsetProduct, and SubsetSum) #[arg(long)] pub target: Option, /// Bits for first factor (for Factoring); also accepted as a processor-count alias for scheduling create commands diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 7eb8c61c0..0ee6fc48c 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -28,8 +28,8 @@ use problemreductions::models::misc::{ ResourceConstrainedScheduling, SchedulingWithIndividualDeadlines, SequencingToMinimizeMaximumCumulativeCost, SequencingToMinimizeWeightedCompletionTime, SequencingToMinimizeWeightedTardiness, SequencingWithReleaseTimesAndDeadlines, - SequencingWithinIntervals, ShortestCommonSupersequence, StringToStringCorrection, SubsetSum, - SumOfSquaresPartition, TimetableDesign, + SequencingWithinIntervals, ShortestCommonSupersequence, StringToStringCorrection, + SubsetProduct, SubsetSum, SumOfSquaresPartition, TimetableDesign, }; use problemreductions::models::BiconnectivityAugmentation; use problemreductions::prelude::*; @@ -677,6 +677,7 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "SequencingToMinimizeWeightedTardiness" => { "--sizes 3,4,2,5,3 --weights 2,3,1,4,2 --deadlines 5,8,4,15,10 --bound 13" } + "SubsetProduct" => "--values 2,3,5,7 --target 30", "SubsetSum" => "--sizes 3,7,1,8,2,4 --target 11", "BoyceCoddNormalFormViolation" => { "--n 6 --sets \"0,1:2;2:3;3,4:5\" --target 0,1,2,3,4,5" @@ -2406,6 +2407,31 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } + // SubsetProduct + "SubsetProduct" => { + let values_str = args.values.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "SubsetProduct requires --values and --target\n\n\ + Usage: pred create SubsetProduct --values 2,3,5,7 --target 30" + ) + })?; + let target = args.target.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "SubsetProduct requires --target\n\n\ + Usage: pred create SubsetProduct --values 2,3,5,7 --target 30" + ) + })?; + let values = util::parse_comma_list::(values_str)?; + let target = target + .trim() + .parse::() + .map_err(|e| anyhow::anyhow!("Invalid target '{}': {e}", target.trim()))?; + ( + ser(SubsetProduct::new(values, target))?, + resolved_variant.clone(), + ) + } + // SubsetSum "SubsetSum" => { let sizes_str = args.sizes.as_deref().ok_or_else(|| { diff --git a/src/lib.rs b/src/lib.rs index 121e41a87..a0e069e4c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -80,7 +80,8 @@ pub mod prelude { SequencingToMinimizeMaximumCumulativeCost, SequencingToMinimizeWeightedCompletionTime, SequencingToMinimizeWeightedTardiness, SequencingWithReleaseTimesAndDeadlines, SequencingWithinIntervals, ShortestCommonSupersequence, StackerCrane, StaffScheduling, - StringToStringCorrection, SubsetSum, SumOfSquaresPartition, Term, TimetableDesign, + StringToStringCorrection, SubsetProduct, SubsetSum, SumOfSquaresPartition, Term, + TimetableDesign, }; pub use crate::models::set::{ ComparativeContainment, ConsecutiveSets, ExactCoverBy3Sets, MaximumSetPacking, diff --git a/src/models/misc/mod.rs b/src/models/misc/mod.rs index 2d07dc9bd..405dca74b 100644 --- a/src/models/misc/mod.rs +++ b/src/models/misc/mod.rs @@ -31,6 +31,7 @@ //! - [`ShortestCommonSupersequence`]: Find a common supersequence of bounded length //! - [`TimetableDesign`]: Schedule craftsmen on tasks across work periods //! - [`StringToStringCorrection`]: String-to-String Correction (derive target via deletions and swaps) +//! - [`SubsetProduct`]: Find a subset whose product hits a target exactly //! - [`SubsetSum`]: Find a subset summing to exactly a target value //! - [`SumOfSquaresPartition`]: Partition integers into K groups minimizing sum of squared group sums @@ -66,6 +67,7 @@ pub(crate) mod shortest_common_supersequence; mod stacker_crane; mod staff_scheduling; pub(crate) mod string_to_string_correction; +mod subset_product; mod subset_sum; pub(crate) mod sum_of_squares_partition; mod timetable_design; @@ -104,6 +106,7 @@ pub use shortest_common_supersequence::ShortestCommonSupersequence; pub use stacker_crane::StackerCrane; pub use staff_scheduling::StaffScheduling; pub use string_to_string_correction::StringToStringCorrection; +pub use subset_product::SubsetProduct; pub use subset_sum::SubsetSum; pub use sum_of_squares_partition::SumOfSquaresPartition; pub use timetable_design::TimetableDesign; @@ -145,6 +148,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec", description: "Positive integer values in the multiset" }, + FieldInfo { name: "target", type_name: "u64", description: "Target product" }, + ], + } +} + +/// The Subset Product problem. +/// +/// Given positive integers `a_1, ..., a_n` and a target `B`, determine whether +/// there exists a subset whose product is exactly `B`. +/// +/// Each element has a binary decision variable: `x_i = 1` if value `i` is +/// selected, `0` otherwise. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubsetProduct { + values: Vec, + target: u64, +} + +impl SubsetProduct { + /// Create a new SubsetProduct instance. + /// + /// # Panics + /// + /// Panics if any value is zero. + pub fn new(values: Vec, target: u64) -> Self { + assert!( + values.iter().all(|&value| value > 0), + "All values must be positive (> 0)" + ); + Self { values, target } + } + + /// Returns the multiset values. + pub fn values(&self) -> &[u64] { + &self.values + } + + /// Returns the target product. + pub fn target(&self) -> u64 { + self.target + } + + /// Returns the number of elements. + pub fn num_elements(&self) -> usize { + self.values.len() + } +} + +impl Problem for SubsetProduct { + const NAME: &'static str = "SubsetProduct"; + type Value = Or; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![] + } + + fn dims(&self) -> Vec { + vec![2; self.values.len()] + } + + fn evaluate(&self, config: &[usize]) -> Or { + if config.len() != self.values.len() || config.iter().any(|&value| value > 1) { + return Or(false); + } + + let mut product = 1u64; + for (index, &selected) in config.iter().enumerate() { + if selected == 1 { + let Some(next_product) = product.checked_mul(self.values[index]) else { + return Or(false); + }; + product = next_product; + if product > self.target { + return Or(false); + } + } + } + + Or(product == self.target) + } +} + +crate::declare_variants! { + default SubsetProduct => "2^num_elements", +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "subset_product", + instance: Box::new(SubsetProduct::new(vec![2, 3, 5, 7], 30)), + optimal_config: vec![1, 1, 1, 0], + optimal_value: serde_json::json!(true), + }] +} + +#[cfg(test)] +#[path = "../../unit_tests/models/misc/subset_product.rs"] +mod tests; diff --git a/src/models/mod.rs b/src/models/mod.rs index 2da73e753..4c34c5a0e 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -45,7 +45,8 @@ pub use misc::{ SequencingToMinimizeMaximumCumulativeCost, SequencingToMinimizeWeightedCompletionTime, SequencingToMinimizeWeightedTardiness, SequencingWithReleaseTimesAndDeadlines, SequencingWithinIntervals, ShortestCommonSupersequence, StackerCrane, StaffScheduling, - StringToStringCorrection, SubsetSum, SumOfSquaresPartition, Term, TimetableDesign, + StringToStringCorrection, SubsetProduct, SubsetSum, SumOfSquaresPartition, Term, + TimetableDesign, }; pub use set::{ ComparativeContainment, ConsecutiveSets, ExactCoverBy3Sets, MaximumSetPacking, diff --git a/src/models/set/exact_cover_by_3_sets.rs b/src/models/set/exact_cover_by_3_sets.rs index 62fa875ce..44e1c515b 100644 --- a/src/models/set/exact_cover_by_3_sets.rs +++ b/src/models/set/exact_cover_by_3_sets.rs @@ -109,11 +109,21 @@ impl ExactCoverBy3Sets { self.subsets.len() } + /// Get the number of sets in the collection. + pub fn num_sets(&self) -> usize { + self.num_subsets() + } + /// Get the subsets. pub fn subsets(&self) -> &[[usize; 3]] { &self.subsets } + /// Get the sets. + pub fn sets(&self) -> &[[usize; 3]] { + self.subsets() + } + /// Get a specific subset. pub fn get_subset(&self, index: usize) -> Option<&[usize; 3]> { self.subsets.get(index) diff --git a/src/rules/exactcoverby3sets_subsetproduct.rs b/src/rules/exactcoverby3sets_subsetproduct.rs new file mode 100644 index 000000000..a5cdc5f4f --- /dev/null +++ b/src/rules/exactcoverby3sets_subsetproduct.rs @@ -0,0 +1,94 @@ +//! Reduction from ExactCoverBy3Sets to SubsetProduct. +//! +//! Assign a distinct prime to each universe element. Each triple becomes the +//! product of its three primes, and the target is the product of all universe +//! primes. Unique factorization then makes exact covers correspond exactly to +//! subsets whose product matches the target. + +use crate::models::misc::SubsetProduct; +use crate::models::set::ExactCoverBy3Sets; +use crate::reduction; +use crate::rules::traits::{ReduceTo, ReductionResult}; + +const FIRST_PRIMES: [u64; 15] = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47]; + +#[derive(Debug, Clone)] +pub struct ReductionX3CToSubsetProduct { + target: SubsetProduct, +} + +impl ReductionResult for ReductionX3CToSubsetProduct { + type Source = ExactCoverBy3Sets; + type Target = SubsetProduct; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + target_solution.to_vec() + } +} + +fn checked_product(values: I, what: &str) -> u64 +where + I: IntoIterator, +{ + values.into_iter().fold(1u64, |product, value| { + product.checked_mul(value).unwrap_or_else(|| { + panic!("ExactCoverBy3Sets -> SubsetProduct requires {what} to fit in u64") + }) + }) +} + +fn assigned_primes(universe_size: usize) -> &'static [u64] { + assert!( + universe_size <= FIRST_PRIMES.len(), + "ExactCoverBy3Sets -> SubsetProduct requires the target product to fit in u64; universe_size={universe_size} exceeds the supported limit {}", + FIRST_PRIMES.len() + ); + &FIRST_PRIMES[..universe_size] +} + +#[reduction(overhead = { + num_elements = "num_sets", +})] +impl ReduceTo for ExactCoverBy3Sets { + type Result = ReductionX3CToSubsetProduct; + + fn reduce_to(&self) -> Self::Result { + let primes = assigned_primes(self.universe_size()); + let values = self + .sets() + .iter() + .map(|set| checked_product(set.iter().map(|&element| primes[element]), "set value")) + .collect(); + let target = checked_product(primes.iter().copied(), "target product"); + + ReductionX3CToSubsetProduct { + target: SubsetProduct::new(values, target), + } + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + use crate::export::SolutionPair; + + vec![crate::example_db::specs::RuleExampleSpec { + id: "exactcoverby3sets_to_subsetproduct", + build: || { + crate::example_db::specs::rule_example_with_witness::<_, SubsetProduct>( + ExactCoverBy3Sets::new(6, vec![[0, 1, 2], [3, 4, 5], [0, 3, 4]]), + SolutionPair { + source_config: vec![1, 1, 0], + target_config: vec![1, 1, 0], + }, + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/exactcoverby3sets_subsetproduct.rs"] +mod tests; diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 381a976d6..16967b8ba 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -9,6 +9,7 @@ pub use registry::{EdgeCapabilities, ReductionEntry, ReductionOverhead}; pub(crate) mod circuit_spinglass; mod closestvectorproblem_qubo; pub(crate) mod coloring_qubo; +pub(crate) mod exactcoverby3sets_subsetproduct; pub(crate) mod factoring_circuit; mod graph; pub(crate) mod graphpartitioning_maxcut; @@ -244,6 +245,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec::NAME, "SubsetProduct"); + assert_eq!(::variant(), vec![]); +} + +#[test] +fn test_subsetproduct_evaluate_satisfying() { + let problem = SubsetProduct::new(vec![2, 3, 5, 6], 30); + assert!(problem.evaluate(&[1, 1, 1, 0])); + assert!(problem.evaluate(&[0, 0, 1, 1])); +} + +#[test] +fn test_subsetproduct_evaluate_unsatisfying() { + let problem = SubsetProduct::new(vec![2, 3, 5, 7], 30); + assert!(!problem.evaluate(&[0, 0, 0, 0])); + assert!(!problem.evaluate(&[1, 1, 0, 0])); + assert!(!problem.evaluate(&[1, 1, 1, 1])); +} + +#[test] +fn test_subsetproduct_rejects_invalid_configs() { + let problem = SubsetProduct::new(vec![2, 3, 5], 30); + assert!(!problem.evaluate(&[1, 0])); + assert!(!problem.evaluate(&[1, 0, 0, 0])); + assert!(!problem.evaluate(&[2, 0, 0])); +} + +#[test] +fn test_subsetproduct_overflow_returns_false() { + let problem = SubsetProduct::new(vec![u64::MAX, 2], u64::MAX); + assert!(problem.evaluate(&[1, 0])); + assert!(!problem.evaluate(&[1, 1])); +} + +#[test] +fn test_subsetproduct_bruteforce_finds_witness() { + let problem = SubsetProduct::new(vec![2, 3, 5, 7], 35); + let solution = BruteForce::new() + .find_witness(&problem) + .expect("expected a satisfying subset"); + assert!(problem.evaluate(&solution)); +} + +#[test] +fn test_subsetproduct_serialization() { + let problem = SubsetProduct::new(vec![2, 3, 5], 30); + let json = serde_json::to_value(&problem).unwrap(); + assert_eq!( + json, + serde_json::json!({ + "values": [2, 3, 5], + "target": 30, + }) + ); + + let restored: SubsetProduct = serde_json::from_value(json).unwrap(); + assert_eq!(restored.values(), problem.values()); + assert_eq!(restored.target(), problem.target()); +} + +#[test] +#[should_panic(expected = "positive")] +fn test_subsetproduct_zero_value_panics() { + SubsetProduct::new(vec![2, 0, 5], 10); +} diff --git a/src/unit_tests/models/set/exact_cover_by_3_sets.rs b/src/unit_tests/models/set/exact_cover_by_3_sets.rs index f5406303b..fef828bfb 100644 --- a/src/unit_tests/models/set/exact_cover_by_3_sets.rs +++ b/src/unit_tests/models/set/exact_cover_by_3_sets.rs @@ -7,6 +7,7 @@ fn test_exact_cover_by_3_sets_creation() { let problem = ExactCoverBy3Sets::new(6, vec![[0, 1, 2], [3, 4, 5], [0, 3, 4]]); assert_eq!(problem.universe_size(), 6); assert_eq!(problem.num_subsets(), 3); + assert_eq!(problem.num_sets(), 3); assert_eq!(problem.num_variables(), 3); assert_eq!(problem.dims(), vec![2, 2, 2]); } @@ -94,6 +95,8 @@ fn test_exact_cover_by_3_sets_serialization() { let deserialized: ExactCoverBy3Sets = serde_json::from_str(&json).unwrap(); assert_eq!(deserialized.universe_size(), problem.universe_size()); assert_eq!(deserialized.num_subsets(), problem.num_subsets()); + assert_eq!(deserialized.num_sets(), problem.num_sets()); + assert_eq!(deserialized.sets(), problem.sets()); assert_eq!(deserialized.subsets(), problem.subsets()); } diff --git a/src/unit_tests/rules/exactcoverby3sets_subsetproduct.rs b/src/unit_tests/rules/exactcoverby3sets_subsetproduct.rs new file mode 100644 index 000000000..cb693fb1f --- /dev/null +++ b/src/unit_tests/rules/exactcoverby3sets_subsetproduct.rs @@ -0,0 +1,53 @@ +use crate::models::misc::SubsetProduct; +use crate::models::set::ExactCoverBy3Sets; +use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; +use crate::rules::{ReduceTo, ReductionResult}; + +#[test] +fn test_exactcoverby3sets_to_subsetproduct_closed_loop() { + let source = ExactCoverBy3Sets::new(6, vec![[0, 1, 2], [3, 4, 5], [0, 3, 4]]); + let reduction = ReduceTo::::reduce_to(&source); + + assert_satisfaction_round_trip_from_satisfaction_target( + &source, + &reduction, + "ExactCoverBy3Sets -> SubsetProduct closed loop", + ); +} + +#[test] +fn test_exactcoverby3sets_to_subsetproduct_structure() { + let source = ExactCoverBy3Sets::new(6, vec![[0, 1, 2], [3, 4, 5], [0, 3, 4]]); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + assert_eq!(target.values(), &[30, 1001, 154]); + assert_eq!(target.target(), 30030); + assert_eq!(target.num_elements(), 3); +} + +#[test] +fn test_exactcoverby3sets_to_subsetproduct_extract_solution_is_identity() { + let source = ExactCoverBy3Sets::new(6, vec![[0, 1, 2], [3, 4, 5], [0, 3, 4]]); + let reduction = ReduceTo::::reduce_to(&source); + + assert_eq!(reduction.extract_solution(&[1, 0, 1]), vec![1, 0, 1]); +} + +#[test] +#[should_panic(expected = "u64")] +fn test_exactcoverby3sets_to_subsetproduct_panics_when_target_overflows_u64() { + let source = ExactCoverBy3Sets::new( + 18, + vec![ + [0, 1, 2], + [3, 4, 5], + [6, 7, 8], + [9, 10, 11], + [12, 13, 14], + [15, 16, 17], + ], + ); + + let _ = ReduceTo::::reduce_to(&source); +} From 692680faffece82f96b9e8d8f35142de4e92365a Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Fri, 3 Apr 2026 23:01:20 +0800 Subject: [PATCH 08/25] =?UTF-8?q?feat:=20add=20IntegerExpressionMembership?= =?UTF-8?q?=20model=20and=20SubsetSum=20=E2=86=92=20IEM=20rule=20(#569)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds IntegerExpressionMembership (pick-one-per-position sum check with Value=Or) and the shift-encoding reduction from SubsetSum. Each element a_i creates choice set {1, a_i+1}; target K = B + n. Picking a_i+1 means "include element"; sum equals K iff selected elements sum to B. Includes CLI support, paper entries, and tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/paper/reductions.typ | 80 ++++++++++++ problemreductions-cli/src/cli.rs | 6 +- problemreductions-cli/src/commands/create.rs | 83 +++++++++++- src/lib.rs | 3 +- .../integer_expression_membership.rs | 107 ++++++++++++++++ src/models/algebraic/mod.rs | 4 + src/models/mod.rs | 3 +- src/rules/mod.rs | 2 + .../subsetsum_integerexpressionmembership.rs | 80 ++++++++++++ .../integer_expression_membership.rs | 121 ++++++++++++++++++ .../subsetsum_integerexpressionmembership.rs | 105 +++++++++++++++ 11 files changed, 590 insertions(+), 4 deletions(-) create mode 100644 src/models/algebraic/integer_expression_membership.rs create mode 100644 src/rules/subsetsum_integerexpressionmembership.rs create mode 100644 src/unit_tests/models/algebraic/integer_expression_membership.rs create mode 100644 src/unit_tests/rules/subsetsum_integerexpressionmembership.rs diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index e828aef80..ee4d8d72f 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -135,6 +135,7 @@ "CapacityAssignment": [Capacity Assignment], "ConsistencyOfDatabaseFrequencyTables": [Consistency of Database Frequency Tables], "ClosestVectorProblem": [Closest Vector Problem], + "IntegerExpressionMembership": [Integer Expression Membership], "ConsecutiveSets": [Consecutive Sets], "DisjointConnectingPaths": [Disjoint Connecting Paths], "MinimumMultiwayCut": [Minimum Multiway Cut], @@ -3473,6 +3474,32 @@ A classical NP-complete problem from Garey and Johnson @garey1979[Ch.~3, p.~76], ] } +#{ + let x = load-model-example("IntegerExpressionMembership") + let choices = x.instance.choices + let target = x.instance.target + let n = choices.len() + let config = x.optimal_config + let selected-values = range(n).map(i => choices.at(i).at(config.at(i))) + let selected-sum = selected-values.fold(0, (a, b) => a + b) + let fmt-choice-set(cs) = "{" + cs.map(str).join(", ") + "}" + [ + #problem-def("IntegerExpressionMembership")[ + Given finite choice sets $C_0, dots, C_(n-1) subset.eq ZZ^(>= 0)$ and a target $K in ZZ^(>= 0)$, determine whether there exist values $x_i in C_i$ for all $i in {0, dots, n-1}$ such that $sum_(i=0)^(n-1) x_i = K$. + ][ + Integer Expression Membership appears in the algebraic reductions chapter of Garey and Johnson @garey1979, where expressions are built from singleton sets using unions and sums. The implementation in this crate uses the product-of-unions fragment needed for the Subset Sum reduction below: one chooses exactly one value from each position and checks whether the resulting total equals the target. + + *Example.* Let the choice sets be $(#choices.map(fmt-choice-set).join(", "))$ and let $K = #target$. The stored config $(#config.map(str).join(", "))$ selects values $(#selected-values.map(str).join(", "))$, whose sum is $#selected-sum = #target$. Hence the instance is YES. + + #pred-commands( + "pred create --example " + problem-spec(x) + " -o integer-expression-membership.json", + "pred solve integer-expression-membership.json", + "pred evaluate integer-expression-membership.json --config " + x.optimal_config.map(str).join(","), + ) + ] + ] +} + == Satisfiability Problems #{ @@ -7043,6 +7070,59 @@ where $P$ is a penalty weight large enough that any constraint violation costs m ] } +#{ + let ss_iem = load-example("SubsetSum", "IntegerExpressionMembership") + let ss_iem_sol = ss_iem.solutions.at(0) + let ss_iem_sizes = ss_iem.source.instance.sizes + let ss_iem_n = ss_iem_sizes.len() + let ss_iem_target = ss_iem.source.instance.target + let iem_choices = ss_iem.target.instance.choices + let iem_target = ss_iem.target.instance.target + let chosen-values = range(ss_iem_n).map(i => iem_choices.at(i).at(ss_iem_sol.target_config.at(i))) + let selected-indices = ss_iem_sol.source_config.enumerate().filter(((i, x)) => x == 1).map(((i, _)) => i) + let selected-sizes = selected-indices.map(i => ss_iem_sizes.at(i)) + let fmt-choice-set(cs) = "{" + cs.map(str).join(", ") + "}" + [ + #reduction-rule("SubsetSum", "IntegerExpressionMembership", + example: true, + example-caption: [#ss_iem_n elements, target sum $B = #ss_iem_target$], + extra: [ + #pred-commands( + "pred create --example " + problem-spec(ss_iem.source) + " -o subsetsum.json", + "pred reduce subsetsum.json --to " + target-spec(ss_iem) + " -o bundle.json", + "pred solve bundle.json", + "pred evaluate subsetsum.json --config " + ss_iem_sol.source_config.map(str).join(","), + ) + + *Step 1 -- Source instance.* The canonical Subset Sum instance has sizes $(#ss_iem_sizes.map(str).join(", "))$ and target $B = #ss_iem_target$. + + *Step 2 -- Shift each subset decision.* For each size $s_i$, the reduction creates one choice set $C_i = {1, s_i + 1}$. In the example this yields $(#iem_choices.map(fmt-choice-set).join(", "))$ and the shifted target becomes $K = B + n = #iem_target$. + + *Step 3 -- Verify the canonical witness.* The stored source witness is $bold(x) = (#ss_iem_sol.source_config.map(str).join(", "))$, selecting indices $\{#selected-indices.map(str).join(", ")\}$ with sizes $(#selected-sizes.map(str).join(", "))$. The stored target witness is $bold(y) = (#ss_iem_sol.target_config.map(str).join(", "))$, which chooses values $(#chosen-values.map(str).join(", "))$. Their sum is $#chosen-values.fold(0, (a, b) => a + b) = #iem_target$, so the Integer Expression Membership instance is YES exactly when the original subset sums to $B$. + + *Witness semantics.* Because every target position is ordered as $(1, s_i + 1)$, the extracted Subset Sum bit is simply the target choice index itself: choose the second value iff the original item is included. + ], + )[ + This $O(n)$ shift encoding turns each Subset Sum include/exclude decision into a binary local choice, instantiating the Integer Expression Membership fragment used in Garey and Johnson @garey1979. The target keeps one position per source element, so the only size overhead is a relabeling of the witness space. + ][ + _Construction._ Given positive sizes $s_0, dots, s_(n-1)$ and target $B$, create one choice set per source element: + $ C_i = {1, s_i + 1} $ + for $i in {0, dots, n-1}$. Set the Integer Expression Membership target to + $ K = B + n. $ + + _Correctness._ ($arrow.r.double$) If $X subset.eq {0, dots, n-1}$ is a satisfying Subset Sum witness with $sum_(i in X) s_i = B$, choose $s_i + 1$ from $C_i$ when $i in X$ and choose $1$ otherwise. The resulting total is + $ sum_(i in X) (s_i + 1) + sum_(i notin X) 1 = sum_(i in X) s_i + n = B + n = K, $ + so the Integer Expression Membership instance is YES. + + ($arrow.l.double$) Conversely, suppose a target witness chooses one value from each $C_i$ and sums to $K = B + n$. Subtract the baseline value $1$ from every chosen term. Each position then contributes either $0$ or $s_i$, and the total residual is + $ K - n = B. $ + Therefore the positions that chose $s_i + 1$ form a subset of the original elements summing to $B$, so the Subset Sum instance is YES. + + _Solution extraction._ Given a target config $bold(y)$, return the same binary vector: output $x_i = 1$ iff $y_i = 1$, meaning the second entry of $C_i = {1, s_i + 1}$ was selected. + ] + ] +} + #reduction-rule("ILP", "QUBO")[ A binary ILP optimizes a linear objective over binary variables subject to linear constraints. The penalty method converts each equality constraint $bold(a)_k^top bold(x) = b_k$ into the quadratic penalty $(bold(a)_k^top bold(x) - b_k)^2$, which is zero if and only if the constraint is satisfied. Inequality constraints are first converted to equalities using binary slack variables with powers-of-two coefficients. The resulting unconstrained quadratic over binary variables is a QUBO whose matrix $Q$ combines the negated objective (as diagonal terms) with the expanded constraint penalties (as a Gram matrix $A^top A$). ][ diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index b955bb5dc..90a1ab03a 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -250,6 +250,7 @@ Flags by problem type: Factoring --target, --m, --n BinPacking --sizes, --capacity CapacityAssignment --capacities, --cost-matrix, --delay-matrix, --cost-budget, --delay-budget + IntegerExpressionMembership --choices, --target SubsetProduct --values, --target SubsetSum --sizes, --target SumOfSquaresPartition --sizes, --num-groups, --bound @@ -456,7 +457,7 @@ pub struct CreateArgs { /// Random seed for reproducibility #[arg(long)] pub seed: Option, - /// Target value (for Factoring, SubsetProduct, and SubsetSum) + /// Target value (for Factoring, IntegerExpressionMembership, SubsetProduct, and SubsetSum) #[arg(long)] pub target: Option, /// Bits for first factor (for Factoring); also accepted as a processor-count alias for scheduling create commands @@ -492,6 +493,9 @@ pub struct CreateArgs { /// Item sizes for BinPacking (comma-separated, e.g., "3,3,2,2") #[arg(long)] pub sizes: Option, + /// Choice rows for IntegerExpressionMembership (semicolon-separated, e.g., "1,2;1,6;1,7;1,9") + #[arg(long)] + pub choices: Option, /// Record access probabilities for ExpectedRetrievalCost (comma-separated, e.g., "0.2,0.15,0.15,0.2,0.1,0.2") #[arg(long)] pub probabilities: Option, diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 0ee6fc48c..93f86416d 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -9,7 +9,7 @@ use anyhow::{bail, Context, Result}; use problemreductions::export::{ModelExample, ProblemRef, ProblemSide, RuleExample}; use problemreductions::models::algebraic::{ ClosestVectorProblem, ConsecutiveBlockMinimization, ConsecutiveOnesMatrixAugmentation, - ConsecutiveOnesSubmatrix, SparseMatrixCompression, BMF, + ConsecutiveOnesSubmatrix, IntegerExpressionMembership, SparseMatrixCompression, BMF, }; use problemreductions::models::formula::Quantifier; use problemreductions::models::graph::{ @@ -93,6 +93,7 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool { && args.requirement_2.is_none() && args.requirement.is_none() && args.sizes.is_none() + && args.choices.is_none() && args.probabilities.is_none() && args.capacity.is_none() && args.sequence.is_none() @@ -514,6 +515,7 @@ fn type_format_hint(type_name: &str, graph_type: Option<&str>) -> &'static str { "comma-separated weighted edges: 0-2:3,1-3:5" } "Vec>" => "semicolon-separated sets: \"0,1;1,2;0,2\"", + "Vec>" => "semicolon-separated rows: \"1,2;1,6;1,7;1,9\"", "Vec" => "semicolon-separated clauses: \"1,2;-1,3\"", "Vec>" => "semicolon-separated terms: \"1,2;-1,3\"", "Vec>" => "JSON 2D bool array: '[[true,false],[false,true]]'", @@ -604,6 +606,7 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { } "KSatisfiability" => "--num-vars 3 --clauses \"1,2,3;-1,2,-3\" --k 3", "QUBO" => "--matrix \"1,0.5;0.5,2\"", + "IntegerExpressionMembership" => "--choices \"1,2;1,6;1,7;1,9\" --target 15", "QuadraticAssignment" => "--matrix \"0,5;5,0\" --distance-matrix \"0,1;1,0\"", "SpinGlass" => "--graph 0-1,1-2 --couplings 1,1", "KColoring" => "--graph 0-1,1-2,2-0 --k 3", @@ -2180,6 +2183,31 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { (ser(QUBO::from_matrix(matrix))?, resolved_variant.clone()) } + // IntegerExpressionMembership + "IntegerExpressionMembership" => { + let choices_str = args.choices.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "IntegerExpressionMembership requires --choices and --target\n\n\ + Usage: pred create IntegerExpressionMembership --choices \"1,2;1,6;1,7;1,9\" --target 15" + ) + })?; + let target = args.target.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "IntegerExpressionMembership requires --target\n\n\ + Usage: pred create IntegerExpressionMembership --choices \"1,2;1,6;1,7;1,9\" --target 15" + ) + })?; + let choices = parse_u64_matrix_rows(choices_str, "choices")?; + let target = target + .trim() + .parse::() + .map_err(|e| anyhow::anyhow!("Invalid target '{}': {e}", target.trim()))?; + ( + ser(IntegerExpressionMembership::new(choices, target))?, + resolved_variant.clone(), + ) + } + // SpinGlass "SpinGlass" => { let (graph, n) = parse_graph(args).map_err(|e| { @@ -6730,6 +6758,51 @@ mod tests { std::fs::remove_file(output_path).unwrap(); } + #[test] + fn test_create_integer_expression_membership_outputs_problem_json() { + let cli = Cli::try_parse_from([ + "pred", + "create", + "IntegerExpressionMembership", + "--choices", + "1,2;1,6;1,7;1,9", + "--target", + "15", + ]) + .unwrap(); + + let args = match cli.command { + Commands::Create(args) => args, + _ => panic!("expected create command"), + }; + + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let output_path = std::env::temp_dir().join(format!( + "integer-expression-membership-create-{suffix}.json" + )); + let out = OutputConfig { + output: Some(output_path.clone()), + quiet: true, + json: false, + auto_json: false, + }; + + create(&args, &out).unwrap(); + + let json: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&output_path).unwrap()).unwrap(); + assert_eq!(json["type"], "IntegerExpressionMembership"); + assert_eq!( + json["data"]["choices"], + serde_json::json!([[1, 2], [1, 6], [1, 7], [1, 9]]) + ); + assert_eq!(json["data"]["target"], serde_json::json!(15)); + std::fs::remove_file(output_path).unwrap(); + } + #[test] fn test_create_path_constrained_network_flow_outputs_problem_json() { let cli = Cli::try_parse_from([ @@ -7415,6 +7488,7 @@ mod tests { requirement_1: None, requirement_2: None, sizes: None, + choices: None, probabilities: None, capacity: None, sequence: None, @@ -7536,6 +7610,13 @@ mod tests { assert!(!all_data_flags_empty(&args)); } + #[test] + fn test_all_data_flags_empty_treats_choices_as_input() { + let mut args = empty_args(); + args.choices = Some("1,2;1,6".to_string()); + assert!(!all_data_flags_empty(&args)); + } + #[test] fn test_all_data_flags_empty_treats_disjuncts_as_input() { let mut args = empty_args(); diff --git a/src/lib.rs b/src/lib.rs index a0e069e4c..502ef7e49 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -43,7 +43,8 @@ pub mod variant; pub mod prelude { // Problem types pub use crate::models::algebraic::{ - ConsecutiveOnesMatrixAugmentation, QuadraticAssignment, SparseMatrixCompression, BMF, QUBO, + ConsecutiveOnesMatrixAugmentation, IntegerExpressionMembership, QuadraticAssignment, + SparseMatrixCompression, BMF, QUBO, }; pub use crate::models::formula::{ CNFClause, CircuitSAT, KSatisfiability, NAESatisfiability, NonTautology, diff --git a/src/models/algebraic/integer_expression_membership.rs b/src/models/algebraic/integer_expression_membership.rs new file mode 100644 index 000000000..8bc43acdc --- /dev/null +++ b/src/models/algebraic/integer_expression_membership.rs @@ -0,0 +1,107 @@ +use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry}; +use crate::traits::Problem; +use crate::types::Or; +use serde::{Deserialize, Serialize}; + +inventory::submit! { + ProblemSchemaEntry { + name: "IntegerExpressionMembership", + display_name: "Integer Expression Membership", + aliases: &[], + dimensions: &[], + module_path: module_path!(), + description: "Pick one value from each choice set so the total equals a target value", + fields: &[ + FieldInfo { name: "choices", type_name: "Vec>", description: "Choice set for each position" }, + FieldInfo { name: "target", type_name: "u64", description: "Target total value" }, + ], + } +} + +inventory::submit! { + ProblemSizeFieldEntry { + name: "IntegerExpressionMembership", + fields: &["num_positions"], + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IntegerExpressionMembership { + choices: Vec>, + target: u64, +} + +impl IntegerExpressionMembership { + pub fn new(choices: Vec>, target: u64) -> Self { + assert!( + choices.iter().all(|choice_set| !choice_set.is_empty()), + "Each choice set must contain at least one value" + ); + Self { choices, target } + } + + pub fn choices(&self) -> &[Vec] { + &self.choices + } + + pub fn target(&self) -> u64 { + self.target + } + + pub fn num_positions(&self) -> usize { + self.choices.len() + } +} + +impl Problem for IntegerExpressionMembership { + const NAME: &'static str = "IntegerExpressionMembership"; + type Value = Or; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![] + } + + fn dims(&self) -> Vec { + self.choices + .iter() + .map(|choice_set| choice_set.len()) + .collect() + } + + fn evaluate(&self, config: &[usize]) -> Or { + if config.len() != self.num_positions() { + return Or(false); + } + + let sum = config + .iter() + .enumerate() + .try_fold(0u64, |sum, (position, &choice_index)| { + let value = *self.choices[position].get(choice_index)?; + sum.checked_add(value) + }); + + Or(matches!(sum, Some(total) if total == self.target)) + } +} + +crate::declare_variants! { + default IntegerExpressionMembership => "2^num_positions", +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "integer_expression_membership", + instance: Box::new(IntegerExpressionMembership::new( + vec![vec![1, 2], vec![1, 6], vec![1, 7], vec![1, 9]], + 15, + )), + optimal_config: vec![0, 1, 1, 0], + optimal_value: serde_json::json!(true), + }] +} + +#[cfg(test)] +#[path = "../../unit_tests/models/algebraic/integer_expression_membership.rs"] +mod tests; diff --git a/src/models/algebraic/mod.rs b/src/models/algebraic/mod.rs index d341270c4..6eb00178a 100644 --- a/src/models/algebraic/mod.rs +++ b/src/models/algebraic/mod.rs @@ -4,6 +4,7 @@ //! - [`QUBO`]: Quadratic Unconstrained Binary Optimization //! - [`ILP`]: Integer Linear Programming //! - [`ClosestVectorProblem`]: Closest Vector Problem (minimize lattice distance) +//! - [`IntegerExpressionMembership`]: Pick one integer from each choice set to hit a target sum //! - [`BMF`]: Boolean Matrix Factorization //! - [`ConsecutiveBlockMinimization`]: Consecutive Block Minimization //! - [`ConsecutiveOnesSubmatrix`]: Consecutive Ones Submatrix (column selection with C1P) @@ -16,6 +17,7 @@ pub(crate) mod consecutive_block_minimization; pub(crate) mod consecutive_ones_matrix_augmentation; pub(crate) mod consecutive_ones_submatrix; pub(crate) mod ilp; +pub(crate) mod integer_expression_membership; pub(crate) mod quadratic_assignment; pub(crate) mod qubo; pub(crate) mod sparse_matrix_compression; @@ -26,6 +28,7 @@ pub use consecutive_block_minimization::ConsecutiveBlockMinimization; pub use consecutive_ones_matrix_augmentation::ConsecutiveOnesMatrixAugmentation; pub use consecutive_ones_submatrix::ConsecutiveOnesSubmatrix; pub use ilp::{Comparison, LinearConstraint, ObjectiveSense, VariableDomain, ILP}; +pub use integer_expression_membership::IntegerExpressionMembership; pub use quadratic_assignment::QuadraticAssignment; pub use qubo::QUBO; pub use sparse_matrix_compression::SparseMatrixCompression; @@ -36,6 +39,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec Vec &Self::Target { + &self.target + } + + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + target_solution + .iter() + .map(|&choice_index| usize::from(choice_index == 1)) + .collect() + } +} + +#[reduction(overhead = { + num_positions = "num_elements", +})] +impl ReduceTo for SubsetSum { + type Result = ReductionSubsetSumToIntegerExpressionMembership; + + fn reduce_to(&self) -> Self::Result { + let choices = self + .sizes() + .iter() + .map(|size| { + let size = size.to_u64().unwrap(); + vec![ + 1, + size.checked_add(1).expect( + "SubsetSum -> IntegerExpressionMembership requires shifted values to fit in u64", + ), + ] + }) + .collect(); + let shift = u64::try_from(self.num_elements()) + .expect("SubsetSum -> IntegerExpressionMembership requires num_elements to fit in u64"); + let target = self.target().to_u64().unwrap().checked_add(shift).expect( + "SubsetSum -> IntegerExpressionMembership requires shifted target to fit in u64", + ); + + ReductionSubsetSumToIntegerExpressionMembership { + target: IntegerExpressionMembership::new(choices, target), + } + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + use crate::export::SolutionPair; + + vec![crate::example_db::specs::RuleExampleSpec { + id: "subsetsum_to_integerexpressionmembership", + build: || { + crate::example_db::specs::rule_example_with_witness::<_, IntegerExpressionMembership>( + SubsetSum::new(vec![1u32, 5, 6, 8], 11u32), + SolutionPair { + source_config: vec![0, 1, 1, 0], + target_config: vec![0, 1, 1, 0], + }, + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/subsetsum_integerexpressionmembership.rs"] +mod tests; diff --git a/src/unit_tests/models/algebraic/integer_expression_membership.rs b/src/unit_tests/models/algebraic/integer_expression_membership.rs new file mode 100644 index 000000000..cafd298e8 --- /dev/null +++ b/src/unit_tests/models/algebraic/integer_expression_membership.rs @@ -0,0 +1,121 @@ +use super::*; +use crate::registry::declared_size_fields; +use crate::solvers::BruteForce; +use crate::traits::Problem; +use std::collections::HashSet; + +fn issue_example_problem() -> IntegerExpressionMembership { + IntegerExpressionMembership::new(vec![vec![1, 2], vec![1, 6], vec![1, 7], vec![1, 9]], 15) +} + +fn issue_example_config() -> Vec { + vec![0, 1, 1, 0] +} + +#[test] +fn test_integer_expression_membership_creation_accessors_and_dimensions() { + let problem = issue_example_problem(); + + assert_eq!( + problem.choices(), + &[vec![1, 2], vec![1, 6], vec![1, 7], vec![1, 9]] + ); + assert_eq!(problem.target(), 15); + assert_eq!(problem.num_positions(), 4); + assert_eq!(problem.num_variables(), 4); + assert_eq!(problem.dims(), vec![2, 2, 2, 2]); + assert_eq!( + ::NAME, + "IntegerExpressionMembership" + ); + assert_eq!( + ::variant(), + Vec::<(&'static str, &'static str)>::new() + ); +} + +#[test] +fn test_integer_expression_membership_evaluate_valid_and_invalid_configs() { + let problem = issue_example_problem(); + + assert!(problem.evaluate(&issue_example_config())); + assert!(!problem.evaluate(&[1, 0, 1, 0])); + assert!(!problem.evaluate(&[0, 1, 1])); + assert!(!problem.evaluate(&[0, 1, 1, 2])); +} + +#[test] +fn test_integer_expression_membership_multiway_choices() { + let problem = IntegerExpressionMembership::new(vec![vec![0, 2, 5], vec![1, 4]], 9); + + assert_eq!(problem.dims(), vec![3, 2]); + assert!(problem.evaluate(&[2, 1])); + assert!(!problem.evaluate(&[1, 1])); +} + +#[test] +fn test_integer_expression_membership_bruteforce_issue_example() { + let problem = issue_example_problem(); + let solver = BruteForce::new(); + + let best = solver + .find_witness(&problem) + .expect("should find a witness"); + assert_eq!(best, issue_example_config()); + assert!(problem.evaluate(&best)); +} + +#[test] +fn test_integer_expression_membership_serialization_round_trip() { + let problem = issue_example_problem(); + let json = serde_json::to_value(&problem).unwrap(); + + assert_eq!( + json, + serde_json::json!({ + "choices": [[1, 2], [1, 6], [1, 7], [1, 9]], + "target": 15, + }) + ); + + let restored: IntegerExpressionMembership = serde_json::from_value(json).unwrap(); + assert_eq!(restored.choices(), problem.choices()); + assert_eq!(restored.target(), problem.target()); +} + +#[test] +fn test_integer_expression_membership_declares_problem_size_fields() { + let fields: HashSet<&'static str> = declared_size_fields("IntegerExpressionMembership") + .into_iter() + .collect(); + assert_eq!(fields, HashSet::from(["num_positions"])); +} + +#[test] +fn test_integer_expression_membership_paper_example() { + let problem = issue_example_problem(); + + assert!(problem.evaluate(&issue_example_config())); + assert_eq!( + BruteForce::new().find_all_witnesses(&problem), + vec![issue_example_config()] + ); +} + +#[cfg(feature = "example-db")] +#[test] +fn test_integer_expression_membership_canonical_example_spec() { + let specs = canonical_model_example_specs(); + assert_eq!(specs.len(), 1); + let spec = &specs[0]; + + assert_eq!(spec.id, "integer_expression_membership"); + assert_eq!(spec.optimal_config, issue_example_config()); + assert_eq!(spec.optimal_value, serde_json::json!(true)); + + let problem: IntegerExpressionMembership = + serde_json::from_value(spec.instance.serialize_json()).unwrap(); + assert_eq!(problem.choices(), issue_example_problem().choices()); + assert_eq!(problem.target(), issue_example_problem().target()); + assert!(problem.evaluate(&issue_example_config())); +} diff --git a/src/unit_tests/rules/subsetsum_integerexpressionmembership.rs b/src/unit_tests/rules/subsetsum_integerexpressionmembership.rs new file mode 100644 index 000000000..f846c7b33 --- /dev/null +++ b/src/unit_tests/rules/subsetsum_integerexpressionmembership.rs @@ -0,0 +1,105 @@ +#[cfg(feature = "example-db")] +use super::canonical_rule_example_specs; +use crate::models::algebraic::IntegerExpressionMembership; +use crate::models::misc::SubsetSum; +use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; +use crate::rules::traits::ReductionResult; +use crate::rules::ReduceTo; +use crate::solvers::BruteForce; +#[cfg(feature = "example-db")] +use crate::traits::Problem; + +fn issue_example_source() -> SubsetSum { + SubsetSum::new(vec![1u32, 5, 6, 8], 11u32) +} + +fn issue_example_source_config() -> Vec { + vec![0, 1, 1, 0] +} + +fn issue_example_target_config() -> Vec { + vec![0, 1, 1, 0] +} + +#[test] +fn test_subsetsum_to_integerexpressionmembership_closed_loop() { + let source = issue_example_source(); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + assert_eq!( + target.choices(), + &[vec![1, 2], vec![1, 6], vec![1, 7], vec![1, 9]] + ); + assert_eq!(target.target(), 15); + assert_eq!(target.num_positions(), source.num_elements()); + + assert_satisfaction_round_trip_from_satisfaction_target( + &source, + &reduction, + "SubsetSum -> IntegerExpressionMembership closed loop", + ); +} + +#[test] +fn test_subsetsum_to_integerexpressionmembership_extract_solution_matches_choice_bits() { + let source = issue_example_source(); + let reduction = ReduceTo::::reduce_to(&source); + + assert_eq!( + reduction.extract_solution(&issue_example_target_config()), + issue_example_source_config() + ); + assert_eq!(reduction.extract_solution(&[1, 0, 0, 1]), vec![1, 0, 0, 1]); +} + +#[test] +fn test_subsetsum_to_integerexpressionmembership_unsatisfiable_instance_stays_unsatisfiable() { + let source = SubsetSum::new(vec![2u32, 4, 6], 5u32); + let reduction = ReduceTo::::reduce_to(&source); + + assert!(BruteForce::new().find_witness(&source).is_none()); + assert!(BruteForce::new() + .find_witness(reduction.target_problem()) + .is_none()); +} + +#[cfg(feature = "example-db")] +#[test] +fn test_subsetsum_to_integerexpressionmembership_canonical_example_spec() { + let example = (canonical_rule_example_specs() + .into_iter() + .find(|spec| spec.id == "subsetsum_to_integerexpressionmembership") + .expect("missing canonical SubsetSum -> IntegerExpressionMembership example spec") + .build)(); + + assert_eq!(example.source.problem, "SubsetSum"); + assert_eq!(example.target.problem, "IntegerExpressionMembership"); + assert_eq!( + example.target.instance["choices"], + serde_json::json!([[1, 2], [1, 6], [1, 7], [1, 9]]) + ); + assert_eq!(example.target.instance["target"], serde_json::json!(15)); + assert_eq!(example.solutions.len(), 1); + assert_eq!( + example.solutions[0].source_config, + issue_example_source_config() + ); + assert_eq!( + example.solutions[0].target_config, + issue_example_target_config() + ); + + let source: SubsetSum = serde_json::from_value(example.source.instance.clone()) + .expect("source example deserializes"); + let target: IntegerExpressionMembership = + serde_json::from_value(example.target.instance.clone()) + .expect("target example deserializes"); + + assert!(source + .evaluate(&example.solutions[0].source_config) + .is_valid()); + assert!(target + .evaluate(&example.solutions[0].target_config) + .is_valid()); +} From 4473224e579da922ae510093afcb5d4dc0d481ed Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Fri, 3 Apr 2026 23:53:17 +0800 Subject: [PATCH 09/25] =?UTF-8?q?feat:=20add=20MinimumWeightSolutionToLine?= =?UTF-8?q?arEquations=20model=20and=20X3C=20=E2=86=92=20MWSLE=20rule=20(#?= =?UTF-8?q?860)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds MWSLE algebraic model (Value=Or, binary variables with linear equation check + sparsity bound) and the incidence-matrix reduction from ExactCoverBy3Sets. One variable per 3-set, one equation per universe element, bound K = |U|/3. Exact cover iff equation system has K-sparse solution. Includes CLI support, paper entries, and tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/paper/reductions.typ | 64 ++++++++ problemreductions-cli/src/cli.rs | 4 + problemreductions-cli/src/commands/create.rs | 109 ++++++++++++- src/lib.rs | 5 +- ...mum_weight_solution_to_linear_equations.rs | 145 ++++++++++++++++++ src/models/algebraic/mod.rs | 4 + src/models/mod.rs | 4 +- ..._minimumweightsolutiontolinearequations.rs | 79 ++++++++++ src/rules/mod.rs | 4 + ...mum_weight_solution_to_linear_equations.rs | 53 +++++++ ..._minimumweightsolutiontolinearequations.rs | 47 ++++++ 11 files changed, 513 insertions(+), 5 deletions(-) create mode 100644 src/models/algebraic/minimum_weight_solution_to_linear_equations.rs create mode 100644 src/rules/exactcoverby3sets_minimumweightsolutiontolinearequations.rs create mode 100644 src/unit_tests/models/algebraic/minimum_weight_solution_to_linear_equations.rs create mode 100644 src/unit_tests/rules/exactcoverby3sets_minimumweightsolutiontolinearequations.rs diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index ee4d8d72f..a10fe4b88 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -136,6 +136,7 @@ "ConsistencyOfDatabaseFrequencyTables": [Consistency of Database Frequency Tables], "ClosestVectorProblem": [Closest Vector Problem], "IntegerExpressionMembership": [Integer Expression Membership], + "MinimumWeightSolutionToLinearEquations": [Minimum-Weight Solution to Linear Equations], "ConsecutiveSets": [Consecutive Sets], "DisjointConnectingPaths": [Disjoint Connecting Paths], "MinimumMultiwayCut": [Minimum Multiway Cut], @@ -3500,6 +3501,36 @@ A classical NP-complete problem from Garey and Johnson @garey1979[Ch.~3, p.~76], ] } +#{ + let x = load-model-example("MinimumWeightSolutionToLinearEquations") + let A = x.instance.coefficients + let b = x.instance.rhs + let K = x.instance.bound + let m = A.len() + let n = A.at(0).len() + let config = x.optimal_config + let row-evals = A.map(row => row.zip(config).map(((a, x)) => a * x).sum()) + let nonzero = config.filter(x => x != 0).len() + let fmt-row(row) = "(" + row.map(str).join(", ") + ")" + [ + #problem-def("MinimumWeightSolutionToLinearEquations")[ + Given an integer matrix $A in ZZ^(m times n)$, a right-hand side vector $b in ZZ^m$, and a non-negative integer $K$, determine whether there exists a binary vector $x in {0, 1}^n$ with $||x||_0 <= K$ such that $A x = b$. + ][ + This feasibility problem asks for a sufficiently sparse binary solution to a linear system. It is a natural algebraic decision model for reductions from exact-cover style set systems: each column represents a combinatorial choice, each row enforces local consistency, and the $ell_0$ bound controls how many choices may be active. + + The exact brute-force algorithm is the obvious one: enumerate all $2^n$ binary vectors and test both the sparsity bound and the $m$ equations, yielding $O^*(2^n)$ time. The model in this crate intentionally restricts variables to $0/1$, which is the binary special case needed by the X3C reduction below. + + *Example.* Let $A$ have rows #A.map(fmt-row).join(", "), let $b = #fmt-row(b)$, and let $K = #K$. The binary vector $x = #fmt-row(config)$ has $#nonzero$ nonzero entries, so it respects the bound. Moreover, the row sums are #row-evals.map(str).join(", "), which exactly match $b$. Hence the instance is YES. + + #pred-commands( + "pred create --example " + problem-spec(x) + " -o minimum-weight-solution-to-linear-equations.json", + "pred solve minimum-weight-solution-to-linear-equations.json", + "pred evaluate minimum-weight-solution-to-linear-equations.json --config " + x.optimal_config.map(str).join(","), + ) + ] + ] +} + == Satisfiability Problems #{ @@ -8441,6 +8472,39 @@ The following reductions to Integer Linear Programming are straightforward formu _Solution extraction._ $cal(C) = {T_j : x_j = 1}$. ] +#let x3c_mws = load-example("ExactCoverBy3Sets", "MinimumWeightSolutionToLinearEquations") +#let x3c_mws_sol = x3c_mws.solutions.at(0) +#reduction-rule("ExactCoverBy3Sets", "MinimumWeightSolutionToLinearEquations", + example: true, + example-caption: [Canonical X3C incidence matrix ($|U| = #x3c_mws.source.instance.universe_size$, $n = #x3c_mws.source.instance.subsets.len()$)], + extra: [ + #pred-commands( + "pred create --example " + problem-spec(x3c_mws.source) + " -o x3c.json", + "pred reduce x3c.json --to " + target-spec(x3c_mws) + " -o bundle.json", + "pred solve bundle.json", + "pred evaluate x3c.json --config " + x3c_mws_sol.source_config.map(str).join(","), + ) + Source: $|U| = #x3c_mws.source.instance.universe_size$, $q = #(int(x3c_mws.source.instance.universe_size / 3))$, and $n = #x3c_mws.source.instance.subsets.len()$ \ + Target: incidence matrix with #x3c_mws.target.instance.coefficients.len() equations, #x3c_mws.target.instance.coefficients.at(0).len() binary variables, and sparsity bound $K = #x3c_mws.target.instance.bound$ \ + Canonical witness: source $(#x3c_mws_sol.source_config.map(str).join(", "))$ maps directly to target $(#x3c_mws_sol.target_config.map(str).join(", "))$ #sym.checkmark + ], +)[ + This $O(|U| n)$ reduction replaces the family of 3-sets by its incidence matrix. Each source set $S_j$ becomes a binary target variable $x_j$, each universe element $i in U$ becomes one equation, the coefficient $A_(i,j)$ is $1$ exactly when $i in S_j$, the right-hand side is the all-ones vector, and the sparsity bound is $K = |U| / 3$. +][ + _Construction._ Let the X3C instance have universe $U = {0, dots, 3 q - 1}$ and sets $S_0, dots, S_(n-1)$. Construct an $|U| times n$ matrix $A$ with entries + $ + A_(i,j) = cases( + 1 "if" i in S_j, + 0 "otherwise", + ). + $ + Let $b in ZZ^|U|$ be the all-ones vector and let $K = q$. The target instance asks whether there exists $x in {0, 1}^n$ with $A x = b$ and $||x||_0 <= K$. + + _Correctness._ ($arrow.r.double$) If $cal(C)' subset.eq {S_0, dots, S_(n-1)}$ is an exact cover, set $x_j = 1$ exactly when $S_j in cal(C)'$. Every element $i in U$ lies in exactly one selected set, so the $i$-th row sum is $1$ and therefore $A x = b$. Because an exact cover of a $3 q$-element universe uses exactly $q$ triples, the vector has $||x||_0 = q <= K$. ($arrow.l.double$) If $x in {0,1}^n$ satisfies $A x = b$ and $||x||_0 <= q$, then every row sum equals $1$, so every universe element lies in exactly one selected set. The selected sets therefore cover exactly $3 ||x||_0$ element-occurrences, but they also cover all $3 q$ universe elements. Hence $3 ||x||_0 = 3 q$, so $||x||_0 = q$, and the selected sets form an exact cover. + + _Solution extraction._ Read the binary vector directly: choose $S_j$ in the source witness iff $x_j = 1$ in the target witness. +] + #reduction-rule("NAESatisfiability", "ILP")[ Each clause must have at least one true and at least one false literal, encoded as a pair of linear inequalities per clause. ][ diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 90a1ab03a..048de5e23 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -224,6 +224,7 @@ Flags by problem type: NonTautology --num-vars, --disjuncts KSAT --num-vars, --clauses [--k] QUBO --matrix + MinimumWeightSolutionToLinearEquations --matrix, --rhs, --bound SpinGlass --graph, --couplings, --fields KColoring --graph, --k KClique --graph, --k @@ -433,6 +434,9 @@ pub struct CreateArgs { /// ConsecutiveBlockMinimization uses a JSON 2D bool array ('[[true,false],[false,true]]') #[arg(long)] pub matrix: Option, + /// Right-hand side vector for linear-equation systems (comma-separated, e.g., "1,0,1") + #[arg(long)] + pub rhs: Option, /// Shared integer parameter (use `pred create ` for the problem-specific meaning) #[arg(long)] pub k: Option, diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 93f86416d..43297e893 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -9,7 +9,8 @@ use anyhow::{bail, Context, Result}; use problemreductions::export::{ModelExample, ProblemRef, ProblemSide, RuleExample}; use problemreductions::models::algebraic::{ ClosestVectorProblem, ConsecutiveBlockMinimization, ConsecutiveOnesMatrixAugmentation, - ConsecutiveOnesSubmatrix, IntegerExpressionMembership, SparseMatrixCompression, BMF, + ConsecutiveOnesSubmatrix, IntegerExpressionMembership, MinimumWeightSolutionToLinearEquations, + SparseMatrixCompression, BMF, }; use problemreductions::models::formula::Quantifier; use problemreductions::models::graph::{ @@ -73,6 +74,7 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool { && args.disjuncts.is_none() && args.num_vars.is_none() && args.matrix.is_none() + && args.rhs.is_none() && args.k.is_none() && args.max_degree.is_none() && args.target.is_none() @@ -518,6 +520,7 @@ fn type_format_hint(type_name: &str, graph_type: Option<&str>) -> &'static str { "Vec>" => "semicolon-separated rows: \"1,2;1,6;1,7;1,9\"", "Vec" => "semicolon-separated clauses: \"1,2;-1,3\"", "Vec>" => "semicolon-separated terms: \"1,2;-1,3\"", + "Vec>" => "semicolon-separated rows: \"1,0;0,1\"", "Vec>" => "JSON 2D bool array: '[[true,false],[false,true]]'", "Vec>" => "semicolon-separated rows: \"1,0.5;0.5,2\"", "usize" => "integer", @@ -607,6 +610,9 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "KSatisfiability" => "--num-vars 3 --clauses \"1,2,3;-1,2,-3\" --k 3", "QUBO" => "--matrix \"1,0.5;0.5,2\"", "IntegerExpressionMembership" => "--choices \"1,2;1,6;1,7;1,9\" --target 15", + "MinimumWeightSolutionToLinearEquations" => { + "--matrix \"1,0,1;0,1,1\" --rhs \"1,1\" --bound 1" + } "QuadraticAssignment" => "--matrix \"0,5;5,0\" --distance-matrix \"0,1;1,0\"", "SpinGlass" => "--graph 0-1,1-2 --couplings 1,1", "KColoring" => "--graph 0-1,1-2,2-0 --k 3", @@ -847,6 +853,10 @@ fn help_flag_hint( "semicolon-separated 0/1 rows: \"1,0;0,1\"" } ("ConsecutiveOnesSubmatrix", "matrix") => "semicolon-separated 0/1 rows: \"1,0;0,1\"", + ("MinimumWeightSolutionToLinearEquations", "matrix") => { + "semicolon-separated integer rows: \"1,0,1;0,1,1\"" + } + ("MinimumWeightSolutionToLinearEquations", "rhs") => "comma-separated integers: 1,1", ("SparseMatrixCompression", "matrix") => "semicolon-separated 0/1 rows: \"1,0;0,1\"", ("TimetableDesign", "craftsman_avail") | ("TimetableDesign", "task_avail") => { "semicolon-separated 0/1 rows: \"1,1,0;0,1,1\"" @@ -2208,6 +2218,40 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } + // MinimumWeightSolutionToLinearEquations + "MinimumWeightSolutionToLinearEquations" => { + let usage = "Usage: pred create MinimumWeightSolutionToLinearEquations --matrix \"1,0,1;0,1,1\" --rhs \"1,1\" --bound 1"; + let matrix_str = args.matrix.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "MinimumWeightSolutionToLinearEquations requires --matrix, --rhs, and --bound\n\n{usage}" + ) + })?; + let rhs_str = args.rhs.as_deref().ok_or_else(|| { + anyhow::anyhow!("MinimumWeightSolutionToLinearEquations requires --rhs\n\n{usage}") + })?; + let bound_raw = args.bound.ok_or_else(|| { + anyhow::anyhow!( + "MinimumWeightSolutionToLinearEquations requires --bound\n\n{usage}" + ) + })?; + let coefficients = + parse_i64_matrix(matrix_str).context("Invalid coefficient matrix")?; + let rhs: Vec = util::parse_comma_list(rhs_str)?; + let bound = parse_nonnegative_usize_bound( + bound_raw, + "MinimumWeightSolutionToLinearEquations", + usage, + )?; + ( + ser(MinimumWeightSolutionToLinearEquations::new( + coefficients, + rhs, + bound, + ))?, + resolved_variant.clone(), + ) + } + // SpinGlass "SpinGlass" => { let (graph, n) = parse_graph(args).map_err(|e| { @@ -7468,6 +7512,7 @@ mod tests { disjuncts: None, num_vars: None, matrix: None, + rhs: None, k: None, max_degree: None, random: false, @@ -8438,6 +8483,68 @@ mod tests { assert!(err.contains("bound >= 1")); } + #[test] + fn test_create_minimum_weight_solution_to_linear_equations_json() { + use crate::dispatch::ProblemJsonOutput; + + let mut args = empty_args(); + args.problem = Some("MinimumWeightSolutionToLinearEquations".to_string()); + args.matrix = Some("1,0,1;0,1,1".to_string()); + args.rhs = Some("1,1".to_string()); + args.bound = Some(1); + + let output_path = + std::env::temp_dir().join(format!("mwsle-create-{}.json", std::process::id())); + let out = OutputConfig { + output: Some(output_path.clone()), + quiet: true, + json: false, + auto_json: false, + }; + + create(&args, &out).unwrap(); + + let json = std::fs::read_to_string(&output_path).unwrap(); + let created: ProblemJsonOutput = serde_json::from_str(&json).unwrap(); + assert_eq!( + created.problem_type, + "MinimumWeightSolutionToLinearEquations" + ); + assert!(created.variant.is_empty()); + assert_eq!( + created.data, + serde_json::json!({ + "coefficients": [ + [1, 0, 1], + [0, 1, 1], + ], + "rhs": [1, 1], + "bound": 1, + }) + ); + + let _ = std::fs::remove_file(output_path); + } + + #[test] + fn test_create_minimum_weight_solution_to_linear_equations_requires_rhs() { + let mut args = empty_args(); + args.problem = Some("MinimumWeightSolutionToLinearEquations".to_string()); + args.matrix = Some("1,0,1;0,1,1".to_string()); + args.bound = Some(1); + + let out = OutputConfig { + output: None, + quiet: true, + json: false, + auto_json: false, + }; + + let err = create(&args, &out).unwrap_err().to_string(); + assert!(err.contains("MinimumWeightSolutionToLinearEquations requires --rhs")); + assert!(err.contains("Usage: pred create MinimumWeightSolutionToLinearEquations")); + } + #[test] fn test_create_consecutive_ones_matrix_augmentation_json() { use crate::dispatch::ProblemJsonOutput; diff --git a/src/lib.rs b/src/lib.rs index 502ef7e49..94c98369b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -43,8 +43,9 @@ pub mod variant; pub mod prelude { // Problem types pub use crate::models::algebraic::{ - ConsecutiveOnesMatrixAugmentation, IntegerExpressionMembership, QuadraticAssignment, - SparseMatrixCompression, BMF, QUBO, + ConsecutiveOnesMatrixAugmentation, IntegerExpressionMembership, + MinimumWeightSolutionToLinearEquations, QuadraticAssignment, SparseMatrixCompression, BMF, + QUBO, }; pub use crate::models::formula::{ CNFClause, CircuitSAT, KSatisfiability, NAESatisfiability, NonTautology, diff --git a/src/models/algebraic/minimum_weight_solution_to_linear_equations.rs b/src/models/algebraic/minimum_weight_solution_to_linear_equations.rs new file mode 100644 index 000000000..9bb511995 --- /dev/null +++ b/src/models/algebraic/minimum_weight_solution_to_linear_equations.rs @@ -0,0 +1,145 @@ +//! Minimum-Weight Solution to Linear Equations. +//! +//! Given an integer matrix `A`, right-hand side `b`, and sparsity bound `K`, +//! determine whether the binary linear system `Ax = b` has a solution with at +//! most `K` nonzero entries. + +use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry}; +use crate::traits::Problem; +use crate::types::Or; +use serde::{Deserialize, Serialize}; + +inventory::submit! { + ProblemSchemaEntry { + name: "MinimumWeightSolutionToLinearEquations", + display_name: "Minimum Weight Solution to Linear Equations", + aliases: &[], + dimensions: &[], + module_path: module_path!(), + description: "Determine whether a binary linear system has a solution with at most K nonzero entries", + fields: &[ + FieldInfo { name: "coefficients", type_name: "Vec>", description: "Coefficient matrix A, stored row-by-row" }, + FieldInfo { name: "rhs", type_name: "Vec", description: "Right-hand side vector b" }, + FieldInfo { name: "bound", type_name: "usize", description: "Maximum number of nonzero variables allowed in the solution" }, + ], + } +} + +inventory::submit! { + ProblemSizeFieldEntry { + name: "MinimumWeightSolutionToLinearEquations", + fields: &["num_variables", "num_equations"], + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MinimumWeightSolutionToLinearEquations { + coefficients: Vec>, + rhs: Vec, + bound: usize, +} + +impl MinimumWeightSolutionToLinearEquations { + pub fn new(coefficients: Vec>, rhs: Vec, bound: usize) -> Self { + assert_eq!( + coefficients.len(), + rhs.len(), + "rhs length must match number of equations" + ); + + if let Some(expected_width) = coefficients.first().map(Vec::len) { + assert!( + coefficients.iter().all(|row| row.len() == expected_width), + "coefficient matrix must be rectangular" + ); + } + + Self { + coefficients, + rhs, + bound, + } + } + + pub fn coefficients(&self) -> &[Vec] { + &self.coefficients + } + + pub fn rhs(&self) -> &[i64] { + &self.rhs + } + + pub fn bound(&self) -> usize { + self.bound + } + + pub fn num_variables(&self) -> usize { + self.coefficients.first().map_or(0, Vec::len) + } + + pub fn num_equations(&self) -> usize { + self.coefficients.len() + } +} + +impl Problem for MinimumWeightSolutionToLinearEquations { + const NAME: &'static str = "MinimumWeightSolutionToLinearEquations"; + type Value = Or; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![] + } + + fn dims(&self) -> Vec { + vec![2; self.num_variables()] + } + + fn evaluate(&self, config: &[usize]) -> Or { + if config.len() != self.num_variables() || config.iter().any(|&value| value > 1) { + return Or(false); + } + + if config.iter().filter(|&&value| value == 1).count() > self.bound { + return Or(false); + } + + let satisfies_equations = self.coefficients.iter().zip(&self.rhs).all(|(row, &rhs)| { + row.iter() + .zip(config) + .map(|(&coefficient, &value)| coefficient * value as i64) + .sum::() + == rhs + }); + + Or(satisfies_equations) + } +} + +crate::declare_variants! { + default MinimumWeightSolutionToLinearEquations => "2^num_variables", +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "minimum_weight_solution_to_linear_equations", + instance: Box::new(MinimumWeightSolutionToLinearEquations::new( + vec![ + vec![1, 0, 1], + vec![1, 0, 0], + vec![1, 0, 0], + vec![0, 1, 1], + vec![0, 1, 1], + vec![0, 1, 0], + ], + vec![1; 6], + 2, + )), + optimal_config: vec![1, 1, 0], + optimal_value: serde_json::json!(true), + }] +} + +#[cfg(test)] +#[path = "../../unit_tests/models/algebraic/minimum_weight_solution_to_linear_equations.rs"] +mod tests; diff --git a/src/models/algebraic/mod.rs b/src/models/algebraic/mod.rs index 6eb00178a..f730d3281 100644 --- a/src/models/algebraic/mod.rs +++ b/src/models/algebraic/mod.rs @@ -8,6 +8,7 @@ //! - [`BMF`]: Boolean Matrix Factorization //! - [`ConsecutiveBlockMinimization`]: Consecutive Block Minimization //! - [`ConsecutiveOnesSubmatrix`]: Consecutive Ones Submatrix (column selection with C1P) +//! - [`MinimumWeightSolutionToLinearEquations`]: Sparse binary solution to a linear system //! - [`QuadraticAssignment`]: Quadratic Assignment Problem //! - [`SparseMatrixCompression`]: Sparse Matrix Compression by row overlay @@ -18,6 +19,7 @@ pub(crate) mod consecutive_ones_matrix_augmentation; pub(crate) mod consecutive_ones_submatrix; pub(crate) mod ilp; pub(crate) mod integer_expression_membership; +pub(crate) mod minimum_weight_solution_to_linear_equations; pub(crate) mod quadratic_assignment; pub(crate) mod qubo; pub(crate) mod sparse_matrix_compression; @@ -29,6 +31,7 @@ pub use consecutive_ones_matrix_augmentation::ConsecutiveOnesMatrixAugmentation; pub use consecutive_ones_submatrix::ConsecutiveOnesSubmatrix; pub use ilp::{Comparison, LinearConstraint, ObjectiveSense, VariableDomain, ILP}; pub use integer_expression_membership::IntegerExpressionMembership; +pub use minimum_weight_solution_to_linear_equations::MinimumWeightSolutionToLinearEquations; pub use quadratic_assignment::QuadraticAssignment; pub use qubo::QUBO; pub use sparse_matrix_compression::SparseMatrixCompression; @@ -40,6 +43,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec &Self::Target { + &self.target + } + + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + target_solution.to_vec() + } +} + +#[reduction(overhead = { + num_variables = "num_sets", + num_equations = "universe_size", +})] +impl ReduceTo for ExactCoverBy3Sets { + type Result = ReductionX3CToMinimumWeightSolutionToLinearEquations; + + fn reduce_to(&self) -> Self::Result { + let mut coefficients = vec![vec![0i64; self.num_sets()]; self.universe_size()]; + for (set_index, set) in self.sets().iter().enumerate() { + for &element in set { + coefficients[element][set_index] = 1; + } + } + + ReductionX3CToMinimumWeightSolutionToLinearEquations { + target: MinimumWeightSolutionToLinearEquations::new( + coefficients, + vec![1; self.universe_size()], + self.universe_size() / 3, + ), + } + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + use crate::export::SolutionPair; + + vec![crate::example_db::specs::RuleExampleSpec { + id: "exactcoverby3sets_to_minimumweightsolutiontolinearequations", + build: || { + crate::example_db::specs::rule_example_with_witness::< + _, + MinimumWeightSolutionToLinearEquations, + >( + ExactCoverBy3Sets::new(6, vec![[0, 1, 2], [3, 4, 5], [0, 3, 4]]), + SolutionPair { + source_config: vec![1, 1, 0], + target_config: vec![1, 1, 0], + }, + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/exactcoverby3sets_minimumweightsolutiontolinearequations.rs"] +mod tests; diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 95b7a510f..ea754d15c 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -9,6 +9,7 @@ pub use registry::{EdgeCapabilities, ReductionEntry, ReductionOverhead}; pub(crate) mod circuit_spinglass; mod closestvectorproblem_qubo; pub(crate) mod coloring_qubo; +pub(crate) mod exactcoverby3sets_minimumweightsolutiontolinearequations; pub(crate) mod exactcoverby3sets_subsetproduct; pub(crate) mod factoring_circuit; mod graph; @@ -246,6 +247,9 @@ pub(crate) fn canonical_rule_example_specs() -> Vec::reduce_to(&source); + + assert_satisfaction_round_trip_from_satisfaction_target( + &source, + &reduction, + "ExactCoverBy3Sets -> MinimumWeightSolutionToLinearEquations closed loop", + ); +} + +#[test] +fn test_exactcoverby3sets_to_minimumweightsolutiontolinearequations_structure() { + let source = ExactCoverBy3Sets::new(6, vec![[0, 1, 2], [3, 4, 5], [0, 3, 4]]); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + assert_eq!( + target.coefficients(), + &[ + vec![1, 0, 1], + vec![1, 0, 0], + vec![1, 0, 0], + vec![0, 1, 1], + vec![0, 1, 1], + vec![0, 1, 0], + ] + ); + assert_eq!(target.rhs(), &[1, 1, 1, 1, 1, 1]); + assert_eq!(target.bound(), 2); + assert_eq!(target.num_variables(), 3); + assert_eq!(target.num_equations(), 6); +} + +#[test] +fn test_exactcoverby3sets_to_minimumweightsolutiontolinearequations_extract_solution_is_identity() { + let source = ExactCoverBy3Sets::new(6, vec![[0, 1, 2], [3, 4, 5], [0, 3, 4]]); + let reduction = ReduceTo::::reduce_to(&source); + + assert_eq!(reduction.extract_solution(&[1, 0, 1]), vec![1, 0, 1]); +} From 0cc816db3dd336a7ed2523bed21e3aaee7bd648f Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 4 Apr 2026 06:10:34 +0800 Subject: [PATCH 10/25] =?UTF-8?q?feat:=20add=20SimultaneousIncongruences?= =?UTF-8?q?=20model=20and=203SAT=20=E2=86=92=20SI=20rule=20(#554)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds SimultaneousIncongruences algebraic model (Value=Or, find x in [1..N] satisfying all x mod m_i != r_i) and the CRT-based reduction from KSatisfiability. Each variable gets a distinct odd prime, residues 1/2 encode true/false, other residues are forbidden, and each clause adds one CRT-derived forbidden residue. Includes CLI support, paper entries, and tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/paper/reductions.typ | 61 +++++- problemreductions-cli/src/cli.rs | 46 ++++ problemreductions-cli/src/commands/create.rs | 128 +++++++++++- problemreductions-cli/src/problem_name.rs | 3 + src/lib.rs | 4 +- src/models/algebraic/mod.rs | 4 + .../algebraic/simultaneous_incongruences.rs | 136 ++++++++++++ src/models/formula/ksat.rs | 53 +++++ src/models/mod.rs | 2 +- ...atisfiability_simultaneousincongruences.rs | 196 ++++++++++++++++++ src/rules/mod.rs | 2 + .../algebraic/simultaneous_incongruences.rs | 49 +++++ ...atisfiability_simultaneousincongruences.rs | 87 ++++++++ tests/main.rs | 4 + ...tisfiability_simultaneous_incongruences.rs | 31 +++ tests/suites/simultaneous_incongruences.rs | 26 +++ 16 files changed, 827 insertions(+), 5 deletions(-) create mode 100644 src/models/algebraic/simultaneous_incongruences.rs create mode 100644 src/rules/ksatisfiability_simultaneousincongruences.rs create mode 100644 src/unit_tests/models/algebraic/simultaneous_incongruences.rs create mode 100644 src/unit_tests/rules/ksatisfiability_simultaneousincongruences.rs create mode 100644 tests/suites/ksatisfiability_simultaneous_incongruences.rs create mode 100644 tests/suites/simultaneous_incongruences.rs diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index a10fe4b88..27cc5972a 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -118,6 +118,7 @@ "Satisfiability": [SAT], "NAESatisfiability": [NAE-SAT], "KSatisfiability": [$k$-SAT], + "SimultaneousIncongruences": [Simultaneous Incongruences], "CircuitSAT": [CircuitSAT], "ConjunctiveQueryFoldability": [Conjunctive Query Foldability], "EnsembleComputation": [Ensemble Computation], @@ -3531,6 +3532,31 @@ A classical NP-complete problem from Garey and Johnson @garey1979[Ch.~3, p.~76], ] } +#{ + let x = load-model-example("SimultaneousIncongruences") + let moduli = x.instance.moduli + let residues = x.instance.residues + let N = x.instance.bound + let witness = x.optimal_config.at(0) + 1 + let k = moduli.len() + let fmt-pair(i) = $x mod #moduli.at(i) != #residues.at(i)$ + [ + #problem-def("SimultaneousIncongruences")[ + Given positive integers $m_1, dots, m_k$, residues $r_i in {0, dots, m_i - 1}$, and a bound $N in ZZ_(>= 0)$, determine whether there exists an integer $x$ with $1 <= x <= N$ such that $x mod m_i != r_i$ for every $i in {1, dots, k}$. + ][ + Simultaneous Incongruences is the covering-system decision problem AN2 in Garey and Johnson @garey1979. Stockmeyer and Meyer showed that deciding whether a bounded interval contains an integer outside a finite union of arithmetic progressions is NP-complete @stockmeyer1973. The direct exact algorithm used by this crate simply scans the range $x in {1, dots, N}$ and checks all $k$ forbidden residue classes, giving $O(N k)$ time#footnote[No better exact worst-case bound is claimed here for the general problem representation used in the codebase.]. + + *Example.* Let $N = #N$ and let the forbidden classes be #range(k).map(i => fmt-pair(i)).join(", "). The stored witness is $x = #witness$; indeed #range(k).map(i => $#witness mod #moduli.at(i) = #calc.rem(witness, moduli.at(i)) != #residues.at(i)$).join(", "). Hence the instance is YES. + + #pred-commands( + "pred create --example " + problem-spec(x) + " -o simultaneous-incongruences.json", + "pred solve simultaneous-incongruences.json", + "pred evaluate simultaneous-incongruences.json --config " + x.optimal_config.map(str).join(","), + ) + ] + ] +} + == Satisfiability Problems #{ @@ -7051,6 +7077,39 @@ where $P$ is a penalty weight large enough that any constraint violation costs m _Solution extraction._ For each $i$: if $y_i$ is selected ($x_(2i) = 1$), set $x_i = 1$; if $z_i$ is selected ($x_(2i+1) = 1$), set $x_i = 0$. ] +#let ksat_si = load-example("KSatisfiability", "SimultaneousIncongruences") +#let ksat_si_sol = ksat_si.solutions.at(0) +#reduction-rule("KSatisfiability", "SimultaneousIncongruences", + example: true, + example-caption: [2-variable 3-CNF encoded with primes 3 and 5], + extra: [ + #pred-commands( + "pred create --example " + problem-spec(ksat_si.source) + " -o ksat.json", + "pred reduce ksat.json --to " + target-spec(ksat_si) + " -o bundle.json", + "pred solve bundle.json", + "pred evaluate ksat.json --config " + ksat_si_sol.source_config.map(str).join(","), + ) + Source: $phi = (x_1 or x_2 or x_2) and (overline(x_1) or x_2 or x_2)$ with source config #ksat_si_sol.source_config \ + Target: forbid #range(ksat_si.target.instance.moduli.len()).map(i => $x mod #ksat_si.target.instance.moduli.at(i) != #ksat_si.target.instance.residues.at(i)$).join(", "), bound $N = #ksat_si.target.instance.bound$ \ + Target config: #ksat_si_sol.target_config (so $x = #( + ksat_si_sol.target_config.at(0) + 1 + )$) + ], +)[ + This polynomial-time CRT encoding, attributed in Garey and Johnson to Stockmeyer and Meyer @stockmeyer1973, assigns a distinct odd prime $p_i$ to each Boolean variable $x_i$. Residues $1$ and $2$ represent $x_i = top$ and $x_i = bot$ respectively; every other residue modulo $p_i$ is forbidden. Each clause contributes one additional forbidden residue class modulo the product of its participating primes, namely the unique class that makes every literal in the clause false. The bound is the product of all variable primes, so the Chinese Remainder Theorem guarantees that every Boolean assignment corresponds to some $x in {1, dots, N}$. +][ + _Construction._ Let $phi = and.big_(j=1)^m C_j$ be a 3-CNF formula over variables $x_1, dots, x_n$. Choose distinct odd primes $p_1, dots, p_n$ (the implementation uses the first $n$ odd primes: $3, 5, 7, dots$). For each variable $x_i$: + - interpret $x mod p_i = 1$ as $x_i = top$, + - interpret $x mod p_i = 2$ as $x_i = bot$, + - add incongruences $x mod p_i != r$ for every $r in {0, 3, dots, p_i - 1}$. + + For each clause $C_j = (ell_(j 1) or ell_(j 2) or ell_(j 3))$, compute the residue requirement that falsifies each literal: a positive literal $x_i$ is false exactly when $x mod p_i = 2$, while a negative literal $overline(x_i)$ is false exactly when $x mod p_i = 1$. By the Chinese Remainder Theorem there is a unique residue $b_j mod M_j$, where $M_j$ is the product of the clause's participating primes, that simultaneously realizes those false-literal residues. Add the clause incongruence $x mod M_j != b_j$. Finally set $N = product_(i=1)^n p_i$. + + _Correctness._ ($arrow.r.double$) Given a satisfying assignment $alpha$, let $x$ be the CRT solution satisfying $x mod p_i = 1$ when $alpha(x_i) = top$ and $x mod p_i = 2$ when $alpha(x_i) = bot$. Then $x$ avoids every variable incongruence by construction. Since each clause has at least one true literal under $alpha$, $x$ cannot realize the all-false residue pattern for that clause, so it also avoids every clause incongruence. Thus $x$ is feasible for the Simultaneous Incongruences instance. ($arrow.l.double$) If some $x in {1, dots, N}$ avoids all incongruences, the variable constraints force $x mod p_i in {1,2}$ for every $i$, defining a Boolean assignment. If a clause were false under that assignment, then $x$ would match its unique forbidden CRT residue class modulo $M_j$, contradicting feasibility. Hence every clause is satisfied. + + _Solution extraction._ Read back the Boolean assignment from the variable primes: set $x_i = top$ when $x mod p_i = 1$ and $x_i = bot$ when $x mod p_i = 2$. +] + #{ let ss-cvp = load-example("SubsetSum", "ClosestVectorProblem") let ss-cvp-sol = ss-cvp.solutions.at(0) @@ -7142,7 +7201,7 @@ where $P$ is a penalty weight large enough that any constraint violation costs m $ K = B + n. $ _Correctness._ ($arrow.r.double$) If $X subset.eq {0, dots, n-1}$ is a satisfying Subset Sum witness with $sum_(i in X) s_i = B$, choose $s_i + 1$ from $C_i$ when $i in X$ and choose $1$ otherwise. The resulting total is - $ sum_(i in X) (s_i + 1) + sum_(i notin X) 1 = sum_(i in X) s_i + n = B + n = K, $ + $ sum_(i in X) (s_i + 1) + sum_(i in.not X) 1 = sum_(i in X) s_i + n = B + n = K, $ so the Integer Expression Membership instance is YES. ($arrow.l.double$) Conversely, suppose a target witness chooses one value from each $C_i$ and sums to $K = B + n$. Subtract the baseline value $1$ from every chosen term. Each position then contributes either $0$ or $s_i$, and the total residual is diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 048de5e23..79c3ff448 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -223,6 +223,7 @@ Flags by problem type: SAT, NAESAT --num-vars, --clauses NonTautology --num-vars, --disjuncts KSAT --num-vars, --clauses [--k] + SimultaneousIncongruences --moduli, --residues, --bound QUBO --matrix MinimumWeightSolutionToLinearEquations --matrix, --rhs, --bound SpinGlass --graph, --couplings, --fields @@ -430,6 +431,12 @@ pub struct CreateArgs { /// Number of variables (for SAT/KSAT) #[arg(long)] pub num_vars: Option, + /// Moduli for SimultaneousIncongruences (comma-separated, e.g., "2,3,5,7") + #[arg(long)] + pub moduli: Option, + /// Forbidden residues for SimultaneousIncongruences (comma-separated, e.g., "0,1,2,3") + #[arg(long)] + pub residues: Option, /// Matrix input. QUBO uses semicolon-separated numeric rows ("1,0.5;0.5,2"); /// ConsecutiveBlockMinimization uses a JSON 2D bool array ('[[true,false],[false,true]]') #[arg(long)] @@ -952,6 +959,45 @@ mod tests { assert!(help.contains("--budget")); } + #[test] + fn test_create_parses_simultaneous_incongruences_flags() { + let cli = Cli::parse_from([ + "pred", + "create", + "SimultaneousIncongruences", + "--moduli", + "2,3,5,7", + "--residues", + "0,1,2,3", + "--bound", + "210", + ]); + + let Commands::Create(args) = cli.command else { + panic!("expected create command"); + }; + + assert_eq!(args.problem.as_deref(), Some("SimultaneousIncongruences")); + assert_eq!(args.moduli.as_deref(), Some("2,3,5,7")); + assert_eq!(args.residues.as_deref(), Some("0,1,2,3")); + assert_eq!(args.bound, Some(210)); + } + + #[test] + fn test_create_help_mentions_simultaneous_incongruences_flags() { + let cmd = Cli::command(); + let create = cmd.find_subcommand("create").expect("create subcommand"); + let help = create + .get_after_help() + .expect("create after_help") + .to_string(); + + assert!(help.contains("SimultaneousIncongruences")); + assert!(help.contains("--moduli")); + assert!(help.contains("--residues")); + assert!(help.contains("--bound")); + } + #[test] fn test_create_parses_partial_feedback_edge_set_flags() { let cli = Cli::parse_from([ diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 43297e893..71704a5e1 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -10,7 +10,7 @@ use problemreductions::export::{ModelExample, ProblemRef, ProblemSide, RuleExamp use problemreductions::models::algebraic::{ ClosestVectorProblem, ConsecutiveBlockMinimization, ConsecutiveOnesMatrixAugmentation, ConsecutiveOnesSubmatrix, IntegerExpressionMembership, MinimumWeightSolutionToLinearEquations, - SparseMatrixCompression, BMF, + SimultaneousIncongruences, SparseMatrixCompression, BMF, }; use problemreductions::models::formula::Quantifier; use problemreductions::models::graph::{ @@ -73,6 +73,8 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool { && args.clauses.is_none() && args.disjuncts.is_none() && args.num_vars.is_none() + && args.moduli.is_none() + && args.residues.is_none() && args.matrix.is_none() && args.rhs.is_none() && args.k.is_none() @@ -608,6 +610,7 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "--num-vars 3 --clauses \"1,2;-1,3\" --quantifiers \"E,A,E\"" } "KSatisfiability" => "--num-vars 3 --clauses \"1,2,3;-1,2,-3\" --k 3", + "SimultaneousIncongruences" => "--moduli 2,3,5,7 --residues 0,1,2,3 --bound 210", "QUBO" => "--matrix \"1,0.5;0.5,2\"", "IntegerExpressionMembership" => "--choices \"1,2;1,6;1,7;1,9\" --target 15", "MinimumWeightSolutionToLinearEquations" => { @@ -853,6 +856,8 @@ fn help_flag_hint( "semicolon-separated 0/1 rows: \"1,0;0,1\"" } ("ConsecutiveOnesSubmatrix", "matrix") => "semicolon-separated 0/1 rows: \"1,0;0,1\"", + ("SimultaneousIncongruences", "moduli") => "comma-separated integers: 2,3,5,7", + ("SimultaneousIncongruences", "residues") => "comma-separated integers: 0,1,2,3", ("MinimumWeightSolutionToLinearEquations", "matrix") => { "semicolon-separated integer rows: \"1,0,1;0,1,1\"" } @@ -871,6 +876,11 @@ fn parse_nonnegative_usize_bound(bound: i64, problem_name: &str, usage: &str) -> .map_err(|_| anyhow::anyhow!("{problem_name} requires nonnegative --bound\n\n{usage}")) } +fn parse_nonnegative_u64_bound(bound: i64, problem_name: &str, usage: &str) -> Result { + u64::try_from(bound) + .map_err(|_| anyhow::anyhow!("{problem_name} requires nonnegative --bound\n\n{usage}")) +} + fn resolve_processor_count_flags( problem_name: &str, usage: &str, @@ -2193,6 +2203,43 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { (ser(QUBO::from_matrix(matrix))?, resolved_variant.clone()) } + // SimultaneousIncongruences + "SimultaneousIncongruences" => { + let usage = "Usage: pred create SimultaneousIncongruences --moduli 2,3,5,7 --residues 0,1,2,3 --bound 210"; + let moduli_str = args.moduli.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "SimultaneousIncongruences requires --moduli, --residues, and --bound\n\n{usage}" + ) + })?; + let residues_str = args.residues.as_deref().ok_or_else(|| { + anyhow::anyhow!("SimultaneousIncongruences requires --residues\n\n{usage}") + })?; + let bound_raw = args.bound.ok_or_else(|| { + anyhow::anyhow!("SimultaneousIncongruences requires --bound\n\n{usage}") + })?; + let moduli: Vec = util::parse_comma_list(moduli_str)?; + let residues: Vec = util::parse_comma_list(residues_str)?; + anyhow::ensure!( + moduli.len() == residues.len(), + "SimultaneousIncongruences requires the same number of moduli and residues\n\n{usage}" + ); + for (index, (&modulus, &residue)) in moduli.iter().zip(&residues).enumerate() { + anyhow::ensure!( + modulus > 0, + "SimultaneousIncongruences modulus at index {index} must be positive\n\n{usage}" + ); + anyhow::ensure!( + residue < modulus, + "SimultaneousIncongruences residue at index {index} must satisfy residue < modulus\n\n{usage}" + ); + } + let bound = parse_nonnegative_u64_bound(bound_raw, "SimultaneousIncongruences", usage)?; + ( + ser(SimultaneousIncongruences::new(moduli, residues, bound))?, + resolved_variant.clone(), + ) + } + // IntegerExpressionMembership "IntegerExpressionMembership" => { let choices_str = args.choices.as_deref().ok_or_else(|| { @@ -6847,6 +6894,69 @@ mod tests { std::fs::remove_file(output_path).unwrap(); } + #[test] + fn test_create_simultaneous_incongruences_outputs_problem_json() { + let cli = Cli::try_parse_from([ + "pred", + "create", + "SimultaneousIncongruences", + "--moduli", + "2,3,5,7", + "--residues", + "0,1,2,3", + "--bound", + "210", + ]) + .unwrap(); + + let args = match cli.command { + Commands::Create(args) => args, + _ => panic!("expected create command"), + }; + + let suffix = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let output_path = + std::env::temp_dir().join(format!("simultaneous-incongruences-create-{suffix}.json")); + let out = OutputConfig { + output: Some(output_path.clone()), + quiet: true, + json: false, + auto_json: false, + }; + + create(&args, &out).unwrap(); + + let json: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&output_path).unwrap()).unwrap(); + assert_eq!(json["type"], "SimultaneousIncongruences"); + assert_eq!(json["data"]["moduli"], serde_json::json!([2, 3, 5, 7])); + assert_eq!(json["data"]["residues"], serde_json::json!([0, 1, 2, 3])); + assert_eq!(json["data"]["bound"], serde_json::json!(210)); + std::fs::remove_file(output_path).unwrap(); + } + + #[test] + fn test_create_simultaneous_incongruences_requires_residues() { + let mut args = empty_args(); + args.problem = Some("SimultaneousIncongruences".to_string()); + args.moduli = Some("2,3,5,7".to_string()); + args.bound = Some(210); + + let out = OutputConfig { + output: None, + quiet: true, + json: false, + auto_json: false, + }; + + let err = create(&args, &out).unwrap_err().to_string(); + assert!(err.contains("SimultaneousIncongruences requires --residues")); + assert!(err.contains("Usage: pred create SimultaneousIncongruences")); + } + #[test] fn test_create_path_constrained_network_flow_outputs_problem_json() { let cli = Cli::try_parse_from([ @@ -7511,6 +7621,8 @@ mod tests { clauses: None, disjuncts: None, num_vars: None, + moduli: None, + residues: None, matrix: None, rhs: None, k: None, @@ -7648,6 +7760,20 @@ mod tests { assert!(!all_data_flags_empty(&args)); } + #[test] + fn test_all_data_flags_empty_treats_moduli_as_input() { + let mut args = empty_args(); + args.moduli = Some("2,3,5,7".to_string()); + assert!(!all_data_flags_empty(&args)); + } + + #[test] + fn test_all_data_flags_empty_treats_residues_as_input() { + let mut args = empty_args(); + args.residues = Some("0,1,2,3".to_string()); + assert!(!all_data_flags_empty(&args)); + } + #[test] fn test_all_data_flags_empty_treats_homologous_pairs_as_input() { let mut args = empty_args(); diff --git a/problemreductions-cli/src/problem_name.rs b/problemreductions-cli/src/problem_name.rs index 94817e754..a3c3721b2 100644 --- a/problemreductions-cli/src/problem_name.rs +++ b/problemreductions-cli/src/problem_name.rs @@ -20,6 +20,9 @@ pub fn resolve_alias(input: &str) -> String { if input.eq_ignore_ascii_case("GroupingBySwapping") { return "GroupingBySwapping".to_string(); } + if input.eq_ignore_ascii_case("SimultaneousIncongruences") { + return "SimultaneousIncongruences".to_string(); + } if let Some(pt) = problemreductions::registry::find_problem_type_by_alias(input) { return pt.canonical_name.to_string(); } diff --git a/src/lib.rs b/src/lib.rs index 94c98369b..9f94477d4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -44,8 +44,8 @@ pub mod prelude { // Problem types pub use crate::models::algebraic::{ ConsecutiveOnesMatrixAugmentation, IntegerExpressionMembership, - MinimumWeightSolutionToLinearEquations, QuadraticAssignment, SparseMatrixCompression, BMF, - QUBO, + MinimumWeightSolutionToLinearEquations, QuadraticAssignment, SimultaneousIncongruences, + SparseMatrixCompression, BMF, QUBO, }; pub use crate::models::formula::{ CNFClause, CircuitSAT, KSatisfiability, NAESatisfiability, NonTautology, diff --git a/src/models/algebraic/mod.rs b/src/models/algebraic/mod.rs index f730d3281..01379b193 100644 --- a/src/models/algebraic/mod.rs +++ b/src/models/algebraic/mod.rs @@ -11,6 +11,7 @@ //! - [`MinimumWeightSolutionToLinearEquations`]: Sparse binary solution to a linear system //! - [`QuadraticAssignment`]: Quadratic Assignment Problem //! - [`SparseMatrixCompression`]: Sparse Matrix Compression by row overlay +//! - [`SimultaneousIncongruences`]: Find an integer avoiding a family of residue classes pub(crate) mod bmf; pub(crate) mod closest_vector_problem; @@ -22,6 +23,7 @@ pub(crate) mod integer_expression_membership; pub(crate) mod minimum_weight_solution_to_linear_equations; pub(crate) mod quadratic_assignment; pub(crate) mod qubo; +pub(crate) mod simultaneous_incongruences; pub(crate) mod sparse_matrix_compression; pub use bmf::BMF; @@ -34,6 +36,7 @@ pub use integer_expression_membership::IntegerExpressionMembership; pub use minimum_weight_solution_to_linear_equations::MinimumWeightSolutionToLinearEquations; pub use quadratic_assignment::QuadraticAssignment; pub use qubo::QUBO; +pub use simultaneous_incongruences::SimultaneousIncongruences; pub use sparse_matrix_compression::SparseMatrixCompression; #[cfg(feature = "example-db")] @@ -49,6 +52,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec", description: "The moduli m_i defining each incongruence x != r_i (mod m_i)" }, + FieldInfo { name: "residues", type_name: "Vec", description: "The forbidden residues r_i, stored in canonical form 0 <= r_i < m_i" }, + FieldInfo { name: "bound", type_name: "u64", description: "Upper bound N on the searched integer x, with 1 <= x <= N" }, + ], + } +} + +inventory::submit! { + ProblemSizeFieldEntry { + name: "SimultaneousIncongruences", + fields: &["bound", "num_incongruences"], + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SimultaneousIncongruences { + moduli: Vec, + residues: Vec, + bound: u64, +} + +impl SimultaneousIncongruences { + pub fn new(moduli: Vec, residues: Vec, bound: u64) -> Self { + assert_eq!( + moduli.len(), + residues.len(), + "moduli and residues must have the same length" + ); + assert!( + usize::try_from(bound).is_ok(), + "bound must fit in usize for brute-force enumeration" + ); + for (index, (&modulus, &residue)) in moduli.iter().zip(&residues).enumerate() { + assert!(modulus > 0, "modulus at index {index} must be positive"); + assert!( + residue < modulus, + "residue at index {index} must satisfy residue < modulus" + ); + } + + Self { + moduli, + residues, + bound, + } + } + + pub fn moduli(&self) -> &[u64] { + &self.moduli + } + + pub fn residues(&self) -> &[u64] { + &self.residues + } + + pub fn bound(&self) -> u64 { + self.bound + } + + pub fn num_incongruences(&self) -> usize { + self.moduli.len() + } +} + +impl Problem for SimultaneousIncongruences { + const NAME: &'static str = "SimultaneousIncongruences"; + type Value = Or; + + fn dims(&self) -> Vec { + vec![usize::try_from(self.bound).expect("bound must fit in usize")] + } + + fn evaluate(&self, config: &[usize]) -> Or { + let Some(&offset) = config.first() else { + return Or(false); + }; + + let bound = usize::try_from(self.bound).expect("bound must fit in usize"); + if config.len() != 1 || offset >= bound { + return Or(false); + } + + let x = offset as u64 + 1; + Or(self + .moduli + .iter() + .zip(&self.residues) + .all(|(&modulus, &residue)| x % modulus != residue)) + } + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![] + } +} + +crate::declare_variants! { + default SimultaneousIncongruences => "bound", +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "simultaneous_incongruences", + instance: Box::new(SimultaneousIncongruences::new( + vec![2, 3, 5, 7], + vec![0, 1, 2, 3], + 210, + )), + optimal_config: vec![4], + optimal_value: serde_json::json!(true), + }] +} + +#[cfg(test)] +#[path = "../../unit_tests/models/algebraic/simultaneous_incongruences.rs"] +mod tests; diff --git a/src/models/formula/ksat.rs b/src/models/formula/ksat.rs index 047c902e5..4677b458e 100644 --- a/src/models/formula/ksat.rs +++ b/src/models/formula/ksat.rs @@ -12,6 +12,42 @@ use serde::{Deserialize, Serialize}; use super::CNFClause; +pub(crate) fn first_n_odd_primes(count: usize) -> Vec { + let mut primes = Vec::with_capacity(count); + let mut candidate = 3u64; + + while primes.len() < count { + if is_prime(candidate) { + primes.push(candidate); + } + candidate += 2; + } + + primes +} + +fn is_prime(candidate: u64) -> bool { + if candidate < 2 { + return false; + } + if candidate == 2 { + return true; + } + if candidate.is_multiple_of(2) { + return false; + } + + let mut divisor = 3u64; + while divisor * divisor <= candidate { + if candidate.is_multiple_of(divisor) { + return false; + } + divisor += 2; + } + + true +} + inventory::submit! { ProblemSchemaEntry { name: "KSatisfiability", @@ -147,6 +183,23 @@ impl KSatisfiability { self.clauses().iter().map(|c| c.len()).sum() } + pub fn simultaneous_incongruences_num_incongruences(&self) -> usize { + first_n_odd_primes(self.num_vars) + .into_iter() + .map(|prime| usize::try_from(prime - 2).expect("prime fits in usize")) + .sum::() + + self.num_clauses() + } + + pub fn simultaneous_incongruences_bound(&self) -> usize { + first_n_odd_primes(self.num_vars) + .into_iter() + .try_fold(1usize, |product, prime| { + product.checked_mul(usize::try_from(prime).expect("prime fits in usize")) + }) + .expect("simultaneous incongruences bound must fit in usize") + } + /// Count satisfied clauses for an assignment. pub fn count_satisfied(&self, assignment: &[bool]) -> usize { self.clauses diff --git a/src/models/mod.rs b/src/models/mod.rs index 84294b2a7..1834ea8cb 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -12,7 +12,7 @@ pub mod set; pub use algebraic::{ ClosestVectorProblem, ConsecutiveBlockMinimization, ConsecutiveOnesMatrixAugmentation, ConsecutiveOnesSubmatrix, IntegerExpressionMembership, MinimumWeightSolutionToLinearEquations, - QuadraticAssignment, SparseMatrixCompression, BMF, ILP, QUBO, + QuadraticAssignment, SimultaneousIncongruences, SparseMatrixCompression, BMF, ILP, QUBO, }; pub use formula::{ CNFClause, CircuitSAT, KSatisfiability, NAESatisfiability, NonTautology, diff --git a/src/rules/ksatisfiability_simultaneousincongruences.rs b/src/rules/ksatisfiability_simultaneousincongruences.rs new file mode 100644 index 000000000..0eba18f92 --- /dev/null +++ b/src/rules/ksatisfiability_simultaneousincongruences.rs @@ -0,0 +1,196 @@ +//! Reduction from 3-SAT to Simultaneous Incongruences. +//! +//! Uses distinct odd primes to encode variable assignments via residues +//! 1 (true) and 2 (false), then forbids each clause's unique falsifying +//! residue class via the Chinese Remainder Theorem. + +use std::collections::BTreeMap; + +use crate::models::algebraic::SimultaneousIncongruences; +use crate::models::formula::{ksat::first_n_odd_primes, CNFClause, KSatisfiability}; +use crate::reduction; +use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::variant::K3; + +#[derive(Debug, Clone)] +pub struct Reduction3SATToSimultaneousIncongruences { + target: SimultaneousIncongruences, + variable_primes: Vec, +} + +impl ReductionResult for Reduction3SATToSimultaneousIncongruences { + type Source = KSatisfiability; + type Target = SimultaneousIncongruences; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + let x = target_solution.first().copied().unwrap_or(0) as u64 + 1; + self.variable_primes + .iter() + .map(|&prime| if x % prime == 1 { 1 } else { 0 }) + .collect() + } +} + +fn falsifying_residue(literal: i32) -> u64 { + if literal > 0 { + 2 + } else { + 1 + } +} + +fn modular_inverse(value: u64, modulus: u64) -> u64 { + let mut t = 0i128; + let mut new_t = 1i128; + let mut r = modulus as i128; + let mut new_r = value as i128; + + while new_r != 0 { + let quotient = r / new_r; + (t, new_t) = (new_t, t - quotient * new_t); + (r, new_r) = (new_r, r - quotient * new_r); + } + + assert_eq!(r, 1, "value and modulus must be coprime"); + if t < 0 { + t += modulus as i128; + } + t as u64 +} + +fn crt_residue(congruences: &[(u64, u64)]) -> (u64, u64) { + let modulus = congruences.iter().fold(1u64, |product, &(m, _)| { + product + .checked_mul(m) + .expect("CRT modulus product overflow") + }); + + let residue = congruences + .iter() + .fold(0u128, |acc, &(modulus_i, residue_i)| { + let partial = modulus / modulus_i; + let inverse = modular_inverse(partial % modulus_i, modulus_i); + acc + residue_i as u128 * partial as u128 * inverse as u128 + }) + % modulus as u128; + + (residue as u64, modulus) +} + +fn clause_bad_residue(clause: &CNFClause, variable_primes: &[u64]) -> (u64, u64) { + let mut residue_by_var = BTreeMap::new(); + let mut contradictory_var = None; + + for &literal in &clause.literals { + let var_index = literal.unsigned_abs() as usize - 1; + let residue = falsifying_residue(literal); + + match residue_by_var.insert(var_index, residue) { + Some(existing) if existing != residue => { + contradictory_var = Some(var_index); + residue_by_var.insert(var_index, 0); + break; + } + Some(existing) => { + residue_by_var.insert(var_index, existing); + } + None => {} + } + } + + if let Some(var_index) = contradictory_var { + for &literal in &clause.literals { + let candidate = literal.unsigned_abs() as usize - 1; + if candidate != var_index { + residue_by_var + .entry(candidate) + .or_insert_with(|| falsifying_residue(literal)); + } + } + } + + let congruences = residue_by_var + .into_iter() + .map(|(var_index, residue)| { + ( + *variable_primes + .get(var_index) + .expect("clause variable index must be within num_vars"), + residue, + ) + }) + .collect::>(); + + crt_residue(&congruences) +} + +#[reduction(overhead = { + num_incongruences = "simultaneous_incongruences_num_incongruences", + bound = "simultaneous_incongruences_bound", +})] +impl ReduceTo for KSatisfiability { + type Result = Reduction3SATToSimultaneousIncongruences; + + fn reduce_to(&self) -> Self::Result { + let variable_primes = first_n_odd_primes(self.num_vars()); + let bound = variable_primes.iter().fold(1u64, |product, &prime| { + product.checked_mul(prime).expect("bound overflow") + }); + + let mut moduli = Vec::new(); + let mut residues = Vec::new(); + + for &prime in &variable_primes { + moduli.push(prime); + residues.push(0); + for residue in 3..prime { + moduli.push(prime); + residues.push(residue); + } + } + + for clause in self.clauses() { + let (bad_residue, clause_modulus) = clause_bad_residue(clause, &variable_primes); + moduli.push(clause_modulus); + residues.push(bad_residue); + } + + Reduction3SATToSimultaneousIncongruences { + target: SimultaneousIncongruences::new(moduli, residues, bound), + variable_primes, + } + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + use crate::export::SolutionPair; + + vec![crate::example_db::specs::RuleExampleSpec { + id: "ksatisfiability_to_simultaneous_incongruences", + build: || { + let source = KSatisfiability::::new( + 2, + vec![ + CNFClause::new(vec![1, 2, 2]), + CNFClause::new(vec![-1, 2, 2]), + ], + ); + crate::example_db::specs::rule_example_with_witness::<_, SimultaneousIncongruences>( + source, + SolutionPair { + source_config: vec![1, 1], + target_config: vec![0], + }, + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/ksatisfiability_simultaneousincongruences.rs"] +mod tests; diff --git a/src/rules/mod.rs b/src/rules/mod.rs index ea754d15c..703b02299 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -23,6 +23,7 @@ mod knapsack_qubo; mod ksatisfiability_casts; pub(crate) mod ksatisfiability_kernel; pub(crate) mod ksatisfiability_qubo; +pub(crate) mod ksatisfiability_simultaneousincongruences; pub(crate) mod ksatisfiability_subsetsum; pub(crate) mod maximumclique_maximumindependentset; mod maximumindependentset_casts; @@ -260,6 +261,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec::new( + 2, + vec![ + CNFClause::new(vec![1, 2, 2]), + CNFClause::new(vec![-1, 2, 2]), + ], + ); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + assert_eq!(target.bound(), 15); + assert_eq!(target.num_incongruences(), 6); + + let solver = BruteForce::new(); + let target_solution = solver + .find_witness(target) + .expect("target should be satisfiable"); + let extracted = reduction.extract_solution(&target_solution); + + assert!(source.evaluate(&extracted)); +} + +#[test] +fn test_ksatisfiability_to_simultaneous_incongruences_structure() { + let source = KSatisfiability::::new( + 2, + vec![ + CNFClause::new(vec![1, 2, 2]), + CNFClause::new(vec![-1, 2, 2]), + ], + ); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + let pairs = target + .moduli() + .iter() + .copied() + .zip(target.residues().iter().copied()) + .collect::>(); + assert_eq!( + pairs, + vec![(3, 0), (5, 0), (5, 3), (5, 4), (15, 2), (15, 7)] + ); +} + +#[test] +fn test_ksatisfiability_to_simultaneous_incongruences_unsatisfiable() { + let source = KSatisfiability::::new( + 1, + vec![ + CNFClause::new(vec![1, 1, 1]), + CNFClause::new(vec![-1, -1, -1]), + ], + ); + let reduction = ReduceTo::::reduce_to(&source); + let solver = BruteForce::new(); + + assert_eq!(solver.find_witness(reduction.target_problem()), None); +} + +#[test] +fn test_ksatisfiability_to_simultaneous_incongruences_tautological_clause_is_redundant() { + let source = KSatisfiability::::new( + 2, + vec![ + CNFClause::new(vec![1, -1, 2]), + CNFClause::new(vec![2, 2, 2]), + ], + ); + let reduction = ReduceTo::::reduce_to(&source); + let solver = BruteForce::new(); + let target_solution = solver + .find_witness(reduction.target_problem()) + .expect("target should remain satisfiable"); + let extracted = reduction.extract_solution(&target_solution); + + assert!(source.evaluate(&extracted)); +} diff --git a/tests/main.rs b/tests/main.rs index 6a7cc0c00..777283aef 100644 --- a/tests/main.rs +++ b/tests/main.rs @@ -6,5 +6,9 @@ mod examples; mod integration; #[path = "suites/jl_parity.rs"] mod jl_parity; +#[path = "suites/ksatisfiability_simultaneous_incongruences.rs"] +mod ksatisfiability_simultaneous_incongruences; #[path = "suites/reductions.rs"] mod reductions; +#[path = "suites/simultaneous_incongruences.rs"] +mod simultaneous_incongruences; diff --git a/tests/suites/ksatisfiability_simultaneous_incongruences.rs b/tests/suites/ksatisfiability_simultaneous_incongruences.rs new file mode 100644 index 000000000..341ed9b6f --- /dev/null +++ b/tests/suites/ksatisfiability_simultaneous_incongruences.rs @@ -0,0 +1,31 @@ +use problemreductions::models::algebraic::SimultaneousIncongruences; +use problemreductions::models::formula::{CNFClause, KSatisfiability}; +use problemreductions::rules::{ReduceTo, ReductionResult}; +use problemreductions::solvers::BruteForce; +use problemreductions::variant::K3; +use problemreductions::Problem; + +#[test] +fn test_ksatisfiability_to_simultaneous_incongruences_closed_loop() { + let source = KSatisfiability::::new( + 3, + vec![ + CNFClause::new(vec![1, 2, 3]), + CNFClause::new(vec![-1, 2, 3]), + ], + ); + + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + assert_eq!(target.bound(), 105); + assert_eq!(target.num_incongruences(), 11); + + let solver = BruteForce::new(); + let target_solution = solver + .find_witness(target) + .expect("target should be satisfiable"); + let extracted = reduction.extract_solution(&target_solution); + + assert!(source.evaluate(&extracted)); +} diff --git a/tests/suites/simultaneous_incongruences.rs b/tests/suites/simultaneous_incongruences.rs new file mode 100644 index 000000000..0ef4d39be --- /dev/null +++ b/tests/suites/simultaneous_incongruences.rs @@ -0,0 +1,26 @@ +use problemreductions::models::algebraic::SimultaneousIncongruences; +use problemreductions::solvers::BruteForce; +use problemreductions::traits::Problem; + +#[test] +fn test_simultaneous_incongruences_issue_example() { + let problem = SimultaneousIncongruences::new(vec![2, 3, 5, 7], vec![0, 1, 2, 3], 210); + + assert_eq!(problem.moduli(), &[2, 3, 5, 7]); + assert_eq!(problem.residues(), &[0, 1, 2, 3]); + assert_eq!(problem.bound(), 210); + assert_eq!(problem.num_incongruences(), 4); + assert_eq!(problem.dims(), vec![210]); + assert!(problem.evaluate(&[4])); + assert!(!problem.evaluate(&[2])); +} + +#[test] +fn test_simultaneous_incongruences_solver_finds_witness() { + let problem = SimultaneousIncongruences::new(vec![2, 3, 5, 7], vec![0, 1, 2, 3], 210); + let solver = BruteForce::new(); + + let witness = solver.find_witness(&problem); + + assert_eq!(witness, Some(vec![4])); +} From bb6de6c7b11c818e7486aa4fbe370ae8e4778376 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 4 Apr 2026 06:41:23 +0800 Subject: [PATCH 11/25] =?UTF-8?q?feat:=20add=20SequencingToMinimizeTardyTa?= =?UTF-8?q?skWeight=20model=20and=20Partition=20=E2=86=92=20SMTTW=20rule?= =?UTF-8?q?=20(#471)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds SequencingToMinimizeTardyTaskWeight model (Value=Min, Lehmer-coded permutation schedules) and the common-deadline reduction from Partition. Each element becomes a task with length=weight=size and deadline=B/2. On-time tasks decode one partition half. Includes CLI support and tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- problemreductions-cli/src/cli.rs | 1 + problemreductions-cli/src/commands/create.rs | 57 +++++- src/models/misc/mod.rs | 4 + ...equencing_to_minimize_tardy_task_weight.rs | 167 ++++++++++++++++++ src/models/mod.rs | 10 +- src/rules/mod.rs | 2 + ...ion_sequencingtominimizetardytaskweight.rs | 102 +++++++++++ ...equencing_to_minimize_tardy_task_weight.rs | 107 +++++++++++ ...ion_sequencingtominimizetardytaskweight.rs | 101 +++++++++++ 9 files changed, 542 insertions(+), 9 deletions(-) create mode 100644 src/models/misc/sequencing_to_minimize_tardy_task_weight.rs create mode 100644 src/rules/partition_sequencingtominimizetardytaskweight.rs create mode 100644 src/unit_tests/models/misc/sequencing_to_minimize_tardy_task_weight.rs create mode 100644 src/unit_tests/rules/partition_sequencingtominimizetardytaskweight.rs diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 79c3ff448..0b770eecc 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -313,6 +313,7 @@ Flags by problem type: RectilinearPictureCompression --matrix (0/1), --k SchedulingWithIndividualDeadlines --n, --num-processors/--m, --deadlines [--precedence-pairs] SequencingToMinimizeMaximumCumulativeCost --costs, --bound [--precedence-pairs] + SequencingToMinimizeTardyTaskWeight --lengths, --weights, --deadlines SequencingToMinimizeWeightedCompletionTime --lengths, --weights [--precedence-pairs] SequencingToMinimizeWeightedTardiness --sizes, --weights, --deadlines, --bound SCS --strings, --bound [--alphabet-size] diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 71704a5e1..f5989264f 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -27,10 +27,10 @@ use problemreductions::models::misc::{ LongestCommonSubsequence, MinimumTardinessSequencing, MultiprocessorScheduling, PaintShop, PartiallyOrderedKnapsack, QueryArg, RectilinearPictureCompression, ResourceConstrainedScheduling, SchedulingWithIndividualDeadlines, - SequencingToMinimizeMaximumCumulativeCost, SequencingToMinimizeWeightedCompletionTime, - SequencingToMinimizeWeightedTardiness, SequencingWithReleaseTimesAndDeadlines, - SequencingWithinIntervals, ShortestCommonSupersequence, StringToStringCorrection, - SubsetProduct, SubsetSum, SumOfSquaresPartition, TimetableDesign, + SequencingToMinimizeMaximumCumulativeCost, SequencingToMinimizeTardyTaskWeight, + SequencingToMinimizeWeightedCompletionTime, SequencingToMinimizeWeightedTardiness, + SequencingWithReleaseTimesAndDeadlines, SequencingWithinIntervals, ShortestCommonSupersequence, + StringToStringCorrection, SubsetProduct, SubsetSum, SumOfSquaresPartition, TimetableDesign, }; use problemreductions::models::BiconnectivityAugmentation; use problemreductions::prelude::*; @@ -689,6 +689,9 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "SequencingToMinimizeWeightedTardiness" => { "--sizes 3,4,2,5,3 --weights 2,3,1,4,2 --deadlines 5,8,4,15,10 --bound 13" } + "SequencingToMinimizeTardyTaskWeight" => { + "--lengths 3,2,4,1,2 --weights 5,3,7,2,4 --deadlines 6,4,10,2,8" + } "SubsetProduct" => "--values 2,3,5,7 --target 30", "SubsetSum" => "--sizes 3,7,1,8,2,4 --target 11", "BoyceCoddNormalFormViolation" => { @@ -3625,6 +3628,52 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } + // SequencingToMinimizeTardyTaskWeight + "SequencingToMinimizeTardyTaskWeight" => { + let lengths_str = args.lengths.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "SequencingToMinimizeTardyTaskWeight requires --lengths, --weights, and --deadlines\n\n\ + Usage: pred create SequencingToMinimizeTardyTaskWeight --lengths 3,2,4,1,2 --weights 5,3,7,2,4 --deadlines 6,4,10,2,8" + ) + })?; + let weights_str = args.weights.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "SequencingToMinimizeTardyTaskWeight requires --weights\n\n\ + Usage: pred create SequencingToMinimizeTardyTaskWeight --lengths 3,2,4,1,2 --weights 5,3,7,2,4 --deadlines 6,4,10,2,8" + ) + })?; + let deadlines_str = args.deadlines.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "SequencingToMinimizeTardyTaskWeight requires --deadlines\n\n\ + Usage: pred create SequencingToMinimizeTardyTaskWeight --lengths 3,2,4,1,2 --weights 5,3,7,2,4 --deadlines 6,4,10,2,8" + ) + })?; + + let lengths: Vec = util::parse_comma_list(lengths_str)?; + let weights: Vec = util::parse_comma_list(weights_str)?; + let deadlines: Vec = util::parse_comma_list(deadlines_str)?; + + anyhow::ensure!( + lengths.len() == weights.len(), + "lengths length ({}) must equal weights length ({})", + lengths.len(), + weights.len() + ); + anyhow::ensure!( + lengths.len() == deadlines.len(), + "lengths length ({}) must equal deadlines length ({})", + lengths.len(), + deadlines.len() + ); + + ( + ser(SequencingToMinimizeTardyTaskWeight::new( + lengths, weights, deadlines, + ))?, + resolved_variant.clone(), + ) + } + // SequencingToMinimizeMaximumCumulativeCost "SequencingToMinimizeMaximumCumulativeCost" => { let costs_str = args.costs.as_deref().ok_or_else(|| { diff --git a/src/models/misc/mod.rs b/src/models/misc/mod.rs index 405dca74b..2de9c358a 100644 --- a/src/models/misc/mod.rs +++ b/src/models/misc/mod.rs @@ -25,6 +25,7 @@ //! - [`StackerCrane`]: Route a crane through required arcs within a length bound //! - [`SequencingToMinimizeMaximumCumulativeCost`]: Keep every cumulative schedule cost prefix under a bound //! - [`SequencingToMinimizeWeightedCompletionTime`]: Minimize total weighted completion time +//! - [`SequencingToMinimizeTardyTaskWeight`]: Minimize the total weight of tardy tasks //! - [`SequencingToMinimizeWeightedTardiness`]: Decide whether a schedule meets a weighted tardiness bound //! - [`SequencingWithReleaseTimesAndDeadlines`]: Single-machine scheduling feasibility //! - [`SequencingWithinIntervals`]: Schedule tasks within time windows @@ -59,6 +60,7 @@ mod rectilinear_picture_compression; pub(crate) mod resource_constrained_scheduling; mod scheduling_with_individual_deadlines; mod sequencing_to_minimize_maximum_cumulative_cost; +mod sequencing_to_minimize_tardy_task_weight; mod sequencing_to_minimize_weighted_completion_time; mod sequencing_to_minimize_weighted_tardiness; mod sequencing_with_release_times_and_deadlines; @@ -98,6 +100,7 @@ pub use rectilinear_picture_compression::RectilinearPictureCompression; pub use resource_constrained_scheduling::ResourceConstrainedScheduling; pub use scheduling_with_individual_deadlines::SchedulingWithIndividualDeadlines; pub use sequencing_to_minimize_maximum_cumulative_cost::SequencingToMinimizeMaximumCumulativeCost; +pub use sequencing_to_minimize_tardy_task_weight::SequencingToMinimizeTardyTaskWeight; pub use sequencing_to_minimize_weighted_completion_time::SequencingToMinimizeWeightedCompletionTime; pub use sequencing_to_minimize_weighted_tardiness::SequencingToMinimizeWeightedTardiness; pub use sequencing_with_release_times_and_deadlines::SequencingWithReleaseTimesAndDeadlines; @@ -138,6 +141,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec", description: "Processing time l(t) for each task" }, + FieldInfo { name: "weights", type_name: "Vec", description: "Weight w(t) for each task" }, + FieldInfo { name: "deadlines", type_name: "Vec", description: "Deadline d(t) for each task" }, + ], + } +} + +/// Sequencing to Minimize Tardy Task Weight. +/// +/// Given tasks with processing times `l(t)`, weights `w(t)`, and deadlines +/// `d(t)`, find a single-machine schedule minimizing the total weight of tasks +/// that finish after their deadlines. +/// +/// # Representation +/// +/// Configurations use Lehmer code with `dims() = [n, n-1, ..., 1]`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SequencingToMinimizeTardyTaskWeight { + lengths: Vec, + weights: Vec, + deadlines: Vec, +} + +impl SequencingToMinimizeTardyTaskWeight { + /// Create a new sequencing instance. + /// + /// # Panics + /// + /// Panics if the three vectors do not have the same length. + pub fn new(lengths: Vec, weights: Vec, deadlines: Vec) -> Self { + assert_eq!( + lengths.len(), + weights.len(), + "weights length must equal lengths length" + ); + assert_eq!( + lengths.len(), + deadlines.len(), + "deadlines length must equal lengths length" + ); + + Self { + lengths, + weights, + deadlines, + } + } + + /// Returns the processing times. + pub fn lengths(&self) -> &[u64] { + &self.lengths + } + + /// Returns the task weights. + pub fn weights(&self) -> &[u64] { + &self.weights + } + + /// Returns the deadlines. + pub fn deadlines(&self) -> &[u64] { + &self.deadlines + } + + /// Returns the number of tasks. + pub fn num_tasks(&self) -> usize { + self.lengths.len() + } + + fn decode_schedule(&self, config: &[usize]) -> Option> { + let n = self.num_tasks(); + if config.len() != n { + return None; + } + + let mut available: Vec = (0..n).collect(); + let mut schedule = Vec::with_capacity(n); + for &digit in config { + if digit >= available.len() { + return None; + } + schedule.push(available.remove(digit)); + } + Some(schedule) + } + + fn tardy_task_weight(&self, schedule: &[usize]) -> u64 { + let mut completion_time = 0u64; + let mut total = 0u64; + + for &task in schedule { + completion_time = completion_time + .checked_add(self.lengths[task]) + .expect("completion time overflowed u64"); + if completion_time > self.deadlines[task] { + total = total + .checked_add(self.weights[task]) + .expect("total tardy weight overflowed u64"); + } + } + + total + } +} + +impl Problem for SequencingToMinimizeTardyTaskWeight { + const NAME: &'static str = "SequencingToMinimizeTardyTaskWeight"; + type Value = Min; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![] + } + + fn dims(&self) -> Vec { + let n = self.num_tasks(); + (0..n).rev().map(|i| i + 1).collect() + } + + fn evaluate(&self, config: &[usize]) -> Min { + let Some(schedule) = self.decode_schedule(config) else { + return Min(None); + }; + Min(Some(self.tardy_task_weight(&schedule))) + } +} + +crate::declare_variants! { + default SequencingToMinimizeTardyTaskWeight => "factorial(num_tasks)", +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "sequencing_to_minimize_tardy_task_weight", + instance: Box::new(SequencingToMinimizeTardyTaskWeight::new( + vec![3, 2, 4, 1, 2], + vec![5, 3, 7, 2, 4], + vec![6, 4, 10, 2, 8], + )), + optimal_config: vec![3, 0, 2, 1, 0], + optimal_value: serde_json::json!(3), + }] +} + +#[cfg(test)] +#[path = "../../unit_tests/models/misc/sequencing_to_minimize_tardy_task_weight.rs"] +mod tests; diff --git a/src/models/mod.rs b/src/models/mod.rs index 1834ea8cb..a30a0e8b5 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -43,11 +43,11 @@ pub use misc::{ LongestCommonSubsequence, MinimumTardinessSequencing, MultiprocessorScheduling, PaintShop, Partition, PrecedenceConstrainedScheduling, QueryArg, RectilinearPictureCompression, ResourceConstrainedScheduling, SchedulingWithIndividualDeadlines, - SequencingToMinimizeMaximumCumulativeCost, SequencingToMinimizeWeightedCompletionTime, - SequencingToMinimizeWeightedTardiness, SequencingWithReleaseTimesAndDeadlines, - SequencingWithinIntervals, ShortestCommonSupersequence, StackerCrane, StaffScheduling, - StringToStringCorrection, SubsetProduct, SubsetSum, SumOfSquaresPartition, Term, - TimetableDesign, + SequencingToMinimizeMaximumCumulativeCost, SequencingToMinimizeTardyTaskWeight, + SequencingToMinimizeWeightedCompletionTime, SequencingToMinimizeWeightedTardiness, + SequencingWithReleaseTimesAndDeadlines, SequencingWithinIntervals, ShortestCommonSupersequence, + StackerCrane, StaffScheduling, StringToStringCorrection, SubsetProduct, SubsetSum, + SumOfSquaresPartition, Term, TimetableDesign, }; pub use set::{ ComparativeContainment, ConsecutiveSets, ExactCoverBy3Sets, MaximumSetPacking, diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 703b02299..0031f4457 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -40,6 +40,7 @@ pub(crate) mod minimumvertexcover_minimumfeedbackvertexset; pub(crate) mod minimumvertexcover_minimumsetcovering; pub(crate) mod naesatisfiability_setsplitting; pub(crate) mod partition_knapsack; +pub(crate) mod partition_sequencingtominimizetardytaskweight; pub(crate) mod sat_circuitsat; pub(crate) mod sat_coloring; pub(crate) mod sat_ksat; @@ -270,6 +271,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec Vec { + let n = self.target.num_tasks(); + assert_eq!( + target_solution.len(), + n, + "target solution length must equal target num_tasks" + ); + + let mut available: Vec = (0..n).collect(); + let mut schedule = Vec::with_capacity(n); + for &digit in target_solution { + assert!( + digit < available.len(), + "target solution must be a valid Lehmer-coded schedule" + ); + schedule.push(available.remove(digit)); + } + schedule + } +} + +impl ReductionResult for ReductionPartitionToSequencingToMinimizeTardyTaskWeight { + type Source = Partition; + type Target = SequencingToMinimizeTardyTaskWeight; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + let schedule = self.decode_schedule(target_solution); + let mut source_config = vec![1; self.target.num_tasks()]; + let mut completion_time = 0u64; + + for task in schedule { + completion_time = completion_time + .checked_add(self.target.lengths()[task]) + .expect("completion time overflowed u64"); + if completion_time <= self.target.deadlines()[task] { + source_config[task] = 0; + } + } + + source_config + } +} + +#[reduction(overhead = { + num_tasks = "num_elements", +})] +impl ReduceTo for Partition { + type Result = ReductionPartitionToSequencingToMinimizeTardyTaskWeight; + + fn reduce_to(&self) -> Self::Result { + let common_deadline = self.total_sum() / 2; + let lengths = self.sizes().to_vec(); + let weights = self.sizes().to_vec(); + let deadlines = vec![common_deadline; self.num_elements()]; + + ReductionPartitionToSequencingToMinimizeTardyTaskWeight { + target: SequencingToMinimizeTardyTaskWeight::new(lengths, weights, deadlines), + } + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + use crate::export::SolutionPair; + + vec![crate::example_db::specs::RuleExampleSpec { + id: "partition_to_sequencing_to_minimize_tardy_task_weight", + build: || { + crate::example_db::specs::rule_example_with_witness::< + _, + SequencingToMinimizeTardyTaskWeight, + >( + Partition::new(vec![3, 1, 1, 2, 2, 1]), + SolutionPair { + source_config: vec![1, 0, 0, 1, 0, 0], + target_config: vec![1, 1, 2, 2, 0, 0], + }, + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/partition_sequencingtominimizetardytaskweight.rs"] +mod tests; diff --git a/src/unit_tests/models/misc/sequencing_to_minimize_tardy_task_weight.rs b/src/unit_tests/models/misc/sequencing_to_minimize_tardy_task_weight.rs new file mode 100644 index 000000000..e218ff44b --- /dev/null +++ b/src/unit_tests/models/misc/sequencing_to_minimize_tardy_task_weight.rs @@ -0,0 +1,107 @@ +use super::*; +use crate::solvers::BruteForce; +use crate::traits::Problem; +use crate::types::Min; + +fn issue_example() -> SequencingToMinimizeTardyTaskWeight { + SequencingToMinimizeTardyTaskWeight::new( + vec![3, 2, 4, 1, 2], + vec![5, 3, 7, 2, 4], + vec![6, 4, 10, 2, 8], + ) +} + +#[test] +fn test_sequencing_to_minimize_tardy_task_weight_basic() { + let problem = issue_example(); + + assert_eq!(problem.num_tasks(), 5); + assert_eq!(problem.lengths(), &[3, 2, 4, 1, 2]); + assert_eq!(problem.weights(), &[5, 3, 7, 2, 4]); + assert_eq!(problem.deadlines(), &[6, 4, 10, 2, 8]); + assert_eq!(problem.dims(), vec![5, 4, 3, 2, 1]); + assert_eq!( + ::NAME, + "SequencingToMinimizeTardyTaskWeight" + ); + assert_eq!( + ::variant(), + vec![] + ); +} + +#[test] +fn test_sequencing_to_minimize_tardy_task_weight_evaluate_issue_example() { + let problem = issue_example(); + + assert_eq!(problem.evaluate(&[3, 0, 2, 1, 0]), Min(Some(3))); + assert_eq!(problem.evaluate(&[3, 3, 0, 1, 0]), Min(Some(3))); +} + +#[test] +fn test_sequencing_to_minimize_tardy_task_weight_evaluate_invalid_lehmer() { + let problem = issue_example(); + + assert_eq!(problem.evaluate(&[0, 4, 0, 0, 0]), Min(None)); +} + +#[test] +fn test_sequencing_to_minimize_tardy_task_weight_evaluate_wrong_length() { + let problem = issue_example(); + + assert_eq!(problem.evaluate(&[3, 0, 2, 1]), Min(None)); + assert_eq!(problem.evaluate(&[3, 0, 2, 1, 0, 0]), Min(None)); +} + +#[test] +fn test_sequencing_to_minimize_tardy_task_weight_brute_force() { + let problem = issue_example(); + let solver = BruteForce::new(); + + let solution = solver + .find_witness(&problem) + .expect("should find an optimal schedule"); + + assert_eq!(problem.evaluate(&solution), Min(Some(3))); +} + +#[test] +fn test_sequencing_to_minimize_tardy_task_weight_paper_example() { + let problem = issue_example(); + let solver = BruteForce::new(); + + let solutions = solver.find_all_witnesses(&problem); + assert_eq!(solutions, vec![vec![3, 0, 2, 1, 0], vec![3, 3, 0, 1, 0]]); +} + +#[test] +fn test_sequencing_to_minimize_tardy_task_weight_serialization() { + let problem = issue_example(); + let json = serde_json::to_value(&problem).unwrap(); + let restored: SequencingToMinimizeTardyTaskWeight = serde_json::from_value(json).unwrap(); + + assert_eq!(restored.lengths(), problem.lengths()); + assert_eq!(restored.weights(), problem.weights()); + assert_eq!(restored.deadlines(), problem.deadlines()); +} + +#[test] +fn test_sequencing_to_minimize_tardy_task_weight_empty() { + let problem = SequencingToMinimizeTardyTaskWeight::new(vec![], vec![], vec![]); + + assert_eq!(problem.num_tasks(), 0); + assert_eq!(problem.dims(), Vec::::new()); + assert_eq!(problem.evaluate(&[]), Min(Some(0))); +} + +#[test] +#[should_panic(expected = "weights length must equal lengths length")] +fn test_sequencing_to_minimize_tardy_task_weight_mismatched_lengths_and_weights() { + SequencingToMinimizeTardyTaskWeight::new(vec![3, 2], vec![5], vec![6, 4]); +} + +#[test] +#[should_panic(expected = "deadlines length must equal lengths length")] +fn test_sequencing_to_minimize_tardy_task_weight_mismatched_lengths_and_deadlines() { + SequencingToMinimizeTardyTaskWeight::new(vec![3, 2], vec![5, 3], vec![6]); +} diff --git a/src/unit_tests/rules/partition_sequencingtominimizetardytaskweight.rs b/src/unit_tests/rules/partition_sequencingtominimizetardytaskweight.rs new file mode 100644 index 000000000..ed1628088 --- /dev/null +++ b/src/unit_tests/rules/partition_sequencingtominimizetardytaskweight.rs @@ -0,0 +1,101 @@ +#[cfg(feature = "example-db")] +use super::canonical_rule_example_specs; +use crate::models::misc::{Partition, SequencingToMinimizeTardyTaskWeight}; +use crate::rules::test_helpers::assert_satisfaction_round_trip_from_optimization_target; +use crate::rules::traits::ReductionResult; +use crate::rules::ReduceTo; +use crate::solvers::BruteForce; +use crate::traits::Problem; +use crate::types::Min; + +#[test] +fn test_partition_to_sequencing_to_minimize_tardy_task_weight_closed_loop() { + let source = Partition::new(vec![3, 1, 1, 2, 2, 1]); + let reduction = ReduceTo::::reduce_to(&source); + + assert_satisfaction_round_trip_from_optimization_target( + &source, + &reduction, + "Partition -> SequencingToMinimizeTardyTaskWeight closed loop", + ); +} + +#[test] +fn test_partition_to_sequencing_to_minimize_tardy_task_weight_structure() { + let source = Partition::new(vec![3, 1, 1, 2, 2, 1]); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + assert_eq!(target.lengths(), &[3, 1, 1, 2, 2, 1]); + assert_eq!(target.weights(), &[3, 1, 1, 2, 2, 1]); + assert_eq!(target.deadlines(), &[5, 5, 5, 5, 5, 5]); + assert_eq!(target.num_tasks(), source.num_elements()); +} + +#[test] +fn test_partition_to_sequencing_to_minimize_tardy_task_weight_extract_solution() { + let source = Partition::new(vec![3, 1, 1, 2, 2, 1]); + let reduction = ReduceTo::::reduce_to(&source); + + assert_eq!( + reduction.extract_solution(&[1, 1, 2, 2, 0, 0]), + vec![1, 0, 0, 1, 0, 0] + ); +} + +#[test] +fn test_partition_to_sequencing_to_minimize_tardy_task_weight_odd_total_is_unsatisfying() { + let source = Partition::new(vec![2, 4, 5]); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + let best = BruteForce::new() + .find_witness(target) + .expect("target should always have an optimal schedule"); + + assert_eq!(target.evaluate(&best), Min(Some(6))); + assert!(!source.evaluate(&reduction.extract_solution(&best))); +} + +#[cfg(feature = "example-db")] +#[test] +fn test_partition_to_sequencing_to_minimize_tardy_task_weight_canonical_example_spec() { + let example = (canonical_rule_example_specs() + .into_iter() + .find(|spec| spec.id == "partition_to_sequencing_to_minimize_tardy_task_weight") + .expect("missing canonical Partition -> SequencingToMinimizeTardyTaskWeight example spec") + .build)(); + + assert_eq!(example.source.problem, "Partition"); + assert_eq!( + example.target.problem, + "SequencingToMinimizeTardyTaskWeight" + ); + assert_eq!( + example.target.instance["lengths"], + serde_json::json!([3, 1, 1, 2, 2, 1]) + ); + assert_eq!( + example.target.instance["weights"], + serde_json::json!([3, 1, 1, 2, 2, 1]) + ); + assert_eq!( + example.target.instance["deadlines"], + serde_json::json!([5, 5, 5, 5, 5, 5]) + ); + assert_eq!(example.solutions.len(), 1); + assert_eq!(example.solutions[0].source_config, vec![1, 0, 0, 1, 0, 0]); + assert_eq!(example.solutions[0].target_config, vec![1, 1, 2, 2, 0, 0]); + + let source: Partition = serde_json::from_value(example.source.instance.clone()) + .expect("source example deserializes"); + let target: SequencingToMinimizeTardyTaskWeight = + serde_json::from_value(example.target.instance.clone()) + .expect("target example deserializes"); + + assert!(source + .evaluate(&example.solutions[0].source_config) + .is_valid()); + assert!(target + .evaluate(&example.solutions[0].target_config) + .is_valid()); +} From a18f3a5f4416acbc9b3efa243883bfa3cdc0d7f7 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 4 Apr 2026 08:52:14 +0800 Subject: [PATCH 12/25] =?UTF-8?q?feat:=20add=20OpenShopScheduling=20model?= =?UTF-8?q?=20and=20Partition=20=E2=86=92=20OSS=20rule=20(#481)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds OpenShopScheduling model (Value=Min, Lehmer-coded permutation schedules with active-schedule decoder) and the Gonzalez-Sahni 1976 reduction from Partition. Uses 3 machines with a special Q-job; makespan ≤3Q iff balanced partition exists. Includes CLI support and tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- problemreductions-cli/src/cli.rs | 2 + problemreductions-cli/src/commands/create.rs | 56 ++++- src/lib.rs | 14 +- src/models/misc/mod.rs | 4 + src/models/misc/open_shop_scheduling.rs | 220 ++++++++++++++++++ src/models/mod.rs | 17 +- src/rules/mod.rs | 2 + src/rules/partition_openshopscheduling.rs | 94 ++++++++ .../models/misc/open_shop_scheduling.rs | 99 ++++++++ .../rules/partition_openshopscheduling.rs | 56 +++++ 10 files changed, 542 insertions(+), 22 deletions(-) create mode 100644 src/models/misc/open_shop_scheduling.rs create mode 100644 src/rules/partition_openshopscheduling.rs create mode 100644 src/unit_tests/models/misc/open_shop_scheduling.rs create mode 100644 src/unit_tests/rules/partition_openshopscheduling.rs diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 0b770eecc..d385406c8 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -284,6 +284,7 @@ Flags by problem type: AcyclicPartition --arcs [--weights] [--arc-costs] --weight-bound --cost-bound [--num-vertices] CVP --basis, --target-vec [--bounds] MultiprocessorScheduling --lengths, --num-processors, --deadline + OpenShopScheduling --task-lengths [--num-processors] SequencingWithinIntervals --release-times, --deadlines, --lengths OptimalLinearArrangement --graph, --bound RootedTreeArrangement --graph, --bound @@ -347,6 +348,7 @@ Examples: pred create MIS/UnitDiskGraph --positions \"0,0;1,0;0.5,0.8\" --radius 1.5 pred create MIS --random --num-vertices 10 --edge-prob 0.3 pred create MultiprocessorScheduling --lengths 4,5,3,2,6 --num-processors 2 --deadline 10 + pred create OpenShopScheduling --task-lengths \"3,1,2;2,3,1;1,2,3;2,2,1\" --num-processors 3 pred create UndirectedFlowLowerBounds --graph 0-1,0-2,1-3,2-3,1-4,3-5,4-5 --capacities 2,2,2,2,1,3,2 --lower-bounds 1,1,0,0,1,0,1 --source 0 --sink 5 --requirement 3 pred create ConsistencyOfDatabaseFrequencyTables --num-objects 6 --attribute-domains \"2,3,2\" --frequency-tables \"0,1:1,1,1|1,1,1;1,2:1,1|0,2|1,1\" --known-values \"0,0,0;3,0,1;1,2,1\" pred create BiconnectivityAugmentation --graph 0-1,1-2,2-3 --potential-edges 0-2:3,0-3:4,1-3:2 --budget 5 diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index f5989264f..1bf8ce4ab 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -24,13 +24,14 @@ use problemreductions::models::misc::{ AdditionalKey, BinPacking, BoyceCoddNormalFormViolation, CapacityAssignment, CbqRelation, ConjunctiveBooleanQuery, ConsistencyOfDatabaseFrequencyTables, EnsembleComputation, ExpectedRetrievalCost, FlowShopScheduling, FrequencyTable, GroupingBySwapping, KnownValue, - LongestCommonSubsequence, MinimumTardinessSequencing, MultiprocessorScheduling, PaintShop, - PartiallyOrderedKnapsack, QueryArg, RectilinearPictureCompression, - ResourceConstrainedScheduling, SchedulingWithIndividualDeadlines, - SequencingToMinimizeMaximumCumulativeCost, SequencingToMinimizeTardyTaskWeight, - SequencingToMinimizeWeightedCompletionTime, SequencingToMinimizeWeightedTardiness, - SequencingWithReleaseTimesAndDeadlines, SequencingWithinIntervals, ShortestCommonSupersequence, - StringToStringCorrection, SubsetProduct, SubsetSum, SumOfSquaresPartition, TimetableDesign, + LongestCommonSubsequence, MinimumTardinessSequencing, MultiprocessorScheduling, + OpenShopScheduling, PaintShop, PartiallyOrderedKnapsack, QueryArg, + RectilinearPictureCompression, ResourceConstrainedScheduling, + SchedulingWithIndividualDeadlines, SequencingToMinimizeMaximumCumulativeCost, + SequencingToMinimizeTardyTaskWeight, SequencingToMinimizeWeightedCompletionTime, + SequencingToMinimizeWeightedTardiness, SequencingWithReleaseTimesAndDeadlines, + SequencingWithinIntervals, ShortestCommonSupersequence, StringToStringCorrection, + SubsetProduct, SubsetSum, SumOfSquaresPartition, TimetableDesign, }; use problemreductions::models::BiconnectivityAugmentation; use problemreductions::prelude::*; @@ -639,6 +640,9 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "--capacities 1,2,3 --cost-matrix \"1,3,6;2,4,7;1,2,5\" --delay-matrix \"8,4,1;7,3,1;6,3,1\" --cost-budget 10 --delay-budget 12" } "MultiprocessorScheduling" => "--lengths 4,5,3,2,6 --num-processors 2 --deadline 10", + "OpenShopScheduling" => { + "--task-lengths \"3,1,2;2,3,1;1,2,3;2,2,1\" --num-processors 3" + } "MinimumMultiwayCut" => "--graph 0-1,1-2,2-3 --terminals 0,2 --edge-weights 1,1,1", "ExpectedRetrievalCost" => EXPECTED_RETRIEVAL_COST_EXAMPLE_ARGS, "SequencingWithinIntervals" => "--release-times 0,0,5 --deadlines 11,11,6 --lengths 3,1,1", @@ -3816,6 +3820,44 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } + // OpenShopScheduling + "OpenShopScheduling" => { + let usage = "Usage: pred create OpenShopScheduling --task-lengths \"3,1,2;2,3,1;1,2,3;2,2,1\" --num-processors 3"; + let task_str = args.task_lengths.as_deref().ok_or_else(|| { + anyhow::anyhow!("OpenShopScheduling requires --task-lengths\n\n{usage}") + })?; + let processing_times: Vec> = task_str + .split(';') + .map(|row| util::parse_comma_list(row.trim())) + .collect::>>()?; + let num_processors = resolve_processor_count_flags( + "OpenShopScheduling", + usage, + args.num_processors, + args.m, + )? + .or_else(|| processing_times.first().map(Vec::len)) + .ok_or_else(|| { + anyhow::anyhow!( + "Cannot infer num_processors from empty task list; use --num-processors" + ) + })?; + for (job, row) in processing_times.iter().enumerate() { + if row.len() != num_processors { + bail!( + "processing_times row {} has {} entries, expected {} (num_processors)", + job, + row.len(), + num_processors + ); + } + } + ( + ser(OpenShopScheduling::new(num_processors, processing_times))?, + resolved_variant.clone(), + ) + } + // StaffScheduling "StaffScheduling" => { let usage = "Usage: pred create StaffScheduling --schedules \"1,1,1,1,1,0,0;0,1,1,1,1,1,0;0,0,1,1,1,1,1;1,0,0,1,1,1,1;1,1,0,0,1,1,1\" --requirements 2,2,2,3,3,2,1 --num-workers 4 --k 5"; diff --git a/src/lib.rs b/src/lib.rs index 9f94477d4..4d8b89a38 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -77,13 +77,13 @@ pub mod prelude { ConjunctiveBooleanQuery, ConjunctiveQueryFoldability, ConsistencyOfDatabaseFrequencyTables, EnsembleComputation, ExpectedRetrievalCost, Factoring, FlowShopScheduling, GroupingBySwapping, Knapsack, LongestCommonSubsequence, MinimumTardinessSequencing, - MultiprocessorScheduling, PaintShop, Partition, QueryArg, RectilinearPictureCompression, - ResourceConstrainedScheduling, SchedulingWithIndividualDeadlines, - SequencingToMinimizeMaximumCumulativeCost, SequencingToMinimizeWeightedCompletionTime, - SequencingToMinimizeWeightedTardiness, SequencingWithReleaseTimesAndDeadlines, - SequencingWithinIntervals, ShortestCommonSupersequence, StackerCrane, StaffScheduling, - StringToStringCorrection, SubsetProduct, SubsetSum, SumOfSquaresPartition, Term, - TimetableDesign, + MultiprocessorScheduling, OpenShopScheduling, PaintShop, Partition, QueryArg, + RectilinearPictureCompression, ResourceConstrainedScheduling, + SchedulingWithIndividualDeadlines, SequencingToMinimizeMaximumCumulativeCost, + SequencingToMinimizeWeightedCompletionTime, SequencingToMinimizeWeightedTardiness, + SequencingWithReleaseTimesAndDeadlines, SequencingWithinIntervals, + ShortestCommonSupersequence, StackerCrane, StaffScheduling, StringToStringCorrection, + SubsetProduct, SubsetSum, SumOfSquaresPartition, Term, TimetableDesign, }; pub use crate::models::set::{ ComparativeContainment, ConsecutiveSets, ExactCoverBy3Sets, MaximumSetPacking, diff --git a/src/models/misc/mod.rs b/src/models/misc/mod.rs index 2de9c358a..e5de1ea0c 100644 --- a/src/models/misc/mod.rs +++ b/src/models/misc/mod.rs @@ -15,6 +15,7 @@ //! - [`MultiprocessorScheduling`]: Schedule tasks on processors to meet a deadline //! - [`LongestCommonSubsequence`]: Longest Common Subsequence //! - [`MinimumTardinessSequencing`]: Minimize tardy tasks in single-machine scheduling +//! - [`OpenShopScheduling`]: Minimize makespan in open-shop scheduling //! - [`PaintShop`]: Minimize color switches in paint shop scheduling //! - [`Partition`]: Partition a multiset into two equal-sum subsets //! - [`PartiallyOrderedKnapsack`]: Knapsack with precedence constraints @@ -52,6 +53,7 @@ mod knapsack; mod longest_common_subsequence; mod minimum_tardiness_sequencing; mod multiprocessor_scheduling; +mod open_shop_scheduling; pub(crate) mod paintshop; pub(crate) mod partially_ordered_knapsack; pub(crate) mod partition; @@ -92,6 +94,7 @@ pub use knapsack::Knapsack; pub use longest_common_subsequence::LongestCommonSubsequence; pub use minimum_tardiness_sequencing::MinimumTardinessSequencing; pub use multiprocessor_scheduling::MultiprocessorScheduling; +pub use open_shop_scheduling::OpenShopScheduling; pub use paintshop::PaintShop; pub use partially_ordered_knapsack::PartiallyOrderedKnapsack; pub use partition::Partition; @@ -128,6 +131,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec>", description: "processing_times[j][i] = processing time of job j on machine i" }, + ], + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OpenShopScheduling { + num_machines: usize, + processing_times: Vec>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct DecodedOpenShopSchedule { + pub machine_orders: Vec>, + pub start_times: Vec>, + pub makespan: u64, +} + +impl OpenShopScheduling { + pub fn new(num_machines: usize, processing_times: Vec>) -> Self { + assert!(num_machines > 0, "num_machines must be positive"); + for (job, row) in processing_times.iter().enumerate() { + assert_eq!( + row.len(), + num_machines, + "job {} has {} tasks, expected {}", + job, + row.len(), + num_machines + ); + } + Self { + num_machines, + processing_times, + } + } + + pub fn num_jobs(&self) -> usize { + self.processing_times.len() + } + + pub fn num_machines(&self) -> usize { + self.num_machines + } + + pub fn processing_times(&self) -> &[Vec] { + &self.processing_times + } + + fn decode_permutation(&self, digits: &[usize]) -> Option> { + let n = self.num_jobs(); + if digits.len() != n { + return None; + } + + let mut available: Vec = (0..n).collect(); + let mut permutation = Vec::with_capacity(n); + for &digit in digits { + if digit >= available.len() { + return None; + } + permutation.push(available.remove(digit)); + } + Some(permutation) + } + + pub(crate) fn decode_machine_orders(&self, config: &[usize]) -> Option>> { + let n = self.num_jobs(); + let expected_len = n.checked_mul(self.num_machines)?; + if config.len() != expected_len { + return None; + } + if n == 0 { + return Some(vec![Vec::new(); self.num_machines]); + } + + config + .chunks_exact(n) + .map(|chunk| self.decode_permutation(chunk)) + .collect() + } + + pub(crate) fn schedule_from_machine_orders( + &self, + machine_orders: &[Vec], + ) -> Option { + if machine_orders.len() != self.num_machines { + return None; + } + + let n = self.num_jobs(); + if n == 0 { + return Some(DecodedOpenShopSchedule { + machine_orders: machine_orders.to_vec(), + start_times: Vec::new(), + makespan: 0, + }); + } + + for order in machine_orders { + if order.len() != n { + return None; + } + let mut seen = vec![false; n]; + for &job in order { + if job >= n || seen[job] { + return None; + } + seen[job] = true; + } + } + + let mut next_position = vec![0usize; self.num_machines]; + let mut machine_available = vec![0u64; self.num_machines]; + let mut job_available = vec![0u64; n]; + let mut start_times = vec![vec![0u64; self.num_machines]; n]; + + for _ in 0..(n * self.num_machines) { + let mut best_candidate: Option<(u64, u64, usize, usize)> = None; + for machine in 0..self.num_machines { + let position = next_position[machine]; + if position >= n { + continue; + } + + let job = machine_orders[machine][position]; + let start = machine_available[machine].max(job_available[job]); + let completion = start + .checked_add(self.processing_times[job][machine]) + .expect("makespan overflowed u64"); + let candidate = (completion, start, machine, job); + if best_candidate.is_none_or(|current| candidate < current) { + best_candidate = Some(candidate); + } + } + + let (completion, start, machine, job) = + best_candidate.expect("there must be a schedulable operation"); + start_times[job][machine] = start; + machine_available[machine] = completion; + job_available[job] = completion; + next_position[machine] += 1; + } + + Some(DecodedOpenShopSchedule { + machine_orders: machine_orders.to_vec(), + start_times, + makespan: job_available.into_iter().max().unwrap_or(0), + }) + } + + pub(crate) fn schedule_from_config(&self, config: &[usize]) -> Option { + let machine_orders = self.decode_machine_orders(config)?; + self.schedule_from_machine_orders(&machine_orders) + } +} + +impl Problem for OpenShopScheduling { + const NAME: &'static str = "OpenShopScheduling"; + type Value = Min; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![] + } + + fn dims(&self) -> Vec { + let n = self.num_jobs(); + let lehmer_dims: Vec = (0..n).rev().map(|i| i + 1).collect(); + (0..self.num_machines) + .flat_map(|_| lehmer_dims.iter().copied()) + .collect() + } + + fn evaluate(&self, config: &[usize]) -> Min { + self.schedule_from_config(config) + .map(|schedule| Min(Some(schedule.makespan))) + .unwrap_or(Min(None)) + } +} + +crate::declare_variants! { + default OpenShopScheduling => "factorial(num_jobs)^num_machines", +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "open_shop_scheduling", + instance: Box::new(OpenShopScheduling::new( + 3, + vec![vec![3, 1, 2], vec![2, 3, 1], vec![1, 2, 3], vec![2, 2, 1]], + )), + optimal_config: vec![0, 0, 0, 0, 1, 0, 1, 0, 2, 2, 0, 0], + optimal_value: serde_json::json!(8), + }] +} + +#[cfg(test)] +#[path = "../../unit_tests/models/misc/open_shop_scheduling.rs"] +mod tests; diff --git a/src/models/mod.rs b/src/models/mod.rs index a30a0e8b5..f40f0610d 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -40,14 +40,15 @@ pub use misc::{ AdditionalKey, BinPacking, CapacityAssignment, CbqRelation, ConjunctiveBooleanQuery, ConjunctiveQueryFoldability, ConsistencyOfDatabaseFrequencyTables, EnsembleComputation, ExpectedRetrievalCost, Factoring, FlowShopScheduling, GroupingBySwapping, Knapsack, - LongestCommonSubsequence, MinimumTardinessSequencing, MultiprocessorScheduling, PaintShop, - Partition, PrecedenceConstrainedScheduling, QueryArg, RectilinearPictureCompression, - ResourceConstrainedScheduling, SchedulingWithIndividualDeadlines, - SequencingToMinimizeMaximumCumulativeCost, SequencingToMinimizeTardyTaskWeight, - SequencingToMinimizeWeightedCompletionTime, SequencingToMinimizeWeightedTardiness, - SequencingWithReleaseTimesAndDeadlines, SequencingWithinIntervals, ShortestCommonSupersequence, - StackerCrane, StaffScheduling, StringToStringCorrection, SubsetProduct, SubsetSum, - SumOfSquaresPartition, Term, TimetableDesign, + LongestCommonSubsequence, MinimumTardinessSequencing, MultiprocessorScheduling, + OpenShopScheduling, PaintShop, Partition, PrecedenceConstrainedScheduling, QueryArg, + RectilinearPictureCompression, ResourceConstrainedScheduling, + SchedulingWithIndividualDeadlines, SequencingToMinimizeMaximumCumulativeCost, + SequencingToMinimizeTardyTaskWeight, SequencingToMinimizeWeightedCompletionTime, + SequencingToMinimizeWeightedTardiness, SequencingWithReleaseTimesAndDeadlines, + SequencingWithinIntervals, ShortestCommonSupersequence, StackerCrane, StaffScheduling, + StringToStringCorrection, SubsetProduct, SubsetSum, SumOfSquaresPartition, Term, + TimetableDesign, }; pub use set::{ ComparativeContainment, ConsecutiveSets, ExactCoverBy3Sets, MaximumSetPacking, diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 0031f4457..eeb952fed 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -40,6 +40,7 @@ pub(crate) mod minimumvertexcover_minimumfeedbackvertexset; pub(crate) mod minimumvertexcover_minimumsetcovering; pub(crate) mod naesatisfiability_setsplitting; pub(crate) mod partition_knapsack; +pub(crate) mod partition_openshopscheduling; pub(crate) mod partition_sequencingtominimizetardytaskweight; pub(crate) mod sat_circuitsat; pub(crate) mod sat_coloring; @@ -271,6 +272,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec &Self::Target { + &self.target + } + + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + let num_elements = self.target.num_jobs().saturating_sub(1); + let mut source_config = vec![0; num_elements]; + let Some(schedule) = self.target.schedule_from_config(target_solution) else { + return source_config; + }; + if num_elements == 0 { + return source_config; + } + + let special_job = num_elements; + let half_sum = self.target.processing_times()[special_job][0]; + let middle_machine = (0..self.target.num_machines()) + .find(|&machine| schedule.start_times[special_job][machine] == half_sum) + .unwrap_or_else(|| { + let mut machines: Vec = (0..self.target.num_machines()).collect(); + machines + .sort_by_key(|&machine| (schedule.start_times[special_job][machine], machine)); + machines[self.target.num_machines() / 2] + }); + let pivot = schedule.start_times[special_job][middle_machine]; + + for (job, slot) in source_config.iter_mut().enumerate() { + let completion = schedule.start_times[job][middle_machine] + .checked_add(self.target.processing_times()[job][middle_machine]) + .expect("completion time overflowed u64"); + if completion <= pivot { + *slot = 1; + } + } + + source_config + } +} + +#[reduction(overhead = { + num_jobs = "num_elements + 1", + num_machines = "3", +})] +impl ReduceTo for Partition { + type Result = ReductionPartitionToOpenShopScheduling; + + fn reduce_to(&self) -> Self::Result { + let half_sum = self.total_sum() / 2; + let mut processing_times: Vec> = + self.sizes().iter().map(|&size| vec![size; 3]).collect(); + processing_times.push(vec![half_sum; 3]); + + ReductionPartitionToOpenShopScheduling { + target: OpenShopScheduling::new(3, processing_times), + } + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + use crate::export::SolutionPair; + + vec![crate::example_db::specs::RuleExampleSpec { + id: "partition_to_open_shop_scheduling", + build: || { + crate::example_db::specs::rule_example_with_witness::<_, OpenShopScheduling>( + Partition::new(vec![1, 2, 3]), + SolutionPair { + source_config: vec![0, 0, 1], + target_config: vec![0, 0, 0, 0, 2, 2, 0, 0, 3, 0, 0, 0], + }, + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/partition_openshopscheduling.rs"] +mod tests; diff --git a/src/unit_tests/models/misc/open_shop_scheduling.rs b/src/unit_tests/models/misc/open_shop_scheduling.rs new file mode 100644 index 000000000..5bd0c4c48 --- /dev/null +++ b/src/unit_tests/models/misc/open_shop_scheduling.rs @@ -0,0 +1,99 @@ +use super::*; +use crate::solvers::BruteForce; +use crate::traits::Problem; +use crate::types::Min; + +fn encode_permutation(permutation: &[usize]) -> Vec { + let mut available: Vec = (0..permutation.len()).collect(); + let mut digits = Vec::with_capacity(permutation.len()); + for &job in permutation { + let index = available + .iter() + .position(|&candidate| candidate == job) + .expect("permutation must contain each job exactly once"); + digits.push(index); + available.remove(index); + } + digits +} + +fn encode_machine_orders(machine_orders: &[Vec]) -> Vec { + machine_orders + .iter() + .flat_map(|order| encode_permutation(order)) + .collect() +} + +#[test] +fn test_open_shop_scheduling_creation() { + let problem = OpenShopScheduling::new( + 3, + vec![vec![3, 1, 2], vec![2, 3, 1], vec![1, 2, 3], vec![2, 2, 1]], + ); + + assert_eq!(problem.num_jobs(), 4); + assert_eq!(problem.num_machines(), 3); + assert_eq!( + problem.processing_times(), + &[vec![3, 1, 2], vec![2, 3, 1], vec![1, 2, 3], vec![2, 2, 1]] + ); + assert_eq!(problem.dims(), vec![4, 3, 2, 1, 4, 3, 2, 1, 4, 3, 2, 1]); + assert_eq!(::NAME, "OpenShopScheduling"); + assert_eq!(::variant(), vec![]); +} + +#[test] +fn test_open_shop_scheduling_evaluate_verified_example() { + let problem = OpenShopScheduling::new( + 3, + vec![vec![3, 1, 2], vec![2, 3, 1], vec![1, 2, 3], vec![2, 2, 1]], + ); + let config = encode_machine_orders(&[vec![0, 1, 2, 3], vec![1, 0, 3, 2], vec![2, 3, 0, 1]]); + + assert_eq!(problem.evaluate(&config), Min(Some(8))); +} + +#[test] +fn test_open_shop_scheduling_invalid_config() { + let problem = OpenShopScheduling::new(2, vec![vec![1, 2], vec![3, 4]]); + + assert_eq!(problem.evaluate(&[0, 2, 0, 0]), Min(None)); + assert_eq!(problem.evaluate(&[0, 0, 0]), Min(None)); +} + +#[test] +fn test_open_shop_scheduling_brute_force_solver() { + let problem = OpenShopScheduling::new( + 3, + vec![vec![3, 1, 2], vec![2, 3, 1], vec![1, 2, 3], vec![2, 2, 1]], + ); + let known_optimum = + encode_machine_orders(&[vec![0, 1, 2, 3], vec![1, 0, 3, 2], vec![2, 3, 0, 1]]); + let solver = BruteForce::new(); + + let witness = solver + .find_witness(&problem) + .expect("open shop example should have an optimal schedule"); + + assert_eq!(problem.evaluate(&witness), Min(Some(8))); + assert!(solver.find_all_witnesses(&problem).contains(&known_optimum)); +} + +#[test] +fn test_open_shop_scheduling_serialization() { + let problem = OpenShopScheduling::new(2, vec![vec![1, 2], vec![3, 4], vec![2, 1]]); + let json = serde_json::to_value(&problem).unwrap(); + let restored: OpenShopScheduling = serde_json::from_value(json).unwrap(); + + assert_eq!(restored.num_machines(), problem.num_machines()); + assert_eq!(restored.processing_times(), problem.processing_times()); +} + +#[test] +fn test_open_shop_scheduling_empty() { + let problem = OpenShopScheduling::new(3, vec![]); + + assert_eq!(problem.num_jobs(), 0); + assert_eq!(problem.dims(), Vec::::new()); + assert_eq!(problem.evaluate(&[]), Min(Some(0))); +} diff --git a/src/unit_tests/rules/partition_openshopscheduling.rs b/src/unit_tests/rules/partition_openshopscheduling.rs new file mode 100644 index 000000000..3ff667e3c --- /dev/null +++ b/src/unit_tests/rules/partition_openshopscheduling.rs @@ -0,0 +1,56 @@ +use super::*; +use crate::models::misc::{OpenShopScheduling, Partition}; +use crate::rules::test_helpers::assert_satisfaction_round_trip_from_optimization_target; +use crate::solvers::BruteForce; +use crate::traits::Problem; +use crate::types::Min; + +#[test] +fn test_partition_to_open_shop_scheduling_closed_loop() { + let source = Partition::new(vec![1, 2, 3]); + let reduction = ReduceTo::::reduce_to(&source); + + assert_satisfaction_round_trip_from_optimization_target( + &source, + &reduction, + "Partition -> OpenShopScheduling closed loop", + ); +} + +#[test] +fn test_partition_to_open_shop_scheduling_structure() { + let source = Partition::new(vec![1, 2, 3]); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + assert_eq!(target.num_jobs(), 4); + assert_eq!(target.num_machines(), 3); + assert_eq!( + target.processing_times(), + &[vec![1, 1, 1], vec![2, 2, 2], vec![3, 3, 3], vec![3, 3, 3]] + ); +} + +#[test] +fn test_partition_to_open_shop_scheduling_extract_solution() { + let source = Partition::new(vec![1, 2, 3]); + let reduction = ReduceTo::::reduce_to(&source); + + assert_eq!( + reduction.extract_solution(&[0, 0, 0, 0, 2, 2, 0, 0, 3, 0, 0, 0]), + vec![0, 0, 1] + ); +} + +#[test] +fn test_partition_to_open_shop_scheduling_odd_total_is_not_satisfying() { + let source = Partition::new(vec![2, 4, 5]); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + let best = BruteForce::new() + .find_witness(target) + .expect("open-shop target should always have an optimal solution"); + + assert_eq!(target.evaluate(&best), Min(Some(16))); + assert!(!source.evaluate(&reduction.extract_solution(&best))); +} From 3b4b562366e1086f9a2601ec3197ad66bc351aec Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 4 Apr 2026 09:17:51 +0800 Subject: [PATCH 13/25] =?UTF-8?q?feat:=20add=20NAESatisfiability=20?= =?UTF-8?q?=E2=86=92=20MaxCut=20reduction=20rule=20(#166)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the Garey-Johnson-Stockmeyer 1976 reduction. Variable edges with weight M=2m+1 force complementary partition; clause triangles with unit weights contribute exactly 2 cut edges per NAE-satisfied clause. Max cut ≥ nM+2m iff NAE-satisfiable. Includes paper entry, canonical example, and tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/paper/reductions.typ | 38 ++++ src/rules/mod.rs | 2 + src/rules/naesatisfiability_maxcut.rs | 178 ++++++++++++++++++ src/unit_tests/rules/graph.rs | 9 + .../rules/naesatisfiability_maxcut.rs | 85 +++++++++ 5 files changed, 312 insertions(+) create mode 100644 src/rules/naesatisfiability_maxcut.rs create mode 100644 src/unit_tests/rules/naesatisfiability_maxcut.rs diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 27cc5972a..0926d1484 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -8606,6 +8606,44 @@ The following reductions to Integer Linear Programming are straightforward formu _Solution extraction._ Read the side of each positive-literal element: assign $x_i = 1$ iff $p_i in S_2$. ] +#let nae_mc = load-example("NAESatisfiability", "MaxCut") +#let nae_mc_sol = nae_mc.solutions.at(0) +#let nae_mc_target_edges = nae_mc.target.instance.graph.edges.map(e => (e.at(0), e.at(1))) +#let nae_mc_weights = nae_mc.target.instance.edge_weights +#let nae_mc_m = sat-num-clauses(nae_mc.source.instance) +#let nae_mc_penalty = 2 * nae_mc_m + 1 +#let nae_mc_shared_weight = nae_mc_target_edges.enumerate() + .filter(((i, e)) => e.at(0) == 0 and e.at(1) == 2) + .map(((i, e)) => nae_mc_weights.at(i)) + .at(0) +#let nae_mc_cut_value = nae_mc_target_edges.enumerate().filter(((i, e)) => + nae_mc_sol.target_config.at(e.at(0)) != nae_mc_sol.target_config.at(e.at(1)) +).map(((i, e)) => nae_mc_weights.at(i)).sum(default: 0) +#reduction-rule("NAESatisfiability", "MaxCut", + example: true, + example-caption: [2 NAE-3SAT clauses to weighted Max-Cut], + extra: [ + #pred-commands( + "pred create --example " + problem-spec(nae_mc.source) + " -o naesat.json", + "pred reduce naesat.json --to " + target-spec(nae_mc) + " -o bundle.json", + "pred solve bundle.json", + "pred evaluate naesat.json --config " + nae_mc_sol.source_config.map(str).join(","), + ) + Source: $n = #nae_mc.source.instance.num_vars$, $m = #nae_mc_m$ \ + Target: $#graph-num-vertices(nae_mc.target.instance)$ vertices, $#graph-num-edges(nae_mc.target.instance)$ weighted edges, $M = 2m + 1 = #nae_mc_penalty$ \ + The repeated literal pair $(v_0, v_2)$ accumulates to weight $#nae_mc_shared_weight$ \ + Canonical witness source $(#nae_mc_sol.source_config.map(str).join(", "))$ maps to target $(#nae_mc_sol.target_config.map(str).join(", "))$ and attains cut $#nae_mc_cut_value$ #sym.checkmark + ], +)[ + @garey1976 This polynomial-time reduction encodes each variable by a heavy edge whose endpoints represent the positive and negative literals, then replaces every 3-literal NAE clause by a unit-weight triangle on the corresponding literal vertices. The heavy edges force opposite sides for $x_i$ and $not x_i$ in every optimum; once that is fixed, each clause triangle contributes exactly $2$ iff its literals are not all equal. The target therefore has $2n$ vertices and at most $n + 3m$ weighted edges after merging parallel clause edges. +][ + _Construction._ Given an NAE-3SAT instance with variables $x_1, dots, x_n$ and clauses $C_1, dots, C_m$, set $M = 2m + 1$. For each variable $x_i$, create two vertices $v_i$ and $v_i'$ and add edge $(v_i, v_i')$ with weight $M$. Map a positive literal $x_i$ to $v_i$ and a negative literal $not x_i$ to $v_i'$. For each clause $C_j = (l_a, l_b, l_c)$, add the three unit-weight edges between the literal vertices of $l_a$, $l_b$, and $l_c$. When the same unordered pair of literal vertices appears in several clauses, keep one Max-Cut edge whose weight is the sum of those occurrences. + + _Correctness._ ($arrow.r.double$) Let $alpha$ be an NAE-satisfying assignment. Put $v_i$ on side $1$ and $v_i'$ on side $0$ when $alpha(x_i) = 1$, and swap them when $alpha(x_i) = 0$. Every heavy variable edge is cut, contributing $n M$. In each clause, the three literal vertices are not monochromatic, so the triangle is split $1$-$2$ and contributes exactly $2$. Hence the cut value is $n M + 2m$. ($arrow.l.double$) Let $S$ be a maximum cut. If some heavy edge $(v_i, v_i')$ were not cut, moving one endpoint across the cut would gain $M$. This can decrease the contribution of each clause containing $x_i$ or $not x_i$ by at most $1$, so the total clause loss is at most $2m < M$. Therefore every maximum cut must cut every heavy variable edge. Reading $x_i = 1$ exactly when $v_i$ lies on side $1$ yields a well-defined assignment. Under that assignment, a clause triangle contributes $2$ exactly when its three literals are not all equal and $0$ otherwise. Since the heavy contribution $n M$ is fixed across all maximum cuts, maximizing the cut is equivalent to maximizing the number of NAE-satisfied clauses; in particular, value $n M + 2m$ occurs iff all clauses are NAE-satisfied. + + _Solution extraction._ Return $x_i = 1$ iff the positive-literal vertex $v_i$ lies on the selected side of the Max-Cut witness. +] + #reduction-rule("KClique", "ILP")[ A $k$-clique requires at least $k$ selected vertices with no non-edge between any pair. ][ diff --git a/src/rules/mod.rs b/src/rules/mod.rs index eeb952fed..3341e79ef 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -38,6 +38,7 @@ pub(crate) mod minimummultiwaycut_qubo; pub(crate) mod minimumvertexcover_maximumindependentset; pub(crate) mod minimumvertexcover_minimumfeedbackvertexset; pub(crate) mod minimumvertexcover_minimumsetcovering; +pub(crate) mod naesatisfiability_maxcut; pub(crate) mod naesatisfiability_setsplitting; pub(crate) mod partition_knapsack; pub(crate) mod partition_openshopscheduling; @@ -277,6 +278,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec, + num_source_variables: usize, +} + +impl ReductionResult for ReductionNAESATToMaxCut { + type Source = NAESatisfiability; + type Target = MaxCut; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + let required_vertices = 2 * self.num_source_variables; + assert!( + target_solution.len() >= required_vertices, + "MaxCut solution has {} vertices but source requires {}", + target_solution.len(), + required_vertices, + ); + + (0..self.num_source_variables) + .map(|var_index| target_solution[2 * var_index]) + .collect() + } +} + +fn variable_gadget_weight(num_clauses: usize) -> i32 { + i32::try_from(num_clauses) + .ok() + .and_then(|m| m.checked_mul(2)) + .and_then(|twice_m| twice_m.checked_add(1)) + .expect("NAESatisfiability -> MaxCut penalty exceeds i32 range") +} + +fn literal_vertex(literal: i32, num_vars: usize) -> usize { + let var = literal.unsigned_abs() as usize; + assert!( + (1..=num_vars).contains(&var), + "NAESatisfiability -> MaxCut literal {literal} is out of range for {num_vars} variables", + ); + + let var_index = var - 1; + if literal > 0 { + 2 * var_index + } else { + 2 * var_index + 1 + } +} + +fn clause_literal_vertices( + clause: &crate::models::formula::CNFClause, + num_vars: usize, +) -> [usize; 3] { + match clause.literals.as_slice() { + [a, b, c] => [ + literal_vertex(*a, num_vars), + literal_vertex(*b, num_vars), + literal_vertex(*c, num_vars), + ], + _ => panic!("NAESatisfiability -> MaxCut requires every clause to have exactly 3 literals"), + } +} + +fn accumulate_edge_weight( + edge_weights: &mut BTreeMap<(usize, usize), i32>, + u: usize, + v: usize, + delta: i32, +) { + if u == v { + // Repeated literals induce self-loops in the multigraph view, but they + // never contribute to a cut, so we drop them when collapsing to + // `SimpleGraph`. + return; + } + + let edge = if u < v { (u, v) } else { (v, u) }; + let weight = edge_weights.entry(edge).or_insert(0); + *weight = weight + .checked_add(delta) + .expect("NAESatisfiability -> MaxCut edge weight overflow"); +} + +#[reduction( + overhead = { + num_vertices = "2 * num_vars", + num_edges = "num_vars + 3 * num_clauses", + } +)] +impl ReduceTo> for NAESatisfiability { + type Result = ReductionNAESATToMaxCut; + + fn reduce_to(&self) -> Self::Result { + let num_vars = self.num_vars(); + let penalty = variable_gadget_weight(self.num_clauses()); + let mut edge_weights = BTreeMap::new(); + + for var_index in 0..num_vars { + accumulate_edge_weight(&mut edge_weights, 2 * var_index, 2 * var_index + 1, penalty); + } + + for clause in self.clauses() { + let [a, b, c] = clause_literal_vertices(clause, num_vars); + accumulate_edge_weight(&mut edge_weights, a, b, 1); + accumulate_edge_weight(&mut edge_weights, b, c, 1); + accumulate_edge_weight(&mut edge_weights, a, c, 1); + } + + let (edges, weights): (Vec<_>, Vec<_>) = edge_weights.into_iter().unzip(); + let target = MaxCut::new(SimpleGraph::new(2 * num_vars, edges), weights); + + ReductionNAESATToMaxCut { + target, + num_source_variables: num_vars, + } + } +} + +#[cfg(any(test, feature = "example-db"))] +const ISSUE_EXAMPLE_SOURCE_CONFIG: [usize; 3] = [1, 0, 1]; + +#[cfg(any(test, feature = "example-db"))] +const ISSUE_EXAMPLE_TARGET_CONFIG: [usize; 6] = [1, 0, 0, 1, 1, 0]; + +#[cfg(any(test, feature = "example-db"))] +fn issue_example() -> NAESatisfiability { + use crate::models::formula::CNFClause; + + NAESatisfiability::new( + 3, + vec![ + CNFClause::new(vec![1, 2, 3]), + CNFClause::new(vec![1, 2, -3]), + ], + ) +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + use crate::export::SolutionPair; + + vec![crate::example_db::specs::RuleExampleSpec { + id: "naesatisfiability_to_maxcut", + build: || { + crate::example_db::specs::rule_example_with_witness::<_, MaxCut>( + issue_example(), + SolutionPair { + source_config: ISSUE_EXAMPLE_SOURCE_CONFIG.to_vec(), + target_config: ISSUE_EXAMPLE_TARGET_CONFIG.to_vec(), + }, + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/naesatisfiability_maxcut.rs"] +mod tests; diff --git a/src/unit_tests/rules/graph.rs b/src/unit_tests/rules/graph.rs index ee1fb0933..f610e9762 100644 --- a/src/unit_tests/rules/graph.rs +++ b/src/unit_tests/rules/graph.rs @@ -1,5 +1,7 @@ use super::*; use crate::models::algebraic::{ILP, QUBO}; +use crate::models::formula::NAESatisfiability; +use crate::models::graph::MaxCut; use crate::models::graph::{MaximumIndependentSet, MinimumVertexCover}; use crate::models::misc::Knapsack; use crate::models::set::MaximumSetPacking; @@ -873,6 +875,13 @@ fn test_ksat_reductions() { assert!(graph.has_direct_reduction::, Satisfiability>()); } +#[test] +fn test_nae_sat_to_maxcut_reduction_registered() { + let graph = ReductionGraph::new(); + + assert!(graph.has_direct_reduction::>()); +} + #[test] fn test_all_categories_present() { let graph = ReductionGraph::new(); diff --git a/src/unit_tests/rules/naesatisfiability_maxcut.rs b/src/unit_tests/rules/naesatisfiability_maxcut.rs new file mode 100644 index 000000000..7c141538c --- /dev/null +++ b/src/unit_tests/rules/naesatisfiability_maxcut.rs @@ -0,0 +1,85 @@ +use super::*; +use crate::models::formula::{CNFClause, NAESatisfiability}; +use crate::rules::test_helpers::assert_satisfaction_round_trip_from_optimization_target; +use crate::rules::{ReduceTo, ReductionResult}; +use crate::topology::SimpleGraph; +use crate::traits::Problem; +use crate::types::Max; + +#[test] +fn test_naesatisfiability_to_maxcut_closed_loop() { + let source = super::issue_example(); + let reduction = ReduceTo::>::reduce_to(&source); + + assert_satisfaction_round_trip_from_optimization_target( + &source, + &reduction, + "NAE-SAT -> MaxCut", + ); +} + +#[test] +fn test_naesatisfiability_to_maxcut_target_structure() { + let source = super::issue_example(); + let reduction = ReduceTo::>::reduce_to(&source); + let target = reduction.target_problem(); + + assert_eq!(target.num_vertices(), 6); + assert_eq!(target.num_edges(), 8); + + assert_eq!(target.edge_weight(0, 1), Some(&5)); + assert_eq!(target.edge_weight(2, 3), Some(&5)); + assert_eq!(target.edge_weight(4, 5), Some(&5)); + assert_eq!(target.edge_weight(0, 2), Some(&2)); + assert_eq!(target.edge_weight(2, 4), Some(&1)); + assert_eq!(target.edge_weight(0, 4), Some(&1)); + assert_eq!(target.edge_weight(2, 5), Some(&1)); + assert_eq!(target.edge_weight(0, 5), Some(&1)); + + assert_eq!( + target.evaluate(&super::ISSUE_EXAMPLE_TARGET_CONFIG), + Max(Some(19)) + ); +} + +#[test] +fn test_naesatisfiability_to_maxcut_extract_solution_reads_positive_literal_vertices() { + let reduction = ReduceTo::>::reduce_to(&super::issue_example()); + + assert_eq!( + reduction.extract_solution(&super::ISSUE_EXAMPLE_TARGET_CONFIG), + super::ISSUE_EXAMPLE_SOURCE_CONFIG, + ); +} + +#[test] +#[should_panic(expected = "requires every clause to have exactly 3 literals")] +fn test_naesatisfiability_to_maxcut_rejects_non_3sat_instances() { + let source = NAESatisfiability::new(2, vec![CNFClause::new(vec![1, 2])]); + + let _ = ReduceTo::>::reduce_to(&source); +} + +#[test] +fn test_naesatisfiability_to_maxcut_penalty_overflow_panics() { + let result = + std::panic::catch_unwind(|| super::variable_gadget_weight((i32::MAX as usize) / 2 + 1)); + + assert!(result.is_err()); +} + +#[cfg(feature = "example-db")] +#[test] +fn test_naesatisfiability_to_maxcut_canonical_example_spec() { + let specs = crate::rules::naesatisfiability_maxcut::canonical_rule_example_specs(); + assert_eq!(specs.len(), 1); + + let example = (specs[0].build)(); + assert_eq!(example.source.problem, "NAESatisfiability"); + assert_eq!(example.target.problem, "MaxCut"); + assert_eq!(example.solutions.len(), 1); + + let pair = &example.solutions[0]; + assert_eq!(pair.source_config, super::ISSUE_EXAMPLE_SOURCE_CONFIG); + assert_eq!(pair.target_config, super::ISSUE_EXAMPLE_TARGET_CONFIG); +} From 17ee260a3996933d86669c9b0e4bc0b81c200f10 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 4 Apr 2026 09:35:57 +0800 Subject: [PATCH 14/25] =?UTF-8?q?feat:=20add=20IsomorphicSpanningTree=20mo?= =?UTF-8?q?del=20and=20HamPath=20=E2=86=92=20IST=20rule=20(#912)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds IsomorphicSpanningTree graph model (Value=Or, permutation-based isomorphism check) and the identity reduction from HamiltonianPath with tree T=P_n. A spanning tree isomorphic to a path is exactly a Hamiltonian path. Includes CLI support, updated ILP rule, and tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/models/graph/isomorphic_spanning_tree.rs | 150 ++++++++++-------- .../hamiltonianpath_isomorphicspanningtree.rs | 86 ++++++++++ src/rules/isomorphicspanningtree_ilp.rs | 8 +- src/rules/mod.rs | 2 + .../models/graph/isomorphic_spanning_tree.rs | 35 ++-- .../hamiltonianpath_isomorphicspanningtree.rs | 60 +++++++ 6 files changed, 261 insertions(+), 80 deletions(-) create mode 100644 src/rules/hamiltonianpath_isomorphicspanningtree.rs create mode 100644 src/unit_tests/rules/hamiltonianpath_isomorphicspanningtree.rs diff --git a/src/models/graph/isomorphic_spanning_tree.rs b/src/models/graph/isomorphic_spanning_tree.rs index b340b6c59..ecf69e417 100644 --- a/src/models/graph/isomorphic_spanning_tree.rs +++ b/src/models/graph/isomorphic_spanning_tree.rs @@ -4,9 +4,10 @@ //! contains a spanning tree isomorphic to T. This is a classical NP-complete //! problem (Garey & Johnson, ND8) that generalizes Hamiltonian Path. -use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; use crate::topology::{Graph, SimpleGraph}; use crate::traits::Problem; +use crate::variant::VariantParam; use serde::{Deserialize, Serialize}; inventory::submit! { @@ -14,11 +15,13 @@ inventory::submit! { name: "IsomorphicSpanningTree", display_name: "Isomorphic Spanning Tree", aliases: &[], - dimensions: &[], + dimensions: &[ + VariantDimension::new("graph", "SimpleGraph", &["SimpleGraph"]), + ], module_path: module_path!(), description: "Does graph G contain a spanning tree isomorphic to tree T?", fields: &[ - FieldInfo { name: "graph", type_name: "SimpleGraph", description: "The host graph G" }, + FieldInfo { name: "graph", type_name: "G", description: "The host graph G" }, FieldInfo { name: "tree", type_name: "SimpleGraph", description: "The target tree T (must be a tree with |V(T)| = |V(G)|)" }, ], } @@ -30,6 +33,9 @@ inventory::submit! { /// |V| = |V_T|, determine if there exists a bijection π: V_T → V such that /// for every edge {u, v} in E_T, {π(u), π(v)} is an edge in E. /// +/// The configuration encodes an isomorphism as a permutation of the vertices of +/// `graph`: `config[i]` is the graph vertex that tree vertex `i` maps to. +/// /// # Example /// /// ``` @@ -48,34 +54,37 @@ inventory::submit! { /// assert!(sol.is_some()); /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct IsomorphicSpanningTree { - graph: SimpleGraph, +#[serde(bound(deserialize = "G: serde::Deserialize<'de>"))] +pub struct IsomorphicSpanningTree { + graph: G, tree: SimpleGraph, } -impl IsomorphicSpanningTree { +impl IsomorphicSpanningTree { /// Create a new IsomorphicSpanningTree problem. /// /// # Panics /// /// Panics if |V(G)| != |V(T)| or if T is not a tree (not connected or /// wrong number of edges). - pub fn new(graph: SimpleGraph, tree: SimpleGraph) -> Self { + pub fn new(graph: G, tree: SimpleGraph) -> Self { let n = graph.num_vertices(); assert_eq!( n, tree.num_vertices(), "graph and tree must have the same number of vertices" ); - if n > 0 { - assert_eq!(tree.num_edges(), n - 1, "tree must have exactly n-1 edges"); - assert!(Self::is_connected(&tree), "tree must be connected"); - } + assert_eq!( + tree.num_edges(), + n.saturating_sub(1), + "tree must have exactly n-1 edges" + ); + assert!(is_connected(&tree), "tree must be connected"); Self { graph, tree } } /// Get a reference to the host graph. - pub fn graph(&self) -> &SimpleGraph { + pub fn graph(&self) -> &G { &self.graph } @@ -90,79 +99,86 @@ impl IsomorphicSpanningTree { } /// Get the number of edges in the host graph. - pub fn num_graph_edges(&self) -> usize { + pub fn num_edges(&self) -> usize { self.graph.num_edges() } - /// Get the number of edges in the target tree. - pub fn num_tree_edges(&self) -> usize { - self.tree.num_edges() - } - - /// Check if a graph is connected using BFS. - fn is_connected(graph: &SimpleGraph) -> bool { - let n = graph.num_vertices(); - if n == 0 { - return true; - } - let mut visited = vec![false; n]; - let mut queue = std::collections::VecDeque::new(); - visited[0] = true; - queue.push_back(0); - let mut count = 1; - while let Some(v) = queue.pop_front() { - for u in graph.neighbors(v) { - if !visited[u] { - visited[u] = true; - count += 1; - queue.push_back(u); - } - } - } - count == n + /// Get the edges of the target tree. + pub fn tree_edges(&self) -> Vec<(usize, usize)> { + self.tree.edges() } } -impl Problem for IsomorphicSpanningTree { +impl Problem for IsomorphicSpanningTree +where + G: Graph + VariantParam, +{ const NAME: &'static str = "IsomorphicSpanningTree"; type Value = crate::types::Or; + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![G] + } + fn dims(&self) -> Vec { - let n = self.graph.num_vertices(); - vec![n; n] + vec![self.graph.num_vertices(); self.graph.num_vertices()] } fn evaluate(&self, config: &[usize]) -> crate::types::Or { - crate::types::Or({ - let n = self.graph.num_vertices(); - if config.len() != n { - return crate::types::Or(false); - } + crate::types::Or(is_valid_isomorphic_spanning_tree( + &self.graph, + &self.tree, + config, + )) + } +} - // Check that config is a valid permutation: all values in 0..n, all distinct - let mut seen = vec![false; n]; - for &v in config { - if v >= n || seen[v] { - return crate::types::Or(false); - } - seen[v] = true; - } +fn is_valid_isomorphic_spanning_tree( + graph: &G, + tree: &SimpleGraph, + config: &[usize], +) -> bool { + let n = graph.num_vertices(); + if config.len() != n { + return false; + } - // Check that every tree edge maps to a graph edge under the permutation - // config[i] = π(i): tree vertex i maps to graph vertex config[i] - for (u, v) in self.tree.edges() { - if !self.graph.has_edge(config[u], config[v]) { - return crate::types::Or(false); - } - } + let mut seen = vec![false; n]; + for &v in config { + if v >= n || seen[v] { + return false; + } + seen[v] = true; + } + + tree.edges() + .into_iter() + .all(|(u, v)| graph.has_edge(config[u], config[v])) +} - true - }) +fn is_connected(graph: &SimpleGraph) -> bool { + let n = graph.num_vertices(); + if n == 0 { + return true; } - fn variant() -> Vec<(&'static str, &'static str)> { - crate::variant_params![] + let mut visited = vec![false; n]; + let mut queue = std::collections::VecDeque::new(); + visited[0] = true; + queue.push_back(0); + let mut count = 1; + + while let Some(v) = queue.pop_front() { + for u in graph.neighbors(v) { + if !visited[u] { + visited[u] = true; + count += 1; + queue.push_back(u); + } + } } + + count == n } #[cfg(feature = "example-db")] @@ -179,7 +195,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec "factorial(num_vertices)", + default IsomorphicSpanningTree => "num_vertices^num_vertices", } #[cfg(test)] diff --git a/src/rules/hamiltonianpath_isomorphicspanningtree.rs b/src/rules/hamiltonianpath_isomorphicspanningtree.rs new file mode 100644 index 000000000..c1dcefabe --- /dev/null +++ b/src/rules/hamiltonianpath_isomorphicspanningtree.rs @@ -0,0 +1,86 @@ +//! Reduction from HamiltonianPath to IsomorphicSpanningTree. +//! +//! A Hamiltonian path is exactly a spanning copy of the path graph `P_n`. + +use crate::models::graph::{HamiltonianPath, IsomorphicSpanningTree}; +use crate::reduction; +use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::topology::SimpleGraph; + +/// Result of reducing HamiltonianPath to IsomorphicSpanningTree. +#[derive(Debug, Clone)] +pub struct ReductionHamiltonianPathToIsomorphicSpanningTree { + target: IsomorphicSpanningTree, +} + +impl ReductionResult for ReductionHamiltonianPathToIsomorphicSpanningTree { + type Source = HamiltonianPath; + type Target = IsomorphicSpanningTree; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + target_solution.to_vec() + } +} + +#[reduction( + overhead = { + num_vertices = "num_vertices", + num_edges = "num_edges", + } +)] +impl ReduceTo> for HamiltonianPath { + type Result = ReductionHamiltonianPathToIsomorphicSpanningTree; + + fn reduce_to(&self) -> Self::Result { + let n = self.num_vertices(); + let tree = SimpleGraph::new(n, (0..n.saturating_sub(1)).map(|i| (i, i + 1)).collect()); + let target = IsomorphicSpanningTree::new(self.graph().clone(), tree); + ReductionHamiltonianPathToIsomorphicSpanningTree { target } + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + use crate::export::SolutionPair; + + fn source_example() -> HamiltonianPath { + HamiltonianPath::new(SimpleGraph::new( + 6, + vec![ + (0, 1), + (0, 2), + (1, 3), + (2, 3), + (3, 4), + (3, 5), + (4, 2), + (5, 1), + ], + )) + } + + vec![crate::example_db::specs::RuleExampleSpec { + id: "hamiltonianpath_to_isomorphicspanningtree", + build: || { + let source_config = vec![0, 2, 4, 3, 1, 5]; + crate::example_db::specs::rule_example_with_witness::< + _, + IsomorphicSpanningTree, + >( + source_example(), + SolutionPair { + source_config: source_config.clone(), + target_config: source_config, + }, + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/hamiltonianpath_isomorphicspanningtree.rs"] +mod tests; diff --git a/src/rules/isomorphicspanningtree_ilp.rs b/src/rules/isomorphicspanningtree_ilp.rs index 9eefbe6a5..dad7a8126 100644 --- a/src/rules/isomorphicspanningtree_ilp.rs +++ b/src/rules/isomorphicspanningtree_ilp.rs @@ -7,7 +7,7 @@ use crate::models::algebraic::{LinearConstraint, ObjectiveSense, ILP}; use crate::models::graph::IsomorphicSpanningTree; use crate::reduction; use crate::rules::traits::{ReduceTo, ReductionResult}; -use crate::topology::Graph; +use crate::topology::{Graph, SimpleGraph}; #[derive(Debug, Clone)] pub struct ReductionISTToILP { @@ -16,7 +16,7 @@ pub struct ReductionISTToILP { } impl ReductionResult for ReductionISTToILP { - type Source = IsomorphicSpanningTree; + type Source = IsomorphicSpanningTree; type Target = ILP; fn target_problem(&self) -> &ILP { @@ -39,10 +39,10 @@ impl ReductionResult for ReductionISTToILP { #[reduction( overhead = { num_vars = "num_vertices * num_vertices", - num_constraints = "2 * num_vertices + 2 * num_tree_edges * num_vertices * num_vertices", + num_constraints = "2 * num_vertices + 2 * (num_vertices - 1) * num_vertices * num_vertices", } )] -impl ReduceTo> for IsomorphicSpanningTree { +impl ReduceTo> for IsomorphicSpanningTree { type Result = ReductionISTToILP; fn reduce_to(&self) -> Self::Result { diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 3341e79ef..5ebfaf6a7 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -17,6 +17,7 @@ pub(crate) mod graphpartitioning_maxcut; pub(crate) mod graphpartitioning_qubo; pub(crate) mod hamiltoniancircuit_travelingsalesman; pub(crate) mod hamiltonianpath_degreeconstrainedspanningtree; +pub(crate) mod hamiltonianpath_isomorphicspanningtree; mod kcoloring_casts; pub(crate) mod kcoloring_partitionintocliques; mod knapsack_qubo; @@ -260,6 +261,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec = + IsomorphicSpanningTree::new(graph.clone(), tree.clone()); assert_eq!(problem.dims(), vec![3, 3, 3]); + assert_eq!(problem.graph(), &graph); + assert_eq!(problem.tree(), &tree); assert_eq!(problem.num_vertices(), 3); - assert_eq!(problem.num_graph_edges(), 3); - assert_eq!(problem.num_tree_edges(), 2); - assert_eq!(IsomorphicSpanningTree::NAME, "IsomorphicSpanningTree"); + assert_eq!(problem.num_edges(), 3); + assert_eq!(problem.tree_edges(), tree.edges()); + assert_eq!( + as Problem>::NAME, + "IsomorphicSpanningTree" + ); } #[test] @@ -111,11 +117,11 @@ fn test_isomorphicspanningtree_serialization() { let problem = IsomorphicSpanningTree::new(graph, tree); let json = serde_json::to_string(&problem).unwrap(); - let deserialized: IsomorphicSpanningTree = serde_json::from_str(&json).unwrap(); + let deserialized: IsomorphicSpanningTree = serde_json::from_str(&json).unwrap(); assert_eq!(deserialized.num_vertices(), 3); - assert_eq!(deserialized.num_graph_edges(), 3); - assert_eq!(deserialized.num_tree_edges(), 2); + assert_eq!(deserialized.num_edges(), 3); + assert_eq!(deserialized.tree_edges(), vec![(0, 1), (1, 2)]); // Verify same evaluation assert!(deserialized.evaluate(&[0, 1, 2])); } @@ -170,7 +176,10 @@ fn test_isomorphicspanningtree_paper_example() { #[test] fn test_isomorphicspanningtree_variant() { - assert!(IsomorphicSpanningTree::variant().is_empty()); + assert_eq!( + as Problem>::variant(), + vec![("graph", "SimpleGraph")] + ); } #[test] @@ -189,3 +198,11 @@ fn test_isomorphicspanningtree_not_a_tree() { let tree = SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]); IsomorphicSpanningTree::new(graph, tree); } + +#[test] +#[should_panic(expected = "tree must be connected")] +fn test_isomorphicspanningtree_disconnected_tree() { + let graph = SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3), (0, 3)]); + let tree = SimpleGraph::new(4, vec![(0, 1), (1, 2), (0, 2)]); + IsomorphicSpanningTree::new(graph, tree); +} diff --git a/src/unit_tests/rules/hamiltonianpath_isomorphicspanningtree.rs b/src/unit_tests/rules/hamiltonianpath_isomorphicspanningtree.rs new file mode 100644 index 000000000..1108cdc6f --- /dev/null +++ b/src/unit_tests/rules/hamiltonianpath_isomorphicspanningtree.rs @@ -0,0 +1,60 @@ +use super::*; +use crate::models::graph::{HamiltonianPath, IsomorphicSpanningTree}; +use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; +use crate::rules::{ReduceTo, ReductionResult}; +use crate::topology::SimpleGraph; +use crate::traits::Problem; + +#[test] +fn test_hamiltonianpath_to_isomorphicspanningtree_structure() { + let source = HamiltonianPath::new(SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3), (0, 2)])); + let reduction: ReductionHamiltonianPathToIsomorphicSpanningTree = + ReduceTo::>::reduce_to(&source); + let target = reduction.target_problem(); + + assert_eq!(target.graph(), source.graph()); + assert_eq!(target.num_vertices(), source.num_vertices()); + assert_eq!(target.num_edges(), source.num_edges()); + assert_eq!(target.tree(), &SimpleGraph::path(source.num_vertices())); + assert_eq!(target.tree_edges(), vec![(0, 1), (1, 2), (2, 3)]); +} + +#[test] +fn test_hamiltonianpath_to_isomorphicspanningtree_closed_loop() { + let source = HamiltonianPath::new(SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3), (0, 2)])); + let reduction: ReductionHamiltonianPathToIsomorphicSpanningTree = + ReduceTo::>::reduce_to(&source); + + assert_satisfaction_round_trip_from_satisfaction_target( + &source, + &reduction, + "HamiltonianPath->IsomorphicSpanningTree closed loop", + ); +} + +#[test] +fn test_hamiltonianpath_to_isomorphicspanningtree_extract_solution_is_identity_mapping() { + let source = HamiltonianPath::new(SimpleGraph::new( + 6, + vec![ + (0, 1), + (0, 2), + (1, 3), + (2, 3), + (3, 4), + (3, 5), + (4, 2), + (5, 1), + ], + )); + let reduction: ReductionHamiltonianPathToIsomorphicSpanningTree = + ReduceTo::>::reduce_to(&source); + let target_solution = vec![0, 2, 4, 3, 1, 5]; + + assert!(reduction.target_problem().evaluate(&target_solution)); + + let extracted = reduction.extract_solution(&target_solution); + + assert_eq!(extracted, target_solution); + assert!(source.evaluate(&extracted)); +} From a5e078513aa7f13d6e1d23ef1b058d4c785431cd Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 4 Apr 2026 09:56:27 +0800 Subject: [PATCH 15/25] =?UTF-8?q?feat:=20add=20AlgebraicEquationsOverGF2?= =?UTF-8?q?=20model=20and=20X3C=20=E2=86=92=20AEGF2=20rule=20(#859)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds AlgebraicEquationsOverGF2 model (Value=Or, polynomial system over GF(2) with XOR/AND evaluation) and the Fraenkel-Yesha 1977 reduction from ExactCoverBy3Sets. Linear equations enforce odd-cover per element; pairwise product equations forbid double-cover. Together they force exactly-one cover. Includes CLI support, paper entries, and tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/paper/reductions.typ | 53 ++++++++ problemreductions-cli/src/cli.rs | 6 +- problemreductions-cli/src/commands/create.rs | 90 +++++++++++- src/lib.rs | 2 +- .../algebraic/algebraic_equations_over_gf2.rs | 128 ++++++++++++++++++ src/models/algebraic/mod.rs | 4 + src/models/mod.rs | 7 +- ...tcoverby3sets_algebraicequationsovergf2.rs | 82 +++++++++++ src/rules/mod.rs | 2 + .../algebraic/algebraic_equations_over_gf2.rs | 50 +++++++ ...tcoverby3sets_algebraicequationsovergf2.rs | 48 +++++++ 11 files changed, 464 insertions(+), 8 deletions(-) create mode 100644 src/models/algebraic/algebraic_equations_over_gf2.rs create mode 100644 src/rules/exactcoverby3sets_algebraicequationsovergf2.rs create mode 100644 src/unit_tests/models/algebraic/algebraic_equations_over_gf2.rs create mode 100644 src/unit_tests/rules/exactcoverby3sets_algebraicequationsovergf2.rs diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 0926d1484..641f1d912 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -136,6 +136,7 @@ "CapacityAssignment": [Capacity Assignment], "ConsistencyOfDatabaseFrequencyTables": [Consistency of Database Frequency Tables], "ClosestVectorProblem": [Closest Vector Problem], + "AlgebraicEquationsOverGF2": [Algebraic Equations over GF(2)], "IntegerExpressionMembership": [Integer Expression Membership], "MinimumWeightSolutionToLinearEquations": [Minimum-Weight Solution to Linear Equations], "ConsecutiveSets": [Consecutive Sets], @@ -3532,6 +3533,28 @@ A classical NP-complete problem from Garey and Johnson @garey1979[Ch.~3, p.~76], ] } +#{ + let x = load-model-example("AlgebraicEquationsOverGF2") + let config = x.optimal_config + [ + #problem-def("AlgebraicEquationsOverGF2")[ + Given a finite system of polynomial equations over $GF(2)$ in variables $x_1, dots, x_n$, determine whether there exists a binary vector $x in {0,1}^n$ satisfying every equation. + ][ + Each equation is represented as an XOR-sum of monomials, where each monomial is the product of a subset of variables and the empty product denotes the constant $1$. This makes the model expressive enough for both parity constraints and quadratic exclusion constraints, which is exactly the combination needed in the X3C reduction below. + + The obvious exact algorithm enumerates all $2^n$ binary assignments and evaluates every polynomial over $GF(2)$, yielding $O^*(2^n)$ time. + + *Example.* The canonical instance consists of the equations $x_1 + 1 = 0$, $x_2 = 0$, and $x_3 + 1 = 0$. The assignment $(#config.at(0), #config.at(1), #config.at(2))$ satisfies all three equations, so the instance is YES. + + #pred-commands( + "pred create --example " + problem-spec(x) + " -o algebraic-equations-over-gf2.json", + "pred solve algebraic-equations-over-gf2.json", + "pred evaluate algebraic-equations-over-gf2.json --config " + x.optimal_config.map(str).join(","), + ) + ] + ] +} + #{ let x = load-model-example("SimultaneousIncongruences") let moduli = x.instance.moduli @@ -8564,6 +8587,36 @@ The following reductions to Integer Linear Programming are straightforward formu _Solution extraction._ Read the binary vector directly: choose $S_j$ in the source witness iff $x_j = 1$ in the target witness. ] +#let x3c_gf2 = load-example("ExactCoverBy3Sets", "AlgebraicEquationsOverGF2") +#let x3c_gf2_sol = x3c_gf2.solutions.at(0) +#reduction-rule("ExactCoverBy3Sets", "AlgebraicEquationsOverGF2", + example: true, + example-caption: [Canonical X3C to GF(2) system ($|U| = #x3c_gf2.source.instance.universe_size$, $n = #x3c_gf2.source.instance.subsets.len()$)], + extra: [ + #pred-commands( + "pred create --example " + problem-spec(x3c_gf2.source) + " -o x3c.json", + "pred reduce x3c.json --to " + target-spec(x3c_gf2) + " -o bundle.json", + "pred solve bundle.json", + "pred evaluate x3c.json --config " + x3c_gf2_sol.source_config.map(str).join(","), + ) + Source: $|U| = #x3c_gf2.source.instance.universe_size$, $q = #(int(x3c_gf2.source.instance.universe_size / 3))$, and $n = #x3c_gf2.source.instance.subsets.len()$ \ + Target: #x3c_gf2.target.instance.equations.len() equations over #x3c_gf2.target.instance.num_vars variables \ + Canonical witness is preserved exactly: $(#x3c_gf2_sol.source_config.map(str).join(", ")) -> (#x3c_gf2_sol.target_config.map(str).join(", "))$ #sym.checkmark + ], +)[ + This reduction introduces one binary variable $x_j$ for each source set $C_j$. For every universe element $u_i$, it adds one parity equation forcing an odd number of selected incident sets and one quadratic equation for every incident pair, forbidding two selected sets from covering the same element simultaneously. +][ + _Construction._ Let $U = {u_0, dots, u_(3q-1)}$ and let the 3-sets be $C_0, dots, C_(n-1)$. Introduce binary variables $x_0, dots, x_(n-1)$, where $x_j = 1$ means that $C_j$ is selected. For each element $u_i$, let $S_i = {j : u_i in C_j}$. Add the $GF(2)$ equation + $ + sum_(j in S_i) x_j + 1 = 0, + $ + and for every distinct pair $j, k in S_i$ add the quadratic equation $x_j x_k = 0$. + + _Correctness._ ($arrow.r.double$) If $cal(C)'$ is an exact cover, set $x_j = 1$ exactly when $C_j in cal(C)'$. Every element $u_i$ lies in exactly one selected set, so the linear equation for $u_i$ evaluates to $1 + 1 = 0$ in $GF(2)$, and every quadratic equation $x_j x_k = 0$ holds because no two selected sets overlap on $u_i$. ($arrow.l.double$) Conversely, suppose the target system is satisfied. For each $u_i$, the linear equation implies that an odd number of variables in $S_i$ is set to $1$, while the quadratic equations force at most one of them to be $1$. Therefore exactly one set containing $u_i$ is selected. Hence every universe element is covered exactly once, so the selected 3-sets form an exact cover. + + _Solution extraction._ Read the binary vector directly: choose $C_j$ in the source witness iff $x_j = 1$ in the target witness. +] + #reduction-rule("NAESatisfiability", "ILP")[ Each clause must have at least one true and at least one false literal, encoded as a pair of linear inequalities per clause. ][ diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index d385406c8..f51aaabed 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -224,6 +224,7 @@ Flags by problem type: NonTautology --num-vars, --disjuncts KSAT --num-vars, --clauses [--k] SimultaneousIncongruences --moduli, --residues, --bound + AlgebraicEquationsOverGF2 --num-vars, --equations (JSON 3D index array) QUBO --matrix MinimumWeightSolutionToLinearEquations --matrix, --rhs, --bound SpinGlass --graph, --couplings, --fields @@ -431,9 +432,12 @@ pub struct CreateArgs { /// Disjuncts for DNF problems (semicolon-separated, e.g., "1,2;-1,3") #[arg(long)] pub disjuncts: Option, - /// Number of variables (for SAT/KSAT) + /// Number of variables (for SAT/KSAT/AlgebraicEquationsOverGF2) #[arg(long)] pub num_vars: Option, + /// Polynomial equations as a JSON 3D array. Example: '[[[0,1],[2],[]],[[1],[]]]' + #[arg(long)] + pub equations: Option, /// Moduli for SimultaneousIncongruences (comma-separated, e.g., "2,3,5,7") #[arg(long)] pub moduli: Option, diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 1bf8ce4ab..c2dc94de0 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -8,9 +8,10 @@ use crate::util; use anyhow::{bail, Context, Result}; use problemreductions::export::{ModelExample, ProblemRef, ProblemSide, RuleExample}; use problemreductions::models::algebraic::{ - ClosestVectorProblem, ConsecutiveBlockMinimization, ConsecutiveOnesMatrixAugmentation, - ConsecutiveOnesSubmatrix, IntegerExpressionMembership, MinimumWeightSolutionToLinearEquations, - SimultaneousIncongruences, SparseMatrixCompression, BMF, + AlgebraicEquationsOverGF2, ClosestVectorProblem, ConsecutiveBlockMinimization, + ConsecutiveOnesMatrixAugmentation, ConsecutiveOnesSubmatrix, IntegerExpressionMembership, + MinimumWeightSolutionToLinearEquations, SimultaneousIncongruences, SparseMatrixCompression, + BMF, }; use problemreductions::models::formula::Quantifier; use problemreductions::models::graph::{ @@ -74,6 +75,7 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool { && args.clauses.is_none() && args.disjuncts.is_none() && args.num_vars.is_none() + && args.equations.is_none() && args.moduli.is_none() && args.residues.is_none() && args.matrix.is_none() @@ -612,6 +614,7 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { } "KSatisfiability" => "--num-vars 3 --clauses \"1,2,3;-1,2,-3\" --k 3", "SimultaneousIncongruences" => "--moduli 2,3,5,7 --residues 0,1,2,3 --bound 210", + "AlgebraicEquationsOverGF2" => "--num-vars 3 --equations '[[[0,1],[2],[]],[[1],[]]]'", "QUBO" => "--matrix \"1,0.5;0.5,2\"", "IntegerExpressionMembership" => "--choices \"1,2;1,6;1,7;1,9\" --target 15", "MinimumWeightSolutionToLinearEquations" => { @@ -865,6 +868,7 @@ fn help_flag_hint( ("ConsecutiveOnesSubmatrix", "matrix") => "semicolon-separated 0/1 rows: \"1,0;0,1\"", ("SimultaneousIncongruences", "moduli") => "comma-separated integers: 2,3,5,7", ("SimultaneousIncongruences", "residues") => "comma-separated integers: 0,1,2,3", + ("AlgebraicEquationsOverGF2", "equations") => "JSON equations: '[[[0,1],[2],[]],[[1],[]]]'", ("MinimumWeightSolutionToLinearEquations", "matrix") => { "semicolon-separated integer rows: \"1,0,1;0,1,1\"" } @@ -2247,6 +2251,29 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } + // AlgebraicEquationsOverGF2 + "AlgebraicEquationsOverGF2" => { + let usage = "Usage: pred create AlgebraicEquationsOverGF2 --num-vars 3 --equations '[[[0,1],[2],[]],[[1],[]]]'"; + let num_vars = args.num_vars.ok_or_else(|| { + anyhow::anyhow!( + "AlgebraicEquationsOverGF2 requires --num-vars and --equations\n\n{usage}" + ) + })?; + let equations_str = args.equations.as_deref().ok_or_else(|| { + anyhow::anyhow!("AlgebraicEquationsOverGF2 requires --equations\n\n{usage}") + })?; + let equations: Vec>> = + serde_json::from_str(equations_str).map_err(|err| { + anyhow::anyhow!( + "AlgebraicEquationsOverGF2 requires --equations as a JSON 3D array of variable indices (e.g., '[[[0,1],[2],[]],[[1],[]]]')\n\n{usage}\n\nFailed to parse --equations: {err}" + ) + })?; + ( + ser(AlgebraicEquationsOverGF2::new(num_vars, equations))?, + resolved_variant.clone(), + ) + } + // IntegerExpressionMembership "IntegerExpressionMembership" => { let choices_str = args.choices.as_deref().ok_or_else(|| { @@ -7712,6 +7739,7 @@ mod tests { clauses: None, disjuncts: None, num_vars: None, + equations: None, moduli: None, residues: None, matrix: None, @@ -8762,6 +8790,62 @@ mod tests { assert!(err.contains("Usage: pred create MinimumWeightSolutionToLinearEquations")); } + #[test] + fn test_create_algebraic_equations_over_gf2_json() { + use crate::dispatch::ProblemJsonOutput; + + let mut args = empty_args(); + args.problem = Some("AlgebraicEquationsOverGF2".to_string()); + args.num_vars = Some(3); + args.equations = Some("[[[0,1],[2],[]],[[1],[]]]".to_string()); + + let output_path = + std::env::temp_dir().join(format!("aegf2-create-{}.json", std::process::id())); + let out = OutputConfig { + output: Some(output_path.clone()), + quiet: true, + json: false, + auto_json: false, + }; + + create(&args, &out).unwrap(); + + let json = std::fs::read_to_string(&output_path).unwrap(); + let created: ProblemJsonOutput = serde_json::from_str(&json).unwrap(); + assert_eq!(created.problem_type, "AlgebraicEquationsOverGF2"); + assert!(created.variant.is_empty()); + assert_eq!( + created.data, + serde_json::json!({ + "num_vars": 3, + "equations": [ + [[0, 1], [2], []], + [[1], []], + ], + }) + ); + + let _ = std::fs::remove_file(output_path); + } + + #[test] + fn test_create_algebraic_equations_over_gf2_requires_equations() { + let mut args = empty_args(); + args.problem = Some("AlgebraicEquationsOverGF2".to_string()); + args.num_vars = Some(3); + + let out = OutputConfig { + output: None, + quiet: true, + json: false, + auto_json: false, + }; + + let err = create(&args, &out).unwrap_err().to_string(); + assert!(err.contains("AlgebraicEquationsOverGF2 requires --equations")); + assert!(err.contains("Usage: pred create AlgebraicEquationsOverGF2")); + } + #[test] fn test_create_consecutive_ones_matrix_augmentation_json() { use crate::dispatch::ProblemJsonOutput; diff --git a/src/lib.rs b/src/lib.rs index 4d8b89a38..7e7a6e5f8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -43,7 +43,7 @@ pub mod variant; pub mod prelude { // Problem types pub use crate::models::algebraic::{ - ConsecutiveOnesMatrixAugmentation, IntegerExpressionMembership, + AlgebraicEquationsOverGF2, ConsecutiveOnesMatrixAugmentation, IntegerExpressionMembership, MinimumWeightSolutionToLinearEquations, QuadraticAssignment, SimultaneousIncongruences, SparseMatrixCompression, BMF, QUBO, }; diff --git a/src/models/algebraic/algebraic_equations_over_gf2.rs b/src/models/algebraic/algebraic_equations_over_gf2.rs new file mode 100644 index 000000000..427e83f84 --- /dev/null +++ b/src/models/algebraic/algebraic_equations_over_gf2.rs @@ -0,0 +1,128 @@ +//! Algebraic Equations over GF(2). +//! +//! Given Boolean variables and a system of polynomial equations over GF(2), +//! determine whether there is a binary assignment satisfying every equation. + +use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry}; +use crate::traits::Problem; +use crate::types::Or; +use serde::{Deserialize, Serialize}; + +inventory::submit! { + ProblemSchemaEntry { + name: "AlgebraicEquationsOverGF2", + display_name: "Algebraic Equations over GF(2)", + aliases: &[], + dimensions: &[], + module_path: module_path!(), + description: "Determine whether a system of polynomial equations over GF(2) has a satisfying binary assignment", + fields: &[ + FieldInfo { name: "num_vars", type_name: "usize", description: "Number of binary variables" }, + FieldInfo { name: "equations", type_name: "Vec>>", description: "Equations represented as XORs of AND-terms over variable indices; an empty term denotes the constant 1" }, + ], + } +} + +inventory::submit! { + ProblemSizeFieldEntry { + name: "AlgebraicEquationsOverGF2", + fields: &["num_vars", "num_equations"], + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AlgebraicEquationsOverGF2 { + num_vars: usize, + equations: Vec>>, +} + +impl AlgebraicEquationsOverGF2 { + pub fn new(num_vars: usize, equations: Vec>>) -> Self { + for (equation_index, equation) in equations.iter().enumerate() { + for (term_index, term) in equation.iter().enumerate() { + for &var in term { + assert!( + var < num_vars, + "Equation {} term {} references variable {} outside 0..{}", + equation_index, + term_index, + var, + num_vars + ); + } + } + } + + Self { + num_vars, + equations, + } + } + + pub fn num_vars(&self) -> usize { + self.num_vars + } + + pub fn equations(&self) -> &[Vec>] { + &self.equations + } + + pub fn num_equations(&self) -> usize { + self.equations.len() + } +} + +impl Problem for AlgebraicEquationsOverGF2 { + const NAME: &'static str = "AlgebraicEquationsOverGF2"; + type Value = Or; + + fn dims(&self) -> Vec { + vec![2; self.num_vars] + } + + fn evaluate(&self, config: &[usize]) -> Or { + if config.len() != self.num_vars || config.iter().any(|&value| value > 1) { + return Or(false); + } + + let all_equations_vanish = self.equations.iter().all(|equation| { + let value = equation.iter().fold(0usize, |acc, term| { + let term_value = if term.is_empty() { + 1 + } else { + term.iter() + .fold(1usize, |product, &var| product * config[var]) + }; + acc ^ term_value + }); + value == 0 + }); + + Or(all_equations_vanish) + } + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![] + } +} + +crate::declare_variants! { + default AlgebraicEquationsOverGF2 => "2^num_vars", +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "algebraic_equations_over_gf2", + instance: Box::new(AlgebraicEquationsOverGF2::new( + 3, + vec![vec![vec![0], vec![]], vec![vec![1]], vec![vec![2], vec![]]], + )), + optimal_config: vec![1, 0, 1], + optimal_value: serde_json::json!(true), + }] +} + +#[cfg(test)] +#[path = "../../unit_tests/models/algebraic/algebraic_equations_over_gf2.rs"] +mod tests; diff --git a/src/models/algebraic/mod.rs b/src/models/algebraic/mod.rs index 01379b193..fc43622c0 100644 --- a/src/models/algebraic/mod.rs +++ b/src/models/algebraic/mod.rs @@ -1,6 +1,7 @@ //! Algebraic problems. //! //! Problems whose input is a matrix, linear system, or lattice: +//! - [`AlgebraicEquationsOverGF2`]: Polynomial equations over GF(2) //! - [`QUBO`]: Quadratic Unconstrained Binary Optimization //! - [`ILP`]: Integer Linear Programming //! - [`ClosestVectorProblem`]: Closest Vector Problem (minimize lattice distance) @@ -13,6 +14,7 @@ //! - [`SparseMatrixCompression`]: Sparse Matrix Compression by row overlay //! - [`SimultaneousIncongruences`]: Find an integer avoiding a family of residue classes +pub(crate) mod algebraic_equations_over_gf2; pub(crate) mod bmf; pub(crate) mod closest_vector_problem; pub(crate) mod consecutive_block_minimization; @@ -26,6 +28,7 @@ pub(crate) mod qubo; pub(crate) mod simultaneous_incongruences; pub(crate) mod sparse_matrix_compression; +pub use algebraic_equations_over_gf2::AlgebraicEquationsOverGF2; pub use bmf::BMF; pub use closest_vector_problem::{ClosestVectorProblem, VarBounds}; pub use consecutive_block_minimization::ConsecutiveBlockMinimization; @@ -42,6 +45,7 @@ pub use sparse_matrix_compression::SparseMatrixCompression; #[cfg(feature = "example-db")] pub(crate) fn canonical_model_example_specs() -> Vec { let mut specs = Vec::new(); + specs.extend(algebraic_equations_over_gf2::canonical_model_example_specs()); specs.extend(qubo::canonical_model_example_specs()); specs.extend(ilp::canonical_model_example_specs()); specs.extend(closest_vector_problem::canonical_model_example_specs()); diff --git a/src/models/mod.rs b/src/models/mod.rs index f40f0610d..bf4dd0939 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -10,9 +10,10 @@ pub mod set; // Re-export commonly used types pub use algebraic::{ - ClosestVectorProblem, ConsecutiveBlockMinimization, ConsecutiveOnesMatrixAugmentation, - ConsecutiveOnesSubmatrix, IntegerExpressionMembership, MinimumWeightSolutionToLinearEquations, - QuadraticAssignment, SimultaneousIncongruences, SparseMatrixCompression, BMF, ILP, QUBO, + AlgebraicEquationsOverGF2, ClosestVectorProblem, ConsecutiveBlockMinimization, + ConsecutiveOnesMatrixAugmentation, ConsecutiveOnesSubmatrix, IntegerExpressionMembership, + MinimumWeightSolutionToLinearEquations, QuadraticAssignment, SimultaneousIncongruences, + SparseMatrixCompression, BMF, ILP, QUBO, }; pub use formula::{ CNFClause, CircuitSAT, KSatisfiability, NAESatisfiability, NonTautology, diff --git a/src/rules/exactcoverby3sets_algebraicequationsovergf2.rs b/src/rules/exactcoverby3sets_algebraicequationsovergf2.rs new file mode 100644 index 000000000..f0693fd82 --- /dev/null +++ b/src/rules/exactcoverby3sets_algebraicequationsovergf2.rs @@ -0,0 +1,82 @@ +//! Reduction from ExactCoverBy3Sets to AlgebraicEquationsOverGF2. + +use crate::models::algebraic::AlgebraicEquationsOverGF2; +use crate::models::set::ExactCoverBy3Sets; +use crate::reduction; +use crate::rules::traits::{ReduceTo, ReductionResult}; + +#[derive(Debug, Clone)] +pub struct ReductionX3CToAlgebraicEquationsOverGF2 { + target: AlgebraicEquationsOverGF2, +} + +impl ReductionResult for ReductionX3CToAlgebraicEquationsOverGF2 { + type Source = ExactCoverBy3Sets; + type Target = AlgebraicEquationsOverGF2; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + target_solution.to_vec() + } +} + +#[reduction(overhead = { + num_vars = "num_sets", +})] +impl ReduceTo for ExactCoverBy3Sets { + type Result = ReductionX3CToAlgebraicEquationsOverGF2; + + fn reduce_to(&self) -> Self::Result { + let mut sets_per_element = vec![Vec::new(); self.universe_size()]; + for (set_index, set) in self.sets().iter().enumerate() { + for &element in set { + sets_per_element[element].push(set_index); + } + } + + let mut equations = Vec::new(); + for containing_sets in sets_per_element { + let mut linear_equation = containing_sets + .iter() + .map(|&set_index| vec![set_index]) + .collect::>(); + linear_equation.push(vec![]); + equations.push(linear_equation); + + for left in 0..containing_sets.len() { + for right in (left + 1)..containing_sets.len() { + equations.push(vec![vec![containing_sets[left], containing_sets[right]]]); + } + } + } + + ReductionX3CToAlgebraicEquationsOverGF2 { + target: AlgebraicEquationsOverGF2::new(self.num_sets(), equations), + } + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + use crate::export::SolutionPair; + + vec![crate::example_db::specs::RuleExampleSpec { + id: "exactcoverby3sets_to_algebraicequationsovergf2", + build: || { + crate::example_db::specs::rule_example_with_witness::<_, AlgebraicEquationsOverGF2>( + ExactCoverBy3Sets::new(6, vec![[0, 1, 2], [3, 4, 5], [0, 3, 4]]), + SolutionPair { + source_config: vec![1, 1, 0], + target_config: vec![1, 1, 0], + }, + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/exactcoverby3sets_algebraicequationsovergf2.rs"] +mod tests; diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 5ebfaf6a7..697cec685 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -9,6 +9,7 @@ pub use registry::{EdgeCapabilities, ReductionEntry, ReductionOverhead}; pub(crate) mod circuit_spinglass; mod closestvectorproblem_qubo; pub(crate) mod coloring_qubo; +pub(crate) mod exactcoverby3sets_algebraicequationsovergf2; pub(crate) mod exactcoverby3sets_minimumweightsolutiontolinearequations; pub(crate) mod exactcoverby3sets_subsetproduct; pub(crate) mod factoring_circuit; @@ -252,6 +253,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec::reduce_to(&source); + + assert_satisfaction_round_trip_from_satisfaction_target( + &source, + &reduction, + "ExactCoverBy3Sets -> AlgebraicEquationsOverGF2 closed loop", + ); +} + +#[test] +fn test_exactcoverby3sets_to_algebraicequationsovergf2_structure() { + let source = ExactCoverBy3Sets::new(6, vec![[0, 1, 2], [3, 4, 5], [0, 3, 4]]); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + assert_eq!(target.num_vars(), 3); + assert_eq!(target.num_equations(), 9); + assert_eq!( + target.equations(), + &[ + vec![vec![0], vec![2], vec![]], + vec![vec![0, 2]], + vec![vec![0], vec![]], + vec![vec![0], vec![]], + vec![vec![1], vec![2], vec![]], + vec![vec![1, 2]], + vec![vec![1], vec![2], vec![]], + vec![vec![1, 2]], + vec![vec![1], vec![]], + ] + ); +} + +#[test] +fn test_exactcoverby3sets_to_algebraicequationsovergf2_extract_solution_is_identity() { + let source = ExactCoverBy3Sets::new(6, vec![[0, 1, 2], [3, 4, 5], [0, 3, 4]]); + let reduction = ReduceTo::::reduce_to(&source); + + assert_eq!(reduction.extract_solution(&[1, 0, 1]), vec![1, 0, 1]); +} From 935f51bb66ea64de4d161aab94b73f120f221fbe Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 4 Apr 2026 10:26:40 +0800 Subject: [PATCH 16/25] =?UTF-8?q?feat:=20add=20ProductionPlanning=20model?= =?UTF-8?q?=20and=20Partition=20=E2=86=92=20PP=20rule=20(#488)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ProductionPlanning model (Value=Or, lot-sizing with setup/production/ inventory costs and a total cost bound) and the Lenstra-Rinnooy Kan-Florian 1978 reduction from Partition. Element items map to production periods; a terminal demand period consumes S/2; balanced partition iff feasible production plan within cost bound. Includes CLI support, paper entries, and tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/paper/reductions.typ | 118 ++++++++++ problemreductions-cli/src/cli.rs | 13 ++ problemreductions-cli/src/commands/create.rs | 96 +++++++- src/models/misc/mod.rs | 3 + src/models/misc/production_planning.rs | 213 ++++++++++++++++++ src/models/mod.rs | 4 +- src/rules/mod.rs | 2 + src/rules/partition_productionplanning.rs | 83 +++++++ .../models/misc/production_planning.rs | 78 +++++++ .../rules/partition_productionplanning.rs | 54 +++++ 10 files changed, 661 insertions(+), 3 deletions(-) create mode 100644 src/models/misc/production_planning.rs create mode 100644 src/rules/partition_productionplanning.rs create mode 100644 src/unit_tests/models/misc/production_planning.rs create mode 100644 src/unit_tests/rules/partition_productionplanning.rs diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 641f1d912..d9370610f 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -151,6 +151,7 @@ "ExactCoverBy3Sets": [Exact Cover by 3-Sets], "SubsetSum": [Subset Sum], "Partition": [Partition], + "ProductionPlanning": [Production Planning], "PartialFeedbackEdgeSet": [Partial Feedback Edge Set], "MinimumFeedbackArcSet": [Minimum Feedback Arc Set], "MinimumFeedbackVertexSet": [Minimum Feedback Vertex Set], @@ -5540,6 +5541,57 @@ A classical NP-complete problem from Garey and Johnson @garey1979[Ch.~3, p.~76], ] } +#{ + let x = load-model-example("ProductionPlanning") + let n = x.instance.demands.len() + let plan = x.optimal_config + let inventory = { + let running = 0 + let result = () + for i in range(n) { + running += plan.at(i) - x.instance.demands.at(i) + result.push(running) + } + result + } + let prod_cost = range(n).fold(0, (acc, i) => acc + x.instance.production_costs.at(i) * plan.at(i)) + let inv_cost = range(n).fold(0, (acc, i) => acc + x.instance.inventory_costs.at(i) * inventory.at(i)) + let setup_cost = range(n).fold(0, (acc, i) => acc + if plan.at(i) > 0 { x.instance.setup_costs.at(i) } else { 0 }) + let total_cost = prod_cost + inv_cost + setup_cost + [ + #problem-def("ProductionPlanning")[ + Given a number $n in ZZ^+$ of periods and, for each period $i in {1, dots, n}$, a demand $r_i in ZZ_(>= 0)$, a production capacity $c_i in ZZ_(>= 0)$, a set-up cost $b_i in ZZ_(>= 0)$, a unit production cost $p_i in ZZ_(>= 0)$, an inventory holding cost $h_i in ZZ_(>= 0)$, and an overall bound $B in ZZ_(>= 0)$, determine whether there exist production amounts $x_i in ZZ_(>= 0)$ such that $x_i <= c_i$ for all $i$, the cumulative inventories $I_i = sum_(j=1)^i (x_j - r_j)$ satisfy $I_i >= 0$ for all $i$, and + $ sum_(i=1)^n (p_i x_i + h_i I_i) + sum_(x_i > 0) b_i <= B. $ + ][ + Production Planning is the lot-sizing feasibility problem SS21 in Garey & Johnson @garey1979. The book notes pseudo-polynomial dynamic programming when the numeric parameters are small, while the direct witness encoding used in this repository has the brute-force baseline $O^*(product_i (c_i + 1))$. The model captures the central lot-sizing trade-off: producing early can save future set-ups but increases holding cost, whereas postponing production risks infeasible inventory deficits. + + *Example.* The canonical instance has $n = #n$ periods, demands $(#x.instance.demands.map(str).join(", "))$, capacities $(#x.instance.capacities.map(str).join(", "))$, set-up costs $(#x.instance.setup_costs.map(str).join(", "))$, production costs $(#x.instance.production_costs.map(str).join(", "))$, inventory costs $(#x.instance.inventory_costs.map(str).join(", "))$, and bound $B = #x.instance.bound$. The witness $bold(x) = (#plan.map(str).join(", "))$ yields inventories $(#inventory.map(str).join(", "))$, so every prefix remains nonnegative and the terminal inventory is zero. Its production cost is $#prod_cost$, holding cost is $#inv_cost$, and set-up cost is $#setup_cost$, for total cost $#total_cost <= #x.instance.bound$; hence the instance is feasible. + + #pred-commands( + "pred create --example " + problem-spec(x) + " -o production-planning.json", + "pred solve production-planning.json --solver brute-force", + "pred evaluate production-planning.json --config " + x.optimal_config.map(str).join(","), + ) + + #figure({ + table( + columns: n + 1, + inset: 4pt, + align: center, + table.header([], ..range(n).map(i => [$#(i + 1)$])), + [$r_i$], ..x.instance.demands.map(v => [#v]), + [$c_i$], ..x.instance.capacities.map(v => [#v]), + [$b_i$], ..x.instance.setup_costs.map(v => [#v]), + [$x_i$], ..plan.map(v => [#v]), + [$I_i$], ..inventory.map(v => [#v]), + ) + }, + caption: [Canonical Production Planning instance. The witness produces in periods 1, 3, and 5, carries inventory across zero-production periods, and finishes with total cost $#total_cost$ within the bound $B = #x.instance.bound$.], + ) + ] + ] +} + #{ let x = load-model-example("PrecedenceConstrainedScheduling") let n = x.instance.num_tasks @@ -7357,6 +7409,72 @@ where $P$ is a penalty weight large enough that any constraint violation costs m _Solution extraction._ Return the same binary selection vector on the original elements: item $i$ is selected in the knapsack witness if and only if element $i$ belongs to the extracted partition subset. ] +#{ + let part_pp = load-example("Partition", "ProductionPlanning") + let part_pp_sol = part_pp.solutions.at(0) + let sizes = part_pp.source.instance.sizes + let n = sizes.len() + let total = sizes.fold(0, (a, b) => a + b) + let terminal_demand = part_pp.target.instance.demands.at(n) + let bound = part_pp.target.instance.bound + let selected = part_pp_sol.source_config.enumerate().filter(((i, x)) => x == 1).map(((i, _)) => i) + let selected_sizes = selected.map(i => sizes.at(i)) + let inventory = { + let running = 0 + let result = () + for i in range(n + 1) { + running += part_pp_sol.target_config.at(i) - part_pp.target.instance.demands.at(i) + result.push(running) + } + result + } + [ + #reduction-rule("Partition", "ProductionPlanning", + example: true, + example-caption: [#n elements, total sum $S = #total$], + extra: [ + #pred-commands( + "pred create --example " + problem-spec(part_pp.source) + " -o partition.json", + "pred reduce partition.json --to " + target-spec(part_pp) + " -o bundle.json", + "pred solve bundle.json", + "pred evaluate partition.json --config " + part_pp_sol.source_config.map(str).join(","), + ) + + *Step 1 -- Source instance.* The canonical Partition multiset is $(#sizes.map(str).join(", "))$ with total sum $S = #total$, so a balanced side must sum to $S/2 = #bound$. + + *Step 2 -- Build the Production Planning instance.* Create one item period per element plus one terminal period. The item periods have zero demand, capacities $(#part_pp.target.instance.capacities.slice(0, n).map(str).join(", "))$, matching set-up costs $(#part_pp.target.instance.setup_costs.slice(0, n).map(str).join(", "))$, and zero running costs. The terminal period has demand $#terminal_demand$ and zero capacity, and the global bound is $B = #bound$. + + *Step 3 -- Verify the canonical witness.* The stored target witness is $bold(x) = (#part_pp_sol.target_config.map(str).join(", "))$, which produces only in periods $\{#selected.map(i => str(i + 1)).join(", ")\}$ with quantities $(#selected_sizes.map(str).join(", "))$. Its inventory trajectory is $(#inventory.map(str).join(", "))$, so the terminal demand is met exactly. The set-up cost equals $#selected_sizes.map(str).join(" + ") = #bound$, matching the target bound. + + *Step 4 -- Extract a partition witness.* Mark every item period with positive production. This yields the Partition vector $bold(y) = (#part_pp_sol.source_config.map(str).join(", "))$, selecting sizes $(#selected_sizes.map(str).join(", "))$ with sum $#bound$. + ], + )[ + This linear-time reduction follows the lot-sizing NP-completeness construction cited in Garey & Johnson @garey1979. It adds one terminal demand period and uses set-up costs to force each item period either to produce nothing or its full capacity, so the only size overhead is `num_periods = num_elements + 1`. + ][ + _Construction._ Given a Partition instance with sizes $a_0, dots, a_(n-1)$ and total sum $S = sum_i a_i$, create a Production Planning instance with $n + 1$ periods. For each item period $i < n$, set + $ r_i = 0, quad c_i = a_i, quad b_i = a_i, quad p_i = 0, quad h_i = 0. $ + For the terminal period $n$, set + $ r_n = ceil(S / 2), quad c_n = 0, quad b_n = 0, quad p_n = 0, quad h_n = 0. $ + Finally set the cost bound to + $ B = floor(S / 2). $ + + _Correctness._ ($arrow.r.double$) If the Partition instance has a balanced subset $X subset.eq {0, dots, n-1}$ with $sum_(i in X) a_i = S / 2$, then necessarily $S$ is even. Define the production plan by setting $x_i = a_i$ for $i in X$, $x_i = 0$ for $i notin X$, and $x_n = 0$. Every item-period inventory is the cumulative selected sum, so inventories stay nonnegative; the terminal demand consumes exactly $S / 2$ units, leaving final inventory zero. Because all running costs vanish, the total cost is the sum of set-up costs on the active periods: + $ sum_(x_i > 0) b_i = sum_(i in X) a_i = S / 2 = B, $ + so the target instance is feasible. + + ($arrow.l.double$) Conversely, suppose the constructed Production Planning instance is feasible with production vector $bold(x)$. The terminal demand requires + $ sum_(i=0)^(n-1) x_i >= ceil(S / 2). $ + Since $x_i <= a_i$ for each item period and all running costs are zero, feasibility also gives + $ sum_(i=0)^(n-1) x_i <= sum_(x_i > 0) a_i = sum_(x_i > 0) b_i <= B = floor(S / 2). $ + Hence feasibility is impossible when $S$ is odd. When $S$ is even, the inequalities force + $ sum_i x_i = sum_(x_i > 0) a_i = S / 2. $ + The equality $sum_i x_i = sum_(x_i > 0) a_i$ can hold only if every active period produces at full capacity, i.e. $x_i in {0, a_i}$ for all $i < n$. Therefore the active item periods define a subset summing to $S / 2$, yielding a valid Partition witness. + + _Solution extraction._ Output a binary vector on the original $n$ elements by writing 1 exactly for item periods with positive production and 0 otherwise; ignore the terminal period. + ] + ] +} + #let ks_qubo = load-example("Knapsack", "QUBO") #let ks_qubo_sol = ks_qubo.solutions.at(0) #let ks_qubo_num_items = ks_qubo.source.instance.weights.len() diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index f51aaabed..8046a95e2 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -253,6 +253,7 @@ Flags by problem type: Factoring --target, --m, --n BinPacking --sizes, --capacity CapacityAssignment --capacities, --cost-matrix, --delay-matrix, --cost-budget, --delay-budget + ProductionPlanning --demands, --capacities, --setup-costs, --production-costs, --inventory-costs, --bound IntegerExpressionMembership --choices, --target SubsetProduct --values, --target SubsetSum --sizes, --target @@ -390,6 +391,9 @@ pub struct CreateArgs { /// Capacities (edge capacities for flow problems, capacity levels for CapacityAssignment) #[arg(long)] pub capacities: Option, + /// Per-period demands for ProductionPlanning (comma-separated, e.g., "5,3,7,2,8,5") + #[arg(long)] + pub demands: Option, /// Bundle capacities for IntegralFlowBundles (e.g., 1,1,1) #[arg(long)] pub bundle_capacities: Option, @@ -619,6 +623,15 @@ pub struct CreateArgs { /// Task costs for SequencingToMinimizeMaximumCumulativeCost (comma-separated, e.g., "2,-1,3,-2,1,-3") #[arg(long, allow_hyphen_values = true)] pub costs: Option, + /// Per-period set-up costs for ProductionPlanning (comma-separated, e.g., "10,10,10,10,10,10") + #[arg(long)] + pub setup_costs: Option, + /// Per-period unit production costs for ProductionPlanning (comma-separated, e.g., "1,1,1,1,1,1") + #[arg(long)] + pub production_costs: Option, + /// Per-period inventory holding costs for ProductionPlanning (comma-separated, e.g., "1,1,1,1,1,1") + #[arg(long)] + pub inventory_costs: Option, /// Arc costs for directed graph problems with per-arc costs (comma-separated, e.g., "1,1,2,3") #[arg(long)] pub arc_costs: Option, diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index c2dc94de0..ffed38676 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -26,7 +26,7 @@ use problemreductions::models::misc::{ ConjunctiveBooleanQuery, ConsistencyOfDatabaseFrequencyTables, EnsembleComputation, ExpectedRetrievalCost, FlowShopScheduling, FrequencyTable, GroupingBySwapping, KnownValue, LongestCommonSubsequence, MinimumTardinessSequencing, MultiprocessorScheduling, - OpenShopScheduling, PaintShop, PartiallyOrderedKnapsack, QueryArg, + OpenShopScheduling, PaintShop, PartiallyOrderedKnapsack, ProductionPlanning, QueryArg, RectilinearPictureCompression, ResourceConstrainedScheduling, SchedulingWithIndividualDeadlines, SequencingToMinimizeMaximumCumulativeCost, SequencingToMinimizeTardyTaskWeight, SequencingToMinimizeWeightedCompletionTime, @@ -60,6 +60,7 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool { && args.edge_weights.is_none() && args.edge_lengths.is_none() && args.capacities.is_none() + && args.demands.is_none() && args.bundle_capacities.is_none() && args.cost_matrix.is_none() && args.delay_matrix.is_none() @@ -137,6 +138,9 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool { && args.strings.is_none() && args.string.is_none() && args.costs.is_none() + && args.setup_costs.is_none() + && args.production_costs.is_none() + && args.inventory_costs.is_none() && args.arc_costs.is_none() && args.arcs.is_none() && args.homologous_pairs.is_none() @@ -642,6 +646,9 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "CapacityAssignment" => { "--capacities 1,2,3 --cost-matrix \"1,3,6;2,4,7;1,2,5\" --delay-matrix \"8,4,1;7,3,1;6,3,1\" --cost-budget 10 --delay-budget 12" } + "ProductionPlanning" => { + "--demands 5,3,7,2,8,5 --capacities 12,12,12,12,12,12 --setup-costs 10,10,10,10,10,10 --production-costs 1,1,1,1,1,1 --inventory-costs 1,1,1,1,1,1 --bound 80" + } "MultiprocessorScheduling" => "--lengths 4,5,3,2,6 --num-processors 2 --deadline 10", "OpenShopScheduling" => { "--task-lengths \"3,1,2;2,3,1;1,2,3;2,2,1\" --num-processors 3" @@ -2469,6 +2476,89 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { } } + "ProductionPlanning" => { + let usage = "Usage: pred create ProductionPlanning --demands 5,3,7,2,8,5 --capacities 12,12,12,12,12,12 --setup-costs 10,10,10,10,10,10 --production-costs 1,1,1,1,1,1 --inventory-costs 1,1,1,1,1,1 --bound 80"; + let demands_str = args.demands.as_deref().ok_or_else(|| { + anyhow::anyhow!("ProductionPlanning requires --demands\n\n{usage}") + })?; + let capacities_str = args.capacities.as_deref().ok_or_else(|| { + anyhow::anyhow!("ProductionPlanning requires --capacities\n\n{usage}") + })?; + let setup_costs_str = args.setup_costs.as_deref().ok_or_else(|| { + anyhow::anyhow!("ProductionPlanning requires --setup-costs\n\n{usage}") + })?; + let production_costs_str = args.production_costs.as_deref().ok_or_else(|| { + anyhow::anyhow!("ProductionPlanning requires --production-costs\n\n{usage}") + })?; + let inventory_costs_str = args.inventory_costs.as_deref().ok_or_else(|| { + anyhow::anyhow!("ProductionPlanning requires --inventory-costs\n\n{usage}") + })?; + let bound = args + .bound + .ok_or_else(|| anyhow::anyhow!("ProductionPlanning requires --bound\n\n{usage}"))?; + let bound = u64::try_from(bound).map_err(|_| { + anyhow::anyhow!("ProductionPlanning requires nonnegative --bound\n\n{usage}") + })?; + + let demands: Vec = util::parse_comma_list(demands_str) + .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let capacities: Vec = util::parse_comma_list(capacities_str) + .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let setup_costs: Vec = util::parse_comma_list(setup_costs_str) + .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let production_costs: Vec = util::parse_comma_list(production_costs_str) + .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let inventory_costs: Vec = util::parse_comma_list(inventory_costs_str) + .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + + let num_periods = demands.len(); + if num_periods == 0 { + bail!("ProductionPlanning requires at least one period\n\n{usage}"); + } + for (field_name, len) in [ + ("capacities", capacities.len()), + ("setup-costs", setup_costs.len()), + ("production-costs", production_costs.len()), + ("inventory-costs", inventory_costs.len()), + ] { + if len != num_periods { + bail!( + "ProductionPlanning requires {} values for --{} but got {}\n\n{}", + num_periods, + field_name, + len, + usage + ); + } + } + for (period, &capacity) in capacities.iter().enumerate() { + let fits = usize::try_from(capacity) + .ok() + .and_then(|value| value.checked_add(1)) + .is_some(); + if !fits { + bail!( + "capacity {} at period {} is too large for this platform\n\n{}", + capacity, + period, + usage + ); + } + } + + ( + ser(ProductionPlanning::new( + demands, + capacities, + setup_costs, + production_costs, + inventory_costs, + bound, + ))?, + resolved_variant.clone(), + ) + } + // AdditionalKey "AdditionalKey" => { let usage = "Usage: pred create AdditionalKey --num-attributes 6 --dependencies \"0,1:2,3;2,3:4,5\" --relation-attrs \"0,1,2,3,4,5\" --known-keys \"0,1;2,3\""; @@ -7724,6 +7814,7 @@ mod tests { edge_weights: None, edge_lengths: None, capacities: None, + demands: None, bundle_capacities: None, cost_matrix: None, delay_matrix: None, @@ -7799,6 +7890,9 @@ mod tests { pattern: None, strings: None, string: None, + setup_costs: None, + production_costs: None, + inventory_costs: None, arc_costs: None, arcs: None, values: None, diff --git a/src/models/misc/mod.rs b/src/models/misc/mod.rs index e5de1ea0c..956f41cab 100644 --- a/src/models/misc/mod.rs +++ b/src/models/misc/mod.rs @@ -58,6 +58,7 @@ pub(crate) mod paintshop; pub(crate) mod partially_ordered_knapsack; pub(crate) mod partition; mod precedence_constrained_scheduling; +mod production_planning; mod rectilinear_picture_compression; pub(crate) mod resource_constrained_scheduling; mod scheduling_with_individual_deadlines; @@ -99,6 +100,7 @@ pub use paintshop::PaintShop; pub use partially_ordered_knapsack::PartiallyOrderedKnapsack; pub use partition::Partition; pub use precedence_constrained_scheduling::PrecedenceConstrainedScheduling; +pub use production_planning::ProductionPlanning; pub use rectilinear_picture_compression::RectilinearPictureCompression; pub use resource_constrained_scheduling::ResourceConstrainedScheduling; pub use scheduling_with_individual_deadlines::SchedulingWithIndividualDeadlines; @@ -134,6 +136,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec", description: "Demand r_i for each period" }, + FieldInfo { name: "capacities", type_name: "Vec", description: "Production capacity c_i for each period" }, + FieldInfo { name: "setup_costs", type_name: "Vec", description: "Set-up cost b_i charged when x_i > 0" }, + FieldInfo { name: "production_costs", type_name: "Vec", description: "Incremental production cost p_i per unit" }, + FieldInfo { name: "inventory_costs", type_name: "Vec", description: "Inventory holding cost h_i per unit of ending inventory" }, + FieldInfo { name: "bound", type_name: "u64", description: "Total cost bound B" }, + ], + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProductionPlanning { + demands: Vec, + capacities: Vec, + setup_costs: Vec, + production_costs: Vec, + inventory_costs: Vec, + bound: u64, +} + +impl ProductionPlanning { + pub fn new( + demands: Vec, + capacities: Vec, + setup_costs: Vec, + production_costs: Vec, + inventory_costs: Vec, + bound: u64, + ) -> Self { + let num_periods = demands.len(); + assert!( + num_periods > 0, + "ProductionPlanning requires at least one period" + ); + assert_eq!( + capacities.len(), + num_periods, + "capacities length must match demands length" + ); + assert_eq!( + setup_costs.len(), + num_periods, + "setup_costs length must match demands length" + ); + assert_eq!( + production_costs.len(), + num_periods, + "production_costs length must match demands length" + ); + assert_eq!( + inventory_costs.len(), + num_periods, + "inventory_costs length must match demands length" + ); + Self { + demands, + capacities, + setup_costs, + production_costs, + inventory_costs, + bound, + } + } + + pub fn num_periods(&self) -> usize { + self.demands.len() + } + + pub fn demands(&self) -> &[u64] { + &self.demands + } + + pub fn capacities(&self) -> &[u64] { + &self.capacities + } + + pub fn setup_costs(&self) -> &[u64] { + &self.setup_costs + } + + pub fn production_costs(&self) -> &[u64] { + &self.production_costs + } + + pub fn inventory_costs(&self) -> &[u64] { + &self.inventory_costs + } + + pub fn bound(&self) -> u64 { + self.bound + } +} + +impl Problem for ProductionPlanning { + const NAME: &'static str = "ProductionPlanning"; + type Value = crate::types::Or; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![] + } + + fn dims(&self) -> Vec { + self.capacities + .iter() + .map(|&capacity| { + usize::try_from(capacity) + .expect("capacity exceeds usize") + .checked_add(1) + .expect("capacity + 1 overflowed usize") + }) + .collect() + } + + fn evaluate(&self, config: &[usize]) -> crate::types::Or { + if config.len() != self.num_periods() { + return crate::types::Or(false); + } + + let mut inventory = 0i128; + let mut total_cost = 0u128; + let bound = u128::from(self.bound); + + for (period, &production) in config.iter().enumerate() { + let Ok(production) = u64::try_from(production) else { + return crate::types::Or(false); + }; + if production > self.capacities[period] { + return crate::types::Or(false); + } + + inventory += i128::from(production); + inventory -= i128::from(self.demands[period]); + if inventory < 0 { + return crate::types::Or(false); + } + + let Some(production_term) = + u128::from(self.production_costs[period]).checked_mul(u128::from(production)) + else { + return crate::types::Or(false); + }; + let Some(inventory_term) = u128::from(self.inventory_costs[period]) + .checked_mul(u128::try_from(inventory).expect("inventory is non-negative")) + else { + return crate::types::Or(false); + }; + + let mut period_cost = match production_term.checked_add(inventory_term) { + Some(cost) => cost, + None => return crate::types::Or(false), + }; + if production > 0 { + period_cost = match period_cost.checked_add(u128::from(self.setup_costs[period])) { + Some(cost) => cost, + None => return crate::types::Or(false), + }; + } + + total_cost = match total_cost.checked_add(period_cost) { + Some(cost) => cost, + None => return crate::types::Or(false), + }; + if total_cost > bound { + return crate::types::Or(false); + } + } + + crate::types::Or(total_cost <= bound) + } +} + +crate::declare_variants! { + default ProductionPlanning => "2^num_periods", +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "production_planning", + instance: Box::new(ProductionPlanning::new( + vec![0, 1, 2], + vec![2, 1, 0], + vec![1, 1, 0], + vec![1, 0, 0], + vec![1, 0, 0], + 6, + )), + optimal_config: vec![2, 1, 0], + optimal_value: serde_json::json!(true), + }] +} + +#[cfg(test)] +#[path = "../../unit_tests/models/misc/production_planning.rs"] +mod tests; diff --git a/src/models/mod.rs b/src/models/mod.rs index bf4dd0939..0cd58c745 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -42,8 +42,8 @@ pub use misc::{ ConjunctiveQueryFoldability, ConsistencyOfDatabaseFrequencyTables, EnsembleComputation, ExpectedRetrievalCost, Factoring, FlowShopScheduling, GroupingBySwapping, Knapsack, LongestCommonSubsequence, MinimumTardinessSequencing, MultiprocessorScheduling, - OpenShopScheduling, PaintShop, Partition, PrecedenceConstrainedScheduling, QueryArg, - RectilinearPictureCompression, ResourceConstrainedScheduling, + OpenShopScheduling, PaintShop, Partition, PrecedenceConstrainedScheduling, ProductionPlanning, + QueryArg, RectilinearPictureCompression, ResourceConstrainedScheduling, SchedulingWithIndividualDeadlines, SequencingToMinimizeMaximumCumulativeCost, SequencingToMinimizeTardyTaskWeight, SequencingToMinimizeWeightedCompletionTime, SequencingToMinimizeWeightedTardiness, SequencingWithReleaseTimesAndDeadlines, diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 697cec685..3544df762 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -44,6 +44,7 @@ pub(crate) mod naesatisfiability_maxcut; pub(crate) mod naesatisfiability_setsplitting; pub(crate) mod partition_knapsack; pub(crate) mod partition_openshopscheduling; +pub(crate) mod partition_productionplanning; pub(crate) mod partition_sequencingtominimizetardytaskweight; pub(crate) mod sat_circuitsat; pub(crate) mod sat_coloring; @@ -278,6 +279,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec &Self::Target { + &self.target + } + + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + target_solution + .iter() + .take(self.target.num_periods().saturating_sub(1)) + .map(|&production| usize::from(production > 0)) + .collect() + } +} + +#[reduction(overhead = { + num_periods = "num_elements + 1", +})] +impl ReduceTo for Partition { + type Result = ReductionPartitionToProductionPlanning; + + fn reduce_to(&self) -> Self::Result { + let half_floor = self.total_sum() / 2; + let half_ceil = half_floor + (self.total_sum() % 2); + let mut demands = vec![0; self.num_elements()]; + demands.push(half_ceil); + + let mut capacities = self.sizes().to_vec(); + capacities.push(0); + + let mut setup_costs = self.sizes().to_vec(); + setup_costs.push(0); + + let production_costs = vec![0; self.num_elements() + 1]; + let inventory_costs = vec![0; self.num_elements() + 1]; + + ReductionPartitionToProductionPlanning { + target: ProductionPlanning::new( + demands, + capacities, + setup_costs, + production_costs, + inventory_costs, + half_floor, + ), + } + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + use crate::export::SolutionPair; + + vec![crate::example_db::specs::RuleExampleSpec { + id: "partition_to_production_planning", + build: || { + crate::example_db::specs::rule_example_with_witness::<_, ProductionPlanning>( + Partition::new(vec![3, 5, 2, 4, 6]), + SolutionPair { + source_config: vec![0, 0, 0, 1, 1], + target_config: vec![0, 0, 0, 4, 6, 0], + }, + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/partition_productionplanning.rs"] +mod tests; diff --git a/src/unit_tests/models/misc/production_planning.rs b/src/unit_tests/models/misc/production_planning.rs new file mode 100644 index 000000000..be7430e3c --- /dev/null +++ b/src/unit_tests/models/misc/production_planning.rs @@ -0,0 +1,78 @@ +use crate::models::misc::ProductionPlanning; +use crate::solvers::BruteForce; +use crate::traits::Problem; +use crate::types::Or; + +fn issue_example() -> ProductionPlanning { + ProductionPlanning::new( + vec![5, 3, 7, 2, 8, 5], + vec![12, 12, 12, 12, 12, 12], + vec![10, 10, 10, 10, 10, 10], + vec![1, 1, 1, 1, 1, 1], + vec![1, 1, 1, 1, 1, 1], + 80, + ) +} + +#[test] +fn test_production_planning_creation() { + let problem = issue_example(); + + assert_eq!(problem.num_periods(), 6); + assert_eq!(problem.demands(), &[5, 3, 7, 2, 8, 5]); + assert_eq!(problem.capacities(), &[12, 12, 12, 12, 12, 12]); + assert_eq!(problem.setup_costs(), &[10, 10, 10, 10, 10, 10]); + assert_eq!(problem.production_costs(), &[1, 1, 1, 1, 1, 1]); + assert_eq!(problem.inventory_costs(), &[1, 1, 1, 1, 1, 1]); + assert_eq!(problem.bound(), 80); + assert_eq!(problem.dims(), vec![13; 6]); + assert_eq!(::NAME, "ProductionPlanning"); + assert_eq!(::variant(), vec![]); +} + +#[test] +fn test_production_planning_evaluate_feasible_plan() { + let problem = issue_example(); + + assert_eq!(problem.evaluate(&[8, 0, 10, 0, 12, 0]), Or(true)); +} + +#[test] +fn test_production_planning_rejects_invalid_plans() { + let problem = issue_example(); + + assert_eq!(problem.evaluate(&[13, 0, 10, 0, 12, 0]), Or(false)); + assert_eq!(problem.evaluate(&[5, 0, 0, 0, 0, 0]), Or(false)); + assert_eq!(problem.evaluate(&[12, 0, 12, 0, 6, 0]), Or(false)); + assert_eq!(problem.evaluate(&[8, 0, 10, 0, 12]), Or(false)); +} + +#[test] +fn test_production_planning_bruteforce_solver() { + let problem = ProductionPlanning::new( + vec![1, 1], + vec![2, 0], + vec![1, 0], + vec![0, 0], + vec![0, 0], + 1, + ); + let solver = BruteForce::new(); + + assert_eq!(solver.find_all_witnesses(&problem), vec![vec![2, 0]]); + assert_eq!(solver.find_witness(&problem), Some(vec![2, 0])); +} + +#[test] +fn test_production_planning_serialization() { + let problem = issue_example(); + let json = serde_json::to_value(&problem).unwrap(); + let restored: ProductionPlanning = serde_json::from_value(json).unwrap(); + + assert_eq!(restored.demands(), problem.demands()); + assert_eq!(restored.capacities(), problem.capacities()); + assert_eq!(restored.setup_costs(), problem.setup_costs()); + assert_eq!(restored.production_costs(), problem.production_costs()); + assert_eq!(restored.inventory_costs(), problem.inventory_costs()); + assert_eq!(restored.bound(), problem.bound()); +} diff --git a/src/unit_tests/rules/partition_productionplanning.rs b/src/unit_tests/rules/partition_productionplanning.rs new file mode 100644 index 000000000..fc1355660 --- /dev/null +++ b/src/unit_tests/rules/partition_productionplanning.rs @@ -0,0 +1,54 @@ +use super::*; +use crate::models::misc::{Partition, ProductionPlanning}; +use crate::rules::test_helpers::assert_satisfaction_round_trip_from_satisfaction_target; +use crate::solvers::BruteForce; + +#[test] +fn test_partition_to_productionplanning_closed_loop() { + let source = Partition::new(vec![3, 5, 2, 4, 6]); + let reduction = ReduceTo::::reduce_to(&source); + + assert_satisfaction_round_trip_from_satisfaction_target( + &source, + &reduction, + "Partition -> ProductionPlanning closed loop", + ); +} + +#[test] +fn test_partition_to_productionplanning_structure_even_total() { + let source = Partition::new(vec![3, 5, 2, 4, 6]); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + assert_eq!(target.demands(), &[0, 0, 0, 0, 0, 10]); + assert_eq!(target.capacities(), &[3, 5, 2, 4, 6, 0]); + assert_eq!(target.setup_costs(), &[3, 5, 2, 4, 6, 0]); + assert_eq!(target.production_costs(), &[0, 0, 0, 0, 0, 0]); + assert_eq!(target.inventory_costs(), &[0, 0, 0, 0, 0, 0]); + assert_eq!(target.bound(), 10); +} + +#[test] +fn test_partition_to_productionplanning_odd_total_is_infeasible() { + let source = Partition::new(vec![2, 4, 5]); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + assert_eq!(target.demands(), &[0, 0, 0, 6]); + assert_eq!(target.capacities(), &[2, 4, 5, 0]); + assert_eq!(target.setup_costs(), &[2, 4, 5, 0]); + assert_eq!(target.bound(), 5); + assert!(BruteForce::new().find_witness(target).is_none()); +} + +#[test] +fn test_partition_to_productionplanning_extract_solution() { + let source = Partition::new(vec![3, 5, 2, 4, 6]); + let reduction = ReduceTo::::reduce_to(&source); + + assert_eq!( + reduction.extract_solution(&[0, 0, 0, 4, 6, 0]), + vec![0, 0, 0, 1, 1] + ); +} From 560c182b2bffaa2894e9094ab8893c904df2896e Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 4 Apr 2026 17:51:11 +0800 Subject: [PATCH 17/25] =?UTF-8?q?feat:=20add=20HamiltonianPathBetweenTwoVe?= =?UTF-8?q?rtices=20=E2=86=92=20LongestPath=20reduction=20rule=20(#359)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copy graph with unit edge weights and same s/t vertices. A Hamiltonian s-t path has n-1 edges = maximum possible simple path length. Solution extraction walks selected edges from source to reconstruct the vertex permutation. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...onianpathbetweentwovertices_longestpath.rs | 129 ++++++++++++++++++ src/rules/mod.rs | 2 + ...onianpathbetweentwovertices_longestpath.rs | 109 +++++++++++++++ 3 files changed, 240 insertions(+) create mode 100644 src/rules/hamiltonianpathbetweentwovertices_longestpath.rs create mode 100644 src/unit_tests/rules/hamiltonianpathbetweentwovertices_longestpath.rs diff --git a/src/rules/hamiltonianpathbetweentwovertices_longestpath.rs b/src/rules/hamiltonianpathbetweentwovertices_longestpath.rs new file mode 100644 index 000000000..bc177227e --- /dev/null +++ b/src/rules/hamiltonianpathbetweentwovertices_longestpath.rs @@ -0,0 +1,129 @@ +//! Reduction from HamiltonianPathBetweenTwoVertices to LongestPath. +//! +//! A Hamiltonian s-t path in G has length n-1 edges (the maximum possible for +//! any simple path). Setting all edge lengths to unit weight and the same +//! source/target vertices, the longest path of length n-1 exactly corresponds +//! to a Hamiltonian s-t path. + +use crate::models::graph::{HamiltonianPathBetweenTwoVertices, LongestPath}; +use crate::reduction; +use crate::rules::traits::{ReduceTo, ReductionResult}; +use crate::topology::{Graph, SimpleGraph}; +use crate::types::One; + +/// Result of reducing HamiltonianPathBetweenTwoVertices to LongestPath. +#[derive(Debug, Clone)] +pub struct ReductionHPBTVToLP { + target: LongestPath, + /// Cached edge list from the graph, indexed by edge position. + edges: Vec<(usize, usize)>, + source_vertex: usize, + num_vertices: usize, +} + +impl ReductionResult for ReductionHPBTVToLP { + type Source = HamiltonianPathBetweenTwoVertices; + type Target = LongestPath; + + fn target_problem(&self) -> &Self::Target { + &self.target + } + + /// Extract a vertex-permutation solution from an edge-selection solution. + /// + /// The target solution is a binary vector over edges. We walk the selected + /// edges from the source vertex to reconstruct the vertex ordering. + fn extract_solution(&self, target_solution: &[usize]) -> Vec { + let n = self.num_vertices; + + // Build adjacency from selected edges + let mut adj: Vec> = vec![Vec::new(); n]; + for (idx, &selected) in target_solution.iter().enumerate() { + if selected == 1 { + let (u, v) = self.edges[idx]; + adj[u].push(v); + adj[v].push(u); + } + } + + // Walk the path from source + let mut path = Vec::with_capacity(n); + let mut current = self.source_vertex; + let mut prev = usize::MAX; // sentinel for "no previous" + path.push(current); + + while path.len() < n { + let next = adj[current] + .iter() + .find(|&&neighbor| neighbor != prev) + .copied(); + match next { + Some(next_vertex) => { + prev = current; + current = next_vertex; + path.push(current); + } + None => break, + } + } + + path + } +} + +#[reduction(overhead = { + num_vertices = "num_vertices", + num_edges = "num_edges", +})] +impl ReduceTo> for HamiltonianPathBetweenTwoVertices { + type Result = ReductionHPBTVToLP; + + fn reduce_to(&self) -> Self::Result { + let graph = self.graph().clone(); + let num_edges = graph.num_edges(); + let edges = graph.edges(); + let edge_lengths = vec![One; num_edges]; + + let target = LongestPath::new( + graph, + edge_lengths, + self.source_vertex(), + self.target_vertex(), + ); + + ReductionHPBTVToLP { + target, + edges, + source_vertex: self.source_vertex(), + num_vertices: self.num_vertices(), + } + } +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_rule_example_specs() -> Vec { + use crate::export::SolutionPair; + + vec![crate::example_db::specs::RuleExampleSpec { + id: "hamiltonianpathbetweentwovertices_to_longestpath", + build: || { + // Path graph 0-1-2-3-4 with s=0, t=4 + let source = HamiltonianPathBetweenTwoVertices::new( + SimpleGraph::new(5, vec![(0, 1), (1, 2), (2, 3), (3, 4)]), + 0, + 4, + ); + crate::example_db::specs::rule_example_with_witness::<_, LongestPath>( + source, + SolutionPair { + source_config: vec![0, 1, 2, 3, 4], + target_config: vec![1, 1, 1, 1], + }, + ) + }, + }] +} + +#[cfg(test)] +#[path = "../unit_tests/rules/hamiltonianpathbetweentwovertices_longestpath.rs"] +mod tests; diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 986df44ce..5edde0754 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -33,6 +33,7 @@ pub(crate) mod hamiltoniancircuit_travelingsalesman; pub(crate) mod hamiltonianpath_consecutiveonessubmatrix; pub(crate) mod hamiltonianpath_degreeconstrainedspanningtree; pub(crate) mod hamiltonianpath_isomorphicspanningtree; +pub(crate) mod hamiltonianpathbetweentwovertices_longestpath; pub(crate) mod ilp_i32_ilp_bool; pub(crate) mod kclique_balancedcompletebipartitesubgraph; pub(crate) mod kclique_conjunctivebooleanquery; @@ -361,6 +362,7 @@ pub(crate) fn canonical_rule_example_specs() -> Vec>::reduce_to(&source); + let target = result.target_problem(); + + assert_eq!(target.num_vertices(), 5); + assert_eq!(target.num_edges(), 6); + assert_eq!(target.source_vertex(), 0); + assert_eq!(target.target_vertex(), 4); + + assert_satisfaction_round_trip_from_optimization_target( + &source, + &result, + "HamiltonianPathBetweenTwoVertices->LongestPath closed loop", + ); +} + +#[test] +fn test_hamiltonianpathbetweentwovertices_to_longestpath_path_graph() { + // Simple path graph: 0-1-2-3 with s=0, t=3 (trivially has a Hamiltonian path) + let source = HamiltonianPathBetweenTwoVertices::new( + SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3)]), + 0, + 3, + ); + let result = ReduceTo::>::reduce_to(&source); + + assert_satisfaction_round_trip_from_optimization_target( + &source, + &result, + "HamiltonianPathBetweenTwoVertices->LongestPath path graph", + ); +} + +#[test] +fn test_hamiltonianpathbetweentwovertices_to_longestpath_no_hamiltonian_path() { + // Star graph K_{1,4}: vertex 0 connected to 1,2,3,4. + // No Hamiltonian path from 1 to 2 exists (vertices 3,4 are leaves + // connected only to 0, so no path can visit all without revisiting 0). + let source = HamiltonianPathBetweenTwoVertices::new( + SimpleGraph::new(5, vec![(0, 1), (0, 2), (0, 3), (0, 4)]), + 1, + 2, + ); + let result = ReduceTo::>::reduce_to(&source); + let solver = BruteForce::new(); + let target_best = solver + .find_witness(result.target_problem()) + .expect("LongestPath should have some valid path"); + + // The best path has fewer than n-1 = 4 edges (it's not Hamiltonian) + let selected_edges: usize = target_best.iter().sum(); + assert!( + selected_edges < 4, + "Best path should have fewer than n-1 edges since no Hamiltonian s-t path exists" + ); +} + +#[test] +fn test_hamiltonianpathbetweentwovertices_to_longestpath_complete_graph() { + // Complete graph K4 with s=0, t=3: many Hamiltonian 0-3 paths exist + let source = HamiltonianPathBetweenTwoVertices::new( + SimpleGraph::new(4, vec![(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)]), + 0, + 3, + ); + let result = ReduceTo::>::reduce_to(&source); + + assert_satisfaction_round_trip_from_optimization_target( + &source, + &result, + "HamiltonianPathBetweenTwoVertices->LongestPath complete K4", + ); +} + +#[test] +fn test_hamiltonianpathbetweentwovertices_to_longestpath_triangle() { + // Triangle: 0-1-2-0, with s=0, t=2 + let source = HamiltonianPathBetweenTwoVertices::new( + SimpleGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]), + 0, + 2, + ); + let result = ReduceTo::>::reduce_to(&source); + let target = result.target_problem(); + + assert_eq!(target.num_vertices(), 3); + assert_eq!(target.num_edges(), 3); + + assert_satisfaction_round_trip_from_optimization_target( + &source, + &result, + "HamiltonianPathBetweenTwoVertices->LongestPath triangle", + ); +} From 9c7e83f161f86e16295605b880ff7b4bba5f9334 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 4 Apr 2026 18:04:44 +0800 Subject: [PATCH 18/25] chore: remove stray prompt.md and implementation_log.md These were working files from the batch implementation session, not intended for the repository. Co-Authored-By: Claude Opus 4.6 (1M context) --- implementation_log.md | 83 ------------------------------------------- prompt.md | 5 --- 2 files changed, 88 deletions(-) delete mode 100644 implementation_log.md delete mode 100644 prompt.md diff --git a/implementation_log.md b/implementation_log.md deleted file mode 100644 index 5f440ae03..000000000 --- a/implementation_log.md +++ /dev/null @@ -1,83 +0,0 @@ -# Reduction Rules Implementation Log - -Branch: `jg/new-rules-batch1` -Started: 2026-04-03 - -## Rules to implement (High confidence, 16 open) - -### Ready (both models exist) -1. [ ] #973 SubsetSum → Partition -2. [ ] #379 MinimumDominatingSet → MinMaxMulticenter -3. [ ] #380 MinimumDominatingSet → MinimumSumMulticenter -4. [ ] #359 HamiltonianPathBetweenTwoVertices → LongestPath - -### Need target model first -5. [ ] #388 ExactCoverBy3Sets → SubsetProduct -6. [ ] #844 KColoring → PartitionIntoCliques -7. [ ] #382 NAESatisfiability → SetSplitting -8. [ ] #481 Partition → OpenShopScheduling -9. [ ] #488 Partition → ProductionPlanning -10. [ ] #471 Partition → SequencingToMinimizeTardyTaskWeight -11. [ ] #868 Satisfiability → NonTautology -12. [ ] #569 SubsetSum → IntegerExpressionMembership -13. [ ] #882 3SAT → Kernel -14. [ ] #554 3SAT → SimultaneousIncongruences -15. [ ] #860 ExactCoverBy3Sets → MinimumWeightSolutionToLinearEquations -16. [ ] #911 HamiltonianPath → DegreeConstrainedSpanningTree - -## Unexpected Events - -### 2026-04-03 - #359 HamiltonianPathBetweenTwoVertices → LongestPath -- **Issue:** Initially classified as "ready" (both models exist), but `HamiltonianPathBetweenTwoVertices` model does NOT exist in the codebase. Only `HamiltonianPath` exists (different problem - no fixed endpoints). -- **Impact:** Moved from "ready" to "needs source model first". Only 3 rules are truly ready (both models exist): #973, #379, #380. - -### 2026-04-03 - #379 and #380 MinDomSet → Multicenter rules BLOCKED -- **Issue:** Both issues explicitly say the reduction is blocked due to optimization vs decision framing mismatch. - - MinimumDominatingSet has `Value = Min` (optimization) with no K parameter - - #379: MinMaxMulticenter has `Value = Or` → Min→Or type mismatch - - #380: MinimumSumMulticenter has `Value = Min` → types match but k parameter needs to come from an unknown optimal dominating set size (circular dependency) -- **Impact:** Only 1 rule is truly ready to implement immediately: #973. All other 15 need either models or type resolution. -- **Resolution:** These require a decision-variant `DominatingSet(G, K)` model first. Proceeding with rules that need target models created. - -### 2026-04-03 - Updated confidence file adds 3 new High-confidence rules -- **File:** `~/Downloads/reduction_derivations_low_tier_reinspected.typ` -- **New High entries (20 total, was 17):** - - HamiltonianPath → Isomorphic Spanning Tree (#912) — promoted from Low - - NAE-Satisfiability → Maximum Cut (#166) — promoted from Low - - X3C → Algebraic Equations over GF(2) (#859) — promoted from Low -- **New Medium entries (16 total, was 14):** - - 3SAT → Feasible Register Assignment (#905) — promoted from Low - - 3SAT → Quadratic Congruences (#553) — promoted from Low -- **Impact:** 3 additional rules to implement in the High-confidence batch. - -### 2026-04-03 - All 12 target model issues are CLOSED but models NOT implemented -- **Issue:** All target model issues (#834, #830, #506, #513, #867, #885, #537, #852, #896, #552, #496, #854) show status CLOSED:COMPLETED but none of the models actually exist in the codebase. -- **Impact:** Must create all 12 target models from scratch as part of each rule implementation. -- **Resolution:** Each codex invocation includes both add-model and add-rule steps. - -## Progress - -| # | Rule | Status | Commit | -|---|------|--------|--------| -| # | Rule | Status | Commit | -|---|------|--------|--------| -| #973 | SubsetSum → Partition | DONE | 6398de17 | -| #868 | Satisfiability → NonTautology | DONE | 1784af77 | -| #844 | KColoring → PartitionIntoCliques | DONE | 188f24b8 | -| #882 | 3SAT → Kernel | DONE | 7cfb16a1 | -| #911 | HamPath → DegreeConstrainedSpanningTree | DONE | 745f90a0 | -| #382 | NAE-SAT → SetSplitting | DONE | 5e7e982d | -| #388 | X3C → SubsetProduct | DONE | bdf8ef48 | -| #569 | SubsetSum → IntegerExprMembership | DONE | 692680fa | -| #860 | X3C → MinWeightSolnLinEq | DONE | 4473224e | -| #554 | 3SAT → SimultaneousIncongruences | DONE | 0cc816db | -| #471 | Partition → SeqMinTardyTaskWeight | DONE | bb6de6c7 | -| #481 | Partition → OpenShopScheduling | TODO | - | -| #488 | Partition → ProductionPlanning | TODO | - | -| #379 | MinDomSet → MinMaxMulticenter | BLOCKED | - | -| #380 | MinDomSet → MinSumMulticenter | BLOCKED | - | -| #359 | HamPathBetween2 → LongestPath | BLOCKED | - | -| #912 | HamPath → IsomorphicSpanningTree | TODO (new) | - | -| #166 | NAE-SAT → MaximumCut | TODO (new) | - | -| #859 | X3C → AlgEqOverGF2 | TODO (new) | - | - diff --git a/prompt.md b/prompt.md deleted file mode 100644 index 16b9e622b..000000000 --- a/prompt.md +++ /dev/null @@ -1,5 +0,0 @@ -Implement high confidence reduction rules in this pr. the rules are derived in ~/Downloads/reduction_derivations_confidence_upgraded_refs.typ , and confidence levels are at the end of the note. Some rules are verified with code in pr 992 and pr 996. We also have issues for these rules that provides the information. which is mentioned in the note - -Rules must be implemented one by one in this branch: -- For each rule, invoke codex in headless mode, provide context for it, and let codex to use add-model to implement. -- Let codex to return all unexpected events during implementation, you record it into a log file. From bd15ab078669af330434dd6f4eb8be8c248450e1 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 4 Apr 2026 18:34:54 +0800 Subject: [PATCH 19/25] fix: address PR #999 review issues --- problemreductions-cli/src/cli.rs | 9 ++ problemreductions-cli/src/commands/create.rs | 144 +++++++++++++++++- problemreductions-cli/src/mcp/tools.rs | 54 +++---- .../algebraic/simultaneous_incongruences.rs | 2 +- src/models/graph/graph_partitioning.rs | 3 + src/rules/exactcoverby3sets_subsetproduct.rs | 35 +++-- ...atisfiability_simultaneousincongruences.rs | 20 +++ .../models/graph/graph_partitioning.rs | 7 + .../rules/exactcoverby3sets_subsetproduct.rs | 14 ++ ...atisfiability_simultaneousincongruences.rs | 8 + 10 files changed, 245 insertions(+), 51 deletions(-) diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index d36a5e617..f1b54917e 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -220,9 +220,11 @@ Flags by problem type: LongestPath --graph, --edge-lengths, --source-vertex, --target-vertex HamiltonianPathBetweenTwoVertices --graph, --source-vertex, --target-vertex ShortestWeightConstrainedPath --graph, --edge-lengths, --edge-weights, --source-vertex, --target-vertex, --weight-bound + GraphPartitioning --graph, --num-partitions MaximalIS --graph, --weights SAT, NAESAT --num-vars, --clauses KSAT --num-vars, --clauses [--k] + NonTautology --num-vars, --disjuncts QUBO --matrix SpinGlass --graph, --couplings, --fields KColoring --graph, --k @@ -370,6 +372,7 @@ Examples: pred create --example MVC/SimpleGraph/i32 --to MIS/SimpleGraph/i32 --example-side target pred create MIS --graph 0-1,1-2,2-3 --weights 1,1,1 pred create SAT --num-vars 3 --clauses \"1,2;-1,3\" + pred create NonTautology --num-vars 3 --disjuncts \"1,2,3;-1,-2,-3\" pred create QUBO --matrix \"1,0.5;0.5,2\" pred create CapacityAssignment --capacities 1,2,3 --cost-matrix \"1,3,6;2,4,7;1,2,5\" --delay-matrix \"8,4,1;7,3,1;6,3,1\" --cost-budget 10 --delay-budget 12 pred create ProductionPlanning --num-periods 6 --demands 5,3,7,2,8,5 --capacities 12,12,12,12,12,12 --setup-costs 10,10,10,10,10,10 --production-costs 1,1,1,1,1,1 --inventory-costs 1,1,1,1,1,1 --cost-bound 80 @@ -474,6 +477,9 @@ pub struct CreateArgs { /// Clauses for SAT problems (semicolon-separated, e.g., "1,2;-1,3") #[arg(long)] pub clauses: Option, + /// Disjuncts for NonTautology (semicolon-separated, e.g., "1,2;-1,3") + #[arg(long)] + pub disjuncts: Option, /// Number of variables (for SAT/KSAT) #[arg(long)] pub num_vars: Option, @@ -484,6 +490,9 @@ pub struct CreateArgs { /// Shared integer parameter (use `pred create ` for the problem-specific meaning) #[arg(long)] pub k: Option, + /// Number of partitions for GraphPartitioning (currently must be 2) + #[arg(long)] + pub num_partitions: Option, /// Generate a random instance (graph-based problems only) #[arg(long)] pub random: bool, diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 1bfd40cab..5cbb0e948 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -16,9 +16,9 @@ use problemreductions::models::algebraic::{ }; use problemreductions::models::formula::Quantifier; use problemreductions::models::graph::{ - DirectedHamiltonianPath, DisjointConnectingPaths, GeneralizedHex, HamiltonianCircuit, - HamiltonianPath, HamiltonianPathBetweenTwoVertices, IntegralFlowBundles, Kernel, - LengthBoundedDisjointPaths, LongestCircuit, LongestPath, MinimumCutIntoBoundedSets, + DirectedHamiltonianPath, DisjointConnectingPaths, GeneralizedHex, GraphPartitioning, + HamiltonianCircuit, HamiltonianPath, HamiltonianPathBetweenTwoVertices, IntegralFlowBundles, + Kernel, LengthBoundedDisjointPaths, LongestCircuit, LongestPath, MinimumCutIntoBoundedSets, MinimumDummyActivitiesPert, MinimumGeometricConnectedDominatingSet, MinimumMaximalMatching, MinimumMultiwayCut, MixedChinesePostman, MultipleChoiceBranching, PathConstrainedNetworkFlow, RootedTreeArrangement, SteinerTree, SteinerTreeInGraphs, StrongConnectivityAugmentation, @@ -90,9 +90,11 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool { && args.couplings.is_none() && args.fields.is_none() && args.clauses.is_none() + && args.disjuncts.is_none() && args.num_vars.is_none() && args.matrix.is_none() && args.k.is_none() + && args.num_partitions.is_none() && args.target.is_none() && args.m.is_none() && args.n.is_none() @@ -657,6 +659,7 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "HamiltonianPathBetweenTwoVertices" => { "--graph 0-1,0-3,1-2,1-4,2-5,3-4,4-5,2-3 --source-vertex 0 --target-vertex 5" } + "GraphPartitioning" => "--graph 0-1,1-2,2-3,3-0 --num-partitions 2", "LongestPath" => { "--graph 0-1,0-2,1-3,2-3,2-4,3-5,4-5,4-6,5-6,1-6 --edge-lengths 3,2,4,1,5,2,3,2,4,1 --source-vertex 0 --target-vertex 6" } @@ -707,7 +710,7 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "KSatisfiability" => "--num-vars 3 --clauses \"1,2,3;-1,2,-3\" --k 3", "Maximum2Satisfiability" => "--num-vars 4 --clauses \"1,2;1,-2;-1,3;-1,-3;2,4;-3,-4;3,4\"", "NonTautology" => { - "--num-vars 3 --clauses \"1,2,3;-1,-2,-3\"" + "--num-vars 3 --disjuncts \"1,2,3;-1,-2,-3\"" } "OneInThreeSatisfiability" => { "--num-vars 4 --clauses \"1,2,3;-1,3,4;2,-3,-4\"" @@ -1092,6 +1095,7 @@ fn help_flag_hint( ("MinimumCodeGenerationParallelAssignments", "assignments") => { "semicolon-separated target:reads entries: \"0:1,2;1:0;2:3;3:1,2\"" } + ("NonTautology", "disjuncts") => "semicolon-separated disjuncts: \"1,2,3;-1,-2,-3\"", ("TimetableDesign", "craftsman_avail") | ("TimetableDesign", "task_avail") => { "semicolon-separated 0/1 rows: \"1,1,0;0,1,1\"" } @@ -1212,6 +1216,12 @@ fn print_problem_help(canonical: &str, graph_type: Option<&str>) -> Result<()> { eprintln!(" --{:<16} {} ({})", flag_name, field.description, hint); } } + if canonical == "GraphPartitioning" { + eprintln!( + " --{:<16} Number of partitions in the balanced partitioning model (must be 2) (integer)", + "num-partitions" + ); + } } else { bail!("{}", crate::problem_name::unknown_problem_error(canonical)); } @@ -1762,6 +1772,23 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } + "GraphPartitioning" => { + let usage = "pred create GraphPartitioning --graph 0-1,1-2,2-3,3-0 --num-partitions 2"; + let (graph, _) = + parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\nUsage: {usage}"))?; + let num_partitions = args.num_partitions.ok_or_else(|| { + anyhow::anyhow!("GraphPartitioning requires --num-partitions\n\nUsage: {usage}") + })?; + anyhow::ensure!( + num_partitions == 2, + "GraphPartitioning currently models balanced bipartition only, so --num-partitions must be 2 (got {num_partitions})" + ); + ( + ser(GraphPartitioning::new(graph))?, + resolved_variant.clone(), + ) + } + // LongestPath "LongestPath" => { let usage = "pred create LongestPath --graph 0-1,0-2,1-3,2-3,2-4,3-5,4-5,4-6,5-6,1-6 --edge-lengths 3,2,4,1,5,2,3,2,4,1 --source-vertex 0 --target-vertex 6"; @@ -2397,13 +2424,11 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { let num_vars = args.num_vars.ok_or_else(|| { anyhow::anyhow!( "NonTautology requires --num-vars\n\n\ - Usage: pred create NonTautology --num-vars 3 --clauses \"1,2,3;-1,-2,-3\"" + Usage: pred create NonTautology --num-vars 3 --disjuncts \"1,2,3;-1,-2,-3\"" ) })?; - let clauses = parse_clauses(args)?; - let disjuncts: Vec> = clauses.into_iter().map(|c| c.literals).collect(); ( - ser(NonTautology::new(num_vars, disjuncts))?, + ser(NonTautology::new(num_vars, parse_disjuncts(args)?))?, resolved_variant.clone(), ) } @@ -7360,6 +7385,28 @@ fn parse_clauses(args: &CreateArgs) -> Result> { .collect() } +fn parse_disjuncts(args: &CreateArgs) -> Result>> { + let disjuncts_str = args + .disjuncts + .as_deref() + .or(args.clauses.as_deref()) + .ok_or_else(|| { + anyhow::anyhow!("NonTautology requires --disjuncts (e.g., \"1,2,3;-1,-2,-3\")") + })?; + + disjuncts_str + .split(';') + .map(|disjunct| { + disjunct + .trim() + .split(',') + .map(|s| s.trim().parse::()) + .collect::, _>>() + .map_err(anyhow::Error::from) + }) + .collect() +} + /// Parse `--sets` as semicolon-separated sets of comma-separated usize. /// E.g., "0,1;1,2;0,2" fn parse_sets(args: &CreateArgs) -> Result>> { @@ -9901,9 +9948,11 @@ mod tests { couplings: None, fields: None, clauses: None, + disjuncts: None, num_vars: None, matrix: None, k: None, + num_partitions: None, random: false, source_vertex: None, target_vertex: None, @@ -10843,6 +10892,85 @@ mod tests { assert!(err.contains("bound >= 1")); } + #[test] + fn test_create_graph_partitioning_with_num_partitions() { + use crate::dispatch::ProblemJsonOutput; + use problemreductions::models::graph::GraphPartitioning; + use problemreductions::topology::SimpleGraph; + + let cli = Cli::try_parse_from([ + "pred", + "create", + "GraphPartitioning", + "--graph", + "0-1,1-2,2-3,3-0", + "--num-partitions", + "2", + ]) + .unwrap(); + let args = match cli.command { + Commands::Create(args) => args, + _ => unreachable!(), + }; + + let output_path = temp_output_path("graph-partitioning-create"); + let out = OutputConfig { + output: Some(output_path.clone()), + quiet: true, + json: false, + auto_json: false, + }; + + create(&args, &out).unwrap(); + + let json = fs::read_to_string(&output_path).unwrap(); + let created: ProblemJsonOutput = serde_json::from_str(&json).unwrap(); + assert_eq!(created.problem_type, "GraphPartitioning"); + let problem: GraphPartitioning = serde_json::from_value(created.data).unwrap(); + assert_eq!(problem.num_vertices(), 4); + + let _ = fs::remove_file(output_path); + } + + #[test] + fn test_create_nontautology_with_disjuncts_flag() { + use crate::dispatch::ProblemJsonOutput; + use problemreductions::models::formula::NonTautology; + + let cli = Cli::try_parse_from([ + "pred", + "create", + "NonTautology", + "--num-vars", + "3", + "--disjuncts", + "1,2,3;-1,-2,-3", + ]) + .unwrap(); + let args = match cli.command { + Commands::Create(args) => args, + _ => unreachable!(), + }; + + let output_path = temp_output_path("non-tautology-create"); + let out = OutputConfig { + output: Some(output_path.clone()), + quiet: true, + json: false, + auto_json: false, + }; + + create(&args, &out).unwrap(); + + let json = fs::read_to_string(&output_path).unwrap(); + let created: ProblemJsonOutput = serde_json::from_str(&json).unwrap(); + assert_eq!(created.problem_type, "NonTautology"); + let problem: NonTautology = serde_json::from_value(created.data).unwrap(); + assert_eq!(problem.disjuncts(), &[vec![1, 2, 3], vec![-1, -2, -3]]); + + let _ = fs::remove_file(output_path); + } + #[test] fn test_create_consecutive_ones_matrix_augmentation_json() { use crate::dispatch::ProblemJsonOutput; diff --git a/problemreductions-cli/src/mcp/tools.rs b/problemreductions-cli/src/mcp/tools.rs index bb600f9d1..e6a5df56e 100644 --- a/problemreductions-cli/src/mcp/tools.rs +++ b/problemreductions-cli/src/mcp/tools.rs @@ -406,20 +406,8 @@ impl McpServer { if edge_lengths.iter().any(|&length| length <= 0) { anyhow::bail!("LongestCircuit edge lengths must be positive (> 0)"); } - let bound = params - .get("bound") - .and_then(|v| v.as_i64()) - .ok_or_else(|| anyhow::anyhow!("LongestCircuit requires 'bound'"))?; - let bound = i32::try_from(bound) - .map_err(|_| anyhow::anyhow!("LongestCircuit bound must fit in i32"))?; - if bound <= 0 { - anyhow::bail!("LongestCircuit bound must be positive (> 0)"); - } let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]); - ( - ser(LongestCircuit::new(graph, edge_lengths, bound))?, - variant, - ) + (ser(LongestCircuit::new(graph, edge_lengths))?, variant) } "KColoring" => { @@ -635,20 +623,8 @@ impl McpServer { } let graph = util::create_random_graph(num_vertices, edge_prob, seed); let edge_lengths = vec![1i32; graph.num_edges()]; - let bound = params - .get("bound") - .and_then(|v| v.as_i64()) - .unwrap_or(num_vertices.max(3) as i64); - let bound = i32::try_from(bound) - .map_err(|_| anyhow::anyhow!("LongestCircuit bound must fit in i32"))?; - if bound <= 0 { - anyhow::bail!("LongestCircuit bound must be positive (> 0)"); - } let variant = variant_map(&[("graph", "SimpleGraph"), ("weight", "i32")]); - ( - ser(LongestCircuit::new(graph, edge_lengths, bound))?, - variant, - ) + (ser(LongestCircuit::new(graph, edge_lengths))?, variant) } "SpinGlass" => { let edge_prob = params @@ -1597,3 +1573,29 @@ fn solve_bundle_inner(bundle: ReductionBundle, solver_name: &str) -> anyhow::Res }); Ok(serde_json::to_string_pretty(&json)?) } + +#[cfg(test)] +mod tests { + use super::McpServer; + use crate::dispatch::ProblemJsonOutput; + use problemreductions::models::formula::NonTautology; + + #[test] + fn test_create_problem_inner_nontautology_uses_disjuncts() { + let server = McpServer::new(); + let output = server + .create_problem_inner( + "NonTautology", + &serde_json::json!({ + "num_vars": 3, + "disjuncts": "1,2,3;-1,-2,-3", + }), + ) + .unwrap(); + + let created: ProblemJsonOutput = serde_json::from_str(&output).unwrap(); + assert_eq!(created.problem_type, "NonTautology"); + let problem: NonTautology = serde_json::from_value(created.data).unwrap(); + assert_eq!(problem.disjuncts(), &[vec![1, 2, 3], vec![-1, -2, -3]]); + } +} diff --git a/src/models/algebraic/simultaneous_incongruences.rs b/src/models/algebraic/simultaneous_incongruences.rs index 5aee90f37..5dc6263d1 100644 --- a/src/models/algebraic/simultaneous_incongruences.rs +++ b/src/models/algebraic/simultaneous_incongruences.rs @@ -62,7 +62,7 @@ pub struct SimultaneousIncongruences { /// Maximum lcm value we will compute in full; if the lcm exceeds this cap we /// return this value to keep the brute-force search space manageable. -const MAX_LCM: u128 = 1_000_000; +pub(crate) const MAX_LCM: u128 = 1_000_000; fn lcm128(a: u128, b: u128) -> u128 { if a == 0 || b == 0 { diff --git a/src/models/graph/graph_partitioning.rs b/src/models/graph/graph_partitioning.rs index a2ea1f0f6..f69aadd2b 100644 --- a/src/models/graph/graph_partitioning.rs +++ b/src/models/graph/graph_partitioning.rs @@ -107,6 +107,9 @@ where if config.len() != n { return Min(None); } + if config.iter().any(|&part| part >= 2) { + return Min(None); + } // Balanced bisection requires even n if !n.is_multiple_of(2) { return Min(None); diff --git a/src/rules/exactcoverby3sets_subsetproduct.rs b/src/rules/exactcoverby3sets_subsetproduct.rs index a5cdc5f4f..3b6aa896e 100644 --- a/src/rules/exactcoverby3sets_subsetproduct.rs +++ b/src/rules/exactcoverby3sets_subsetproduct.rs @@ -5,12 +5,13 @@ //! primes. Unique factorization then makes exact covers correspond exactly to //! subsets whose product matches the target. +use crate::models::formula::ksat::first_n_odd_primes; use crate::models::misc::SubsetProduct; use crate::models::set::ExactCoverBy3Sets; use crate::reduction; use crate::rules::traits::{ReduceTo, ReductionResult}; - -const FIRST_PRIMES: [u64; 15] = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47]; +use num_bigint::BigUint; +use num_traits::One; #[derive(Debug, Clone)] pub struct ReductionX3CToSubsetProduct { @@ -30,24 +31,26 @@ impl ReductionResult for ReductionX3CToSubsetProduct { } } -fn checked_product(values: I, what: &str) -> u64 +fn product_biguint(values: I) -> BigUint where I: IntoIterator, { - values.into_iter().fold(1u64, |product, value| { - product.checked_mul(value).unwrap_or_else(|| { - panic!("ExactCoverBy3Sets -> SubsetProduct requires {what} to fit in u64") - }) + values.into_iter().fold(BigUint::one(), |product, value| { + product * BigUint::from(value) }) } -fn assigned_primes(universe_size: usize) -> &'static [u64] { - assert!( - universe_size <= FIRST_PRIMES.len(), - "ExactCoverBy3Sets -> SubsetProduct requires the target product to fit in u64; universe_size={universe_size} exceeds the supported limit {}", - FIRST_PRIMES.len() - ); - &FIRST_PRIMES[..universe_size] +fn assigned_primes(universe_size: usize) -> Vec { + match universe_size { + 0 => Vec::new(), + 1 => vec![2], + _ => { + let mut primes = Vec::with_capacity(universe_size); + primes.push(2); + primes.extend(first_n_odd_primes(universe_size - 1)); + primes + } + } } #[reduction(overhead = { @@ -61,9 +64,9 @@ impl ReduceTo for ExactCoverBy3Sets { let values = self .sets() .iter() - .map(|set| checked_product(set.iter().map(|&element| primes[element]), "set value")) + .map(|set| product_biguint(set.iter().map(|&element| primes[element]))) .collect(); - let target = checked_product(primes.iter().copied(), "target product"); + let target = product_biguint(primes.iter().copied()); ReductionX3CToSubsetProduct { target: SubsetProduct::new(values, target), diff --git a/src/rules/ksatisfiability_simultaneousincongruences.rs b/src/rules/ksatisfiability_simultaneousincongruences.rs index 289927952..484d50585 100644 --- a/src/rules/ksatisfiability_simultaneousincongruences.rs +++ b/src/rules/ksatisfiability_simultaneousincongruences.rs @@ -6,6 +6,7 @@ use std::collections::BTreeMap; +use crate::models::algebraic::simultaneous_incongruences::MAX_LCM; use crate::models::algebraic::SimultaneousIncongruences; use crate::models::formula::{ksat::first_n_odd_primes, CNFClause, KSatisfiability}; use crate::reduction; @@ -128,6 +129,24 @@ fn clause_bad_residue(clause: &CNFClause, variable_primes: &[u64]) -> (u64, u64) crt_residue(&congruences) } +fn ensure_prime_product_within_lcm_cap(variable_primes: &[u64]) { + let mut product = 1u128; + for &prime in variable_primes { + product = product.checked_mul(prime as u128).unwrap_or_else(|| { + panic!( + "3-SAT -> SimultaneousIncongruences requires the variable-prime product to fit within the target model's LCM cap ({MAX_LCM}); num_vars={} overflows while multiplying primes", + variable_primes.len() + ) + }); + if product > MAX_LCM { + panic!( + "3-SAT -> SimultaneousIncongruences requires the variable-prime product to fit within the target model's LCM cap ({MAX_LCM}); num_vars={} yields prime product {product}", + variable_primes.len() + ); + } + } +} + #[reduction(overhead = { num_pairs = "simultaneous_incongruences_num_incongruences", })] @@ -136,6 +155,7 @@ impl ReduceTo for KSatisfiability { fn reduce_to(&self) -> Self::Result { let variable_primes = first_n_odd_primes(self.num_vars()); + ensure_prime_product_within_lcm_cap(&variable_primes); let mut pairs = Vec::new(); diff --git a/src/unit_tests/models/graph/graph_partitioning.rs b/src/unit_tests/models/graph/graph_partitioning.rs index fe844ed47..2791d563b 100644 --- a/src/unit_tests/models/graph/graph_partitioning.rs +++ b/src/unit_tests/models/graph/graph_partitioning.rs @@ -113,6 +113,13 @@ fn test_graphpartitioning_unbalanced_invalid() { assert_eq!(problem.evaluate(&[1, 1, 0, 0]), Min(Some(2))); } +#[test] +fn test_graphpartitioning_rejects_non_binary_configs() { + let problem = issue_example(); + + assert_eq!(problem.evaluate(&[0, 0, 1, 1, 1, 2]), Min(None)); +} + #[test] fn test_graphpartitioning_size_getters() { let problem = issue_example(); diff --git a/src/unit_tests/rules/exactcoverby3sets_subsetproduct.rs b/src/unit_tests/rules/exactcoverby3sets_subsetproduct.rs index 7aae73b6e..6433f7dcd 100644 --- a/src/unit_tests/rules/exactcoverby3sets_subsetproduct.rs +++ b/src/unit_tests/rules/exactcoverby3sets_subsetproduct.rs @@ -38,3 +38,17 @@ fn test_exactcoverby3sets_to_subsetproduct_extract_solution_is_identity() { assert_eq!(reduction.extract_solution(&[1, 0, 1]), vec![1, 0, 1]); } + +#[test] +fn test_exactcoverby3sets_to_subsetproduct_supports_large_universe() { + let source = ExactCoverBy3Sets::new(18, vec![[0, 1, 2], [15, 16, 17]]); + let reduction = ReduceTo::::reduce_to(&source); + let target = reduction.target_problem(); + + let expected_sizes: Vec = vec![30u64, 190_747u64] + .into_iter() + .map(BigUint::from) + .collect(); + assert_eq!(target.sizes(), &expected_sizes[..]); + assert!(target.target() > &BigUint::from(u64::MAX)); +} diff --git a/src/unit_tests/rules/ksatisfiability_simultaneousincongruences.rs b/src/unit_tests/rules/ksatisfiability_simultaneousincongruences.rs index 5517a1ecd..c2613cdc6 100644 --- a/src/unit_tests/rules/ksatisfiability_simultaneousincongruences.rs +++ b/src/unit_tests/rules/ksatisfiability_simultaneousincongruences.rs @@ -80,3 +80,11 @@ fn test_ksatisfiability_to_simultaneous_incongruences_tautological_clause_is_red assert!(source.evaluate(&extracted)); } + +#[test] +#[should_panic(expected = "3-SAT -> SimultaneousIncongruences requires the variable-prime product")] +fn test_ksatisfiability_to_simultaneous_incongruences_rejects_large_instances() { + let source = KSatisfiability::::new(7, vec![CNFClause::new(vec![1, 2, 3])]); + + let _ = ReduceTo::::reduce_to(&source); +} From 22309a844884a3e93fa233fcd652601c28fa1660 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 4 Apr 2026 18:55:35 +0800 Subject: [PATCH 20/25] update rules note --- docs/paper/reductions.typ | 216 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 216 insertions(+) diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 3ff1ba9d8..b87596b96 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -86,6 +86,7 @@ "MinimumVertexCover": [Minimum Vertex Cover], "MaxCut": [Max-Cut], "GeneralizedHex": [Generalized Hex], + "GraphPartitioning": [Graph Partitioning], "HamiltonianCircuit": [Hamiltonian Circuit], "BiconnectivityAugmentation": [Biconnectivity Augmentation], "HamiltonianPath": [Hamiltonian Path], @@ -707,6 +708,13 @@ In all graph problems below, $G = (V, E)$ denotes an undirected graph with $|V| ] ] } +#problem-def("GraphPartitioning")[ + Given an undirected graph $G = (V, E)$ with $|V| = n$ (even), find a partition of $V$ into two disjoint sets $A$ and $B$ with $|A| = |B| = n\/2$ minimizing the number of crossing edges $|{(u,v) in E : u in A, v in B}|$. +][ +Graph Partitioning (Minimum Bisection, Garey & Johnson ND14) is the special case of Minimum Cut Into Bounded Sets with unit weights, $B = n\/2$, and no designated $s, t$ vertices. The problem is NP-hard even on 3-regular graphs @garey1976. + +The best known exact algorithm is brute-force enumeration over all balanced partitions in $O^*(2^n)$ time#footnote[No algorithm improving on brute-force enumeration is known for general Graph Partitioning.]. +] #problem-def("MinimumCutIntoBoundedSets")[ Given an undirected graph $G = (V, E)$ with edge weights $w: E -> ZZ^+$, designated vertices $s, t in V$, and a positive integer $B <= |V|$, find a partition of $V$ into disjoint sets $V_1$ and $V_2$ such that $s in V_1$, $t in V_2$, $|V_1| <= B$, $|V_2| <= B$, that minimizes the total cut weight $ sum_({u,v} in E: u in V_1, v in V_2) w({u,v}). $ @@ -13036,5 +13044,213 @@ The following table shows concrete variable overhead for example instances, take _Solution extraction._ First $n$ variables (sign variables) give the source configuration. ] +// ── Batch of 18 reduction rules from derivation document ── + +// 1. SubsetSum → Partition (#973) +#reduction-rule("SubsetSum", "Partition")[ + Given a Subset Sum instance $(S, T)$ with $Sigma = sum s_i$, compute padding $d = |Sigma - 2T|$. If $d = 0$, output Partition$(S)$; otherwise output Partition$(S union {d})$. The reduction adds at most one element. A subset of $S$ sums to $T$ if and only if the padded multiset admits a balanced partition. +][ + _Construction._ Let $(S, T)$ be a Subset Sum instance with $n$ elements and $Sigma = sum_(i=1)^n s_i$. Compute $d = |Sigma - 2T|$. If $d = 0$, output Partition instance $S$. If $d > 0$, output $S union {d}$ (one extra element). Let $Sigma'$ denote the total of the Partition instance and $H = Sigma' \/ 2$. + + _Correctness._ There are three cases. + + *Case 1* ($Sigma = 2T$, $d = 0$): $H = T$. ($arrow.r.double$) A subset summing to $T = H$ is one half of a balanced partition. ($arrow.l.double$) A balanced partition yields a subset summing to $H = T$. + + *Case 2* ($Sigma > 2T$, $d = Sigma - 2T$): $Sigma' = 2(Sigma - T)$, $H = Sigma - T$. ($arrow.r.double$) If $A' subset.eq S$ sums to $T$, then $A' union {d}$ sums to $T + (Sigma - 2T) = H$. ($arrow.l.double$) In any balanced partition, the side containing $d$ has $S$-elements summing to $H - d = T$. + + *Case 3* ($Sigma < 2T$, $d = 2T - Sigma$): $Sigma' = 2T$, $H = T$. ($arrow.r.double$) A subset summing to $T = H$ gives one side directly. ($arrow.l.double$) The side opposite $d$ has $S$-elements summing to $H = T$. + + If $T > Sigma$, then $d > Sigma' \/ 2$, so a single element exceeds the half-sum and the Partition instance is infeasible. + + _Solution extraction._ Given a Partition solution $c in {0,1}^m$: if $d = 0$, return $c[0..n]$. If $Sigma > 2T$, the $S$-elements on the same side as the padding form the subset summing to $T$. If $Sigma < 2T$, the $S$-elements on the opposite side from the padding form the subset summing to $T$. +] + +// 2. Satisfiability → NonTautology (#868) +#reduction-rule("Satisfiability", "NonTautology")[ + Negate the CNF formula via De Morgan's laws to obtain a DNF formula $E = not phi$. The formula $phi$ is satisfiable if and only if $E$ is not a tautology. Variables, their count, and polarity are preserved; each clause becomes a disjunct of negated literals. +][ + _Construction._ Let $phi = C_1 and dots and C_m$ be a CNF formula over $n$ variables. For each clause $C_j = (l_1 or dots or l_k)$, form the disjunct $D_j = (overline(l_1) and dots and overline(l_k))$ where $overline(l)$ is the complement of literal $l$. The Non-Tautology instance is the DNF formula $E = D_1 or dots or D_m$. + + _Correctness._ ($arrow.r.double$) If $alpha models phi$, then $alpha$ makes every clause true, so $alpha models not E$ (since $E = not phi$), and $E$ has a falsifying assignment. ($arrow.l.double$) If $beta$ falsifies $E$, then $beta tack.r.not not phi$, so $beta models phi$ and $phi$ is satisfiable. + + _Solution extraction._ The falsifying assignment for $E$ is directly the satisfying assignment for $phi$ -- no transformation needed since the variables are identical. +] + +// 3. KColoring → PartitionIntoCliques (#844) +#reduction-rule("KColoring", "PartitionIntoCliques")[ + Compute the complement graph $overline(G)$ and set the clique bound $K' = K$. A proper $K$-coloring of $G$ exists if and only if $overline(G)$ can be partitioned into at most $K'$ cliques, because color classes (independent sets in $G$) correspond to cliques in $overline(G)$. +][ + _Construction._ Given $K$-Coloring instance $(G = (V, E), K)$, compute $overline(G) = (V, overline(E))$ where $overline(E) = {{u, v} : u != v, {u,v} in.not E}$. Set $K' = K$. Output Partition Into Cliques instance $(overline(G), K')$. + + _Correctness._ ($arrow.r.double$) If $c : V -> {0, dots, K-1}$ is a proper coloring, define $V_i = {v : c(v) = i}$. For $u, v in V_i$, $c(u) = c(v)$ implies ${u,v} in.not E$, so ${u,v} in overline(E)$, making $V_i$ a clique in $overline(G)$. The $K$ color classes partition $V$ into at most $K' = K$ cliques. ($arrow.l.double$) If $V_0, dots, V_(k-1)$ is a partition of $overline(G)$ into $k <= K'$ cliques, then each $V_i$ is an independent set in $G$. Assigning color $i$ to all vertices in $V_i$ gives a proper $k$-coloring of $G$. + + _Solution extraction._ Given a partition $V_0, dots, V_(k-1)$ into cliques, assign color $i$ to every vertex in $V_i$. +] + +// 4. KSatisfiability → Kernel (#882) +#reduction-rule("KSatisfiability", "Kernel")[ + Given a 3-SAT instance with $n$ variables and $m$ clauses, construct a directed graph with $2n + 3m$ vertices and $2n + 12m$ arcs. Variable gadgets are digons (directed 2-cycles) forcing exactly one literal per variable into any kernel. Clause gadgets are directed 3-cycles whose vertices also point to the corresponding literal vertices, ensuring every clause is satisfied. +][ + _Construction._ Let $phi$ have variables $u_1, dots, u_n$ and clauses $C_1, dots, C_m$ (each with 3 literals). (1) For each variable $u_i$, create vertices $x_i, overline(x)_i$ with arcs $(x_i, overline(x)_i)$ and $(overline(x)_i, x_i)$ (a digon). (2) For each clause $C_j$, create vertices $c_(j,1), c_(j,2), c_(j,3)$ with a directed 3-cycle. (3) For each clause $C_j$ and each literal vertex $v$ of $C_j$, add arcs from each $c_(j,t)$ to $v$ ($t = 1,2,3$). Total: $2n + 3m$ vertices, $2n + 12m$ arcs. + + _Correctness._ ($arrow.r.double$) A satisfying assignment $alpha$ yields a kernel $S$ containing $x_i$ if $alpha(u_i)$ is true, $overline(x)_i$ otherwise. Independence holds since $S$ picks one from each digon. Every literal vertex not in $S$ is absorbed by its digon partner. Every clause vertex is absorbed because at least one literal of its clause is in $S$. ($arrow.l.double$) In any kernel $S$, no clause vertex belongs to $S$ (otherwise a clause vertex's successors would not be absorbed). Thus $S$ contains only literal vertices, exactly one per digon. At least one literal vertex of each clause is in $S$ (to absorb clause vertices), so the derived assignment satisfies every clause. + + _Solution extraction._ Set $alpha(u_i) = "true"$ if $x_i in S$, $alpha(u_i) = "false"$ if $overline(x)_i in S$. +] + +// 5. HamiltonianPath → DegreeConstrainedSpanningTree (#911) +#reduction-rule("HamiltonianPath", "DegreeConstrainedSpanningTree")[ + Pass the graph through unchanged and set the degree bound $K = 2$. A Hamiltonian path is a spanning tree with maximum degree 2 (a path), and conversely any degree-2 spanning tree is a Hamiltonian path. The reduction is size-preserving. +][ + _Construction._ Given Hamiltonian Path instance $G = (V, E)$, output Degree-Constrained Spanning Tree instance $(G, K = 2)$. + + _Correctness._ ($arrow.r.double$) A Hamiltonian path $v_0, v_1, dots, v_(n-1)$ uses $n - 1$ edges spanning all vertices. Interior vertices have degree 2, endpoints have degree 1, so max degree $<= 2 = K$. ($arrow.l.double$) A spanning tree with max degree $<= 2$ has no branching (a branch point requires degree $>= 3$). A connected acyclic graph without branching is a simple path. Since the tree spans all $n$ vertices, it is a Hamiltonian path. + + _Solution extraction._ Collect the selected edges, find an endpoint (degree 1 vertex), walk the path to produce the vertex permutation. +] + +// 6. NAESatisfiability → SetSplitting (#382) +#reduction-rule("NAESatisfiability", "SetSplitting")[ + Each variable $x_(i+1)$ contributes two universe elements ($2i$ for positive, $2i+1$ for negative literal). Complementarity subsets ${2i, 2i+1}$ force opposite colors, and each clause becomes a subset of the corresponding literal elements. A NAE-satisfying assignment exists if and only if the Set Splitting instance admits a valid 2-coloring. +][ + _Construction._ Given NAE-SAT with $n$ variables and $m$ clauses, define universe $U = {0, 1, dots, 2n - 1}$. For each variable $x_(i+1)$, create complementarity subset $R_i = {2i, 2i+1}$. For each clause $C_j$, create subset $T_j$ containing element $2(k-1)$ for positive literal $x_k$ and $2(k-1)+1$ for negative literal $overline(x)_k$. Total: $|U| = 2n$, $n + m$ subsets. + + _Correctness._ ($arrow.r.double$) A NAE-satisfying assignment $alpha$ induces 2-coloring $chi(2i) = alpha(x_(i+1))$, $chi(2i+1) = 1 - alpha(x_(i+1))$. Complementarity subsets are non-monochromatic by construction. Clause subsets are non-monochromatic because NAE ensures both true and false literals. ($arrow.l.double$) A valid 2-coloring with $chi(2i) != chi(2i+1)$ (forced by $R_i$) defines $alpha(x_(i+1)) = chi(2i)$. Non-monochromaticity of clause subsets ensures both true and false literals in each clause. + + _Solution extraction._ Set $alpha(x_(i+1)) = chi(2i)$ for $i = 0, dots, n-1$. +] + +// 7. ExactCoverBy3Sets → SubsetProduct (#388) +#reduction-rule("ExactCoverBy3Sets", "SubsetProduct")[ + Assign the first $3q$ primes $p_0 < dots < p_(3q-1)$ to universe elements. Each 3-element subset $C_j = {a, b, c}$ maps to the integer $s_j = p_a dot p_b dot p_c$. The target product is $B = product_(i=0)^(3q-1) p_i$. By unique factorization, a sub-product equals $B$ if and only if the selected subsets form an exact cover. +][ + _Construction._ Let $(X, cal(C))$ be an X3C instance with $|X| = 3q$ and $cal(C) = {C_1, dots, C_n}$. Assign primes $p_0 = 2, p_1 = 3, dots, p_(3q-1)$. For each $C_j = {a, b, c}$, set $s_j = p_a dot p_b dot p_c$. Set target $B = product_(i=0)^(3q-1) p_i$. + + _Correctness._ ($arrow.r.double$) An exact cover ${C_(j_1), dots, C_(j_q)}$ partitions $X$, so $product s_(j_ell) = product_(i=0)^(3q-1) p_i = B$. ($arrow.l.double$) If $product_(j : x_j = 1) s_j = B$, unique factorization forces each prime to appear exactly once, so the selected subsets are pairwise disjoint and cover all elements. + + _Solution extraction._ The X3C configuration equals the Subset Product configuration: select subset $j$ iff $x_j = 1$. +] + +// 8. SubsetSum → IntegerExpressionMembership (#569) +#reduction-rule("SubsetSum", "IntegerExpressionMembership")[ + For each element $s_i$, build a union node $(1 union (s_i + 1))$, then chain all unions via Minkowski sum. Set target $K = B + n$. Selecting $s_i + 1$ (right branch) encodes including element $i$ in the subset. The expression tree has $4n - 1$ nodes. +][ + _Construction._ Given Subset Sum instance $(S = {s_1, dots, s_n}, B)$, for each $s_i$ construct choice expression $c_i = (1 union (s_i + 1))$. Build the overall expression $e = c_1 + c_2 + dots + c_n$ (Minkowski sum chain). Set target $K = B + n$. + + _Correctness._ ($arrow.r.double$) If $A' subset.eq S$ sums to $B$, choose $d_i = s_i + 1$ for $s_i in A'$ and $d_i = 1$ otherwise. Then $sum d_i = B + |A'| + (n - |A'|) = B + n = K$. ($arrow.l.double$) If $sum d_i = K$ with $d_i in {1, s_i + 1}$, let $A' = {s_i : d_i = s_i + 1}$. Then $sum d_i = sum_(s_i in A') s_i + n$, so $sum_(s_i in A') s_i = B$. + + _Solution extraction._ The Subset Sum configuration is the Integer Expression Membership configuration: $x_i = 1$ (right branch) means element $i$ is selected. +] + +// 9. ExactCoverBy3Sets → MinimumWeightSolutionToLinearEquations (#860) +#reduction-rule("ExactCoverBy3Sets", "MinimumWeightSolutionToLinearEquations")[ + Build the $3q times n$ incidence matrix $A$ where $A_(i,j) = 1$ iff element $u_i in C_j$. Set right-hand side $b = (1, dots, 1)^top$ and weight bound $K = q = |X|\/3$. An exact cover corresponds to a binary solution of weight exactly $q$. +][ + _Construction._ Let $(X, cal(C))$ be an X3C instance with $|X| = 3q$ and $cal(C) = {C_1, dots, C_n}$. Define $n$ variables $y_1, dots, y_n$. Build matrix $A in {0,1}^(3q times n)$ with $A_(i,j) = 1$ iff $u_i in C_j$. Each column has exactly 3 ones. Set $b = bold(1) in ZZ^(3q)$ and $K = q$. + + _Correctness._ ($arrow.r.double$) An exact cover selects $q$ sets, each covering 3 elements with no overlap, giving $A y = b$ with $y in {0,1}^n$ and weight $q = K$. ($arrow.l.double$) If $y$ has at most $K = q$ nonzero entries and $A y = b$, summing all equations gives $3 sum_j y_j = 3q$, so $sum y_j = q$. With $|"support"| <= q$ and each column contributing 3 incidences, every row is hit by exactly one selected column, forcing each nonzero $y_j = 1$. The selected sets form an exact cover. + + _Solution extraction._ Select subset $j$ iff $y_j != 0$. +] + +// 10. KSatisfiability → SimultaneousIncongruences (#554) +#reduction-rule("KSatisfiability", "SimultaneousIncongruences")[ + Each variable $x_i$ gets a distinct prime $p_i >= 5$, encoding TRUE as residue 1 and FALSE as residue 2 modulo $p_i$. All other residues are forbidden. Each clause is encoded via CRT as a single forbidden residue class modulo the product of its variables' primes. A satisfying assignment exists iff some integer avoids all forbidden classes. +][ + _Construction._ Given 3-SAT with $n$ variables and $m$ clauses, assign primes $p_1, dots, p_n >= 5$. For each variable $x_i$, forbid residues ${0, 3, 4, dots, p_i - 1}$ modulo $p_i$, leaving only ${1, 2}$. For each clause $C_j$ over variables $x_(i_1), x_(i_2), x_(i_3)$, compute the falsifying residue $r_k in {1, 2}$ for each literal and use CRT to find $R_j$ with $R_j equiv r_k mod p_(i_k)$ for $k = 1,2,3$. Forbid $R_j$ modulo $M_j = p_(i_1) p_(i_2) p_(i_3)$. + + _Correctness._ ($arrow.r.double$) A satisfying assignment $tau$ defines residues $r_i in {1,2}$ per variable. By CRT, some integer $x$ has these residues. It avoids all variable-forbidden classes and all clause-forbidden classes (since at least one literal is true, the residue triple differs from the falsifying triple). ($arrow.l.double$) Any feasible $x$ has $x mod p_i in {1,2}$ for all $i$. Define $tau(x_i) = "TRUE"$ if residue 1, FALSE if 2. If a clause were false, $x$ would match its forbidden CRT class -- contradiction. + + _Solution extraction._ Set $tau(x_i) = "TRUE"$ if $x mod p_i = 1$, FALSE if $x mod p_i = 2$. +] + +// 11. Partition → SequencingToMinimizeTardyTaskWeight (#471) +#reduction-rule("Partition", "SequencingToMinimizeTardyTaskWeight")[ + Each element $a_i$ becomes a task with length $l(t_i) = a_i$, weight $w(t_i) = a_i$, and common deadline $T = B\/2$. The tardiness bound is $K = T$. Tasks scheduled before $T$ are on-time; those after are tardy. A balanced partition exists iff total tardy weight can be at most $K$. +][ + _Construction._ Given Partition instance $A = {a_1, dots, a_n}$ with total $B$. If $B$ is odd, output a trivially infeasible instance (all deadlines 0, $K = 0$). If $B$ is even, set $T = B\/2$. For each $a_i$, create task $t_i$ with $l(t_i) = w(t_i) = a_i$ and deadline $d(t_i) = T$. Set bound $K = T$. + + _Correctness._ ($arrow.r.double$) A balanced partition $A', A''$ with sums $T$ each: schedule $A'$ first (on-time, total time $T$), then $A''$ (tardy, weight $T = K$). ($arrow.l.double$) If tardy weight $<= K = T$, then on-time tasks fit before $T$ and sum to $<= T$, while tardy tasks have weight $B - sum_("on-time") <= T$, forcing on-time sum $= T$. This yields a balanced partition. + + _Solution extraction._ On-time tasks (completing by $T$) form one partition half ($x_i = 0$), tardy tasks the other ($x_i = 1$). +] + +// 12. Partition → OpenShopScheduling (#481) +#reduction-rule("Partition", "OpenShopScheduling")[ + Create $k + 1$ jobs on 3 machines: $k$ element jobs with $p_(j,i) = a_j$ on all machines, plus one special job with $p = Q = S\/2$. The target makespan is $3Q$. A balanced partition exists iff the open shop can achieve makespan $<= 3Q$, because the special job tiles $[0, 3Q)$ into three $Q$-length blocks and element jobs must fill the remaining idle slots exactly. +][ + _Construction._ Given Partition instance $A = {a_1, dots, a_k}$ with total $S$ and $Q = S\/2$. Set $m = 3$ machines. For each $a_j$, create element job $J_j$ with $p_(j,1) = p_(j,2) = p_(j,3) = a_j$. Create special job $J_(k+1)$ with $p_(k+1,i) = Q$ on all machines. Deadline $D = 3Q$. + + _Correctness._ ($arrow.r.double$) With a balanced partition $I_1, I_2$, schedule the special job consecutively on machines 1, 2, 3 during $[0,Q), [Q,2Q), [2Q,3Q)$. Use a rotated assignment for $I_1$ and $I_2$ jobs to fill the remaining idle blocks, each of length $Q$. ($arrow.l.double$) With makespan $<= 3Q$, the special job alone needs $3Q$ elapsed time, so it tiles $[0,3Q)$ exactly. On each machine, element jobs fill two idle blocks of length $Q$ each. The jobs in one block sum to $Q$, giving a balanced partition. + + _Solution extraction._ Identify the special job's position on machine 1. Element jobs in one idle block form a subset summing to $Q$. +] + +// 13. NAESatisfiability → MaxCut (#166) +#reduction-rule("NAESatisfiability", "MaxCut")[ + For each variable $x_i$, create two vertices $v_i, v_i'$ connected by a heavy edge of weight $M = 2m + 1$. For each clause, add a unit-weight triangle on the three literal vertices. The NAE-SAT instance is satisfiable iff the maximum cut has weight $>= n M + 2m$. This is the classical Garey--Johnson--Stockmeyer construction. +][ + _Construction._ Given NAE-3SAT with $n$ variables and $m$ clauses. Set $M = 2m + 1$. (1) For each variable $x_i$, create vertices $v_i$ (positive) and $v_i'$ (negative) with edge weight $M$. (2) For each clause $C_j = (ell_a, ell_b, ell_c)$, add weight-1 edges $(ell_a, ell_b), (ell_b, ell_c), (ell_a, ell_c)$. Total: $2n$ vertices, at most $n + 3m$ edges. Threshold $W = n M + 2m$. + + _Correctness._ ($arrow.r.double$) A NAE-satisfying $tau$ defines $S = {v_i : tau(x_i) = "true"} union {v_i' : tau(x_i) = "false"}$. All $n$ variable edges are cut (weight $n M$). Each NAE-satisfied clause has a 1-2 split on its triangle, contributing exactly 2 cut edges. Total: $n M + 2m$. ($arrow.l.double$) Since $M > 2m$, all variable edges must be cut to reach $n M + 2m$. With all variable edges cut, $v_i$ and $v_i'$ are on opposite sides, defining a consistent assignment. The remaining $2m$ must come from clause triangles (at most 2 each), so every triangle is 1-2 split, meaning no clause is all-equal. + + _Solution extraction._ Set $x_i = "true"$ if $v_i in S$, else $x_i = "false"$. +] + +// 14. HamiltonianPath → IsomorphicSpanningTree (#912) +#reduction-rule("HamiltonianPath", "IsomorphicSpanningTree")[ + Pass the graph through unchanged and set the target tree $T = P_n$ (the path on $n$ vertices). A Hamiltonian path in $G$ is exactly a spanning tree isomorphic to $P_n$: both are connected, acyclic, span all vertices, and have maximum degree 2. The reduction is size-preserving. +][ + _Construction._ Given $G = (V, E)$ with $|V| = n$, set the host graph to $G$ and the target tree to $T = P_n = ({t_0, dots, t_(n-1)}, {{t_i, t_(i+1)} : 0 <= i <= n-2})$. + + _Correctness._ ($arrow.r.double$) A Hamiltonian path $v_(pi(0)), dots, v_(pi(n-1))$ gives edges ${v_(pi(i)), v_(pi(i+1))}$ forming a spanning subgraph isomorphic to $P_n$ via $phi(t_i) = v_(pi(i))$. ($arrow.l.double$) A spanning tree of $G$ isomorphic to $P_n$ is a path on all $n$ vertices (since $P_n$ has max degree 2 and is connected). The isomorphism $phi$ gives the Hamiltonian path $phi(t_0), dots, phi(t_(n-1))$. + + _Solution extraction._ The isomorphism $phi$ directly yields the Hamiltonian path as the sequence $phi(t_0), phi(t_1), dots, phi(t_(n-1))$. +] + +// 15. ExactCoverBy3Sets → AlgebraicEquationsOverGF2 (#859) +#reduction-rule("ExactCoverBy3Sets", "AlgebraicEquationsOverGF2")[ + One binary variable $x_j$ per subset. For each universe element $u_i$, a linear equation $sum_(j in S_i) x_j + 1 = 0$ (mod 2) enforces odd coverage, and pairwise products $x_j x_k = 0$ (mod 2) forbid double coverage. Together these force exactly-once covering. +][ + _Construction._ Let $(X, cal(C))$ be an X3C instance with $|X| = 3q$ and $cal(C) = {C_1, dots, C_n}$. Define $n$ variables over GF(2). For each element $u_i$, let $S_i = {j : u_i in C_j}$. Add linear constraint $sum_(j in S_i) x_j + 1 = 0$ (mod 2) and pairwise constraints $x_j dot x_k = 0$ (mod 2) for all $j < k in S_i$. Total: at most $3q + sum_i binom(|S_i|, 2)$ equations. + + _Correctness._ ($arrow.r.double$) An exact cover sets $x_j = 1$ for exactly $q$ selected sets. Each element has exactly one covering set, so $sum_(j in S_i) x_j = 1 equiv 1$ (mod 2), satisfying the linear constraint. All pairwise products vanish since at most one $x_j = 1$ per $S_i$. ($arrow.l.double$) The linear constraint forces an odd number of selected sets per element. The pairwise constraints forbid selecting two sets covering the same element. Together: exactly one set per element. Since each set has 3 elements and all $3q$ are covered, exactly $q$ sets are selected. + + _Solution extraction._ The X3C configuration equals the GF(2) configuration: select subset $j$ iff $x_j = 1$. +] + +// 16. Partition → ProductionPlanning (#488) +#reduction-rule("Partition", "ProductionPlanning")[ + Each element $a_i$ becomes a production period with capacity $c_i = a_i$ and setup cost $b_i = a_i$ (zero production and inventory costs). One demand period requires $Q = S\/2$ units with no production capacity. The cost bound is $B = Q$. Activating a subset summing to $Q$ exactly meets demand at cost $Q = B$. +][ + _Construction._ Given Partition instance $A = {a_1, dots, a_n}$ with total $S$ and $Q = S\/2$. If $S$ is odd, output a trivially infeasible instance. Otherwise create $n + 1$ periods: for each $a_i$, period $i$ has $r_i = 0, c_i = a_i, b_i = a_i, p_i = h_i = 0$; demand period $n+1$ has $r_(n+1) = Q, c_(n+1) = 0, b_(n+1) = p_(n+1) = h_(n+1) = 0$. Cost bound $B = Q$. + + _Correctness._ ($arrow.r.double$) A balanced partition $A'$ with sum $Q$ activates those periods: total production $= Q$ meets demand, inventory levels stay non-negative, and total cost $= sum_(i in A') a_i = Q = B$. ($arrow.l.double$) Any feasible plan has setup cost $sum_(i in J) a_i <= Q$ (where $J$ is the set of active periods) and must produce at least $Q$ units. Since $x_i <= c_i = a_i$, total production $<= sum_(i in J) a_i$. These force $sum_(i in J) a_i = Q$, yielding a balanced partition. + + _Solution extraction._ Active element periods ($x_i > 0$) form one partition half. +] + +// 17. HamiltonianPathBetweenTwoVertices → LongestPath (#359) +#reduction-rule("HamiltonianPathBetweenTwoVertices", "LongestPath")[ + Pass the graph through with unit edge lengths, same source $s$ and target $t$, and bound $K = n - 1$. A Hamiltonian $s$-$t$ path uses exactly $n - 1$ edges, which is the maximum possible for a simple path on $n$ vertices. The reduction is size-preserving. +][ + _Construction._ Given $(G = (V, E), s, t)$ with $n = |V|$, set $G' = G$, $ell(e) = 1$ for all $e in E$, $s' = s$, $t' = t$, and $K = n - 1$. + + _Correctness._ ($arrow.r.double$) A Hamiltonian $s$-$t$ path has $n - 1$ edges of length 1 each, giving total length $n - 1 = K$. ($arrow.l.double$) A simple $s'$-$t'$ path of length $>= K = n - 1$ has $>= n - 1$ edges. Since a simple path on $n$ vertices can have at most $n - 1$ edges, it has exactly $n - 1$ edges and visits all vertices -- it is a Hamiltonian $s$-$t$ path. + + _Solution extraction._ From the edge-selection vector, trace the path from $s$ following selected edges to reconstruct the vertex permutation. +] + +// 18. GraphPartitioning → MaxCut (from main codebase) +#reduction-rule("GraphPartitioning", "MaxCut")[ + Build a weighted complete graph on the same $n$ vertices. Edges present in $G$ receive weight $P - 1$ and non-edges receive weight $P$, where $P = |E| + 1$. The heavy non-edge weights force any maximum balanced cut to avoid splitting non-adjacent pairs, effectively minimizing crossing edges from $E$. Variables correspond one-to-one. +][ + _Construction._ Given Graph Partitioning instance $G = (V, E)$, let $P = |E| + 1$. Build $K_n$ with edge weights $w(u,v) = P - 1$ if ${u,v} in E$ and $w(u,v) = P$ if ${u,v} in.not E$. The Max-Cut instance has $n$ vertices and $n(n-1)\/2$ edges. + + _Correctness._ ($arrow.r.double$) A balanced partition $(A, B)$ with $|A| = |B| = n\/2$ cutting $c$ edges of $G$ yields a cut of weight $(P-1) dot c + P dot (n\/2 dot n\/2 - c - "non-edges within halves") = dots$ For any balanced partition, increasing the number of non-edges crossing the cut increases the total weight (since non-edges have higher weight $P > P - 1$), so the maximum cut places as many non-edges as possible across the cut, equivalently minimizing edges of $G$ across the cut. ($arrow.l.double$) The maximum-weight balanced cut in $K_n$ corresponds to the minimum bisection of $G$. + + _Solution extraction._ The Max-Cut partition assignment is directly the Graph Partitioning assignment: $x_v = 0$ for side $A$, $x_v = 1$ for side $B$. +] + #pagebreak() #bibliography("references.bib", style: "ieee") From 0f536ac4364709f6d6f65fc48784c1ab2eaf1a89 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 4 Apr 2026 19:22:02 +0800 Subject: [PATCH 21/25] docs: add Source Material section to write-rule-in-paper skill Adds guidance to consult GitHub issues and derivation documents as primary sources for mathematical content, rather than inventing proofs. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/write-rule-in-paper/SKILL.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.claude/skills/write-rule-in-paper/SKILL.md b/.claude/skills/write-rule-in-paper/SKILL.md index c10e52e43..0d9755b1b 100644 --- a/.claude/skills/write-rule-in-paper/SKILL.md +++ b/.claude/skills/write-rule-in-paper/SKILL.md @@ -21,6 +21,15 @@ Before using this skill, ensure: - If the canonical example changed, fixtures are regenerated (`make regenerate-fixtures`) - The reduction graph and schemas are up to date (`cargo run --example export_graph && cargo run --example export_schemas`) +## Source Material + +For mathematical content (theorems, proofs, examples), consult these sources in priority order: +1. **GitHub issue** for the rule (`gh issue view `): contains the verified reduction algorithm, correctness proof, size overhead, and worked examples written during issue creation +2. **Derivation documents** (if available): e.g., `~/Downloads/reduction_derivations_*.typ` — these contain batch-verified proofs with explicit theorem/proof blocks +3. **The implementation** (`src/rules/_.rs`): the code is the ground truth for the construction + +Do NOT invent proofs — always cross-check against the issue and derivation sources. The issue body typically has: Reduction Algorithm, Correctness (forward/backward), Size Overhead, Example, and References sections that map directly to the paper entry structure. + ## Step 1: Load Example Data ```typst From 04865968a66bded54eb8c0b76aef43800897837a Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 4 Apr 2026 19:40:29 +0800 Subject: [PATCH 22/25] docs: augment 18 paper entries with examples, citations, and pred-commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each reduction-rule entry now includes: - load-example data bindings and example: true parameter - Complexity citation (@garey1979, @karp1972, etc.) - Worked example with pred-commands() reproducibility block - Step-by-step verification using fixture data Fixed entry #13 (NAE-SAT → MaxCut): proper @citation. Fixed entry #18 (GraphPartitioning → MaxCut): completed proof replacing $= dots$ placeholder with full derivation. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/paper/reductions.typ | 460 +++++++++++++++++++++++++++++++++++--- 1 file changed, 423 insertions(+), 37 deletions(-) diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index b87596b96..41d5225c8 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -13047,8 +13047,37 @@ The following table shows concrete variable overhead for example instances, take // ── Batch of 18 reduction rules from derivation document ── // 1. SubsetSum → Partition (#973) -#reduction-rule("SubsetSum", "Partition")[ - Given a Subset Sum instance $(S, T)$ with $Sigma = sum s_i$, compute padding $d = |Sigma - 2T|$. If $d = 0$, output Partition$(S)$; otherwise output Partition$(S union {d})$. The reduction adds at most one element. A subset of $S$ sums to $T$ if and only if the padded multiset admits a balanced partition. +#let ss_part = load-example("SubsetSum", "Partition") +#let ss_part_sol = ss_part.solutions.at(0) +#reduction-rule("SubsetSum", "Partition", + example: true, + example-caption: [#subsetsum-num-elements(ss_part.source.instance) elements, target $T = #ss_part.source.instance.target$], + extra: [ + #pred-commands( + "pred create --example " + problem-spec(ss_part.source) + " -o subsetsum.json", + "pred reduce subsetsum.json --to " + target-spec(ss_part) + " -o bundle.json", + "pred solve bundle.json", + "pred evaluate subsetsum.json --config " + ss_part_sol.source_config.map(str).join(","), + ) + + #{ + let sizes = ss_part.source.instance.sizes.map(s => int(s)) + let sigma = sizes.sum() + let T = int(ss_part.source.instance.target) + let d = calc.abs(sigma - 2 * T) + [ + *Step 1 -- Source instance.* Subset Sum with sizes $(#sizes.map(str).join(", "))$ and target $T = #T$. Total $Sigma = #sigma$. + + *Step 2 -- Compute padding.* $Sigma = #sigma$, $2T = #(2 * T)$. Since $Sigma < 2T$, we have $d = 2T - Sigma = #d$. The Partition instance is $S union {d} = (#ss_part.target.instance.sizes.map(str).join(", "))$ with #ss_part.target.instance.sizes.len() elements. + + *Step 3 -- Verify a solution.* Source config $(#ss_part_sol.source_config.map(str).join(", "))$: selected elements $= {#ss_part_sol.source_config.enumerate().filter(((i, x)) => x == 1).map(((i, x)) => str(sizes.at(i))).join(", ")}$ sum to $#T = T$ #sym.checkmark. Target config $(#ss_part_sol.target_config.map(str).join(", "))$: side-0 sum $= #ss_part_sol.target_config.enumerate().filter(((i, x)) => x == 0).map(((i, x)) => ss_part.target.instance.sizes.at(i)).sum()$, side-1 sum $= #ss_part_sol.target_config.enumerate().filter(((i, x)) => x == 1).map(((i, x)) => ss_part.target.instance.sizes.at(i)).sum()$ -- balanced #sym.checkmark. + ] + } + + *Multiplicity:* The fixture stores one canonical witness. Other valid subsets summing to $T$ may exist. + ], +)[ + This $O(n)$ reduction @garey1979 computes padding $d = |Sigma - 2T|$ and appends at most one element. A subset of $S$ sums to $T$ if and only if the padded multiset admits a balanced partition. ][ _Construction._ Let $(S, T)$ be a Subset Sum instance with $n$ elements and $Sigma = sum_(i=1)^n s_i$. Compute $d = |Sigma - 2T|$. If $d = 0$, output Partition instance $S$. If $d > 0$, output $S union {d}$ (one extra element). Let $Sigma'$ denote the total of the Partition instance and $H = Sigma' \/ 2$. @@ -13066,8 +13095,29 @@ The following table shows concrete variable overhead for example instances, take ] // 2. Satisfiability → NonTautology (#868) -#reduction-rule("Satisfiability", "NonTautology")[ - Negate the CNF formula via De Morgan's laws to obtain a DNF formula $E = not phi$. The formula $phi$ is satisfiable if and only if $E$ is not a tautology. Variables, their count, and polarity are preserved; each clause becomes a disjunct of negated literals. +#let sat_nt = load-example("Satisfiability", "NonTautology") +#let sat_nt_sol = sat_nt.solutions.at(0) +#reduction-rule("Satisfiability", "NonTautology", + example: true, + example-caption: [$n = #sat_nt.source.instance.num_vars$ variables, $m = #sat-num-clauses(sat_nt.source.instance)$ clauses], + extra: [ + #pred-commands( + "pred create --example " + problem-spec(sat_nt.source) + " -o sat.json", + "pred reduce sat.json --to " + target-spec(sat_nt) + " -o bundle.json", + "pred solve bundle.json", + "pred evaluate sat.json --config " + sat_nt_sol.source_config.map(str).join(","), + ) + + *Step 1 -- Source instance.* CNF with $n = #sat_nt.source.instance.num_vars$ variables and $m = #sat-num-clauses(sat_nt.source.instance)$ clauses. + + *Step 2 -- Apply De Morgan.* Each clause $C_j = (l_1 or dots or l_k)$ becomes disjunct $D_j = (overline(l_1) and dots and overline(l_k))$. The Non-Tautology instance has #sat_nt.target.instance.disjuncts.len() disjuncts over #sat_nt.target.instance.num_vars variables. + + *Step 3 -- Verify a solution.* Source config $(#sat_nt_sol.source_config.map(str).join(", "))$ satisfies the CNF. Target config $(#sat_nt_sol.target_config.map(str).join(", "))$ falsifies the DNF (same assignment). Variables are identical #sym.checkmark. + + *Multiplicity:* The fixture stores one canonical witness. + ], +)[ + This $O(n + m)$ reduction @garey1979 negates a CNF formula via De Morgan's laws to obtain a DNF formula $E = not phi$. The formula $phi$ is satisfiable if and only if $E$ is not a tautology. Variables, their count, and polarity are preserved; each clause becomes a disjunct of negated literals. ][ _Construction._ Let $phi = C_1 and dots and C_m$ be a CNF formula over $n$ variables. For each clause $C_j = (l_1 or dots or l_k)$, form the disjunct $D_j = (overline(l_1) and dots and overline(l_k))$ where $overline(l)$ is the complement of literal $l$. The Non-Tautology instance is the DNF formula $E = D_1 or dots or D_m$. @@ -13077,8 +13127,29 @@ The following table shows concrete variable overhead for example instances, take ] // 3. KColoring → PartitionIntoCliques (#844) -#reduction-rule("KColoring", "PartitionIntoCliques")[ - Compute the complement graph $overline(G)$ and set the clique bound $K' = K$. A proper $K$-coloring of $G$ exists if and only if $overline(G)$ can be partitioned into at most $K'$ cliques, because color classes (independent sets in $G$) correspond to cliques in $overline(G)$. +#let kc_pic = load-example("KColoring", "PartitionIntoCliques") +#let kc_pic_sol = kc_pic.solutions.at(0) +#reduction-rule("KColoring", "PartitionIntoCliques", + example: true, + example-caption: [$n = #graph-num-vertices(kc_pic.source.instance)$ vertices, $k = #kc_pic.source.instance.num_colors$ colors], + extra: [ + #pred-commands( + "pred create --example " + problem-spec(kc_pic.source) + " -o kcoloring.json", + "pred reduce kcoloring.json --to " + target-spec(kc_pic) + " -o bundle.json", + "pred solve bundle.json", + "pred evaluate kcoloring.json --config " + kc_pic_sol.source_config.map(str).join(","), + ) + + *Step 1 -- Source instance.* Graph $G$ with $n = #graph-num-vertices(kc_pic.source.instance)$ vertices, $|E| = #graph-num-edges(kc_pic.source.instance)$ edges, $k = #kc_pic.source.instance.num_colors$ colors. + + *Step 2 -- Complement graph.* $overline(G)$ has the same $n = #graph-num-vertices(kc_pic.target.instance)$ vertices and $|overline(E)| = #graph-num-edges(kc_pic.target.instance)$ edges. Clique bound $K' = #kc_pic.target.instance.num_cliques$. + + *Step 3 -- Verify a solution.* Source coloring $(#kc_pic_sol.source_config.map(str).join(", "))$. Target partition $(#kc_pic_sol.target_config.map(str).join(", "))$ -- each color class is a clique in $overline(G)$ #sym.checkmark. + + *Multiplicity:* The fixture stores one canonical witness. + ], +)[ + This $O(n^2)$ reduction @karp1972 computes the complement graph $overline(G)$ and sets the clique bound $K' = K$. A proper $K$-coloring of $G$ exists if and only if $overline(G)$ can be partitioned into at most $K'$ cliques, because color classes (independent sets in $G$) correspond to cliques in $overline(G)$. ][ _Construction._ Given $K$-Coloring instance $(G = (V, E), K)$, compute $overline(G) = (V, overline(E))$ where $overline(E) = {{u, v} : u != v, {u,v} in.not E}$. Set $K' = K$. Output Partition Into Cliques instance $(overline(G), K')$. @@ -13088,8 +13159,29 @@ The following table shows concrete variable overhead for example instances, take ] // 4. KSatisfiability → Kernel (#882) -#reduction-rule("KSatisfiability", "Kernel")[ - Given a 3-SAT instance with $n$ variables and $m$ clauses, construct a directed graph with $2n + 3m$ vertices and $2n + 12m$ arcs. Variable gadgets are digons (directed 2-cycles) forcing exactly one literal per variable into any kernel. Clause gadgets are directed 3-cycles whose vertices also point to the corresponding literal vertices, ensuring every clause is satisfied. +#let ksat_ker = load-example("KSatisfiability", "Kernel") +#let ksat_ker_sol = ksat_ker.solutions.at(0) +#reduction-rule("KSatisfiability", "Kernel", + example: true, + example-caption: [$n = #ksat_ker.source.instance.num_vars$ variables, $m = #sat-num-clauses(ksat_ker.source.instance)$ clauses], + extra: [ + #pred-commands( + "pred create --example " + problem-spec(ksat_ker.source) + " -o ksat.json", + "pred reduce ksat.json --to " + target-spec(ksat_ker) + " -o bundle.json", + "pred solve bundle.json", + "pred evaluate ksat.json --config " + ksat_ker_sol.source_config.map(str).join(","), + ) + + *Step 1 -- Source instance.* 3-SAT with $n = #ksat_ker.source.instance.num_vars$ variables and $m = #sat-num-clauses(ksat_ker.source.instance)$ clauses. + + *Step 2 -- Construct digraph.* Variable gadgets: $2n = #(2 * ksat_ker.source.instance.num_vars)$ vertices (digons). Clause gadgets: $3m = #(3 * sat-num-clauses(ksat_ker.source.instance))$ vertices (directed 3-cycles). Total: $#ksat_ker.target.instance.graph.num_vertices$ vertices, $#ksat_ker.target.instance.graph.arcs.len()$ arcs. + + *Step 3 -- Verify a solution.* Source config $(#ksat_ker_sol.source_config.map(str).join(", "))$ satisfies all clauses. Target kernel selects #{ksat_ker_sol.target_config.filter(x => x == 1).len()} vertices: indices $= {#ksat_ker_sol.target_config.enumerate().filter(((i, x)) => x == 1).map(((i, x)) => str(i)).join(", ")}$ #sym.checkmark. + + *Multiplicity:* The fixture stores one canonical witness. + ], +)[ + This $O(n + m)$ reduction @garey1979 constructs a directed graph with $2n + 3m$ vertices and $2n + 12m$ arcs. Variable gadgets are digons (directed 2-cycles) forcing exactly one literal per variable into any kernel. Clause gadgets are directed 3-cycles whose vertices also point to the corresponding literal vertices, ensuring every clause is satisfied. ][ _Construction._ Let $phi$ have variables $u_1, dots, u_n$ and clauses $C_1, dots, C_m$ (each with 3 literals). (1) For each variable $u_i$, create vertices $x_i, overline(x)_i$ with arcs $(x_i, overline(x)_i)$ and $(overline(x)_i, x_i)$ (a digon). (2) For each clause $C_j$, create vertices $c_(j,1), c_(j,2), c_(j,3)$ with a directed 3-cycle. (3) For each clause $C_j$ and each literal vertex $v$ of $C_j$, add arcs from each $c_(j,t)$ to $v$ ($t = 1,2,3$). Total: $2n + 3m$ vertices, $2n + 12m$ arcs. @@ -13099,8 +13191,29 @@ The following table shows concrete variable overhead for example instances, take ] // 5. HamiltonianPath → DegreeConstrainedSpanningTree (#911) -#reduction-rule("HamiltonianPath", "DegreeConstrainedSpanningTree")[ - Pass the graph through unchanged and set the degree bound $K = 2$. A Hamiltonian path is a spanning tree with maximum degree 2 (a path), and conversely any degree-2 spanning tree is a Hamiltonian path. The reduction is size-preserving. +#let hp_dcst = load-example("HamiltonianPath", "DegreeConstrainedSpanningTree") +#let hp_dcst_sol = hp_dcst.solutions.at(0) +#reduction-rule("HamiltonianPath", "DegreeConstrainedSpanningTree", + example: true, + example-caption: [$n = #graph-num-vertices(hp_dcst.source.instance)$ vertices, $K = 2$], + extra: [ + #pred-commands( + "pred create --example " + problem-spec(hp_dcst.source) + " -o hampath.json", + "pred reduce hampath.json --to " + target-spec(hp_dcst) + " -o bundle.json", + "pred solve bundle.json", + "pred evaluate hampath.json --config " + hp_dcst_sol.source_config.map(str).join(","), + ) + + *Step 1 -- Source instance.* Graph $G$ with $n = #graph-num-vertices(hp_dcst.source.instance)$ vertices and $|E| = #graph-num-edges(hp_dcst.source.instance)$ edges. + + *Step 2 -- Identity reduction.* Target graph is identical: $n = #graph-num-vertices(hp_dcst.target.instance)$ vertices, $|E| = #graph-num-edges(hp_dcst.target.instance)$ edges, degree bound $K = #hp_dcst.target.instance.max_degree$. + + *Step 3 -- Verify a solution.* Hamiltonian path visits vertices in order $(#hp_dcst_sol.source_config.map(str).join(", "))$. The corresponding spanning tree selects #hp_dcst_sol.target_config.filter(x => x == 1).len() edges (all with max degree $<= 2$) #sym.checkmark. + + *Multiplicity:* The fixture stores one canonical witness. + ], +)[ + This $O(n + m)$ reduction @garey1979 passes the graph through unchanged and sets the degree bound $K = 2$. A Hamiltonian path is a spanning tree with maximum degree 2 (a path), and conversely any degree-2 spanning tree is a Hamiltonian path. The reduction is size-preserving. ][ _Construction._ Given Hamiltonian Path instance $G = (V, E)$, output Degree-Constrained Spanning Tree instance $(G, K = 2)$. @@ -13110,8 +13223,29 @@ The following table shows concrete variable overhead for example instances, take ] // 6. NAESatisfiability → SetSplitting (#382) -#reduction-rule("NAESatisfiability", "SetSplitting")[ - Each variable $x_(i+1)$ contributes two universe elements ($2i$ for positive, $2i+1$ for negative literal). Complementarity subsets ${2i, 2i+1}$ force opposite colors, and each clause becomes a subset of the corresponding literal elements. A NAE-satisfying assignment exists if and only if the Set Splitting instance admits a valid 2-coloring. +#let nae_ss = load-example("NAESatisfiability", "SetSplitting") +#let nae_ss_sol = nae_ss.solutions.at(0) +#reduction-rule("NAESatisfiability", "SetSplitting", + example: true, + example-caption: [$n = #nae_ss.source.instance.num_vars$ variables, $m = #sat-num-clauses(nae_ss.source.instance)$ clauses], + extra: [ + #pred-commands( + "pred create --example " + problem-spec(nae_ss.source) + " -o naesat.json", + "pred reduce naesat.json --to " + target-spec(nae_ss) + " -o bundle.json", + "pred solve bundle.json", + "pred evaluate naesat.json --config " + nae_ss_sol.source_config.map(str).join(","), + ) + + *Step 1 -- Source instance.* NAE-SAT with $n = #nae_ss.source.instance.num_vars$ variables and $m = #sat-num-clauses(nae_ss.source.instance)$ clauses. + + *Step 2 -- Construct Set Splitting instance.* Universe $|U| = #nae_ss.target.instance.universe_size = 2n$. Total subsets: #nae_ss.target.instance.subsets.len() ($n$ complementarity + $m$ clause subsets). + + *Step 3 -- Verify a solution.* Source NAE-assignment $(#nae_ss_sol.source_config.map(str).join(", "))$. Target 2-coloring $(#nae_ss_sol.target_config.map(str).join(", "))$: complementarity subsets are non-monochromatic, clause subsets are non-monochromatic #sym.checkmark. + + *Multiplicity:* The fixture stores one canonical witness. + ], +)[ + This $O(n + m)$ reduction @garey1979 maps each variable $x_(i+1)$ to two universe elements ($2i$ for positive, $2i+1$ for negative literal). Complementarity subsets ${2i, 2i+1}$ force opposite colors, and each clause becomes a subset of the corresponding literal elements. A NAE-satisfying assignment exists if and only if the Set Splitting instance admits a valid 2-coloring. ][ _Construction._ Given NAE-SAT with $n$ variables and $m$ clauses, define universe $U = {0, 1, dots, 2n - 1}$. For each variable $x_(i+1)$, create complementarity subset $R_i = {2i, 2i+1}$. For each clause $C_j$, create subset $T_j$ containing element $2(k-1)$ for positive literal $x_k$ and $2(k-1)+1$ for negative literal $overline(x)_k$. Total: $|U| = 2n$, $n + m$ subsets. @@ -13121,8 +13255,29 @@ The following table shows concrete variable overhead for example instances, take ] // 7. ExactCoverBy3Sets → SubsetProduct (#388) -#reduction-rule("ExactCoverBy3Sets", "SubsetProduct")[ - Assign the first $3q$ primes $p_0 < dots < p_(3q-1)$ to universe elements. Each 3-element subset $C_j = {a, b, c}$ maps to the integer $s_j = p_a dot p_b dot p_c$. The target product is $B = product_(i=0)^(3q-1) p_i$. By unique factorization, a sub-product equals $B$ if and only if the selected subsets form an exact cover. +#let x3c_sp = load-example("ExactCoverBy3Sets", "SubsetProduct") +#let x3c_sp_sol = x3c_sp.solutions.at(0) +#reduction-rule("ExactCoverBy3Sets", "SubsetProduct", + example: true, + example-caption: [$|U| = #x3c_sp.source.instance.universe_size$, $|cal(C)| = #x3c_sp.source.instance.subsets.len()$ subsets], + extra: [ + #pred-commands( + "pred create --example " + problem-spec(x3c_sp.source) + " -o x3c.json", + "pred reduce x3c.json --to " + target-spec(x3c_sp) + " -o bundle.json", + "pred solve bundle.json", + "pred evaluate x3c.json --config " + x3c_sp_sol.source_config.map(str).join(","), + ) + + *Step 1 -- Source instance.* X3C with $|U| = #x3c_sp.source.instance.universe_size$ and $|cal(C)| = #x3c_sp.source.instance.subsets.len()$ subsets. + + *Step 2 -- Assign primes and compute products.* Target Subset Product sizes: $(#x3c_sp.target.instance.sizes.join(", "))$, target product $B = #x3c_sp.target.instance.target$. + + *Step 3 -- Verify a solution.* Source config $(#x3c_sp_sol.source_config.map(str).join(", "))$: selected subsets form an exact cover. Target config $(#x3c_sp_sol.target_config.map(str).join(", "))$: selected products multiply to $B$ #sym.checkmark. + + *Multiplicity:* The fixture stores one canonical witness. + ], +)[ + This $O(q^2 log q)$ reduction @garey1979 assigns the first $3q$ primes to universe elements. Each 3-element subset $C_j = {a, b, c}$ maps to $s_j = p_a dot p_b dot p_c$. The target product is $B = product_(i=0)^(3q-1) p_i$. By unique factorization, a sub-product equals $B$ if and only if the selected subsets form an exact cover. ][ _Construction._ Let $(X, cal(C))$ be an X3C instance with $|X| = 3q$ and $cal(C) = {C_1, dots, C_n}$. Assign primes $p_0 = 2, p_1 = 3, dots, p_(3q-1)$. For each $C_j = {a, b, c}$, set $s_j = p_a dot p_b dot p_c$. Set target $B = product_(i=0)^(3q-1) p_i$. @@ -13132,8 +13287,29 @@ The following table shows concrete variable overhead for example instances, take ] // 8. SubsetSum → IntegerExpressionMembership (#569) -#reduction-rule("SubsetSum", "IntegerExpressionMembership")[ - For each element $s_i$, build a union node $(1 union (s_i + 1))$, then chain all unions via Minkowski sum. Set target $K = B + n$. Selecting $s_i + 1$ (right branch) encodes including element $i$ in the subset. The expression tree has $4n - 1$ nodes. +#let ss_iem = load-example("SubsetSum", "IntegerExpressionMembership") +#let ss_iem_sol = ss_iem.solutions.at(0) +#reduction-rule("SubsetSum", "IntegerExpressionMembership", + example: true, + example-caption: [#subsetsum-num-elements(ss_iem.source.instance) elements, target $B = #ss_iem.source.instance.target$], + extra: [ + #pred-commands( + "pred create --example " + problem-spec(ss_iem.source) + " -o subsetsum.json", + "pred reduce subsetsum.json --to " + target-spec(ss_iem) + " -o bundle.json", + "pred solve bundle.json", + "pred evaluate subsetsum.json --config " + ss_iem_sol.source_config.map(str).join(","), + ) + + *Step 1 -- Source instance.* Subset Sum with sizes $(#ss_iem.source.instance.sizes.join(", "))$ and target $B = #ss_iem.source.instance.target$. + + *Step 2 -- Construct expression.* Each element $s_i$ becomes a union node $(1 union (s_i + 1))$, chained via Minkowski sum. Target $K = B + n = #ss_iem.target.instance.target$. + + *Step 3 -- Verify a solution.* Source config $(#ss_iem_sol.source_config.map(str).join(", "))$: selected elements sum to $B$. Target config $(#ss_iem_sol.target_config.map(str).join(", "))$ encodes the same selection (right branch = include) #sym.checkmark. + + *Multiplicity:* The fixture stores one canonical witness. + ], +)[ + This $O(n)$ reduction @stockmeyer1973 builds for each element $s_i$ a union node $(1 union (s_i + 1))$, then chains all unions via Minkowski sum. Set target $K = B + n$. Selecting $s_i + 1$ (right branch) encodes including element $i$ in the subset. The expression tree has $4n - 1$ nodes. ][ _Construction._ Given Subset Sum instance $(S = {s_1, dots, s_n}, B)$, for each $s_i$ construct choice expression $c_i = (1 union (s_i + 1))$. Build the overall expression $e = c_1 + c_2 + dots + c_n$ (Minkowski sum chain). Set target $K = B + n$. @@ -13143,8 +13319,29 @@ The following table shows concrete variable overhead for example instances, take ] // 9. ExactCoverBy3Sets → MinimumWeightSolutionToLinearEquations (#860) -#reduction-rule("ExactCoverBy3Sets", "MinimumWeightSolutionToLinearEquations")[ - Build the $3q times n$ incidence matrix $A$ where $A_(i,j) = 1$ iff element $u_i in C_j$. Set right-hand side $b = (1, dots, 1)^top$ and weight bound $K = q = |X|\/3$. An exact cover corresponds to a binary solution of weight exactly $q$. +#let x3c_mwle = load-example("ExactCoverBy3Sets", "MinimumWeightSolutionToLinearEquations") +#let x3c_mwle_sol = x3c_mwle.solutions.at(0) +#reduction-rule("ExactCoverBy3Sets", "MinimumWeightSolutionToLinearEquations", + example: true, + example-caption: [$|U| = #x3c_mwle.source.instance.universe_size$, $|cal(C)| = #x3c_mwle.source.instance.subsets.len()$ subsets], + extra: [ + #pred-commands( + "pred create --example " + problem-spec(x3c_mwle.source) + " -o x3c.json", + "pred reduce x3c.json --to " + target-spec(x3c_mwle) + " -o bundle.json", + "pred solve bundle.json", + "pred evaluate x3c.json --config " + x3c_mwle_sol.source_config.map(str).join(","), + ) + + *Step 1 -- Source instance.* X3C with $|U| = #x3c_mwle.source.instance.universe_size$ and $|cal(C)| = #x3c_mwle.source.instance.subsets.len()$ subsets, $q = #(x3c_mwle.source.instance.universe_size / 3)$. + + *Step 2 -- Construct linear system.* Incidence matrix $A$ has #x3c_mwle.target.instance.matrix.len() rows and #x3c_mwle.target.instance.matrix.at(0).len() columns. Right-hand side $b = (#x3c_mwle.target.instance.rhs.map(str).join(", "))$, weight bound $K = q = #(x3c_mwle.source.instance.universe_size / 3)$. + + *Step 3 -- Verify a solution.* Source config $(#x3c_mwle_sol.source_config.map(str).join(", "))$: selected subsets form an exact cover. Target config $(#x3c_mwle_sol.target_config.map(str).join(", "))$: weight $= #x3c_mwle_sol.target_config.filter(x => x != 0).len() <= K$ #sym.checkmark. + + *Multiplicity:* The fixture stores one canonical witness. + ], +)[ + This $O(q n)$ reduction @garey1979 builds the $3q times n$ incidence matrix $A$ where $A_(i,j) = 1$ iff element $u_i in C_j$. Set right-hand side $b = (1, dots, 1)^top$ and weight bound $K = q = |X|\/3$. An exact cover corresponds to a binary solution of weight exactly $q$. ][ _Construction._ Let $(X, cal(C))$ be an X3C instance with $|X| = 3q$ and $cal(C) = {C_1, dots, C_n}$. Define $n$ variables $y_1, dots, y_n$. Build matrix $A in {0,1}^(3q times n)$ with $A_(i,j) = 1$ iff $u_i in C_j$. Each column has exactly 3 ones. Set $b = bold(1) in ZZ^(3q)$ and $K = q$. @@ -13154,8 +13351,29 @@ The following table shows concrete variable overhead for example instances, take ] // 10. KSatisfiability → SimultaneousIncongruences (#554) -#reduction-rule("KSatisfiability", "SimultaneousIncongruences")[ - Each variable $x_i$ gets a distinct prime $p_i >= 5$, encoding TRUE as residue 1 and FALSE as residue 2 modulo $p_i$. All other residues are forbidden. Each clause is encoded via CRT as a single forbidden residue class modulo the product of its variables' primes. A satisfying assignment exists iff some integer avoids all forbidden classes. +#let ksat_si = load-example("KSatisfiability", "SimultaneousIncongruences") +#let ksat_si_sol = ksat_si.solutions.at(0) +#reduction-rule("KSatisfiability", "SimultaneousIncongruences", + example: true, + example-caption: [$n = #ksat_si.source.instance.num_vars$ variables, $m = #sat-num-clauses(ksat_si.source.instance)$ clauses], + extra: [ + #pred-commands( + "pred create --example " + problem-spec(ksat_si.source) + " -o ksat.json", + "pred reduce ksat.json --to " + target-spec(ksat_si) + " -o bundle.json", + "pred solve bundle.json", + "pred evaluate ksat.json --config " + ksat_si_sol.source_config.map(str).join(","), + ) + + *Step 1 -- Source instance.* 3-SAT with $n = #ksat_si.source.instance.num_vars$ variables and $m = #sat-num-clauses(ksat_si.source.instance)$ clauses. + + *Step 2 -- Construct incongruences.* Each variable gets a prime $>= 5$. Total: #ksat_si.target.instance.pairs.len() forbidden $(a, b)$ pairs. + + *Step 3 -- Verify a solution.* Source config $(#ksat_si_sol.source_config.map(str).join(", "))$ satisfies all clauses. Target: integer $x = #ksat_si_sol.target_config.at(0)$ avoids all #ksat_si.target.instance.pairs.len() forbidden residue classes #sym.checkmark. + + *Multiplicity:* The fixture stores one canonical witness. + ], +)[ + This $O(n^2 + m)$ reduction @stockmeyer1973 assigns each variable $x_i$ a distinct prime $p_i >= 5$, encoding TRUE as residue 1 and FALSE as residue 2 modulo $p_i$. All other residues are forbidden. Each clause is encoded via CRT as a single forbidden residue class modulo the product of its variables' primes. A satisfying assignment exists iff some integer avoids all forbidden classes. ][ _Construction._ Given 3-SAT with $n$ variables and $m$ clauses, assign primes $p_1, dots, p_n >= 5$. For each variable $x_i$, forbid residues ${0, 3, 4, dots, p_i - 1}$ modulo $p_i$, leaving only ${1, 2}$. For each clause $C_j$ over variables $x_(i_1), x_(i_2), x_(i_3)$, compute the falsifying residue $r_k in {1, 2}$ for each literal and use CRT to find $R_j$ with $R_j equiv r_k mod p_(i_k)$ for $k = 1,2,3$. Forbid $R_j$ modulo $M_j = p_(i_1) p_(i_2) p_(i_3)$. @@ -13165,8 +13383,29 @@ The following table shows concrete variable overhead for example instances, take ] // 11. Partition → SequencingToMinimizeTardyTaskWeight (#471) -#reduction-rule("Partition", "SequencingToMinimizeTardyTaskWeight")[ - Each element $a_i$ becomes a task with length $l(t_i) = a_i$, weight $w(t_i) = a_i$, and common deadline $T = B\/2$. The tardiness bound is $K = T$. Tasks scheduled before $T$ are on-time; those after are tardy. A balanced partition exists iff total tardy weight can be at most $K$. +#let part_stw = load-example("Partition", "SequencingToMinimizeTardyTaskWeight") +#let part_stw_sol = part_stw.solutions.at(0) +#reduction-rule("Partition", "SequencingToMinimizeTardyTaskWeight", + example: true, + example-caption: [#part_stw.source.instance.sizes.len() elements, total $= #part_stw.source.instance.sizes.sum()$], + extra: [ + #pred-commands( + "pred create --example " + problem-spec(part_stw.source) + " -o partition.json", + "pred reduce partition.json --to " + target-spec(part_stw) + " -o bundle.json", + "pred solve bundle.json", + "pred evaluate partition.json --config " + part_stw_sol.source_config.map(str).join(","), + ) + + *Step 1 -- Source instance.* Partition with sizes $(#part_stw.source.instance.sizes.map(str).join(", "))$, total $= #part_stw.source.instance.sizes.sum()$, half $= #(part_stw.source.instance.sizes.sum() / 2)$. + + *Step 2 -- Construct scheduling instance.* #part_stw.target.instance.lengths.len() tasks, common deadline $= #part_stw.target.instance.deadlines.at(0)$, weights $= (#part_stw.target.instance.weights.map(str).join(", "))$. + + *Step 3 -- Verify a solution.* Source partition $(#part_stw_sol.source_config.map(str).join(", "))$: side-0 sum $= #{part_stw_sol.source_config.enumerate().filter(((i, x)) => x == 0).map(((i, x)) => part_stw.source.instance.sizes.at(i)).sum()}$, side-1 sum $= #{part_stw_sol.source_config.enumerate().filter(((i, x)) => x == 1).map(((i, x)) => part_stw.source.instance.sizes.at(i)).sum()}$ -- balanced #sym.checkmark. + + *Multiplicity:* The fixture stores one canonical witness. + ], +)[ + This $O(n)$ reduction @karp1972 maps each element $a_i$ to a task with length $l(t_i) = a_i$, weight $w(t_i) = a_i$, and common deadline $T = B\/2$. The tardiness bound is $K = T$. Tasks scheduled before $T$ are on-time; those after are tardy. A balanced partition exists iff total tardy weight can be at most $K$. ][ _Construction._ Given Partition instance $A = {a_1, dots, a_n}$ with total $B$. If $B$ is odd, output a trivially infeasible instance (all deadlines 0, $K = 0$). If $B$ is even, set $T = B\/2$. For each $a_i$, create task $t_i$ with $l(t_i) = w(t_i) = a_i$ and deadline $d(t_i) = T$. Set bound $K = T$. @@ -13176,8 +13415,29 @@ The following table shows concrete variable overhead for example instances, take ] // 12. Partition → OpenShopScheduling (#481) -#reduction-rule("Partition", "OpenShopScheduling")[ - Create $k + 1$ jobs on 3 machines: $k$ element jobs with $p_(j,i) = a_j$ on all machines, plus one special job with $p = Q = S\/2$. The target makespan is $3Q$. A balanced partition exists iff the open shop can achieve makespan $<= 3Q$, because the special job tiles $[0, 3Q)$ into three $Q$-length blocks and element jobs must fill the remaining idle slots exactly. +#let part_oss = load-example("Partition", "OpenShopScheduling") +#let part_oss_sol = part_oss.solutions.at(0) +#reduction-rule("Partition", "OpenShopScheduling", + example: true, + example-caption: [#part_oss.source.instance.sizes.len() elements, $m = #part_oss.target.instance.num_machines$ machines], + extra: [ + #pred-commands( + "pred create --example " + problem-spec(part_oss.source) + " -o partition.json", + "pred reduce partition.json --to " + target-spec(part_oss) + " -o bundle.json", + "pred solve bundle.json", + "pred evaluate partition.json --config " + part_oss_sol.source_config.map(str).join(","), + ) + + *Step 1 -- Source instance.* Partition with sizes $(#part_oss.source.instance.sizes.map(str).join(", "))$, total $S = #part_oss.source.instance.sizes.sum()$, $Q = S\/2 = #(part_oss.source.instance.sizes.sum() / 2)$. + + *Step 2 -- Construct open-shop instance.* #part_oss.target.instance.processing_times.len() jobs on $m = #part_oss.target.instance.num_machines$ machines. The special job has processing time $Q = #(part_oss.source.instance.sizes.sum() / 2)$ per machine. Deadline $D = 3Q = #(3 * part_oss.source.instance.sizes.sum() / 2)$. + + *Step 3 -- Verify a solution.* Source partition $(#part_oss_sol.source_config.map(str).join(", "))$: side-0 sum $= #{part_oss_sol.source_config.enumerate().filter(((i, x)) => x == 0).map(((i, x)) => part_oss.source.instance.sizes.at(i)).sum()}$, side-1 sum $= #{part_oss_sol.source_config.enumerate().filter(((i, x)) => x == 1).map(((i, x)) => part_oss.source.instance.sizes.at(i)).sum()}$ -- balanced #sym.checkmark. + + *Multiplicity:* The fixture stores one canonical witness. + ], +)[ + This $O(k)$ reduction @gonzalez1976 creates $k + 1$ jobs on 3 machines: $k$ element jobs with $p_(j,i) = a_j$ on all machines, plus one special job with $p = Q = S\/2$. The target makespan is $3Q$. A balanced partition exists iff the open shop can achieve makespan $<= 3Q$. ][ _Construction._ Given Partition instance $A = {a_1, dots, a_k}$ with total $S$ and $Q = S\/2$. Set $m = 3$ machines. For each $a_j$, create element job $J_j$ with $p_(j,1) = p_(j,2) = p_(j,3) = a_j$. Create special job $J_(k+1)$ with $p_(k+1,i) = Q$ on all machines. Deadline $D = 3Q$. @@ -13187,8 +13447,29 @@ The following table shows concrete variable overhead for example instances, take ] // 13. NAESatisfiability → MaxCut (#166) -#reduction-rule("NAESatisfiability", "MaxCut")[ - For each variable $x_i$, create two vertices $v_i, v_i'$ connected by a heavy edge of weight $M = 2m + 1$. For each clause, add a unit-weight triangle on the three literal vertices. The NAE-SAT instance is satisfiable iff the maximum cut has weight $>= n M + 2m$. This is the classical Garey--Johnson--Stockmeyer construction. +#let nae_mc = load-example("NAESatisfiability", "MaxCut") +#let nae_mc_sol = nae_mc.solutions.at(0) +#reduction-rule("NAESatisfiability", "MaxCut", + example: true, + example-caption: [$n = #nae_mc.source.instance.num_vars$ variables, $m = #sat-num-clauses(nae_mc.source.instance)$ clauses, $M = #(2 * sat-num-clauses(nae_mc.source.instance) + 1)$], + extra: [ + #pred-commands( + "pred create --example " + problem-spec(nae_mc.source) + " -o naesat.json", + "pred reduce naesat.json --to " + target-spec(nae_mc) + " -o bundle.json", + "pred solve bundle.json", + "pred evaluate naesat.json --config " + nae_mc_sol.source_config.map(str).join(","), + ) + + *Step 1 -- Source instance.* NAE-SAT with $n = #nae_mc.source.instance.num_vars$ variables and $m = #sat-num-clauses(nae_mc.source.instance)$ clauses. Forcing weight $M = 2m + 1 = #(2 * sat-num-clauses(nae_mc.source.instance) + 1)$. + + *Step 2 -- Construct weighted graph.* Variable gadgets: $#nae_mc.source.instance.num_vars$ heavy edges (weight $M$). Clause triangles: $#sat-num-clauses(nae_mc.source.instance)$ triangles of unit-weight edges. Total: $#graph-num-vertices(nae_mc.target.instance)$ vertices, $#graph-num-edges(nae_mc.target.instance)$ edges. + + *Step 3 -- Verify a solution.* Source NAE-assignment $(#nae_mc_sol.source_config.map(str).join(", "))$. Target cut partition $(#nae_mc_sol.target_config.map(str).join(", "))$: all variable edges are cut, each clause triangle has a 1-2 split contributing 2 edges #sym.checkmark. + + *Multiplicity:* The fixture stores one canonical witness. + ], +)[ + This $O(n + m)$ reduction @gareyJohnsonStockmeyer1976 creates for each variable $x_i$ two vertices $v_i, v_i'$ connected by a heavy edge of weight $M = 2m + 1$. For each clause, a unit-weight triangle is added on the three literal vertices. The NAE-SAT instance is satisfiable iff the maximum cut has weight $>= n M + 2m$. ][ _Construction._ Given NAE-3SAT with $n$ variables and $m$ clauses. Set $M = 2m + 1$. (1) For each variable $x_i$, create vertices $v_i$ (positive) and $v_i'$ (negative) with edge weight $M$. (2) For each clause $C_j = (ell_a, ell_b, ell_c)$, add weight-1 edges $(ell_a, ell_b), (ell_b, ell_c), (ell_a, ell_c)$. Total: $2n$ vertices, at most $n + 3m$ edges. Threshold $W = n M + 2m$. @@ -13198,8 +13479,29 @@ The following table shows concrete variable overhead for example instances, take ] // 14. HamiltonianPath → IsomorphicSpanningTree (#912) -#reduction-rule("HamiltonianPath", "IsomorphicSpanningTree")[ - Pass the graph through unchanged and set the target tree $T = P_n$ (the path on $n$ vertices). A Hamiltonian path in $G$ is exactly a spanning tree isomorphic to $P_n$: both are connected, acyclic, span all vertices, and have maximum degree 2. The reduction is size-preserving. +#let hp_ist = load-example("HamiltonianPath", "IsomorphicSpanningTree") +#let hp_ist_sol = hp_ist.solutions.at(0) +#reduction-rule("HamiltonianPath", "IsomorphicSpanningTree", + example: true, + example-caption: [$n = #graph-num-vertices(hp_ist.source.instance)$ vertices, target tree $P_n$], + extra: [ + #pred-commands( + "pred create --example " + problem-spec(hp_ist.source) + " -o hampath.json", + "pred reduce hampath.json --to " + target-spec(hp_ist) + " -o bundle.json", + "pred solve bundle.json", + "pred evaluate hampath.json --config " + hp_ist_sol.source_config.map(str).join(","), + ) + + *Step 1 -- Source instance.* Graph $G$ with $n = #graph-num-vertices(hp_ist.source.instance)$ vertices and $|E| = #graph-num-edges(hp_ist.source.instance)$ edges. + + *Step 2 -- Identity reduction.* Target host graph is identical. Target tree $T = P_#graph-num-vertices(hp_ist.target.instance)$ with #hp_ist.target.instance.tree.edges.len() edges. + + *Step 3 -- Verify a solution.* Hamiltonian path visits vertices in order $(#hp_ist_sol.source_config.map(str).join(", "))$. The isomorphism maps $P_n$ to this path in $G$ #sym.checkmark. + + *Multiplicity:* The fixture stores one canonical witness. + ], +)[ + This $O(n)$ reduction @garey1979 passes the graph through unchanged and sets the target tree $T = P_n$ (the path on $n$ vertices). A Hamiltonian path in $G$ is exactly a spanning tree isomorphic to $P_n$: both are connected, acyclic, span all vertices, and have maximum degree 2. The reduction is size-preserving. ][ _Construction._ Given $G = (V, E)$ with $|V| = n$, set the host graph to $G$ and the target tree to $T = P_n = ({t_0, dots, t_(n-1)}, {{t_i, t_(i+1)} : 0 <= i <= n-2})$. @@ -13209,8 +13511,29 @@ The following table shows concrete variable overhead for example instances, take ] // 15. ExactCoverBy3Sets → AlgebraicEquationsOverGF2 (#859) -#reduction-rule("ExactCoverBy3Sets", "AlgebraicEquationsOverGF2")[ - One binary variable $x_j$ per subset. For each universe element $u_i$, a linear equation $sum_(j in S_i) x_j + 1 = 0$ (mod 2) enforces odd coverage, and pairwise products $x_j x_k = 0$ (mod 2) forbid double coverage. Together these force exactly-once covering. +#let x3c_gf2 = load-example("ExactCoverBy3Sets", "AlgebraicEquationsOverGF2") +#let x3c_gf2_sol = x3c_gf2.solutions.at(0) +#reduction-rule("ExactCoverBy3Sets", "AlgebraicEquationsOverGF2", + example: true, + example-caption: [$|U| = #x3c_gf2.source.instance.universe_size$, $|cal(C)| = #x3c_gf2.source.instance.subsets.len()$ subsets], + extra: [ + #pred-commands( + "pred create --example " + problem-spec(x3c_gf2.source) + " -o x3c.json", + "pred reduce x3c.json --to " + target-spec(x3c_gf2) + " -o bundle.json", + "pred solve bundle.json", + "pred evaluate x3c.json --config " + x3c_gf2_sol.source_config.map(str).join(","), + ) + + *Step 1 -- Source instance.* X3C with $|U| = #x3c_gf2.source.instance.universe_size$ elements and $|cal(C)| = #x3c_gf2.source.instance.subsets.len()$ subsets. + + *Step 2 -- Construct polynomial system.* #x3c_gf2.target.instance.num_variables variables, #x3c_gf2.target.instance.equations.len() polynomial equations over GF(2). + + *Step 3 -- Verify a solution.* Source config $(#x3c_gf2_sol.source_config.map(str).join(", "))$: selected subsets form an exact cover. Target config $(#x3c_gf2_sol.target_config.map(str).join(", "))$: all equations satisfied over GF(2) #sym.checkmark. + + *Multiplicity:* The fixture stores one canonical witness. + ], +)[ + This $O(q n^2)$ reduction @garey1979 assigns one binary variable $x_j$ per subset. For each universe element $u_i$, a linear equation $sum_(j in S_i) x_j + 1 = 0$ (mod 2) enforces odd coverage, and pairwise products $x_j x_k = 0$ (mod 2) forbid double coverage. Together these force exactly-once covering. ][ _Construction._ Let $(X, cal(C))$ be an X3C instance with $|X| = 3q$ and $cal(C) = {C_1, dots, C_n}$. Define $n$ variables over GF(2). For each element $u_i$, let $S_i = {j : u_i in C_j}$. Add linear constraint $sum_(j in S_i) x_j + 1 = 0$ (mod 2) and pairwise constraints $x_j dot x_k = 0$ (mod 2) for all $j < k in S_i$. Total: at most $3q + sum_i binom(|S_i|, 2)$ equations. @@ -13220,8 +13543,29 @@ The following table shows concrete variable overhead for example instances, take ] // 16. Partition → ProductionPlanning (#488) -#reduction-rule("Partition", "ProductionPlanning")[ - Each element $a_i$ becomes a production period with capacity $c_i = a_i$ and setup cost $b_i = a_i$ (zero production and inventory costs). One demand period requires $Q = S\/2$ units with no production capacity. The cost bound is $B = Q$. Activating a subset summing to $Q$ exactly meets demand at cost $Q = B$. +#let part_pp = load-example("Partition", "ProductionPlanning") +#let part_pp_sol = part_pp.solutions.at(0) +#reduction-rule("Partition", "ProductionPlanning", + example: true, + example-caption: [#part_pp.source.instance.sizes.len() elements, total $= #part_pp.source.instance.sizes.sum()$], + extra: [ + #pred-commands( + "pred create --example " + problem-spec(part_pp.source) + " -o partition.json", + "pred reduce partition.json --to " + target-spec(part_pp) + " -o bundle.json", + "pred solve bundle.json", + "pred evaluate partition.json --config " + part_pp_sol.source_config.map(str).join(","), + ) + + *Step 1 -- Source instance.* Partition with sizes $(#part_pp.source.instance.sizes.map(str).join(", "))$, total $S = #part_pp.source.instance.sizes.sum()$, $Q = S\/2 = #(part_pp.source.instance.sizes.sum() / 2)$. + + *Step 2 -- Construct production planning instance.* #part_pp.target.instance.num_periods periods. Element periods have capacities $(#part_pp.target.instance.capacities.map(str).join(", "))$ and setup costs $(#part_pp.target.instance.setup_costs.map(str).join(", "))$. Demand $= (#part_pp.target.instance.demands.map(str).join(", "))$, cost bound $B = #part_pp.target.instance.cost_bound$. + + *Step 3 -- Verify a solution.* Source partition $(#part_pp_sol.source_config.map(str).join(", "))$: side-0 sum $= #{part_pp_sol.source_config.enumerate().filter(((i, x)) => x == 0).map(((i, x)) => part_pp.source.instance.sizes.at(i)).sum()}$, side-1 sum $= #{part_pp_sol.source_config.enumerate().filter(((i, x)) => x == 1).map(((i, x)) => part_pp.source.instance.sizes.at(i)).sum()}$ -- balanced #sym.checkmark. Target production amounts $(#part_pp_sol.target_config.map(str).join(", "))$ meet demand at cost $<= B$ #sym.checkmark. + + *Multiplicity:* The fixture stores one canonical witness. + ], +)[ + This $O(n)$ reduction @florianLenstraRinnooyKan1980 maps each element $a_i$ to a production period with capacity $c_i = a_i$ and setup cost $b_i = a_i$ (zero production and inventory costs). One demand period requires $Q = S\/2$ units with no production capacity. The cost bound is $B = Q$. Activating a subset summing to $Q$ exactly meets demand at cost $Q = B$. ][ _Construction._ Given Partition instance $A = {a_1, dots, a_n}$ with total $S$ and $Q = S\/2$. If $S$ is odd, output a trivially infeasible instance. Otherwise create $n + 1$ periods: for each $a_i$, period $i$ has $r_i = 0, c_i = a_i, b_i = a_i, p_i = h_i = 0$; demand period $n+1$ has $r_(n+1) = Q, c_(n+1) = 0, b_(n+1) = p_(n+1) = h_(n+1) = 0$. Cost bound $B = Q$. @@ -13231,8 +13575,29 @@ The following table shows concrete variable overhead for example instances, take ] // 17. HamiltonianPathBetweenTwoVertices → LongestPath (#359) -#reduction-rule("HamiltonianPathBetweenTwoVertices", "LongestPath")[ - Pass the graph through with unit edge lengths, same source $s$ and target $t$, and bound $K = n - 1$. A Hamiltonian $s$-$t$ path uses exactly $n - 1$ edges, which is the maximum possible for a simple path on $n$ vertices. The reduction is size-preserving. +#let hpbtv_lp = load-example("HamiltonianPathBetweenTwoVertices", "LongestPath") +#let hpbtv_lp_sol = hpbtv_lp.solutions.at(0) +#reduction-rule("HamiltonianPathBetweenTwoVertices", "LongestPath", + example: true, + example-caption: [$n = #graph-num-vertices(hpbtv_lp.source.instance)$ vertices, $s = #hpbtv_lp.source.instance.source_vertex$, $t = #hpbtv_lp.source.instance.target_vertex$], + extra: [ + #pred-commands( + "pred create --example " + problem-spec(hpbtv_lp.source) + " -o hampath2v.json", + "pred reduce hampath2v.json --to " + target-spec(hpbtv_lp) + " -o bundle.json", + "pred solve bundle.json", + "pred evaluate hampath2v.json --config " + hpbtv_lp_sol.source_config.map(str).join(","), + ) + + *Step 1 -- Source instance.* Graph $G$ with $n = #graph-num-vertices(hpbtv_lp.source.instance)$ vertices, $|E| = #graph-num-edges(hpbtv_lp.source.instance)$ edges, $s = #hpbtv_lp.source.instance.source_vertex$, $t = #hpbtv_lp.source.instance.target_vertex$. + + *Step 2 -- Identity reduction.* Target graph is identical with unit edge lengths. $K = n - 1 = #(graph-num-vertices(hpbtv_lp.source.instance) - 1)$, same $s$ and $t$. + + *Step 3 -- Verify a solution.* Source Hamiltonian path visits vertices $(#hpbtv_lp_sol.source_config.map(str).join(", "))$ from $s = #hpbtv_lp.source.instance.source_vertex$ to $t = #hpbtv_lp.source.instance.target_vertex$. Target selects #hpbtv_lp_sol.target_config.filter(x => x == 1).len() edges, total length $= n - 1 = K$ #sym.checkmark. + + *Multiplicity:* The fixture stores one canonical witness. + ], +)[ + This $O(n + m)$ reduction @garey1979 passes the graph through with unit edge lengths, same source $s$ and target $t$, and bound $K = n - 1$. A Hamiltonian $s$-$t$ path uses exactly $n - 1$ edges, which is the maximum possible for a simple path on $n$ vertices. The reduction is size-preserving. ][ _Construction._ Given $(G = (V, E), s, t)$ with $n = |V|$, set $G' = G$, $ell(e) = 1$ for all $e in E$, $s' = s$, $t' = t$, and $K = n - 1$. @@ -13242,12 +13607,33 @@ The following table shows concrete variable overhead for example instances, take ] // 18. GraphPartitioning → MaxCut (from main codebase) -#reduction-rule("GraphPartitioning", "MaxCut")[ - Build a weighted complete graph on the same $n$ vertices. Edges present in $G$ receive weight $P - 1$ and non-edges receive weight $P$, where $P = |E| + 1$. The heavy non-edge weights force any maximum balanced cut to avoid splitting non-adjacent pairs, effectively minimizing crossing edges from $E$. Variables correspond one-to-one. +#let gp_mc = load-example("GraphPartitioning", "MaxCut") +#let gp_mc_sol = gp_mc.solutions.at(0) +#reduction-rule("GraphPartitioning", "MaxCut", + example: true, + example-caption: [$n = #graph-num-vertices(gp_mc.source.instance)$ vertices, $|E| = #graph-num-edges(gp_mc.source.instance)$], + extra: [ + #pred-commands( + "pred create --example " + problem-spec(gp_mc.source) + " -o graphpart.json", + "pred reduce graphpart.json --to " + target-spec(gp_mc) + " -o bundle.json", + "pred solve bundle.json", + "pred evaluate graphpart.json --config " + gp_mc_sol.source_config.map(str).join(","), + ) + + *Step 1 -- Source instance.* Graph $G$ with $n = #graph-num-vertices(gp_mc.source.instance)$ vertices and $|E| = #graph-num-edges(gp_mc.source.instance)$ edges. Penalty $P = |E| + 1 = #(graph-num-edges(gp_mc.source.instance) + 1)$. + + *Step 2 -- Build weighted $K_n$.* Target has $#graph-num-vertices(gp_mc.target.instance)$ vertices and $#graph-num-edges(gp_mc.target.instance)$ edges ($n(n-1)\/2$). Original edges get weight $P - 1 = #graph-num-edges(gp_mc.source.instance)$, non-edges get weight $P = #(graph-num-edges(gp_mc.source.instance) + 1)$. + + *Step 3 -- Verify a solution.* Source partition $(#gp_mc_sol.source_config.map(str).join(", "))$: side $A = {#gp_mc_sol.source_config.enumerate().filter(((i, x)) => x == 0).map(((i, x)) => str(i)).join(", ")}$, side $B = {#gp_mc_sol.source_config.enumerate().filter(((i, x)) => x == 1).map(((i, x)) => str(i)).join(", ")}$. Target partition is identical #sym.checkmark. + + *Multiplicity:* The fixture stores one canonical witness. + ], +)[ + This $O(n^2)$ reduction @garey1979 builds a weighted complete graph on the same $n$ vertices. Edges present in $G$ receive weight $P - 1$ and non-edges receive weight $P$, where $P = |E| + 1$. The heavy non-edge weights force any maximum balanced cut to minimize crossing edges from $E$. Variables correspond one-to-one. ][ _Construction._ Given Graph Partitioning instance $G = (V, E)$, let $P = |E| + 1$. Build $K_n$ with edge weights $w(u,v) = P - 1$ if ${u,v} in E$ and $w(u,v) = P$ if ${u,v} in.not E$. The Max-Cut instance has $n$ vertices and $n(n-1)\/2$ edges. - _Correctness._ ($arrow.r.double$) A balanced partition $(A, B)$ with $|A| = |B| = n\/2$ cutting $c$ edges of $G$ yields a cut of weight $(P-1) dot c + P dot (n\/2 dot n\/2 - c - "non-edges within halves") = dots$ For any balanced partition, increasing the number of non-edges crossing the cut increases the total weight (since non-edges have higher weight $P > P - 1$), so the maximum cut places as many non-edges as possible across the cut, equivalently minimizing edges of $G$ across the cut. ($arrow.l.double$) The maximum-weight balanced cut in $K_n$ corresponds to the minimum bisection of $G$. + _Correctness._ ($arrow.r.double$) A balanced partition $(A, B)$ with $|A| = |B| = n\/2$ cutting $c$ edges of $G$ yields a cut in $K_n$ crossing $n^2\/4$ pairs total ($|A| dot |B|$). Of those, $c$ are edges of $G$ (weight $P - 1$ each) and $n^2\/4 - c$ are non-edges (weight $P$ each). The total cut weight is $c(P-1) + (n^2\/4 - c) P = n^2 P\/4 - c$. Since $P$ and $n$ are fixed, maximizing the cut weight is equivalent to minimizing $c$, the number of crossing edges of $G$. ($arrow.l.double$) The maximum-weight balanced cut in $K_n$ therefore corresponds to the minimum bisection of $G$. _Solution extraction._ The Max-Cut partition assignment is directly the Graph Partitioning assignment: $x_v = 0$ for side $A$, $x_v = 1$ for side $B$. ] From 752ce9aa14d244d0542f60358cb3072f0c8cc3f9 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 4 Apr 2026 21:10:24 +0800 Subject: [PATCH 23/25] fix: add explicit GraphPartitioning name resolution for CI determinism The inventory-based registry resolution is non-deterministic across platforms. On CI, "GraphPartitioning" was resolving to "MaxCut" due to registration order, causing test_create_graph_partitioning to fail. Add explicit case-insensitive resolution, matching the pattern used for other ambiguous problem names. Co-Authored-By: Claude Opus 4.6 (1M context) --- problemreductions-cli/src/problem_name.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/problemreductions-cli/src/problem_name.rs b/problemreductions-cli/src/problem_name.rs index 835cc7a07..24a51eb73 100644 --- a/problemreductions-cli/src/problem_name.rs +++ b/problemreductions-cli/src/problem_name.rs @@ -35,6 +35,9 @@ pub fn resolve_alias(input: &str) -> String { if input.eq_ignore_ascii_case("ThreeMatroidIntersection") { return "ThreeMatroidIntersection".to_string(); } + if input.eq_ignore_ascii_case("GraphPartitioning") { + return "GraphPartitioning".to_string(); + } if let Some(pt) = problemreductions::registry::find_problem_type_by_alias(input) { return pt.canonical_name.to_string(); } From 22129c45830bec7e05b21a40aed10e5e8a350532 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 4 Apr 2026 22:11:34 +0800 Subject: [PATCH 24/25] fix: align 3 paper entries with implemented code (Kernel, MaxCut, GraphPartitioning) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - KSatisfiability → Kernel: corrected arc count to 2n+6m, rewrote proof to allow clause vertices in kernel matching the actual code - NAESatisfiability → MaxCut: corrected M = m+1 (was 2m+1), fixed example - GraphPartitioning → MaxCut: completed balance proof via P=|E|+1 penalty All examples now use load-example() fixture data with pred-commands(). Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/paper/reductions.typ | 93 ++++++++++++++++++++++++++++++--------- 1 file changed, 71 insertions(+), 22 deletions(-) diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 41d5225c8..9913c19c6 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -13172,22 +13172,35 @@ The following table shows concrete variable overhead for example instances, take "pred evaluate ksat.json --config " + ksat_ker_sol.source_config.map(str).join(","), ) - *Step 1 -- Source instance.* 3-SAT with $n = #ksat_ker.source.instance.num_vars$ variables and $m = #sat-num-clauses(ksat_ker.source.instance)$ clauses. + #{ + let n = ksat_ker.source.instance.num_vars + let m = sat-num-clauses(ksat_ker.source.instance) + let selected = ksat_ker_sol.target_config.enumerate().filter(((i, x)) => x == 1).map(((i, x)) => i) + let literal-selected = selected.filter(v => v < 2 * n) + let clause-selected = selected.filter(v => v >= 2 * n) + let second-clause-base = 2 * n + 3 + let last-literal-vertex = literal-selected.at(literal-selected.len() - 1) + [ + *Step 1 -- Source instance.* 3-SAT with $n = #n$ variables and $m = #m$ clauses. The canonical satisfying assignment is $(#ksat_ker_sol.source_config.map(str).join(", "))$. - *Step 2 -- Construct digraph.* Variable gadgets: $2n = #(2 * ksat_ker.source.instance.num_vars)$ vertices (digons). Clause gadgets: $3m = #(3 * sat-num-clauses(ksat_ker.source.instance))$ vertices (directed 3-cycles). Total: $#ksat_ker.target.instance.graph.num_vertices$ vertices, $#ksat_ker.target.instance.graph.arcs.len()$ arcs. + *Step 2 -- Construct the digraph.* Variable gadgets contribute $2n = #(2 * n)$ literal vertices and $2n = #(2 * n)$ digon arcs. Clause gadgets contribute $3m = #(3 * m)$ clause vertices, $3m = #(3 * m)$ cycle arcs, and $3m = #(3 * m)$ literal arcs. Total: $#ksat_ker.target.instance.graph.num_vertices$ vertices and $#ksat_ker.target.instance.graph.arcs.len()$ arcs $= 2n + 6m$. - *Step 3 -- Verify a solution.* Source config $(#ksat_ker_sol.source_config.map(str).join(", "))$ satisfies all clauses. Target kernel selects #{ksat_ker_sol.target_config.filter(x => x == 1).len()} vertices: indices $= {#ksat_ker_sol.target_config.enumerate().filter(((i, x)) => x == 1).map(((i, x)) => str(i)).join(", ")}$ #sym.checkmark. + *Step 3 -- Verify the canonical witness.* The target kernel selects literal vertices ${#literal-selected.map(str).join(", ")}$ and clause vertices ${#clause-selected.map(str).join(", ")}$. Here ${#literal-selected.map(str).join(", ")}$ encode $(x_1, x_2, x_3) = (#ksat_ker_sol.source_config.map(str).join(", "))$, and the extra clause vertex $#(second-clause-base + 1)$ is needed in the second clause gadget: vertex $#second-clause-base$ is absorbed by arc $(#second-clause-base, #(second-clause-base + 1))$, while vertex $#(second-clause-base + 2)$ is absorbed by its literal arc to vertex $#last-literal-vertex$ #sym.checkmark. + ] + } *Multiplicity:* The fixture stores one canonical witness. ], )[ - This $O(n + m)$ reduction @garey1979 constructs a directed graph with $2n + 3m$ vertices and $2n + 12m$ arcs. Variable gadgets are digons (directed 2-cycles) forcing exactly one literal per variable into any kernel. Clause gadgets are directed 3-cycles whose vertices also point to the corresponding literal vertices, ensuring every clause is satisfied. + This $O(n + m)$ reduction @garey1979 constructs a directed graph with $2n + 3m$ vertices and $2n + 6m$ arcs. Each variable contributes a digon on its positive and negative literal vertices, and each 3-clause contributes a directed 3-cycle whose three clause vertices point to the corresponding literal vertices. The implemented kernels may contain clause vertices as well as literal vertices. ][ - _Construction._ Let $phi$ have variables $u_1, dots, u_n$ and clauses $C_1, dots, C_m$ (each with 3 literals). (1) For each variable $u_i$, create vertices $x_i, overline(x)_i$ with arcs $(x_i, overline(x)_i)$ and $(overline(x)_i, x_i)$ (a digon). (2) For each clause $C_j$, create vertices $c_(j,1), c_(j,2), c_(j,3)$ with a directed 3-cycle. (3) For each clause $C_j$ and each literal vertex $v$ of $C_j$, add arcs from each $c_(j,t)$ to $v$ ($t = 1,2,3$). Total: $2n + 3m$ vertices, $2n + 12m$ arcs. + _Construction._ Let $phi = C_1 and dots and C_m$ be a 3-SAT instance on variables $x_1, dots, x_n$. For each variable $x_i$, create two literal vertices $p_i$ and $n_i$ with arcs $(p_i, n_i)$ and $(n_i, p_i)$. For each clause $C_j = (ell_(j,0) or ell_(j,1) or ell_(j,2))$, create clause vertices $c_(j,0), c_(j,1), c_(j,2)$ with cycle arcs $(c_(j,0), c_(j,1))$, $(c_(j,1), c_(j,2))$, and $(c_(j,2), c_(j,0))$. Add one literal arc $(c_(j,t), v(ell_(j,t)))$ from each clause vertex to the literal vertex representing its own literal. Thus each clause contributes 3 cycle arcs and 3 literal arcs, for a total of $2n + 6m$ arcs. + + _Correctness._ ($arrow.r.double$) Let $alpha$ be a satisfying assignment. Put into $K$ exactly one literal vertex from each digon: $p_i$ if $alpha(x_i) = "true"$ and $n_i$ otherwise. For each clause $C_j$, additionally put $c_(j,t)$ into $K$ exactly when $ell_(j,t)$ is false and $ell_(j,(t+1) mod 3)$ is true. This never creates an arc inside $K$: each selected clause vertex points to a false literal vertex, and two adjacent clause vertices cannot both satisfy the selection rule. Every unselected literal vertex is absorbed by its digon partner. For an unselected clause vertex $c_(j,t)$, either $ell_(j,t)$ is true, so its literal arc hits the selected literal vertex, or $ell_(j,t)$ is false. In the latter case $c_(j,t)$ was not selected, so $ell_(j,(t+1) mod 3)$ is also false; because the clause is satisfied, $ell_(j,(t+2) mod 3)$ is true, hence $c_(j,(t+1) mod 3)$ was selected and absorbs $c_(j,t)$ along the cycle. - _Correctness._ ($arrow.r.double$) A satisfying assignment $alpha$ yields a kernel $S$ containing $x_i$ if $alpha(u_i)$ is true, $overline(x)_i$ otherwise. Independence holds since $S$ picks one from each digon. Every literal vertex not in $S$ is absorbed by its digon partner. Every clause vertex is absorbed because at least one literal of its clause is in $S$. ($arrow.l.double$) In any kernel $S$, no clause vertex belongs to $S$ (otherwise a clause vertex's successors would not be absorbed). Thus $S$ contains only literal vertices, exactly one per digon. At least one literal vertex of each clause is in $S$ (to absorb clause vertices), so the derived assignment satisfies every clause. + ($arrow.l.double$) Let $K$ be a kernel of the constructed digraph. In each variable digon, at most one literal vertex can lie in $K$ by independence, and at least one must lie in $K$ to absorb the other endpoint; so each digon contributes exactly one selected literal vertex. Set $alpha(x_i) = "true"$ iff $p_i in K$. Now fix a clause gadget with cycle $(c_(j,0), c_(j,1), c_(j,2))$. If none of its three literal vertices were selected, then the only possible absorbers for $c_(j,0), c_(j,1), c_(j,2)$ would be the cycle successors. Independence allows at most one clause vertex in $K$, but one selected vertex on a directed 3-cycle cannot absorb the other two, contradiction. Therefore every clause has at least one selected literal vertex, so $alpha$ satisfies every clause. - _Solution extraction._ Set $alpha(u_i) = "true"$ if $x_i in S$, $alpha(u_i) = "false"$ if $overline(x)_i in S$. + _Solution extraction._ Read only the positive literal vertices: $alpha(x_i) = 1$ iff the even-indexed vertex for $x_i$ is in the kernel. Any selected clause vertices are ignored during extraction. ] // 5. HamiltonianPath → DegreeConstrainedSpanningTree (#911) @@ -13451,7 +13464,7 @@ The following table shows concrete variable overhead for example instances, take #let nae_mc_sol = nae_mc.solutions.at(0) #reduction-rule("NAESatisfiability", "MaxCut", example: true, - example-caption: [$n = #nae_mc.source.instance.num_vars$ variables, $m = #sat-num-clauses(nae_mc.source.instance)$ clauses, $M = #(2 * sat-num-clauses(nae_mc.source.instance) + 1)$], + example-caption: [$n = #nae_mc.source.instance.num_vars$ variables, $m = #sat-num-clauses(nae_mc.source.instance)$ clauses, $M = #(sat-num-clauses(nae_mc.source.instance) + 1)$], extra: [ #pred-commands( "pred create --example " + problem-spec(nae_mc.source) + " -o naesat.json", @@ -13460,22 +13473,35 @@ The following table shows concrete variable overhead for example instances, take "pred evaluate naesat.json --config " + nae_mc_sol.source_config.map(str).join(","), ) - *Step 1 -- Source instance.* NAE-SAT with $n = #nae_mc.source.instance.num_vars$ variables and $m = #sat-num-clauses(nae_mc.source.instance)$ clauses. Forcing weight $M = 2m + 1 = #(2 * sat-num-clauses(nae_mc.source.instance) + 1)$. + #{ + let n = nae_mc.source.instance.num_vars + let m = sat-num-clauses(nae_mc.source.instance) + let big-m = m + 1 + let clause-edge-count = graph-num-edges(nae_mc.target.instance) - n + let cut-value = n * big-m + 2 * m + [ + *Step 1 -- Source instance.* NAE-SAT with $n = #n$ variables and $m = #m$ clauses. The implementation uses forcing weight $M = m + 1 = #big-m$. - *Step 2 -- Construct weighted graph.* Variable gadgets: $#nae_mc.source.instance.num_vars$ heavy edges (weight $M$). Clause triangles: $#sat-num-clauses(nae_mc.source.instance)$ triangles of unit-weight edges. Total: $#graph-num-vertices(nae_mc.target.instance)$ vertices, $#graph-num-edges(nae_mc.target.instance)$ edges. + *Step 2 -- Construct the weighted graph.* Variable gadgets contribute #n heavy edges of weight $M$. Because the canonical fixture has 3 literals per clause, each clause contributes one unit-weight triangle, so the target has #clause-edge-count unit-weight clause edges and $#graph-num-edges(nae_mc.target.instance)$ edges total on $#graph-num-vertices(nae_mc.target.instance)$ vertices. - *Step 3 -- Verify a solution.* Source NAE-assignment $(#nae_mc_sol.source_config.map(str).join(", "))$. Target cut partition $(#nae_mc_sol.target_config.map(str).join(", "))$: all variable edges are cut, each clause triangle has a 1-2 split contributing 2 edges #sym.checkmark. + *Step 3 -- Verify the canonical witness.* Source assignment $(#nae_mc_sol.source_config.map(str).join(", "))$ induces target cut $(#nae_mc_sol.target_config.map(str).join(", "))$. All #n heavy edges are cut, and each of the #m clause triangles has a 1-2 split contributing 2, so the total cut weight is $#cut-value$ #sym.checkmark. + ] + } *Multiplicity:* The fixture stores one canonical witness. ], )[ - This $O(n + m)$ reduction @gareyJohnsonStockmeyer1976 creates for each variable $x_i$ two vertices $v_i, v_i'$ connected by a heavy edge of weight $M = 2m + 1$. For each clause, a unit-weight triangle is added on the three literal vertices. The NAE-SAT instance is satisfiable iff the maximum cut has weight $>= n M + 2m$. + This implemented reduction sets $M = m + 1$, creates two literal vertices per variable, and adds one unit-weight edge for every literal pair inside a clause. When every clause has 3 literals, each clause gadget is a triangle, so the construction is the usual NAE-SAT-to-Max-Cut graph on $2n$ vertices with $n + 3m$ edges. ][ - _Construction._ Given NAE-3SAT with $n$ variables and $m$ clauses. Set $M = 2m + 1$. (1) For each variable $x_i$, create vertices $v_i$ (positive) and $v_i'$ (negative) with edge weight $M$. (2) For each clause $C_j = (ell_a, ell_b, ell_c)$, add weight-1 edges $(ell_a, ell_b), (ell_b, ell_c), (ell_a, ell_c)$. Total: $2n$ vertices, at most $n + 3m$ edges. Threshold $W = n M + 2m$. + _Construction._ Let $phi$ have $n$ variables and $m$ clauses, and set $M = m + 1$. For each variable $x_i$, create a positive literal vertex $p_i = 2i$ and a negative literal vertex $n_i = 2i + 1$, joined by one weight-$M$ edge. For each clause $C_j$, add a unit-weight edge between every pair of literal vertices appearing in $C_j$. In particular, if every clause has three literals, each clause becomes a unit-weight triangle. + + _Correctness._ Assume from here on that each clause has exactly three literals, matching the canonical fixture. Then every clause gadget is a triangle. - _Correctness._ ($arrow.r.double$) A NAE-satisfying $tau$ defines $S = {v_i : tau(x_i) = "true"} union {v_i' : tau(x_i) = "false"}$. All $n$ variable edges are cut (weight $n M$). Each NAE-satisfied clause has a 1-2 split on its triangle, contributing exactly 2 cut edges. Total: $n M + 2m$. ($arrow.l.double$) Since $M > 2m$, all variable edges must be cut to reach $n M + 2m$. With all variable edges cut, $v_i$ and $v_i'$ are on opposite sides, defining a consistent assignment. The remaining $2m$ must come from clause triangles (at most 2 each), so every triangle is 1-2 split, meaning no clause is all-equal. + ($arrow.r.double$) Let $alpha$ be a NAE-satisfying assignment. Put $p_i$ and $n_i$ on opposite sides of the cut according to $alpha$, so every variable edge is cut and contributes $M$. In each clause triangle, at least one literal is true and at least one is false, so the three vertices split $1$-$2$ across the cut and contribute exactly 2. Therefore the cut weight is $n M + 2m = n (m + 1) + 2m$. - _Solution extraction._ Set $x_i = "true"$ if $v_i in S$, else $x_i = "false"$. + ($arrow.l.double$) Suppose a cut has weight at least $n (m + 1) + 2m$. The $m$ clause triangles contribute at most $2m$ in total, so the variable edges must contribute at least $n (m + 1)$. Since each variable edge contributes at most $M = m + 1$, all $n$ variable edges are cut. Thus $p_i$ and $n_i$ lie on opposite sides for every variable, and the cut defines a consistent Boolean assignment by reading the side of $p_i$. The remaining $2m$ weight must come from the clause triangles, so each triangle contributes exactly 2 and therefore has vertices on both sides of the cut. Hence every clause contains both a true and a false literal, and the extracted assignment NAE-satisfies $phi$. Because a satisfying instance attains $n (m + 1) + 2m$, every optimal cut of the target has this form. + + _Solution extraction._ Read the positive literal vertices: $x_i = 1$ iff vertex $2i$ lies on side 1 of the cut. ] // 14. HamiltonianPath → IsomorphicSpanningTree (#912) @@ -13620,22 +13646,45 @@ The following table shows concrete variable overhead for example instances, take "pred evaluate graphpart.json --config " + gp_mc_sol.source_config.map(str).join(","), ) - *Step 1 -- Source instance.* Graph $G$ with $n = #graph-num-vertices(gp_mc.source.instance)$ vertices and $|E| = #graph-num-edges(gp_mc.source.instance)$ edges. Penalty $P = |E| + 1 = #(graph-num-edges(gp_mc.source.instance) + 1)$. + #{ + let n = graph-num-vertices(gp_mc.source.instance) + let m = graph-num-edges(gp_mc.source.instance) + let penalty = m + 1 + let side-a = gp_mc_sol.source_config.enumerate().filter(((i, x)) => x == 0).map(((i, x)) => i) + let side-b = gp_mc_sol.source_config.enumerate().filter(((i, x)) => x == 1).map(((i, x)) => i) + let source-cut = gp_mc.source.instance.graph.edges.filter(e => gp_mc_sol.source_config.at(e.at(0)) != gp_mc_sol.source_config.at(e.at(1))).len() + let target-weight = gp_mc.target.instance.graph.edges.enumerate().filter(((i, e)) => gp_mc_sol.target_config.at(e.at(0)) != gp_mc_sol.target_config.at(e.at(1))).map(((i, e)) => gp_mc.target.instance.edge_weights.at(i)).sum() + let unbalanced-pairs = (n / 2 - 1) * (n / 2 + 1) + let unbalanced-upper = penalty * unbalanced-pairs + [ + *Step 1 -- Source instance.* Graph $G$ has $n = #n$ vertices and $|E| = #m$ edges, so the code uses penalty $P = |E| + 1 = #penalty$. - *Step 2 -- Build weighted $K_n$.* Target has $#graph-num-vertices(gp_mc.target.instance)$ vertices and $#graph-num-edges(gp_mc.target.instance)$ edges ($n(n-1)\/2$). Original edges get weight $P - 1 = #graph-num-edges(gp_mc.source.instance)$, non-edges get weight $P = #(graph-num-edges(gp_mc.source.instance) + 1)$. + *Step 2 -- Build the weighted complete graph.* The target has $#graph-num-vertices(gp_mc.target.instance)$ vertices and $#graph-num-edges(gp_mc.target.instance)$ edges. Original edges receive weight $P - 1 = #(penalty - 1)$, while non-edges receive weight $P = #penalty$. - *Step 3 -- Verify a solution.* Source partition $(#gp_mc_sol.source_config.map(str).join(", "))$: side $A = {#gp_mc_sol.source_config.enumerate().filter(((i, x)) => x == 0).map(((i, x)) => str(i)).join(", ")}$, side $B = {#gp_mc_sol.source_config.enumerate().filter(((i, x)) => x == 1).map(((i, x)) => str(i)).join(", ")}$. Target partition is identical #sym.checkmark. + *Step 3 -- Verify the canonical witness.* The balanced partition $(#gp_mc_sol.source_config.map(str).join(", "))$ gives sides $A = {#side-a.map(str).join(", ")}$ and $B = {#side-b.map(str).join(", ")}$ with $#(side-a.len() * side-b.len())$ crossing pairs. It cuts #source-cut source edges, so the identical Max-Cut partition has weight $#target-weight = #penalty dot #(side-a.len() * side-b.len()) - #source-cut$. Any unbalanced $2$-$4$ split has at most #unbalanced-pairs crossing pairs and therefore weight at most $#unbalanced-upper < #target-weight$, so the optimum is forced to be balanced #sym.checkmark. + ] + } *Multiplicity:* The fixture stores one canonical witness. ], )[ - This $O(n^2)$ reduction @garey1979 builds a weighted complete graph on the same $n$ vertices. Edges present in $G$ receive weight $P - 1$ and non-edges receive weight $P$, where $P = |E| + 1$. The heavy non-edge weights force any maximum balanced cut to minimize crossing edges from $E$. Variables correspond one-to-one. + This $O(n^2)$ reduction @garey1979 builds a weighted complete graph on the same vertices, with weight $P - 1$ on original edges and weight $P$ on non-edges, where $P = |E| + 1$. For any partition $(A, B)$, the target cut weight is $P |A| |B| - |E(A, B)|$, so the factor $P$ first forces balance and then the $- |E(A, B)|$ term minimizes the number of source edges crossing the bisection. ][ - _Construction._ Given Graph Partitioning instance $G = (V, E)$, let $P = |E| + 1$. Build $K_n$ with edge weights $w(u,v) = P - 1$ if ${u,v} in E$ and $w(u,v) = P$ if ${u,v} in.not E$. The Max-Cut instance has $n$ vertices and $n(n-1)\/2$ edges. + _Construction._ Let $G = (V, E)$ be a Graph Partitioning instance with $|V| = n$ even, and let $P = |E| + 1$. Build the complete graph $K_n$ on the same vertex set. For each pair $u != v$, set + $w(u,v) = P - 1$ if ${u,v} in E$ and $w(u,v) = P$ otherwise. + The target Max-Cut instance therefore has $n$ vertices and $n(n-1)\/2$ weighted edges. + + _Correctness._ For any partition $(A, B)$ of $V$, let $c(A, B) = |E(A, B)|$ be the number of source edges crossing from $A$ to $B$. Among the $|A| |B|$ crossing pairs, exactly $c(A, B)$ are edges of $G$ and the remaining $|A| |B| - c(A, B)$ are non-edges. Hence the target cut weight is + $ + c(A, B) (P - 1) + (|A| |B| - c(A, B)) P + = P |A| |B| - c(A, B). + $ + + ($arrow.r.double$) If $(A, B)$ is a balanced partition of $G$ with $|A| = |B| = n\/2$ and cut size $c(A, B)$, then the same partition in the target graph has weight $P n^2 \/ 4 - c(A, B)$. - _Correctness._ ($arrow.r.double$) A balanced partition $(A, B)$ with $|A| = |B| = n\/2$ cutting $c$ edges of $G$ yields a cut in $K_n$ crossing $n^2\/4$ pairs total ($|A| dot |B|$). Of those, $c$ are edges of $G$ (weight $P - 1$ each) and $n^2\/4 - c$ are non-edges (weight $P$ each). The total cut weight is $c(P-1) + (n^2\/4 - c) P = n^2 P\/4 - c$. Since $P$ and $n$ are fixed, maximizing the cut weight is equivalent to minimizing $c$, the number of crossing edges of $G$. ($arrow.l.double$) The maximum-weight balanced cut in $K_n$ therefore corresponds to the minimum bisection of $G$. + ($arrow.l.double$) Any unbalanced partition of an even-sized vertex set satisfies $|A| |B| <= n^2 \/ 4 - 1$, so its target weight is at most $P (n^2 \/ 4 - 1)$. On the other hand, any balanced partition has weight at least $P n^2 \/ 4 - |E| > P (n^2 \/ 4 - 1)$ because $P = |E| + 1$. Therefore no optimal Max-Cut can be unbalanced: every optimum must satisfy $|A| = |B| = n\/2$. Once balance is forced, the term $P |A| |B| = P n^2 \/ 4$ is constant, so maximizing target weight is exactly the same as minimizing $c(A, B)$. Thus optimal Max-Cut solutions are precisely minimum bisections of $G$. - _Solution extraction._ The Max-Cut partition assignment is directly the Graph Partitioning assignment: $x_v = 0$ for side $A$, $x_v = 1$ for side $B$. + _Solution extraction._ The Max-Cut partition vector is already a valid Graph Partitioning witness, so extraction is the identity map. ] #pagebreak() From bb82d12995c8df1f4987c77a7043f5e0f6c23cd3 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 4 Apr 2026 22:40:44 +0800 Subject: [PATCH 25/25] docs: expand 9 paper examples with concrete fixture data and step-by-step verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expanded worked examples for: NAE-SAT→SetSplitting, X3C→SubsetProduct, SubsetSum→IntegerExprMembership, X3C→MWSLE, 3SAT→SimultaneousIncongruences, Partition→SeqMinTardyTaskWeight, Partition→OpenShopScheduling, X3C→AlgEqOverGF2, Partition→ProductionPlanning. Each example now shows construction with actual numbers from load-example() fixtures and step-by-step witness verification. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/paper/reductions.typ | 166 +++++++++++++++++++++++++++++++------- 1 file changed, 138 insertions(+), 28 deletions(-) diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 9913c19c6..98fd3fbce 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -13249,11 +13249,18 @@ The following table shows concrete variable overhead for example instances, take "pred evaluate naesat.json --config " + nae_ss_sol.source_config.map(str).join(","), ) - *Step 1 -- Source instance.* NAE-SAT with $n = #nae_ss.source.instance.num_vars$ variables and $m = #sat-num-clauses(nae_ss.source.instance)$ clauses. + #{ + let colors = nae_ss_sol.target_config + [ + *Step 1 -- Source instance.* The fixture has clauses $C_1 = (x_1 or overline(x_2) or x_3)$ and $C_2 = (overline(x_1) or x_2 or overline(x_3))$. The canonical NAE assignment is $(#nae_ss_sol.source_config.map(str).join(", "))$, so the two clauses evaluate to $(1, 0, 1)$ and $(0, 1, 0)$ respectively. - *Step 2 -- Construct Set Splitting instance.* Universe $|U| = #nae_ss.target.instance.universe_size = 2n$. Total subsets: #nae_ss.target.instance.subsets.len() ($n$ complementarity + $m$ clause subsets). + *Step 2 -- Build the universe and complementarity subsets.* The reduction creates $U = {0, dots, #(nae_ss.target.instance.universe_size - 1)}$ with positive literals on $\{0, 1, 2\}$ and negative literals on $\{3, 4, 5\}$. The first three target subsets are $R_1 = {#nae_ss.target.instance.subsets.at(0).map(str).join(", ")}$, $R_2 = {#nae_ss.target.instance.subsets.at(1).map(str).join(", ")}$, and $R_3 = {#nae_ss.target.instance.subsets.at(2).map(str).join(", ")}$. - *Step 3 -- Verify a solution.* Source NAE-assignment $(#nae_ss_sol.source_config.map(str).join(", "))$. Target 2-coloring $(#nae_ss_sol.target_config.map(str).join(", "))$: complementarity subsets are non-monochromatic, clause subsets are non-monochromatic #sym.checkmark. + *Step 3 -- Encode the clauses as set-splitting constraints.* Clause $C_1$ becomes $T_1 = {#nae_ss.target.instance.subsets.at(3).map(str).join(", ")}$, and clause $C_2$ becomes $T_2 = {#nae_ss.target.instance.subsets.at(4).map(str).join(", ")}$. Under the target coloring $(#colors.map(str).join(", "))$, $T_1$ receives colors $(#colors.at(0), #colors.at(4), #colors.at(2))$ and $T_2$ receives $(#colors.at(3), #colors.at(1), #colors.at(5))$, so both subsets are non-monochromatic. + + *Step 4 -- Verify the witness pair.* Every complementarity pair has opposite colors: $(0, 3)$ gives $(#colors.at(0), #colors.at(3))$, $(1, 4)$ gives $(#colors.at(1), #colors.at(4))$, and $(2, 5)$ gives $(#colors.at(2), #colors.at(5))$. Reading the positive-literal colors $(#colors.at(0), #colors.at(1), #colors.at(2))$ recovers the source assignment $(#nae_ss_sol.source_config.map(str).join(", "))$ #sym.checkmark. + ] + } *Multiplicity:* The fixture stores one canonical witness. ], @@ -13281,11 +13288,18 @@ The following table shows concrete variable overhead for example instances, take "pred evaluate x3c.json --config " + x3c_sp_sol.source_config.map(str).join(","), ) - *Step 1 -- Source instance.* X3C with $|U| = #x3c_sp.source.instance.universe_size$ and $|cal(C)| = #x3c_sp.source.instance.subsets.len()$ subsets. + #{ + let sizes = x3c_sp.target.instance.sizes + [ + *Step 1 -- Source instance.* The fixture has $U = {0, dots, #(x3c_sp.source.instance.universe_size - 1)}$ and three 3-sets: $C_0 = {#x3c_sp.source.instance.subsets.at(0).map(str).join(", ")}$, $C_1 = {#x3c_sp.source.instance.subsets.at(1).map(str).join(", ")}$, and $C_2 = {#x3c_sp.source.instance.subsets.at(2).map(str).join(", ")}$. The witness $(#x3c_sp_sol.source_config.map(str).join(", "))$ selects $C_0$ and $C_1$. - *Step 2 -- Assign primes and compute products.* Target Subset Product sizes: $(#x3c_sp.target.instance.sizes.join(", "))$, target product $B = #x3c_sp.target.instance.target$. + *Step 2 -- Recover the prime assignment from the concrete products.* The target numbers are $s_0 = #sizes.at(0) = 2 dot 3 dot 5$, $s_1 = #sizes.at(1) = 7 dot 11 dot 13$, and $s_2 = #sizes.at(2) = 2 dot 7 dot 11$. Thus the six universe elements are concretely labeled by the primes $(2, 3, 5, 7, 11, 13)$. - *Step 3 -- Verify a solution.* Source config $(#x3c_sp_sol.source_config.map(str).join(", "))$: selected subsets form an exact cover. Target config $(#x3c_sp_sol.target_config.map(str).join(", "))$: selected products multiply to $B$ #sym.checkmark. + *Step 3 -- Form the Subset Product instance.* The target product is $B = #x3c_sp.target.instance.target = 2 dot 3 dot 5 dot 7 dot 11 dot 13$. Selecting the first two source subsets therefore means selecting target numbers $(#sizes.at(0), #sizes.at(1))$. + + *Step 4 -- Verify the witness pair.* The selected sets $C_0$ and $C_1$ are disjoint and cover all six elements exactly once, and on the target side $#sizes.at(0) dot #sizes.at(1) = #x3c_sp.target.instance.target$ while $#sizes.at(2)$ is omitted. Because the configuration is unchanged, the target witness $(#x3c_sp_sol.target_config.map(str).join(", "))$ extracts back to the same exact cover #sym.checkmark. + ] + } *Multiplicity:* The fixture stores one canonical witness. ], @@ -13313,11 +13327,18 @@ The following table shows concrete variable overhead for example instances, take "pred evaluate subsetsum.json --config " + ss_iem_sol.source_config.map(str).join(","), ) - *Step 1 -- Source instance.* Subset Sum with sizes $(#ss_iem.source.instance.sizes.join(", "))$ and target $B = #ss_iem.source.instance.target$. + #{ + let sizes = ss_iem.source.instance.sizes + [ + *Step 1 -- Source instance.* The Subset Sum fixture has sizes $(#sizes.join(", "))$ and target $B = #ss_iem.source.instance.target$. The canonical source configuration $(#ss_iem_sol.source_config.map(str).join(", "))$ selects the second and third items, so the source sum is $5 + 6 = #ss_iem.source.instance.target$. + + *Step 2 -- Build the choice sets inside the expression.* Each source item contributes one union node $(1 union (s_i + 1))$, so the concrete choices are $(1 union 2)$, $(1 union 6)$, $(1 union 7)$, and $(1 union 9)$. With $n = #ss_iem_sol.target_config.len()$ union nodes, the target is shifted to $K = B + n = #ss_iem.target.instance.target$. - *Step 2 -- Construct expression.* Each element $s_i$ becomes a union node $(1 union (s_i + 1))$, chained via Minkowski sum. Target $K = B + n = #ss_iem.target.instance.target$. + *Step 3 -- Follow the canonical branch choices.* The target configuration $(#ss_iem_sol.target_config.map(str).join(", "))$ means left, right, right, left, so the chosen branch values are $1$, $6$, $7$, and $1$. - *Step 3 -- Verify a solution.* Source config $(#ss_iem_sol.source_config.map(str).join(", "))$: selected elements sum to $B$. Target config $(#ss_iem_sol.target_config.map(str).join(", "))$ encodes the same selection (right branch = include) #sym.checkmark. + *Step 4 -- Verify the equality.* The target-side sum is $1 + 6 + 7 + 1 = #ss_iem.target.instance.target$, exactly matching $K$. The right branches occur in the same two positions as the chosen source elements, so extracting the target witness returns the original Subset Sum solution #sym.checkmark. + ] + } *Multiplicity:* The fixture stores one canonical witness. ], @@ -13345,11 +13366,20 @@ The following table shows concrete variable overhead for example instances, take "pred evaluate x3c.json --config " + x3c_mwle_sol.source_config.map(str).join(","), ) - *Step 1 -- Source instance.* X3C with $|U| = #x3c_mwle.source.instance.universe_size$ and $|cal(C)| = #x3c_mwle.source.instance.subsets.len()$ subsets, $q = #(x3c_mwle.source.instance.universe_size / 3)$. + #{ + let rows = x3c_mwle.target.instance.matrix + let y = x3c_mwle_sol.target_config + let q = x3c_mwle.source.instance.universe_size / 3 + [ + *Step 1 -- Source instance.* The X3C fixture has subsets $C_0 = {#x3c_mwle.source.instance.subsets.at(0).map(str).join(", ")}$, $C_1 = {#x3c_mwle.source.instance.subsets.at(1).map(str).join(", ")}$, and $C_2 = {#x3c_mwle.source.instance.subsets.at(2).map(str).join(", ")}$ over a universe of size $#x3c_mwle.source.instance.universe_size$, so $q = #q$. - *Step 2 -- Construct linear system.* Incidence matrix $A$ has #x3c_mwle.target.instance.matrix.len() rows and #x3c_mwle.target.instance.matrix.at(0).len() columns. Right-hand side $b = (#x3c_mwle.target.instance.rhs.map(str).join(", "))$, weight bound $K = q = #(x3c_mwle.source.instance.universe_size / 3)$. + *Step 2 -- Build the incidence matrix.* The three columns correspond to $C_0$, $C_1$, and $C_2$. The six rows are $r_0 = (#rows.at(0).map(str).join(", "))$, $r_1 = (#rows.at(1).map(str).join(", "))$, $r_2 = (#rows.at(2).map(str).join(", "))$, $r_3 = (#rows.at(3).map(str).join(", "))$, $r_4 = (#rows.at(4).map(str).join(", "))$, and $r_5 = (#rows.at(5).map(str).join(", "))$, with right-hand side $b = (#x3c_mwle.target.instance.rhs.map(str).join(", "))$. - *Step 3 -- Verify a solution.* Source config $(#x3c_mwle_sol.source_config.map(str).join(", "))$: selected subsets form an exact cover. Target config $(#x3c_mwle_sol.target_config.map(str).join(", "))$: weight $= #x3c_mwle_sol.target_config.filter(x => x != 0).len() <= K$ #sym.checkmark. + *Step 3 -- Check the linear equations on the witness.* With $y = (#y.map(str).join(", "))$, the row products are $r_0 dot y = 1$, $r_1 dot y = 1$, $r_2 dot y = 1$, $r_3 dot y = 1$, $r_4 dot y = 1$, and $r_5 dot y = 1$. Hence $A y = b$ for the stored target witness. + + *Step 4 -- Check weight and extraction.* The vector $y$ has #y.filter(x => x != 0).len() nonzero entries, exactly the required $q = #q$. Those two nonzero positions select $C_0$ and $C_1$, so the target witness encodes the same exact cover as the source configuration $(#x3c_mwle_sol.source_config.map(str).join(", "))$ #sym.checkmark. + ] + } *Multiplicity:* The fixture stores one canonical witness. ], @@ -13377,11 +13407,19 @@ The following table shows concrete variable overhead for example instances, take "pred evaluate ksat.json --config " + ksat_si_sol.source_config.map(str).join(","), ) - *Step 1 -- Source instance.* 3-SAT with $n = #ksat_si.source.instance.num_vars$ variables and $m = #sat-num-clauses(ksat_si.source.instance)$ clauses. + #{ + let pairs = ksat_si.target.instance.pairs + let x = ksat_si_sol.target_config.at(0) + [ + *Step 1 -- Source instance.* The two clauses are $C_1 = (x_1 or x_2 or x_2)$ and $C_2 = (overline(x_1) or x_2 or x_2)$. The canonical satisfying assignment is $(#ksat_si_sol.source_config.map(str).join(", "))$. + + *Step 2 -- Assign primes and variable residue constraints.* With two variables, the reduction uses primes $3$ and $5$. The variable-generated forbidden pairs are $(#pairs.at(0).at(0), #pairs.at(0).at(1))$, $(#pairs.at(1).at(0), #pairs.at(1).at(1))$, $(#pairs.at(2).at(0), #pairs.at(2).at(1))$, and $(#pairs.at(3).at(0), #pairs.at(3).at(1))$, leaving only residues $1$ and $2$ modulo $3$ and modulo $5$. - *Step 2 -- Construct incongruences.* Each variable gets a prime $>= 5$. Total: #ksat_si.target.instance.pairs.len() forbidden $(a, b)$ pairs. + *Step 3 -- Encode the clauses by CRT.* Clause $C_1$ is false only when $(x_1, x_2) = (0, 0)$, i.e.\ residues $(2 mod 3, 2 mod 5)$, which yields the forbidden pair $(#pairs.at(4).at(0), #pairs.at(4).at(1))$. Clause $C_2$ is false only when $(x_1, x_2) = (1, 0)$, i.e.\ residues $(1 mod 3, 2 mod 5)$, which yields $(#pairs.at(5).at(0), #pairs.at(5).at(1))$. - *Step 3 -- Verify a solution.* Source config $(#ksat_si_sol.source_config.map(str).join(", "))$ satisfies all clauses. Target: integer $x = #ksat_si_sol.target_config.at(0)$ avoids all #ksat_si.target.instance.pairs.len() forbidden residue classes #sym.checkmark. + *Step 4 -- Verify the target witness.* The stored integer is $x = #x$. It satisfies $x equiv 1 mod 3$ and $x equiv 1 mod 5$, so it decodes to the source assignment $(1, 1)$. It also avoids all six forbidden classes: $1 not equiv 0 mod 3$, $1 not equiv 0, 3, 4 mod 5$, and $1 not equiv 2, 7 mod 15$ #sym.checkmark. + ] + } *Multiplicity:* The fixture stores one canonical witness. ], @@ -13409,11 +13447,33 @@ The following table shows concrete variable overhead for example instances, take "pred evaluate partition.json --config " + part_stw_sol.source_config.map(str).join(","), ) - *Step 1 -- Source instance.* Partition with sizes $(#part_stw.source.instance.sizes.map(str).join(", "))$, total $= #part_stw.source.instance.sizes.sum()$, half $= #(part_stw.source.instance.sizes.sum() / 2)$. + #{ + let lengths = part_stw.target.instance.lengths + let weights = part_stw.target.instance.weights + let deadline = part_stw.target.instance.deadlines.at(0) + let on-time-sum = part_stw_sol.source_config.enumerate().filter(((i, x)) => x == 0).map(((i, x)) => part_stw.source.instance.sizes.at(i)).sum() + let tardy-sum = part_stw_sol.source_config.enumerate().filter(((i, x)) => x == 1).map(((i, x)) => part_stw.source.instance.sizes.at(i)).sum() + [ + *Step 1 -- Source instance.* The Partition fixture has sizes $(#part_stw.source.instance.sizes.map(str).join(", "))$ with total $#part_stw.source.instance.sizes.sum()$, so the target deadline is $T = #deadline$. The canonical source vector $(#part_stw_sol.source_config.map(str).join(", "))$ splits the multiset into sums $#on-time-sum$ and $#tardy-sum$. + + *Step 2 -- Build the task table.* #table( + columns: (auto, auto, auto, auto), + inset: 4pt, + align: left, + table.header([*task*], [*$l_j$*], [*$w_j$*], [*$d_j$*]), + [$t_0$], [#lengths.at(0)], [#weights.at(0)], [#deadline], + [$t_1$], [#lengths.at(1)], [#weights.at(1)], [#deadline], + [$t_2$], [#lengths.at(2)], [#weights.at(2)], [#deadline], + [$t_3$], [#lengths.at(3)], [#weights.at(3)], [#deadline], + [$t_4$], [#lengths.at(4)], [#weights.at(4)], [#deadline], + [$t_5$], [#lengths.at(5)], [#weights.at(5)], [#deadline], + ) - *Step 2 -- Construct scheduling instance.* #part_stw.target.instance.lengths.len() tasks, common deadline $= #part_stw.target.instance.deadlines.at(0)$, weights $= (#part_stw.target.instance.weights.map(str).join(", "))$. + *Step 3 -- Follow the canonical schedule.* The target permutation $(#part_stw_sol.target_config.map(str).join(", "))$ schedules tasks in the order $t_1, t_2, t_4, t_5, t_0, t_3$. The completion times are $1, 2, 4, 5, 8, 10$, so $t_1, t_2, t_4, t_5$ are on time and $t_0, t_3$ are tardy. - *Step 3 -- Verify a solution.* Source partition $(#part_stw_sol.source_config.map(str).join(", "))$: side-0 sum $= #{part_stw_sol.source_config.enumerate().filter(((i, x)) => x == 0).map(((i, x)) => part_stw.source.instance.sizes.at(i)).sum()}$, side-1 sum $= #{part_stw_sol.source_config.enumerate().filter(((i, x)) => x == 1).map(((i, x)) => part_stw.source.instance.sizes.at(i)).sum()}$ -- balanced #sym.checkmark. + *Step 4 -- Compute tardy weight and recover the partition.* Because weights equal lengths here, the tardy weight is $w_0 + w_3 = 3 + 2 = #deadline$, and the on-time tasks have total size $#on-time-sum$ while the tardy tasks have total size #tardy-sum. Extracting the schedule therefore returns the balanced partition $(#part_stw_sol.source_config.map(str).join(", "))$ #sym.checkmark. + ] + } *Multiplicity:* The fixture stores one canonical witness. ], @@ -13441,11 +13501,30 @@ The following table shows concrete variable overhead for example instances, take "pred evaluate partition.json --config " + part_oss_sol.source_config.map(str).join(","), ) - *Step 1 -- Source instance.* Partition with sizes $(#part_oss.source.instance.sizes.map(str).join(", "))$, total $S = #part_oss.source.instance.sizes.sum()$, $Q = S\/2 = #(part_oss.source.instance.sizes.sum() / 2)$. + #{ + let q = part_oss.source.instance.sizes.sum() / 2 + let p = part_oss.target.instance.processing_times + let left-sum = part_oss_sol.source_config.enumerate().filter(((i, x)) => x == 0).map(((i, x)) => part_oss.source.instance.sizes.at(i)).sum() + let right-sum = part_oss_sol.source_config.enumerate().filter(((i, x)) => x == 1).map(((i, x)) => part_oss.source.instance.sizes.at(i)).sum() + [ + *Step 1 -- Source instance.* The Partition fixture has sizes $(#part_oss.source.instance.sizes.map(str).join(", "))$, total $#part_oss.source.instance.sizes.sum()$, and half-sum $Q = #q$. The canonical source vector $(#part_oss_sol.source_config.map(str).join(", "))$ gives subset sums $#left-sum$ and $#right-sum$. + + *Step 2 -- Build the open-shop job table.* #table( + columns: (auto, auto, auto, auto), + inset: 4pt, + align: left, + table.header([*job*], [*$p_{j,1}$*], [*$p_{j,2}$*], [*$p_{j,3}$*]), + [$J_0$], [#p.at(0).at(0)], [#p.at(0).at(1)], [#p.at(0).at(2)], + [$J_1$], [#p.at(1).at(0)], [#p.at(1).at(1)], [#p.at(1).at(2)], + [$J_2$], [#p.at(2).at(0)], [#p.at(2).at(1)], [#p.at(2).at(2)], + [$J_3$], [#p.at(3).at(0)], [#p.at(3).at(1)], [#p.at(3).at(2)], + ) The first three jobs come from the partition elements, and the special job $J_3$ has processing time $Q = #q$ on every machine. - *Step 2 -- Construct open-shop instance.* #part_oss.target.instance.processing_times.len() jobs on $m = #part_oss.target.instance.num_machines$ machines. The special job has processing time $Q = #(part_oss.source.instance.sizes.sum() / 2)$ per machine. Deadline $D = 3Q = #(3 * part_oss.source.instance.sizes.sum() / 2)$. + *Step 3 -- Decode the canonical machine orders.* The target configuration $(#part_oss_sol.target_config.map(str).join(", "))$ splits into $M_1 = (0, 1, 2, 3)$, $M_2 = (0, 1, 2, 3)$, and $M_3 = (2, 3, 0, 1)$. On machine $M_3$, job $J_2$ occupies $[0, 3)$ and the special job $J_3$ starts exactly at time $Q = 3$, so the prefix before the special job contains precisely job $J_2$. - *Step 3 -- Verify a solution.* Source partition $(#part_oss_sol.source_config.map(str).join(", "))$: side-0 sum $= #{part_oss_sol.source_config.enumerate().filter(((i, x)) => x == 0).map(((i, x)) => part_oss.source.instance.sizes.at(i)).sum()}$, side-1 sum $= #{part_oss_sol.source_config.enumerate().filter(((i, x)) => x == 1).map(((i, x)) => part_oss.source.instance.sizes.at(i)).sum()}$ -- balanced #sym.checkmark. + *Step 4 -- Verify extraction and makespan.* Because only $J_2$ finishes on $M_3$ by time $Q$, the extracted source vector is $(#part_oss_sol.source_config.map(str).join(", "))$, i.e.\ subset sum #right-sum versus #left-sum. Evaluating the stored machine orders gives a concrete makespan of $12$, so the `load-example()` fixture shows both the machine assignment and the middle-machine split used for extraction #sym.checkmark. + ] + } *Multiplicity:* The fixture stores one canonical witness. ], @@ -13550,11 +13629,22 @@ The following table shows concrete variable overhead for example instances, take "pred evaluate x3c.json --config " + x3c_gf2_sol.source_config.map(str).join(","), ) - *Step 1 -- Source instance.* X3C with $|U| = #x3c_gf2.source.instance.universe_size$ elements and $|cal(C)| = #x3c_gf2.source.instance.subsets.len()$ subsets. + #{ + let x = x3c_gf2_sol.target_config + [ + *Step 1 -- Source instance.* The X3C fixture uses subsets $C_0 = {#x3c_gf2.source.instance.subsets.at(0).map(str).join(", ")}$, $C_1 = {#x3c_gf2.source.instance.subsets.at(1).map(str).join(", ")}$, and $C_2 = {#x3c_gf2.source.instance.subsets.at(2).map(str).join(", ")}$ over a universe of size $#x3c_gf2.source.instance.universe_size$. - *Step 2 -- Construct polynomial system.* #x3c_gf2.target.instance.num_variables variables, #x3c_gf2.target.instance.equations.len() polynomial equations over GF(2). + *Step 2 -- Build the GF(2) system.* The target has $#x3c_gf2.target.instance.num_variables$ variables and #x3c_gf2.target.instance.equations.len() equations. Grouping the JSON equations by element gives: + for element 0, $x_0 + x_2 + 1 = 0$ and $x_0 x_2 = 0$; + for elements 1 and 2, $x_0 + 1 = 0$; + for elements 3 and 4, $x_1 + x_2 + 1 = 0$ and $x_1 x_2 = 0$; + for element 5, $x_1 + 1 = 0$. - *Step 3 -- Verify a solution.* Source config $(#x3c_gf2_sol.source_config.map(str).join(", "))$: selected subsets form an exact cover. Target config $(#x3c_gf2_sol.target_config.map(str).join(", "))$: all equations satisfied over GF(2) #sym.checkmark. + *Step 3 -- Evaluate the canonical target witness.* The target assignment is $x = (#x.map(str).join(", ")) = (1, 1, 0)$. Substituting gives $1 + 0 + 1 = 0$ mod 2, $1 dot 0 = 0$, and $1 + 1 = 0$ mod 2, which are exactly the three polynomial patterns appearing in the fixture. + + *Step 4 -- Verify the witness pair.* The two 1-entries in $x$ select $C_0$ and $C_1$, while $x_2 = 0$ omits $C_2$. Thus the target witness encodes the same exact cover as the source configuration $(#x3c_gf2_sol.source_config.map(str).join(", "))$ #sym.checkmark. + ] + } *Multiplicity:* The fixture stores one canonical witness. ], @@ -13582,11 +13672,31 @@ The following table shows concrete variable overhead for example instances, take "pred evaluate partition.json --config " + part_pp_sol.source_config.map(str).join(","), ) - *Step 1 -- Source instance.* Partition with sizes $(#part_pp.source.instance.sizes.map(str).join(", "))$, total $S = #part_pp.source.instance.sizes.sum()$, $Q = S\/2 = #(part_pp.source.instance.sizes.sum() / 2)$. - - *Step 2 -- Construct production planning instance.* #part_pp.target.instance.num_periods periods. Element periods have capacities $(#part_pp.target.instance.capacities.map(str).join(", "))$ and setup costs $(#part_pp.target.instance.setup_costs.map(str).join(", "))$. Demand $= (#part_pp.target.instance.demands.map(str).join(", "))$, cost bound $B = #part_pp.target.instance.cost_bound$. + #{ + let left-sum = part_pp_sol.source_config.enumerate().filter(((i, x)) => x == 0).map(((i, x)) => part_pp.source.instance.sizes.at(i)).sum() + let right-sum = part_pp_sol.source_config.enumerate().filter(((i, x)) => x == 1).map(((i, x)) => part_pp.source.instance.sizes.at(i)).sum() + let prod = part_pp_sol.target_config + [ + *Step 1 -- Source instance.* The Partition fixture has sizes $(#part_pp.source.instance.sizes.map(str).join(", "))$ with total $#part_pp.source.instance.sizes.sum()$, so $Q = #(part_pp.source.instance.sizes.sum() / 2)$. The canonical source vector $(#part_pp_sol.source_config.map(str).join(", "))$ splits the instance into sums $#left-sum$ and $#right-sum$. - *Step 3 -- Verify a solution.* Source partition $(#part_pp_sol.source_config.map(str).join(", "))$: side-0 sum $= #{part_pp_sol.source_config.enumerate().filter(((i, x)) => x == 0).map(((i, x)) => part_pp.source.instance.sizes.at(i)).sum()}$, side-1 sum $= #{part_pp_sol.source_config.enumerate().filter(((i, x)) => x == 1).map(((i, x)) => part_pp.source.instance.sizes.at(i)).sum()}$ -- balanced #sym.checkmark. Target production amounts $(#part_pp_sol.target_config.map(str).join(", "))$ meet demand at cost $<= B$ #sym.checkmark. + *Step 2 -- Build the period table.* #table( + columns: (auto, auto, auto, auto, auto), + inset: 4pt, + align: left, + table.header([*period*], [*$c_t$*], [*$b_t$*], [*$r_t$*], [*$x_t$*]), + [$P_0$], [#part_pp.target.instance.capacities.at(0)], [#part_pp.target.instance.setup_costs.at(0)], [#part_pp.target.instance.demands.at(0)], [#prod.at(0)], + [$P_1$], [#part_pp.target.instance.capacities.at(1)], [#part_pp.target.instance.setup_costs.at(1)], [#part_pp.target.instance.demands.at(1)], [#prod.at(1)], + [$P_2$], [#part_pp.target.instance.capacities.at(2)], [#part_pp.target.instance.setup_costs.at(2)], [#part_pp.target.instance.demands.at(2)], [#prod.at(2)], + [$P_3$], [#part_pp.target.instance.capacities.at(3)], [#part_pp.target.instance.setup_costs.at(3)], [#part_pp.target.instance.demands.at(3)], [#prod.at(3)], + [$P_4$], [#part_pp.target.instance.capacities.at(4)], [#part_pp.target.instance.setup_costs.at(4)], [#part_pp.target.instance.demands.at(4)], [#prod.at(4)], + [$P_5$], [#part_pp.target.instance.capacities.at(5)], [#part_pp.target.instance.setup_costs.at(5)], [#part_pp.target.instance.demands.at(5)], [#prod.at(5)], + ) The first five periods encode the partition elements, and the last period carries the demand of $10$ units. + + *Step 3 -- Track cumulative production and inventory.* The stored plan $(#prod.map(str).join(", "))$ gives cumulative production $0, 0, 0, 4, 10, 10$ against cumulative demand $0, 0, 0, 0, 0, 10$. Hence the inventory levels are $0, 0, 0, 4, 10, 0$, so every prefix remains feasible. + + *Step 4 -- Check the cost and recover the partition.* Only periods $P_3$ and $P_4$ are active, so the total cost is just the setup cost $4 + 6 = #part_pp.target.instance.cost_bound$; production and inventory costs are all zero in the fixture. The active periods therefore recover the source vector $(#part_pp_sol.source_config.map(str).join(", "))$, selecting the subset of size #right-sum #sym.checkmark. + ] + } *Multiplicity:* The fixture stores one canonical witness. ],