diff --git a/docs/paper/reductions.typ b/docs/paper/reductions.typ index eba813fd8..0bb2b68d5 100644 --- a/docs/paper/reductions.typ +++ b/docs/paper/reductions.typ @@ -141,6 +141,7 @@ "MinMaxMulticenter": [Min-Max Multicenter], "FlowShopScheduling": [Flow Shop Scheduling], "MinimumCutIntoBoundedSets": [Minimum Cut Into Bounded Sets], + "MinimumDummyActivitiesPert": [Minimum Dummy Activities in PERT Networks], "MinimumSumMulticenter": [Minimum Sum Multicenter], "MinimumTardinessSequencing": [Minimum Tardiness Sequencing], "MultipleChoiceBranching": [Multiple Choice Branching], @@ -2126,6 +2127,58 @@ is feasible: each set induces a connected subgraph, the component weights are $2 ] } +#{ + let x = load-model-example("MinimumDummyActivitiesPert") + let nv = x.instance.graph.num_vertices + let arcs = x.instance.graph.arcs + let ne = arcs.len() + let sol = (config: x.optimal_config, metric: x.optimal_value) + let merged = arcs.enumerate().filter(((i, _)) => sol.config.at(i) == 1).map(((i, arc)) => arc) + let dummy = arcs.enumerate().filter(((i, _)) => sol.config.at(i) == 0).map(((i, arc)) => arc) + let opt = sol.metric.Valid + let blue = graph-colors.at(0) + [ + #problem-def("MinimumDummyActivitiesPert")[ + Given a precedence DAG $G = (V, A)$, find an activity-on-arc PERT event network with one real activity arc for each task $v in V$, minimizing the number of dummy activity arcs, such that for every ordered pair of tasks $(u, v)$ there is a path from the finish event of $u$ to the start event of $v$ if and only if $v$ is reachable from $u$ in $G$. + ][ + The decision version of minimum dummy activities appears as ND44 in Garey and Johnson's compendium @garey1979. It arises when an activity-on-node precedence DAG must be converted into an activity-on-arc PERT chart: merging compatible finish/start events removes dummy activities, but an over-aggressive merge creates spurious precedence relations between unrelated tasks. The implementation here enumerates, for each direct precedence arc, whether it is realized as an event merge or left as a dummy activity, so brute-force over the $m = #ne$ direct precedences yields a worst-case bound of $O^*(2^m)$. #footnote[No exact algorithm improving on the direct-precedence merge encoding implemented in the codebase is claimed here.] + + *Example.* Consider the canonical precedence DAG on $n = #nv$ tasks with direct precedences #arcs.map(a => $(v_#(a.at(0)), v_#(a.at(1)))$).join(", "). The optimal encoding merges the predecessor-finish/successor-start pairs #merged.map(a => $(v_#(a.at(0)), v_#(a.at(1)))$).join(", "), so those handoffs need no dummy activity at all. The remaining direct precedences #dummy.map(a => $(v_#(a.at(0)), v_#(a.at(1)))$).join(" and ") still require dummy activities, so the optimum is $#opt$. Both unresolved precedences enter $v_3$, and merging either of them would identify unrelated task completions, creating spurious reachability between the two source tasks. + + #pred-commands( + "pred create --example " + problem-spec(x) + " -o minimum-dummy-activities-pert.json", + "pred solve minimum-dummy-activities-pert.json --solver brute-force", + "pred evaluate minimum-dummy-activities-pert.json --config " + x.optimal_config.map(str).join(","), + ) + + #figure({ + let positions = ((0, 1.0), (0, -0.3), (2.0, 1.3), (2.0, 0.35), (2.0, -0.95), (4.0, 1.3)) + canvas(length: 1cm, { + for (k, pos) in positions.enumerate() { + g-node(pos, name: "v" + str(k), + fill: white, + label: [$v_#k$]) + } + for arc in dummy { + let (u, v) = arc + draw.line("v" + str(u), "v" + str(v), + stroke: (paint: luma(140), thickness: 1pt, dash: "dashed"), + mark: (end: "straight", scale: 0.4)) + } + for arc in merged { + let (u, v) = arc + draw.line("v" + str(u), "v" + str(v), + stroke: 1.7pt + blue, + mark: (end: "straight", scale: 0.45)) + } + }) + }, + caption: [Canonical Minimum Dummy Activities in PERT Networks instance. Blue precedence arcs are encoded by merging the predecessor finish event with the successor start event; dashed gray arcs still require dummy activities. The optimal encoding leaves exactly #opt dummy activities.], + ) + ] + ] +} + #{ let x = load-model-example("MinimumFeedbackVertexSet") let nv = graph-num-vertices(x.instance) diff --git a/problemreductions-cli/src/cli.rs b/problemreductions-cli/src/cli.rs index 4fe588faa..44fa075dc 100644 --- a/problemreductions-cli/src/cli.rs +++ b/problemreductions-cli/src/cli.rs @@ -302,6 +302,7 @@ Flags by problem type: SCS --strings, --bound [--alphabet-size] StringToStringCorrection --source-string, --target-string, --bound [--alphabet-size] D2CIF --arcs, --capacities, --source-1, --sink-1, --source-2, --sink-2, --requirement-1, --requirement-2 + MinimumDummyActivitiesPert --arcs [--num-vertices] CBQ --domain-size, --relations, --conjuncts-spec ILP, CircuitSAT (via reduction only) @@ -331,6 +332,7 @@ Examples: pred create ConsistencyOfDatabaseFrequencyTables --num-objects 6 --attribute-domains \"2,3,2\" --frequency-tables \"0,1:1,1,1|1,1,1;1,2:1,1|0,2|1,1\" --known-values \"0,0,0;3,0,1;1,2,1\" pred create BiconnectivityAugmentation --graph 0-1,1-2,2-3 --potential-edges 0-2:3,0-3:4,1-3:2 --budget 5 pred create FVS --arcs \"0>1,1>2,2>0\" --weights 1,1,1 + pred create MinimumDummyActivitiesPert --arcs \"0>2,0>3,1>3,1>4,2>5\" --num-vertices 6 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 pred create IntegralFlowHomologousArcs --arcs \"0>1,0>2,1>3,2>3,1>4,2>4,3>5,4>5\" --capacities 1,1,1,1,1,1,1,1 --source 0 --sink 5 --requirement 2 --homologous-pairs \"2=5;4=3\" pred create X3C --universe 9 --sets \"0,1,2;0,2,4;3,4,5;3,5,7;6,7,8;1,4,6;2,5,8\" diff --git a/problemreductions-cli/src/commands/create.rs b/problemreductions-cli/src/commands/create.rs index 005fbc713..b631b2a84 100644 --- a/problemreductions-cli/src/commands/create.rs +++ b/problemreductions-cli/src/commands/create.rs @@ -14,8 +14,9 @@ use problemreductions::models::formula::Quantifier; use problemreductions::models::graph::{ DisjointConnectingPaths, GeneralizedHex, GraphPartitioning, HamiltonianCircuit, HamiltonianPath, IntegralFlowBundles, LengthBoundedDisjointPaths, LongestCircuit, LongestPath, - MinimumCutIntoBoundedSets, MinimumMultiwayCut, MixedChinesePostman, MultipleChoiceBranching, - PathConstrainedNetworkFlow, SteinerTree, SteinerTreeInGraphs, StrongConnectivityAugmentation, + MinimumCutIntoBoundedSets, MinimumDummyActivitiesPert, MinimumMultiwayCut, MixedChinesePostman, + MultipleChoiceBranching, PathConstrainedNetworkFlow, SteinerTree, SteinerTreeInGraphs, + StrongConnectivityAugmentation, }; use problemreductions::models::misc::{ AdditionalKey, BinPacking, BoyceCoddNormalFormViolation, CbqRelation, ConjunctiveBooleanQuery, @@ -617,6 +618,7 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str { "--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" } "MinimumFeedbackArcSet" => "--arcs \"0>1,1>2,2>0\"", + "MinimumDummyActivitiesPert" => "--arcs \"0>2,0>3,1>3,1>4,2>5\" --num-vertices 6", "StrongConnectivityAugmentation" => { "--arcs \"0>1,1>2\" --candidate-arcs \"2>0:1\" --bound 1" } @@ -3608,6 +3610,22 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> { ) } + // MinimumDummyActivitiesPert + "MinimumDummyActivitiesPert" => { + let usage = "Usage: pred create MinimumDummyActivitiesPert --arcs \"0>2,0>3,1>3,1>4,2>5\" [--num-vertices N]"; + let arcs_str = args.arcs.as_deref().ok_or_else(|| { + anyhow::anyhow!( + "MinimumDummyActivitiesPert requires --arcs\n\n\ + {usage}" + ) + })?; + let (graph, _) = parse_directed_graph(arcs_str, args.num_vertices)?; + ( + ser(MinimumDummyActivitiesPert::try_new(graph).map_err(|e| anyhow::anyhow!(e))?)?, + resolved_variant.clone(), + ) + } + // MixedChinesePostman "MixedChinesePostman" => { let usage = "Usage: pred create MixedChinesePostman --graph 0-2,1-3,0-4,4-2 --arcs \"0>1,1>2,2>3,3>0\" --edge-weights 2,3,1,2 --arc-costs 2,3,1,4 --bound 24 [--num-vertices N]"; @@ -7166,6 +7184,56 @@ mod tests { assert!(err.contains("--num-vertices (5) is too small for the arcs")); } + #[test] + fn test_create_minimum_dummy_activities_pert_json() { + use crate::dispatch::ProblemJsonOutput; + use problemreductions::models::graph::MinimumDummyActivitiesPert; + + let mut args = empty_args(); + args.problem = Some("MinimumDummyActivitiesPert".to_string()); + args.num_vertices = Some(6); + args.arcs = Some("0>2,0>3,1>3,1>4,2>5".to_string()); + + let output_path = temp_output_path("minimum_dummy_activities_pert"); + let out = OutputConfig { + output: Some(output_path.clone()), + quiet: true, + json: false, + auto_json: false, + }; + + create(&args, &out).unwrap(); + + let json = fs::read_to_string(&output_path).unwrap(); + let created: ProblemJsonOutput = serde_json::from_str(&json).unwrap(); + assert_eq!(created.problem_type, "MinimumDummyActivitiesPert"); + assert!(created.variant.is_empty()); + + let problem: MinimumDummyActivitiesPert = serde_json::from_value(created.data).unwrap(); + assert_eq!(problem.num_vertices(), 6); + assert_eq!(problem.num_arcs(), 5); + + let _ = fs::remove_file(output_path); + } + + #[test] + fn test_create_minimum_dummy_activities_pert_rejects_cycles() { + let mut args = empty_args(); + args.problem = Some("MinimumDummyActivitiesPert".to_string()); + args.num_vertices = Some(3); + args.arcs = Some("0>1,1>2,2>0".to_string()); + + let out = OutputConfig { + output: None, + quiet: true, + json: false, + auto_json: false, + }; + + let err = create(&args, &out).unwrap_err().to_string(); + assert!(err.contains("requires the input graph to be a DAG")); + } + #[test] fn test_create_balanced_complete_bipartite_subgraph() { use crate::dispatch::ProblemJsonOutput; diff --git a/src/lib.rs b/src/lib.rs index 458193d8d..e2d3daa4a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -59,12 +59,12 @@ pub mod prelude { pub use crate::models::graph::{ KColoring, LongestCircuit, MaxCut, MaximalIS, MaximumClique, MaximumIndependentSet, MaximumMatching, MinMaxMulticenter, MinimumCutIntoBoundedSets, MinimumDominatingSet, - MinimumFeedbackArcSet, MinimumFeedbackVertexSet, MinimumMultiwayCut, MinimumSumMulticenter, - MinimumVertexCover, MultipleChoiceBranching, MultipleCopyFileAllocation, - OptimalLinearArrangement, PartitionIntoPathsOfLength2, PartitionIntoTriangles, - PathConstrainedNetworkFlow, RuralPostman, ShortestWeightConstrainedPath, - SteinerTreeInGraphs, TravelingSalesman, UndirectedFlowLowerBounds, - UndirectedTwoCommodityIntegralFlow, + MinimumDummyActivitiesPert, MinimumFeedbackArcSet, MinimumFeedbackVertexSet, + MinimumMultiwayCut, MinimumSumMulticenter, MinimumVertexCover, MultipleChoiceBranching, + MultipleCopyFileAllocation, OptimalLinearArrangement, PartitionIntoPathsOfLength2, + PartitionIntoTriangles, PathConstrainedNetworkFlow, RuralPostman, + ShortestWeightConstrainedPath, SteinerTreeInGraphs, TravelingSalesman, + UndirectedFlowLowerBounds, UndirectedTwoCommodityIntegralFlow, }; pub use crate::models::misc::{ AdditionalKey, BinPacking, BoyceCoddNormalFormViolation, CbqRelation, diff --git a/src/models/graph/minimum_dummy_activities_pert.rs b/src/models/graph/minimum_dummy_activities_pert.rs new file mode 100644 index 000000000..1dff45eac --- /dev/null +++ b/src/models/graph/minimum_dummy_activities_pert.rs @@ -0,0 +1,310 @@ +//! Minimum Dummy Activities in PERT Networks. +//! +//! Given a precedence DAG whose vertices are tasks, select which direct +//! precedence constraints can be represented by merging the predecessor's +//! finish event with the successor's start event. The remaining precedence +//! constraints require dummy activities. A configuration is valid when the +//! resulting event network is acyclic and preserves exactly the same +//! task-to-task reachability relation as the original DAG. + +use crate::registry::{FieldInfo, ProblemSchemaEntry}; +use crate::topology::DirectedGraph; +use crate::traits::{OptimizationProblem, Problem}; +use crate::types::{Direction, SolutionSize}; +use serde::{Deserialize, Deserializer, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; + +inventory::submit! { + ProblemSchemaEntry { + name: "MinimumDummyActivitiesPert", + display_name: "Minimum Dummy Activities in PERT Networks", + aliases: &[], + dimensions: &[], + module_path: module_path!(), + description: "Find a PERT event network for a precedence DAG minimizing dummy activities", + fields: &[ + FieldInfo { + name: "graph", + type_name: "DirectedGraph", + description: "The precedence DAG G=(V,A) whose vertices are tasks and arcs encode direct precedence constraints", + }, + ], + } +} + +/// Minimum Dummy Activities in PERT Networks. +/// +/// For each precedence arc `u -> v`, the configuration chooses one of two +/// encodings: +/// - `1`: merge `u`'s finish event with `v`'s start event +/// - `0`: keep a dummy activity from `u`'s finish event to `v`'s start event +/// +/// A valid configuration must preserve exactly the same reachability relation +/// between task completions and task starts as the original precedence DAG. +#[derive(Debug, Clone, Serialize)] +pub struct MinimumDummyActivitiesPert { + graph: DirectedGraph, +} + +impl MinimumDummyActivitiesPert { + /// Fallible constructor used by CLI validation and deserialization. + pub fn try_new(graph: DirectedGraph) -> Result { + if !graph.is_dag() { + return Err("MinimumDummyActivitiesPert requires the input graph to be a DAG".into()); + } + Ok(Self { graph }) + } + + /// Create a new instance. + /// + /// # Panics + /// + /// Panics if the input graph is not a DAG. + pub fn new(graph: DirectedGraph) -> Self { + Self::try_new(graph).unwrap_or_else(|msg| panic!("{msg}")) + } + + /// Get the precedence DAG. + pub fn graph(&self) -> &DirectedGraph { + &self.graph + } + + /// Get the number of tasks. + pub fn num_vertices(&self) -> usize { + self.graph.num_vertices() + } + + /// Get the number of direct precedence arcs. + pub fn num_arcs(&self) -> usize { + self.graph.num_arcs() + } + + /// Check whether the merge-selection config encodes a valid PERT network. + pub fn is_valid_solution(&self, config: &[usize]) -> bool { + self.evaluate(config).is_valid() + } + + fn precedence_arcs(&self) -> Vec<(usize, usize)> { + self.graph.arcs() + } + + fn build_candidate_network(&self, config: &[usize]) -> Option { + let num_tasks = self.num_vertices(); + let arcs = self.precedence_arcs(); + if config.len() != arcs.len() || config.iter().any(|&bit| bit > 1) { + return None; + } + + let mut uf = UnionFind::new(2 * num_tasks); + for ((u, v), &merge_bit) in arcs.iter().zip(config.iter()) { + if merge_bit == 1 { + uf.union(finish_endpoint(*u), start_endpoint(*v)); + } + } + + let roots: Vec = (0..2 * num_tasks) + .map(|endpoint| uf.find(endpoint)) + .collect(); + let mut root_to_dense = BTreeMap::new(); + for &root in &roots { + let next = root_to_dense.len(); + root_to_dense.entry(root).or_insert(next); + } + + let start_events: Vec = (0..num_tasks) + .map(|task| root_to_dense[&roots[start_endpoint(task)]]) + .collect(); + let finish_events: Vec = (0..num_tasks) + .map(|task| root_to_dense[&roots[finish_endpoint(task)]]) + .collect(); + + if start_events + .iter() + .zip(finish_events.iter()) + .any(|(start, finish)| start == finish) + { + return None; + } + + let task_arcs: Vec<(usize, usize)> = (0..num_tasks) + .map(|task| (start_events[task], finish_events[task])) + .collect(); + + let dummy_arcs: BTreeSet<(usize, usize)> = arcs + .iter() + .zip(config.iter()) + .filter_map(|((u, v), &merge_bit)| { + if merge_bit == 1 { + return None; + } + let source = finish_events[*u]; + let target = start_events[*v]; + (source != target).then_some((source, target)) + }) + .collect(); + + let task_arc_set: BTreeSet<(usize, usize)> = task_arcs.iter().copied().collect(); + let num_dummy_arcs = dummy_arcs.difference(&task_arc_set).count(); + + let mut event_arcs = task_arcs; + event_arcs.extend(dummy_arcs.iter().copied()); + let event_graph = DirectedGraph::new(root_to_dense.len(), event_arcs); + if !event_graph.is_dag() { + return None; + } + + Some(CandidatePertNetwork { + event_graph, + start_events, + finish_events, + num_dummy_arcs, + }) + } +} + +impl Problem for MinimumDummyActivitiesPert { + const NAME: &'static str = "MinimumDummyActivitiesPert"; + type Metric = SolutionSize; + + fn variant() -> Vec<(&'static str, &'static str)> { + crate::variant_params![] + } + + fn dims(&self) -> Vec { + vec![2; self.graph.num_arcs()] + } + + fn evaluate(&self, config: &[usize]) -> SolutionSize { + let Some(candidate) = self.build_candidate_network(config) else { + return SolutionSize::Invalid; + }; + + let source_reachability = reachability_matrix(&self.graph); + let event_reachability = reachability_matrix(&candidate.event_graph); + + for source in 0..self.num_vertices() { + for target in 0..self.num_vertices() { + let pert_reachable = candidate.finish_events[source] + == candidate.start_events[target] + || event_reachability[candidate.finish_events[source]] + [candidate.start_events[target]]; + if source_reachability[source][target] != pert_reachable { + return SolutionSize::Invalid; + } + } + } + + SolutionSize::Valid( + i32::try_from(candidate.num_dummy_arcs).expect("dummy activity count must fit in i32"), + ) + } +} + +impl OptimizationProblem for MinimumDummyActivitiesPert { + type Value = i32; + + fn direction(&self) -> Direction { + Direction::Minimize + } +} + +crate::declare_variants! { + default opt MinimumDummyActivitiesPert => "2^num_arcs", +} + +#[cfg(feature = "example-db")] +pub(crate) fn canonical_model_example_specs() -> Vec { + vec![crate::example_db::specs::ModelExampleSpec { + id: "minimum_dummy_activities_pert", + instance: Box::new(MinimumDummyActivitiesPert::new(DirectedGraph::new( + 6, + vec![(0, 2), (0, 3), (1, 3), (1, 4), (2, 5)], + ))), + optimal_config: vec![1, 0, 0, 1, 1], + optimal_value: serde_json::json!({"Valid": 2}), + }] +} + +#[derive(Deserialize)] +struct MinimumDummyActivitiesPertData { + graph: DirectedGraph, +} + +impl<'de> Deserialize<'de> for MinimumDummyActivitiesPert { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let data = MinimumDummyActivitiesPertData::deserialize(deserializer)?; + Self::try_new(data.graph).map_err(serde::de::Error::custom) + } +} + +struct CandidatePertNetwork { + event_graph: DirectedGraph, + start_events: Vec, + finish_events: Vec, + num_dummy_arcs: usize, +} + +#[derive(Debug)] +struct UnionFind { + parent: Vec, +} + +impl UnionFind { + fn new(size: usize) -> Self { + Self { + parent: (0..size).collect(), + } + } + + fn find(&mut self, x: usize) -> usize { + if self.parent[x] != x { + let root = self.find(self.parent[x]); + self.parent[x] = root; + } + self.parent[x] + } + + fn union(&mut self, a: usize, b: usize) { + let root_a = self.find(a); + let root_b = self.find(b); + if root_a != root_b { + self.parent[root_b] = root_a; + } + } +} + +fn start_endpoint(task: usize) -> usize { + 2 * task +} + +fn finish_endpoint(task: usize) -> usize { + 2 * task + 1 +} + +fn reachability_matrix(graph: &DirectedGraph) -> Vec> { + let num_vertices = graph.num_vertices(); + let adjacency: Vec> = (0..num_vertices) + .map(|vertex| graph.successors(vertex)) + .collect(); + let mut reachable = vec![vec![false; num_vertices]; num_vertices]; + + for source in 0..num_vertices { + let mut stack = adjacency[source].clone(); + while let Some(vertex) = stack.pop() { + if reachable[source][vertex] { + continue; + } + reachable[source][vertex] = true; + stack.extend(adjacency[vertex].iter().copied()); + } + } + + reachable +} + +#[cfg(test)] +#[path = "../../unit_tests/models/graph/minimum_dummy_activities_pert.rs"] +mod tests; diff --git a/src/models/graph/mod.rs b/src/models/graph/mod.rs index f0270ae9a..45a012730 100644 --- a/src/models/graph/mod.rs +++ b/src/models/graph/mod.rs @@ -11,6 +11,7 @@ //! - [`MaxCut`]: Maximum cut on weighted graphs //! - [`GraphPartitioning`]: Minimum bisection (balanced graph partitioning) //! - [`MinimumCutIntoBoundedSets`]: Minimum cut into bounded sets (Garey & Johnson ND17) +//! - [`MinimumDummyActivitiesPert`]: Minimum dummy activities in activity-on-arc PERT networks //! - [`HamiltonianCircuit`]: Hamiltonian circuit (decision problem) //! - [`IsomorphicSpanningTree`]: Isomorphic spanning tree (satisfaction) //! - [`KClique`]: Clique decision problem with threshold k @@ -82,6 +83,7 @@ pub(crate) mod maximum_matching; pub(crate) mod min_max_multicenter; pub(crate) mod minimum_cut_into_bounded_sets; pub(crate) mod minimum_dominating_set; +pub(crate) mod minimum_dummy_activities_pert; pub(crate) mod minimum_feedback_arc_set; pub(crate) mod minimum_feedback_vertex_set; pub(crate) mod minimum_multiway_cut; @@ -135,6 +137,7 @@ pub use maximum_matching::MaximumMatching; pub use min_max_multicenter::MinMaxMulticenter; pub use minimum_cut_into_bounded_sets::MinimumCutIntoBoundedSets; pub use minimum_dominating_set::MinimumDominatingSet; +pub use minimum_dummy_activities_pert::MinimumDummyActivitiesPert; pub use minimum_feedback_arc_set::MinimumFeedbackArcSet; pub use minimum_feedback_vertex_set::MinimumFeedbackVertexSet; pub use minimum_multiway_cut::MinimumMultiwayCut; @@ -183,6 +186,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec DirectedGraph { + DirectedGraph::new(6, vec![(0, 2), (0, 3), (1, 3), (1, 4), (2, 5)]) +} + +fn issue_problem() -> MinimumDummyActivitiesPert { + MinimumDummyActivitiesPert::new(issue_graph()) +} + +fn config_for_merges( + problem: &MinimumDummyActivitiesPert, + merges: &[(usize, usize)], +) -> Vec { + let mut config = vec![0; problem.num_arcs()]; + let arcs = problem.graph().arcs(); + for &(u, v) in merges { + let index = arcs + .iter() + .position(|&(a, b)| a == u && b == v) + .expect("merge arc must exist in issue graph"); + config[index] = 1; + } + config +} + +#[test] +fn test_minimum_dummy_activities_pert_creation() { + let problem = issue_problem(); + assert_eq!(problem.num_vertices(), 6); + assert_eq!(problem.num_arcs(), 5); + assert_eq!(problem.dims(), vec![2; 5]); +} + +#[test] +fn test_minimum_dummy_activities_pert_rejects_cyclic_input() { + let err = + MinimumDummyActivitiesPert::try_new(DirectedGraph::new(3, vec![(0, 1), (1, 2), (2, 0)])) + .unwrap_err(); + assert!(err.contains("DAG")); +} + +#[test] +fn test_minimum_dummy_activities_pert_issue_example() { + let problem = issue_problem(); + let config = config_for_merges(&problem, &[(0, 2), (1, 4), (2, 5)]); + assert_eq!(problem.direction(), Direction::Minimize); + assert_eq!(problem.evaluate(&config), SolutionSize::Valid(2)); + assert!(problem.is_valid_solution(&config)); +} + +#[test] +fn test_minimum_dummy_activities_pert_rejects_spurious_reachability() { + let problem = issue_problem(); + let config = config_for_merges(&problem, &[(0, 3), (1, 3)]); + assert_eq!(problem.evaluate(&config), SolutionSize::Invalid); + assert!(!problem.is_valid_solution(&config)); +} + +#[test] +fn test_minimum_dummy_activities_pert_solver_finds_optimum_two() { + let problem = issue_problem(); + let solution = BruteForce::new().find_best(&problem).unwrap(); + assert_eq!(problem.evaluate(&solution), SolutionSize::Valid(2)); +} + +#[test] +fn test_minimum_dummy_activities_pert_serialization_roundtrip() { + let problem = issue_problem(); + let json = serde_json::to_string(&problem).unwrap(); + let restored: MinimumDummyActivitiesPert = serde_json::from_str(&json).unwrap(); + assert_eq!(restored.graph(), problem.graph()); +} + +#[test] +fn test_minimum_dummy_activities_pert_transitive_arc_zero_dummies() { + // DAG with transitive arc: 0→1, 1→2, 0→2. + // Merging 0+=1- and 1+=2- makes the 0→2 reachability transitively + // satisfied, so the optimal dummy count is 0. + let dag = DirectedGraph::new(3, vec![(0, 1), (1, 2), (0, 2)]); + let problem = MinimumDummyActivitiesPert::new(dag); + let solution = BruteForce::new().find_best(&problem).unwrap(); + assert_eq!(problem.evaluate(&solution), SolutionSize::Valid(0)); +} + +#[test] +fn test_minimum_dummy_activities_pert_paper_example() { + let problem = issue_problem(); + let config = config_for_merges(&problem, &[(0, 2), (1, 4), (2, 5)]); + assert_eq!(problem.evaluate(&config), SolutionSize::Valid(2)); + let solution = BruteForce::new().find_best(&problem).unwrap(); + assert_eq!(problem.evaluate(&solution), SolutionSize::Valid(2)); +}