Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions docs/paper/reductions.typ
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@
"ExactCoverBy3Sets": [Exact Cover by 3-Sets],
"SubsetSum": [Subset Sum],
"Partition": [Partition],
"PartialFeedbackEdgeSet": [Partial Feedback Edge Set],
"MinimumFeedbackArcSet": [Minimum Feedback Arc Set],
"MinimumFeedbackVertexSet": [Minimum Feedback Vertex Set],
"ConjunctiveBooleanQuery": [Conjunctive Boolean Query],
Expand Down Expand Up @@ -4804,6 +4805,65 @@ A classical NP-complete problem from Garey and Johnson @garey1979[Ch.~3, p.~76],
]
}

#{
let x = load-model-example("PartialFeedbackEdgeSet")
let nv = graph-num-vertices(x.instance)
let edges = x.instance.graph.edges
let ne = edges.len()
let K = x.instance.budget
let L = x.instance.max_cycle_length
let config = x.optimal_config
let removed-indices = config.enumerate().filter(((i, v)) => v == 1).map(((i, _)) => i)
let removed-edges = removed-indices.map(i => edges.at(i))
let blue = graph-colors.at(0)
let gray = luma(180)
[
#problem-def("PartialFeedbackEdgeSet")[
Given an undirected graph $G = (V, E)$, a budget $K in ZZ_(>= 0)$, and a cycle-length bound $L in ZZ_(>= 0)$, determine whether there exists a subset $E' subset.eq E$ with $|E'| <= K$ such that every simple cycle in $G$ of length at most $L$ contains at least one edge of $E'$.
][
Partial Feedback Edge Set is the bounded-cycle edge-deletion problem GT9 in Garey and Johnson @garey1979. Bounding the cycle length is what makes the problem hard: hitting only the short cycles is NP-complete, whereas the unrestricted undirected feedback-edge-set problem is polynomial-time solvable by reducing to a spanning forest. The implementation here uses one binary variable per edge, so brute-force search explores $O^*(2^|E|)$ candidate edge subsets.#footnote[No sharper general exact worst-case bound is claimed here.]

*Example.* Consider the graph $G$ with $n = #nv$ vertices, $|E| = #ne$ edges, budget $K = #K$, and length bound $L = #L$. Removing
$E' = {#removed-edges.map(e => [$\{v_#(e.at(0)), v_#(e.at(1))\}$]).join(", ")}$
hits the triangles $(v_0, v_1, v_2)$, $(v_0, v_2, v_3)$, $(v_2, v_3, v_4)$, and $(v_3, v_4, v_5)$, together with the 4-cycles $(v_0, v_1, v_2, v_3)$, $(v_0, v_2, v_4, v_3)$, and $(v_2, v_3, v_5, v_4)$. Hence every cycle of length at most 4 is hit. Brute-force search on this instance finds exactly five satisfying 3-edge deletions and none of size 2, so the displayed configuration certifies a YES-instance.

#pred-commands(
"pred create --example PartialFeedbackEdgeSet -o partial-feedback-edge-set.json",
"pred solve partial-feedback-edge-set.json",
"pred evaluate partial-feedback-edge-set.json --config " + x.optimal_config.map(str).join(","),
)

#figure(
canvas(length: 1cm, {
let verts = (
(0, 1.4),
(1.2, 2.4),
(1.9, 1.0),
(3.3, 1.4),
(4.5, 2.4),
(4.5, 0.4),
)
for edge in edges {
let (u, v) = edge
let selected = removed-edges.any(e =>
(e.at(0) == u and e.at(1) == v) or (e.at(0) == v and e.at(1) == u)
)
g-edge(
verts.at(u),
verts.at(v),
stroke: if selected { 2pt + blue } else { 1pt + gray },
)
}
for (idx, pos) in verts.enumerate() {
g-node(pos, name: "v" + str(idx), label: [$v_#idx$])
}
}),
caption: [Partial Feedback Edge Set example with $K = 3$ and $L = 4$. Blue edges $\{v_0, v_2\}$, $\{v_2, v_3\}$, and $\{v_3, v_4\}$ form a satisfying edge set that hits every cycle of length at most 4.],
) <fig:partial-feedback-edge-set>
]
]
}

