Skip to content

Commit 9640b88

Browse files
GiggleLiuclaude
andauthored
Fix #401: [Model] ComparativeContainment (#662)
* Add plan for #401: [Model] ComparativeContainment * Implement #401: [Model] ComparativeContainment * chore: remove plan file after implementation * fix: address ComparativeContainment review comments * fix: validate ComparativeContainment CLI inputs * fix: DRY evaluate via is_valid_solution, add S-family i32 weight test - evaluate() now delegates to is_valid_solution() instead of duplicating the r_weight_sum >= s_weight_sum comparison - Add missing #[should_panic] test for S-family i32 nonpositive weights Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: replace dyn Any weight validation with trait-based check, improve coverage - Replace `validate_weight_family` dyn Any downcasting with WeightElement trait: use `weight.to_sum() > zero` via `partial_cmp`, which correctly rejects non-positive and NaN values for all weight types - Remove `use std::any::Any` import - Tighten impl block bound from `W: 'static` to `W: WeightElement` - Add direct r_weight_sum/s_weight_sum test with value assertions - Add S-family weight count mismatch should_panic test - Update should_panic messages to match unified validation error format Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: add missing ComparativeContainment fields to empty_args() test helper The empty_args() helper in create.rs tests constructs CreateArgs manually and was missing the r_sets/s_sets/r_weights/s_weights fields added by this PR, causing a compilation error in CI coverage builds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 302d54d commit 9640b88

10 files changed

Lines changed: 872 additions & 11 deletions

File tree

docs/paper/reductions.typ

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@
7878
"MaximumClique": [Maximum Clique],
7979
"MaximumSetPacking": [Maximum Set Packing],
8080
"MinimumSetCovering": [Minimum Set Covering],
81+
"ComparativeContainment": [Comparative Containment],
8182
"SetBasis": [Set Basis],
8283
"SpinGlass": [Spin Glass],
8384
"QUBO": [QUBO],
@@ -1413,6 +1414,70 @@ NP-completeness was established by Garey, Johnson, and Stockmeyer @gareyJohnsonS
14131414
]
14141415
}
14151416

