use csp_solver::{SENTINEL, assignment};
use proptest::prelude::*;
fn bruteforce_min_assignment(
costs: &[f64],
rows: usize,
cols: usize,
unmatch_penalty: f64,
) -> f64 {
let mut best = f64::INFINITY;
let mut assign: Vec<Option<usize>> = vec![None; rows];
bruteforce_recurse(
costs,
rows,
cols,
unmatch_penalty,
0,
&mut assign,
0.0,
&mut best,
);
best
}
fn bruteforce_recurse(
costs: &[f64],
rows: usize,
cols: usize,
unmatch_penalty: f64,
row: usize,
assign: &mut Vec<Option<usize>>,
running_cost: f64,
best: &mut f64,
) {
if row == rows {
if running_cost < *best {
*best = running_cost;
}
return;
}
if running_cost >= *best {
return;
}
assign[row] = None;
bruteforce_recurse(
costs,
rows,
cols,
unmatch_penalty,
row + 1,
assign,
running_cost + unmatch_penalty,
best,
);
for c in 0..cols {
let taken = (0..row).any(|r| assign[r] == Some(c));
if taken {
continue;
}
assign[row] = Some(c);
bruteforce_recurse(
costs,
rows,
cols,
unmatch_penalty,
row + 1,
assign,
running_cost + costs[row * cols + c],
best,
);
}
assign[row] = None;
}
fn cost_matrix_strategy(
max_n: usize,
max_m: usize,
) -> impl Strategy<Value = (usize, usize, Vec<f64>)> {
(1..=max_n, 1..=max_m).prop_flat_map(|(n, m)| {
proptest::collection::vec(0.0..100.0_f64, n * m).prop_map(move |costs| (n, m, costs))
})
}
proptest! {
#![proptest_config(ProptestConfig {
cases: 256,
// Deterministic seed for reproducibility.
failure_persistence: None,
.. ProptestConfig::default()
})]
#[test]
fn prop_square_matches_bruteforce(
(n, _, costs) in cost_matrix_strategy(5, 5)
.prop_filter("square only", |(n, m, _)| *n == *m)
) {
let rows = n;
let cols = n;
let penalty = 1e9;
let bf = bruteforce_min_assignment(&costs, rows, cols, penalty);
let sol = assignment()
.rows(rows)
.cols(cols)
.cost(|i, k| costs[i * cols + k])
.unmatch_penalty(penalty)
.solve()
.expect("square problem must be solvable");
let delta = (sol.cost - bf).abs();
prop_assert!(
delta < 1e-9,
"solver cost {} differs from brute-force {} by {}",
sol.cost,
bf,
delta,
);
}
#[test]
fn prop_rectangular_matches_bruteforce(
(rows, cols, costs) in cost_matrix_strategy(4, 4)
.prop_filter("rectangular only", |(n, m, _)| *n != *m)
) {
let penalty = 1e9;
let bf = bruteforce_min_assignment(&costs, rows, cols, penalty);
let sol = assignment()
.rows(rows)
.cols(cols)
.cost(|i, k| costs[i * cols + k])
.unmatch_penalty(penalty)
.solve()
.expect("rectangular problem must be solvable");
let delta = (sol.cost - bf).abs();
prop_assert!(
delta < 1e-9,
"solver cost {} differs from brute-force {} by {} ({}x{})",
sol.cost,
bf,
delta,
rows,
cols,
);
}
#[test]
fn prop_roles_partition(
(n, _, costs) in cost_matrix_strategy(4, 4)
.prop_filter("even square", |(n, m, _)| *n == *m && *n >= 2 && *n % 2 == 0),
split in 1..=3usize
) {
let rows = n;
let cols = n;
let mid = (split.min(rows - 1)).max(1);
let row_groups: Vec<u8> = (0..rows).map(|i| if i < mid { 0 } else { 1 }).collect();
let col_groups: Vec<u8> = (0..cols).map(|k| if k < mid { 0 } else { 1 }).collect();
let sol = assignment()
.rows(rows)
.cols(cols)
.cost(|i, k| costs[i * cols + k])
.row_group(|i| row_groups[i])
.col_group(|k| col_groups[k])
.unmatch_penalty(1e9)
.solve()
.expect("grouped problem must be solvable");
for (row, &assigned_col) in sol.assign.iter().enumerate() {
if assigned_col != SENTINEL {
let col = assigned_col as usize;
prop_assert_eq!(
row_groups[row],
col_groups[col],
"row {} (group {}) assigned to col {} (group {})",
row,
row_groups[row],
col,
col_groups[col],
);
}
}
}
#[test]
fn prop_pins_respected(
(n, _, costs) in cost_matrix_strategy(4, 4)
.prop_filter("square >= 2", |(n, m, _)| *n == *m && *n >= 2),
pin_row in 0..4usize,
pin_col in 0..4usize,
) {
let rows = n;
let cols = n;
let pr = pin_row % rows;
let pc = pin_col % cols;
let sol = assignment()
.rows(rows)
.cols(cols)
.cost(|i, k| costs[i * cols + k])
.unmatch_penalty(1e9)
.pin(pr, pc as i32)
.solve()
.expect("pinned problem must be solvable");
prop_assert_eq!(
sol.assign[pr],
pc as i32,
"pin ({}, {}) not respected: assign[{}] = {}",
pr,
pc,
pr,
sol.assign[pr],
);
}
#[test]
fn prop_unmatched_penalty_monotone(
(rows, cols, costs) in cost_matrix_strategy(4, 4)
) {
let penalties = [0.0, 1.0, 10.0, 100.0, 1e6];
let mut prev_matched = 0usize;
for (step, &penalty) in penalties.iter().enumerate() {
let sol = assignment()
.rows(rows)
.cols(cols)
.cost(|i, k| costs[i * cols + k])
.unmatch_penalty(penalty)
.solve()
.expect("problem must be solvable at any penalty");
let matched = sol.assign.iter().filter(|&&c| c != SENTINEL).count();
if step > 0 {
prop_assert!(
matched >= prev_matched,
"matched count dropped from {} to {} when penalty rose to {}",
prev_matched,
matched,
penalty,
);
}
prev_matched = matched;
}
}
#[test]
fn prop_single_row(
cols in 1..8usize,
costs in proptest::collection::vec(0.0..100.0_f64, 1..8),
) {
let cols = cols.min(costs.len());
let cost_slice = &costs[..cols];
let sol = assignment()
.rows(1)
.cols(cols)
.cost(|_, k| cost_slice[k])
.unmatch_penalty(1e9)
.solve()
.expect("1xM must be solvable");
let (min_col, min_cost) = cost_slice
.iter()
.enumerate()
.min_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
.unwrap();
prop_assert_eq!(
sol.assign[0],
min_col as i32,
"1x{}: expected col {} (cost {}), got col {} (cost {})",
cols,
min_col,
min_cost,
sol.assign[0],
sol.cost,
);
let delta = (sol.cost - min_cost).abs();
prop_assert!(delta < 1e-9, "cost mismatch: {} vs {}", sol.cost, min_cost);
}
}