#{
let x = load-model-example("MultipleChoiceBranching")
let nv = graph-num-vertices(x.instance)
Expand Down
40 changes: 15 additions & 25 deletions examples/export_module_graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,7 @@ fn main() {
for entry in inventory::iter::<ProblemSchemaEntry> {
let display = module_display_path(entry.module_path);
let category = module_category(entry.module_path).to_string();
module_categories
.entry(display.clone())
.or_insert(category);
module_categories.entry(display.clone()).or_insert(category);
module_items.entry(display).or_default().push(ModuleItem {
name: entry.display_name.to_string(),
kind: "struct".to_string(),
Expand All @@ -103,13 +101,21 @@ fn main() {
}

// Add well-known non-model modules with their key items
type ModuleSpec = (&'static str, &'static str, &'static [(&'static str, &'static str, &'static str)]);
type ModuleSpec = (
&'static str,
&'static str,
&'static [(&'static str, &'static str, &'static str)],
);
let static_modules: &[ModuleSpec] = &[
(
"traits",
"core",
&[
("Problem", "trait", "Core trait for all computational problems"),
(
"Problem",
"trait",
"Core trait for all computational problems",
),
(
"OptimizationProblem",
"trait",
Expand Down Expand Up @@ -140,11 +146,7 @@ fn main() {
"variant",
"core",
&[
(
"VariantParam",
"trait",
"Trait for variant parameter types",
),
("VariantParam", "trait", "Trait for variant parameter types"),
(
"CastToParent",
"trait",
Expand Down Expand Up @@ -188,28 +190,16 @@ fn main() {
"struct",
"Global graph of all registered reductions",
),
(
"ReductionEntry",
"struct",
"A single registered reduction",
),
(
"VariantEntry",
"struct",
"A registered problem variant",
),
("ReductionEntry", "struct", "A single registered reduction"),
("VariantEntry", "struct", "A registered problem variant"),
],
),
(
"solvers",
"solver",
&[
("BruteForce", "struct", "Exhaustive search solver"),
(
"ILPSolver",
"struct",
"Integer linear programming solver",
),
("ILPSolver", "struct", "Integer linear programming solver"),
(
"Solver",
"trait",
Expand Down
42 changes: 42 additions & 0 deletions problemreductions-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ Flags by problem type:
BicliqueCover --left, --right, --biedges, --k
BalancedCompleteBipartiteSubgraph --left, --right, --biedges, --k
BiconnectivityAugmentation --graph, --potential-edges, --budget [--num-vertices]
PartialFeedbackEdgeSet --graph, --budget, --max-cycle-length [--num-vertices]
BMF --matrix (0/1), --rank
ConsecutiveBlockMinimization --matrix (JSON 2D bool), --bound-k
ConsecutiveOnesSubmatrix --matrix (0/1), --k
Expand Down Expand Up @@ -612,6 +613,9 @@ pub struct CreateArgs {
/// Total budget for selected potential edges
#[arg(long)]
pub budget: Option<String>,
/// Maximum cycle length L for PartialFeedbackEdgeSet
#[arg(long)]
pub max_cycle_length: Option<usize>,
/// Candidate weighted arcs for StrongConnectivityAugmentation (e.g., 2>0:1,2>1:3)
#[arg(long)]
pub candidate_arcs: Option<String>,
Expand Down Expand Up @@ -916,6 +920,44 @@ mod tests {
assert!(help.contains("--budget"));
}

#[test]
fn test_create_parses_partial_feedback_edge_set_flags() {
let cli = Cli::parse_from([
"pred",
"create",
"PartialFeedbackEdgeSet",
"--graph",
"0-1,1-2,2-0",
"--budget",
"1",
"--max-cycle-length",
"3",
]);

let Commands::Create(args) = cli.command else {
panic!("expected create command");
};

assert_eq!(args.problem.as_deref(), Some("PartialFeedbackEdgeSet"));
assert_eq!(args.graph.as_deref(), Some("0-1,1-2,2-0"));
assert_eq!(args.budget.as_deref(), Some("1"));
assert_eq!(args.max_cycle_length, Some(3));
}

#[test]
fn test_create_help_mentions_partial_feedback_edge_set_flags() {
let cmd = Cli::command();
let create = cmd.find_subcommand("create").expect("create subcommand");
let help = create
.get_after_help()
.expect("create after_help")
.to_string();

assert!(help.contains("PartialFeedbackEdgeSet"));
assert!(help.contains("--budget"));
assert!(help.contains("--max-cycle-length"));
}

#[test]
fn test_create_help_mentions_stacker_crane_flags() {
let cmd = Cli::command();
Expand Down
92 changes: 92 additions & 0 deletions problemreductions-cli/src/commands/create.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool {
&& args.candidate_arcs.is_none()
&& args.potential_edges.is_none()
&& args.budget.is_none()
&& args.max_cycle_length.is_none()
&& args.deadlines.is_none()
&& args.lengths.is_none()
&& args.precedence_pairs.is_none()
Expand Down Expand Up @@ -587,6 +588,9 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str {
"BiconnectivityAugmentation" => {
"--graph 0-1,1-2,2-3 --potential-edges 0-2:3,0-3:4,1-3:2 --budget 5"
}
"PartialFeedbackEdgeSet" => {
"--graph 0-1,1-2,2-0,2-3,3-4,4-2,3-5,5-4,0-3 --budget 3 --max-cycle-length 4"
}
"Satisfiability" => "--num-vars 3 --clauses \"1,2;-1,3\"",
"NAESatisfiability" => "--num-vars 3 --clauses \"1,2,-3;-1,2,3\"",
"QuantifiedBooleanFormulas" => {
Expand Down Expand Up @@ -1351,6 +1355,31 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> {
)
}

// Partial Feedback Edge Set
"PartialFeedbackEdgeSet" => {
let usage = "Usage: pred create PartialFeedbackEdgeSet --graph 0-1,1-2,2-0,2-3,3-4,4-2,3-5,5-4,0-3 --budget 3 --max-cycle-length 4";
let (graph, _) = parse_graph(args).map_err(|e| anyhow::anyhow!("{e}\n\n{usage}"))?;
let budget = args
.budget
.as_deref()
.ok_or_else(|| {
anyhow::anyhow!("PartialFeedbackEdgeSet requires --budget\n\n{usage}")
})?
.parse::<usize>()
.map_err(|e| {
anyhow::anyhow!(
"Invalid --budget value for PartialFeedbackEdgeSet: {e}\n\n{usage}"
)
})?;
let max_cycle_length = args.max_cycle_length.ok_or_else(|| {
anyhow::anyhow!("PartialFeedbackEdgeSet requires --max-cycle-length\n\n{usage}")
})?;
(
ser(PartialFeedbackEdgeSet::new(graph, budget, max_cycle_length))?,
resolved_variant.clone(),
)
}

// Bounded Component Spanning Forest
"BoundedComponentSpanningForest" => {
let usage = "Usage: pred create BoundedComponentSpanningForest --graph 0-1,1-2,2-3,3-4,4-5,5-6,6-7,0-7,1-5,2-6 --weights 2,3,1,2,3,1,2,1 --k 3 --bound 6";
Expand Down Expand Up @@ -7200,6 +7229,7 @@ mod tests {
distance_matrix: None,
potential_edges: None,
budget: None,
max_cycle_length: None,
candidate_arcs: None,
deadlines: None,
precedence_pairs: None,
Expand Down Expand Up @@ -7258,6 +7288,13 @@ mod tests {
assert!(!all_data_flags_empty(&args));
}

#[test]
fn test_all_data_flags_empty_treats_max_cycle_length_as_input() {
let mut args = empty_args();
args.max_cycle_length = Some(4);
assert!(!all_data_flags_empty(&args));
}

#[test]
fn test_all_data_flags_empty_treats_homologous_pairs_as_input() {
let mut args = empty_args();
Expand Down Expand Up @@ -7458,6 +7495,61 @@ mod tests {
std::fs::remove_file(output_path).ok();
}

#[test]
fn test_create_partial_feedback_edge_set_json() {
use problemreductions::models::graph::PartialFeedbackEdgeSet;

let mut args = empty_args();
args.problem = Some("PartialFeedbackEdgeSet".to_string());
args.graph = Some("0-1,1-2,2-0".to_string());
args.budget = Some("1".to_string());
args.max_cycle_length = Some(3);

let output_path =
std::env::temp_dir().join("pred_test_create_partial_feedback_edge_set.json");
let out = OutputConfig {
output: Some(output_path.clone()),
quiet: true,
json: false,
auto_json: false,
};

create(&args, &out).unwrap();

let content = std::fs::read_to_string(&output_path).unwrap();
let json: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(json["type"], "PartialFeedbackEdgeSet");
assert_eq!(json["data"]["budget"], 1);
assert_eq!(json["data"]["max_cycle_length"], 3);

let problem: PartialFeedbackEdgeSet<SimpleGraph> =
serde_json::from_value(json["data"].clone()).unwrap();
assert_eq!(problem.num_vertices(), 3);
assert_eq!(problem.num_edges(), 3);
assert_eq!(problem.budget(), 1);
assert_eq!(problem.max_cycle_length(), 3);

std::fs::remove_file(output_path).ok();
}

#[test]
fn test_create_partial_feedback_edge_set_requires_max_cycle_length() {
let mut args = empty_args();
args.problem = Some("PartialFeedbackEdgeSet".to_string());
args.graph = Some("0-1,1-2,2-0".to_string());
args.budget = Some("1".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("PartialFeedbackEdgeSet requires --max-cycle-length"));
}

#[test]
fn test_create_ensemble_computation_json() {
let mut args = empty_args();
Expand Down
8 changes: 4 additions & 4 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,10 @@ pub mod prelude {
MaximumMatching, MinMaxMulticenter, MinimumCutIntoBoundedSets, MinimumDominatingSet,
MinimumDummyActivitiesPert, MinimumFeedbackArcSet, MinimumFeedbackVertexSet,
MinimumMultiwayCut, MinimumSumMulticenter, MinimumVertexCover, MultipleChoiceBranching,
MultipleCopyFileAllocation, OptimalLinearArrangement, PartitionIntoPathsOfLength2,
PartitionIntoTriangles, PathConstrainedNetworkFlow, RootedTreeArrangement, RuralPostman,
ShortestWeightConstrainedPath, SteinerTreeInGraphs, TravelingSalesman,
UndirectedFlowLowerBounds, UndirectedTwoCommodityIntegralFlow,
MultipleCopyFileAllocation, OptimalLinearArrangement, PartialFeedbackEdgeSet,
PartitionIntoPathsOfLength2, PartitionIntoTriangles, PathConstrainedNetworkFlow,
RootedTreeArrangement, RuralPostman, ShortestWeightConstrainedPath, SteinerTreeInGraphs,
TravelingSalesman, UndirectedFlowLowerBounds, UndirectedTwoCommodityIntegralFlow,
};
pub use crate::models::misc::{
AdditionalKey, BinPacking, BoyceCoddNormalFormViolation, CapacityAssignment, CbqRelation,
Expand Down
4 changes: 4 additions & 0 deletions src/models/graph/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
//! - [`BottleneckTravelingSalesman`]: Hamiltonian cycle minimizing the maximum selected edge weight
//! - [`MultipleCopyFileAllocation`]: File-copy placement under storage and access costs
//! - [`OptimalLinearArrangement`]: Optimal linear arrangement (total edge length at most K)
//! - [`PartialFeedbackEdgeSet`]: Remove at most K edges to hit every short cycle
//! - [`RootedTreeArrangement`]: Rooted-tree embedding with bounded total edge stretch
//! - [`MinimumFeedbackArcSet`]: Minimum feedback arc set on directed graphs
//! - [`MinMaxMulticenter`]: Min-max multicenter (vertex p-center, satisfaction)
Expand Down Expand Up @@ -94,6 +95,7 @@ pub(crate) mod mixed_chinese_postman;
pub(crate) mod multiple_choice_branching;
pub(crate) mod multiple_copy_file_allocation;
pub(crate) mod optimal_linear_arrangement;
pub(crate) mod partial_feedback_edge_set;
pub(crate) mod partition_into_paths_of_length_2;
pub(crate) mod partition_into_triangles;
pub(crate) mod path_constrained_network_flow;
Expand Down Expand Up @@ -149,6 +151,7 @@ pub use mixed_chinese_postman::MixedChinesePostman;
pub use multiple_choice_branching::MultipleChoiceBranching;
pub use multiple_copy_file_allocation::MultipleCopyFileAllocation;
pub use optimal_linear_arrangement::OptimalLinearArrangement;
pub use partial_feedback_edge_set::PartialFeedbackEdgeSet;
pub use partition_into_paths_of_length_2::PartitionIntoPathsOfLength2;
pub use partition_into_triangles::PartitionIntoTriangles;
pub use path_constrained_network_flow::PathConstrainedNetworkFlow;
Expand Down Expand Up @@ -219,6 +222,7 @@ pub(crate) fn canonical_model_example_specs() -> Vec<crate::example_db::specs::M
specs.extend(integral_flow_homologous_arcs::canonical_model_example_specs());
specs.extend(minimum_feedback_arc_set::canonical_model_example_specs());
specs.extend(optimal_linear_arrangement::canonical_model_example_specs());
specs.extend(partial_feedback_edge_set::canonical_model_example_specs());
specs.extend(mixed_chinese_postman::canonical_model_example_specs());
specs.extend(subgraph_isomorphism::canonical_model_example_specs());
specs
Expand Down
Loading
Loading