1use logicaffeine_proof::sat::{prove_unsat, UnsatOutcome};
15use logicaffeine_proof::ProofExpr;
16use std::collections::HashMap;
17
18#[derive(Clone, Debug, PartialEq)]
20pub struct Intersection {
21 pub movements: Vec<String>,
23 pub conflicts: Vec<(usize, usize)>,
25}
26
27impl Intersection {
28 pub fn names(&self, idxs: &[usize]) -> Vec<String> {
30 idxs.iter().filter_map(|&i| self.movements.get(i).cloned()).collect()
31 }
32}
33
34#[derive(Clone, Debug, PartialEq)]
36pub struct PhasePlan {
37 pub num_phases: usize,
39 pub assignment: Vec<usize>,
41 pub minimal_certified: bool,
44}
45
46impl PhasePlan {
47 pub fn groups(&self) -> Vec<Vec<usize>> {
49 let mut groups = vec![Vec::new(); self.num_phases];
50 for (m, &p) in self.assignment.iter().enumerate() {
51 if p < self.num_phases {
52 groups[p].push(m);
53 }
54 }
55 groups
56 }
57}
58
59pub fn is_valid_coloring(it: &Intersection, plan: &PhasePlan) -> bool {
61 if plan.assignment.len() != it.movements.len() {
62 return false;
63 }
64 if plan.assignment.iter().any(|&p| p >= plan.num_phases.max(1)) {
65 return false;
66 }
67 it.conflicts.iter().all(|&(a, b)| {
68 a == b
69 || a >= it.movements.len()
70 || b >= it.movements.len()
71 || plan.assignment.get(a) != plan.assignment.get(b)
72 })
73}
74
75pub fn design_phase_plan(it: &Intersection) -> Option<PhasePlan> {
92 let n = it.movements.len();
93 if n == 0 {
94 return None;
95 }
96 let adj = build_adjacency(it, n);
97
98 match two_color(&adj, n) {
103 Ok(coloring) => {
104 let num_phases = coloring.iter().copied().max().map(|c| c + 1).unwrap_or(1);
105 return Some(PhasePlan { num_phases, assignment: coloring, minimal_certified: true });
106 }
107 Err(odd_walk) => {
108 debug_assert!(
109 is_odd_closed_walk(&adj, &odd_walk),
110 "two_color must return a genuine odd closed walk: {odd_walk:?}"
111 );
112 }
113 }
114
115 let clique = greedy_clique(&adj, n);
116 let lb = clique.len().max(3);
118 let (greedy_colors, ub) = greedy_coloring(&adj, n);
119
120 if lb >= ub {
123 return Some(PhasePlan { num_phases: ub, assignment: greedy_colors, minimal_certified: true });
124 }
125
126 let mut prev_infeasible_certified = true;
130 for k in lb..ub {
131 match solve_coloring(it, &adj, &clique, k) {
132 ColoringResult::Feasible(assignment) => {
133 return Some(PhasePlan {
134 num_phases: k,
135 assignment,
136 minimal_certified: prev_infeasible_certified,
137 });
138 }
139 ColoringResult::Infeasible => prev_infeasible_certified = true,
140 ColoringResult::Unknown => prev_infeasible_certified = false,
141 }
142 }
143 Some(PhasePlan { num_phases: ub, assignment: greedy_colors, minimal_certified: prev_infeasible_certified })
146}
147
148pub fn design_from_spec(spec: &str) -> Result<(Intersection, PhasePlan), String> {
150 let it = parse_intersection(spec)?;
151 let plan =
152 design_phase_plan(&it).ok_or_else(|| "no movements to schedule".to_string())?;
153 Ok((it, plan))
154}
155
156enum ColoringResult {
159 Feasible(Vec<usize>),
160 Infeasible,
161 Unknown,
162}
163
164#[cfg(test)]
167thread_local! {
168 static SAT_SOLVE_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
169}
170
171#[cfg(test)]
173fn take_solve_count() -> usize {
174 SAT_SOLVE_COUNT.with(|c| {
175 let v = c.get();
176 c.set(0);
177 v
178 })
179}
180
181fn solve_coloring(it: &Intersection, adj: &[Vec<bool>], clique: &[usize], k: usize) -> ColoringResult {
182 #[cfg(test)]
183 SAT_SOLVE_COUNT.with(|c| c.set(c.get() + 1));
184 let formula = coloring_formula(it, adj, clique, k);
185 match prove_unsat(&formula) {
186 UnsatOutcome::Sat(model) => {
187 ColoringResult::Feasible(decode(it.movements.len(), k, &model))
188 }
189 UnsatOutcome::Refuted => ColoringResult::Infeasible,
190 UnsatOutcome::Unsupported => ColoringResult::Unknown,
191 }
192}
193
194fn assign_atom(m: usize, p: usize) -> ProofExpr {
196 ProofExpr::Atom(format!("a_{m}_{p}"))
197}
198
199fn and(a: ProofExpr, b: ProofExpr) -> ProofExpr {
200 ProofExpr::And(Box::new(a), Box::new(b))
201}
202
203fn not(a: ProofExpr) -> ProofExpr {
204 ProofExpr::Not(Box::new(a))
205}
206
207fn conj(mut parts: Vec<ProofExpr>) -> ProofExpr {
208 match parts.len() {
209 0 => {
210 let t = ProofExpr::Atom("__true".to_string());
211 ProofExpr::Or(Box::new(t.clone()), Box::new(ProofExpr::Not(Box::new(t))))
212 }
213 1 => parts.pop().unwrap(),
214 _ => {
215 let mut acc = parts.pop().unwrap();
216 while let Some(p) = parts.pop() {
217 acc = and(p, acc);
218 }
219 acc
220 }
221 }
222}
223
224fn coloring_formula(it: &Intersection, adj: &[Vec<bool>], clique: &[usize], k: usize) -> ProofExpr {
231 let n = it.movements.len();
232 let mut clauses: Vec<ProofExpr> = Vec::new();
233
234 for m in 0..n {
236 let mut disj = assign_atom(m, 0);
237 for p in 1..k {
238 disj = ProofExpr::Or(Box::new(disj), Box::new(assign_atom(m, p)));
239 }
240 clauses.push(disj);
241 }
242 for m in 0..n {
244 for p in 0..k {
245 for q in (p + 1)..k {
246 clauses.push(not(and(assign_atom(m, p), assign_atom(m, q))));
247 }
248 }
249 }
250 for x in 0..n {
253 for y in (x + 1)..n {
254 if adj[x][y] {
255 for p in 0..k {
256 clauses.push(not(and(assign_atom(x, p), assign_atom(y, p))));
257 }
258 }
259 }
260 }
261 for (i, &m) in clique.iter().enumerate() {
263 if i < k {
264 clauses.push(assign_atom(m, i));
265 }
266 }
267 conj(clauses)
268}
269
270fn build_adjacency(it: &Intersection, n: usize) -> Vec<Vec<bool>> {
275 let mut adj = vec![vec![false; n]; n];
276 for &(x, y) in &it.conflicts {
277 if x != y && x < n && y < n {
278 adj[x][y] = true;
279 adj[y][x] = true;
280 }
281 }
282 adj
283}
284
285fn degree_order(adj: &[Vec<bool>], n: usize) -> Vec<usize> {
288 let deg = |v: usize| adj[v].iter().filter(|&&b| b).count();
289 let mut order: Vec<usize> = (0..n).collect();
290 order.sort_by(|&a, &b| deg(b).cmp(°(a)).then(a.cmp(&b)));
291 order
292}
293
294fn greedy_clique(adj: &[Vec<bool>], n: usize) -> Vec<usize> {
298 if n == 0 {
299 return Vec::new();
300 }
301 let order = degree_order(adj, n);
302 let mut best = vec![order[0]];
303 for &start in &order {
304 let mut clique = vec![start];
305 for &v in &order {
306 if v != start && clique.iter().all(|&u| adj[v][u]) {
307 clique.push(v);
308 }
309 }
310 if clique.len() > best.len() {
311 best = clique;
312 }
313 }
314 best
315}
316
317fn greedy_coloring(adj: &[Vec<bool>], n: usize) -> (Vec<usize>, usize) {
320 let mut color = vec![usize::MAX; n];
321 let mut num_colors = 0usize;
322 for v in degree_order(adj, n) {
323 let mut used = vec![false; num_colors + 1];
324 for (u, &is_adj) in adj[v].iter().enumerate() {
325 if is_adj && color[u] != usize::MAX && color[u] < used.len() {
326 used[color[u]] = true;
327 }
328 }
329 let c = used.iter().position(|&b| !b).unwrap_or(num_colors);
330 color[v] = c;
331 num_colors = num_colors.max(c + 1);
332 }
333 for c in color.iter_mut() {
334 if *c == usize::MAX {
335 *c = 0;
336 }
337 }
338 (color, num_colors.max(1))
339}
340
341fn two_color(adj: &[Vec<bool>], n: usize) -> Result<Vec<usize>, Vec<usize>> {
346 let mut color = vec![usize::MAX; n];
347 let mut parent = vec![usize::MAX; n];
348 for start in 0..n {
349 if color[start] != usize::MAX {
350 continue;
351 }
352 color[start] = 0;
353 let mut stack = vec![start];
354 while let Some(v) = stack.pop() {
355 for u in 0..n {
356 if !adj[v][u] {
357 continue;
358 }
359 if color[u] == usize::MAX {
360 color[u] = 1 - color[v];
361 parent[u] = v;
362 stack.push(u);
363 } else if color[u] == color[v] {
364 return Err(odd_walk(&parent, v, u));
365 }
366 }
367 }
368 }
369 Ok(color)
370}
371
372fn odd_walk(parent: &[usize], v: usize, u: usize) -> Vec<usize> {
375 let to_root = |mut x: usize| {
376 let mut path = vec![x];
377 while parent[x] != usize::MAX {
378 x = parent[x];
379 path.push(x);
380 }
381 path
382 };
383 let mut walk = to_root(v); let mut up_u = to_root(u); up_u.reverse(); walk.extend(up_u.into_iter().skip(1)); walk
388}
389
390fn is_odd_closed_walk(adj: &[Vec<bool>], walk: &[usize]) -> bool {
394 let m = walk.len();
395 m >= 3 && m % 2 == 1 && (0..m).all(|i| adj[walk[i]][walk[(i + 1) % m]])
396}
397
398fn decode(n: usize, k: usize, model: &[(String, bool)]) -> Vec<usize> {
399 let truth: HashMap<&str, bool> = model.iter().map(|(s, b)| (s.as_str(), *b)).collect();
400 let mut assignment = vec![0usize; n];
401 for m in 0..n {
402 for p in 0..k {
403 let key = format!("a_{m}_{p}");
404 if *truth.get(key.as_str()).unwrap_or(&false) {
405 assignment[m] = p;
406 break;
407 }
408 }
409 }
410 assignment
411}
412
413pub fn parse_intersection(spec: &str) -> Result<Intersection, String> {
424 let mut names: Vec<String> = Vec::new();
425 let mut index: HashMap<String, usize> = HashMap::new();
426 let mut conflicts: Vec<(usize, usize)> = Vec::new();
427
428 for raw in spec.split(['.', '\n']) {
429 let sentence = raw.trim();
430 if sentence.is_empty() {
431 continue;
432 }
433 let lower = sentence.to_lowercase();
434
435 if let Some(mpos) = lower.find("movements:") {
436 let list = &lower[mpos + "movements:".len()..];
437 for m in split_list(list) {
438 intern_movement(&mut names, &mut index, &m);
439 }
440 continue;
441 }
442
443 if let Some(cpos) = lower.find("conflict") {
444 if let Some(wrel) = lower[cpos..].find("with") {
445 let subject = &lower[..cpos];
446 let objects = &lower[cpos + wrel + "with".len()..];
447 if let Some(si) = intern_movement(&mut names, &mut index, subject) {
448 for obj in split_list(objects) {
449 if let Some(oi) = intern_movement(&mut names, &mut index, &obj) {
450 if si != oi {
451 push_conflict(&mut conflicts, si, oi);
452 }
453 }
454 }
455 }
456 }
457 }
458 }
459
460 if names.is_empty() {
461 return Err("no movements found — describe conflicts like \
462 \"northbound-left conflicts with southbound-through\""
463 .to_string());
464 }
465 Ok(Intersection { movements: names, conflicts })
466}
467
468fn intern_movement(
469 names: &mut Vec<String>,
470 index: &mut HashMap<String, usize>,
471 raw: &str,
472) -> Option<usize> {
473 let name = clean_name(raw);
474 if name.is_empty() {
475 return None;
476 }
477 let key = name.to_lowercase();
478 if let Some(&i) = index.get(&key) {
479 return Some(i);
480 }
481 let i = names.len();
482 names.push(name);
483 index.insert(key, i);
484 Some(i)
485}
486
487fn clean_name(raw: &str) -> String {
488 let mut s = raw
489 .trim()
490 .trim_matches(|c: char| c == '.' || c == ',' || c == ';' || c == ':')
491 .trim()
492 .to_string();
493 for article in ["the ", "a ", "an "] {
494 if s.to_lowercase().starts_with(article) {
495 s = s[article.len()..].trim().to_string();
496 }
497 }
498 s.split_whitespace().collect::<Vec<_>>().join(" ")
499}
500
501fn split_list(s: &str) -> Vec<String> {
502 s.replace(" and ", ",")
503 .replace('&', ",")
504 .split(',')
505 .map(|x| x.trim().to_string())
506 .filter(|x| !x.is_empty())
507 .collect()
508}
509
510fn push_conflict(conflicts: &mut Vec<(usize, usize)>, a: usize, b: usize) {
511 let pair = (a.min(b), a.max(b));
512 if !conflicts.contains(&pair) {
513 conflicts.push(pair);
514 }
515}
516
517#[cfg(test)]
518mod tests {
519 use super::*;
520
521 fn graph(n: usize, conflicts: &[(usize, usize)]) -> Intersection {
522 Intersection {
523 movements: (0..n).map(|i| format!("m{i}")).collect(),
524 conflicts: conflicts.iter().map(|&(a, b)| (a.min(b), a.max(b))).collect(),
525 }
526 }
527
528 #[test]
531 fn conflict_free_needs_one_phase() {
532 let plan = design_phase_plan(&graph(3, &[])).unwrap();
533 assert_eq!(plan.num_phases, 1);
534 assert!(plan.minimal_certified);
535 }
536
537 #[test]
538 fn one_conflict_needs_two_phases() {
539 let g = graph(2, &[(0, 1)]);
540 let plan = design_phase_plan(&g).unwrap();
541 assert_eq!(plan.num_phases, 2);
542 assert!(is_valid_coloring(&g, &plan));
543 assert!(plan.minimal_certified, "1 phase must be RUP-refuted");
544 }
545
546 #[test]
547 fn triangle_needs_three_phases() {
548 let g = graph(3, &[(0, 1), (1, 2), (0, 2)]);
549 let plan = design_phase_plan(&g).unwrap();
550 assert_eq!(plan.num_phases, 3);
551 assert!(is_valid_coloring(&g, &plan));
552 assert!(plan.minimal_certified);
553 }
554
555 #[test]
556 fn even_cycle_is_two_colourable() {
557 let g = graph(4, &[(0, 1), (1, 2), (2, 3), (3, 0)]);
558 let plan = design_phase_plan(&g).unwrap();
559 assert_eq!(plan.num_phases, 2);
560 assert!(is_valid_coloring(&g, &plan));
561 }
562
563 #[test]
564 fn odd_cycle_needs_three_phases() {
565 let g = graph(5, &[(0, 1), (1, 2), (2, 3), (3, 4), (4, 0)]);
566 let plan = design_phase_plan(&g).unwrap();
567 assert_eq!(plan.num_phases, 3);
568 assert!(is_valid_coloring(&g, &plan));
569 }
570
571 #[test]
572 fn k4_needs_four_phases() {
573 let g = graph(4, &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)]);
574 let plan = design_phase_plan(&g).unwrap();
575 assert_eq!(plan.num_phases, 4);
576 assert!(is_valid_coloring(&g, &plan));
577 assert!(plan.minimal_certified);
578 }
579
580 #[test]
581 fn every_returned_plan_is_a_valid_coloring() {
582 for g in [
584 graph(1, &[]),
585 graph(6, &[(0, 1), (2, 3), (4, 5)]),
586 graph(5, &[(0, 1), (0, 2), (0, 3), (0, 4)]), graph(4, &[(0, 1), (1, 2), (2, 3)]), ] {
589 let plan = design_phase_plan(&g).unwrap();
590 assert!(is_valid_coloring(&g, &plan), "invalid plan for {g:?}: {plan:?}");
591 }
592 }
593
594 #[test]
595 fn star_is_two_colourable() {
596 let g = graph(5, &[(0, 1), (0, 2), (0, 3), (0, 4)]);
597 assert_eq!(design_phase_plan(&g).unwrap().num_phases, 2);
598 }
599
600 #[test]
601 fn groups_partition_the_movements() {
602 let g = graph(4, &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)]);
603 let plan = design_phase_plan(&g).unwrap();
604 let total: usize = plan.groups().iter().map(|g| g.len()).sum();
605 assert_eq!(total, 4);
606 assert_eq!(plan.groups().len(), plan.num_phases);
607 }
608
609 #[test]
612 fn parses_a_simple_conflict() {
613 let it = parse_intersection("Northbound-left conflicts with southbound-through.").unwrap();
614 assert_eq!(it.movements.len(), 2, "{:?}", it.movements);
615 assert_eq!(it.conflicts.len(), 1);
616 }
617
618 #[test]
619 fn parses_a_list_with_and_and_articles() {
620 let it = parse_intersection(
621 "Northbound-left conflicts with southbound-through and the east-west crossing.",
622 )
623 .unwrap();
624 assert_eq!(it.movements.len(), 3, "{:?}", it.movements);
625 assert_eq!(it.conflicts.len(), 2);
626 }
627
628 #[test]
629 fn movements_declaration_keeps_isolated_movements() {
630 let it = parse_intersection(
631 "Movements: ns-through, ew-through, pedestrian.\n\
632 ns-through conflicts with ew-through.",
633 )
634 .unwrap();
635 assert_eq!(it.movements.len(), 3, "pedestrian must remain: {:?}", it.movements);
636 assert_eq!(it.conflicts.len(), 1);
637 }
638
639 #[test]
640 fn dedupes_symmetric_conflicts() {
641 let it = parse_intersection("alpha conflicts with beta.\nbeta conflicts with alpha.").unwrap();
642 assert_eq!(it.conflicts.len(), 1);
643 }
644
645 #[test]
646 fn empty_spec_is_an_error() {
647 assert!(parse_intersection(" ").is_err());
648 }
649
650 #[test]
651 fn end_to_end_english_to_certified_plan() {
652 let (it, plan) = design_from_spec(
653 "Movements: ns, ew, ped.\n\
654 ns conflicts with ew.\n\
655 ped conflicts with ns and ew.",
656 )
657 .unwrap();
658 assert_eq!(plan.num_phases, 3);
660 assert!(is_valid_coloring(&it, &plan));
661 assert!(plan.minimal_certified);
662 }
663
664 #[test]
667 fn self_conflict_is_ignored_not_unsatisfiable() {
668 let g = Intersection {
671 movements: vec!["a".into(), "b".into()],
672 conflicts: vec![(0, 0), (0, 1)],
673 };
674 let plan = design_phase_plan(&g).expect("self-conflict must not break solving");
675 assert_eq!(plan.num_phases, 2);
676 assert!(is_valid_coloring(&g, &plan));
677 }
678
679 #[test]
680 fn out_of_range_conflict_is_ignored() {
681 let g = Intersection {
683 movements: vec!["a".into(), "b".into()],
684 conflicts: vec![(0, 9), (0, 1)],
685 };
686 let plan = design_phase_plan(&g).expect("dangling conflict must not break solving");
687 assert_eq!(plan.num_phases, 2);
688 assert!(is_valid_coloring(&g, &plan));
689 }
690
691 #[test]
692 fn disconnected_components_share_phases() {
693 let g = graph(4, &[(0, 1), (2, 3)]);
695 let plan = design_phase_plan(&g).unwrap();
696 assert_eq!(plan.num_phases, 2);
697 assert!(is_valid_coloring(&g, &plan));
698 assert!(plan.minimal_certified);
699 }
700
701 #[test]
702 fn realistic_eight_movement_intersection() {
703 let g = graph(
706 8,
707 &[
708 (0, 1), (0, 4), (0, 5), (0, 6), (0, 7),
709 (1, 4), (1, 5), (1, 6), (1, 7),
710 (2, 3), (2, 6), (2, 7),
711 (3, 6), (3, 7),
712 (6, 7),
713 ],
714 );
715 let plan = design_phase_plan(&g).unwrap();
716 assert!(is_valid_coloring(&g, &plan), "plan must be conflict-free: {plan:?}");
717 assert!(plan.minimal_certified, "minimality must be RUP-certified");
718 assert!(plan.num_phases >= 2 && plan.num_phases <= 8);
719 }
720
721 #[test]
722 fn parses_singular_conflict_with_and_three_objects() {
723 let it = parse_intersection("ped conflicts with ns, ew, and nsl.").unwrap();
724 assert_eq!(it.movements.len(), 4, "{:?}", it.movements);
725 assert_eq!(it.conflicts.len(), 3);
726 }
727
728 fn grotzsch() -> Intersection {
734 let mut edges = vec![(0, 1), (1, 2), (2, 3), (3, 4), (4, 0)];
736 let b_neighbors = [(4, 1), (0, 2), (1, 3), (2, 4), (3, 0)];
737 for (i, &(lo, hi)) in b_neighbors.iter().enumerate() {
738 edges.push((5 + i, lo));
739 edges.push((5 + i, hi));
740 edges.push((10, 5 + i));
741 }
742 graph(11, &edges)
743 }
744
745 fn is_clique(adj: &[Vec<bool>], clique: &[usize]) -> bool {
746 clique.iter().enumerate().all(|(i, &u)| {
747 clique.iter().skip(i + 1).all(|&v| adj[u][v])
748 })
749 }
750
751 #[test]
752 fn greedy_clique_returns_a_genuine_clique() {
753 for g in [
754 graph(4, &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)]), graph(3, &[(0, 1), (1, 2), (0, 2)]), graph(5, &[(0, 1), (1, 2), (2, 3), (3, 4), (4, 0)]), grotzsch(), ] {
759 let n = g.movements.len();
760 let adj = build_adjacency(&g, n);
761 let clique = greedy_clique(&adj, n);
762 assert!(is_clique(&adj, &clique), "not a clique for {g:?}: {clique:?}");
763 }
764 let k4 = graph(4, &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)]);
766 assert_eq!(greedy_clique(&build_adjacency(&k4, 4), 4).len(), 4);
767 }
768
769 #[test]
770 fn greedy_coloring_is_always_proper() {
771 for g in [
772 grotzsch(),
773 graph(5, &[(0, 1), (1, 2), (2, 3), (3, 4), (4, 0)]),
774 graph(6, &[(0, 1), (2, 3), (4, 5)]),
775 ] {
776 let n = g.movements.len();
777 let adj = build_adjacency(&g, n);
778 let (colors, ub) = greedy_coloring(&adj, n);
779 for x in 0..n {
780 for y in (x + 1)..n {
781 if adj[x][y] {
782 assert_ne!(colors[x], colors[y], "adjacent {x},{y} share a colour in {g:?}");
783 }
784 }
785 }
786 assert!(colors.iter().all(|&c| c < ub), "colour out of range in {g:?}");
787 }
788 }
789
790 #[test]
791 fn perfect_graph_design_uses_zero_sat_solves() {
792 for g in [
795 graph(4, &[(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)]),
796 graph(3, &[(0, 1), (1, 2), (0, 2)]),
797 graph(4, &[(0, 1), (1, 2), (2, 3), (3, 0)]),
798 graph(5, &[(0, 1), (0, 2), (0, 3), (0, 4)]),
799 graph(3, &[]),
800 ] {
801 let _ = take_solve_count();
802 let plan = design_phase_plan(&g).unwrap();
803 assert_eq!(take_solve_count(), 0, "perfect graph must not call the solver: {g:?}");
804 assert!(is_valid_coloring(&g, &plan));
805 assert!(plan.minimal_certified);
806 }
807 }
808
809 fn petersen() -> Intersection {
813 graph(
814 10,
815 &[
816 (0, 1), (1, 2), (2, 3), (3, 4), (4, 0),
817 (0, 5), (1, 6), (2, 7), (3, 8), (4, 9),
818 (5, 7), (7, 9), (9, 6), (6, 8), (8, 5),
819 ],
820 )
821 }
822
823 #[test]
824 fn bipartite_graphs_use_zero_sat_solves() {
825 for g in [
828 graph(4, &[(0, 1), (1, 2), (2, 3), (3, 0)]), graph(5, &[(0, 1), (0, 2), (0, 3), (0, 4)]), graph(4, &[(0, 1), (1, 2), (2, 3)]), graph(4, &[(0, 1), (2, 3)]), graph(3, &[]), ] {
834 let _ = take_solve_count();
835 let plan = design_phase_plan(&g).unwrap();
836 assert_eq!(take_solve_count(), 0, "bipartite graph must not call the solver: {g:?}");
837 assert!(is_valid_coloring(&g, &plan));
838 assert!(plan.minimal_certified);
839 }
840 }
841
842 #[test]
843 fn odd_cycle_design_uses_zero_sat_solves() {
844 let g = graph(5, &[(0, 1), (1, 2), (2, 3), (3, 4), (4, 0)]);
847 let _ = take_solve_count();
848 let plan = design_phase_plan(&g).unwrap();
849 assert_eq!(take_solve_count(), 0, "odd-cycle bound should make C5 solver-free");
850 assert_eq!(plan.num_phases, 3);
851 assert!(plan.minimal_certified);
852 }
853
854 #[test]
855 fn petersen_uses_zero_sat_solves() {
856 let g = petersen();
857 let _ = take_solve_count();
858 let plan = design_phase_plan(&g).unwrap();
859 assert_eq!(take_solve_count(), 0, "Petersen: odd-cycle bound meets greedy ub");
860 assert_eq!(plan.num_phases, 3);
861 assert!(is_valid_coloring(&g, &plan));
862 assert!(plan.minimal_certified);
863 }
864
865 #[test]
866 fn grotzsch_design_uses_a_single_sat_solve() {
867 let g = grotzsch();
871 let _ = take_solve_count();
872 let plan = design_phase_plan(&g).unwrap();
873 assert_eq!(take_solve_count(), 1, "Grötzsch should need exactly one refutation solve");
874 assert_eq!(plan.num_phases, 4);
875 assert!(plan.minimal_certified);
876 }
877
878 #[test]
879 fn two_color_detects_bipartiteness_and_certifies_odd_cycles() {
880 for g in [
882 graph(4, &[(0, 1), (1, 2), (2, 3), (3, 0)]),
883 graph(4, &[(0, 1), (1, 2), (2, 3)]),
884 graph(5, &[(0, 1), (0, 2), (0, 3), (0, 4)]),
885 ] {
886 let n = g.movements.len();
887 let adj = build_adjacency(&g, n);
888 let coloring = two_color(&adj, n).expect("graph is bipartite");
889 for x in 0..n {
890 for y in (x + 1)..n {
891 if adj[x][y] {
892 assert_ne!(coloring[x], coloring[y], "improper 2-colouring of {g:?}");
893 }
894 }
895 }
896 }
897 for g in [
899 graph(3, &[(0, 1), (1, 2), (0, 2)]),
900 graph(5, &[(0, 1), (1, 2), (2, 3), (3, 4), (4, 0)]),
901 petersen(),
902 grotzsch(),
903 ] {
904 let n = g.movements.len();
905 let adj = build_adjacency(&g, n);
906 let walk = two_color(&adj, n).expect_err("graph is not bipartite");
907 assert!(
908 is_odd_closed_walk(&adj, &walk),
909 "two_color must certify non-bipartiteness with an odd walk for {g:?}: {walk:?}"
910 );
911 }
912 }
913
914 #[test]
915 fn grotzsch_chromatic_number_is_four_and_certified() {
916 let g = grotzsch();
919 let plan = design_phase_plan(&g).unwrap();
920 assert_eq!(plan.num_phases, 4, "Grötzsch is 4-chromatic");
921 assert!(is_valid_coloring(&g, &plan), "plan must be conflict-free: {plan:?}");
922 assert!(plan.minimal_certified, "3 phases must be RUP-refuted");
923 }
924
925 #[test]
926 fn build_adjacency_drops_self_and_out_of_range() {
927 let g = Intersection {
928 movements: vec!["a".into(), "b".into()],
929 conflicts: vec![(0, 0), (0, 9), (0, 1)],
930 };
931 let adj = build_adjacency(&g, 2);
932 assert!(adj[0][1] && adj[1][0], "real conflict kept");
933 assert!(!adj[0][0], "self-loop dropped");
934 }
935}