1417+
#{
1418+
let x = load-model-example("ComparativeContainment")
1419+
let n = x.instance.universe_size
1420+
let R = x.instance.r_sets
1421+
let S = x.instance.s_sets
1422+
let r-weights = x.instance.r_weights
1423+
let s-weights = x.instance.s_weights
1424+
let sample = x.samples.at(0)
1425+
let selected = sample.config.enumerate().filter(((i, v)) => v == 1).map(((i, _)) => i)
1426+
let satisfiers = x.optimal.map(sol => sol.config.enumerate().filter(((i, v)) => v == 1).map(((i, _)) => i))
1427+
let contains-selected(family-set) = selected.all(i => family-set.contains(i))
1428+
let r-active = range(R.len()).filter(i => contains-selected(R.at(i)))
1429+
let s-active = range(S.len()).filter(i => contains-selected(S.at(i)))
1430+
let r-total = r-active.map(i => r-weights.at(i)).sum(default: 0)
1431+
let s-total = s-active.map(i => s-weights.at(i)).sum(default: 0)
1432+
let fmt-set(items) = if items.len() == 0 {
1433+
$emptyset$
1434+
} else {
1435+
"${" + items.map(e => str(e + 1)).join(", ") + "}$"
1436+
}
1437+
let left-elems = (
1438+
(-3.1, 0.4),
1439+
(-2.4, -0.4),
1440+
(-1.6, 0.4),
1441+
(-0.9, -0.4),
1442+
)
1443+
let right-elems = (
1444+
(0.9, 0.4),
1445+
(1.6, -0.4),
1446+
(2.4, 0.4),
1447+
(3.1, -0.4),
1448+
)
1449+
[
1450+
#problem-def("ComparativeContainment")[
1451+
Given a finite universe $X$, two set families $cal(R) = {R_1, dots, R_k}$ and $cal(S) = {S_1, dots, S_l}$ over $X$, and positive integer weights $w_R(R_i)$ and $w_S(S_j)$, does there exist a subset $Y subset.eq X$ such that $sum_(Y subset.eq R_i) w_R(R_i) >= sum_(Y subset.eq S_j) w_S(S_j)$?
1452+
][
1453+
Comparative Containment is the set-system comparison problem SP10 in Garey & Johnson @garey1979. Unlike covering and packing problems, feasibility depends on how the chosen subset $Y$ is nested inside two competing set families: the $cal(R)$ family rewards containment while the $cal(S)$ family penalizes it. The problem remains NP-complete in the unit-weight special case and provides a clean weighted-set comparison primitive for future reduction entries in this catalog.
1454+
1455+
A direct exact algorithm enumerates all $2^n$ subsets $Y subset.eq X$ for $n = |X|$ and checks which members of $cal(R)$ and $cal(S)$ contain each candidate. This yields an $O^*(2^n)$ exact algorithm, with the polynomial factor coming from scanning the $k + l$ sets for each subset#footnote[No specialized exact algorithm improving on brute-force enumeration is recorded in the standard references used for this catalog entry.].
1456+
1457+
*Example.* Let $X = {1, 2, dots, #n}$, $cal(R) = {#range(R.len()).map(i => $R_#(i + 1)$).join(", ")}$ with #R.enumerate().map(((i, family-set)) => [$R_#(i + 1) = #fmt-set(family-set)$ with $w_R(R_#(i + 1)) = #(r-weights.at(i))$]).join(", "), and $cal(S) = {#range(S.len()).map(i => $S_#(i + 1)$).join(", ")}$ with #S.enumerate().map(((i, family-set)) => [$S_#(i + 1) = #fmt-set(family-set)$ with $w_S(S_#(i + 1)) = #(s-weights.at(i))$]).join(", "). The subset $Y = #fmt-set(selected)$ is satisfying because #r-active.map(i => $R_#(i + 1)$).join(", ") contribute $#r-total$ on the left while #s-active.map(i => $S_#(i + 1)$).join(", ") contribute only $#s-total$ on the right, so $#r-total >= #s-total$. In fact, the satisfying subsets are #satisfiers.map(fmt-set).join(", "), so this instance has exactly #satisfiers.len() satisfying solutions.
1458+
1459+
#figure(
1460+
canvas(length: 1cm, {
1461+
import draw: *
1462+
content((-2.0, 1.5), text(8pt)[$cal(R)$])
1463+
content((2.0, 1.5), text(8pt)[$cal(S)$])
1464+
sregion((left-elems.at(0), left-elems.at(1), left-elems.at(2), left-elems.at(3)), pad: 0.5, label: [$R_1$], ..if r-active.contains(0) { sregion-selected } else { sregion-dimmed })
1465+
sregion((left-elems.at(0), left-elems.at(1)), pad: 0.35, label: [$R_2$], ..if r-active.contains(1) { sregion-selected } else { sregion-dimmed })
1466+
sregion((right-elems.at(0), right-elems.at(1), right-elems.at(2), right-elems.at(3)), pad: 0.5, label: [$S_1$], ..if s-active.contains(0) { sregion-selected } else { sregion-dimmed })
1467+
sregion((right-elems.at(2), right-elems.at(3)), pad: 0.35, label: [$S_2$], ..if s-active.contains(1) { sregion-selected } else { sregion-dimmed })
1468+
for (k, pos) in left-elems.enumerate() {
1469+
selem(pos, label: [#(k + 1)], fill: if selected.contains(k) { graph-colors.at(0) } else { black })
1470+
}
1471+
for (k, pos) in right-elems.enumerate() {
1472+
selem(pos, label: [#(k + 1)], fill: if selected.contains(k) { graph-colors.at(0) } else { black })
1473+
}
1474+
}),
1475+
caption: [Comparative containment for $Y = #fmt-set(selected)$: both $R_1$ and $R_2$ contain $Y$, while only $S_1$ does, so the $cal(R)$ side dominates the $cal(S)$ side.]
1476+
) <fig:comparative-containment>
1477+
]
1478+
]
1479+
}
1480+
14161481
#{
14171482
let x = load-model-example("SetBasis")
14181483
let coll = x.instance.collection

problemreductions-cli/src/cli.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,7 @@ Flags by problem type:
234234
PaintShop --sequence
235235
MaximumSetPacking --sets [--weights]
236236
MinimumSetCovering --universe, --sets [--weights]
237+
ComparativeContainment --universe, --r-sets, --s-sets [--r-weights] [--s-weights]
237238
X3C (ExactCoverBy3Sets) --universe, --sets (3 elements each)
238239
SetBasis --universe, --sets, --k
239240
BicliqueCover --left, --right, --biedges, --k
@@ -389,10 +390,22 @@ pub struct CreateArgs {
389390
/// Sets for SetPacking/SetCovering (semicolon-separated, e.g., "0,1;1,2;0,2")
390391
#[arg(long)]
391392
pub sets: Option<String>,
393+
/// R-family sets for ComparativeContainment (semicolon-separated, e.g., "0,1;1,2")
394+
#[arg(long)]
395+
pub r_sets: Option<String>,
396+
/// S-family sets for ComparativeContainment (semicolon-separated, e.g., "0,1;1,2")
397+
#[arg(long)]
398+
pub s_sets: Option<String>,
399+
/// R-family weights for ComparativeContainment (comma-separated, e.g., "2,5")
400+
#[arg(long)]
401+
pub r_weights: Option<String>,
402+
/// S-family weights for ComparativeContainment (comma-separated, e.g., "3,6")
403+
#[arg(long)]
404+
pub s_weights: Option<String>,
392405
/// Partition groups for arc-index partitions (semicolon-separated, e.g., "0,1;2,3")
393406
#[arg(long)]
394407
pub partition: Option<String>,
395-
/// Universe size for MinimumSetCovering
408+
/// Universe size for set-system problems such as MinimumSetCovering and ComparativeContainment
396409
#[arg(long)]
397410
pub universe: Option<usize>,
398411
/// Bipartite graph edges for BicliqueCover / BalancedCompleteBipartiteSubgraph (e.g., "0-0,0-1,1-2" for left-right pairs)

problemreductions-cli/src/commands/create.rs

Lines changed: 176 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ fn all_data_flags_empty(args: &CreateArgs) -> bool {
5959
&& args.capacity.is_none()
6060
&& args.sequence.is_none()
6161
&& args.sets.is_none()
62+
&& args.r_sets.is_none()
63+
&& args.s_sets.is_none()
64+
&& args.r_weights.is_none()
65+
&& args.s_weights.is_none()
6266
&& args.partition.is_none()
6367
&& args.universe.is_none()
6468
&& args.biedges.is_none()
@@ -222,15 +226,15 @@ fn type_format_hint(type_name: &str, graph_type: Option<&str>) -> &'static str {
222226
Some("UnitDiskGraph") => "float positions: \"0.0,0.0;1.0,0.0\"",
223227
_ => "edge list: 0-1,1-2,2-3",
224228
},
225-
"Vec<u64>" => "comma-separated integers: 1,1,2",
229+
"Vec<u64>" => "comma-separated integers: 1,2,3",
226230
"Vec<W>" => "comma-separated: 1,2,3",
227231
"Vec<usize>" => "comma-separated indices: 0,2,4",
228232
"Vec<(usize, usize, W)>" | "Vec<(usize,usize,W)>" => {
229233
"comma-separated weighted edges: 0-2:3,1-3:5"
230234
}
235+
"Vec<Vec<usize>>" => "semicolon-separated sets: \"0,1;1,2;0,2\"",
231236
"Vec<CNFClause>" => "semicolon-separated clauses: \"1,2;-1,3\"",
232237
"Vec<Vec<W>>" => "semicolon-separated rows: \"1,0.5;0.5,2\"",
233-
"Vec<Vec<usize>>" => "semicolon-separated groups: \"0,1;2,3\"",
234238
"usize" | "W::Sum" => "integer",
235239
"u64" => "integer",
236240
"i64" => "integer",
@@ -303,6 +307,9 @@ fn example_for(canonical: &str, graph_type: Option<&str>) -> &'static str {
303307
}
304308
"SubgraphIsomorphism" => "--graph 0-1,1-2,2-0 --pattern 0-1",
305309
"SubsetSum" => "--sizes 3,7,1,8,2,4 --target 11",
310+
"ComparativeContainment" => {
311+
"--universe 4 --r-sets \"0,1,2,3;0,1\" --s-sets \"0,1,2,3;2,3\" --r-weights 2,5 --s-weights 3,6"
312+
}
306313
"SetBasis" => "--universe 4 --sets \"0,1;1,2;0,2;0,1,2\" --k 3",
307314
"ShortestCommonSupersequence" => "--strings \"0,1,2;1,2,0\" --bound 4",
308315
_ => "",
@@ -341,6 +348,7 @@ fn help_flag_hint(
341348
) -> &'static str {
342349
match (canonical, field_name) {
343350
("BoundedComponentSpanningForest", "max_weight") => "integer",
351+
("MultipleChoiceBranching", "partition") => "semicolon-separated groups: \"0,1;2,3\"",
344352
_ => type_format_hint(type_name, graph_type),
345353
}
346354
}
@@ -1037,6 +1045,80 @@ pub fn create(args: &CreateArgs, out: &OutputConfig) -> Result<()> {
10371045
)
10381046
}
10391047

1048+
// ComparativeContainment
1049+
"ComparativeContainment" => {
1050+
let universe = args.universe.ok_or_else(|| {
1051+
anyhow::anyhow!(
1052+
"ComparativeContainment requires --universe, --r-sets, and --s-sets\n\n\
1053+
Usage: pred create ComparativeContainment --universe 4 --r-sets \"0,1,2,3;0,1\" --s-sets \"0,1,2,3;2,3\" [--r-weights 2,5] [--s-weights 3,6]"
1054+
)
1055+
})?;
1056+
let r_sets = parse_named_sets(args.r_sets.as_deref(), "--r-sets")?;
1057+
let s_sets = parse_named_sets(args.s_sets.as_deref(), "--s-sets")?;
1058+
validate_comparative_containment_sets("R", "--r-sets", universe, &r_sets)?;
1059+
validate_comparative_containment_sets("S", "--s-sets", universe, &s_sets)?;
1060+
let data = match resolved_variant.get("weight").map(|value| value.as_str()) {
1061+
Some("One") => {
1062+
let r_weights = parse_named_set_weights(
1063+
args.r_weights.as_deref(),
1064+
r_sets.len(),
1065+
"--r-weights",
1066+
)?;
1067+
let s_weights = parse_named_set_weights(
1068+
args.s_weights.as_deref(),
1069+
s_sets.len(),
1070+
"--s-weights",
1071+
)?;
1072+
if r_weights.iter().any(|&w| w != 1) || s_weights.iter().any(|&w| w != 1) {
1073+
bail!(
1074+
"Non-unit weights are not supported for ComparativeContainment/One.\n\n\
1075+
Use `pred create ComparativeContainment/i32 ... --r-weights ... --s-weights ...` for weighted instances."
1076+
);
1077+
}
1078+
ser(ComparativeContainment::<One>::new(universe, r_sets, s_sets))?
1079+
}
1080+
Some("f64") => {
1081+
let r_weights = parse_named_set_weights_f64(
1082+
args.r_weights.as_deref(),
1083+
r_sets.len(),
1084+
"--r-weights",
1085+
)?;
1086+
validate_comparative_containment_f64_weights("R", "--r-weights", &r_weights)?;
1087+
let s_weights = parse_named_set_weights_f64(
1088+
args.s_weights.as_deref(),
1089+
s_sets.len(),
1090+
"--s-weights",
1091+
)?;
1092+
validate_comparative_containment_f64_weights("S", "--s-weights", &s_weights)?;
1093+
ser(ComparativeContainment::<f64>::with_weights(
1094+
universe, r_sets, s_sets, r_weights, s_weights,
1095+
))?
1096+
}
1097+
Some("i32") | None => {
1098+
let r_weights = parse_named_set_weights(
1099+
args.r_weights.as_deref(),
1100+
r_sets.len(),
1101+
"--r-weights",
1102+
)?;
1103+
validate_comparative_containment_i32_weights("R", "--r-weights", &r_weights)?;
1104+
let s_weights = parse_named_set_weights(
1105+
args.s_weights.as_deref(),
1106+
s_sets.len(),
1107+
"--s-weights",
1108+
)?;
1109+
validate_comparative_containment_i32_weights("S", "--s-weights", &s_weights)?;
1110+
ser(ComparativeContainment::with_weights(
1111+
universe, r_sets, s_sets, r_weights, s_weights,
1112+
))?
1113+
}
1114+
Some(other) => bail!(
1115+
"Unsupported ComparativeContainment weight variant: {}",
1116+
other
1117+
),
1118+
};
1119+
(data, resolved_variant.clone())
1120+
}
1121+
10401122
// ExactCoverBy3Sets
10411123
"ExactCoverBy3Sets" => {
10421124
let universe = args.universe.ok_or_else(|| {
@@ -2092,10 +2174,12 @@ fn parse_clauses(args: &CreateArgs) -> Result<Vec<CNFClause>> {
20922174
/// Parse `--sets` as semicolon-separated sets of comma-separated usize.
20932175
/// E.g., "0,1;1,2;0,2"
20942176
fn parse_sets(args: &CreateArgs) -> Result<Vec<Vec<usize>>> {
2095-
let sets_str = args
2096-
.sets
2097-
.as_deref()
2098-
.ok_or_else(|| anyhow::anyhow!("This problem requires --sets (e.g., \"0,1;1,2;0,2\")"))?;
2177+
parse_named_sets(args.sets.as_deref(), "--sets")
2178+
}
2179+
2180+
fn parse_named_sets(sets_str: Option<&str>, flag: &str) -> Result<Vec<Vec<usize>>> {
2181+
let sets_str = sets_str
2182+
.ok_or_else(|| anyhow::anyhow!("This problem requires {flag} (e.g., \"0,1;1,2;0,2\")"))?;
20992183
sets_str
21002184
.split(';')
21012185
.map(|set| {
@@ -2111,6 +2195,23 @@ fn parse_sets(args: &CreateArgs) -> Result<Vec<Vec<usize>>> {
21112195
.collect()
21122196
}
21132197

2198+
fn validate_comparative_containment_sets(
2199+
family_name: &str,
2200+
flag: &str,
2201+
universe_size: usize,
2202+
sets: &[Vec<usize>],
2203+
) -> Result<()> {
2204+
for (set_index, set) in sets.iter().enumerate() {
2205+
for &element in set {
2206+
anyhow::ensure!(
2207+
element < universe_size,
2208+
"{family_name} set {set_index} from {flag} contains element {element} outside universe of size {universe_size}"
2209+
);
2210+
}
2211+
}
2212+
Ok(())
2213+
}
2214+
21142215
/// Parse `--partition` as semicolon-separated groups of comma-separated arc indices.
21152216
/// E.g., "0,1;2,3;4,7;5,6"
21162217
fn parse_partition_groups(args: &CreateArgs, num_arcs: usize) -> Result<Vec<Vec<usize>>> {
@@ -2175,18 +2276,81 @@ fn parse_multiple_choice_branching_threshold(args: &CreateArgs, usage: &str) ->
21752276

21762277
/// Parse `--weights` for set-based problems (i32), defaulting to all 1s.
21772278
fn parse_set_weights(args: &CreateArgs, num_sets: usize) -> Result<Vec<i32>> {
2178-
match &args.weights {
2279+
parse_named_set_weights(args.weights.as_deref(), num_sets, "--weights")
2280+
}
2281+
2282+
fn parse_named_set_weights(
2283+
weights_str: Option<&str>,
2284+
num_sets: usize,
2285+
flag: &str,
2286+
) -> Result<Vec<i32>> {
2287+
match weights_str {
21792288
Some(w) => {
21802289
let weights: Vec<i32> = util::parse_comma_list(w)?;
21812290
if weights.len() != num_sets {
2182-
bail!("Expected {} weights but got {}", num_sets, weights.len());
2291+
bail!(
2292+
"Expected {} values for {} but got {}",
2293+
num_sets,
2294+
flag,
2295+
weights.len()
2296+
);
21832297
}
21842298
Ok(weights)
21852299
}
21862300
None => Ok(vec![1i32; num_sets]),
21872301
}
21882302
}
21892303

2304+
fn parse_named_set_weights_f64(
2305+
weights_str: Option<&str>,
2306+
num_sets: usize,
2307+
flag: &str,
2308+
) -> Result<Vec<f64>> {
2309+
match weights_str {
2310+
Some(w) => {
2311+
let weights: Vec<f64> = util::parse_comma_list(w)?;
2312+
if weights.len() != num_sets {
2313+
bail!(
2314+
"Expected {} values for {} but got {}",
2315+
num_sets,
2316+
flag,
2317+
weights.len()
2318+
);
2319+
}
2320+
Ok(weights)
2321+
}
2322+
None => Ok(vec![1.0f64; num_sets]),
2323+
}
2324+
}
2325+
2326+
fn validate_comparative_containment_i32_weights(
2327+
family_name: &str,
2328+
flag: &str,
2329+
weights: &[i32],
2330+
) -> Result<()> {
2331+
for (index, weight) in weights.iter().enumerate() {
2332+
anyhow::ensure!(
2333+
*weight > 0,
2334+
"{family_name} weights from {flag} must be positive; found {weight} at index {index}"
2335+
);
2336+
}
2337+
Ok(())
2338+
}
2339+
2340+
fn validate_comparative_containment_f64_weights(
2341+
family_name: &str,
2342+
flag: &str,
2343+
weights: &[f64],
2344+
) -> Result<()> {
2345+
for (index, weight) in weights.iter().enumerate() {
2346+
anyhow::ensure!(
2347+
weight.is_finite() && *weight > 0.0,
2348+
"{family_name} weights from {flag} must be finite and positive; found {weight} at index {index}"
2349+
);
2350+
}
2351+
Ok(())
2352+
}
2353+
21902354
/// Parse `--matrix` as semicolon-separated rows of comma-separated bool values (0/1).
21912355
/// E.g., "1,0;0,1;1,1"
21922356
fn parse_bool_matrix(args: &CreateArgs) -> Result<Vec<Vec<bool>>> {
@@ -2728,6 +2892,10 @@ mod tests {
27282892
capacity: None,
27292893
sequence: None,
27302894
sets: None,
2895+
r_sets: None,
2896+
s_sets: None,
2897+
r_weights: None,
2898+
s_weights: None,
27312899
partition: None,
27322900
universe: None,
27332901
biedges: None,

0 commit comments

Comments
 (0)