From bbe4ec6dc655da8f4d3fd3ecf9c9fa408fd64baf Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Mon, 16 Mar 2026 08:25:20 +0800 Subject: [PATCH 1/5] Add plan for #253: [Model] MultipleChoiceBranching --- .../2026-03-16-multiple-choice-branching.md | 371 ++++++++++++++++++ 1 file changed, 371 insertions(+) create mode 100644 docs/plans/2026-03-16-multiple-choice-branching.md diff --git a/docs/plans/2026-03-16-multiple-choice-branching.md b/docs/plans/2026-03-16-multiple-choice-branching.md new file mode 100644 index 00000000..be8de296 --- /dev/null +++ b/docs/plans/2026-03-16-multiple-choice-branching.md @@ -0,0 +1,371 @@ +# MultipleChoiceBranching Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add the `MultipleChoiceBranching` model as a directed-graph satisfaction problem, wire it into the registry/example-db/CLI, and document it in the paper using the issue's YES instance. + +**Architecture:** Implement `MultipleChoiceBranching` as a `DirectedGraph`-backed satisfaction problem with one binary variable per arc. The constructor owns the static invariants (weight count, partition indices in range, partition groups pairwise disjoint, and full coverage of all arcs); `evaluate()` then checks dynamic feasibility on a configuration: binary domain, one selected arc per partition group, in-degree at most one at each vertex, acyclicity of the selected subgraph, and total selected weight at least the threshold. Use the issue's 8-arc YES instance as the canonical model example so tests, CLI examples, and the paper stay aligned. + +**Tech Stack:** Rust, serde, inventory schema registry, `DirectedGraph`, `BruteForce`, `pred create`, Typst paper/example fixtures. + +--- + +## Constraints And Decisions + +- Follow repo-local [`.claude/skills/add-model/SKILL.md`](/Users/jinguomini/rcode/problem-reductions/.worktrees/issue-253-multiple-choice-branching/.claude/skills/add-model/SKILL.md) Steps 1-7. +- Keep the paper work in a separate batch from the implementation work. Do not start Batch 2 until Batch 1 is green. +- Model shape: + - Type: `MultipleChoiceBranching` + - Category: `src/models/graph/` + - Problem kind: satisfaction (`Metric = bool`) + - Variant dimensions: weight only, default `i32` + - Complexity string: `"2^num_arcs"` +- Canonical example: + - Use issue #253 YES instance with arc order `[(0,1), (0,2), (1,3), (2,3), (1,4), (3,5), (4,5), (2,4)]` + - Use satisfying configuration `[1,0,1,0,0,1,0,1]` + - Keep the threshold at `10` +- There is currently no open rule issue referencing `MultipleChoiceBranching`. Treat that as an orphan-model warning to surface in the PR summary/body, but do not block implementation. + +## Batch 1: Add-Model Steps 1-5.5 + +### Task 1: Write The Failing Model Tests First + +**Files:** +- Create: `src/unit_tests/models/graph/multiple_choice_branching.rs` +- Reference: `src/unit_tests/models/graph/hamiltonian_path.rs` +- Reference: `src/unit_tests/models/graph/minimum_feedback_arc_set.rs` +- Reference: `src/models/graph/rural_postman.rs` + +**Step 1: Write failing tests for the model contract** + +Cover these behaviors explicitly: +- `test_multiple_choice_branching_creation_and_accessors` + - Construct the YES instance from issue #253. + - Assert `num_vertices() == 6`, `num_arcs() == 8`, `num_partition_groups() == 4`. + - Assert `dims() == vec![2; 8]`. + - Assert `weights()`, `partition()`, `threshold()`, and `graph()` expose the expected data. +- `test_multiple_choice_branching_partition_validation` + - Constructor should panic for out-of-range arc indices. + - Constructor should panic for overlapping groups. + - Constructor should panic if the partition omits an arc. +- `test_multiple_choice_branching_evaluate_yes_instance` + - Use config `[1,0,1,0,0,1,0,1]`. + - Assert `evaluate()` returns `true`. +- `test_multiple_choice_branching_rejects_constraint_violations` + - One config that violates the partition constraint. + - One config that violates in-degree-at-most-one. + - One config that contains a directed cycle. + - One config that stays feasible but misses the threshold. +- `test_multiple_choice_branching_solver_issue_examples` + - Assert `BruteForce::find_satisfying()` finds a solution on the YES instance. + - Assert `BruteForce::find_satisfying()` returns `None` on a small NO instance. + - Assert every solution returned by `find_all_satisfying()` is accepted by `evaluate()`. +- `test_multiple_choice_branching_serialization` + - Round-trip through serde JSON. + +**Step 2: Run the focused test target and verify RED** + +Run: + +```bash +cargo test multiple_choice_branching --lib +``` + +Expected: compile or link failure because the new model/module does not exist yet. + +**Step 3: Commit nothing yet** + +Do not write production code until the failing tests are in place and the RED run has been observed. + +### Task 2: Implement The Model And Register It + +**Files:** +- Create: `src/models/graph/multiple_choice_branching.rs` +- Modify: `src/models/graph/mod.rs` +- Modify: `src/models/mod.rs` + +**Step 1: Implement the minimal model to satisfy Task 1** + +Model requirements: +- Add `inventory::submit!` `ProblemSchemaEntry` with: + - `name: "MultipleChoiceBranching"` + - `display_name: "Multiple Choice Branching"` + - one variant dimension for `weight` with default `i32` + - fields `graph`, `weights`, `partition`, `threshold` +- Define: + +```rust +pub struct MultipleChoiceBranching { + graph: DirectedGraph, + weights: Vec, + partition: Vec>, + threshold: W::Sum, +} +``` + +- Constructor: + - assert `weights.len() == graph.num_arcs()` + - assert every partition entry is in `0..graph.num_arcs()` + - assert each arc index appears exactly once across all groups +- Accessors/getters: + - `graph()` + - `weights()` + - `set_weights(...)` + - `partition()` + - `threshold()` + - `num_vertices()` + - `num_arcs()` + - `num_partition_groups()` + - `is_weighted()` + - `is_valid_solution(config)` +- `Problem` impl: + - `type Metric = bool` + - `variant() = crate::variant_params![W]` + - `dims() = vec![2; self.graph.num_arcs()]` + - `evaluate()` returns `true` iff: + - config length matches `num_arcs` + - every entry is `0` or `1` + - each partition group contains at most one selected arc + - selected arcs give every vertex in-degree at most one + - selected arcs form an acyclic subgraph via `DirectedGraph::is_acyclic_subgraph` + - selected weight sum is `>= threshold` +- `impl SatisfactionProblem for MultipleChoiceBranching` +- `declare_variants!`: + +```rust +crate::declare_variants! { + default sat MultipleChoiceBranching => "2^num_arcs", +} +``` + +- Add the test link at the bottom. + +**Step 2: Register the new graph module** + +Update: +- `src/models/graph/mod.rs` +- `src/models/mod.rs` + +Required outcomes: +- export `multiple_choice_branching` +- re-export `MultipleChoiceBranching` +- extend `canonical_model_example_specs()` in the graph module once Task 3 adds the example spec + +**Step 3: Run the focused test target and verify GREEN** + +Run: + +```bash +cargo test multiple_choice_branching --lib +``` + +Expected: the model tests compile and pass. + +### Task 3: Add The Canonical Example And Trait Coverage + +**Files:** +- Modify: `src/models/graph/multiple_choice_branching.rs` +- Modify: `src/models/graph/mod.rs` +- Modify: `src/unit_tests/trait_consistency.rs` +- Reference: `src/unit_tests/example_db.rs` + +**Step 1: Add the canonical model example to the model file** + +Inside `#[cfg(feature = "example-db")] canonical_model_example_specs()`: +- Build the YES instance from issue #253 exactly. +- Register one example id: `multiple_choice_branching_i32`. +- Use `crate::example_db::specs::satisfaction_example(problem, vec![vec![1,0,1,0,0,1,0,1]])`. + +**Step 2: Wire the canonical example into the graph example aggregator** + +Extend `src/models/graph/mod.rs` so `canonical_model_example_specs()` includes the new model's specs. + +**Step 3: Add trait-consistency coverage** + +Update `src/unit_tests/trait_consistency.rs`: +- Add a `check_problem_trait(...)` call with a small `MultipleChoiceBranching` instance. +- Do not add a `test_direction` entry because this is not an optimization problem. + +**Step 4: Run the focused tests** + +Run: + +```bash +cargo test trait_consistency --lib +cargo test example_db --lib +``` + +Expected: both pass with the new model included. + +### Task 4: Add CLI Creation Support With TDD + +**Files:** +- Modify: `problemreductions-cli/src/cli.rs` +- Modify: `problemreductions-cli/src/commands/create.rs` +- Modify: `problemreductions-cli/tests/cli_tests.rs` + +**Step 1: Write failing CLI tests first** + +Add tests covering: +- `test_create_multiple_choice_branching` + - Example command: + +```bash +pred create MultipleChoiceBranching/i32 \ + --arcs "0>1,0>2,1>3,2>3,1>4,3>5,4>5,2>4" \ + --weights 3,2,4,1,2,3,1,3 \ + --partition "0,1;2,3;4,7;5,6" \ + --bound 10 +``` + + - Assert the emitted JSON has: + - `problem_type == "MultipleChoiceBranching"` + - variant `weight=i32` + - the expected graph/weights/partition/threshold payload +- `test_create_multiple_choice_branching_from_example` + - `pred create --example MultipleChoiceBranching/i32` + - Assert it resolves to the canonical example. +- `test_create_multiple_choice_branching_round_trips_into_solve` + - Pipe `pred create ...` into `pred solve - --solver brute-force` + - Assert a satisfying solution is returned. + +**Step 2: Run the CLI test target and verify RED** + +Run: + +```bash +cargo test -p problemreductions-cli test_create_multiple_choice_branching -- --nocapture +``` + +Expected: failure because the CLI does not know the new problem/flag yet. + +**Step 3: Implement the CLI plumbing** + +Update `problemreductions-cli/src/cli.rs`: +- Add `MultipleChoiceBranching` to the help tables and examples. +- Add a new flag: + +```rust +#[arg(long)] +pub partition: Option, +``` + +Update `all_data_flags_empty()` in `problemreductions-cli/src/commands/create.rs` to include `args.partition`. + +Update `problemreductions-cli/src/commands/create.rs`: +- Add help/example text for `MultipleChoiceBranching`. +- Add a `create()` arm for `MultipleChoiceBranching`. +- Parse: + - directed arcs from `--arcs` + - arc weights from `--weights` using the existing integer-weight path + - partition groups from `--partition` as semicolon-separated groups of comma-separated arc indices + - threshold from `--bound` cast to `i32` +- Construct `MultipleChoiceBranching::new(...)`. +- Add a small `parse_partition_groups(...)` helper near the other parsing helpers. + +**Step 4: Re-run the CLI target and verify GREEN** + +Run: + +```bash +cargo test -p problemreductions-cli test_create_multiple_choice_branching -- --nocapture +``` + +Expected: the new CLI tests pass. + +### Task 5: Batch 1 Verification + +**Files:** +- No new files + +**Step 1: Run the implementation verification for Batch 1** + +Run: + +```bash +cargo test multiple_choice_branching --lib +cargo test trait_consistency --lib +cargo test example_db --lib +cargo test -p problemreductions-cli test_create_multiple_choice_branching -- --nocapture +cargo fmt --check +cargo clippy --all-targets --all-features -- -D warnings +``` + +Expected: all pass before Batch 2 starts. + +## Batch 2: Add-Model Step 6 + +### Task 6: Write The Paper Entry And Finalize The Paper Example Test + +**Files:** +- Modify: `docs/paper/reductions.typ` +- Modify: `src/unit_tests/models/graph/multiple_choice_branching.rs` + +**Step 1: Add the paper entry** + +Update `docs/paper/reductions.typ`: +- Add `"MultipleChoiceBranching": [Multiple Choice Branching],` to `display-name` +- Add a `#problem-def("MultipleChoiceBranching")[...][...]` entry near the other graph problems +- Use the canonical YES instance from issue #253 for the example +- Show: + - selected arcs + - total weight `13` + - one-per-group satisfaction + - in-degree-at-most-one + - acyclicity +- Cite the foundational context conservatively: + - Garey & Johnson for NP-completeness/problem statement + - Edmonds/Tarjan only for the special-case branching background +- Avoid making a stronger claim than the issue comments support about Suurballe; phrase it as "as referenced in Garey & Johnson" if retained at all + +**Step 2: Finalize the `paper_example` unit test** + +Add `test_multiple_choice_branching_paper_example` to `src/unit_tests/models/graph/multiple_choice_branching.rs`: +- Rebuild the exact paper example +- Assert `[1,0,1,0,0,1,0,1]` is satisfying +- Use `BruteForce::find_all_satisfying()` to compute the full satisfying set count +- Assert the paper prose matches that computed count exactly instead of hard-coding a guessed number + +**Step 3: Verify the paper entry** + +Run: + +```bash +make paper +cargo test multiple_choice_branching --lib +``` + +Expected: the paper builds and the paper-example test passes. + +### Task 7: Final Verification And Review Handoff + +**Files:** +- No new files + +**Step 1: Run the repo-level verification required before completion** + +Run: + +```bash +make test +make clippy +``` + +If runtime is acceptable, also run: + +```bash +make paper +``` + +**Step 2: Run the implementation completeness review** + +Run the repo-local review step after code is complete: +- `review-implementation` with auto-fixes if it finds structural gaps + +**Step 3: Record PR summary notes** + +The implementation summary comment should explicitly mention: +- new model file + tests +- new CLI `--partition` support +- canonical example/paper alignment +- orphan-model warning: no companion rule issue currently exists From 305f112a9fb25eab2128de46b6b8b4c96a2cd6cc Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Mon, 16 Mar 2026 08:47:12 +0800 Subject: [PATCH 2/5] Implement #253: [Model] MultipleChoiceBranching --- docs/paper/reductions.typ | 41 +++ docs/src/reductions/problem_schemas.json | 26 ++ docs/src/reductions/reduction_graph.json | 61 ++-- problemreductions-cli/src/cli.rs | 5 + problemreductions-cli/src/commands/create.rs | 61 +++- problemreductions-cli/tests/cli_tests.rs | 149 ++++++++++ src/example_db/fixtures/examples.json | 1 + src/lib.rs | 4 +- src/models/graph/mod.rs | 4 + src/models/graph/multiple_choice_branching.rs | 272 ++++++++++++++++++ src/models/mod.rs | 6 +- .../models/graph/multiple_choice_branching.rs | 187 ++++++++++++ src/unit_tests/trait_consistency.rs | 9 + 13 files changed, 794 insertions(+), 32 deletions(-) create mode 100644 src/models/graph/multiple_choice_branching.rs create mode 100644 src/unit_tests/models/graph/multiple_choice_branching.rs diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index b3a286b6..e97a66e8 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -98,6 +98,7 @@ "SubsetSum": [Subset Sum], "MinimumFeedbackArcSet": [Minimum Feedback Arc Set], "MinimumFeedbackVertexSet": [Minimum Feedback Vertex Set], + "MultipleChoiceBranching": [Multiple Choice Branching], "ShortestCommonSupersequence": [Shortest Common Supersequence], "MinimumSumMulticenter": [Minimum Sum Multicenter], "SubgraphIsomorphism": [Subgraph Isomorphism], @@ -1725,6 +1726,46 @@ NP-completeness was established by Garey, Johnson, and Stockmeyer @gareyJohnsonS *Example.* Consider $G$ with $V = {0, 1, 2, 3, 4, 5}$ and arcs $(0 arrow 1), (1 arrow 2), (2 arrow 0), (1 arrow 3), (3 arrow 4), (4 arrow 1), (2 arrow 5), (5 arrow 3), (3 arrow 0)$. This graph contains four directed cycles: $0 arrow 1 arrow 2 arrow 0$, $1 arrow 3 arrow 4 arrow 1$, $0 arrow 1 arrow 3 arrow 0$, and $2 arrow 5 arrow 3 arrow 0 arrow 1 arrow 2$. Removing $A' = {(0 arrow 1), (3 arrow 4)}$ breaks all four cycles (vertex 0 becomes a sink in the residual graph), giving a minimum FAS of size 2. ] +#{ + let x = load-model-example("MultipleChoiceBranching") + let nv = graph-num-vertices(x.instance) + let arcs = x.instance.graph.inner.edges.map(e => (e.at(0), e.at(1))) + let sol = x.samples.at(0) + let chosen = sol.config.enumerate().filter(((i, v)) => v == 1).map(((i, _)) => i) + [ + #problem-def("MultipleChoiceBranching")[ + Given a directed graph $G = (V, A)$, arc weights $w: A -> ZZ^+$, a partition $A_1, A_2, dots, A_m$ of $A$, and a threshold $K in ZZ^+$, determine whether there exists a subset $A' subset.eq A$ with $sum_(a in A') w(a) >= K$ such that every vertex has in-degree at most one in $(V, A')$, the selected subgraph $(V, A')$ is acyclic, and $|A' inter A_i| <= 1$ for every partition group. + ][ + Multiple Choice Branching is the directed-graph problem ND11 in Garey & Johnson @garey1979. The partition constraint turns the polynomial-time maximum branching setting into an NP-complete decision problem: Garey and Johnson note that the problem remains NP-complete even when the digraph is strongly connected and all weights are equal, while the special case in which every partition group has size 1 reduces to ordinary maximum branching and becomes polynomial-time solvable @garey1979. + + A conservative exact algorithm enumerates all $2^{|A|}$ arc subsets and checks the partition, in-degree, acyclicity, and threshold constraints in polynomial time. This is the brute-force search space used by the implementation.#footnote[We use the registry complexity bound $O^*(2^{|A|})$ for the full partitioned problem.] + + *Example.* Consider the digraph on $n = #nv$ vertices with arcs $(0 arrow 1), (0 arrow 2), (1 arrow 3), (2 arrow 3), (1 arrow 4), (3 arrow 5), (4 arrow 5), (2 arrow 4)$, partition groups $A_1 = {(0 arrow 1), (0 arrow 2)}$, $A_2 = {(1 arrow 3), (2 arrow 3)}$, $A_3 = {(1 arrow 4), (2 arrow 4)}$, $A_4 = {(3 arrow 5), (4 arrow 5)}$, and threshold $K = 10$. The highlighted selection $A' = {(0 arrow 1), (1 arrow 3), (2 arrow 4), (3 arrow 5)}$ has total weight $3 + 4 + 3 + 3 = 13 >= 10$, uses exactly one arc from each partition group, and gives in-degrees 1 at vertices $1, 3, 4,$ and $5$. Because every selected arc points strictly left-to-right in the drawing, the selected subgraph is acyclic. The canonical fixture contains #x.optimal.len() satisfying selections for this instance; the figure highlights one of them. + + #figure({ + let verts = ((0, 1.6), (1.3, 2.3), (1.3, 0.9), (3.0, 2.3), (3.0, 0.9), (4.6, 1.6)) + canvas(length: 1cm, { + for (idx, arc) in arcs.enumerate() { + let (u, v) = arc + let selected = chosen.contains(idx) + draw.line( + verts.at(u), + verts.at(v), + stroke: if selected { 2pt + graph-colors.at(0) } else { 0.9pt + luma(180) }, + mark: (end: "straight", scale: if selected { 0.5 } else { 0.4 }), + ) + } + for (k, pos) in verts.enumerate() { + g-node(pos, name: "v" + str(k), label: [$v_#k$]) + } + }) + }, + caption: [Directed graph for Multiple Choice Branching. Blue arcs show the satisfying branching $(0 arrow 1), (1 arrow 3), (2 arrow 4), (3 arrow 5)$ of total weight 13; gray arcs are available but unselected.], + ) + ] + ] +} + #problem-def("FlowShopScheduling")[ Given $m$ processors and a set $J$ of $n$ jobs, where each job $j in J$ consists of $m$ tasks $t_1 [j], t_2 [j], dots, t_m [j]$ with lengths $ell(t_i [j]) in ZZ^+_0$, and a deadline $D in ZZ^+$, determine whether there exists a permutation schedule $pi$ of the jobs such that all jobs complete by time $D$. Each job must be processed on machines $1, 2, dots, m$ in order, and job $j$ cannot start on machine $i+1$ until its task on machine $i$ is completed. ][ diff --git a/docs/src/reductions/problem_schemas.json b/docs/src/reductions/problem_schemas.json index 3a6e9df8..c4c2e35f 100644 --- a/docs/src/reductions/problem_schemas.json +++ b/docs/src/reductions/problem_schemas.json @@ -498,6 +498,32 @@ } ] }, + { + "name": "MultipleChoiceBranching", + "description": "Find a branching with partition constraints and weight at least K", + "fields": [ + { + "name": "graph", + "type_name": "DirectedGraph", + "description": "The directed graph G=(V,A)" + }, + { + "name": "weights", + "type_name": "Vec", + "description": "Arc weights w(a) for each arc a in A" + }, + { + "name": "partition", + "type_name": "Vec>", + "description": "Partition of arc indices; each arc index must appear in exactly one group" + }, + { + "name": "threshold", + "type_name": "W::Sum", + "description": "Weight threshold K" + } + ] + }, { "name": "OptimalLinearArrangement", "description": "Find a vertex ordering on a line with total edge length at most K", diff --git a/docs/src/reductions/reduction_graph.json b/docs/src/reductions/reduction_graph.json index c1334cfb..cd831266 100644 --- a/docs/src/reductions/reduction_graph.json +++ b/docs/src/reductions/reduction_graph.json @@ -413,6 +413,15 @@ "doc_path": "models/graph/struct.MinimumVertexCover.html", "complexity": "1.1996^num_vertices" }, + { + "name": "MultipleChoiceBranching", + "variant": { + "weight": "i32" + }, + "category": "graph", + "doc_path": "models/graph/struct.MultipleChoiceBranching.html", + "complexity": "2^num_arcs" + }, { "name": "OptimalLinearArrangement", "variant": { @@ -549,7 +558,7 @@ }, { "source": 4, - "target": 54, + "target": 55, "overhead": [ { "field": "num_spins", @@ -609,7 +618,7 @@ }, { "source": 12, - "target": 49, + "target": 50, "overhead": [ { "field": "num_vars", @@ -650,7 +659,7 @@ }, { "source": 19, - "target": 49, + "target": 50, "overhead": [ { "field": "num_vars", @@ -676,7 +685,7 @@ }, { "source": 20, - "target": 49, + "target": 50, "overhead": [ { "field": "num_vars", @@ -702,7 +711,7 @@ }, { "source": 21, - "target": 49, + "target": 50, "overhead": [ { "field": "num_vars", @@ -713,7 +722,7 @@ }, { "source": 21, - "target": 56, + "target": 57, "overhead": [ { "field": "num_elements", @@ -724,7 +733,7 @@ }, { "source": 22, - "target": 51, + "target": 52, "overhead": [ { "field": "num_clauses", @@ -743,7 +752,7 @@ }, { "source": 23, - "target": 49, + "target": 50, "overhead": [ { "field": "num_vars", @@ -769,7 +778,7 @@ }, { "source": 25, - "target": 54, + "target": 55, "overhead": [ { "field": "num_spins", @@ -1084,7 +1093,7 @@ }, { "source": 37, - "target": 49, + "target": 50, "overhead": [ { "field": "num_vars", @@ -1199,7 +1208,7 @@ "doc_path": "rules/minimumvertexcover_minimumsetcovering/index.html" }, { - "source": 49, + "source": 50, "target": 12, "overhead": [ { @@ -1214,8 +1223,8 @@ "doc_path": "rules/qubo_ilp/index.html" }, { - "source": 49, - "target": 53, + "source": 50, + "target": 54, "overhead": [ { "field": "num_spins", @@ -1225,7 +1234,7 @@ "doc_path": "rules/spinglass_qubo/index.html" }, { - "source": 51, + "source": 52, "target": 4, "overhead": [ { @@ -1240,7 +1249,7 @@ "doc_path": "rules/sat_circuitsat/index.html" }, { - "source": 51, + "source": 52, "target": 16, "overhead": [ { @@ -1255,7 +1264,7 @@ "doc_path": "rules/sat_coloring/index.html" }, { - "source": 51, + "source": 52, "target": 21, "overhead": [ { @@ -1270,7 +1279,7 @@ "doc_path": "rules/sat_ksat/index.html" }, { - "source": 51, + "source": 52, "target": 30, "overhead": [ { @@ -1285,7 +1294,7 @@ "doc_path": "rules/sat_maximumindependentset/index.html" }, { - "source": 51, + "source": 52, "target": 39, "overhead": [ { @@ -1300,8 +1309,8 @@ "doc_path": "rules/sat_minimumdominatingset/index.html" }, { - "source": 53, - "target": 49, + "source": 54, + "target": 50, "overhead": [ { "field": "num_vars", @@ -1311,7 +1320,7 @@ "doc_path": "rules/spinglass_qubo/index.html" }, { - "source": 54, + "source": 55, "target": 25, "overhead": [ { @@ -1326,8 +1335,8 @@ "doc_path": "rules/spinglass_maxcut/index.html" }, { - "source": 54, - "target": 53, + "source": 55, + "target": 54, "overhead": [ { "field": "num_spins", @@ -1341,7 +1350,7 @@ "doc_path": "rules/spinglass_casts/index.html" }, { - "source": 57, + "source": 58, "target": 12, "overhead": [ { @@ -1356,8 +1365,8 @@ "doc_path": "rules/travelingsalesman_ilp/index.html" }, { - "source": 57, - "target": 49, + "source": 58, + "target": 50, "overhead": [ { "field": "num_vars", diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index c42f69c2..c8c458c5 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -236,6 +236,7 @@ Flags by problem type: CVP --basis, --target-vec [--bounds] OptimalLinearArrangement --graph, --bound RuralPostman (RPP) --graph, --edge-weights, --required-edges, --bound + MultipleChoiceBranching --arcs [--weights] --partition --bound [--num-vertices] SubgraphIsomorphism --graph (host), --pattern (pattern) LCS --strings FAS --arcs [--weights] [--num-vertices] @@ -259,6 +260,7 @@ Examples: pred create MIS --graph 0-1,1-2,2-3 --weights 1,1,1 pred create SAT --num-vars 3 --clauses \"1,2;-1,3\" pred create QUBO --matrix \"1,0.5;0.5,2\" + pred create MultipleChoiceBranching/i32 --arcs \"0>1,0>2,1>3,2>3,1>4,3>5,4>5,2>4\" --weights 3,2,4,1,2,3,1,3 --partition \"0,1;2,3;4,7;5,6\" --bound 10 pred create MIS/KingsSubgraph --positions \"0,0;1,0;1,1;0,1\" pred create MIS/UnitDiskGraph --positions \"0,0;1,0;0.5,0.8\" --radius 1.5 pred create MIS --random --num-vertices 10 --edge-prob 0.3 @@ -343,6 +345,9 @@ pub struct CreateArgs { /// Sets for SetPacking/SetCovering (semicolon-separated, e.g., "0,1;1,2;0,2") #[arg(long)] pub sets: Option, + /// Partition groups for arc-index partitions (semicolon-separated, e.g., "0,1;2,3") + #[arg(long)] + pub partition: Option, /// Universe size for MinimumSetCovering #[arg(long)] pub universe: Option, diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index a1444224..51d7f265 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -6,7 +6,9 @@ use crate::util; use anyhow::{bail, Context, Result}; use problemreductions::export::{ModelExample, ProblemRef, ProblemSide, RuleExample}; use problemreductions::models::algebraic::{ClosestVectorProblem, BMF}; -use problemreductions::models::graph::{GraphPartitioning, HamiltonianPath}; +use problemreductions::models::graph::{ + GraphPartitioning, HamiltonianPath, MultipleChoiceBranching, +}; use problemreductions::models::misc::{ BinPacking, FlowShopScheduling, LongestCommonSubsequence, MinimumTardinessSequencing, PaintShop, ShortestCommonSupersequence, SubsetSum, @@ -43,6 +45,7 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool { && args.capacity.is_none() && args.sequence.is_none() && args.sets.is_none() + && args.partition.is_none() && args.universe.is_none() && args.biedges.is_none() && args.left.is_none() @@ -201,6 +204,7 @@ fn type_format_hint(type_name: &str, graph_type: Option<&str>) -> &'static str { "Vec" => "comma-separated: 1,2,3", "Vec" => "semicolon-separated clauses: \"1,2;-1,3\"", "Vec>" => "semicolon-separated rows: \"1,0.5;0.5,2\"", + "Vec>" => "semicolon-separated groups: \"0,1;2,3\"", "usize" => "integer", "u64" => "integer", "i64" => "integer", @@ -244,6 +248,9 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "RuralPostman" => { "--graph 0-1,1-2,2-3,3-0 --edge-weights 1,1,1,1 --required-edges 0,2 --bound 4" } + "MultipleChoiceBranching" => { + "--arcs \"0>1,0>2,1>3,2>3,1>4,3>5,4>5,2>4\" --weights 3,2,4,1,2,3,1,3 --partition \"0,1;2,3;4,7;5,6\" --bound 10" + } "SubgraphIsomorphism" => "--graph 0-1,1-2,2-0 --pattern 0-1", "SubsetSum" => "--sizes 3,7,1,8,2,4 --target 11", "ShortestCommonSupersequence" => "--strings \"0,1,2;1,2,0\" --bound 4", @@ -470,6 +477,34 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } + // MultipleChoiceBranching + "MultipleChoiceBranching" => { + let arcs_str = args.arcs.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "MultipleChoiceBranching requires --arcs\n\n\ + Usage: pred create MultipleChoiceBranching/i32 --arcs \"0>1,0>2,1>3,2>3,1>4,3>5,4>5,2>4\" --weights 3,2,4,1,2,3,1,3 --partition \"0,1;2,3;4,7;5,6\" --bound 10" + ) + })?; + let (graph, num_arcs) = parse_directed_graph(arcs_str, args.num_vertices)?; + let weights = parse_arc_weights(args, num_arcs)?; + let partition = parse_partition_groups(args)?; + let threshold = args.bound.ok_or_else(|| { + anyhow::anyhow!( + "MultipleChoiceBranching requires --bound\n\n\ + Usage: pred create MultipleChoiceBranching/i32 --arcs \"0>1,0>2,1>3,2>3,1>4,3>5,4>5,2>4\" --weights 3,2,4,1,2,3,1,3 --partition \"0,1;2,3;4,7;5,6\" --bound 10" + ) + })? as i32; + ( + ser(MultipleChoiceBranching::new( + graph, + weights, + partition, + threshold, + ))?, + resolved_variant.clone(), + ) + } + // KColoring "KColoring" => { let (graph, _) = parse_graph(args).map_err(|e| { @@ -1435,6 +1470,30 @@ fn parse_sets(args: &CreateArgs) -> Result>> { .collect() } +/// Parse `--partition` as semicolon-separated groups of comma-separated arc indices. +/// E.g., "0,1;2,3;4,7;5,6" +fn parse_partition_groups(args: &CreateArgs) -> Result>> { + let partition_str = args.partition.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "MultipleChoiceBranching requires --partition (e.g., \"0,1;2,3;4,7;5,6\")" + ) + })?; + + partition_str + .split(';') + .map(|group| { + group.trim() + .split(',') + .map(|s| { + s.trim() + .parse::() + .map_err(|e| anyhow::anyhow!("Invalid partition index: {}", e)) + }) + .collect() + }) + .collect() +} + /// Parse `--weights` for set-based problems (i32), defaulting to all 1s. fn parse_set_weights(args: &CreateArgs, num_sets: usize) -> Result> { match &args.weights { diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index f011bad1..416fbb46 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -686,6 +686,105 @@ fn test_create_sat() { std::fs::remove_file(&output_file).ok(); } +#[test] +fn test_create_multiple_choice_branching() { + let output_file = std::env::temp_dir().join("pred_test_create_mcb.json"); + let output = pred() + .args([ + "-o", + output_file.to_str().unwrap(), + "create", + "MultipleChoiceBranching/i32", + "--arcs", + "0>1,0>2,1>3,2>3,1>4,3>5,4>5,2>4", + "--weights", + "3,2,4,1,2,3,1,3", + "--partition", + "0,1;2,3;4,7;5,6", + "--bound", + "10", + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(output_file.exists()); + + let content = std::fs::read_to_string(&output_file).unwrap(); + let json: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert_eq!(json["type"], "MultipleChoiceBranching"); + assert_eq!(json["variant"]["weight"], "i32"); + assert_eq!(json["data"]["weights"], serde_json::json!([3, 2, 4, 1, 2, 3, 1, 3])); + assert_eq!( + json["data"]["partition"], + serde_json::json!([[0, 1], [2, 3], [4, 7], [5, 6]]) + ); + assert_eq!(json["data"]["threshold"], 10); + + std::fs::remove_file(&output_file).ok(); +} + +#[test] +fn test_create_model_example_multiple_choice_branching() { + let output = pred() + .args(["create", "--example", "MultipleChoiceBranching/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"], "MultipleChoiceBranching"); + assert_eq!(json["variant"]["weight"], "i32"); + assert_eq!(json["data"]["threshold"], 10); + assert_eq!(json["data"]["partition"].as_array().unwrap().len(), 4); +} + +#[test] +fn test_create_model_example_multiple_choice_branching_round_trips_into_solve() { + let path = std::env::temp_dir().join(format!( + "pred_test_model_example_mcb_{}.json", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let create = pred() + .args([ + "create", + "--example", + "MultipleChoiceBranching/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_qubo() { let output_file = std::env::temp_dir().join("pred_test_create_qubo.json"); @@ -2273,6 +2372,56 @@ fn test_create_pipe_to_solve() { ); } +#[test] +fn test_create_multiple_choice_branching_pipe_to_solve() { + let create_out = pred() + .args([ + "create", + "MultipleChoiceBranching/i32", + "--arcs", + "0>1,0>2,1>3,2>3,1>4,3>5,4>5,2>4", + "--weights", + "3,2,4,1,2,3,1,3", + "--partition", + "0,1;2,3;4,7;5,6", + "--bound", + "10", + ]) + .output() + .unwrap(); + assert!( + create_out.status.success(), + "create stderr: {}", + String::from_utf8_lossy(&create_out.stderr) + ); + + use std::io::Write; + let mut child = pred() + .args(["solve", "-", "--solver", "brute-force"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(&create_out.stdout) + .unwrap(); + let solve_result = child.wait_with_output().unwrap(); + assert!( + solve_result.status.success(), + "stderr: {}", + String::from_utf8_lossy(&solve_result.stderr) + ); + let stdout = String::from_utf8(solve_result.stdout).unwrap(); + assert!( + stdout.contains("\"solution\""), + "stdout should contain solution, got: {stdout}" + ); +} + #[test] fn test_create_pipe_to_evaluate() { // pred create MIS --graph 0-1,1-2 | pred evaluate - --config 1,0,1 diff --git a/src/example_db/fixtures/examples.json b/src/example_db/fixtures/examples.json index e48eef82..a41ef170 100644 --- a/src/example_db/fixtures/examples.json +++ b/src/example_db/fixtures/examples.json @@ -24,6 +24,7 @@ {"problem":"MinimumSumMulticenter","variant":{"graph":"SimpleGraph","weight":"i32"},"instance":{"edge_lengths":[1,1,1,1,1,1,1,1],"graph":{"inner":{"edge_property":"undirected","edges":[[0,1,null],[1,2,null],[2,3,null],[3,4,null],[4,5,null],[5,6,null],[0,6,null],[2,5,null]],"node_holes":[],"nodes":[null,null,null,null,null,null,null]}},"k":2,"vertex_weights":[1,1,1,1,1,1,1]},"samples":[{"config":[0,0,1,0,0,1,0],"metric":{"Valid":6}}],"optimal":[{"config":[0,0,0,1,0,0,1],"metric":{"Valid":6}},{"config":[0,0,1,0,0,0,1],"metric":{"Valid":6}},{"config":[0,0,1,0,0,1,0],"metric":{"Valid":6}},{"config":[0,1,0,0,0,1,0],"metric":{"Valid":6}},{"config":[0,1,0,0,1,0,0],"metric":{"Valid":6}},{"config":[1,0,0,0,0,1,0],"metric":{"Valid":6}},{"config":[1,0,0,0,1,0,0],"metric":{"Valid":6}},{"config":[1,0,0,1,0,0,0],"metric":{"Valid":6}},{"config":[1,0,1,0,0,0,0],"metric":{"Valid":6}}]}, {"problem":"MinimumTardinessSequencing","variant":{},"instance":{"deadlines":[2,3,1,4],"num_tasks":4,"precedences":[[0,2]]},"samples":[{"config":[0,0,0,0],"metric":{"Valid":1}}],"optimal":[{"config":[0,0,0,0],"metric":{"Valid":1}},{"config":[0,0,1,0],"metric":{"Valid":1}},{"config":[0,1,0,0],"metric":{"Valid":1}},{"config":[0,2,0,0],"metric":{"Valid":1}},{"config":[1,0,0,0],"metric":{"Valid":1}},{"config":[1,0,1,0],"metric":{"Valid":1}},{"config":[3,0,0,0],"metric":{"Valid":1}}]}, {"problem":"MinimumVertexCover","variant":{"graph":"SimpleGraph","weight":"i32"},"instance":{"graph":{"inner":{"edge_property":"undirected","edges":[[0,1,null],[0,2,null],[1,3,null],[2,3,null],[2,4,null],[3,4,null]],"node_holes":[],"nodes":[null,null,null,null,null]}},"weights":[1,1,1,1,1]},"samples":[{"config":[1,0,0,1,1],"metric":{"Valid":3}}],"optimal":[{"config":[0,1,1,0,1],"metric":{"Valid":3}},{"config":[0,1,1,1,0],"metric":{"Valid":3}},{"config":[1,0,0,1,1],"metric":{"Valid":3}},{"config":[1,0,1,1,0],"metric":{"Valid":3}}]}, + {"problem":"MultipleChoiceBranching","variant":{"weight":"i32"},"instance":{"graph":{"inner":{"edge_property":"directed","edges":[[0,1,null],[0,2,null],[1,3,null],[2,3,null],[1,4,null],[3,5,null],[4,5,null],[2,4,null]],"node_holes":[],"nodes":[null,null,null,null,null,null]}},"partition":[[0,1],[2,3],[4,7],[5,6]],"threshold":10,"weights":[3,2,4,1,2,3,1,3]},"samples":[{"config":[1,0,1,0,0,1,0,1],"metric":true}],"optimal":[{"config":[0,0,1,0,0,1,0,1],"metric":true},{"config":[0,1,1,0,0,0,1,1],"metric":true},{"config":[0,1,1,0,0,1,0,1],"metric":true},{"config":[0,1,1,0,1,1,0,0],"metric":true},{"config":[1,0,0,1,0,1,0,1],"metric":true},{"config":[1,0,1,0,0,0,0,1],"metric":true},{"config":[1,0,1,0,0,0,1,1],"metric":true},{"config":[1,0,1,0,0,1,0,0],"metric":true},{"config":[1,0,1,0,0,1,0,1],"metric":true},{"config":[1,0,1,0,1,0,1,0],"metric":true},{"config":[1,0,1,0,1,1,0,0],"metric":true}]}, {"problem":"PaintShop","variant":{},"instance":{"car_labels":["A","B","C"],"is_first":[true,true,false,true,false,false],"num_cars":3,"sequence_indices":[0,1,0,2,1,2]},"samples":[{"config":[0,0,1],"metric":{"Valid":2}}],"optimal":[{"config":[0,0,1],"metric":{"Valid":2}},{"config":[0,1,1],"metric":{"Valid":2}},{"config":[1,0,0],"metric":{"Valid":2}},{"config":[1,1,0],"metric":{"Valid":2}}]}, {"problem":"PartitionIntoTriangles","variant":{"graph":"SimpleGraph"},"instance":{"graph":{"inner":{"edge_property":"undirected","edges":[[0,1,null],[0,2,null],[1,2,null],[3,4,null],[3,5,null],[4,5,null],[0,3,null]],"node_holes":[],"nodes":[null,null,null,null,null,null]}}},"samples":[{"config":[0,0,0,1,1,1],"metric":true}],"optimal":[{"config":[0,0,0,1,1,1],"metric":true},{"config":[1,1,1,0,0,0],"metric":true}]}, {"problem":"QUBO","variant":{"weight":"f64"},"instance":{"matrix":[[-1.0,2.0,0.0],[0.0,-1.0,2.0],[0.0,0.0,-1.0]],"num_vars":3},"samples":[{"config":[1,0,1],"metric":{"Valid":-2.0}}],"optimal":[{"config":[1,0,1],"metric":{"Valid":-2.0}}]}, diff --git a/src/lib.rs b/src/lib.rs index bceccfe2..80e79bc1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -51,8 +51,8 @@ pub mod prelude { pub use crate::models::graph::{ KColoring, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, MaximumMatching, MinimumDominatingSet, MinimumFeedbackArcSet, MinimumFeedbackVertexSet, - MinimumSumMulticenter, MinimumVertexCover, OptimalLinearArrangement, - PartitionIntoTriangles, RuralPostman, TravelingSalesman, + MinimumSumMulticenter, MultipleChoiceBranching, MinimumVertexCover, + OptimalLinearArrangement, PartitionIntoTriangles, RuralPostman, TravelingSalesman, }; pub use crate::models::misc::{ BinPacking, Factoring, FlowShopScheduling, Knapsack, LongestCommonSubsequence, diff --git a/src/models/graph/mod.rs b/src/models/graph/mod.rs index 7d76de65..970e424b 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -20,6 +20,7 @@ //! - [`OptimalLinearArrangement`]: Optimal linear arrangement (total edge length at most K) //! - [`MinimumFeedbackArcSet`]: Minimum feedback arc set on directed graphs //! - [`MinimumSumMulticenter`]: Min-sum multicenter (p-median) +//! - [`MultipleChoiceBranching`]: Directed branching with partition constraints //! - [`RuralPostman`]: Rural Postman (circuit covering required edges) //! - [`SubgraphIsomorphism`]: Subgraph isomorphism (decision problem) @@ -37,6 +38,7 @@ pub(crate) mod minimum_dominating_set; pub(crate) mod minimum_feedback_arc_set; pub(crate) mod minimum_feedback_vertex_set; pub(crate) mod minimum_sum_multicenter; +pub(crate) mod multiple_choice_branching; pub(crate) mod minimum_vertex_cover; pub(crate) mod optimal_linear_arrangement; pub(crate) mod partition_into_triangles; @@ -59,6 +61,7 @@ pub use minimum_dominating_set::MinimumDominatingSet; pub use minimum_feedback_arc_set::MinimumFeedbackArcSet; pub use minimum_feedback_vertex_set::MinimumFeedbackVertexSet; pub use minimum_sum_multicenter::MinimumSumMulticenter; +pub use multiple_choice_branching::MultipleChoiceBranching; pub use minimum_vertex_cover::MinimumVertexCover; pub use optimal_linear_arrangement::OptimalLinearArrangement; pub use partition_into_triangles::PartitionIntoTriangles; @@ -83,6 +86,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec", description: "Arc weights w(a) for each arc a in A" }, + FieldInfo { name: "partition", type_name: "Vec>", description: "Partition of arc indices; each arc index must appear in exactly one group" }, + FieldInfo { name: "threshold", type_name: "W::Sum", description: "Weight threshold K" }, + ], + } +} + +/// The Multiple Choice Branching problem. +/// +/// Given a directed graph G = (V, A), arc weights w(a), a partition of A into +/// disjoint groups A_1, ..., A_m, and a threshold K, determine whether there +/// exists a subset A' of arcs such that: +/// - the selected arcs have total weight at least K +/// - every vertex has in-degree at most one in the selected subgraph +/// - the selected subgraph is acyclic +/// - at most one arc is selected from each partition group +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MultipleChoiceBranching { + graph: DirectedGraph, + weights: Vec, + partition: Vec>, + threshold: W::Sum, +} + +impl MultipleChoiceBranching { + /// Create a new Multiple Choice Branching instance. + pub fn new( + graph: DirectedGraph, + weights: Vec, + partition: Vec>, + threshold: W::Sum, + ) -> Self { + let num_arcs = graph.num_arcs(); + assert_eq!( + weights.len(), + num_arcs, + "weights length must match graph num_arcs" + ); + validate_partition(&partition, num_arcs); + Self { + graph, + weights, + partition, + threshold, + } + } + + /// Get the underlying directed graph. + pub fn graph(&self) -> &DirectedGraph { + &self.graph + } + + /// Get the arc weights. + pub fn weights(&self) -> &[W] { + &self.weights + } + + /// Replace the arc weights. + pub fn set_weights(&mut self, weights: Vec) { + assert_eq!( + weights.len(), + self.graph.num_arcs(), + "weights length must match graph num_arcs" + ); + self.weights = weights; + } + + /// Check whether this problem uses a non-unit weight type. + pub fn is_weighted(&self) -> bool { + !W::IS_UNIT + } + + /// Get the partition groups. + pub fn partition(&self) -> &[Vec] { + &self.partition + } + + /// Get the threshold K. + pub fn threshold(&self) -> &W::Sum { + &self.threshold + } + + /// 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() + } + + /// Get the number of partition groups. + pub fn num_partition_groups(&self) -> usize { + self.partition.len() + } + + /// Check whether a configuration is a satisfying solution. + pub fn is_valid_solution(&self, config: &[usize]) -> bool { + is_valid_multiple_choice_branching(&self.graph, &self.weights, &self.partition, &self.threshold, config) + } +} + +impl Problem for MultipleChoiceBranching +where + W: WeightElement + crate::variant::VariantParam, +{ + const NAME: &'static str = "MultipleChoiceBranching"; + type Metric = bool; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![W] + } + + fn dims(&self) -> Vec { + vec![2; self.graph.num_arcs()] + } + + fn evaluate(&self, config: &[usize]) -> bool { + is_valid_multiple_choice_branching( + &self.graph, + &self.weights, + &self.partition, + &self.threshold, + config, + ) + } +} + +impl SatisfactionProblem for MultipleChoiceBranching where + W: WeightElement + crate::variant::VariantParam +{ +} + +fn validate_partition(partition: &[Vec], num_arcs: usize) { + let mut seen = vec![false; num_arcs]; + for group in partition { + for &arc_index in group { + assert!( + arc_index < num_arcs, + "partition arc index {} out of range for {} arcs", + arc_index, + num_arcs + ); + assert!( + !seen[arc_index], + "partition arc index {} appears more than once", + arc_index + ); + seen[arc_index] = true; + } + } + assert!( + seen.iter().all(|present| *present), + "partition must cover every arc exactly once" + ); +} + +fn is_valid_multiple_choice_branching( + graph: &DirectedGraph, + weights: &[W], + partition: &[Vec], + threshold: &W::Sum, + config: &[usize], +) -> bool { + if config.len() != graph.num_arcs() { + return false; + } + if config.iter().any(|&value| value >= 2) { + return false; + } + + for group in partition { + if group + .iter() + .filter(|&&arc_index| config[arc_index] == 1) + .count() + > 1 + { + return false; + } + } + + let arcs = graph.arcs(); + let mut in_degree = vec![0usize; graph.num_vertices()]; + for (index, &selected) in config.iter().enumerate() { + if selected == 1 { + let (_, target) = arcs[index]; + in_degree[target] += 1; + if in_degree[target] > 1 { + return false; + } + } + } + + let selected_arcs: Vec = config.iter().map(|&selected| selected == 1).collect(); + if !graph.is_acyclic_subgraph(&selected_arcs) { + return false; + } + + let mut total = W::Sum::zero(); + for (index, &selected) in config.iter().enumerate() { + if selected == 1 { + total += weights[index].to_sum(); + } + } + total >= *threshold +} + +crate::declare_variants! { + default sat MultipleChoiceBranching => "2^num_arcs", +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "multiple_choice_branching_i32", + build: || { + let problem = MultipleChoiceBranching::new( + DirectedGraph::new( + 6, + vec![ + (0, 1), + (0, 2), + (1, 3), + (2, 3), + (1, 4), + (3, 5), + (4, 5), + (2, 4), + ], + ), + vec![3, 2, 4, 1, 2, 3, 1, 3], + vec![vec![0, 1], vec![2, 3], vec![4, 7], vec![5, 6]], + 10, + ); + crate::example_db::specs::satisfaction_example( + problem, + vec![vec![1, 0, 1, 0, 0, 1, 0, 1]], + ) + }, + }] +} + +#[cfg(test)] +#[path = "../../unit_tests/models/graph/multiple_choice_branching.rs"] +mod tests; diff --git a/src/models/mod.rs b/src/models/mod.rs index 8a9da4db..3f7eb3c7 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -14,9 +14,9 @@ pub use formula::{CNFClause, CircuitSAT, KSatisfiability, Satisfiability}; pub use graph::{ BicliqueCover, GraphPartitioning, HamiltonianPath, IsomorphicSpanningTree, KColoring, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, MaximumMatching, MinimumDominatingSet, - MinimumFeedbackArcSet, MinimumFeedbackVertexSet, MinimumSumMulticenter, MinimumVertexCover, - OptimalLinearArrangement, PartitionIntoTriangles, RuralPostman, SpinGlass, SubgraphIsomorphism, - TravelingSalesman, + MinimumFeedbackArcSet, MinimumFeedbackVertexSet, MinimumSumMulticenter, + MultipleChoiceBranching, MinimumVertexCover, OptimalLinearArrangement, + PartitionIntoTriangles, RuralPostman, SpinGlass, SubgraphIsomorphism, TravelingSalesman, }; pub use misc::{ BinPacking, Factoring, FlowShopScheduling, Knapsack, LongestCommonSubsequence, diff --git a/src/unit_tests/models/graph/multiple_choice_branching.rs b/src/unit_tests/models/graph/multiple_choice_branching.rs new file mode 100644 index 00000000..c833a630 --- /dev/null +++ b/src/unit_tests/models/graph/multiple_choice_branching.rs @@ -0,0 +1,187 @@ +use super::*; +use crate::solvers::{BruteForce, Solver}; +use crate::topology::DirectedGraph; +use crate::traits::Problem; + +fn yes_instance() -> MultipleChoiceBranching { + MultipleChoiceBranching::new( + DirectedGraph::new( + 6, + vec![ + (0, 1), + (0, 2), + (1, 3), + (2, 3), + (1, 4), + (3, 5), + (4, 5), + (2, 4), + ], + ), + vec![3, 2, 4, 1, 2, 3, 1, 3], + vec![vec![0, 1], vec![2, 3], vec![4, 7], vec![5, 6]], + 10, + ) +} + +fn no_instance() -> MultipleChoiceBranching { + MultipleChoiceBranching::new( + DirectedGraph::new(3, vec![(0, 1), (1, 2)]), + vec![2, 2], + vec![vec![0], vec![1]], + 5, + ) +} + +#[test] +fn test_multiple_choice_branching_creation_and_accessors() { + let mut problem = yes_instance(); + + assert_eq!(problem.num_vertices(), 6); + assert_eq!(problem.num_arcs(), 8); + assert_eq!(problem.num_partition_groups(), 4); + assert_eq!(problem.dims(), vec![2; 8]); + assert_eq!(problem.graph().arcs().len(), 8); + assert_eq!(problem.weights(), &[3, 2, 4, 1, 2, 3, 1, 3]); + assert_eq!(problem.partition(), &[vec![0, 1], vec![2, 3], vec![4, 7], vec![5, 6]]); + assert_eq!(problem.threshold(), &10); + assert!(problem.is_weighted()); + + problem.set_weights(vec![1; 8]); + assert_eq!(problem.weights(), &[1, 1, 1, 1, 1, 1, 1, 1]); +} + +#[test] +fn test_multiple_choice_branching_rejects_weight_length_mismatch() { + let result = std::panic::catch_unwind(|| { + MultipleChoiceBranching::new( + DirectedGraph::new(2, vec![(0, 1)]), + vec![1, 2], + vec![vec![0]], + 1, + ) + }); + assert!(result.is_err()); +} + +#[test] +fn test_multiple_choice_branching_partition_validation_out_of_range() { + let result = std::panic::catch_unwind(|| { + MultipleChoiceBranching::new( + DirectedGraph::new(2, vec![(0, 1)]), + vec![1], + vec![vec![1]], + 1, + ) + }); + assert!(result.is_err()); +} + +#[test] +fn test_multiple_choice_branching_partition_validation_overlap() { + let result = std::panic::catch_unwind(|| { + MultipleChoiceBranching::new( + DirectedGraph::new(3, vec![(0, 1), (1, 2)]), + vec![1, 1], + vec![vec![0, 1], vec![1]], + 1, + ) + }); + assert!(result.is_err()); +} + +#[test] +fn test_multiple_choice_branching_partition_validation_missing_arc() { + let result = std::panic::catch_unwind(|| { + MultipleChoiceBranching::new( + DirectedGraph::new(3, vec![(0, 1), (1, 2)]), + vec![1, 1], + vec![vec![0]], + 1, + ) + }); + assert!(result.is_err()); +} + +#[test] +fn test_multiple_choice_branching_evaluate_yes_instance() { + let problem = yes_instance(); + assert!(problem.evaluate(&[1, 0, 1, 0, 0, 1, 0, 1])); + assert!(problem.is_valid_solution(&[1, 0, 1, 0, 0, 1, 0, 1])); +} + +#[test] +fn test_multiple_choice_branching_rejects_partition_violation() { + let problem = yes_instance(); + assert!(!problem.evaluate(&[1, 1, 0, 0, 0, 0, 0, 0])); +} + +#[test] +fn test_multiple_choice_branching_rejects_indegree_violation() { + let problem = MultipleChoiceBranching::new( + DirectedGraph::new(3, vec![(0, 2), (1, 2)]), + vec![2, 2], + vec![vec![0], vec![1]], + 1, + ); + assert!(!problem.evaluate(&[1, 1])); +} + +#[test] +fn test_multiple_choice_branching_rejects_cycle_violation() { + let problem = MultipleChoiceBranching::new( + DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)]), + vec![1, 1, 1], + vec![vec![0], vec![1], vec![2]], + 1, + ); + assert!(!problem.evaluate(&[1, 1, 1])); +} + +#[test] +fn test_multiple_choice_branching_rejects_threshold_violation() { + let problem = yes_instance(); + assert!(!problem.evaluate(&[1, 0, 1, 0, 0, 0, 0, 0])); +} + +#[test] +fn test_multiple_choice_branching_solver_issue_examples() { + let yes_problem = yes_instance(); + let solver = BruteForce::new(); + + let solution = solver.find_satisfying(&yes_problem); + assert!(solution.is_some()); + assert!(yes_problem.evaluate(&solution.unwrap())); + + let all_solutions = solver.find_all_satisfying(&yes_problem); + assert!(!all_solutions.is_empty()); + assert!(all_solutions.contains(&vec![1, 0, 1, 0, 0, 1, 0, 1])); + for config in &all_solutions { + assert!(yes_problem.evaluate(config)); + } + + let no_problem = no_instance(); + assert!(solver.find_satisfying(&no_problem).is_none()); +} + +#[test] +fn test_multiple_choice_branching_paper_example() { + let problem = yes_instance(); + let config = vec![1, 0, 1, 0, 0, 1, 0, 1]; + + assert!(problem.evaluate(&config)); + + let all_solutions = BruteForce::new().find_all_satisfying(&problem); + assert_eq!(all_solutions.len(), 11); + assert!(all_solutions.contains(&config)); +} + +#[test] +fn test_multiple_choice_branching_serialization() { + let problem = yes_instance(); + let json = serde_json::to_string(&problem).unwrap(); + let deserialized: MultipleChoiceBranching = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.num_vertices(), 6); + assert_eq!(deserialized.num_arcs(), 8); + assert_eq!(deserialized.threshold(), &10); +} diff --git a/src/unit_tests/trait_consistency.rs b/src/unit_tests/trait_consistency.rs index eee5dc64..a411352b 100644 --- a/src/unit_tests/trait_consistency.rs +++ b/src/unit_tests/trait_consistency.rs @@ -94,6 +94,15 @@ fn test_all_problems_implement_trait_correctly() { ), "MinimumFeedbackArcSet", ); + check_problem_trait( + &MultipleChoiceBranching::new( + DirectedGraph::new(3, vec![(0, 1), (1, 2)]), + vec![1i32; 2], + vec![vec![0], vec![1]], + 1, + ), + "MultipleChoiceBranching", + ); check_problem_trait( &MinimumSumMulticenter::new( SimpleGraph::new(3, vec![(0, 1), (1, 2)]), From ed2bf6930a2426916b3dcfbc5df3d03e5a85b569 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Mon, 16 Mar 2026 08:47:44 +0800 Subject: [PATCH 3/5] chore: remove plan file after implementation --- .../2026-03-16-multiple-choice-branching.md | 371 ------------------ 1 file changed, 371 deletions(-) delete mode 100644 docs/plans/2026-03-16-multiple-choice-branching.md diff --git a/docs/plans/2026-03-16-multiple-choice-branching.md b/docs/plans/2026-03-16-multiple-choice-branching.md deleted file mode 100644 index be8de296..00000000 --- a/docs/plans/2026-03-16-multiple-choice-branching.md +++ /dev/null @@ -1,371 +0,0 @@ -# MultipleChoiceBranching Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Add the `MultipleChoiceBranching` model as a directed-graph satisfaction problem, wire it into the registry/example-db/CLI, and document it in the paper using the issue's YES instance. - -**Architecture:** Implement `MultipleChoiceBranching` as a `DirectedGraph`-backed satisfaction problem with one binary variable per arc. The constructor owns the static invariants (weight count, partition indices in range, partition groups pairwise disjoint, and full coverage of all arcs); `evaluate()` then checks dynamic feasibility on a configuration: binary domain, one selected arc per partition group, in-degree at most one at each vertex, acyclicity of the selected subgraph, and total selected weight at least the threshold. Use the issue's 8-arc YES instance as the canonical model example so tests, CLI examples, and the paper stay aligned. - -**Tech Stack:** Rust, serde, inventory schema registry, `DirectedGraph`, `BruteForce`, `pred create`, Typst paper/example fixtures. - ---- - -## Constraints And Decisions - -- Follow repo-local [`.claude/skills/add-model/SKILL.md`](/Users/jinguomini/rcode/problem-reductions/.worktrees/issue-253-multiple-choice-branching/.claude/skills/add-model/SKILL.md) Steps 1-7. -- Keep the paper work in a separate batch from the implementation work. Do not start Batch 2 until Batch 1 is green. -- Model shape: - - Type: `MultipleChoiceBranching` - - Category: `src/models/graph/` - - Problem kind: satisfaction (`Metric = bool`) - - Variant dimensions: weight only, default `i32` - - Complexity string: `"2^num_arcs"` -- Canonical example: - - Use issue #253 YES instance with arc order `[(0,1), (0,2), (1,3), (2,3), (1,4), (3,5), (4,5), (2,4)]` - - Use satisfying configuration `[1,0,1,0,0,1,0,1]` - - Keep the threshold at `10` -- There is currently no open rule issue referencing `MultipleChoiceBranching`. Treat that as an orphan-model warning to surface in the PR summary/body, but do not block implementation. - -## Batch 1: Add-Model Steps 1-5.5 - -### Task 1: Write The Failing Model Tests First - -**Files:** -- Create: `src/unit_tests/models/graph/multiple_choice_branching.rs` -- Reference: `src/unit_tests/models/graph/hamiltonian_path.rs` -- Reference: `src/unit_tests/models/graph/minimum_feedback_arc_set.rs` -- Reference: `src/models/graph/rural_postman.rs` - -**Step 1: Write failing tests for the model contract** - -Cover these behaviors explicitly: -- `test_multiple_choice_branching_creation_and_accessors` - - Construct the YES instance from issue #253. - - Assert `num_vertices() == 6`, `num_arcs() == 8`, `num_partition_groups() == 4`. - - Assert `dims() == vec![2; 8]`. - - Assert `weights()`, `partition()`, `threshold()`, and `graph()` expose the expected data. -- `test_multiple_choice_branching_partition_validation` - - Constructor should panic for out-of-range arc indices. - - Constructor should panic for overlapping groups. - - Constructor should panic if the partition omits an arc. -- `test_multiple_choice_branching_evaluate_yes_instance` - - Use config `[1,0,1,0,0,1,0,1]`. - - Assert `evaluate()` returns `true`. -- `test_multiple_choice_branching_rejects_constraint_violations` - - One config that violates the partition constraint. - - One config that violates in-degree-at-most-one. - - One config that contains a directed cycle. - - One config that stays feasible but misses the threshold. -- `test_multiple_choice_branching_solver_issue_examples` - - Assert `BruteForce::find_satisfying()` finds a solution on the YES instance. - - Assert `BruteForce::find_satisfying()` returns `None` on a small NO instance. - - Assert every solution returned by `find_all_satisfying()` is accepted by `evaluate()`. -- `test_multiple_choice_branching_serialization` - - Round-trip through serde JSON. - -**Step 2: Run the focused test target and verify RED** - -Run: - -```bash -cargo test multiple_choice_branching --lib -``` - -Expected: compile or link failure because the new model/module does not exist yet. - -**Step 3: Commit nothing yet** - -Do not write production code until the failing tests are in place and the RED run has been observed. - -### Task 2: Implement The Model And Register It - -**Files:** -- Create: `src/models/graph/multiple_choice_branching.rs` -- Modify: `src/models/graph/mod.rs` -- Modify: `src/models/mod.rs` - -**Step 1: Implement the minimal model to satisfy Task 1** - -Model requirements: -- Add `inventory::submit!` `ProblemSchemaEntry` with: - - `name: "MultipleChoiceBranching"` - - `display_name: "Multiple Choice Branching"` - - one variant dimension for `weight` with default `i32` - - fields `graph`, `weights`, `partition`, `threshold` -- Define: - -```rust -pub struct MultipleChoiceBranching { - graph: DirectedGraph, - weights: Vec, - partition: Vec>, - threshold: W::Sum, -} -``` - -- Constructor: - - assert `weights.len() == graph.num_arcs()` - - assert every partition entry is in `0..graph.num_arcs()` - - assert each arc index appears exactly once across all groups -- Accessors/getters: - - `graph()` - - `weights()` - - `set_weights(...)` - - `partition()` - - `threshold()` - - `num_vertices()` - - `num_arcs()` - - `num_partition_groups()` - - `is_weighted()` - - `is_valid_solution(config)` -- `Problem` impl: - - `type Metric = bool` - - `variant() = crate::variant_params![W]` - - `dims() = vec![2; self.graph.num_arcs()]` - - `evaluate()` returns `true` iff: - - config length matches `num_arcs` - - every entry is `0` or `1` - - each partition group contains at most one selected arc - - selected arcs give every vertex in-degree at most one - - selected arcs form an acyclic subgraph via `DirectedGraph::is_acyclic_subgraph` - - selected weight sum is `>= threshold` -- `impl SatisfactionProblem for MultipleChoiceBranching` -- `declare_variants!`: - -```rust -crate::declare_variants! { - default sat MultipleChoiceBranching => "2^num_arcs", -} -``` - -- Add the test link at the bottom. - -**Step 2: Register the new graph module** - -Update: -- `src/models/graph/mod.rs` -- `src/models/mod.rs` - -Required outcomes: -- export `multiple_choice_branching` -- re-export `MultipleChoiceBranching` -- extend `canonical_model_example_specs()` in the graph module once Task 3 adds the example spec - -**Step 3: Run the focused test target and verify GREEN** - -Run: - -```bash -cargo test multiple_choice_branching --lib -``` - -Expected: the model tests compile and pass. - -### Task 3: Add The Canonical Example And Trait Coverage - -**Files:** -- Modify: `src/models/graph/multiple_choice_branching.rs` -- Modify: `src/models/graph/mod.rs` -- Modify: `src/unit_tests/trait_consistency.rs` -- Reference: `src/unit_tests/example_db.rs` - -**Step 1: Add the canonical model example to the model file** - -Inside `#[cfg(feature = "example-db")] canonical_model_example_specs()`: -- Build the YES instance from issue #253 exactly. -- Register one example id: `multiple_choice_branching_i32`. -- Use `crate::example_db::specs::satisfaction_example(problem, vec![vec![1,0,1,0,0,1,0,1]])`. - -**Step 2: Wire the canonical example into the graph example aggregator** - -Extend `src/models/graph/mod.rs` so `canonical_model_example_specs()` includes the new model's specs. - -**Step 3: Add trait-consistency coverage** - -Update `src/unit_tests/trait_consistency.rs`: -- Add a `check_problem_trait(...)` call with a small `MultipleChoiceBranching` instance. -- Do not add a `test_direction` entry because this is not an optimization problem. - -**Step 4: Run the focused tests** - -Run: - -```bash -cargo test trait_consistency --lib -cargo test example_db --lib -``` - -Expected: both pass with the new model included. - -### Task 4: Add CLI Creation Support With TDD - -**Files:** -- Modify: `problemreductions-cli/src/cli.rs` -- Modify: `problemreductions-cli/src/commands/create.rs` -- Modify: `problemreductions-cli/tests/cli_tests.rs` - -**Step 1: Write failing CLI tests first** - -Add tests covering: -- `test_create_multiple_choice_branching` - - Example command: - -```bash -pred create MultipleChoiceBranching/i32 \ - --arcs "0>1,0>2,1>3,2>3,1>4,3>5,4>5,2>4" \ - --weights 3,2,4,1,2,3,1,3 \ - --partition "0,1;2,3;4,7;5,6" \ - --bound 10 -``` - - - Assert the emitted JSON has: - - `problem_type == "MultipleChoiceBranching"` - - variant `weight=i32` - - the expected graph/weights/partition/threshold payload -- `test_create_multiple_choice_branching_from_example` - - `pred create --example MultipleChoiceBranching/i32` - - Assert it resolves to the canonical example. -- `test_create_multiple_choice_branching_round_trips_into_solve` - - Pipe `pred create ...` into `pred solve - --solver brute-force` - - Assert a satisfying solution is returned. - -**Step 2: Run the CLI test target and verify RED** - -Run: - -```bash -cargo test -p problemreductions-cli test_create_multiple_choice_branching -- --nocapture -``` - -Expected: failure because the CLI does not know the new problem/flag yet. - -**Step 3: Implement the CLI plumbing** - -Update `problemreductions-cli/src/cli.rs`: -- Add `MultipleChoiceBranching` to the help tables and examples. -- Add a new flag: - -```rust -#[arg(long)] -pub partition: Option, -``` - -Update `all_data_flags_empty()` in `problemreductions-cli/src/commands/create.rs` to include `args.partition`. - -Update `problemreductions-cli/src/commands/create.rs`: -- Add help/example text for `MultipleChoiceBranching`. -- Add a `create()` arm for `MultipleChoiceBranching`. -- Parse: - - directed arcs from `--arcs` - - arc weights from `--weights` using the existing integer-weight path - - partition groups from `--partition` as semicolon-separated groups of comma-separated arc indices - - threshold from `--bound` cast to `i32` -- Construct `MultipleChoiceBranching::new(...)`. -- Add a small `parse_partition_groups(...)` helper near the other parsing helpers. - -**Step 4: Re-run the CLI target and verify GREEN** - -Run: - -```bash -cargo test -p problemreductions-cli test_create_multiple_choice_branching -- --nocapture -``` - -Expected: the new CLI tests pass. - -### Task 5: Batch 1 Verification - -**Files:** -- No new files - -**Step 1: Run the implementation verification for Batch 1** - -Run: - -```bash -cargo test multiple_choice_branching --lib -cargo test trait_consistency --lib -cargo test example_db --lib -cargo test -p problemreductions-cli test_create_multiple_choice_branching -- --nocapture -cargo fmt --check -cargo clippy --all-targets --all-features -- -D warnings -``` - -Expected: all pass before Batch 2 starts. - -## Batch 2: Add-Model Step 6 - -### Task 6: Write The Paper Entry And Finalize The Paper Example Test - -**Files:** -- Modify: `docs/paper/reductions.typ` -- Modify: `src/unit_tests/models/graph/multiple_choice_branching.rs` - -**Step 1: Add the paper entry** - -Update `docs/paper/reductions.typ`: -- Add `"MultipleChoiceBranching": [Multiple Choice Branching],` to `display-name` -- Add a `#problem-def("MultipleChoiceBranching")[...][...]` entry near the other graph problems -- Use the canonical YES instance from issue #253 for the example -- Show: - - selected arcs - - total weight `13` - - one-per-group satisfaction - - in-degree-at-most-one - - acyclicity -- Cite the foundational context conservatively: - - Garey & Johnson for NP-completeness/problem statement - - Edmonds/Tarjan only for the special-case branching background -- Avoid making a stronger claim than the issue comments support about Suurballe; phrase it as "as referenced in Garey & Johnson" if retained at all - -**Step 2: Finalize the `paper_example` unit test** - -Add `test_multiple_choice_branching_paper_example` to `src/unit_tests/models/graph/multiple_choice_branching.rs`: -- Rebuild the exact paper example -- Assert `[1,0,1,0,0,1,0,1]` is satisfying -- Use `BruteForce::find_all_satisfying()` to compute the full satisfying set count -- Assert the paper prose matches that computed count exactly instead of hard-coding a guessed number - -**Step 3: Verify the paper entry** - -Run: - -```bash -make paper -cargo test multiple_choice_branching --lib -``` - -Expected: the paper builds and the paper-example test passes. - -### Task 7: Final Verification And Review Handoff - -**Files:** -- No new files - -**Step 1: Run the repo-level verification required before completion** - -Run: - -```bash -make test -make clippy -``` - -If runtime is acceptable, also run: - -```bash -make paper -``` - -**Step 2: Run the implementation completeness review** - -Run the repo-local review step after code is complete: -- `review-implementation` with auto-fixes if it finds structural gaps - -**Step 3: Record PR summary notes** - -The implementation summary comment should explicitly mention: -- new model file + tests -- new CLI `--partition` support -- canonical example/paper alignment -- orphan-model warning: no companion rule issue currently exists From cf5688bbd2b667aa2bc219fbd53e2c7499dc2c31 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Mon, 16 Mar 2026 14:34:59 +0800 Subject: [PATCH 4/5] fix: address MultipleChoiceBranching review feedback --- problemreductions-cli/src/commands/create.rs | 72 +++++++++++----- problemreductions-cli/tests/cli_tests.rs | 83 ++++++++++++++++++- src/models/graph/multiple_choice_branching.rs | 35 ++++++-- .../models/graph/multiple_choice_branching.rs | 17 +++- 4 files changed, 174 insertions(+), 33 deletions(-) diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 51d7f265..c6c503b0 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -479,27 +479,17 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { // MultipleChoiceBranching "MultipleChoiceBranching" => { + let usage = "Usage: pred create MultipleChoiceBranching/i32 --arcs \"0>1,0>2,1>3,2>3,1>4,3>5,4>5,2>4\" --weights 3,2,4,1,2,3,1,3 --partition \"0,1;2,3;4,7;5,6\" --bound 10"; let arcs_str = args.arcs.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "MultipleChoiceBranching requires --arcs\n\n\ - Usage: pred create MultipleChoiceBranching/i32 --arcs \"0>1,0>2,1>3,2>3,1>4,3>5,4>5,2>4\" --weights 3,2,4,1,2,3,1,3 --partition \"0,1;2,3;4,7;5,6\" --bound 10" - ) + anyhow::anyhow!("MultipleChoiceBranching requires --arcs\n\n{usage}") })?; let (graph, num_arcs) = parse_directed_graph(arcs_str, args.num_vertices)?; let weights = parse_arc_weights(args, num_arcs)?; - let partition = parse_partition_groups(args)?; - let threshold = args.bound.ok_or_else(|| { - anyhow::anyhow!( - "MultipleChoiceBranching requires --bound\n\n\ - Usage: pred create MultipleChoiceBranching/i32 --arcs \"0>1,0>2,1>3,2>3,1>4,3>5,4>5,2>4\" --weights 3,2,4,1,2,3,1,3 --partition \"0,1;2,3;4,7;5,6\" --bound 10" - ) - })? as i32; + let partition = parse_partition_groups(args, num_arcs)?; + let threshold = parse_multiple_choice_branching_threshold(args, usage)?; ( ser(MultipleChoiceBranching::new( - graph, - weights, - partition, - threshold, + graph, weights, partition, threshold, ))?, resolved_variant.clone(), ) @@ -1472,17 +1462,16 @@ fn parse_sets(args: &CreateArgs) -> Result>> { /// Parse `--partition` as semicolon-separated groups of comma-separated arc indices. /// E.g., "0,1;2,3;4,7;5,6" -fn parse_partition_groups(args: &CreateArgs) -> Result>> { +fn parse_partition_groups(args: &CreateArgs, num_arcs: usize) -> Result>> { let partition_str = args.partition.as_deref().ok_or_else(|| { - anyhow::anyhow!( - "MultipleChoiceBranching requires --partition (e.g., \"0,1;2,3;4,7;5,6\")" - ) + anyhow::anyhow!("MultipleChoiceBranching requires --partition (e.g., \"0,1;2,3;4,7;5,6\")") })?; - partition_str + let partition: Vec> = partition_str .split(';') .map(|group| { - group.trim() + group + .trim() .split(',') .map(|s| { s.trim() @@ -1491,7 +1480,46 @@ fn parse_partition_groups(args: &CreateArgs) -> Result>> { }) .collect() }) - .collect() + .collect::>()?; + + let mut seen = vec![false; num_arcs]; + for group in &partition { + for &arc_index in group { + anyhow::ensure!( + arc_index < num_arcs, + "partition arc index {} out of range for {} arcs", + arc_index, + num_arcs + ); + anyhow::ensure!( + !seen[arc_index], + "partition arc index {} appears more than once", + arc_index + ); + seen[arc_index] = true; + } + } + anyhow::ensure!( + seen.iter().all(|present| *present), + "partition must cover every arc exactly once" + ); + + Ok(partition) +} + +fn parse_multiple_choice_branching_threshold(args: &CreateArgs, usage: &str) -> Result { + let raw_bound = args + .bound + .ok_or_else(|| anyhow::anyhow!("MultipleChoiceBranching requires --bound\n\n{usage}"))?; + anyhow::ensure!( + raw_bound >= 0, + "MultipleChoiceBranching threshold must be non-negative, got {raw_bound}" + ); + i32::try_from(raw_bound).map_err(|_| { + anyhow::anyhow!( + "MultipleChoiceBranching threshold must fit in a 32-bit signed integer, got {raw_bound}" + ) + }) } /// Parse `--weights` for set-based problems (i32), defaulting to all 1s. diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index 416fbb46..c201d1d7 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -717,7 +717,10 @@ fn test_create_multiple_choice_branching() { let json: serde_json::Value = serde_json::from_str(&content).unwrap(); assert_eq!(json["type"], "MultipleChoiceBranching"); assert_eq!(json["variant"]["weight"], "i32"); - assert_eq!(json["data"]["weights"], serde_json::json!([3, 2, 4, 1, 2, 3, 1, 3])); + assert_eq!( + json["data"]["weights"], + serde_json::json!([3, 2, 4, 1, 2, 3, 1, 3]) + ); assert_eq!( json["data"]["partition"], serde_json::json!([[0, 1], [2, 3], [4, 7], [5, 6]]) @@ -785,6 +788,84 @@ fn test_create_model_example_multiple_choice_branching_round_trips_into_solve() std::fs::remove_file(&path).ok(); } +#[test] +fn test_create_multiple_choice_branching_rejects_negative_bound() { + let output = pred() + .args([ + "create", + "MultipleChoiceBranching/i32", + "--arcs", + "0>1,0>2,1>3,2>3,1>4,3>5,4>5,2>4", + "--weights", + "3,2,4,1,2,3,1,3", + "--partition", + "0,1;2,3;4,7;5,6", + "--bound=-1", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!( + stderr.contains("threshold") || stderr.contains("--bound"), + "stderr should mention the invalid threshold: {stderr}" + ); +} + +#[test] +fn test_create_multiple_choice_branching_rejects_overflowing_bound() { + let output = pred() + .args([ + "create", + "MultipleChoiceBranching/i32", + "--arcs", + "0>1,0>2,1>3,2>3,1>4,3>5,4>5,2>4", + "--weights", + "3,2,4,1,2,3,1,3", + "--partition", + "0,1;2,3;4,7;5,6", + "--bound", + "2147483648", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!( + stderr.contains("threshold") || stderr.contains("--bound"), + "stderr should mention the overflowing threshold: {stderr}" + ); +} + +#[test] +fn test_create_multiple_choice_branching_rejects_invalid_partition_without_panicking() { + let output = pred() + .args([ + "create", + "MultipleChoiceBranching/i32", + "--arcs", + "0>1,0>2,1>3,2>3,1>4,3>5,4>5,2>4", + "--weights", + "3,2,4,1,2,3,1,3", + "--partition", + "0,1;2,3;4,7;5,7", + "--bound", + "10", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!( + !stderr.contains("panicked at"), + "invalid partition should return a user error, got panic output: {stderr}" + ); + assert!( + stderr.contains("partition"), + "stderr should mention the invalid partition: {stderr}" + ); +} + #[test] fn test_create_qubo() { let output_file = std::env::temp_dir().join("pred_test_create_qubo.json"); diff --git a/src/models/graph/multiple_choice_branching.rs b/src/models/graph/multiple_choice_branching.rs index 2292d50f..ad06325c 100644 --- a/src/models/graph/multiple_choice_branching.rs +++ b/src/models/graph/multiple_choice_branching.rs @@ -122,7 +122,13 @@ impl MultipleChoiceBranching { /// Check whether a configuration is a satisfying solution. pub fn is_valid_solution(&self, config: &[usize]) -> bool { - is_valid_multiple_choice_branching(&self.graph, &self.weights, &self.partition, &self.threshold, config) + is_valid_multiple_choice_branching( + &self.graph, + &self.weights, + &self.partition, + &self.threshold, + config, + ) } } @@ -208,28 +214,39 @@ fn is_valid_multiple_choice_branching( let arcs = graph.arcs(); let mut in_degree = vec![0usize; graph.num_vertices()]; + let mut selected_successors = vec![Vec::new(); graph.num_vertices()]; + let mut total = W::Sum::zero(); for (index, &selected) in config.iter().enumerate() { if selected == 1 { - let (_, target) = arcs[index]; + let (source, target) = arcs[index]; in_degree[target] += 1; if in_degree[target] > 1 { return false; } + selected_successors[source].push(target); + total += weights[index].to_sum(); } } - let selected_arcs: Vec = config.iter().map(|&selected| selected == 1).collect(); - if !graph.is_acyclic_subgraph(&selected_arcs) { + if total < *threshold { return false; } - let mut total = W::Sum::zero(); - for (index, &selected) in config.iter().enumerate() { - if selected == 1 { - total += weights[index].to_sum(); + let mut queue: Vec = (0..graph.num_vertices()) + .filter(|&vertex| in_degree[vertex] == 0) + .collect(); + let mut visited = 0usize; + while let Some(source) = queue.pop() { + visited += 1; + for &target in &selected_successors[source] { + in_degree[target] -= 1; + if in_degree[target] == 0 { + queue.push(target); + } } } - total >= *threshold + + visited == graph.num_vertices() } crate::declare_variants! { diff --git a/src/unit_tests/models/graph/multiple_choice_branching.rs b/src/unit_tests/models/graph/multiple_choice_branching.rs index c833a630..2570e19e 100644 --- a/src/unit_tests/models/graph/multiple_choice_branching.rs +++ b/src/unit_tests/models/graph/multiple_choice_branching.rs @@ -43,7 +43,10 @@ fn test_multiple_choice_branching_creation_and_accessors() { assert_eq!(problem.dims(), vec![2; 8]); assert_eq!(problem.graph().arcs().len(), 8); assert_eq!(problem.weights(), &[3, 2, 4, 1, 2, 3, 1, 3]); - assert_eq!(problem.partition(), &[vec![0, 1], vec![2, 3], vec![4, 7], vec![5, 6]]); + assert_eq!( + problem.partition(), + &[vec![0, 1], vec![2, 3], vec![4, 7], vec![5, 6]] + ); assert_eq!(problem.threshold(), &10); assert!(problem.is_weighted()); @@ -116,6 +119,18 @@ fn test_multiple_choice_branching_rejects_partition_violation() { assert!(!problem.evaluate(&[1, 1, 0, 0, 0, 0, 0, 0])); } +#[test] +fn test_multiple_choice_branching_rejects_wrong_config_length() { + let problem = yes_instance(); + assert!(!problem.evaluate(&[1, 0, 1])); +} + +#[test] +fn test_multiple_choice_branching_rejects_non_binary_config_value() { + let problem = yes_instance(); + assert!(!problem.evaluate(&[2, 0, 1, 0, 0, 1, 0, 1])); +} + #[test] fn test_multiple_choice_branching_rejects_indegree_violation() { let problem = MultipleChoiceBranching::new( From f6f56b2bdc0f2cf5c30672006a266ea7e536cd43 Mon Sep 17 00:00:00 2001 From: GiggleLiu Date: Mon, 16 Mar 2026 15:02:01 +0800 Subject: [PATCH 5/5] fix: harden MultipleChoiceBranching review follow-ups --- README.md | 4 +- problemreductions-cli/src/cli.rs | 2 +- problemreductions-cli/src/commands/create.rs | 13 +-- problemreductions-cli/tests/cli_tests.rs | 51 ++++++++++++ src/models/graph/multiple_choice_branching.rs | 79 +++++++++++++++---- 5 files changed, 124 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 94d4cb1f..b9a699b0 100644 --- a/README.md +++ b/README.md @@ -32,12 +32,12 @@ Install the `pred` command-line tool for exploring the reduction graph from your cargo install problemreductions-cli ``` -Or build from source: +Or install/update it from source: ```bash git clone https://github.com/CodingThrust/problem-reductions cd problem-reductions -make cli # builds target/release/pred +make cli # installs or updates `pred` in your Cargo bin directory ``` See the [Getting Started](https://codingthrust.github.io/problem-reductions/getting-started.html) guide for usage examples, the reduction workflow, and [CLI usage](https://codingthrust.github.io/problem-reductions/cli.html). diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index c8c458c5..a51bd99f 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -378,7 +378,7 @@ pub struct CreateArgs { /// Required edge indices for RuralPostman (comma-separated, e.g., "0,2,4") #[arg(long)] pub required_edges: Option, - /// Upper bound (for RuralPostman or SCS) + /// Bound / threshold parameter (for RuralPostman, MultipleChoiceBranching, or SCS) #[arg(long)] pub bound: Option, /// Pattern graph edge list for SubgraphIsomorphism (e.g., 0-1,1-2,2-0) diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index c6c503b0..df44feed 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -283,12 +283,13 @@ fn print_problem_help(canonical: &str, graph_type: Option<&str>) -> Result<()> { eprintln!(" --{:<16} {} ({})", "arcs", field.description, hint); } else { let hint = type_format_hint(&field.type_name, graph_type); - eprintln!( - " --{:<16} {} ({})", - field.name.replace('_', "-"), - field.description, - hint - ); + let flag_name = + if canonical == "MultipleChoiceBranching" && field.name == "threshold" { + "bound".to_string() + } else { + field.name.replace('_', "-") + }; + eprintln!(" --{:<16} {} ({})", flag_name, field.description, hint); } } } else { diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index c201d1d7..9d773cfb 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -280,6 +280,39 @@ fn test_evaluate_sat() { std::fs::remove_file(&tmp).ok(); } +#[test] +fn test_evaluate_multiple_choice_branching_rejects_invalid_partition_without_panicking() { + let problem_json = r#"{ + "type": "MultipleChoiceBranching", + "variant": {"weight": "i32"}, + "data": { + "graph": {"inner": {"nodes": [null, null], "node_holes": [], "edge_property": "directed", "edges": [[0,1,null]]}}, + "weights": [1], + "partition": [[1]], + "threshold": 1 + } + }"#; + let tmp = std::env::temp_dir().join("pred_test_eval_invalid_mcb_partition.json"); + std::fs::write(&tmp, problem_json).unwrap(); + + let output = pred() + .args(["evaluate", tmp.to_str().unwrap(), "--config", "1"]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!( + !stderr.contains("panicked at"), + "invalid partition should return a user error, got panic output: {stderr}" + ); + assert!( + stderr.contains("partition"), + "stderr should mention the invalid partition: {stderr}" + ); + + std::fs::remove_file(&tmp).ok(); +} + #[test] fn test_reduce() { let problem_json = r#"{ @@ -1593,6 +1626,24 @@ fn test_create_no_flags_shows_help() { ); } +#[test] +fn test_create_multiple_choice_branching_help_uses_bound_flag() { + let output = pred() + .args(["create", "MultipleChoiceBranching/i32"]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).unwrap(); + assert!( + stderr.contains("--bound"), + "expected '--bound' in help output, got: {stderr}" + ); + assert!( + !stderr.contains("--threshold"), + "help output should not advertise '--threshold', got: {stderr}" + ); +} + #[test] fn test_create_kcoloring_missing_k() { let output = pred() diff --git a/src/models/graph/multiple_choice_branching.rs b/src/models/graph/multiple_choice_branching.rs index ad06325c..9887b70f 100644 --- a/src/models/graph/multiple_choice_branching.rs +++ b/src/models/graph/multiple_choice_branching.rs @@ -9,6 +9,7 @@ use crate::topology::DirectedGraph; use crate::traits::{Problem, SatisfactionProblem}; use crate::types::WeightElement; use num_traits::Zero; +use serde::de::Error as _; use serde::{Deserialize, Serialize}; inventory::submit! { @@ -39,7 +40,7 @@ inventory::submit! { /// - every vertex has in-degree at most one in the selected subgraph /// - the selected subgraph is acyclic /// - at most one arc is selected from each partition group -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize)] pub struct MultipleChoiceBranching { graph: DirectedGraph, weights: Vec, @@ -47,6 +48,44 @@ pub struct MultipleChoiceBranching { threshold: W::Sum, } +#[derive(Debug, Deserialize)] +struct MultipleChoiceBranchingUnchecked { + graph: DirectedGraph, + weights: Vec, + partition: Vec>, + threshold: W::Sum, +} + +impl<'de, W> Deserialize<'de> for MultipleChoiceBranching +where + W: WeightElement + Deserialize<'de>, + W::Sum: Deserialize<'de>, +{ + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let unchecked = MultipleChoiceBranchingUnchecked::::deserialize(deserializer)?; + let num_arcs = unchecked.graph.num_arcs(); + if unchecked.weights.len() != num_arcs { + return Err(D::Error::custom(format!( + "weights length must match graph num_arcs (expected {num_arcs}, got {})", + unchecked.weights.len() + ))); + } + if let Some(message) = partition_validation_error(&unchecked.partition, num_arcs) { + return Err(D::Error::custom(message)); + } + + Ok(Self { + graph: unchecked.graph, + weights: unchecked.weights, + partition: unchecked.partition, + threshold: unchecked.threshold, + }) + } +} + impl MultipleChoiceBranching { /// Create a new Multiple Choice Branching instance. pub fn new( @@ -164,27 +203,35 @@ impl SatisfactionProblem for MultipleChoiceBranching where } fn validate_partition(partition: &[Vec], num_arcs: usize) { + if let Some(message) = partition_validation_error(partition, num_arcs) { + panic!("{message}"); + } +} + +fn partition_validation_error(partition: &[Vec], num_arcs: usize) -> Option { let mut seen = vec![false; num_arcs]; for group in partition { for &arc_index in group { - assert!( - arc_index < num_arcs, - "partition arc index {} out of range for {} arcs", - arc_index, - num_arcs - ); - assert!( - !seen[arc_index], - "partition arc index {} appears more than once", - arc_index - ); + if arc_index >= num_arcs { + return Some(format!( + "partition arc index {} out of range for {} arcs", + arc_index, num_arcs + )); + } + if seen[arc_index] { + return Some(format!( + "partition arc index {} appears more than once", + arc_index + )); + } seen[arc_index] = true; } } - assert!( - seen.iter().all(|present| *present), - "partition must cover every arc exactly once" - ); + if seen.iter().all(|present| *present) { + None + } else { + Some("partition must cover every arc exactly once".to_string()) + } } fn is_valid_multiple_choice_branching(