use std::collections::BTreeSet;
use csp_solver::constraint::{LambdaConstraint, VarId};
use csp_solver::domain::BitsetDomain;
use csp_solver::ordering::Ordering;
use csp_solver::{Csp, Pruning, SolveConfig};
fn enumerate_cfg(pruning: Pruning, ordering: Ordering) -> SolveConfig {
SolveConfig {
pruning,
ordering,
max_solutions: usize::MAX,
node_budget: Some(50_000_000),
..Default::default()
}
}
fn set_of(sols: &[Vec<u32>]) -> BTreeSet<Vec<u32>> {
sols.iter().cloned().collect()
}
fn brute(domains: &[Vec<u32>], keep: impl Fn(&[u32]) -> bool) -> BTreeSet<Vec<u32>> {
let mut out = BTreeSet::new();
let mut cur = vec![0u32; domains.len()];
fn rec(
i: usize,
domains: &[Vec<u32>],
cur: &mut Vec<u32>,
keep: &dyn Fn(&[u32]) -> bool,
out: &mut BTreeSet<Vec<u32>>,
) {
if i == domains.len() {
if keep(cur) {
out.insert(cur.clone());
}
return;
}
for &v in &domains[i] {
cur[i] = v;
rec(i + 1, domains, cur, keep, out);
}
}
rec(0, domains, &mut cur, &keep, &mut out);
out
}
fn all_distinct(vals: &[u32]) -> bool {
let mut seen = BTreeSet::new();
vals.iter().all(|v| seen.insert(*v))
}
fn lambda_cage(scope: &[VarId], target: u32, product: bool) -> LambdaConstraint<BitsetDomain> {
let sc = scope.to_vec();
let inner = sc.clone();
LambdaConstraint::new(
sc,
move |a: &[Option<u32>]| {
let mut acc: i128 = if product { 1 } else { 0 };
for &v in &inner {
match a[v as usize] {
Some(x) if product => acc *= x as i128,
Some(x) => acc += x as i128,
None => return true,
}
}
acc == target as i128
},
if product {
"lambda_product"
} else {
"lambda_sum"
},
)
}
#[test]
fn cage_sum_solver_matches_bruteforce_and_is_set_invariant() {
let domains: Vec<Vec<u32>> = (0..4).map(|_| (1..=6).collect()).collect();
let target = 14u32;
let oracle = brute(&domains, |v| {
all_distinct(v) && v.iter().sum::<u32>() == target
});
assert!(!oracle.is_empty(), "oracle must be non-trivial");
let build = || {
let mut csp: Csp<BitsetDomain> = Csp::new();
let vars: Vec<VarId> = domains
.iter()
.map(|d| csp.add_variable(BitsetDomain::new(d.iter().copied())))
.collect();
csp.add_all_different(vars.clone());
csp.add_cage_sum(vars, target);
csp.finalize();
csp
};
let prunings = [
Pruning::None,
Pruning::ForwardChecking,
Pruning::Ac3,
Pruning::AcFc,
];
let orderings = [Ordering::Chronological, Ordering::FailFirst, Ordering::Mrv];
for p in prunings {
for o in orderings {
let mut csp = build();
let got = set_of(&csp.solve(&enumerate_cfg(p, o)));
assert!(!csp.stats().budget_exceeded, "{p:?}/{o:?} hit budget");
assert_eq!(
got, oracle,
"CageSum solver diverged from the brute-force oracle under {p:?}/{o:?}"
);
}
}
}
#[test]
fn cage_product_solver_matches_bruteforce_and_is_set_invariant() {
let domains: Vec<Vec<u32>> = (0..3).map(|_| (1..=6).collect()).collect();
let target = 24u32;
let oracle = brute(&domains, |v| {
v.iter().map(|&x| x as i128).product::<i128>() == target as i128
});
assert!(!oracle.is_empty(), "oracle must be non-trivial");
let build = || {
let mut csp: Csp<BitsetDomain> = Csp::new();
let vars: Vec<VarId> = domains
.iter()
.map(|d| csp.add_variable(BitsetDomain::new(d.iter().copied())))
.collect();
csp.add_cage_product(vars, target);
csp.finalize();
csp
};
let prunings = [
Pruning::None,
Pruning::ForwardChecking,
Pruning::Ac3,
Pruning::AcFc,
];
let orderings = [Ordering::Chronological, Ordering::FailFirst, Ordering::Mrv];
for p in prunings {
for o in orderings {
let mut csp = build();
let got = set_of(&csp.solve(&enumerate_cfg(p, o)));
assert!(!csp.stats().budget_exceeded, "{p:?}/{o:?} hit budget");
assert_eq!(
got, oracle,
"CageProduct solver diverged from the brute-force oracle under {p:?}/{o:?}"
);
}
}
}
const N4: usize = 4;
const NN4: usize = 16;
fn cell(r: usize, c: usize) -> VarId {
(r * N4 + c) as VarId
}
fn add_latin(csp: &mut Csp<BitsetDomain>, boxes: bool) {
for r in 0..N4 {
csp.add_all_different((0..N4).map(|c| cell(r, c)).collect());
}
for c in 0..N4 {
csp.add_all_different((0..N4).map(|r| cell(r, c)).collect());
}
if boxes {
for br in (0..N4).step_by(2) {
for bc in (0..N4).step_by(2) {
let mut b = Vec::new();
for dr in 0..2 {
for dc in 0..2 {
b.push(cell(br + dr, bc + dc));
}
}
csp.add_all_different(b);
}
}
}
}
const SEED: [u32; NN4] = [1, 2, 3, 4, 3, 4, 1, 2, 2, 1, 4, 3, 4, 3, 2, 1];
fn cage_partition() -> Vec<(Vec<VarId>, char)> {
vec![
(vec![0, 1, 4], '+'), (vec![2, 3, 7], '+'), (vec![5, 6, 10], '*'), (vec![8, 9, 12], '+'), (vec![13, 14], '-'), (vec![11, 15], '/'), ]
}
fn sum_of(scope: &[VarId]) -> u32 {
scope.iter().map(|&v| SEED[v as usize]).sum()
}
fn prod_of(scope: &[VarId]) -> u32 {
scope.iter().map(|&v| SEED[v as usize]).product()
}
fn add_diff(csp: &mut Csp<BitsetDomain>, a: VarId, b: VarId, diff: u32) {
csp.add_constraint(LambdaConstraint::new(
vec![a, b],
move |x: &[Option<u32>]| match (x[a as usize], x[b as usize]) {
(Some(p), Some(q)) => p.abs_diff(q) == diff,
_ => true,
},
"diff",
));
}
fn add_ratio(csp: &mut Csp<BitsetDomain>, a: VarId, b: VarId, q: u32) {
csp.add_constraint(LambdaConstraint::new(
vec![a, b],
move |x: &[Option<u32>]| match (x[a as usize], x[b as usize]) {
(Some(p), Some(r)) => {
let (hi, lo) = if p >= r { (p, r) } else { (r, p) };
lo != 0 && hi % lo == 0 && hi / lo == q
}
_ => true,
},
"ratio",
));
}
fn build_board(boxes: bool, use_cages: bool) -> Csp<BitsetDomain> {
let mut csp: Csp<BitsetDomain> = Csp::new();
let _ = csp.add_variables(&BitsetDomain::new(1..=N4 as u32), NN4);
add_latin(&mut csp, boxes);
for (scope, op) in cage_partition() {
match op {
'+' => {
let t = sum_of(&scope);
if use_cages {
csp.add_cage_sum(scope, t);
} else {
csp.add_constraint(lambda_cage(&scope, t, false));
}
}
'*' => {
let t = prod_of(&scope);
if use_cages {
csp.add_cage_product(scope, t);
} else {
csp.add_constraint(lambda_cage(&scope, t, true));
}
}
'-' => add_diff(
&mut csp,
scope[0],
scope[1],
SEED[scope[0] as usize].abs_diff(SEED[scope[1] as usize]),
),
'/' => {
let (a, b) = (scope[0], scope[1]);
let (hi, lo) = if SEED[a as usize] >= SEED[b as usize] {
(SEED[a as usize], SEED[b as usize])
} else {
(SEED[b as usize], SEED[a as usize])
};
add_ratio(&mut csp, a, b, hi / lo);
}
_ => unreachable!(),
}
}
csp.finalize();
csp
}
fn node_drop(boxes: bool) -> (u64, u64) {
let cfg = enumerate_cfg(Pruning::Ac3, Ordering::FailFirst);
let mut lam = build_board(boxes, false);
let lam_set = set_of(&lam.solve(&cfg));
let lam_nodes = lam.stats().nodes_explored;
assert!(!lam.stats().budget_exceeded, "lambda build hit node budget");
let mut cage = build_board(boxes, true);
let cage_set = set_of(&cage.solve(&cfg));
let cage_nodes = cage.stats().nodes_explored;
assert!(!cage.stats().budget_exceeded, "cage build hit node budget");
assert!(!lam_set.is_empty(), "board must be satisfiable");
assert!(
lam_set.contains(SEED.as_slice()),
"the seed solution must be in the enumerated set"
);
assert_eq!(
lam_set, cage_set,
"cage propagation changed the solution set — a soundness violation"
);
assert!(
cage_nodes < lam_nodes,
"cage explored {cage_nodes} nodes, lambda {lam_nodes} — the cage must prune strictly more"
);
(lam_nodes, cage_nodes)
}
#[test]
fn killer_cage_sum_node_drop_vs_lambda() {
let (lam, cage) = node_drop(true);
println!("KILLER lambda_nodes={lam} cage_nodes={cage}");
}
#[test]
fn kenken_mixed_cage_node_drop_vs_lambda() {
let (lam, cage) = node_drop(false);
println!("KENKEN lambda_nodes={lam} cage_nodes={cage}");
}