diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 76cddb44..4910b9b4 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -101,6 +101,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], "SteinerTree": [Steiner Tree], @@ -1945,6 +1946,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 a69a1275..22e28072 100644 --- a/docs/src/reductions/problem_schemas.json +++ b/docs/src/reductions/problem_schemas.json @@ -575,6 +575,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 5429b350..d59141c1 100644 --- a/docs/src/reductions/reduction_graph.json +++ b/docs/src/reductions/reduction_graph.json @@ -429,6 +429,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": { @@ -599,7 +608,7 @@ }, { "source": 4, - "target": 57, + "target": 58, "overhead": [ { "field": "num_spins", @@ -659,7 +668,7 @@ }, { "source": 13, - "target": 51, + "target": 52, "overhead": [ { "field": "num_vars", @@ -700,7 +709,7 @@ }, { "source": 20, - "target": 51, + "target": 52, "overhead": [ { "field": "num_vars", @@ -726,7 +735,7 @@ }, { "source": 21, - "target": 51, + "target": 52, "overhead": [ { "field": "num_vars", @@ -752,7 +761,7 @@ }, { "source": 22, - "target": 51, + "target": 52, "overhead": [ { "field": "num_vars", @@ -763,7 +772,7 @@ }, { "source": 22, - "target": 61, + "target": 62, "overhead": [ { "field": "num_elements", @@ -774,7 +783,7 @@ }, { "source": 23, - "target": 53, + "target": 54, "overhead": [ { "field": "num_clauses", @@ -793,7 +802,7 @@ }, { "source": 24, - "target": 51, + "target": 52, "overhead": [ { "field": "num_vars", @@ -819,7 +828,7 @@ }, { "source": 27, - "target": 57, + "target": 58, "overhead": [ { "field": "num_spins", @@ -1134,7 +1143,7 @@ }, { "source": 39, - "target": 51, + "target": 52, "overhead": [ { "field": "num_vars", @@ -1249,7 +1258,7 @@ "doc_path": "rules/minimumvertexcover_minimumsetcovering/index.html" }, { - "source": 51, + "source": 52, "target": 13, "overhead": [ { @@ -1264,8 +1273,8 @@ "doc_path": "rules/qubo_ilp/index.html" }, { - "source": 51, - "target": 56, + "source": 52, + "target": 57, "overhead": [ { "field": "num_spins", @@ -1275,7 +1284,7 @@ "doc_path": "rules/spinglass_qubo/index.html" }, { - "source": 53, + "source": 54, "target": 4, "overhead": [ { @@ -1290,7 +1299,7 @@ "doc_path": "rules/sat_circuitsat/index.html" }, { - "source": 53, + "source": 54, "target": 17, "overhead": [ { @@ -1305,7 +1314,7 @@ "doc_path": "rules/sat_coloring/index.html" }, { - "source": 53, + "source": 54, "target": 22, "overhead": [ { @@ -1320,7 +1329,7 @@ "doc_path": "rules/sat_ksat/index.html" }, { - "source": 53, + "source": 54, "target": 32, "overhead": [ { @@ -1335,7 +1344,7 @@ "doc_path": "rules/sat_maximumindependentset/index.html" }, { - "source": 53, + "source": 54, "target": 41, "overhead": [ { @@ -1350,8 +1359,8 @@ "doc_path": "rules/sat_minimumdominatingset/index.html" }, { - "source": 56, - "target": 51, + "source": 57, + "target": 52, "overhead": [ { "field": "num_vars", @@ -1361,7 +1370,7 @@ "doc_path": "rules/spinglass_qubo/index.html" }, { - "source": 57, + "source": 58, "target": 27, "overhead": [ { @@ -1376,8 +1385,8 @@ "doc_path": "rules/spinglass_maxcut/index.html" }, { - "source": 57, - "target": 56, + "source": 58, + "target": 57, "overhead": [ { "field": "num_spins", @@ -1391,7 +1400,7 @@ "doc_path": "rules/spinglass_casts/index.html" }, { - "source": 62, + "source": 63, "target": 13, "overhead": [ { @@ -1406,8 +1415,8 @@ "doc_path": "rules/travelingsalesman_ilp/index.html" }, { - "source": 62, - "target": 51, + "source": 63, + "target": 52, "overhead": [ { "field": "num_vars", diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 32d58428..4f0ff53e 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -240,6 +240,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] @@ -264,6 +265,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 @@ -380,6 +382,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, @@ -413,7 +418,7 @@ pub struct CreateArgs { /// Required edge indices for RuralPostman (comma-separated, e.g., "0,2,4") #[arg(long)] pub required_edges: Option, - /// Upper bound or length bound (for LengthBoundedDisjointPaths, OptimalLinearArrangement, RuralPostman, or SCS) + /// Upper bound or length bound (for LengthBoundedDisjointPaths, MultipleChoiceBranching, OptimalLinearArrangement, RuralPostman, or SCS) #[arg(long, allow_hyphen_values = true)] 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 dc1aaca6..47e5c682 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -7,7 +7,7 @@ use anyhow::{bail, Context, Result}; use problemreductions::export::{ModelExample, ProblemRef, ProblemSide, RuleExample}; use problemreductions::models::algebraic::{ClosestVectorProblem, BMF}; use problemreductions::models::graph::{ - GraphPartitioning, HamiltonianPath, LengthBoundedDisjointPaths, + GraphPartitioning, HamiltonianPath, LengthBoundedDisjointPaths, MultipleChoiceBranching, }; use problemreductions::models::misc::{ BinPacking, FlowShopScheduling, LongestCommonSubsequence, MinimumTardinessSequencing, @@ -55,6 +55,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() @@ -223,6 +224,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", @@ -245,6 +247,7 @@ fn cli_flag_name(field_name: &str) -> String { "edge_lengths" => "edge-weights".to_string(), "num_tasks" => "n".to_string(), "precedences" => "precedence-pairs".to_string(), + "threshold" => "bound".to_string(), _ => field_name.replace('_', "-"), } } @@ -291,6 +294,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", "SetBasis" => "--universe 4 --sets \"0,1;1,2;0,2;0,1,2\" --k 3", @@ -686,6 +692,24 @@ 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}") + })?; + 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, num_arcs)?; + let threshold = parse_multiple_choice_branching_threshold(args, usage)?; + ( + ser(MultipleChoiceBranching::new( + graph, weights, partition, threshold, + ))?, + resolved_variant.clone(), + ) + } + // KColoring "KColoring" => { let (graph, _) = parse_graph(args).map_err(|e| { @@ -1825,6 +1849,68 @@ 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, 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\")") + })?; + + let partition: Vec> = partition_str + .split(';') + .map(|group| { + group + .trim() + .split(',') + .map(|s| { + s.trim() + .parse::() + .map_err(|e| anyhow::anyhow!("Invalid partition index: {}", e)) + }) + .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. 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 8d481fcc..70f6ab83 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -310,6 +310,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_create_undirected_two_commodity_integral_flow() { let output = pred() @@ -1086,6 +1119,186 @@ 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_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"); @@ -1883,6 +2096,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_set_basis_no_flags_uses_actual_cli_flag_names() { let output = pred().args(["create", "SetBasis"]).output().unwrap(); @@ -2884,6 +3115,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 39501490..ea07c1da 100644 --- a/src/example_db/fixtures/examples.json +++ b/src/example_db/fixtures/examples.json @@ -26,6 +26,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 80e37f7f..5a6104be 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -52,8 +52,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, MinimumVertexCover, MultipleChoiceBranching, + OptimalLinearArrangement, PartitionIntoTriangles, RuralPostman, TravelingSalesman, UndirectedTwoCommodityIntegralFlow, }; pub use crate::models::misc::{ diff --git a/src/models/graph/mod.rs b/src/models/graph/mod.rs index ef940c03..a2c1bede 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 //! - [`LengthBoundedDisjointPaths`]: Length-bounded internally disjoint s-t paths //! - [`RuralPostman`]: Rural Postman (circuit covering required edges) //! - [`SteinerTree`]: Minimum-weight tree spanning all required terminals @@ -44,6 +45,7 @@ pub(crate) mod minimum_feedback_arc_set; pub(crate) mod minimum_feedback_vertex_set; pub(crate) mod minimum_sum_multicenter; pub(crate) mod minimum_vertex_cover; +pub(crate) mod multiple_choice_branching; pub(crate) mod optimal_linear_arrangement; pub(crate) mod partition_into_triangles; pub(crate) mod rural_postman; @@ -70,6 +72,7 @@ pub use minimum_feedback_arc_set::MinimumFeedbackArcSet; pub use minimum_feedback_vertex_set::MinimumFeedbackVertexSet; pub use minimum_sum_multicenter::MinimumSumMulticenter; pub use minimum_vertex_cover::MinimumVertexCover; +pub use multiple_choice_branching::MultipleChoiceBranching; pub use optimal_linear_arrangement::OptimalLinearArrangement; pub use partition_into_triangles::PartitionIntoTriangles; pub use rural_postman::RuralPostman; @@ -96,6 +99,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)] +pub struct MultipleChoiceBranching { + graph: DirectedGraph, + weights: Vec, + partition: Vec>, + 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( + 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) { + 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 { + 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; + } + } + if seen.iter().all(|present| *present) { + None + } else { + Some("partition must cover every arc exactly once".to_string()) + } +} + +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()]; + 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 (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(); + } + } + + if total < *threshold { + return false; + } + + 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); + } + } + } + + visited == graph.num_vertices() +} + +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 94614519..5469c3a0 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -16,8 +16,9 @@ pub use graph::{ IsomorphicSpanningTree, KColoring, LengthBoundedDisjointPaths, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, MaximumMatching, MinimumDominatingSet, MinimumFeedbackArcSet, MinimumFeedbackVertexSet, MinimumSumMulticenter, MinimumVertexCover, - OptimalLinearArrangement, PartitionIntoTriangles, RuralPostman, SpinGlass, SteinerTree, - SubgraphIsomorphism, TravelingSalesman, UndirectedTwoCommodityIntegralFlow, + MultipleChoiceBranching, OptimalLinearArrangement, PartitionIntoTriangles, RuralPostman, + SpinGlass, SteinerTree, SubgraphIsomorphism, TravelingSalesman, + UndirectedTwoCommodityIntegralFlow, }; 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..0317948c --- /dev/null +++ b/src/unit_tests/models/graph/multiple_choice_branching.rs @@ -0,0 +1,249 @@ +use super::*; +use crate::solvers::{BruteForce, Solver}; +use crate::topology::DirectedGraph; +use crate::traits::Problem; +use serde_json; + +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_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( + 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); +} + +#[test] +fn test_multiple_choice_branching_deserialize_rejects_weight_length_mismatch() { + let json = r#"{ + "graph": {"inner": {"nodes": [null, null], "node_holes": [], "edge_property": "directed", "edges": [[0, 1, null]]}}, + "weights": [1, 2], + "partition": [[0]], + "threshold": 1 + }"#; + let result: Result, _> = serde_json::from_str(json); + let err = result.unwrap_err().to_string(); + assert!(err.contains("weights length must match"), "got: {err}"); +} + +#[test] +fn test_multiple_choice_branching_deserialize_rejects_invalid_partition() { + let json = r#"{ + "graph": {"inner": {"nodes": [null, null], "node_holes": [], "edge_property": "directed", "edges": [[0, 1, null]]}}, + "weights": [1], + "partition": [[1]], + "threshold": 1 + }"#; + let result: Result, _> = serde_json::from_str(json); + let err = result.unwrap_err().to_string(); + assert!(err.contains("partition"), "got: {err}"); +} + +#[test] +fn test_multiple_choice_branching_set_weights_rejects_wrong_length() { + let result = std::panic::catch_unwind(|| { + let mut problem = MultipleChoiceBranching::new( + DirectedGraph::new(3, vec![(0, 1), (1, 2)]), + vec![1, 1], + vec![vec![0], vec![1]], + 1, + ); + problem.set_weights(vec![1, 2, 3]); + }); + assert!(result.is_err()); +} + +#[test] +fn test_multiple_choice_branching_num_variables() { + let problem = yes_instance(); + assert_eq!(problem.num_variables(), 8); +} diff --git a/src/unit_tests/trait_consistency.rs b/src/unit_tests/trait_consistency.rs index ef0f09f6..11c3a9be 100644 --- a/src/unit_tests/trait_consistency.rs +++ b/src/unit_tests/trait_consistency.rs @@ -106,6 +106,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( &DirectedTwoCommodityIntegralFlow::new( DirectedGraph::new(4, vec![(0, 1), (1, 2), (2, 3)]),