diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index 09da8adb..ac213588 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -124,6 +124,7 @@ "SequencingWithReleaseTimesAndDeadlines": [Sequencing with Release Times and Deadlines], "ShortestCommonSupersequence": [Shortest Common Supersequence], "MinimumSumMulticenter": [Minimum Sum Multicenter], + "MultipleCopyFileAllocation": [Multiple Copy File Allocation], "SteinerTree": [Steiner Tree], "StrongConnectivityAugmentation": [Strong Connectivity Augmentation], "SubgraphIsomorphism": [Subgraph Isomorphism], @@ -1557,6 +1558,45 @@ A classical NP-complete problem from Garey and Johnson @garey1979[Ch.~3, p.~76], ] } +#{ + let x = load-model-example("MultipleCopyFileAllocation") + let edges = x.instance.graph.edges.map(e => (e.at(0), e.at(1))) + let K = x.instance.bound + let sol = (config: x.optimal_config, metric: x.optimal_value) + let copies = sol.config.enumerate().filter(((i, v)) => v == 1).map(((i, _)) => i) + [ + #problem-def("MultipleCopyFileAllocation")[ + Given a graph $G = (V, E)$, usage values $u: V -> ZZ_(> 0)$, storage costs $s: V -> ZZ_(> 0)$, and a positive integer $K$, determine whether there exists a subset $V' subset.eq V$ such that + $sum_(v in V') s(v) + sum_(v in V) u(v) dot d(v, V') <= K,$ + where $d(v, V') = min_(w in V') d_G(v, w)$ is the shortest-path distance from $v$ to the nearest copy vertex. + ][ + Multiple Copy File Allocation appears in the storage-and-retrieval section of Garey and Johnson (SR6) @garey1979. The model combines two competing costs: each chosen copy vertex incurs a storage charge, while every vertex pays an access cost weighted by its demand and graph distance to the nearest copy. Garey and Johnson record the problem as NP-complete in the strong sense, even when usage and storage costs are uniform @garey1979. + + *Example.* Consider the 6-cycle $C_6$ with uniform usage $u(v) = 10$, uniform storage $s(v) = 1$, and bound $K = #K$. Placing copies at $V' = {#copies.map(i => $v_#i$).join(", ")}$ gives storage cost $1 + 1 + 1 = 3$. The remaining vertices $v_0, v_2, v_4$ are each at distance 1 from the nearest copy, so the access cost is $10 + 10 + 10 = 30$. Thus the total cost is $3 + 30 = 33 <= #K$, so this placement is satisfying. The alternating placement shown below is one symmetric witness. + + #figure({ + let blue = graph-colors.at(0) + let gray = luma(200) + canvas(length: 1cm, { + import draw: * + let verts = ((0, 1.6), (1.35, 0.8), (1.35, -0.8), (0, -1.6), (-1.35, -0.8), (-1.35, 0.8)) + for (u, v) in edges { + g-edge(verts.at(u), verts.at(v), stroke: 1pt + gray) + } + for (k, pos) in verts.enumerate() { + let has-copy = copies.any(c => c == k) + g-node(pos, name: "v" + str(k), + fill: if has-copy { blue } else { white }, + label: if has-copy { text(fill: white)[$v_#k$] } else { [$v_#k$] }) + } + }) + }, + caption: [Multiple Copy File Allocation on a 6-cycle. Copy vertices $v_1$, $v_3$, and $v_5$ are shown in blue; every white vertex is one hop from the nearest copy, so the total cost is $33$.], + ) + ] + ] +} + == Set Problems #{ diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 73bd01fd..b26b0e8e 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -251,6 +251,7 @@ Flags by problem type: BMF --matrix (0/1), --rank ConsecutiveOnesSubmatrix --matrix (0/1), --k SteinerTree --graph, --edge-weights, --terminals + MultipleCopyFileAllocation --graph, --usage, --storage, --bound CVP --basis, --target-vec [--bounds] MultiprocessorScheduling --lengths, --num-processors, --deadline SequencingWithinIntervals --release-times, --deadlines, --lengths @@ -470,7 +471,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 BoundedComponentSpanningForest, LengthBoundedDisjointPaths, LongestCommonSubsequence, MultipleChoiceBranching, OptimalLinearArrangement, RuralPostman, ShortestCommonSupersequence, or StringToStringCorrection) + /// Upper bound or length bound (for BoundedComponentSpanningForest, LengthBoundedDisjointPaths, LongestCommonSubsequence, MultipleCopyFileAllocation, MultipleChoiceBranching, OptimalLinearArrangement, RuralPostman, ShortestCommonSupersequence, or StringToStringCorrection) #[arg(long, allow_hyphen_values = true)] pub bound: Option, /// Pattern graph edge list for SubgraphIsomorphism (e.g., 0-1,1-2,2-0) @@ -509,6 +510,12 @@ pub struct CreateArgs { /// Candidate weighted arcs for StrongConnectivityAugmentation (e.g., 2>0:1,2>1:3) #[arg(long)] pub candidate_arcs: Option, + /// Usage frequencies for MultipleCopyFileAllocation (comma-separated, e.g., "5,4,3,2") + #[arg(long)] + pub usage: Option, + /// Storage costs for MultipleCopyFileAllocation (comma-separated, e.g., "1,1,1,1") + #[arg(long)] + pub storage: Option, /// Deadlines for scheduling problems (comma-separated, e.g., "5,5,5,3,3") #[arg(long)] pub deadlines: Option, diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 132f0e5a..cbd45235 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -32,6 +32,11 @@ use problemreductions::topology::{ use serde::Serialize; use std::collections::{BTreeMap, BTreeSet}; +const MULTIPLE_COPY_FILE_ALLOCATION_EXAMPLE_ARGS: &str = + "--graph 0-1,1-2,2-3 --usage 5,4,3,2 --storage 1,1,1,1 --bound 8"; +const MULTIPLE_COPY_FILE_ALLOCATION_USAGE: &str = + "Usage: pred create MultipleCopyFileAllocation --graph 0-1,1-2,2-3 --usage 5,4,3,2 --storage 1,1,1,1 --bound 8"; + /// Check if all data flags are None (no problem-specific input provided). fn all_data_flags_empty(args: &CreateArgs) -> bool { args.graph.is_none() @@ -89,6 +94,8 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool { && args.strings.is_none() && args.costs.is_none() && args.arcs.is_none() + && args.usage.is_none() + && args.storage.is_none() && args.source.is_none() && args.sink.is_none() && args.size_bound.is_none() @@ -99,6 +106,7 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool { && args.candidate_arcs.is_none() && args.potential_edges.is_none() && args.budget.is_none() + && args.deadlines.is_none() && args.precedence_pairs.is_none() && args.resource_bounds.is_none() && args.resource_requirements.is_none() @@ -402,6 +410,9 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "--schedules \"1,1,1,1,1,0,0;0,1,1,1,1,1,0;0,0,1,1,1,1,1;1,0,0,1,1,1,1;1,1,0,0,1,1,1\" --requirements 2,2,2,3,3,2,1 --num-workers 4 --k 5" } "SteinerTree" => "--graph 0-1,1-2,1-3,3-4 --edge-weights 2,2,1,1 --terminals 0,2,4", + "MultipleCopyFileAllocation" => { + MULTIPLE_COPY_FILE_ALLOCATION_EXAMPLE_ARGS + } "OptimalLinearArrangement" => "--graph 0-1,1-2,2-3 --bound 5", "DirectedTwoCommodityIntegralFlow" => { "--arcs \"0>2,0>3,1>2,1>3,2>4,2>5,3>4,3>5\" --capacities 1,1,1,1,1,1,1,1 --source-1 0 --sink-1 4 --source-2 1 --sink-2 5 --requirement-1 1 --requirement-2 1" @@ -957,6 +968,37 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { (ser(HamiltonianPath::new(graph))?, resolved_variant.clone()) } + // MultipleCopyFileAllocation (graph + usage + storage + bound) + "MultipleCopyFileAllocation" => { + let (graph, num_vertices) = parse_graph(args) + .map_err(|e| anyhow::anyhow!("{e}\n\n{MULTIPLE_COPY_FILE_ALLOCATION_USAGE}"))?; + let usage = parse_vertex_i64_values( + args.usage.as_deref(), + "usage", + num_vertices, + "MultipleCopyFileAllocation", + MULTIPLE_COPY_FILE_ALLOCATION_USAGE, + )?; + let storage = parse_vertex_i64_values( + args.storage.as_deref(), + "storage", + num_vertices, + "MultipleCopyFileAllocation", + MULTIPLE_COPY_FILE_ALLOCATION_USAGE, + )?; + let bound = args.bound.ok_or_else(|| { + anyhow::anyhow!( + "MultipleCopyFileAllocation requires --bound\n\n{MULTIPLE_COPY_FILE_ALLOCATION_USAGE}" + ) + })?; + ( + ser(MultipleCopyFileAllocation::new( + graph, usage, storage, bound, + ))?, + resolved_variant.clone(), + ) + } + // UndirectedTwoCommodityIntegralFlow (graph + capacities + terminals + requirements) "UndirectedTwoCommodityIntegralFlow" => { let usage = "Usage: pred create UndirectedTwoCommodityIntegralFlow --graph 0-2,1-2,2-3 --capacities 1,1,2 --source-1 0 --sink-1 3 --source-2 1 --sink-2 3 --requirement-1 1 --requirement-2 1"; @@ -3105,6 +3147,29 @@ fn parse_vertex_weights(args: &CreateArgs, num_vertices: usize) -> Result, + field_name: &str, + num_vertices: usize, + problem_name: &str, + usage: &str, +) -> Result> { + let raw = + raw.ok_or_else(|| anyhow::anyhow!("{problem_name} requires --{field_name}\n\n{usage}"))?; + let values: Vec = util::parse_comma_list(raw) + .map_err(|e| anyhow::anyhow!("invalid {field_name} list: {e}\n\n{usage}"))?; + if values.len() != num_vertices { + bail!( + "Expected {} {} values but got {}\n\n{}", + num_vertices, + field_name, + values.len(), + usage + ); + } + Ok(values) +} + /// Parse `--terminals` as comma-separated vertex indices. fn parse_terminals(args: &CreateArgs, num_vertices: usize) -> Result> { let s = args @@ -4554,6 +4619,8 @@ mod tests { costs: None, cut_bound: None, size_bound: None, + usage: None, + storage: None, } } diff --git a/problemreductions-cli/tests/cli_tests.rs b/problemreductions-cli/tests/cli_tests.rs index 7140aa58..408f9b3a 100644 --- a/problemreductions-cli/tests/cli_tests.rs +++ b/problemreductions-cli/tests/cli_tests.rs @@ -4724,6 +4724,72 @@ fn test_inspect_undirected_two_commodity_integral_flow_reports_size_fields() { std::fs::remove_file(&result_file).ok(); } +#[test] +fn test_inspect_multiple_copy_file_allocation_reports_size_fields() { + let problem_file = std::env::temp_dir().join("pred_test_mcfa_inspect_in.json"); + let result_file = std::env::temp_dir().join("pred_test_mcfa_inspect_out.json"); + let create_out = pred() + .args([ + "-o", + problem_file.to_str().unwrap(), + "create", + "--example", + "MultipleCopyFileAllocation", + ]) + .output() + .unwrap(); + assert!( + create_out.status.success(), + "stderr: {}", + String::from_utf8_lossy(&create_out.stderr) + ); + + let output = pred() + .args([ + "-o", + result_file.to_str().unwrap(), + "inspect", + problem_file.to_str().unwrap(), + ]) + .output() + .unwrap(); + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(result_file.exists()); + + let content = std::fs::read_to_string(&result_file).unwrap(); + let json: serde_json::Value = serde_json::from_str(&content).unwrap(); + let size_fields: Vec<&str> = json["size_fields"] + .as_array() + .expect("size_fields should be an array") + .iter() + .map(|v| v.as_str().unwrap()) + .collect(); + assert!( + size_fields.contains(&"num_vertices"), + "MultipleCopyFileAllocation size_fields should contain num_vertices, got: {:?}", + size_fields + ); + assert!( + size_fields.contains(&"num_edges"), + "MultipleCopyFileAllocation size_fields should contain num_edges, got: {:?}", + size_fields + ); + let solvers: Vec<&str> = json["solvers"] + .as_array() + .expect("solvers should be an array") + .iter() + .map(|v| v.as_str().unwrap()) + .collect(); + assert_eq!(solvers, vec!["brute-force"]); + + std::fs::remove_file(&problem_file).ok(); + std::fs::remove_file(&result_file).ok(); +} + // ---- Random generation tests ---- #[test] @@ -5151,6 +5217,44 @@ fn test_create_bcnf_rejects_out_of_range_target_attribute_indices() { ); } +#[test] +fn test_create_multiple_copy_file_allocation() { + let output = pred() + .args([ + "create", + "MultipleCopyFileAllocation", + "--graph", + "0-1,1-2,2-3", + "--usage", + "5,4,3,2", + "--storage", + "1,1,1,1", + "--bound", + "8", + ]) + .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"], "MultipleCopyFileAllocation"); + assert_eq!(json["data"]["usage"], serde_json::json!([5, 4, 3, 2])); + assert_eq!(json["data"]["storage"], serde_json::json!([1, 1, 1, 1])); + assert_eq!(json["data"]["bound"], 8); + assert_eq!(json["data"]["graph"]["num_vertices"], 4); + assert_eq!( + json["data"]["graph"]["edges"] + .as_array() + .unwrap() + .len(), + 3 + ); +} + #[test] fn test_create_sequencing_to_minimize_maximum_cumulative_cost() { let output = pred() @@ -5185,6 +5289,31 @@ fn test_create_sequencing_to_minimize_maximum_cumulative_cost() { assert_eq!(json["data"]["bound"], 4); } +#[test] +fn test_create_multiple_copy_file_allocation_no_flags_shows_help() { + let output = pred() + .args(["create", "MultipleCopyFileAllocation"]) + .output() + .unwrap(); + assert!( + !output.status.success(), + "should exit non-zero when showing help without data flags" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("--usage"), + "expected '--usage' in help output, got: {stderr}" + ); + assert!( + stderr.contains("--storage"), + "expected '--storage' in help output, got: {stderr}" + ); + assert!( + stderr.contains("--bound"), + "expected '--bound' in help output, got: {stderr}" + ); +} + #[test] fn test_create_sequencing_to_minimize_maximum_cumulative_cost_no_flags_shows_help() { let output = pred() @@ -5206,6 +5335,35 @@ fn test_create_sequencing_to_minimize_maximum_cumulative_cost_no_flags_shows_hel ); } +#[test] +fn test_create_multiple_copy_file_allocation_rejects_length_mismatch() { + let output = pred() + .args([ + "create", + "MultipleCopyFileAllocation", + "--graph", + "0-1,1-2,2-3", + "--usage", + "5,4", + "--storage", + "1,1,1,1", + "--bound", + "8", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("usage"), + "expected usage-length diagnostic, got: {stderr}" + ); + assert!( + stderr.contains("Usage: pred create MultipleCopyFileAllocation"), + "expected recovery usage hint, got: {stderr}" + ); +} + #[test] fn test_create_sequencing_to_minimize_maximum_cumulative_cost_missing_costs() { let output = pred() @@ -5225,6 +5383,35 @@ fn test_create_sequencing_to_minimize_maximum_cumulative_cost_missing_costs() { ); } +#[test] +fn test_create_multiple_copy_file_allocation_rejects_storage_length_mismatch() { + let output = pred() + .args([ + "create", + "MultipleCopyFileAllocation", + "--graph", + "0-1,1-2,2-3", + "--usage", + "5,4,3,2", + "--storage", + "1,1", + "--bound", + "8", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("storage"), + "expected storage-length diagnostic, got: {stderr}" + ); + assert!( + stderr.contains("Usage: pred create MultipleCopyFileAllocation"), + "expected recovery usage hint, got: {stderr}" + ); +} + #[test] fn test_create_sequencing_to_minimize_maximum_cumulative_cost_bad_precedence() { let output = pred() @@ -5248,6 +5435,35 @@ fn test_create_sequencing_to_minimize_maximum_cumulative_cost_bad_precedence() { ); } +#[test] +fn test_create_multiple_copy_file_allocation_rejects_invalid_usage_values() { + let output = pred() + .args([ + "create", + "MultipleCopyFileAllocation", + "--graph", + "0-1,1-2,2-3", + "--usage", + "5,x,3,2", + "--storage", + "1,1,1,1", + "--bound", + "8", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("invalid usage list"), + "expected usage parse diagnostic, got: {stderr}" + ); + assert!( + stderr.contains("Usage: pred create MultipleCopyFileAllocation"), + "expected recovery usage hint, got: {stderr}" + ); +} + #[test] fn test_create_sequencing_to_minimize_maximum_cumulative_cost_invalid_precedence_pair() { let output = pred() @@ -5337,21 +5553,15 @@ fn test_evaluate_multiprocessor_scheduling_rejects_zero_processors_json() { } #[test] -fn test_solve_multiprocessor_scheduling_default_solver_suggests_brute_force() { - let problem_file = - std::env::temp_dir().join("pred_test_solve_multiprocessor_default_solver.json"); +fn test_solve_multiple_copy_file_allocation_brute_force() { + let problem_file = std::env::temp_dir().join("pred_test_solve_mcfa_bf.json"); let create_out = pred() .args([ "-o", problem_file.to_str().unwrap(), "create", - "MultiprocessorScheduling", - "--lengths", - "4,5,3,2,6", - "--num-processors", - "2", - "--deadline", - "10", + "--example", + "MultipleCopyFileAllocation", ]) .output() .unwrap(); @@ -5362,20 +5572,24 @@ fn test_solve_multiprocessor_scheduling_default_solver_suggests_brute_force() { ); let output = pred() - .args(["solve", problem_file.to_str().unwrap()]) + .args([ + "solve", + problem_file.to_str().unwrap(), + "--solver", + "brute-force", + ]) .output() .unwrap(); assert!( - !output.status.success(), - "stdout: {}", - String::from_utf8_lossy(&output.stdout) + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) ); - let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8(output.stdout).unwrap(); assert!( - stderr.contains("No reduction path from MultiprocessorScheduling to ILP"), - "stderr: {stderr}" + stdout.contains("\"solver\": \"brute-force\""), + "MultipleCopyFileAllocation should solve with brute-force: {stdout}" ); - assert!(stderr.contains("--solver brute-force"), "stderr: {stderr}"); std::fs::remove_file(&problem_file).ok(); } diff --git a/src/lib.rs b/src/lib.rs index 78c8923c..cbcbbb63 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -55,8 +55,8 @@ pub mod prelude { KColoring, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, MaximumMatching, MinimumCutIntoBoundedSets, MinimumDominatingSet, MinimumFeedbackArcSet, MinimumFeedbackVertexSet, MinimumMultiwayCut, MinimumSumMulticenter, MinimumVertexCover, - MultipleChoiceBranching, OptimalLinearArrangement, PartitionIntoPathsOfLength2, - PartitionIntoTriangles, RuralPostman, TravelingSalesman, + MultipleChoiceBranching, MultipleCopyFileAllocation, OptimalLinearArrangement, + PartitionIntoPathsOfLength2, PartitionIntoTriangles, RuralPostman, TravelingSalesman, UndirectedTwoCommodityIntegralFlow, }; pub use crate::models::misc::{ diff --git a/src/models/graph/mod.rs b/src/models/graph/mod.rs index 81c9fd0c..6029a471 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -25,6 +25,7 @@ //! - [`BalancedCompleteBipartiteSubgraph`]: Balanced biclique decision problem //! - [`BiconnectivityAugmentation`]: Biconnectivity augmentation with weighted potential edges //! - [`BoundedComponentSpanningForest`]: Partition vertices into bounded-weight connected components +//! - [`MultipleCopyFileAllocation`]: File-copy placement under storage and access costs //! - [`OptimalLinearArrangement`]: Optimal linear arrangement (total edge length at most K) //! - [`MinimumFeedbackArcSet`]: Minimum feedback arc set on directed graphs //! - [`MinimumSumMulticenter`]: Min-sum multicenter (p-median) @@ -63,6 +64,7 @@ pub(crate) mod minimum_multiway_cut; pub(crate) mod minimum_sum_multicenter; pub(crate) mod minimum_vertex_cover; pub(crate) mod multiple_choice_branching; +pub(crate) mod multiple_copy_file_allocation; pub(crate) mod optimal_linear_arrangement; pub(crate) mod partition_into_paths_of_length_2; pub(crate) mod partition_into_triangles; @@ -100,6 +102,7 @@ pub use minimum_multiway_cut::MinimumMultiwayCut; pub use minimum_sum_multicenter::MinimumSumMulticenter; pub use minimum_vertex_cover::MinimumVertexCover; pub use multiple_choice_branching::MultipleChoiceBranching; +pub use multiple_copy_file_allocation::MultipleCopyFileAllocation; pub use optimal_linear_arrangement::OptimalLinearArrangement; pub use partition_into_paths_of_length_2::PartitionIntoPathsOfLength2; pub use partition_into_triangles::PartitionIntoTriangles; @@ -130,6 +133,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec", description: "Usage frequencies u(v) for each vertex" }, + FieldInfo { name: "storage", type_name: "Vec", description: "Storage costs s(v) for placing a copy at each vertex" }, + FieldInfo { name: "bound", type_name: "i64", description: "Upper bound K on total storage plus access cost" }, + ], + } +} + +inventory::submit! { + ProblemSizeFieldEntry { + name: "MultipleCopyFileAllocation", + fields: &["num_vertices", "num_edges"], + } +} + +/// Multiple Copy File Allocation problem. +/// +/// Given an undirected graph G = (V, E), a usage value u(v) for each vertex, +/// a storage cost s(v) for each vertex, and a bound K, determine whether there +/// exists a subset V' of copy vertices such that: +/// +/// Σ_{v ∈ V'} s(v) + Σ_{v ∈ V} u(v) · d(v, V') ≤ K +/// +/// where d(v, V') is the shortest-path distance from v to the nearest copy in V'. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MultipleCopyFileAllocation { + graph: SimpleGraph, + usage: Vec, + storage: Vec, + bound: i64, +} + +impl MultipleCopyFileAllocation { + /// Create a new Multiple Copy File Allocation instance. + pub fn new(graph: SimpleGraph, usage: Vec, storage: Vec, bound: i64) -> Self { + assert_eq!( + usage.len(), + graph.num_vertices(), + "usage length must match graph num_vertices" + ); + assert_eq!( + storage.len(), + graph.num_vertices(), + "storage length must match graph num_vertices" + ); + Self { + graph, + usage, + storage, + bound, + } + } + + /// Get a reference to the underlying graph. + pub fn graph(&self) -> &SimpleGraph { + &self.graph + } + + /// Get the usage values. + pub fn usage(&self) -> &[i64] { + &self.usage + } + + /// Get the storage costs. + pub fn storage(&self) -> &[i64] { + &self.storage + } + + /// Get the bound K. + pub fn bound(&self) -> i64 { + self.bound + } + + /// Get the number of vertices. + pub fn num_vertices(&self) -> usize { + self.graph.num_vertices() + } + + /// Get the number of edges. + pub fn num_edges(&self) -> usize { + self.graph.num_edges() + } + + fn selected_vertices(&self, config: &[usize]) -> Option> { + if config.len() != self.graph.num_vertices() { + return None; + } + + let mut selected = Vec::new(); + for (vertex, &value) in config.iter().enumerate() { + match value { + 0 => {} + 1 => selected.push(vertex), + _ => return None, + } + } + + if selected.is_empty() { + None + } else { + Some(selected) + } + } + + fn shortest_distances(&self, selected: &[usize]) -> Option> { + let n = self.graph.num_vertices(); + let mut distances = vec![usize::MAX; n]; + let mut queue = VecDeque::new(); + + for &vertex in selected { + distances[vertex] = 0; + queue.push_back(vertex); + } + + while let Some(vertex) = queue.pop_front() { + let next_distance = distances[vertex] + 1; + for neighbor in self.graph.neighbors(vertex) { + if distances[neighbor] == usize::MAX { + distances[neighbor] = next_distance; + queue.push_back(neighbor); + } + } + } + + if distances.contains(&usize::MAX) { + None + } else { + Some(distances) + } + } + + /// Compute the total storage plus access cost for a configuration. + /// + /// Returns `None` if the configuration is not binary, has the wrong length, + /// selects no copy vertices, or leaves some vertex unreachable from every copy. + pub fn total_cost(&self, config: &[usize]) -> Option { + let selected = self.selected_vertices(config)?; + let distances = self.shortest_distances(&selected)?; + + let storage_cost = selected + .into_iter() + .map(|vertex| self.storage[vertex]) + .sum::(); + let access_cost = distances + .into_iter() + .enumerate() + .map(|(vertex, distance)| self.usage[vertex] * distance as i64) + .sum::(); + + Some(storage_cost + access_cost) + } + + /// Check whether a configuration satisfies the bound. + pub fn is_valid_solution(&self, config: &[usize]) -> bool { + self.total_cost(config) + .is_some_and(|cost| cost <= self.bound) + } +} + +impl Problem for MultipleCopyFileAllocation { + const NAME: &'static str = "MultipleCopyFileAllocation"; + type Metric = bool; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![] + } + + fn dims(&self) -> Vec { + vec![2; self.graph.num_vertices()] + } + + fn evaluate(&self, config: &[usize]) -> bool { + self.is_valid_solution(config) + } +} + +impl SatisfactionProblem for MultipleCopyFileAllocation {} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "multiple_copy_file_allocation", + instance: Box::new(MultipleCopyFileAllocation::new( + SimpleGraph::cycle(6), + vec![10; 6], + vec![1; 6], + 33, + )), + optimal_config: vec![0, 1, 0, 1, 0, 1], + optimal_value: serde_json::json!(true), + }] +} + +crate::declare_variants! { + default sat MultipleCopyFileAllocation => "2^num_vertices", +} + +#[cfg(test)] +#[path = "../../unit_tests/models/graph/multiple_copy_file_allocation.rs"] +mod tests; diff --git a/src/models/mod.rs b/src/models/mod.rs index 7e4b11e9..36a0b6e1 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -20,9 +20,9 @@ pub use graph::{ KthBestSpanningTree, LengthBoundedDisjointPaths, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, MaximumMatching, MinimumCutIntoBoundedSets, MinimumDominatingSet, MinimumFeedbackArcSet, MinimumFeedbackVertexSet, MinimumMultiwayCut, MinimumSumMulticenter, - MinimumVertexCover, MultipleChoiceBranching, OptimalLinearArrangement, - PartitionIntoPathsOfLength2, PartitionIntoTriangles, RuralPostman, SpinGlass, SteinerTree, - StrongConnectivityAugmentation, SubgraphIsomorphism, TravelingSalesman, + MinimumVertexCover, MultipleChoiceBranching, MultipleCopyFileAllocation, + OptimalLinearArrangement, PartitionIntoPathsOfLength2, PartitionIntoTriangles, RuralPostman, + SpinGlass, SteinerTree, StrongConnectivityAugmentation, SubgraphIsomorphism, TravelingSalesman, UndirectedTwoCommodityIntegralFlow, }; pub use misc::PartiallyOrderedKnapsack; diff --git a/src/unit_tests/models/graph/multiple_copy_file_allocation.rs b/src/unit_tests/models/graph/multiple_copy_file_allocation.rs new file mode 100644 index 00000000..56a9c7b7 --- /dev/null +++ b/src/unit_tests/models/graph/multiple_copy_file_allocation.rs @@ -0,0 +1,127 @@ +use super::*; +use crate::solvers::{BruteForce, Solver}; +use crate::topology::SimpleGraph; +use crate::traits::Problem; + +fn cycle_yes_instance() -> MultipleCopyFileAllocation { + let graph = SimpleGraph::new(6, vec![(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 0)]); + MultipleCopyFileAllocation::new(graph, vec![10; 6], vec![1; 6], 33) +} + +fn cycle_no_instance() -> MultipleCopyFileAllocation { + let graph = SimpleGraph::new(6, vec![(0, 1), (1, 2), (2, 3), (3, 4), (4, 5)]); + MultipleCopyFileAllocation::new(graph, vec![100; 6], vec![1; 6], 5) +} + +#[test] +fn test_multiple_copy_file_allocation_creation() { + let problem = cycle_yes_instance(); + assert_eq!(problem.graph().num_vertices(), 6); + assert_eq!(problem.graph().num_edges(), 6); + assert_eq!(problem.num_vertices(), 6); + assert_eq!(problem.num_edges(), 6); + assert_eq!(problem.usage(), &[10; 6]); + assert_eq!(problem.storage(), &[1; 6]); + assert_eq!(problem.bound(), 33); + assert_eq!(problem.dims(), vec![2; 6]); + assert!(MultipleCopyFileAllocation::variant().is_empty()); +} + +#[test] +fn test_multiple_copy_file_allocation_total_cost_and_validity() { + let problem = cycle_yes_instance(); + let config = vec![0, 1, 0, 1, 0, 1]; + + assert_eq!(problem.total_cost(&config), Some(33)); + assert!(problem.is_valid_solution(&config)); + assert!(problem.evaluate(&config)); +} + +#[test] +fn test_multiple_copy_file_allocation_uses_per_vertex_costs() { + let problem = MultipleCopyFileAllocation::new( + SimpleGraph::new(4, vec![(0, 1), (1, 2), (2, 3)]), + vec![1, 10, 100, 1000], + vec![3, 5, 7, 11], + 1020, + ); + let config = vec![1, 0, 1, 0]; + + assert_eq!(problem.total_cost(&config), Some(1020)); + assert!(problem.is_valid_solution(&config)); + assert!(problem.evaluate(&config)); +} + +#[test] +fn test_multiple_copy_file_allocation_invalid_configs() { + let problem = cycle_yes_instance(); + + assert_eq!(problem.total_cost(&[]), None); + assert!(!problem.evaluate(&[])); + + assert_eq!(problem.total_cost(&[0, 1, 2, 1, 0, 1]), None); + assert!(!problem.evaluate(&[0, 1, 2, 1, 0, 1])); + + assert_eq!(problem.total_cost(&[0, 0, 0, 0, 0, 0]), None); + assert!(!problem.evaluate(&[0, 0, 0, 0, 0, 0])); +} + +#[test] +fn test_multiple_copy_file_allocation_unreachable_component_is_invalid() { + let graph = SimpleGraph::new(4, vec![(0, 1), (2, 3)]); + let problem = MultipleCopyFileAllocation::new(graph, vec![5; 4], vec![1; 4], 100); + let config = vec![1, 0, 0, 0]; + + assert_eq!(problem.total_cost(&config), None); + assert!(!problem.is_valid_solution(&config)); + assert!(!problem.evaluate(&config)); +} + +#[test] +fn test_multiple_copy_file_allocation_cost_above_bound_is_invalid() { + let problem = + MultipleCopyFileAllocation::new(SimpleGraph::cycle(6), vec![10; 6], vec![1; 6], 32); + let config = vec![0, 1, 0, 1, 0, 1]; + + assert_eq!(problem.total_cost(&config), Some(33)); + assert!(!problem.is_valid_solution(&config)); + assert!(!problem.evaluate(&config)); +} + +#[test] +fn test_multiple_copy_file_allocation_solver_yes_and_no() { + let yes_problem = cycle_yes_instance(); + let no_problem = cycle_no_instance(); + let solver = BruteForce::new(); + + let solution = solver.find_satisfying(&yes_problem).unwrap(); + assert!(yes_problem.evaluate(&solution)); + assert!(solver.find_satisfying(&no_problem).is_none()); +} + +#[test] +fn test_multiple_copy_file_allocation_serialization() { + let problem = cycle_yes_instance(); + let json = serde_json::to_string(&problem).unwrap(); + let restored: MultipleCopyFileAllocation = serde_json::from_str(&json).unwrap(); + + assert_eq!(restored.graph().num_vertices(), 6); + assert_eq!(restored.usage(), &[10; 6]); + assert_eq!(restored.storage(), &[1; 6]); + assert_eq!(restored.bound(), 33); + assert_eq!(restored.total_cost(&[0, 1, 0, 1, 0, 1]), Some(33)); +} + +#[test] +fn test_multiple_copy_file_allocation_paper_example() { + let problem = cycle_yes_instance(); + let config = vec![0, 1, 0, 1, 0, 1]; + + assert!(problem.evaluate(&config)); + assert_eq!(problem.total_cost(&config), Some(33)); + + let solver = BruteForce::new(); + let all = solver.find_all_satisfying(&problem); + assert_eq!(all.len(), 36); + assert!(all.iter().any(|candidate| candidate == &config)); +} diff --git a/src/unit_tests/trait_consistency.rs b/src/unit_tests/trait_consistency.rs index 6bdca790..e77004e9 100644 --- a/src/unit_tests/trait_consistency.rs +++ b/src/unit_tests/trait_consistency.rs @@ -124,6 +124,38 @@ fn test_all_problems_implement_trait_correctly() { &HamiltonianPath::new(SimpleGraph::new(3, vec![(0, 1), (1, 2)])), "HamiltonianPath", ); + check_problem_trait( + &MultipleCopyFileAllocation::new( + SimpleGraph::new(3, vec![(0, 1), (1, 2)]), + vec![1; 3], + vec![1; 3], + 3, + ), + "MultipleCopyFileAllocation", + ); + check_problem_trait( + &UndirectedTwoCommodityIntegralFlow::new( + SimpleGraph::new(4, vec![(0, 2), (1, 2), (2, 3)]), + vec![1, 1, 2], + 0, + 3, + 1, + 3, + 1, + 1, + ), + "UndirectedTwoCommodityIntegralFlow", + ); + check_problem_trait( + &LengthBoundedDisjointPaths::new( + SimpleGraph::new(4, vec![(0, 1), (1, 3), (0, 2), (2, 3)]), + 0, + 3, + 2, + 2, + ), + "LengthBoundedDisjointPaths", + ); check_problem_trait( &OptimalLinearArrangement::new(SimpleGraph::new(3, vec![(0, 1), (1, 2)]), 3), "OptimalLinearArrangement",