From f51dddc5adb9da3b0e2fa8c2c4a7a770e807c900 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 21 Mar 2026 19:49:36 +0800 Subject: [PATCH 1/4] Add plan for #242: [Model] MixedChinesePostman --- .../plans/2026-03-21-mixed-chinese-postman.md | 238 ++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 docs/plans/2026-03-21-mixed-chinese-postman.md diff --git a/docs/plans/2026-03-21-mixed-chinese-postman.md b/docs/plans/2026-03-21-mixed-chinese-postman.md new file mode 100644 index 000000000..408fbde71 --- /dev/null +++ b/docs/plans/2026-03-21-mixed-chinese-postman.md @@ -0,0 +1,238 @@ +# MixedChinesePostman Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add the `MixedChinesePostman` model, including a new `MixedGraph` topology, exact orientation-based evaluation, CLI/example-db integration, and paper documentation for issue #242. + +**Architecture:** Represent a mixed graph as directed arcs plus undirected edges in a reusable topology type. Model `MixedChinesePostman` as a satisfaction problem with one binary variable per undirected edge, orient each edge according to the config, reject orientations that do not yield a strongly connected directed graph, then compute the minimum extra traversal cost needed to balance the directed graph by combining all-pairs shortest paths with a minimum-cost assignment over degree imbalances. + +**Tech Stack:** Rust, petgraph, serde, inventory, existing `BruteForce` solver, existing CLI create/example pipeline, Typst paper exports + +--- + +## Skill Alignment + +- **Primary repo skill:** `.claude/skills/add-model/SKILL.md` +- **Execution mode:** `superpowers:subagent-driven-development` +- **Testing discipline:** `superpowers:test-driven-development` + +## Issue Packet Summary + +- **Issue:** #242 — `[Model] MixedChinesePostman` +- **Kind:** model +- **Labels:** `model`, `Good` +- **Associated rule:** #260 — `[Rule] 3-SATISFIABILITY to CHINESE POSTMAN FOR MIXED GRAPHS` +- **Approved design corrections from comments:** + - Rename to `MixedChinesePostman` + - Use `|E|` binary orientation variables for undirected edges + - Add a reusable `MixedGraph` topology + - Expose size getters `num_vertices`, `num_arcs`, `num_edges`, `bound` + - Use complexity metadata `2^num_edges * num_vertices^3` + - Use the fixed YES/NO examples from the issue comments as the source of truth + +## Information Checklist + +| # | Item | Value | +|---|------|-------| +| 1 | Problem name | `MixedChinesePostman` | +| 2 | Mathematical definition | Given a mixed graph `G = (V, A, E)` with nonnegative lengths on arcs and undirected edges and a bound `B`, determine whether there exists a closed walk of total length at most `B` that traverses every directed arc in its given direction and every undirected edge in at least one direction | +| 3 | Problem type | Satisfaction (`Metric = bool`) | +| 4 | Type parameters | Weight parameter `W` only; topology is a concrete `MixedGraph` | +| 5 | Struct fields | `graph: MixedGraph`, `arc_weights: Vec`, `edge_weights: Vec`, `bound: W::Sum` | +| 6 | Configuration space | `vec![2; num_edges]` — one binary orientation choice per undirected edge | +| 7 | Feasibility check | Orient each undirected edge, require the resulting digraph to be strongly connected, compute the cheapest added traversal cost needed to balance in/out degrees, and accept iff base cost + added cost `<= bound` | +| 8 | Objective function | `bool` | +| 9 | Best known exact algorithm | Brute-force over all edge orientations with polynomial subproblem solve; metadata string: `"2^num_edges * num_vertices^3"` | +| 10 | Solving strategy | `BruteForce` over orientations; `evaluate()` solves the oriented directed-postman subproblem exactly via shortest-path costs plus minimum-cost assignment over imbalances | +| 11 | Category | `graph` | +| 12 | Expected outcome | Use the corrected YES/NO examples from issue #242 comments, including the nontrivial YES example requiring duplicated traversals | + +## Batch Structure + +- **Batch 1:** Steps 1-5.5 from `add-model` — topology, model, registration, CLI, example-db, tests +- **Batch 2:** Step 6 from `add-model` — paper entry and paper/example alignment + +## Batch 1 + +### Task 1: Add failing topology tests for `MixedGraph` + +**Files:** +- Create: `src/unit_tests/topology/mixed_graph.rs` +- Modify: `src/topology/mod.rs` + +**Steps:** +1. Write tests that define the expected `MixedGraph` surface: + - constructor preserves `num_vertices`, arcs, and undirected edges + - accessors report `num_arcs`, `num_edges`, `arcs()`, `edges()` + - adjacency helpers and degree helpers behave correctly for both arc and edge views + - serde round-trip preserves the mixed structure +2. Run the focused tests and verify they fail because `MixedGraph` does not exist yet: + - `cargo test mixed_graph --lib` + +### Task 2: Implement `MixedGraph` and register it as a topology + +**Files:** +- Create: `src/topology/mixed_graph.rs` +- Modify: `src/topology/mod.rs` +- Modify: `src/lib.rs` + +**Steps:** +1. Implement `MixedGraph` with: + - `num_vertices` + - directed `arcs: Vec<(usize, usize)>` + - undirected `edges: Vec<(usize, usize)>` + - constructor validation for vertex bounds + - accessors for counts and edge lists + - helpers needed by the model (`num_vertices`, `num_arcs`, `num_edges`) +2. Add serde support and `impl_variant_param!(MixedGraph, "graph")`. +3. Export the new topology from `src/topology/mod.rs` and the public prelude in `src/lib.rs`. +4. Re-run the focused topology tests until green: + - `cargo test mixed_graph --lib` + +### Task 3: Add failing model tests for `MixedChinesePostman` + +**Files:** +- Create: `src/unit_tests/models/graph/mixed_chinese_postman.rs` +- Reference: `src/unit_tests/models/graph/directed_two_commodity_integral_flow.rs` +- Reference: `src/unit_tests/models/graph/strong_connectivity_augmentation.rs` + +**Steps:** +1. Write tests for: + - construction, dimensions, and accessors + - corrected YES issue example evaluates to `true` + - corrected NO issue example evaluates to `false` + - a disconnected or not-strongly-connected orientation evaluates to `false` + - serialization round-trip + - `BruteForce::find_satisfying()` finds a witness for the YES instance + - paper/example invariant test placeholder using the issue example +2. Run the focused model tests and verify they fail because the model is missing: + - `cargo test mixed_chinese_postman --lib` + +### Task 4: Implement `MixedChinesePostman` with exact oriented evaluation + +**Files:** +- Create: `src/models/graph/mixed_chinese_postman.rs` +- Modify: `src/models/graph/mod.rs` +- Modify: `src/models/mod.rs` +- Modify: `src/lib.rs` + +**Steps:** +1. Add `ProblemSchemaEntry` metadata with display name, aliases (if any), fields, and dimensions. +2. Implement the struct and constructor validation: + - `arc_weights.len() == graph.num_arcs()` + - `edge_weights.len() == graph.num_edges()` +3. Add size/accessor methods: + - `num_vertices()` + - `num_arcs()` + - `num_edges()` + - `bound()` + - `graph()`, `arc_weights()`, `edge_weights()` +4. Implement `Problem` / `SatisfactionProblem`: + - `NAME = "MixedChinesePostman"` + - `dims() = vec![2; num_edges]` + - `variant() = crate::variant_params![W]` +5. Implement `evaluate()` in small, testable helpers: + - orient each undirected edge according to the config + - compute the oriented base cost + - reject if the resulting directed graph is not strongly connected + - compute all-pairs shortest path distances on the weighted oriented digraph + - derive vertex imbalances `out_degree - in_degree` + - solve the balancing transportation problem exactly with minimum-cost assignment over expanded imbalance copies + - reject if any required shortest path is unreachable + - accept iff `base_cost + balancing_cost <= bound` +6. Register concrete variants with `declare_variants!`: + - `MixedChinesePostman` + - default `MixedChinesePostman` if unit weights are supported cleanly by the implementation + - use complexity metadata string `"2^num_edges * num_vertices^3"` +7. Link the new unit test file and export the model from the graph modules and prelude. +8. Re-run the focused model tests until green: + - `cargo test mixed_chinese_postman --lib` + +### Task 5: Add canonical example-db coverage + +**Files:** +- Modify: `src/models/graph/mixed_chinese_postman.rs` +- Modify: `src/models/graph/mod.rs` +- Modify: `src/example_db/model_builders.rs` + +**Steps:** +1. Add `canonical_model_example_specs()` in the model file. +2. Use the corrected YES example from issue #242 comments as the canonical example. +3. Register the spec in the graph example chain so `pred create --example MixedChinesePostman/...` works. +4. Add or update any example-db assertions needed by existing tests. +5. Run focused example-db coverage if available: + - `cargo test example_db --lib --features example-db` + +### Task 6: Add CLI discovery and create support + +**Files:** +- Modify: `problemreductions-cli/src/problem_name.rs` +- Modify: `problemreductions-cli/src/commands/create.rs` +- Modify: `problemreductions-cli/src/cli.rs` +- Modify: `problemreductions-cli/tests/cli_tests.rs` + +**Steps:** +1. Add alias resolution for `MixedChinesePostman`. +2. Wire `pred create MixedChinesePostman` using: + - `--arcs` for directed arcs + - `--graph` for undirected edges + - `--arc-costs` for arc lengths + - `--weights` or `--edge-weights` for undirected edge lengths, depending on the existing CLI convention that best fits the repo + - `--bound` + - optional `--num-vertices` +3. Update help text and `all_data_flags_empty()` if a new flag is required for edge weights. +4. Add CLI tests for: + - successful creation + - missing required flags + - weight-count mismatch errors + - `pred show` / alias resolution if needed +5. Run the focused CLI tests and verify the new path: + - `cargo test -p problemreductions-cli mixed_chinese_postman` + +### Task 7: Run integration verification for Batch 1 + +**Files:** +- No code changes expected + +**Steps:** +1. Run the targeted Rust checks: + - `cargo test mixed_graph --lib` + - `cargo test mixed_chinese_postman --lib` + - `cargo test -p problemreductions-cli mixed_chinese_postman` +2. Run broader checks once the targeted suite is green: + - `make test` + - `make clippy` +3. If any failures expose missing registration or schema issues, fix them before moving to Batch 2. + +## Batch 2 + +### Task 8: Add paper entry for `MixedChinesePostman` + +**Files:** +- Modify: `docs/paper/reductions.typ` + +**Steps:** +1. Add the display-name dictionary entry for `MixedChinesePostman`. +2. Add a `problem-def("MixedChinesePostman")` entry with: + - formal mixed-graph definition + - background and references + - best-known algorithm prose with citations + - a worked example aligned with the canonical YES instance + - `pred-commands()` wired to the canonical example data +3. Keep the paper example consistent with the model test and example-db fixture. +4. Run: + - `make paper` + +### Task 9: Final verification and implementation handoff + +**Files:** +- No code changes expected + +**Steps:** +1. Re-run the full verification commands fresh: + - `make test` + - `make clippy` + - `make fmt-check` + - `make paper` +2. Inspect `git status --short` and confirm only intentional tracked changes remain. +3. Summarize any deviations from this plan before the implementation summary comment is posted on the PR. From 86572e91658ec28e2b0abc909f889e9ed6c1a22f Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 21 Mar 2026 20:17:35 +0800 Subject: [PATCH 2/4] Implement #242: [Model] MixedChinesePostman --- docs/paper/reductions.typ | 92 ++++ docs/paper/references.bib | 19 + problemreductions-cli/src/cli.rs | 1 + problemreductions-cli/src/commands/create.rs | 98 +++- problemreductions-cli/tests/cli_tests.rs | 125 ++++- src/lib.rs | 9 +- src/models/graph/acyclic_partition.rs | 5 +- src/models/graph/mixed_chinese_postman.rs | 452 ++++++++++++++++++ src/models/graph/mod.rs | 6 +- src/models/mod.rs | 8 +- src/topology/mixed_graph.rs | 165 +++++++ src/topology/mod.rs | 3 + .../models/graph/acyclic_partition.rs | 10 +- .../models/graph/mixed_chinese_postman.rs | 122 +++++ src/unit_tests/topology/mixed_graph.rs | 58 +++ 15 files changed, 1141 insertions(+), 32 deletions(-) create mode 100644 src/models/graph/mixed_chinese_postman.rs create mode 100644 src/topology/mixed_graph.rs create mode 100644 src/unit_tests/models/graph/mixed_chinese_postman.rs create mode 100644 src/unit_tests/topology/mixed_graph.rs diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 34d0f83b3..262b62a86 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -117,6 +117,7 @@ "MinimumMultiwayCut": [Minimum Multiway Cut], "OptimalLinearArrangement": [Optimal Linear Arrangement], "RuralPostman": [Rural Postman], + "MixedChinesePostman": [Mixed Chinese Postman], "LongestCommonSubsequence": [Longest Common Subsequence], "ExactCoverBy3Sets": [Exact Cover by 3-Sets], "SubsetSum": [Subset Sum], @@ -3570,6 +3571,97 @@ A classical NP-complete problem from Garey and Johnson @garey1979[Ch.~3, p.~76], ] } +#{ + let x = load-model-example("MixedChinesePostman", variant: (weight: "i32")) + let nv = x.instance.graph.num_vertices + let arcs = x.instance.graph.arcs + let edges = x.instance.graph.edges + let arc-weights = x.instance.arc_weights + let edge-weights = x.instance.edge_weights + let B = x.instance.bound + let config = x.optimal_config + let oriented = edges.enumerate().map(((i, e)) => if config.at(i) == 0 { e } else { (e.at(1), e.at(0)) }) + let base-cost = arc-weights.sum() + edge-weights.sum() + let total-cost = 22 + [ + #problem-def("MixedChinesePostman")[ + Given a mixed graph $G = (V, A, E)$ with directed arcs $A$, undirected edges $E$, integer lengths $l(e) >= 0$ for every $e in A union E$, and a bound $B in ZZ^+$, determine whether there exists a closed walk in $G$ that traverses every arc in its prescribed direction and every undirected edge at least once in some direction with total length at most $B$. + ][ + Mixed Chinese Postman is the mixed-graph arc-routing problem ND25 in Garey and Johnson @garey1979. Papadimitriou proved the mixed case NP-complete even when all lengths are 1, the graph is planar, and the maximum degree is 3 @papadimitriou1976edge. In contrast, the pure undirected and pure directed cases are polynomial-time solvable via matching / circulation machinery @edmondsjohnson1973. The implementation here uses one binary variable per undirected edge orientation, so the search space contributes the $2^|E|$ factor visible in the registered exact bound. + + *Example.* Consider the instance on #nv vertices with directed arcs $(v_0, v_1)$, $(v_1, v_2)$, $(v_2, v_3)$, $(v_3, v_0)$ of lengths $2, 3, 1, 4$ and undirected edges $\{v_0, v_2\}$, $\{v_1, v_3\}$, $\{v_0, v_4\}$, $\{v_4, v_2\}$ of lengths $2, 3, 1, 2$. The config $(1, 1, 0, 0)$ orients those edges as $(v_2, v_0)$, $(v_3, v_1)$, $(v_0, v_4)$, and $(v_4, v_2)$, producing a strongly connected digraph. The base traversal cost is #base-cost, and duplicating the shortest path $v_1 arrow v_2 arrow v_3$ adds 4 more, so the total cost is $#total-cost <= B = #B$, proving the answer is YES. + + #pred-commands( + "pred create --example MixedChinesePostman/i32 -o mixed-chinese-postman.json", + "pred solve mixed-chinese-postman.json", + "pred evaluate mixed-chinese-postman.json --config " + x.optimal_config.map(str).join(","), + ) + + #figure( + canvas(length: 1cm, { + import draw: * + let positions = ( + (-1.25, 0.85), + (1.25, 0.85), + (1.25, -0.85), + (-1.25, -0.85), + (0.25, 0.0), + ) + + for (idx, (u, v)) in arcs.enumerate() { + line( + positions.at(u), + positions.at(v), + stroke: 0.8pt + luma(80), + mark: (end: "straight", scale: 0.45), + ) + let mid = ( + (positions.at(u).at(0) + positions.at(v).at(0)) / 2, + (positions.at(u).at(1) + positions.at(v).at(1)) / 2, + ) + content( + mid, + text(6pt, fill: luma(40))[#arc-weights.at(idx)], + fill: white, + frame: "rect", + padding: 0.04, + stroke: none, + ) + } + + for (idx, (u, v)) in oriented.enumerate() { + line( + positions.at(u), + positions.at(v), + stroke: 1.3pt + graph-colors.at(0), + mark: (end: "straight", scale: 0.5), + ) + let mid = ( + (positions.at(u).at(0) + positions.at(v).at(0)) / 2, + (positions.at(u).at(1) + positions.at(v).at(1)) / 2, + ) + let offset = if idx == 0 { (-0.18, 0.12) } else if idx == 1 { (0.18, 0.12) } else if idx == 2 { (-0.12, -0.1) } else { (0.12, -0.1) } + content( + (mid.at(0) + offset.at(0), mid.at(1) + offset.at(1)), + text(6pt, fill: graph-colors.at(0))[#edge-weights.at(idx)], + fill: white, + frame: "rect", + padding: 0.04, + stroke: none, + ) + } + + for (i, pos) in positions.enumerate() { + circle(pos, radius: 0.18, fill: white, stroke: 0.6pt + black) + content(pos, text(7pt)[$v_#i$]) + } + }), + caption: [Mixed Chinese Postman example. Gray arrows are the original directed arcs, while blue arrows are the chosen orientations of the former undirected edges under config $(1, 1, 0, 0)$. Duplicating the path $v_1 arrow v_2 arrow v_3$ yields total cost #total-cost.], + ) + ] + ] +} + #{ let x = load-model-example("SubgraphIsomorphism") let nv-host = x.instance.host_graph.num_vertices diff --git a/docs/paper/references.bib b/docs/paper/references.bib index fd8106120..6681affce 100644 --- a/docs/paper/references.bib +++ b/docs/paper/references.bib @@ -1282,3 +1282,22 @@ @inproceedings{williams2002 pages = {299--307}, year = {2002} } + +@article{papadimitriou1976edge, + author = {Christos H. Papadimitriou}, + title = {On the Complexity of Edge Traversing}, + journal = {Journal of the ACM}, + volume = {23}, + number = {3}, + pages = {544--554}, + year = {1976} +} + +@article{edmondsjohnson1973, + author = {Jack Edmonds and Ellis L. Johnson}, + title = {Matching, Euler Tours and the Chinese Postman}, + journal = {Mathematical Programming}, + volume = {5}, + pages = {88--124}, + year = {1973} +} diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 5a26d50c5..9643be372 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -265,6 +265,7 @@ Flags by problem type: SequencingWithinIntervals --release-times, --deadlines, --lengths OptimalLinearArrangement --graph, --bound MinMaxMulticenter (pCenter) --graph, --weights, --edge-weights, --k, --bound + MixedChinesePostman (MCPP) --graph, --arcs, --edge-weights, --arc-costs, --bound [--num-vertices] RuralPostman (RPP) --graph, --edge-weights, --required-edges, --bound MultipleChoiceBranching --arcs [--weights] --partition --bound [--num-vertices] AdditionalKey --num-attributes, --dependencies, --relation-attrs [--known-keys] diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 73e3f6b52..65bb31d50 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -13,7 +13,7 @@ use problemreductions::models::algebraic::{ use problemreductions::models::formula::Quantifier; use problemreductions::models::graph::{ GeneralizedHex, GraphPartitioning, HamiltonianCircuit, HamiltonianPath, - LengthBoundedDisjointPaths, MinimumCutIntoBoundedSets, MinimumMultiwayCut, + LengthBoundedDisjointPaths, MinimumCutIntoBoundedSets, MinimumMultiwayCut, MixedChinesePostman, MultipleChoiceBranching, SteinerTree, SteinerTreeInGraphs, StrongConnectivityAugmentation, }; use problemreductions::models::misc::{ @@ -31,8 +31,8 @@ use problemreductions::models::BiconnectivityAugmentation; use problemreductions::prelude::*; use problemreductions::registry::collect_schemas; use problemreductions::topology::{ - BipartiteGraph, DirectedGraph, Graph, KingsSubgraph, SimpleGraph, TriangularSubgraph, - UnitDiskGraph, + BipartiteGraph, DirectedGraph, Graph, KingsSubgraph, MixedGraph, SimpleGraph, + TriangularSubgraph, UnitDiskGraph, }; use serde::Serialize; use std::collections::{BTreeMap, BTreeSet}; @@ -584,6 +584,9 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "StrongConnectivityAugmentation" => { "--arcs \"0>1,1>2\" --candidate-arcs \"2>0:1\" --bound 1" } + "MixedChinesePostman" => { + "--graph 0-2,1-3,0-4,4-2 --arcs \"0>1,1>2,2>3,3>0\" --edge-weights 2,3,1,2 --arc-costs 2,3,1,4 --bound 24" + } "RuralPostman" => { "--graph 0-1,1-2,2-3,3-0 --edge-weights 1,1,1,1 --required-edges 0,2 --bound 4" } @@ -643,7 +646,12 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { fn uses_edge_weights_flag(canonical: &str) -> bool { matches!( canonical, - "KthBestSpanningTree" | "MaxCut" | "MaximumMatching" | "TravelingSalesman" | "RuralPostman" + "KthBestSpanningTree" + | "MaxCut" + | "MaximumMatching" + | "TravelingSalesman" + | "RuralPostman" + | "MixedChinesePostman" ) } @@ -661,6 +669,7 @@ fn help_flag_name(canonical: &str, field_name: &str) -> String { ("PrimeAttributeName", "num_attributes") => return "universe".to_string(), ("PrimeAttributeName", "dependencies") => return "deps".to_string(), ("PrimeAttributeName", "query_attribute") => return "query".to_string(), + ("MixedChinesePostman", "arc_weights") => return "arc-costs".to_string(), ("ConsecutiveOnesSubmatrix", "bound") => return "bound".to_string(), ("StaffScheduling", "shifts_per_schedule") => return "k".to_string(), ("TimetableDesign", "num_tasks") => return "num-tasks".to_string(), @@ -827,6 +836,15 @@ fn print_problem_help(canonical: &str, graph_type: Option<&str>) -> Result<()> { // DirectedGraph fields use --arcs, not --graph let hint = type_format_hint(&field.type_name, graph_type); eprintln!(" --{:<16} {} ({})", "arcs", field.description, hint); + } else if field.type_name == "MixedGraph" { + eprintln!( + " --{:<16} {} ({})", + "graph", "Undirected edges E of the mixed graph", "edge list: 0-1,1-2,2-3" + ); + eprintln!( + " --{:<16} {} ({})", + "arcs", "Directed arcs A of the mixed graph", "directed arcs: 0>1,1>2,2>0" + ); } else if field.type_name == "BipartiteGraph" { eprintln!( " --{:<16} {} ({})", @@ -876,6 +894,9 @@ fn problem_help_flag_name( if field_type == "DirectedGraph" { return "arcs".to_string(); } + if field_type == "MixedGraph" { + return "graph".to_string(); + } if canonical == "LengthBoundedDisjointPaths" && field_name == "max_length" { return "bound".to_string(); } @@ -3003,9 +3024,10 @@ 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 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)?; @@ -3109,6 +3131,46 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } + // MixedChinesePostman + "MixedChinesePostman" => { + let usage = "Usage: pred create MixedChinesePostman --graph 0-2,1-3,0-4,4-2 --arcs \"0>1,1>2,2>3,3>0\" --edge-weights 2,3,1,2 --arc-costs 2,3,1,4 --bound 24 [--num-vertices N]"; + let graph = parse_mixed_graph(args, usage)?; + let arc_costs = parse_arc_costs(args, graph.num_arcs())?; + let edge_weights = parse_edge_weights(args, graph.num_edges())?; + let bound = args.bound.ok_or_else(|| { + anyhow::anyhow!("MixedChinesePostman requires --bound\n\n{usage}") + })?; + let bound = i32::try_from(bound).map_err(|_| { + anyhow::anyhow!( + "MixedChinesePostman --bound must fit in i32 (got {bound})\n\n{usage}" + ) + })?; + if arc_costs.iter().any(|&cost| cost < 0) { + bail!("MixedChinesePostman --arc-costs must be non-negative\n\n{usage}"); + } + if edge_weights.iter().any(|&weight| weight < 0) { + bail!("MixedChinesePostman --edge-weights must be non-negative\n\n{usage}"); + } + if resolved_variant.get("weight").map(|w| w.as_str()) == Some("One") + && (arc_costs.iter().any(|&cost| cost != 1) + || edge_weights.iter().any(|&weight| weight != 1)) + { + bail!( + "Non-unit lengths are not supported for MixedChinesePostman/One.\n\n\ + Use the weighted variant instead:\n pred create MixedChinesePostman/i32 --graph ... --arcs ... --edge-weights ... --arc-costs ..." + ); + } + ( + ser(MixedChinesePostman::new( + graph, + arc_costs, + edge_weights, + bound, + ))?, + resolved_variant.clone(), + ) + } + // MinimumSumMulticenter (p-median) "MinimumSumMulticenter" => { let (graph, n) = parse_graph(args).map_err(|e| { @@ -4759,6 +4821,22 @@ fn parse_directed_graph( Ok((DirectedGraph::new(num_v, arcs), num_arcs)) } +fn parse_mixed_graph(args: &CreateArgs, usage: &str) -> Result { + let (undirected_graph, num_vertices) = + parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + let arcs_str = args + .arcs + .as_deref() + .ok_or_else(|| anyhow::anyhow!("MixedChinesePostman requires --arcs\n\n{usage}"))?; + let (directed_graph, _) = parse_directed_graph(arcs_str, Some(num_vertices)) + .map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?; + Ok(MixedGraph::new( + num_vertices, + directed_graph.arcs(), + undirected_graph.edges(), + )) +} + /// Parse `--weights` as arc weights (i32), defaulting to all 1s. fn parse_arc_weights(args: &CreateArgs, num_arcs: usize) -> Result> { match &args.weights { @@ -4789,11 +4867,7 @@ fn parse_arc_costs(args: &CreateArgs, num_arcs: usize) -> Result> { .map(|s| s.trim().parse::()) .collect::, _>>()?; if parsed.len() != num_arcs { - bail!( - "Expected {} arc costs but got {}", - num_arcs, - parsed.len() - ); + bail!("Expected {} arc costs but got {}", num_arcs, parsed.len()); } Ok(parsed) } diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index a5e4abe85..b904cf85e 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -1980,8 +1980,14 @@ fn test_create_acyclic_partition() { 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"]["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); } @@ -2045,6 +2051,121 @@ fn test_create_model_example_acyclic_partition_round_trips_into_solve() { std::fs::remove_file(&path).ok(); } +#[test] +fn test_create_mixed_chinese_postman() { + let output = pred() + .args([ + "create", + "MixedChinesePostman", + "--graph", + "0-2,1-3,0-4,4-2", + "--arcs", + "0>1,1>2,2>3,3>0", + "--edge-weights", + "2,3,1,2", + "--arc-costs", + "2,3,1,4", + "--bound", + "24", + ]) + .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"], "MixedChinesePostman"); + assert_eq!(json["variant"]["weight"], "i32"); + assert_eq!(json["data"]["graph"]["num_vertices"], 5); + assert_eq!(json["data"]["arc_weights"], serde_json::json!([2, 3, 1, 4])); + assert_eq!( + json["data"]["edge_weights"], + serde_json::json!([2, 3, 1, 2]) + ); + assert_eq!(json["data"]["bound"], 24); +} + +#[test] +fn test_create_model_example_mixed_chinese_postman() { + let output = pred() + .args(["create", "--example", "MixedChinesePostman/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"], "MixedChinesePostman"); + assert_eq!(json["variant"]["weight"], "i32"); + assert_eq!(json["data"]["bound"], 24); +} + +#[test] +fn test_create_mixed_chinese_postman_missing_arcs_shows_usage() { + let output = pred() + .args([ + "create", + "MixedChinesePostman", + "--graph", + "0-2,1-3,0-4,4-2", + "--edge-weights", + "2,3,1,2", + "--arc-costs", + "2,3,1,4", + "--bound", + "24", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("MixedChinesePostman requires --arcs"), + "expected missing --arcs error, got: {stderr}" + ); + assert!( + stderr.contains("Usage: pred create MixedChinesePostman"), + "expected recovery usage hint, got: {stderr}" + ); +} + +#[test] +fn test_create_mixed_chinese_postman_rejects_edge_weight_length_mismatch() { + let output = pred() + .args([ + "create", + "MixedChinesePostman", + "--graph", + "0-2,1-3,0-4,4-2", + "--arcs", + "0>1,1>2,2>3,3>0", + "--edge-weights", + "2,3", + "--arc-costs", + "2,3,1,4", + "--bound", + "24", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("Expected 4 edge weight"), + "expected edge-weight mismatch diagnostic, got: {stderr}" + ); +} + #[test] fn test_create_multiple_choice_branching_rejects_negative_bound() { let output = pred() diff --git a/src/lib.rs b/src/lib.rs index 9c8bd8664..c2bb7220d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -48,10 +48,11 @@ pub mod prelude { Satisfiability, }; pub use crate::models::graph::{ - AcyclicPartition, BalancedCompleteBipartiteSubgraph, BicliqueCover, BiconnectivityAugmentation, - BoundedComponentSpanningForest, DirectedTwoCommodityIntegralFlow, GeneralizedHex, - GraphPartitioning, HamiltonianCircuit, HamiltonianPath, IsomorphicSpanningTree, KClique, - KthBestSpanningTree, LengthBoundedDisjointPaths, SpinGlass, SteinerTree, + AcyclicPartition, BalancedCompleteBipartiteSubgraph, BicliqueCover, + BiconnectivityAugmentation, BoundedComponentSpanningForest, + DirectedTwoCommodityIntegralFlow, GeneralizedHex, GraphPartitioning, HamiltonianCircuit, + HamiltonianPath, IsomorphicSpanningTree, KClique, KthBestSpanningTree, + LengthBoundedDisjointPaths, MixedChinesePostman, SpinGlass, SteinerTree, StrongConnectivityAugmentation, SubgraphIsomorphism, }; pub use crate::models::graph::{ diff --git a/src/models/graph/acyclic_partition.rs b/src/models/graph/acyclic_partition.rs index 6660d76a7..c81725854 100644 --- a/src/models/graph/acyclic_partition.rs +++ b/src/models/graph/acyclic_partition.rs @@ -178,7 +178,10 @@ where } } -impl SatisfactionProblem for AcyclicPartition where W: WeightElement + crate::variant::VariantParam {} +impl SatisfactionProblem for AcyclicPartition where + W: WeightElement + crate::variant::VariantParam +{ +} fn is_valid_acyclic_partition( graph: &DirectedGraph, diff --git a/src/models/graph/mixed_chinese_postman.rs b/src/models/graph/mixed_chinese_postman.rs new file mode 100644 index 000000000..9b5598de0 --- /dev/null +++ b/src/models/graph/mixed_chinese_postman.rs @@ -0,0 +1,452 @@ +//! Mixed Chinese Postman problem implementation. +//! +//! Given a mixed graph with directed arcs and undirected edges, determine +//! whether there exists a closed walk of bounded total length that traverses +//! every directed arc in its prescribed direction and every undirected edge in +//! at least one direction. + +use crate::registry::{FieldInfo, ProblemSchemaEntry, VariantDimension}; +use crate::topology::{DirectedGraph, MixedGraph}; +use crate::traits::{Problem, SatisfactionProblem}; +use crate::types::{One, WeightElement}; +use num_traits::Zero; +use serde::{Deserialize, Serialize}; +use std::cmp::Ordering; + +const INF_COST: i64 = i64::MAX / 4; + +inventory::submit! { + ProblemSchemaEntry { + name: "MixedChinesePostman", + display_name: "Mixed Chinese Postman", + aliases: &["MCPP"], + dimensions: &[ + VariantDimension::new("weight", "i32", &["i32", "One"]), + ], + module_path: module_path!(), + description: "Determine whether a mixed graph has a bounded closed walk covering all arcs and edges", + fields: &[ + FieldInfo { name: "graph", type_name: "MixedGraph", description: "The mixed graph G=(V,A,E)" }, + FieldInfo { name: "arc_weights", type_name: "Vec", description: "Lengths for the directed arcs in A" }, + FieldInfo { name: "edge_weights", type_name: "Vec", description: "Lengths for the undirected edges in E" }, + FieldInfo { name: "bound", type_name: "W::Sum", description: "Upper bound B on the total walk length" }, + ], + } +} + +/// Mixed Chinese Postman. +/// +/// Each configuration picks an orientation for every undirected edge. Once the +/// undirected edges are oriented, the instance becomes a directed Chinese +/// Postman subproblem. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MixedChinesePostman> { + graph: MixedGraph, + arc_weights: Vec, + edge_weights: Vec, + bound: W::Sum, +} + +impl> MixedChinesePostman { + /// Create a new mixed Chinese postman instance. + /// + /// # Panics + /// + /// Panics if the weight-vector lengths do not match the graph shape or if + /// any weight or the bound is negative. + pub fn new( + graph: MixedGraph, + arc_weights: Vec, + edge_weights: Vec, + bound: W::Sum, + ) -> Self { + assert_eq!( + arc_weights.len(), + graph.num_arcs(), + "arc_weights length must match num_arcs" + ); + assert_eq!( + edge_weights.len(), + graph.num_edges(), + "edge_weights length must match num_edges" + ); + assert!( + matches!( + bound.partial_cmp(&W::Sum::zero()), + Some(Ordering::Equal | Ordering::Greater) + ), + "bound must be nonnegative" + ); + for (index, weight) in arc_weights.iter().enumerate() { + assert!( + matches!( + weight.to_sum().partial_cmp(&W::Sum::zero()), + Some(Ordering::Equal | Ordering::Greater) + ), + "arc weight at index {} must be nonnegative", + index + ); + } + for (index, weight) in edge_weights.iter().enumerate() { + assert!( + matches!( + weight.to_sum().partial_cmp(&W::Sum::zero()), + Some(Ordering::Equal | Ordering::Greater) + ), + "edge weight at index {} must be nonnegative", + index + ); + } + + Self { + graph, + arc_weights, + edge_weights, + bound, + } + } + + /// Return the mixed graph. + pub fn graph(&self) -> &MixedGraph { + &self.graph + } + + /// Return the directed-arc lengths. + pub fn arc_weights(&self) -> &[W] { + &self.arc_weights + } + + /// Return the undirected-edge lengths. + pub fn edge_weights(&self) -> &[W] { + &self.edge_weights + } + + /// Return the bound. + pub fn bound(&self) -> &W::Sum { + &self.bound + } + + /// Return the number of vertices. + pub fn num_vertices(&self) -> usize { + self.graph.num_vertices() + } + + /// Return the number of directed arcs. + pub fn num_arcs(&self) -> usize { + self.graph.num_arcs() + } + + /// Return the number of undirected edges. + pub fn num_edges(&self) -> usize { + self.graph.num_edges() + } + + /// Return whether this instance uses non-unit lengths. + pub fn is_weighted(&self) -> bool { + !W::IS_UNIT + } + + fn oriented_arc_pairs(&self, config: &[usize]) -> Option> { + if config.len() != self.graph.num_edges() { + return None; + } + + let mut arcs = self.graph.arcs(); + for ((u, v), &direction) in self.graph.edges().iter().zip(config.iter()) { + match direction { + 0 => arcs.push((*u, *v)), + 1 => arcs.push((*v, *u)), + _ => return None, + } + } + Some(arcs) + } + + fn weighted_oriented_arcs(&self, config: &[usize]) -> Option> { + if config.len() != self.graph.num_edges() { + return None; + } + + let mut arcs: Vec<(usize, usize, i64)> = self + .graph + .arcs() + .into_iter() + .zip(self.arc_weights.iter()) + .map(|((u, v), weight)| (u, v, i64::from(weight.to_sum()))) + .collect(); + + for (((u, v), weight), &direction) in self + .graph + .edges() + .iter() + .zip(self.edge_weights.iter()) + .zip(config.iter()) + { + let cost = i64::from(weight.to_sum()); + match direction { + 0 => arcs.push((*u, *v, cost)), + 1 => arcs.push((*v, *u, cost)), + _ => return None, + } + } + + Some(arcs) + } + + fn base_cost(&self) -> i64 { + self.arc_weights + .iter() + .map(|weight| i64::from(weight.to_sum())) + .sum::() + + self + .edge_weights + .iter() + .map(|weight| i64::from(weight.to_sum())) + .sum::() + } +} + +impl MixedChinesePostman +where + W: WeightElement + crate::variant::VariantParam, +{ + /// Check whether a configuration is satisfying. + pub fn is_valid_solution(&self, config: &[usize]) -> bool { + self.evaluate(config) + } +} + +impl Problem for MixedChinesePostman +where + W: WeightElement + crate::variant::VariantParam, +{ + const NAME: &'static str = "MixedChinesePostman"; + type Metric = bool; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![W] + } + + fn dims(&self) -> Vec { + vec![2; self.graph.num_edges()] + } + + fn evaluate(&self, config: &[usize]) -> bool { + let Some(oriented_pairs) = self.oriented_arc_pairs(config) else { + return false; + }; + + if !DirectedGraph::new(self.graph.num_vertices(), oriented_pairs.clone()) + .is_strongly_connected() + { + return false; + } + + let Some(weighted_arcs) = self.weighted_oriented_arcs(config) else { + return false; + }; + + let distances = all_pairs_shortest_paths(self.graph.num_vertices(), &weighted_arcs); + let balance = degree_imbalances(self.graph.num_vertices(), &oriented_pairs); + let Some(extra_cost) = minimum_balancing_cost(&balance, &distances) else { + return false; + }; + + self.base_cost() + extra_cost <= i64::from(self.bound) + } +} + +impl SatisfactionProblem for MixedChinesePostman where + W: WeightElement + crate::variant::VariantParam +{ +} + +crate::declare_variants! { + default sat MixedChinesePostman => "2^num_edges * num_vertices^3", + sat MixedChinesePostman => "2^num_edges * num_vertices^3", +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "mixed_chinese_postman_i32", + instance: Box::new(MixedChinesePostman::new( + MixedGraph::new( + 5, + vec![(0, 1), (1, 2), (2, 3), (3, 0)], + vec![(0, 2), (1, 3), (0, 4), (4, 2)], + ), + vec![2, 3, 1, 4], + vec![2, 3, 1, 2], + 24, + )), + optimal_config: vec![1, 1, 0, 0], + optimal_value: serde_json::json!(true), + }] +} + +fn all_pairs_shortest_paths(num_vertices: usize, arcs: &[(usize, usize, i64)]) -> Vec> { + let mut distances = vec![vec![INF_COST; num_vertices]; num_vertices]; + + for (vertex, row) in distances.iter_mut().enumerate() { + row[vertex] = 0; + } + + for &(u, v, cost) in arcs { + if cost < distances[u][v] { + distances[u][v] = cost; + } + } + + for via in 0..num_vertices { + for src in 0..num_vertices { + if distances[src][via] == INF_COST { + continue; + } + for dst in 0..num_vertices { + if distances[via][dst] == INF_COST { + continue; + } + let through = distances[src][via] + distances[via][dst]; + if through < distances[src][dst] { + distances[src][dst] = through; + } + } + } + } + + distances +} + +fn degree_imbalances(num_vertices: usize, arcs: &[(usize, usize)]) -> Vec { + let mut balance = vec![0_i32; num_vertices]; + for &(u, v) in arcs { + balance[u] += 1; + balance[v] -= 1; + } + balance +} + +fn minimum_balancing_cost(balance: &[i32], distances: &[Vec]) -> Option { + let mut deficits = Vec::new(); + let mut surpluses = Vec::new(); + + for (vertex, &value) in balance.iter().enumerate() { + if value < 0 { + for _ in 0..usize::try_from(-value).ok()? { + deficits.push(vertex); + } + } else if value > 0 { + for _ in 0..usize::try_from(value).ok()? { + surpluses.push(vertex); + } + } + } + + if deficits.len() != surpluses.len() { + return None; + } + if deficits.is_empty() { + return Some(0); + } + + let mut costs = vec![vec![INF_COST; surpluses.len()]; deficits.len()]; + for (row, &src) in deficits.iter().enumerate() { + for (col, &dst) in surpluses.iter().enumerate() { + costs[row][col] = distances[src][dst]; + } + } + + hungarian_min_cost(&costs) +} + +fn hungarian_min_cost(costs: &[Vec]) -> Option { + let size = costs.len(); + if size == 0 { + return Some(0); + } + if costs.iter().any(|row| row.len() != size) { + return None; + } + + let mut u = vec![0_i64; size + 1]; + let mut v = vec![0_i64; size + 1]; + let mut p = vec![0_usize; size + 1]; + let mut way = vec![0_usize; size + 1]; + + for row in 1..=size { + p[0] = row; + let mut column0 = 0; + let mut minv = vec![INF_COST; size + 1]; + let mut used = vec![false; size + 1]; + + loop { + used[column0] = true; + let row0 = p[column0]; + let mut delta = INF_COST; + let mut column1 = 0; + + for column in 1..=size { + if used[column] { + continue; + } + + let current = costs[row0 - 1][column - 1] - u[row0] - v[column]; + if current < minv[column] { + minv[column] = current; + way[column] = column0; + } + if minv[column] < delta { + delta = minv[column]; + column1 = column; + } + } + + if delta == INF_COST { + return None; + } + + for column in 0..=size { + if used[column] { + u[p[column]] += delta; + v[column] -= delta; + } else { + minv[column] -= delta; + } + } + + column0 = column1; + if p[column0] == 0 { + break; + } + } + + loop { + let column1 = way[column0]; + p[column0] = p[column1]; + column0 = column1; + if column0 == 0 { + break; + } + } + } + + let mut assignment = vec![0_usize; size + 1]; + for column in 1..=size { + assignment[p[column]] = column; + } + + let mut total = 0_i64; + for row in 1..=size { + let cost = costs[row - 1][assignment[row] - 1]; + if cost == INF_COST { + return None; + } + total += cost; + } + Some(total) +} + +#[cfg(test)] +#[path = "../../unit_tests/models/graph/mixed_chinese_postman.rs"] +mod tests; diff --git a/src/models/graph/mod.rs b/src/models/graph/mod.rs index c971b7746..0c95f91cf 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -37,14 +37,15 @@ //! - [`MultipleChoiceBranching`]: Directed branching with partition constraints //! - [`LengthBoundedDisjointPaths`]: Length-bounded internally disjoint s-t paths //! - [`RuralPostman`]: Rural Postman (circuit covering required edges) +//! - [`MixedChinesePostman`]: Mixed-graph postman tour with bounded total length //! - [`SteinerTree`]: Minimum-weight tree spanning all required terminals //! - [`SubgraphIsomorphism`]: Subgraph isomorphism (decision problem) //! - [`DirectedTwoCommodityIntegralFlow`]: Directed two-commodity integral flow (satisfaction) //! - [`UndirectedTwoCommodityIntegralFlow`]: Two-commodity integral flow on undirected graphs //! - [`StrongConnectivityAugmentation`]: Strong connectivity augmentation with weighted candidate arcs -pub(crate) mod balanced_complete_bipartite_subgraph; pub(crate) mod acyclic_partition; +pub(crate) mod balanced_complete_bipartite_subgraph; pub(crate) mod biclique_cover; pub(crate) mod biconnectivity_augmentation; pub(crate) mod bounded_component_spanning_forest; @@ -71,6 +72,7 @@ pub(crate) mod minimum_feedback_vertex_set; pub(crate) mod minimum_multiway_cut; pub(crate) mod minimum_sum_multicenter; pub(crate) mod minimum_vertex_cover; +pub(crate) mod mixed_chinese_postman; pub(crate) mod multiple_choice_branching; pub(crate) mod multiple_copy_file_allocation; pub(crate) mod optimal_linear_arrangement; @@ -114,6 +116,7 @@ pub use minimum_feedback_vertex_set::MinimumFeedbackVertexSet; pub use minimum_multiway_cut::MinimumMultiwayCut; pub use minimum_sum_multicenter::MinimumSumMulticenter; pub use minimum_vertex_cover::MinimumVertexCover; +pub use mixed_chinese_postman::MixedChinesePostman; pub use multiple_choice_branching::MultipleChoiceBranching; pub use multiple_copy_file_allocation::MultipleCopyFileAllocation; pub use optimal_linear_arrangement::OptimalLinearArrangement; @@ -173,6 +176,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec, + edges: Vec<(usize, usize)>, +} + +impl MixedGraph { + /// Create a new mixed graph. + /// + /// # Panics + /// + /// Panics if any endpoint references a vertex outside `0..num_vertices`. + pub fn new(num_vertices: usize, arcs: Vec<(usize, usize)>, edges: Vec<(usize, usize)>) -> Self { + for &(u, v) in &arcs { + assert!( + u < num_vertices && v < num_vertices, + "arc ({}, {}) references vertex >= num_vertices ({})", + u, + v, + num_vertices + ); + } + + for &(u, v) in &edges { + assert!( + u < num_vertices && v < num_vertices, + "edge ({}, {}) references vertex >= num_vertices ({})", + u, + v, + num_vertices + ); + } + + Self { + num_vertices, + arcs, + edges, + } + } + + /// Create an empty mixed graph with no arcs or undirected edges. + pub fn empty(num_vertices: usize) -> Self { + Self::new(num_vertices, vec![], vec![]) + } + + /// Return the number of vertices. + pub fn num_vertices(&self) -> usize { + self.num_vertices + } + + /// Return the number of directed arcs. + pub fn num_arcs(&self) -> usize { + self.arcs.len() + } + + /// Return the number of undirected edges. + pub fn num_edges(&self) -> usize { + self.edges.len() + } + + /// Return the directed arcs. + pub fn arcs(&self) -> Vec<(usize, usize)> { + self.arcs.clone() + } + + /// Return the undirected edges. + pub fn edges(&self) -> Vec<(usize, usize)> { + self.edges.clone() + } + + /// Return true when the directed arc `(u, v)` is present. + pub fn has_arc(&self, u: usize, v: usize) -> bool { + self.arcs.iter().any(|&(src, dst)| src == u && dst == v) + } + + /// Return true when the undirected edge `{u, v}` is present. + pub fn has_edge(&self, u: usize, v: usize) -> bool { + let edge = normalize_edge(u, v); + self.edges + .iter() + .any(|&(a, b)| normalize_edge(a, b) == edge) + } + + /// Return the outgoing arc count of vertex `v`. + pub fn out_degree(&self, v: usize) -> usize { + self.arcs.iter().filter(|&&(u, _)| u == v).count() + } + + /// Return the incoming arc count of vertex `v`. + pub fn in_degree(&self, v: usize) -> usize { + self.arcs.iter().filter(|&&(_, w)| w == v).count() + } + + /// Return the undirected-edge count incident to vertex `v`. + pub fn undirected_degree(&self, v: usize) -> usize { + self.edges + .iter() + .filter(|&&(u, w)| u == v || w == v) + .count() + } + + /// Return true if the graph has no vertices. + pub fn is_empty(&self) -> bool { + self.num_vertices == 0 + } +} + +fn normalize_edge(u: usize, v: usize) -> (usize, usize) { + if u <= v { + (u, v) + } else { + (v, u) + } +} + +impl PartialEq for MixedGraph { + fn eq(&self, other: &Self) -> bool { + if self.num_vertices != other.num_vertices { + return false; + } + + let mut self_arcs = self.arcs.clone(); + let mut other_arcs = other.arcs.clone(); + self_arcs.sort(); + other_arcs.sort(); + if self_arcs != other_arcs { + return false; + } + + let mut self_edges = self.edges.clone(); + let mut other_edges = other.edges.clone(); + for edge in &mut self_edges { + *edge = normalize_edge(edge.0, edge.1); + } + for edge in &mut other_edges { + *edge = normalize_edge(edge.0, edge.1); + } + self_edges.sort(); + other_edges.sort(); + self_edges == other_edges + } +} + +impl Eq for MixedGraph {} + +use crate::impl_variant_param; +impl_variant_param!(MixedGraph, "graph"); + +#[cfg(test)] +#[path = "../unit_tests/topology/mixed_graph.rs"] +mod tests; diff --git a/src/topology/mod.rs b/src/topology/mod.rs index 8881ca9f7..4e7eed829 100644 --- a/src/topology/mod.rs +++ b/src/topology/mod.rs @@ -4,6 +4,7 @@ //! - [`PlanarGraph`]: Planar graph //! - [`BipartiteGraph`]: Bipartite graph //! - [`DirectedGraph`]: Directed graph (digraph) +//! - [`MixedGraph`]: Mixed graph with directed arcs and undirected edges //! - [`UnitDiskGraph`]: Vertices with 2D positions, edges based on distance //! - [`KingsSubgraph`]: 8-connected grid graph (King's graph) //! - [`TriangularSubgraph`]: Triangular lattice subgraph @@ -13,6 +14,7 @@ mod bipartite_graph; mod directed_graph; mod graph; mod kings_subgraph; +mod mixed_graph; mod planar_graph; pub mod small_graphs; mod triangular_subgraph; @@ -22,6 +24,7 @@ pub use bipartite_graph::BipartiteGraph; pub use directed_graph::DirectedGraph; pub use graph::{Graph, GraphCast, SimpleGraph}; pub use kings_subgraph::KingsSubgraph; +pub use mixed_graph::MixedGraph; pub use planar_graph::PlanarGraph; pub use small_graphs::{available_graphs, smallgraph}; pub use triangular_subgraph::TriangularSubgraph; diff --git a/src/unit_tests/models/graph/acyclic_partition.rs b/src/unit_tests/models/graph/acyclic_partition.rs index 0abb21887..c15582f30 100644 --- a/src/unit_tests/models/graph/acyclic_partition.rs +++ b/src/unit_tests/models/graph/acyclic_partition.rs @@ -1,6 +1,6 @@ use super::*; -use crate::solvers::{BruteForce, Solver}; use crate::registry::declared_size_fields; +use crate::solvers::{BruteForce, Solver}; use crate::topology::DirectedGraph; use crate::traits::Problem; use serde_json; @@ -98,13 +98,7 @@ fn test_acyclic_partition_creation_and_accessors() { #[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, - ) + AcyclicPartition::new(DirectedGraph::new(2, vec![(0, 1)]), vec![1], vec![1], 2, 1) }); assert!(result.is_err()); } diff --git a/src/unit_tests/models/graph/mixed_chinese_postman.rs b/src/unit_tests/models/graph/mixed_chinese_postman.rs new file mode 100644 index 000000000..d8ff92706 --- /dev/null +++ b/src/unit_tests/models/graph/mixed_chinese_postman.rs @@ -0,0 +1,122 @@ +use super::*; +use crate::solvers::{BruteForce, Solver}; +use crate::topology::MixedGraph; +use crate::traits::Problem; + +fn yes_instance() -> MixedChinesePostman { + MixedChinesePostman::new( + MixedGraph::new( + 5, + vec![(0, 1), (1, 2), (2, 3), (3, 0)], + vec![(0, 2), (1, 3), (0, 4), (4, 2)], + ), + vec![2, 3, 1, 4], + vec![2, 3, 1, 2], + 24, + ) +} + +fn no_instance() -> MixedChinesePostman { + MixedChinesePostman::new( + MixedGraph::new( + 6, + vec![(0, 1), (1, 0), (2, 3)], + vec![(0, 2), (1, 3), (3, 4), (4, 5), (5, 2)], + ), + vec![1, 1, 1], + vec![1, 1, 5, 5, 5], + 10, + ) +} + +#[test] +fn test_mixed_chinese_postman_creation_and_accessors() { + let problem = yes_instance(); + + assert_eq!(problem.num_vertices(), 5); + assert_eq!(problem.num_arcs(), 4); + assert_eq!(problem.num_edges(), 4); + assert_eq!(problem.dims(), vec![2, 2, 2, 2]); + assert_eq!(problem.arc_weights(), &[2, 3, 1, 4]); + assert_eq!(problem.edge_weights(), &[2, 3, 1, 2]); + assert_eq!(*problem.bound(), 24); +} + +#[test] +fn test_mixed_chinese_postman_evaluate_yes_issue_example() { + let problem = yes_instance(); + + // Reverse (0,2) and (1,3), keep (0,4) and (4,2) forward. + assert!(problem.evaluate(&[1, 1, 0, 0])); +} + +#[test] +fn test_mixed_chinese_postman_evaluate_no_issue_example() { + let problem = no_instance(); + + assert!(!problem.evaluate(&[0, 0, 0, 0, 0])); +} + +#[test] +fn test_mixed_chinese_postman_rejects_non_strongly_connected_orientation() { + let problem = MixedChinesePostman::new( + MixedGraph::new(3, vec![(0, 1), (1, 2)], vec![(0, 2)]), + vec![1, 1], + vec![1], + 10, + ); + + assert!(!problem.evaluate(&[0])); + assert!(problem.evaluate(&[1])); +} + +#[test] +fn test_mixed_chinese_postman_rejects_wrong_config_length() { + let problem = yes_instance(); + + assert!(!problem.evaluate(&[])); + assert!(!problem.evaluate(&[1, 1, 0])); + assert!(!problem.evaluate(&[1, 1, 0, 0, 1])); +} + +#[test] +fn test_mixed_chinese_postman_solver_finds_satisfying_orientation() { + let problem = yes_instance(); + let solver = BruteForce::new(); + + let solution = solver + .find_satisfying(&problem) + .expect("expected a satisfying orientation"); + assert!(problem.evaluate(&solution)); +} + +#[test] +fn test_mixed_chinese_postman_solver_reports_unsat_issue_example() { + let problem = no_instance(); + let solver = BruteForce::new(); + + assert!(solver.find_satisfying(&problem).is_none()); +} + +#[test] +fn test_mixed_chinese_postman_serialization_roundtrip() { + let problem = yes_instance(); + + let json = serde_json::to_string(&problem).unwrap(); + let restored: MixedChinesePostman = serde_json::from_str(&json).unwrap(); + + assert_eq!(restored.num_vertices(), 5); + assert_eq!(restored.num_arcs(), 4); + assert_eq!(restored.num_edges(), 4); + assert_eq!(restored.arc_weights(), &[2, 3, 1, 4]); + assert_eq!(restored.edge_weights(), &[2, 3, 1, 2]); + assert_eq!(*restored.bound(), 24); +} + +#[test] +fn test_mixed_chinese_postman_problem_name() { + assert_eq!( + as Problem>::NAME, + "MixedChinesePostman" + ); +} diff --git a/src/unit_tests/topology/mixed_graph.rs b/src/unit_tests/topology/mixed_graph.rs new file mode 100644 index 000000000..316e4ac02 --- /dev/null +++ b/src/unit_tests/topology/mixed_graph.rs @@ -0,0 +1,58 @@ +use crate::topology::MixedGraph; + +#[test] +fn test_mixed_graph_creation_and_counts() { + let graph = MixedGraph::new(4, vec![(0, 1), (2, 3)], vec![(0, 2), (1, 3)]); + + assert_eq!(graph.num_vertices(), 4); + assert_eq!(graph.num_arcs(), 2); + assert_eq!(graph.num_edges(), 2); + + let mut arcs = graph.arcs(); + arcs.sort(); + assert_eq!(arcs, vec![(0, 1), (2, 3)]); + + let mut edges = graph.edges(); + edges.sort(); + assert_eq!(edges, vec![(0, 2), (1, 3)]); +} + +#[test] +fn test_mixed_graph_incidence_queries() { + let graph = MixedGraph::new(4, vec![(0, 1), (2, 1)], vec![(1, 3), (0, 2)]); + + assert!(graph.has_arc(0, 1)); + assert!(!graph.has_arc(1, 0)); + assert!(graph.has_edge(1, 3)); + assert!(graph.has_edge(3, 1)); + assert!(!graph.has_edge(0, 3)); + + assert_eq!(graph.out_degree(0), 1); + assert_eq!(graph.in_degree(1), 2); + assert_eq!(graph.undirected_degree(1), 1); + assert_eq!(graph.undirected_degree(0), 1); +} + +#[test] +fn test_mixed_graph_has_edge_is_order_insensitive() { + let graph = MixedGraph::new(3, vec![], vec![(2, 0)]); + + assert!(graph.has_edge(0, 2)); + assert!(graph.has_edge(2, 0)); +} + +#[test] +fn test_mixed_graph_serialization_roundtrip() { + let graph = MixedGraph::new(5, vec![(0, 1), (1, 4)], vec![(0, 2), (2, 3), (3, 4)]); + + let json = serde_json::to_string(&graph).unwrap(); + let restored: MixedGraph = serde_json::from_str(&json).unwrap(); + + assert_eq!(restored, graph); +} + +#[test] +#[should_panic(expected = "references vertex >= num_vertices")] +fn test_mixed_graph_panics_on_out_of_bounds_arc() { + MixedGraph::new(3, vec![(0, 3)], vec![]); +} From e578e463716558a6157fdb7b095bc9cb7ec30c5f Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 21 Mar 2026 20:17:40 +0800 Subject: [PATCH 3/4] chore: remove plan file after implementation --- .../plans/2026-03-21-mixed-chinese-postman.md | 238 ------------------ 1 file changed, 238 deletions(-) delete mode 100644 docs/plans/2026-03-21-mixed-chinese-postman.md diff --git a/docs/plans/2026-03-21-mixed-chinese-postman.md b/docs/plans/2026-03-21-mixed-chinese-postman.md deleted file mode 100644 index 408fbde71..000000000 --- a/docs/plans/2026-03-21-mixed-chinese-postman.md +++ /dev/null @@ -1,238 +0,0 @@ -# MixedChinesePostman Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Add the `MixedChinesePostman` model, including a new `MixedGraph` topology, exact orientation-based evaluation, CLI/example-db integration, and paper documentation for issue #242. - -**Architecture:** Represent a mixed graph as directed arcs plus undirected edges in a reusable topology type. Model `MixedChinesePostman` as a satisfaction problem with one binary variable per undirected edge, orient each edge according to the config, reject orientations that do not yield a strongly connected directed graph, then compute the minimum extra traversal cost needed to balance the directed graph by combining all-pairs shortest paths with a minimum-cost assignment over degree imbalances. - -**Tech Stack:** Rust, petgraph, serde, inventory, existing `BruteForce` solver, existing CLI create/example pipeline, Typst paper exports - ---- - -## Skill Alignment - -- **Primary repo skill:** `.claude/skills/add-model/SKILL.md` -- **Execution mode:** `superpowers:subagent-driven-development` -- **Testing discipline:** `superpowers:test-driven-development` - -## Issue Packet Summary - -- **Issue:** #242 — `[Model] MixedChinesePostman` -- **Kind:** model -- **Labels:** `model`, `Good` -- **Associated rule:** #260 — `[Rule] 3-SATISFIABILITY to CHINESE POSTMAN FOR MIXED GRAPHS` -- **Approved design corrections from comments:** - - Rename to `MixedChinesePostman` - - Use `|E|` binary orientation variables for undirected edges - - Add a reusable `MixedGraph` topology - - Expose size getters `num_vertices`, `num_arcs`, `num_edges`, `bound` - - Use complexity metadata `2^num_edges * num_vertices^3` - - Use the fixed YES/NO examples from the issue comments as the source of truth - -## Information Checklist - -| # | Item | Value | -|---|------|-------| -| 1 | Problem name | `MixedChinesePostman` | -| 2 | Mathematical definition | Given a mixed graph `G = (V, A, E)` with nonnegative lengths on arcs and undirected edges and a bound `B`, determine whether there exists a closed walk of total length at most `B` that traverses every directed arc in its given direction and every undirected edge in at least one direction | -| 3 | Problem type | Satisfaction (`Metric = bool`) | -| 4 | Type parameters | Weight parameter `W` only; topology is a concrete `MixedGraph` | -| 5 | Struct fields | `graph: MixedGraph`, `arc_weights: Vec`, `edge_weights: Vec`, `bound: W::Sum` | -| 6 | Configuration space | `vec![2; num_edges]` — one binary orientation choice per undirected edge | -| 7 | Feasibility check | Orient each undirected edge, require the resulting digraph to be strongly connected, compute the cheapest added traversal cost needed to balance in/out degrees, and accept iff base cost + added cost `<= bound` | -| 8 | Objective function | `bool` | -| 9 | Best known exact algorithm | Brute-force over all edge orientations with polynomial subproblem solve; metadata string: `"2^num_edges * num_vertices^3"` | -| 10 | Solving strategy | `BruteForce` over orientations; `evaluate()` solves the oriented directed-postman subproblem exactly via shortest-path costs plus minimum-cost assignment over imbalances | -| 11 | Category | `graph` | -| 12 | Expected outcome | Use the corrected YES/NO examples from issue #242 comments, including the nontrivial YES example requiring duplicated traversals | - -## Batch Structure - -- **Batch 1:** Steps 1-5.5 from `add-model` — topology, model, registration, CLI, example-db, tests -- **Batch 2:** Step 6 from `add-model` — paper entry and paper/example alignment - -## Batch 1 - -### Task 1: Add failing topology tests for `MixedGraph` - -**Files:** -- Create: `src/unit_tests/topology/mixed_graph.rs` -- Modify: `src/topology/mod.rs` - -**Steps:** -1. Write tests that define the expected `MixedGraph` surface: - - constructor preserves `num_vertices`, arcs, and undirected edges - - accessors report `num_arcs`, `num_edges`, `arcs()`, `edges()` - - adjacency helpers and degree helpers behave correctly for both arc and edge views - - serde round-trip preserves the mixed structure -2. Run the focused tests and verify they fail because `MixedGraph` does not exist yet: - - `cargo test mixed_graph --lib` - -### Task 2: Implement `MixedGraph` and register it as a topology - -**Files:** -- Create: `src/topology/mixed_graph.rs` -- Modify: `src/topology/mod.rs` -- Modify: `src/lib.rs` - -**Steps:** -1. Implement `MixedGraph` with: - - `num_vertices` - - directed `arcs: Vec<(usize, usize)>` - - undirected `edges: Vec<(usize, usize)>` - - constructor validation for vertex bounds - - accessors for counts and edge lists - - helpers needed by the model (`num_vertices`, `num_arcs`, `num_edges`) -2. Add serde support and `impl_variant_param!(MixedGraph, "graph")`. -3. Export the new topology from `src/topology/mod.rs` and the public prelude in `src/lib.rs`. -4. Re-run the focused topology tests until green: - - `cargo test mixed_graph --lib` - -### Task 3: Add failing model tests for `MixedChinesePostman` - -**Files:** -- Create: `src/unit_tests/models/graph/mixed_chinese_postman.rs` -- Reference: `src/unit_tests/models/graph/directed_two_commodity_integral_flow.rs` -- Reference: `src/unit_tests/models/graph/strong_connectivity_augmentation.rs` - -**Steps:** -1. Write tests for: - - construction, dimensions, and accessors - - corrected YES issue example evaluates to `true` - - corrected NO issue example evaluates to `false` - - a disconnected or not-strongly-connected orientation evaluates to `false` - - serialization round-trip - - `BruteForce::find_satisfying()` finds a witness for the YES instance - - paper/example invariant test placeholder using the issue example -2. Run the focused model tests and verify they fail because the model is missing: - - `cargo test mixed_chinese_postman --lib` - -### Task 4: Implement `MixedChinesePostman` with exact oriented evaluation - -**Files:** -- Create: `src/models/graph/mixed_chinese_postman.rs` -- Modify: `src/models/graph/mod.rs` -- Modify: `src/models/mod.rs` -- Modify: `src/lib.rs` - -**Steps:** -1. Add `ProblemSchemaEntry` metadata with display name, aliases (if any), fields, and dimensions. -2. Implement the struct and constructor validation: - - `arc_weights.len() == graph.num_arcs()` - - `edge_weights.len() == graph.num_edges()` -3. Add size/accessor methods: - - `num_vertices()` - - `num_arcs()` - - `num_edges()` - - `bound()` - - `graph()`, `arc_weights()`, `edge_weights()` -4. Implement `Problem` / `SatisfactionProblem`: - - `NAME = "MixedChinesePostman"` - - `dims() = vec![2; num_edges]` - - `variant() = crate::variant_params![W]` -5. Implement `evaluate()` in small, testable helpers: - - orient each undirected edge according to the config - - compute the oriented base cost - - reject if the resulting directed graph is not strongly connected - - compute all-pairs shortest path distances on the weighted oriented digraph - - derive vertex imbalances `out_degree - in_degree` - - solve the balancing transportation problem exactly with minimum-cost assignment over expanded imbalance copies - - reject if any required shortest path is unreachable - - accept iff `base_cost + balancing_cost <= bound` -6. Register concrete variants with `declare_variants!`: - - `MixedChinesePostman` - - default `MixedChinesePostman` if unit weights are supported cleanly by the implementation - - use complexity metadata string `"2^num_edges * num_vertices^3"` -7. Link the new unit test file and export the model from the graph modules and prelude. -8. Re-run the focused model tests until green: - - `cargo test mixed_chinese_postman --lib` - -### Task 5: Add canonical example-db coverage - -**Files:** -- Modify: `src/models/graph/mixed_chinese_postman.rs` -- Modify: `src/models/graph/mod.rs` -- Modify: `src/example_db/model_builders.rs` - -**Steps:** -1. Add `canonical_model_example_specs()` in the model file. -2. Use the corrected YES example from issue #242 comments as the canonical example. -3. Register the spec in the graph example chain so `pred create --example MixedChinesePostman/...` works. -4. Add or update any example-db assertions needed by existing tests. -5. Run focused example-db coverage if available: - - `cargo test example_db --lib --features example-db` - -### Task 6: Add CLI discovery and create support - -**Files:** -- Modify: `problemreductions-cli/src/problem_name.rs` -- Modify: `problemreductions-cli/src/commands/create.rs` -- Modify: `problemreductions-cli/src/cli.rs` -- Modify: `problemreductions-cli/tests/cli_tests.rs` - -**Steps:** -1. Add alias resolution for `MixedChinesePostman`. -2. Wire `pred create MixedChinesePostman` using: - - `--arcs` for directed arcs - - `--graph` for undirected edges - - `--arc-costs` for arc lengths - - `--weights` or `--edge-weights` for undirected edge lengths, depending on the existing CLI convention that best fits the repo - - `--bound` - - optional `--num-vertices` -3. Update help text and `all_data_flags_empty()` if a new flag is required for edge weights. -4. Add CLI tests for: - - successful creation - - missing required flags - - weight-count mismatch errors - - `pred show` / alias resolution if needed -5. Run the focused CLI tests and verify the new path: - - `cargo test -p problemreductions-cli mixed_chinese_postman` - -### Task 7: Run integration verification for Batch 1 - -**Files:** -- No code changes expected - -**Steps:** -1. Run the targeted Rust checks: - - `cargo test mixed_graph --lib` - - `cargo test mixed_chinese_postman --lib` - - `cargo test -p problemreductions-cli mixed_chinese_postman` -2. Run broader checks once the targeted suite is green: - - `make test` - - `make clippy` -3. If any failures expose missing registration or schema issues, fix them before moving to Batch 2. - -## Batch 2 - -### Task 8: Add paper entry for `MixedChinesePostman` - -**Files:** -- Modify: `docs/paper/reductions.typ` - -**Steps:** -1. Add the display-name dictionary entry for `MixedChinesePostman`. -2. Add a `problem-def("MixedChinesePostman")` entry with: - - formal mixed-graph definition - - background and references - - best-known algorithm prose with citations - - a worked example aligned with the canonical YES instance - - `pred-commands()` wired to the canonical example data -3. Keep the paper example consistent with the model test and example-db fixture. -4. Run: - - `make paper` - -### Task 9: Final verification and implementation handoff - -**Files:** -- No code changes expected - -**Steps:** -1. Re-run the full verification commands fresh: - - `make test` - - `make clippy` - - `make fmt-check` - - `make paper` -2. Inspect `git status --short` and confirm only intentional tracked changes remain. -3. Summarize any deviations from this plan before the implementation summary comment is posted on the PR. From 451d9cfd787fa0afe82c6533117c6f28ae50a6fe Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Sat, 21 Mar 2026 22:29:36 +0800 Subject: [PATCH 4/4] Fix MixedChinesePostman evaluate() to use both directions of undirected edges The previous implementation only allowed traversals in the chosen orientation direction, rejecting valid MCPP solutions. The real Mixed Chinese Postman allows undirected edges to be traversed in either direction during the walk. Fix by building the available-arc set with both directions for connectivity checks and shortest-path computation, while keeping oriented-only arcs for degree imbalance. Also fix paper pred solve command to include --solver brute-force (no ILP reduction path exists for this model). Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/paper/reductions.typ | 2 +- src/models/graph/mixed_chinese_postman.rs | 50 +++++++++---------- .../models/graph/mixed_chinese_postman.rs | 29 ++++++++--- 3 files changed, 49 insertions(+), 32 deletions(-) diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 262b62a86..bd036ad5c 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -3593,7 +3593,7 @@ A classical NP-complete problem from Garey and Johnson @garey1979[Ch.~3, p.~76], #pred-commands( "pred create --example MixedChinesePostman/i32 -o mixed-chinese-postman.json", - "pred solve mixed-chinese-postman.json", + "pred solve mixed-chinese-postman.json --solver brute-force", "pred evaluate mixed-chinese-postman.json --config " + x.optimal_config.map(str).join(","), ) diff --git a/src/models/graph/mixed_chinese_postman.rs b/src/models/graph/mixed_chinese_postman.rs index 9b5598de0..d79188ba4 100644 --- a/src/models/graph/mixed_chinese_postman.rs +++ b/src/models/graph/mixed_chinese_postman.rs @@ -36,9 +36,10 @@ inventory::submit! { /// Mixed Chinese Postman. /// -/// Each configuration picks an orientation for every undirected edge. Once the -/// undirected edges are oriented, the instance becomes a directed Chinese -/// Postman subproblem. +/// Each configuration picks a required traversal direction for every undirected +/// edge. The minimum-cost closed walk is then computed via the directed Chinese +/// Postman subproblem, using all available arcs (including both directions of +/// every undirected edge) for degree-balancing detours. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MixedChinesePostman> { graph: MixedGraph, @@ -162,11 +163,16 @@ impl> MixedChinesePostman { Some(arcs) } - fn weighted_oriented_arcs(&self, config: &[usize]) -> Option> { - if config.len() != self.graph.num_edges() { - return None; + fn available_arc_pairs(&self) -> Vec<(usize, usize)> { + let mut arcs = self.graph.arcs(); + for &(u, v) in self.graph.edges().iter() { + arcs.push((u, v)); + arcs.push((v, u)); } + arcs + } + fn weighted_available_arcs(&self) -> Vec<(usize, usize, i64)> { let mut arcs: Vec<(usize, usize, i64)> = self .graph .arcs() @@ -175,22 +181,13 @@ impl> MixedChinesePostman { .map(|((u, v), weight)| (u, v, i64::from(weight.to_sum()))) .collect(); - for (((u, v), weight), &direction) in self - .graph - .edges() - .iter() - .zip(self.edge_weights.iter()) - .zip(config.iter()) - { + for ((u, v), weight) in self.graph.edges().iter().zip(self.edge_weights.iter()) { let cost = i64::from(weight.to_sum()); - match direction { - 0 => arcs.push((*u, *v, cost)), - 1 => arcs.push((*v, *u, cost)), - _ => return None, - } + arcs.push((*u, *v, cost)); + arcs.push((*v, *u, cost)); } - Some(arcs) + arcs } fn base_cost(&self) -> i64 { @@ -236,17 +233,20 @@ where return false; }; - if !DirectedGraph::new(self.graph.num_vertices(), oriented_pairs.clone()) + // Connectivity uses the full available graph: original arcs plus both + // directions of every undirected edge. + if !DirectedGraph::new(self.graph.num_vertices(), self.available_arc_pairs()) .is_strongly_connected() { return false; } - let Some(weighted_arcs) = self.weighted_oriented_arcs(config) else { - return false; - }; - - let distances = all_pairs_shortest_paths(self.graph.num_vertices(), &weighted_arcs); + // Shortest paths also use the full available graph so that balancing + // can route through undirected edges in either direction. + let distances = + all_pairs_shortest_paths(self.graph.num_vertices(), &self.weighted_available_arcs()); + // Degree imbalance is computed from the required arcs only (original + // arcs plus the chosen orientation of each undirected edge). let balance = degree_imbalances(self.graph.num_vertices(), &oriented_pairs); let Some(extra_cost) = minimum_balancing_cost(&balance, &distances) else { return false; diff --git a/src/unit_tests/models/graph/mixed_chinese_postman.rs b/src/unit_tests/models/graph/mixed_chinese_postman.rs index d8ff92706..e73da1ff6 100644 --- a/src/unit_tests/models/graph/mixed_chinese_postman.rs +++ b/src/unit_tests/models/graph/mixed_chinese_postman.rs @@ -58,16 +58,33 @@ fn test_mixed_chinese_postman_evaluate_no_issue_example() { } #[test] -fn test_mixed_chinese_postman_rejects_non_strongly_connected_orientation() { +fn test_mixed_chinese_postman_single_edge_walk() { + // V={0,1}, A=∅, E={{0,1}}, weight=1, B=2. + // Walk 0→1→0 is valid: traverses the edge at least once, total cost 2 ≤ 2. + let problem = + MixedChinesePostman::new(MixedGraph::new(2, vec![], vec![(0, 1)]), vec![], vec![1], 2); + + assert!(problem.evaluate(&[0])); + assert!(problem.evaluate(&[1])); + + let solver = BruteForce::new(); + assert!(solver.find_satisfying(&problem).is_some()); +} + +#[test] +fn test_mixed_chinese_postman_rejects_disconnected_graph() { + // Two disconnected components {0,1} and {2,3}: no closed walk can cover all edges. let problem = MixedChinesePostman::new( - MixedGraph::new(3, vec![(0, 1), (1, 2)], vec![(0, 2)]), + MixedGraph::new(4, vec![], vec![(0, 1), (2, 3)]), + vec![], vec![1, 1], - vec![1], - 10, + 100, ); - assert!(!problem.evaluate(&[0])); - assert!(problem.evaluate(&[1])); + assert!(!problem.evaluate(&[0, 0])); + assert!(!problem.evaluate(&[0, 1])); + assert!(!problem.evaluate(&[1, 0])); + assert!(!problem.evaluate(&[1, 1])); } #[test]