From 5dd47199cf7c7f986c1583fea9f8d5e7cc3dfaf1 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 21 Mar 2026 16:06:11 +0800 Subject: [PATCH 1/7] Add plan for #226: [Model] AcyclicPartition --- docs/plans/2026-03-21-acyclic-partition.md | 330 +++++++++++++++++++++ 1 file changed, 330 insertions(+) create mode 100644 docs/plans/2026-03-21-acyclic-partition.md diff --git a/docs/plans/2026-03-21-acyclic-partition.md b/docs/plans/2026-03-21-acyclic-partition.md new file mode 100644 index 000000000..f67cf6792 --- /dev/null +++ b/docs/plans/2026-03-21-acyclic-partition.md @@ -0,0 +1,330 @@ +# AcyclicPartition Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add the `AcyclicPartition` model for issue `#226`, including registry/CLI/example-db integration, tests, and a paper entry. + +**Architecture:** `AcyclicPartition` will reuse the existing `DirectedGraph` topology type and store per-vertex weights, per-arc costs, a per-part weight bound, and a global inter-partition cost bound. `evaluate()` will treat each config entry as a partition label, validate per-part totals, accumulate cut cost across arcs whose endpoints land in different parts, construct the quotient digraph on used labels, and accept iff that quotient graph is acyclic and the total cut cost is within bound. + +**Tech Stack:** Rust, serde, inventory, `DirectedGraph`, brute-force solver, Typst paper, CLI integration tests. + +--- + +## Skill Mapping + +- Primary implementation skill: `add-model` +- `add-model` Step 2: Task 2 +- `add-model` Step 3: Task 3 +- `add-model` Steps 4-4.6: Tasks 3-4 +- `add-model` Step 5: Tasks 1-2 +- `add-model` Step 6: Task 6 +- `add-model` Step 7: Tasks 5 and 7 + +## Issue Notes + +- Issue: `#226` `[Model] AcyclicPartition` +- Repo pipeline preflight: `Good` label present, no existing PR, action=`create-pr` +- Associated rule issue exists: `#247` `[Rule] 3-SATISFIABILITY to ACYCLIC PARTITION` +- The issue comments claiming `DirectedGraph` is missing are outdated; the repo already provides `src/topology/directed_graph.rs` +- Scope rule: model-only PR. Do **not** add the reduction rule in this branch. + +## Batch Structure + +- **Batch 1:** Implement model, register it, add CLI/example support, add tests, verify build/tests +- **Batch 2:** Add the paper entry after the model/example data exists, then rerun verification + +### Task 1: Add failing model tests for the issue example + +**Files:** +- Create: `src/unit_tests/models/graph/acyclic_partition.rs` +- Modify: `src/models/graph/acyclic_partition.rs` + +**Step 1: Write the failing test file** + +Create tests that encode the issue’s 6-vertex example and cover: +- constructor/accessors/dims (`dims() == vec![6; 6]`) +- valid YES config `[0, 1, 0, 2, 2, 2]` +- invalid config for `K = 4` +- invalid config that creates a quotient-cycle +- brute-force solver count for the 4 canonical satisfying configs +- serde round-trip + +**Step 2: Run the focused test target to verify RED** + +Run: `cargo test acyclic_partition --lib` +Expected: FAIL because `AcyclicPartition` does not exist yet. + +**Step 3: Add the minimal model skeleton needed to compile** + +Create `src/models/graph/acyclic_partition.rs` with: +- `ProblemSchemaEntry` +- struct fields +- constructor/accessors/size getters +- `Problem` + `SatisfactionProblem` impls +- `declare_variants!` +- `#[cfg(test)]` test link + +Use `todo!()` only where necessary to keep the first green step small. + +**Step 4: Re-run the focused tests** + +Run: `cargo test acyclic_partition --lib` +Expected: FAIL for the intended behavior assertions, not missing symbols. + +**Step 5: Commit** + +```bash +git add src/models/graph/acyclic_partition.rs src/unit_tests/models/graph/acyclic_partition.rs +git commit -m "test: add AcyclicPartition model coverage" +``` + +### Task 2: Implement the model behavior until the tests pass + +**Files:** +- Modify: `src/models/graph/acyclic_partition.rs` + +**Step 1: Implement constructor validation** + +Enforce: +- `vertex_weights.len() == graph.num_vertices()` +- `arc_costs.len() == graph.num_arcs()` + +Expose: +- `graph()` +- `vertex_weights()` +- `arc_costs()` +- `weight_bound()` +- `cost_bound()` +- `set_vertex_weights()` / `set_arc_costs()` +- `is_weighted()` +- `num_vertices()` / `num_arcs()` + +**Step 2: Implement `dims()` and label-range validation** + +Use `vec![self.graph.num_vertices(); self.graph.num_vertices()]`. +Reject configs whose length is wrong or whose labels are outside `0..num_vertices`. + +**Step 3: Implement `evaluate()` / `is_valid_solution()`** + +Implement the exact feasibility checks in this order: +1. Config length and label range +2. Per-part accumulated vertex weight `<= weight_bound` +3. Inter-partition arc cost `<= cost_bound` +4. Quotient digraph acyclicity + +Quotient-graph construction requirements: +- Ignore unused labels +- Compress used labels to dense `0..q-1` +- Add one quotient arc per distinct cross-part arc direction +- A self-loop must be impossible because intra-part arcs are ignored + +**Step 4: Add helper(s) only if they simplify the logic** + +Acceptable helpers: +- `used_partition_labels(config) -> Vec` +- `quotient_graph(config) -> DirectedGraph` +- `inter_partition_cost(config) -> W::Sum` + +Do not introduce extra abstractions unless the tests force them. + +**Step 5: Run the focused model tests to verify GREEN** + +Run: `cargo test acyclic_partition --lib` +Expected: PASS + +**Step 6: Refactor while staying green** + +Keep the implementation straightforward; prefer one-pass accumulation over repeated scans. + +**Step 7: Commit** + +```bash +git add src/models/graph/acyclic_partition.rs src/unit_tests/models/graph/acyclic_partition.rs +git commit -m "feat: implement AcyclicPartition model" +``` + +### Task 3: Register the model in the crate and example-db + +**Files:** +- Modify: `src/models/graph/mod.rs` +- Modify: `src/models/mod.rs` +- Modify: `src/lib.rs` +- Modify: `src/models/graph/acyclic_partition.rs` + +**Step 1: Register the graph module export** + +Add: +- `pub(crate) mod acyclic_partition;` +- `pub use acyclic_partition::AcyclicPartition;` +- graph-module docs bullet +- `canonical_model_example_specs()` chaining entry + +**Step 2: Register crate-level re-exports** + +Add `AcyclicPartition` to: +- `src/models/mod.rs` +- `src/lib.rs` prelude export list + +**Step 3: Add the canonical model example in the model file** + +Under `#[cfg(feature = "example-db")]`, add one `ModelExampleSpec` using the issue’s YES instance: +- 6 vertices +- arcs `(0,1),(0,2),(1,3),(1,4),(2,4),(2,5),(3,5),(4,5)` +- vertex weights `[2,3,2,1,3,1]` +- arc costs `[1; 8]` +- bounds `B=5`, `K=5` +- canonical config `[0,1,0,2,2,2]` +- optimal value `true` + +**Step 4: Run a focused example-db-related test** + +Run: `cargo test example_db --lib` +Expected: PASS for the touched example-db checks. + +**Step 5: Commit** + +```bash +git add src/models/graph/mod.rs src/models/mod.rs src/lib.rs src/models/graph/acyclic_partition.rs +git commit -m "feat: register AcyclicPartition model" +``` + +### Task 4: Add CLI alias and `pred create` support + +**Files:** +- Modify: `problemreductions-cli/src/problem_name.rs` +- Modify: `problemreductions-cli/src/commands/create.rs` +- Modify: `problemreductions-cli/tests/cli_tests.rs` + +**Step 1: Write failing CLI tests** + +Add tests for: +- `pred create AcyclicPartition/i32 --arcs ... --vertex-weights ... --arc-weights ... --weight-bound 5 --cost-bound 5` +- `pred create --example AcyclicPartition/i32` +- `pred inspect AcyclicPartition/i32 --json` reporting `num_vertices` and `num_arcs` + +**Step 2: Run the focused CLI tests to verify RED** + +Run: `cargo test -p problemreductions-cli acyclic_partition` +Expected: FAIL because the alias/create branch does not exist yet. + +**Step 3: Implement CLI support** + +Update `problem_name.rs`: +- resolve lowercase `"acyclicpartition"` to `"AcyclicPartition"` + +Update `create.rs`: +- import `AcyclicPartition` +- add help/example strings for `DirectedGraph`/weight-bound/cost-bound usage +- parse `--arcs` +- parse `--vertex-weights` against `graph.num_vertices()` +- parse `--arc-weights` against `graph.num_arcs()` +- require `--weight-bound` +- require `--cost-bound` +- construct `AcyclicPartition::new(...)` + +Reuse existing directed-graph parsing helpers instead of adding new parsers. + +**Step 4: Re-run the focused CLI tests to verify GREEN** + +Run: `cargo test -p problemreductions-cli acyclic_partition` +Expected: PASS + +**Step 5: Commit** + +```bash +git add problemreductions-cli/src/problem_name.rs problemreductions-cli/src/commands/create.rs problemreductions-cli/tests/cli_tests.rs +git commit -m "feat: add CLI support for AcyclicPartition" +``` + +### Task 5: Batch-1 verification + +**Files:** +- No code changes expected + +**Step 1: Run targeted verification** + +Run: +- `cargo test acyclic_partition --lib` +- `cargo test -p problemreductions-cli acyclic_partition` + +Expected: PASS + +**Step 2: Run broader verification** + +Run: +- `make test` +- `make clippy` + +Expected: PASS + +**Step 3: Commit only if verification required a fix** + +```bash +git add -A +git commit -m "test: fix AcyclicPartition verification issues" +``` + +### Task 6: Add the paper entry + +**Files:** +- Modify: `docs/paper/reductions.typ` + +**Step 1: Write the failing paper-oriented test/check expectation** + +Use the existing canonical example as the source of truth for the paper text: +- display name `Acyclic Partition` +- formal definition using `DirectedGraph`, vertex weights, arc costs, `B`, `K` +- background referencing Garey & Johnson ND15 and DAG partitioning applications +- worked example matching the canonical config `[0,1,0,2,2,2]` + +**Step 2: Implement the paper entry** + +Add: +- display-name dictionary entry +- `#problem-def("AcyclicPartition")[...]` block + +Conventions to follow: +- load the model example with `load-model-example("AcyclicPartition")` +- explain the quotient graph explicitly +- include a directed figure with highlighted partition groups or highlighted quotient-order intuition +- keep the example text consistent with the unit tests and canonical example data + +**Step 3: Run paper build to verify GREEN** + +Run: `make paper` +Expected: PASS + +**Step 4: Commit** + +```bash +git add docs/paper/reductions.typ +git commit -m "docs: add AcyclicPartition paper entry" +``` + +### Task 7: Final verification and pipeline handoff + +**Files:** +- No code changes expected + +**Step 1: Run final repo checks** + +Run: +- `make test` +- `make clippy` +- `make paper` + +Expected: PASS + +**Step 2: Inspect git status** + +Run: `git status --short` +Expected: clean except for ignored/generated artifacts + +**Step 3: Prepare implementation summary notes for the PR comment** + +Capture: +- model file(s) added/modified +- CLI support added +- tests added +- paper entry added +- any deviations from the issue comments (notably that `DirectedGraph` already existed) From 3ee804adff8e4ffcfe5ba3850f3ed291dc6b3ed4 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 21 Mar 2026 16:11:42 +0800 Subject: [PATCH 2/7] Implement #226: add AcyclicPartition model --- src/lib.rs | 2 +- src/models/graph/acyclic_partition.rs | 266 ++++++++++++++++++ src/models/graph/mod.rs | 4 + src/models/mod.rs | 2 +- .../models/graph/acyclic_partition.rs | 214 ++++++++++++++ 5 files changed, 486 insertions(+), 2 deletions(-) create mode 100644 src/models/graph/acyclic_partition.rs create mode 100644 src/unit_tests/models/graph/acyclic_partition.rs diff --git a/src/lib.rs b/src/lib.rs index 89ce43d8c..2b9b34505 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -48,7 +48,7 @@ pub mod prelude { Satisfiability, }; pub use crate::models::graph::{ - BalancedCompleteBipartiteSubgraph, BicliqueCover, BiconnectivityAugmentation, + AcyclicPartition, BalancedCompleteBipartiteSubgraph, BicliqueCover, BiconnectivityAugmentation, BoundedComponentSpanningForest, DirectedTwoCommodityIntegralFlow, GeneralizedHex, GraphPartitioning, HamiltonianCircuit, HamiltonianPath, IsomorphicSpanningTree, KClique, KthBestSpanningTree, LengthBoundedDisjointPaths, SpinGlass, SteinerTree, diff --git a/src/models/graph/acyclic_partition.rs b/src/models/graph/acyclic_partition.rs new file mode 100644 index 000000000..9799f7b21 --- /dev/null +++ b/src/models/graph/acyclic_partition.rs @@ -0,0 +1,266 @@ +//! Acyclic Partition problem implementation. +//! +//! Given a directed graph with vertex weights, arc costs, and bounds, determine +//! whether the vertices can be partitioned into groups whose quotient graph is a +//! DAG, each group's total vertex weight is bounded, and the total +//! inter-partition arc cost is bounded. + +use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::topology::DirectedGraph; +use crate::traits::{Problem, SatisfactionProblem}; +use crate::types::WeightElement; +use num_traits::Zero; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; + +inventory::submit! { + ProblemSchemaEntry { + name: "AcyclicPartition", + display_name: "Acyclic Partition", + aliases: &[], + dimensions: &[ + VariantDimension::new("weight", "i32", &["i32"]), + ], + module_path: module_path!(), + description: "Partition a directed graph into bounded-weight groups with an acyclic quotient graph and bounded inter-partition cost", + fields: &[ + FieldInfo { name: "graph", type_name: "DirectedGraph", description: "The directed graph G=(V,A)" }, + FieldInfo { name: "vertex_weights", type_name: "Vec", description: "Vertex weights w(v) for each vertex v in V" }, + FieldInfo { name: "arc_costs", type_name: "Vec", description: "Arc costs c(a) for each arc a in A, matching graph.arcs() order" }, + FieldInfo { name: "weight_bound", type_name: "W::Sum", description: "Maximum total vertex weight B for each partition" }, + FieldInfo { name: "cost_bound", type_name: "W::Sum", description: "Maximum total inter-partition arc cost K" }, + ], + } +} + +/// Acyclic Partition (Garey & Johnson ND15). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AcyclicPartition { + graph: DirectedGraph, + vertex_weights: Vec, + arc_costs: Vec, + weight_bound: W::Sum, + cost_bound: W::Sum, +} + +impl AcyclicPartition { + /// Create a new Acyclic Partition instance. + pub fn new( + graph: DirectedGraph, + vertex_weights: Vec, + arc_costs: Vec, + weight_bound: W::Sum, + cost_bound: W::Sum, + ) -> Self { + assert_eq!( + vertex_weights.len(), + graph.num_vertices(), + "vertex_weights length must match graph num_vertices" + ); + assert_eq!( + arc_costs.len(), + graph.num_arcs(), + "arc_costs length must match graph num_arcs" + ); + Self { + graph, + vertex_weights, + arc_costs, + weight_bound, + cost_bound, + } + } + + /// Get the underlying graph. + pub fn graph(&self) -> &DirectedGraph { + &self.graph + } + + /// Get the vertex weights. + pub fn vertex_weights(&self) -> &[W] { + &self.vertex_weights + } + + /// Get the arc costs. + pub fn arc_costs(&self) -> &[W] { + &self.arc_costs + } + + /// Replace the vertex weights. + pub fn set_vertex_weights(&mut self, vertex_weights: Vec) { + assert_eq!( + vertex_weights.len(), + self.graph.num_vertices(), + "vertex_weights length must match graph num_vertices" + ); + self.vertex_weights = vertex_weights; + } + + /// Replace the arc costs. + pub fn set_arc_costs(&mut self, arc_costs: Vec) { + assert_eq!( + arc_costs.len(), + self.graph.num_arcs(), + "arc_costs length must match graph num_arcs" + ); + self.arc_costs = arc_costs; + } + + /// Get the per-part weight bound. + pub fn weight_bound(&self) -> &W::Sum { + &self.weight_bound + } + + /// Get the inter-partition cost bound. + pub fn cost_bound(&self) -> &W::Sum { + &self.cost_bound + } + + /// Check whether this instance uses non-unit weights. + pub fn is_weighted(&self) -> bool { + !W::IS_UNIT + } + + /// Get the number of vertices. + pub fn num_vertices(&self) -> usize { + self.graph.num_vertices() + } + + /// Get the number of arcs. + pub fn num_arcs(&self) -> usize { + self.graph.num_arcs() + } + + /// Check whether a configuration is a valid solution. + pub fn is_valid_solution(&self, config: &[usize]) -> bool { + is_valid_acyclic_partition( + &self.graph, + &self.vertex_weights, + &self.arc_costs, + &self.weight_bound, + &self.cost_bound, + config, + ) + } +} + +impl Problem for AcyclicPartition +where + W: WeightElement + crate::variant::VariantParam, +{ + const NAME: &'static str = "AcyclicPartition"; + type Metric = bool; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![W] + } + + fn dims(&self) -> Vec { + vec![self.graph.num_vertices(); self.graph.num_vertices()] + } + + fn evaluate(&self, config: &[usize]) -> bool { + is_valid_acyclic_partition( + &self.graph, + &self.vertex_weights, + &self.arc_costs, + &self.weight_bound, + &self.cost_bound, + config, + ) + } +} + +impl SatisfactionProblem for AcyclicPartition where W: WeightElement + crate::variant::VariantParam {} + +fn is_valid_acyclic_partition( + graph: &DirectedGraph, + vertex_weights: &[W], + arc_costs: &[W], + weight_bound: &W::Sum, + cost_bound: &W::Sum, + config: &[usize], +) -> bool { + let num_vertices = graph.num_vertices(); + if config.len() != num_vertices { + return false; + } + if vertex_weights.len() != num_vertices || arc_costs.len() != graph.num_arcs() { + return false; + } + if config.iter().any(|&label| label >= num_vertices) { + return false; + } + + let mut partition_weights = vec![W::Sum::zero(); num_vertices]; + let mut used_labels = vec![false; num_vertices]; + for (vertex, &label) in config.iter().enumerate() { + used_labels[label] = true; + partition_weights[label] += vertex_weights[vertex].to_sum(); + if partition_weights[label] > *weight_bound { + return false; + } + } + + let mut dense_label = vec![usize::MAX; num_vertices]; + let mut next_dense = 0usize; + for (label, used) in used_labels.iter().enumerate() { + if *used { + dense_label[label] = next_dense; + next_dense += 1; + } + } + + let mut total_cost = W::Sum::zero(); + let mut quotient_arcs = BTreeSet::new(); + for ((source, target), cost) in graph.arcs().iter().zip(arc_costs.iter()) { + let source_label = config[*source]; + let target_label = config[*target]; + if source_label == target_label { + continue; + } + total_cost += cost.to_sum(); + if total_cost > *cost_bound { + return false; + } + quotient_arcs.insert((dense_label[source_label], dense_label[target_label])); + } + + DirectedGraph::new(next_dense, quotient_arcs.into_iter().collect()).is_dag() +} + +crate::declare_variants! { + default sat AcyclicPartition => "num_vertices^num_vertices", +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "acyclic_partition_i32", + instance: Box::new(AcyclicPartition::new( + DirectedGraph::new( + 6, + vec![ + (0, 1), + (0, 2), + (1, 3), + (1, 4), + (2, 4), + (2, 5), + (3, 5), + (4, 5), + ], + ), + vec![2, 3, 2, 1, 3, 1], + vec![1; 8], + 5, + 5, + )), + optimal_config: vec![0, 1, 0, 2, 2, 2], + optimal_value: serde_json::json!(true), + }] +} + +#[cfg(test)] +#[path = "../../unit_tests/models/graph/acyclic_partition.rs"] +mod tests; diff --git a/src/models/graph/mod.rs b/src/models/graph/mod.rs index c88017482..c971b7746 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -1,6 +1,7 @@ //! Graph problems. //! //! Problems whose input is a graph (optionally weighted): +//! - [`AcyclicPartition`]: Partition a digraph into bounded-weight groups with an acyclic quotient graph //! - [`MaximumIndependentSet`]: Maximum weight independent set //! - [`MaximalIS`]: Maximal independent set //! - [`MinimumVertexCover`]: Minimum weight vertex cover @@ -43,6 +44,7 @@ //! - [`StrongConnectivityAugmentation`]: Strong connectivity augmentation with weighted candidate arcs pub(crate) mod balanced_complete_bipartite_subgraph; +pub(crate) mod acyclic_partition; pub(crate) mod biclique_cover; pub(crate) mod biconnectivity_augmentation; pub(crate) mod bounded_component_spanning_forest; @@ -84,6 +86,7 @@ pub(crate) mod subgraph_isomorphism; pub(crate) mod traveling_salesman; pub(crate) mod undirected_two_commodity_integral_flow; +pub use acyclic_partition::AcyclicPartition; pub use balanced_complete_bipartite_subgraph::BalancedCompleteBipartiteSubgraph; pub use biclique_cover::BicliqueCover; pub use biconnectivity_augmentation::BiconnectivityAugmentation; @@ -129,6 +132,7 @@ pub use undirected_two_commodity_integral_flow::UndirectedTwoCommodityIntegralFl #[cfg(feature = "example-db")] pub(crate) fn canonical_model_example_specs() -> Vec { let mut specs = Vec::new(); + specs.extend(acyclic_partition::canonical_model_example_specs()); specs.extend(maximum_independent_set::canonical_model_example_specs()); specs.extend(minimum_vertex_cover::canonical_model_example_specs()); specs.extend(max_cut::canonical_model_example_specs()); diff --git a/src/models/mod.rs b/src/models/mod.rs index a2be59f0c..bdeddcf53 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -18,7 +18,7 @@ pub use formula::{ Quantifier, Satisfiability, }; pub use graph::{ - BalancedCompleteBipartiteSubgraph, BicliqueCover, BiconnectivityAugmentation, + AcyclicPartition, BalancedCompleteBipartiteSubgraph, BicliqueCover, BiconnectivityAugmentation, BoundedComponentSpanningForest, DirectedTwoCommodityIntegralFlow, GeneralizedHex, GraphPartitioning, HamiltonianCircuit, HamiltonianPath, IsomorphicSpanningTree, KClique, KColoring, KthBestSpanningTree, LengthBoundedDisjointPaths, MaxCut, MaximalIS, MaximumClique, diff --git a/src/unit_tests/models/graph/acyclic_partition.rs b/src/unit_tests/models/graph/acyclic_partition.rs new file mode 100644 index 000000000..c24adee5c --- /dev/null +++ b/src/unit_tests/models/graph/acyclic_partition.rs @@ -0,0 +1,214 @@ +use super::*; +use crate::solvers::{BruteForce, Solver}; +use crate::topology::DirectedGraph; +use crate::traits::Problem; +use serde_json; +use std::collections::BTreeSet; + +fn yes_instance() -> AcyclicPartition { + AcyclicPartition::new( + DirectedGraph::new( + 6, + vec![ + (0, 1), + (0, 2), + (1, 3), + (1, 4), + (2, 4), + (2, 5), + (3, 5), + (4, 5), + ], + ), + vec![2, 3, 2, 1, 3, 1], + vec![1; 8], + 5, + 5, + ) +} + +fn no_cost_instance() -> AcyclicPartition { + AcyclicPartition::new( + DirectedGraph::new( + 6, + vec![ + (0, 1), + (0, 2), + (1, 3), + (1, 4), + (2, 4), + (2, 5), + (3, 5), + (4, 5), + ], + ), + vec![2, 3, 2, 1, 3, 1], + vec![1; 8], + 5, + 4, + ) +} + +fn quotient_cycle_instance() -> AcyclicPartition { + AcyclicPartition::new( + DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]), + vec![1, 1, 1], + vec![1, 1, 1], + 3, + 3, + ) +} + +fn canonicalize_labels(config: &[usize]) -> Vec { + let mut next_label = 0usize; + let mut mapping = std::collections::BTreeMap::new(); + let mut normalized = Vec::with_capacity(config.len()); + for &label in config { + let mapped = mapping.entry(label).or_insert_with(|| { + let current = next_label; + next_label += 1; + current + }); + normalized.push(*mapped); + } + normalized +} + +#[test] +fn test_acyclic_partition_creation_and_accessors() { + let mut problem = yes_instance(); + + assert_eq!(problem.num_vertices(), 6); + assert_eq!(problem.num_arcs(), 8); + assert_eq!(problem.dims(), vec![6; 6]); + assert_eq!(problem.graph().arcs().len(), 8); + assert_eq!(problem.vertex_weights(), &[2, 3, 2, 1, 3, 1]); + assert_eq!(problem.arc_costs(), &[1, 1, 1, 1, 1, 1, 1, 1]); + assert_eq!(problem.weight_bound(), &5); + assert_eq!(problem.cost_bound(), &5); + assert!(problem.is_weighted()); + + problem.set_vertex_weights(vec![1; 6]); + problem.set_arc_costs(vec![2; 8]); + assert_eq!(problem.vertex_weights(), &[1, 1, 1, 1, 1, 1]); + assert_eq!(problem.arc_costs(), &[2, 2, 2, 2, 2, 2, 2, 2]); +} + +#[test] +fn test_acyclic_partition_rejects_weight_length_mismatch() { + let result = std::panic::catch_unwind(|| { + AcyclicPartition::new( + DirectedGraph::new(2, vec![(0, 1)]), + vec![1], + vec![1], + 2, + 1, + ) + }); + assert!(result.is_err()); +} + +#[test] +fn test_acyclic_partition_rejects_arc_cost_length_mismatch() { + let result = std::panic::catch_unwind(|| { + AcyclicPartition::new( + DirectedGraph::new(2, vec![(0, 1)]), + vec![1, 1], + vec![], + 2, + 1, + ) + }); + assert!(result.is_err()); +} + +#[test] +fn test_acyclic_partition_evaluate_yes_instance() { + let problem = yes_instance(); + let config = vec![0, 1, 0, 2, 2, 2]; + assert!(problem.evaluate(&config)); + assert!(problem.is_valid_solution(&config)); +} + +#[test] +fn test_acyclic_partition_rejects_too_small_cost_bound() { + let problem = no_cost_instance(); + assert!(!problem.evaluate(&[0, 1, 0, 2, 2, 2])); +} + +#[test] +fn test_acyclic_partition_rejects_quotient_cycle() { + let problem = quotient_cycle_instance(); + assert!(!problem.evaluate(&[0, 1, 2])); +} + +#[test] +fn test_acyclic_partition_rejects_weight_bound_violation() { + let problem = yes_instance(); + assert!(!problem.evaluate(&[0, 0, 0, 1, 1, 1])); +} + +#[test] +fn test_acyclic_partition_rejects_wrong_config_length() { + let problem = yes_instance(); + assert!(!problem.evaluate(&[0, 1, 0])); +} + +#[test] +fn test_acyclic_partition_rejects_out_of_range_label() { + let problem = yes_instance(); + assert!(!problem.evaluate(&[0, 1, 0, 2, 2, 6])); +} + +#[test] +fn test_acyclic_partition_solver_finds_issue_example() { + let problem = yes_instance(); + let solver = BruteForce::new(); + + let solution = solver.find_satisfying(&problem); + assert!(solution.is_some()); + assert!(problem.evaluate(&solution.unwrap())); +} + +#[test] +fn test_acyclic_partition_solver_has_four_canonical_solutions() { + let problem = yes_instance(); + let solutions = BruteForce::new().find_all_satisfying(&problem); + let normalized: BTreeSet> = solutions + .iter() + .map(|config| canonicalize_labels(config)) + .collect(); + + let expected = BTreeSet::from([ + vec![0, 0, 1, 2, 1, 2], + vec![0, 0, 1, 2, 2, 2], + vec![0, 1, 0, 1, 2, 2], + vec![0, 1, 0, 2, 2, 2], + ]); + + assert_eq!(normalized, expected); +} + +#[test] +fn test_acyclic_partition_no_solution_when_cost_bound_is_four() { + let problem = no_cost_instance(); + assert!(BruteForce::new().find_satisfying(&problem).is_none()); +} + +#[test] +fn test_acyclic_partition_serialization() { + let problem = yes_instance(); + let json = serde_json::to_string(&problem).unwrap(); + let deserialized: AcyclicPartition = serde_json::from_str(&json).unwrap(); + + assert_eq!(deserialized.num_vertices(), 6); + assert_eq!(deserialized.num_arcs(), 8); + assert_eq!(deserialized.weight_bound(), &5); + assert_eq!(deserialized.cost_bound(), &5); +} + +#[test] +fn test_acyclic_partition_num_variables() { + let problem = yes_instance(); + assert_eq!(problem.num_variables(), 6); +} From f45dab2f244fcd1a898a4ee9cd635365774df292 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 21 Mar 2026 16:14:17 +0800 Subject: [PATCH 3/7] docs: add AcyclicPartition paper entry --- docs/paper/reductions.typ | 56 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 05ae126fe..8692b51a6 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -63,6 +63,7 @@ // Problem display names for theorem headers #let display-name = ( "AdditionalKey": [Additional Key], + "AcyclicPartition": [Acyclic Partition], "MaximumIndependentSet": [Maximum Independent Set], "MinimumVertexCover": [Minimum Vertex Cover], "MaxCut": [Max-Cut], @@ -3585,6 +3586,61 @@ A classical NP-complete problem from Garey and Johnson @garey1979[Ch.~3, p.~76], ] } +#{ + let x = load-model-example("AcyclicPartition") + let nv = x.instance.graph.num_vertices + let arcs = x.instance.graph.arcs.map(a => (a.at(0), a.at(1))) + let weights = x.instance.vertex_weights + let config = x.optimal_config + let B = x.instance.weight_bound + let K = x.instance.cost_bound + let part0 = range(nv).filter(v => config.at(v) == 0) + let part1 = range(nv).filter(v => config.at(v) == 1) + let part2 = range(nv).filter(v => config.at(v) == 2) + let part0w = part0.map(v => weights.at(v)).sum(default: 0) + let part1w = part1.map(v => weights.at(v)).sum(default: 0) + let part2w = part2.map(v => weights.at(v)).sum(default: 0) + let cross-arcs = arcs.filter(a => config.at(a.at(0)) != config.at(a.at(1))) + [ + #problem-def("AcyclicPartition")[ + Given a directed graph $G = (V, A)$ with vertex weights $w: V -> ZZ^+$, arc costs $c: A -> ZZ^+$, and bounds $B, K in ZZ^+$, determine whether there exists a partition $V = V_1 ∪ dots ∪ V_m$ such that every part satisfies $sum_(v in V_i) w(v) <= B$, the total cost of arcs crossing between different parts is at most $K$, and the quotient digraph on the parts is acyclic. + ][ + Acyclic Partition is the directed partitioning problem ND15 in Garey & Johnson @garey1979. Unlike ordinary graph partitioning, the goal is not merely to minimize the cut: the partition must preserve a global topological order after every part is contracted to a super-node. This makes the model a natural abstraction for DAG-aware task clustering in compiler scheduling, parallel execution pipelines, and automatic differentiation systems where coarse-grained blocks must still communicate without creating cyclic dependencies. + + The implementation uses the natural witness encoding in which each of the $n = #nv$ vertices chooses one of at most $n$ part labels, so direct brute-force search explores $n^n$ assignments.#footnote[Many labelings represent the same unordered partition, but the full configuration space exposed to the solver is still $n^n$.] + + *Example.* Consider the six-vertex digraph in the figure with vertex weights $w = (#weights.map(w => str(w)).join(", "))$, part bound $B = #B$, and cut-cost bound $K = #K$. The witness $V_0 = {#part0.map(v => $v_#v$).join(", ")}$, $V_1 = {#part1.map(v => $v_#v$).join(", ")}$, $V_2 = {#part2.map(v => $v_#v$).join(", ")}$ has part weights $#part0w$, $#part1w$, and $#part2w$, so every part respects the weight cap. Exactly #cross-arcs.len() arcs cross between different parts, namely #cross-arcs.map(a => $(v_#(a.at(0)) arrow v_#(a.at(1)))$).join($,$), so the total crossing cost is $#cross-arcs.len() <= K$. These crossings induce quotient arcs $V_0 arrow V_1$, $V_0 arrow V_2$, and $V_1 arrow V_2$, which form a DAG; hence this instance is a YES-instance. + + #figure({ + let verts = ((0, 1.6), (1.4, 2.4), (1.4, 0.8), (3.2, 2.4), (3.2, 0.8), (4.8, 1.6)) + canvas(length: 1cm, { + for arc in arcs { + let (u, v) = arc + let crossing = config.at(u) != config.at(v) + draw.line( + verts.at(u), + verts.at(v), + stroke: if crossing { 1.3pt + black } else { 0.9pt + luma(170) }, + mark: (end: "straight", scale: if crossing { 0.5 } else { 0.4 }), + ) + } + for (v, pos) in verts.enumerate() { + let color = graph-colors.at(config.at(v)) + g-node( + pos, + name: "v" + str(v), + fill: color, + label: text(fill: white)[$v_#v$], + ) + } + }) + }, + caption: [A YES witness for Acyclic Partition. Node colors indicate the parts $V_0$, $V_1$, and $V_2$. Black arcs cross parts and define the quotient DAG $V_0 arrow V_1$, $V_0 arrow V_2$, $V_1 arrow V_2$; gray arcs stay inside a part and therefore do not contribute to the quotient graph.], + ) + ] + ] +} + #{ let x = load-model-example("FlowShopScheduling") let m = x.instance.num_processors From 02c2ca0b268ab44a4c8012e97a58ebb6fba4c0f1 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 21 Mar 2026 16:20:58 +0800 Subject: [PATCH 4/7] feat: add CLI support for AcyclicPartition --- problemreductions-cli/src/cli.rs | 7 + problemreductions-cli/src/commands/create.rs | 67 ++++++++ problemreductions-cli/tests/cli_tests.rs | 153 +++++++++++++++++++ 3 files changed, 227 insertions(+) diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 292400243..f77c41793 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -258,6 +258,7 @@ Flags by problem type: ConsecutiveOnesSubmatrix --matrix (0/1), --k SteinerTree --graph, --edge-weights, --terminals MultipleCopyFileAllocation --graph, --usage, --storage, --bound + AcyclicPartition --arcs [--weights] [--arc-costs] --weight-bound --cost-bound [--num-vertices] CVP --basis, --target-vec [--bounds] MultiprocessorScheduling --lengths, --num-processors, --deadline SequencingWithinIntervals --release-times, --deadlines, --lengths @@ -505,6 +506,9 @@ pub struct CreateArgs { /// Upper bound on total path weight #[arg(long)] pub weight_bound: Option, + /// Upper bound on total inter-partition arc cost + #[arg(long)] + pub cost_bound: Option, /// Pattern graph edge list for SubgraphIsomorphism (e.g., 0-1,1-2,2-0) #[arg(long)] pub pattern: Option, @@ -514,6 +518,9 @@ 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, + /// Arc costs for directed graph problems with per-arc costs (comma-separated, e.g., "1,1,2,3") + #[arg(long)] + pub arc_costs: Option, /// Directed arcs for directed graph problems (e.g., 0>1,1>2,2>0) #[arg(long)] pub arcs: Option, diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index be56d6f5f..97e5c62a4 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -100,9 +100,11 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool { && args.bound.is_none() && args.length_bound.is_none() && args.weight_bound.is_none() + && args.cost_bound.is_none() && args.pattern.is_none() && args.strings.is_none() && args.costs.is_none() + && args.arc_costs.is_none() && args.arcs.is_none() && args.quantifiers.is_none() && args.usage.is_none() @@ -578,6 +580,9 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "MultipleCopyFileAllocation" => { MULTIPLE_COPY_FILE_ALLOCATION_EXAMPLE_ARGS } + "AcyclicPartition" => { + "--arcs \"0>1,0>2,1>3,1>4,2>4,2>5,3>5,4>5\" --weights 2,3,2,1,3,1 --arc-costs 1,1,1,1,1,1,1,1 --weight-bound 5 --cost-bound 5" + } "OptimalLinearArrangement" => "--graph 0-1,1-2,2-3 --bound 5", "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" @@ -2977,6 +2982,45 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } + // AcyclicPartition + "AcyclicPartition" => { + let usage = "Usage: pred create AcyclicPartition/i32 --arcs \"0>1,0>2,1>3,1>4,2>4,2>5,3>5,4>5\" --weights 2,3,2,1,3,1 --arc-costs 1,1,1,1,1,1,1,1 --weight-bound 5 --cost-bound 5"; + let arcs_str = args.arcs.as_deref().ok_or_else(|| { + anyhow::anyhow!("AcyclicPartition requires --arcs\n\n{usage}") + })?; + let (graph, num_arcs) = parse_directed_graph(arcs_str, args.num_vertices)?; + let vertex_weights = parse_vertex_weights(args, graph.num_vertices())?; + let arc_costs = parse_arc_costs(args, num_arcs)?; + let weight_bound = args.weight_bound.ok_or_else(|| { + anyhow::anyhow!("AcyclicPartition requires --weight-bound\n\n{usage}") + })?; + let cost_bound = args.cost_bound.ok_or_else(|| { + anyhow::anyhow!("AcyclicPartition requires --cost-bound\n\n{usage}") + })?; + if vertex_weights.iter().any(|&weight| weight < 0) { + bail!("AcyclicPartition --weights must be non-negative"); + } + if arc_costs.iter().any(|&cost| cost < 0) { + bail!("AcyclicPartition --arc-costs must be non-negative"); + } + if weight_bound < 0 { + bail!("AcyclicPartition --weight-bound must be non-negative"); + } + if cost_bound < 0 { + bail!("AcyclicPartition --cost-bound must be non-negative"); + } + ( + ser(AcyclicPartition::new( + graph, + vertex_weights, + arc_costs, + weight_bound, + cost_bound, + ))?, + resolved_variant.clone(), + ) + } + // MinMaxMulticenter (vertex p-center) "MinMaxMulticenter" => { let usage = "Usage: pred create MinMaxMulticenter --graph 0-1,1-2,2-3 [--weights 1,1,1,1] [--edge-weights 1,1,1] --k 2 --bound 2"; @@ -4718,6 +4762,27 @@ fn parse_arc_weights(args: &CreateArgs, num_arcs: usize) -> Result> { } } +/// Parse `--arc-costs` as per-arc costs (i32), defaulting to all 1s. +fn parse_arc_costs(args: &CreateArgs, num_arcs: usize) -> Result> { + match &args.arc_costs { + Some(costs) => { + let parsed: Vec = costs + .split(',') + .map(|s| s.trim().parse::()) + .collect::, _>>()?; + if parsed.len() != num_arcs { + bail!( + "Expected {} arc costs but got {}", + num_arcs, + parsed.len() + ); + } + Ok(parsed) + } + None => Ok(vec![1i32; num_arcs]), + } +} + /// Parse `--candidate-arcs` as `u>v:w` entries for StrongConnectivityAugmentation. fn parse_candidate_arcs( args: &CreateArgs, @@ -5643,8 +5708,10 @@ mod tests { bound: None, length_bound: None, weight_bound: None, + cost_bound: None, pattern: None, strings: None, + arc_costs: None, arcs: None, values: None, precedences: None, diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index 646a76e94..16d84959f 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -1951,6 +1951,100 @@ fn test_create_model_example_multiple_choice_branching_round_trips_into_solve() std::fs::remove_file(&path).ok(); } +#[test] +fn test_create_acyclic_partition() { + let output = pred() + .args([ + "create", + "AcyclicPartition/i32", + "--arcs", + "0>1,0>2,1>3,1>4,2>4,2>5,3>5,4>5", + "--weights", + "2,3,2,1,3,1", + "--arc-costs", + "1,1,1,1,1,1,1,1", + "--weight-bound", + "5", + "--cost-bound", + "5", + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8(output.stdout).unwrap(); + let json: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(json["type"], "AcyclicPartition"); + assert_eq!(json["variant"]["weight"], "i32"); + assert_eq!(json["data"]["vertex_weights"], serde_json::json!([2, 3, 2, 1, 3, 1])); + assert_eq!(json["data"]["arc_costs"], serde_json::json!([1, 1, 1, 1, 1, 1, 1, 1])); + assert_eq!(json["data"]["weight_bound"], 5); + assert_eq!(json["data"]["cost_bound"], 5); +} + +#[test] +fn test_create_model_example_acyclic_partition() { + let output = pred() + .args(["create", "--example", "AcyclicPartition/i32"]) + .output() + .unwrap(); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8(output.stdout).unwrap(); + let json: serde_json::Value = serde_json::from_str(&stdout).unwrap(); + assert_eq!(json["type"], "AcyclicPartition"); + assert_eq!(json["variant"]["weight"], "i32"); + assert_eq!(json["data"]["weight_bound"], 5); + assert_eq!(json["data"]["cost_bound"], 5); + assert_eq!(json["data"]["graph"]["num_vertices"], 6); +} + +#[test] +fn test_create_model_example_acyclic_partition_round_trips_into_solve() { + let path = std::env::temp_dir().join(format!( + "pred_test_model_example_acyclic_partition_{}.json", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let create = pred() + .args([ + "create", + "--example", + "AcyclicPartition/i32", + "-o", + path.to_str().unwrap(), + ]) + .output() + .unwrap(); + assert!( + create.status.success(), + "stderr: {}", + String::from_utf8_lossy(&create.stderr) + ); + + let solve = pred() + .args(["solve", path.to_str().unwrap(), "--solver", "brute-force"]) + .output() + .unwrap(); + assert!( + solve.status.success(), + "stderr: {}", + String::from_utf8_lossy(&solve.stderr) + ); + + std::fs::remove_file(&path).ok(); +} + #[test] fn test_create_multiple_choice_branching_rejects_negative_bound() { let output = pred() @@ -5355,6 +5449,65 @@ fn test_inspect_undirected_two_commodity_integral_flow_reports_size_fields() { std::fs::remove_file(&result_file).ok(); } +#[test] +fn test_inspect_acyclic_partition_reports_size_fields() { + let problem_file = std::env::temp_dir().join("pred_test_acyclic_partition_inspect_in.json"); + let result_file = std::env::temp_dir().join("pred_test_acyclic_partition_inspect_out.json"); + let create_out = pred() + .args([ + "-o", + problem_file.to_str().unwrap(), + "create", + "--example", + "AcyclicPartition/i32", + ]) + .output() + .unwrap(); + assert!( + create_out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&create_out.stderr) + ); + + let output = pred() + .args([ + "-o", + result_file.to_str().unwrap(), + "inspect", + problem_file.to_str().unwrap(), + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(result_file.exists()); + + let content = std::fs::read_to_string(&result_file).unwrap(); + let json: serde_json::Value = serde_json::from_str(&content).unwrap(); + let size_fields: Vec<&str> = json["size_fields"] + .as_array() + .expect("size_fields should be an array") + .iter() + .map(|v| v.as_str().unwrap()) + .collect(); + assert!( + size_fields.contains(&"num_vertices"), + "AcyclicPartition size_fields should contain num_vertices, got: {:?}", + size_fields + ); + assert!( + size_fields.contains(&"num_arcs"), + "AcyclicPartition size_fields should contain num_arcs, got: {:?}", + size_fields + ); + + std::fs::remove_file(&problem_file).ok(); + std::fs::remove_file(&result_file).ok(); +} + #[test] fn test_inspect_multiple_copy_file_allocation_reports_size_fields() { let problem_file = std::env::temp_dir().join("pred_test_mcfa_inspect_in.json"); From 4a7305eb09db80c7cbee027b2c6d88739dfbef8a Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 21 Mar 2026 16:22:17 +0800 Subject: [PATCH 5/7] test: declare AcyclicPartition size fields --- src/models/graph/acyclic_partition.rs | 9 ++++++++- src/unit_tests/models/graph/acyclic_partition.rs | 11 ++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/models/graph/acyclic_partition.rs b/src/models/graph/acyclic_partition.rs index 9799f7b21..6660d76a7 100644 --- a/src/models/graph/acyclic_partition.rs +++ b/src/models/graph/acyclic_partition.rs @@ -5,7 +5,7 @@ //! DAG, each group's total vertex weight is bounded, and the total //! inter-partition arc cost is bounded. -use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::registry::{FieldInfo, ProblemSchemaEntry, ProblemSizeFieldEntry, VariantDimension}; use crate::topology::DirectedGraph; use crate::traits::{Problem, SatisfactionProblem}; use crate::types::WeightElement; @@ -33,6 +33,13 @@ inventory::submit! { } } +inventory::submit! { + ProblemSizeFieldEntry { + name: "AcyclicPartition", + fields: &["num_vertices", "num_arcs"], + } +} + /// Acyclic Partition (Garey & Johnson ND15). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AcyclicPartition { diff --git a/src/unit_tests/models/graph/acyclic_partition.rs b/src/unit_tests/models/graph/acyclic_partition.rs index c24adee5c..0abb21887 100644 --- a/src/unit_tests/models/graph/acyclic_partition.rs +++ b/src/unit_tests/models/graph/acyclic_partition.rs @@ -1,9 +1,10 @@ use super::*; use crate::solvers::{BruteForce, Solver}; +use crate::registry::declared_size_fields; use crate::topology::DirectedGraph; use crate::traits::Problem; use serde_json; -use std::collections::BTreeSet; +use std::collections::{BTreeSet, HashSet}; fn yes_instance() -> AcyclicPartition { AcyclicPartition::new( @@ -212,3 +213,11 @@ fn test_acyclic_partition_num_variables() { let problem = yes_instance(); assert_eq!(problem.num_variables(), 6); } + +#[test] +fn test_acyclic_partition_declares_problem_size_fields() { + let fields: HashSet<&'static str> = declared_size_fields("AcyclicPartition") + .into_iter() + .collect(); + assert_eq!(fields, HashSet::from(["num_vertices", "num_arcs"])); +} From 0d9d6d77b9a695fa7dcc9964aa562dd35d5a40b9 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 21 Mar 2026 16:25:18 +0800 Subject: [PATCH 6/7] chore: remove plan file after implementation --- docs/plans/2026-03-21-acyclic-partition.md | 330 --------------------- 1 file changed, 330 deletions(-) delete mode 100644 docs/plans/2026-03-21-acyclic-partition.md diff --git a/docs/plans/2026-03-21-acyclic-partition.md b/docs/plans/2026-03-21-acyclic-partition.md deleted file mode 100644 index f67cf6792..000000000 --- a/docs/plans/2026-03-21-acyclic-partition.md +++ /dev/null @@ -1,330 +0,0 @@ -# AcyclicPartition Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Add the `AcyclicPartition` model for issue `#226`, including registry/CLI/example-db integration, tests, and a paper entry. - -**Architecture:** `AcyclicPartition` will reuse the existing `DirectedGraph` topology type and store per-vertex weights, per-arc costs, a per-part weight bound, and a global inter-partition cost bound. `evaluate()` will treat each config entry as a partition label, validate per-part totals, accumulate cut cost across arcs whose endpoints land in different parts, construct the quotient digraph on used labels, and accept iff that quotient graph is acyclic and the total cut cost is within bound. - -**Tech Stack:** Rust, serde, inventory, `DirectedGraph`, brute-force solver, Typst paper, CLI integration tests. - ---- - -## Skill Mapping - -- Primary implementation skill: `add-model` -- `add-model` Step 2: Task 2 -- `add-model` Step 3: Task 3 -- `add-model` Steps 4-4.6: Tasks 3-4 -- `add-model` Step 5: Tasks 1-2 -- `add-model` Step 6: Task 6 -- `add-model` Step 7: Tasks 5 and 7 - -## Issue Notes - -- Issue: `#226` `[Model] AcyclicPartition` -- Repo pipeline preflight: `Good` label present, no existing PR, action=`create-pr` -- Associated rule issue exists: `#247` `[Rule] 3-SATISFIABILITY to ACYCLIC PARTITION` -- The issue comments claiming `DirectedGraph` is missing are outdated; the repo already provides `src/topology/directed_graph.rs` -- Scope rule: model-only PR. Do **not** add the reduction rule in this branch. - -## Batch Structure - -- **Batch 1:** Implement model, register it, add CLI/example support, add tests, verify build/tests -- **Batch 2:** Add the paper entry after the model/example data exists, then rerun verification - -### Task 1: Add failing model tests for the issue example - -**Files:** -- Create: `src/unit_tests/models/graph/acyclic_partition.rs` -- Modify: `src/models/graph/acyclic_partition.rs` - -**Step 1: Write the failing test file** - -Create tests that encode the issue’s 6-vertex example and cover: -- constructor/accessors/dims (`dims() == vec![6; 6]`) -- valid YES config `[0, 1, 0, 2, 2, 2]` -- invalid config for `K = 4` -- invalid config that creates a quotient-cycle -- brute-force solver count for the 4 canonical satisfying configs -- serde round-trip - -**Step 2: Run the focused test target to verify RED** - -Run: `cargo test acyclic_partition --lib` -Expected: FAIL because `AcyclicPartition` does not exist yet. - -**Step 3: Add the minimal model skeleton needed to compile** - -Create `src/models/graph/acyclic_partition.rs` with: -- `ProblemSchemaEntry` -- struct fields -- constructor/accessors/size getters -- `Problem` + `SatisfactionProblem` impls -- `declare_variants!` -- `#[cfg(test)]` test link - -Use `todo!()` only where necessary to keep the first green step small. - -**Step 4: Re-run the focused tests** - -Run: `cargo test acyclic_partition --lib` -Expected: FAIL for the intended behavior assertions, not missing symbols. - -**Step 5: Commit** - -```bash -git add src/models/graph/acyclic_partition.rs src/unit_tests/models/graph/acyclic_partition.rs -git commit -m "test: add AcyclicPartition model coverage" -``` - -### Task 2: Implement the model behavior until the tests pass - -**Files:** -- Modify: `src/models/graph/acyclic_partition.rs` - -**Step 1: Implement constructor validation** - -Enforce: -- `vertex_weights.len() == graph.num_vertices()` -- `arc_costs.len() == graph.num_arcs()` - -Expose: -- `graph()` -- `vertex_weights()` -- `arc_costs()` -- `weight_bound()` -- `cost_bound()` -- `set_vertex_weights()` / `set_arc_costs()` -- `is_weighted()` -- `num_vertices()` / `num_arcs()` - -**Step 2: Implement `dims()` and label-range validation** - -Use `vec![self.graph.num_vertices(); self.graph.num_vertices()]`. -Reject configs whose length is wrong or whose labels are outside `0..num_vertices`. - -**Step 3: Implement `evaluate()` / `is_valid_solution()`** - -Implement the exact feasibility checks in this order: -1. Config length and label range -2. Per-part accumulated vertex weight `<= weight_bound` -3. Inter-partition arc cost `<= cost_bound` -4. Quotient digraph acyclicity - -Quotient-graph construction requirements: -- Ignore unused labels -- Compress used labels to dense `0..q-1` -- Add one quotient arc per distinct cross-part arc direction -- A self-loop must be impossible because intra-part arcs are ignored - -**Step 4: Add helper(s) only if they simplify the logic** - -Acceptable helpers: -- `used_partition_labels(config) -> Vec` -- `quotient_graph(config) -> DirectedGraph` -- `inter_partition_cost(config) -> W::Sum` - -Do not introduce extra abstractions unless the tests force them. - -**Step 5: Run the focused model tests to verify GREEN** - -Run: `cargo test acyclic_partition --lib` -Expected: PASS - -**Step 6: Refactor while staying green** - -Keep the implementation straightforward; prefer one-pass accumulation over repeated scans. - -**Step 7: Commit** - -```bash -git add src/models/graph/acyclic_partition.rs src/unit_tests/models/graph/acyclic_partition.rs -git commit -m "feat: implement AcyclicPartition model" -``` - -### Task 3: Register the model in the crate and example-db - -**Files:** -- Modify: `src/models/graph/mod.rs` -- Modify: `src/models/mod.rs` -- Modify: `src/lib.rs` -- Modify: `src/models/graph/acyclic_partition.rs` - -**Step 1: Register the graph module export** - -Add: -- `pub(crate) mod acyclic_partition;` -- `pub use acyclic_partition::AcyclicPartition;` -- graph-module docs bullet -- `canonical_model_example_specs()` chaining entry - -**Step 2: Register crate-level re-exports** - -Add `AcyclicPartition` to: -- `src/models/mod.rs` -- `src/lib.rs` prelude export list - -**Step 3: Add the canonical model example in the model file** - -Under `#[cfg(feature = "example-db")]`, add one `ModelExampleSpec` using the issue’s YES instance: -- 6 vertices -- arcs `(0,1),(0,2),(1,3),(1,4),(2,4),(2,5),(3,5),(4,5)` -- vertex weights `[2,3,2,1,3,1]` -- arc costs `[1; 8]` -- bounds `B=5`, `K=5` -- canonical config `[0,1,0,2,2,2]` -- optimal value `true` - -**Step 4: Run a focused example-db-related test** - -Run: `cargo test example_db --lib` -Expected: PASS for the touched example-db checks. - -**Step 5: Commit** - -```bash -git add src/models/graph/mod.rs src/models/mod.rs src/lib.rs src/models/graph/acyclic_partition.rs -git commit -m "feat: register AcyclicPartition model" -``` - -### Task 4: Add CLI alias and `pred create` support - -**Files:** -- Modify: `problemreductions-cli/src/problem_name.rs` -- Modify: `problemreductions-cli/src/commands/create.rs` -- Modify: `problemreductions-cli/tests/cli_tests.rs` - -**Step 1: Write failing CLI tests** - -Add tests for: -- `pred create AcyclicPartition/i32 --arcs ... --vertex-weights ... --arc-weights ... --weight-bound 5 --cost-bound 5` -- `pred create --example AcyclicPartition/i32` -- `pred inspect AcyclicPartition/i32 --json` reporting `num_vertices` and `num_arcs` - -**Step 2: Run the focused CLI tests to verify RED** - -Run: `cargo test -p problemreductions-cli acyclic_partition` -Expected: FAIL because the alias/create branch does not exist yet. - -**Step 3: Implement CLI support** - -Update `problem_name.rs`: -- resolve lowercase `"acyclicpartition"` to `"AcyclicPartition"` - -Update `create.rs`: -- import `AcyclicPartition` -- add help/example strings for `DirectedGraph`/weight-bound/cost-bound usage -- parse `--arcs` -- parse `--vertex-weights` against `graph.num_vertices()` -- parse `--arc-weights` against `graph.num_arcs()` -- require `--weight-bound` -- require `--cost-bound` -- construct `AcyclicPartition::new(...)` - -Reuse existing directed-graph parsing helpers instead of adding new parsers. - -**Step 4: Re-run the focused CLI tests to verify GREEN** - -Run: `cargo test -p problemreductions-cli acyclic_partition` -Expected: PASS - -**Step 5: Commit** - -```bash -git add problemreductions-cli/src/problem_name.rs problemreductions-cli/src/commands/create.rs problemreductions-cli/tests/cli_tests.rs -git commit -m "feat: add CLI support for AcyclicPartition" -``` - -### Task 5: Batch-1 verification - -**Files:** -- No code changes expected - -**Step 1: Run targeted verification** - -Run: -- `cargo test acyclic_partition --lib` -- `cargo test -p problemreductions-cli acyclic_partition` - -Expected: PASS - -**Step 2: Run broader verification** - -Run: -- `make test` -- `make clippy` - -Expected: PASS - -**Step 3: Commit only if verification required a fix** - -```bash -git add -A -git commit -m "test: fix AcyclicPartition verification issues" -``` - -### Task 6: Add the paper entry - -**Files:** -- Modify: `docs/paper/reductions.typ` - -**Step 1: Write the failing paper-oriented test/check expectation** - -Use the existing canonical example as the source of truth for the paper text: -- display name `Acyclic Partition` -- formal definition using `DirectedGraph`, vertex weights, arc costs, `B`, `K` -- background referencing Garey & Johnson ND15 and DAG partitioning applications -- worked example matching the canonical config `[0,1,0,2,2,2]` - -**Step 2: Implement the paper entry** - -Add: -- display-name dictionary entry -- `#problem-def("AcyclicPartition")[...]` block - -Conventions to follow: -- load the model example with `load-model-example("AcyclicPartition")` -- explain the quotient graph explicitly -- include a directed figure with highlighted partition groups or highlighted quotient-order intuition -- keep the example text consistent with the unit tests and canonical example data - -**Step 3: Run paper build to verify GREEN** - -Run: `make paper` -Expected: PASS - -**Step 4: Commit** - -```bash -git add docs/paper/reductions.typ -git commit -m "docs: add AcyclicPartition paper entry" -``` - -### Task 7: Final verification and pipeline handoff - -**Files:** -- No code changes expected - -**Step 1: Run final repo checks** - -Run: -- `make test` -- `make clippy` -- `make paper` - -Expected: PASS - -**Step 2: Inspect git status** - -Run: `git status --short` -Expected: clean except for ignored/generated artifacts - -**Step 3: Prepare implementation summary notes for the PR comment** - -Capture: -- model file(s) added/modified -- CLI support added -- tests added -- paper entry added -- any deviations from the issue comments (notably that `DirectedGraph` already existed) From 862156d89208075e679d83fb81a5794a5c3c88b8 Mon Sep 17 00:00:00 2001 From: Xiwei Pan Date: Sat, 21 Mar 2026 18:36:17 +0800 Subject: [PATCH 7/7] Fix AcyclicPartition CLI validation: require positive values (Z+) The G&J definition specifies weights, costs, and bounds in Z+ (positive integers). Updated CLI validation from `< 0` (non-negative) to `<= 0` (positive) to match the mathematical definition. Co-Authored-By: Claude Opus 4.6 (1M context) --- problemreductions-cli/src/commands/create.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 3dddbf752..5947fb94b 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -3024,17 +3024,17 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { let cost_bound = args.cost_bound.ok_or_else(|| { anyhow::anyhow!("AcyclicPartition requires --cost-bound\n\n{usage}") })?; - if vertex_weights.iter().any(|&weight| weight < 0) { - bail!("AcyclicPartition --weights must be non-negative"); + if vertex_weights.iter().any(|&weight| weight <= 0) { + bail!("AcyclicPartition --weights must be positive (Z+)"); } - if arc_costs.iter().any(|&cost| cost < 0) { - bail!("AcyclicPartition --arc-costs must be non-negative"); + if arc_costs.iter().any(|&cost| cost <= 0) { + bail!("AcyclicPartition --arc-costs must be positive (Z+)"); } - if weight_bound < 0 { - bail!("AcyclicPartition --weight-bound must be non-negative"); + if weight_bound <= 0 { + bail!("AcyclicPartition --weight-bound must be positive (Z+)"); } - if cost_bound < 0 { - bail!("AcyclicPartition --cost-bound must be non-negative"); + if cost_bound <= 0 { + bail!("AcyclicPartition --cost-bound must be positive (Z+)"); } ( ser(AcyclicPartition::new(