Skip to main content

logicaffeine_compile/codegen_sva/
signal_design.rs

1//! Conflict-free traffic-signal PHASE DESIGNER (certified SAT synthesis).
2//!
3//! Grouping intersection movements into green phases so no two *conflicting* movements ever share
4//! a phase is exactly graph k-colouring — a SAT problem. We encode it as a boolean [`ProofExpr`]
5//! and discharge it with the project's own certified solver (`logicaffeine_proof::sat`), never Z3:
6//!
7//! * a feasible plan is a SAT **model** (a witness that is trivially re-checkable), and
8//! * the claim "fewer phases is impossible" is a `prove_unsat` **`Refuted`** result, which is
9//!   RUP-certified — so the *minimality* of the plan is certified, not merely asserted.
10//!
11//! The English frontend is a focused grammar ("X conflicts with Y, Z and …") rather than the
12//! general FOL pipeline, so the designer is robust independent of that surface.
13
14use logicaffeine_proof::sat::{prove_unsat, UnsatOutcome};
15use logicaffeine_proof::ProofExpr;
16use std::collections::HashMap;
17
18/// An intersection: a set of movements and the pairs that may not be green together.
19#[derive(Clone, Debug, PartialEq)]
20pub struct Intersection {
21    /// Movement display names, in first-seen order.
22    pub movements: Vec<String>,
23    /// Conflicting movement pairs, as `(min, max)` indices into [`movements`](Self::movements).
24    pub conflicts: Vec<(usize, usize)>,
25}
26
27impl Intersection {
28    /// Resolve a slice of movement indices to their display names.
29    pub fn names(&self, idxs: &[usize]) -> Vec<String> {
30        idxs.iter().filter_map(|&i| self.movements.get(i).cloned()).collect()
31    }
32}
33
34/// A synthesized signal plan: each movement assigned to a green phase.
35#[derive(Clone, Debug, PartialEq)]
36pub struct PhasePlan {
37    /// Number of green phases used (the chromatic number when `minimal_certified`).
38    pub num_phases: usize,
39    /// `assignment[m]` = the phase index movement `m` is served in.
40    pub assignment: Vec<usize>,
41    /// `true` when `num_phases - 1` was proven infeasible (RUP-certified) — i.e. this is provably
42    /// the *fewest* phases possible. (Trivially `true` for a single phase.)
43    pub minimal_certified: bool,
44}
45
46impl PhasePlan {
47    /// Movement indices grouped by phase, in phase order.
48    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
59/// Re-check that a plan is a genuine conflict-free colouring (used by tests and as a guard).
60pub 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
75/// Design the minimal-phase conflict-free plan for an intersection, or `None` if it has no
76/// movements.
77///
78/// The chromatic number `χ` is pinned between two **checkable combinatorial certificates** before
79/// the SAT solver is ever consulted:
80///
81/// * a maximal **clique** of mutually-conflicting movements proves `χ ≥ |clique|` (you can verify
82///   it by eye — every pair really is in conflict), and
83/// * a **greedy proper colouring** is a self-checking witness that `χ ≤ greedy`.
84///
85/// When those meet (`|clique| == greedy`) the greedy colouring is provably optimal and we return it
86/// with **zero SAT calls** — the common case for the perfect-ish conflict graphs real intersections
87/// produce. Otherwise we close the gap `[lb, ub)` with the certified solver, pinning the clique to
88/// phases `0..lb-1` to break colour-permutation symmetry; the first feasible `k` is `χ`, its witness
89/// is a SAT model, and minimality is certified either by the clique (`k == lb`) or by the RUP-
90/// `Refuted` solve at `k-1`. Either route is certified end-to-end.
91pub 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    // Sharpest cheap lever first — bipartiteness. A 2-colouring (BFS, O(V+E)) either *is* the
99    // certified-minimal plan (the graph is 1- or 2-chromatic; any edge rules out a single phase),
100    // or it fails and hands back an **odd closed walk**: a checkable certificate that χ ≥ 3, since
101    // colours must alternate along any walk and a 2-colourable graph therefore has none.
102    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    // χ ≥ 3 (the odd walk just proved non-bipartiteness) and χ ≥ |clique|.
117    let lb = clique.len().max(3);
118    let (greedy_colors, ub) = greedy_coloring(&adj, n);
119
120    // Fast path: lower bound meets the greedy upper bound, so the greedy colouring is optimal and
121    // fewer phases is impossible — certified by the clique (when it sets the bound) or the odd walk.
122    if lb >= ub {
123        return Some(PhasePlan { num_phases: ub, assignment: greedy_colors, minimal_certified: true });
124    }
125
126    // Close the gap. `k = lb-1 ≥ 2` is already infeasible by a certificate (the clique when
127    // `lb = |clique|`, otherwise the odd walk, which forbids any 2-colouring), so entering the scan
128    // at `lb` is sound and `lb` is certified-minimal the moment it proves feasible.
129    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    // Every `k` in `[lb, ub)` was infeasible, so `χ = ub`, witnessed by the greedy colouring we
144    // already hold — no need to re-solve at `ub`.
145    Some(PhasePlan { num_phases: ub, assignment: greedy_colors, minimal_certified: prev_infeasible_certified })
146}
147
148/// Parse an English spec into an [`Intersection`], then design its minimal plan.
149pub 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
156// ── SAT encoding ────────────────────────────────────────────────────────────
157
158enum ColoringResult {
159    Feasible(Vec<usize>),
160    Infeasible,
161    Unknown,
162}
163
164/// Counts every actual SAT query, so tests can prove the bounds short-circuit (zero solves for a
165/// perfect graph, one for an odd cycle, etc.) — the whole point of the speedup.
166#[cfg(test)]
167thread_local! {
168    static SAT_SOLVE_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
169}
170
171/// Read and reset the per-thread SAT-solve counter (test instrumentation).
172#[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
194/// `a_m_p` ≙ "movement `m` is served in phase `p`".
195fn 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
224/// Build the boolean k-colouring obligation: every movement gets exactly one phase, and no
225/// conflicting pair shares a phase. The `clique` is pinned to phases `0..|clique|` as a
226/// **symmetry-breaking** constraint — colours are interchangeable, so a clique of distinct colours
227/// can WLOG take `0, 1, 2, …` in order; this is satisfiability-preserving (so a SAT model is a real
228/// witness and a RUP refutation still certifies the original UNSAT) while collapsing the otherwise
229/// factorial colour-permutation search.
230fn 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    // Every movement is served in at least one phase.
235    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    // …and at most one phase (no movement runs in two phases at once).
243    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    // Conflicting movements never share a phase. (Adjacency already dropped self-loops and
251    // out-of-range pairs, so no nonsensical conflict can make the problem unsatisfiable.)
252    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    // Symmetry break: pin clique member `i` to phase `i` (sound because k ≥ |clique| here).
262    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
270// ── combinatorial bounds (checkable certificates that often avoid the solver) ──
271
272/// `n × n` symmetric adjacency from the conflict pairs, with self-loops and out-of-range indices
273/// dropped — the single normalised view shared by the bounds and the SAT encoding.
274fn 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
285/// Degree of each vertex, then a descending-degree order (ties by index) — the ordering both the
286/// clique and the colouring heuristics use.
287fn 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(&deg(a)).then(a.cmp(&b)));
291    order
292}
293
294/// A large clique of mutually-conflicting movements: try growing greedily from each vertex (in
295/// degree order) and keep the biggest. Any clique is a sound lower bound on the chromatic number;
296/// a larger one just makes it tighter, so heuristic maximality is enough.
297fn 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
317/// A greedy proper colouring (Welsh–Powell: descending degree, smallest non-conflicting colour).
318/// Returns the assignment and the number of colours used — a self-checking upper bound on `χ`.
319fn 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
341/// BFS 2-colouring. `Ok(colouring)` when the graph is bipartite (χ ≤ 2, the colouring uses 1 colour
342/// if edgeless else 2); `Err(odd_walk)` otherwise, where `odd_walk` is an odd closed walk witnessing
343/// χ ≥ 3. The walk is `v … root … u` (closed by the conflicting edge `u–v`); since `v` and `u` were
344/// found at the same colour their BFS depths share parity, so the walk has odd length.
345fn 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
372/// Reconstruct the odd closed walk `v → … → root → … → u` from the BFS parent forest (the edge
373/// `u–v` then closes it).
374fn 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); // v … root
384    let mut up_u = to_root(u); // u … root
385    up_u.reverse(); // root … u
386    walk.extend(up_u.into_iter().skip(1)); // … u (drop the duplicated root)
387    walk
388}
389
390/// Verify a vertex sequence is a genuine odd closed walk: every consecutive pair (including the
391/// wrap-around) is an edge, and the number of edges is odd — the property that makes it a checkable
392/// certificate of `χ ≥ 3`.
393fn 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
413// ── English frontend (focused grammar) ──────────────────────────────────────
414
415/// Parse a focused English spec into an [`Intersection`].
416///
417/// Recognised:
418/// * `Movements: a, b, c.` — an optional explicit movement set (so isolated movements appear).
419/// * `<A> conflicts with <B>, <C> and <D>.` — `A` may not share a phase with any of `B`, `C`, `D`.
420///
421/// Movement names are everything else (articles `the`/`a`/`an` are dropped). Names are matched
422/// case-insensitively; the first spelling seen is the display name.
423pub 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    // ── colouring / minimality (the certified core) ──
529
530    #[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        // A grab-bag of graphs: the synthesized plan must always be conflict-free.
583        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)]), // star → 2 phases
587            graph(4, &[(0, 1), (1, 2), (2, 3)]),         // path → 2 phases
588        ] {
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    // ── English frontend ──
610
611    #[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        // ns–ew, ped–ns, ped–ew form a triangle → exactly 3 phases.
659        assert_eq!(plan.num_phases, 3);
660        assert!(is_valid_coloring(&it, &plan));
661        assert!(plan.minimal_certified);
662    }
663
664    // ── adversarial / robustness ──
665
666    #[test]
667    fn self_conflict_is_ignored_not_unsatisfiable() {
668        // A movement "conflicting with itself" is nonsense; it must be ignored, not make the
669        // whole problem unsolvable. Here only (0,1) is a real conflict → 2 phases.
670        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        // A hand-built intersection with a dangling index must not poison the solve.
682        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        // Two independent conflict pairs are both 2-colourable together → 2 phases, not 4.
694        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        // NS/EW through + 4 protected lefts + 2 peds, with a plausible conflict matrix; the
704        // designer must return a valid, certified-minimal plan whatever the chromatic number is.
705        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    // ── certified-speedup machinery (bounds short-circuit + symmetry break) ──
729
730    /// The Grötzsch graph (Mycielskian of C5): triangle-free so its clique number is 2, yet its
731    /// chromatic number is 4. The clique lower bound is far below `χ`, so the fast path can't fire
732    /// and the certified SAT scan must close the whole gap — and certify minimality by RUP.
733    fn grotzsch() -> Intersection {
734        // a0..a4 = 0..4 (outer C5), b0..b4 = 5..9, c = 10.
735        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)]), // K4 → clique 4
755            graph(3, &[(0, 1), (1, 2), (0, 2)]),                          // triangle → 3
756            graph(5, &[(0, 1), (1, 2), (2, 3), (3, 4), (4, 0)]),          // C5 → 2
757            grotzsch(),                                                    // triangle-free → 2
758        ] {
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        // Exact sizes on the easy cases.
765        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        // K4, triangle, even cycle and star are perfect: clique == greedy, so the bounds meet and
793        // the solver is never invoked.
794        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    /// The Petersen graph: 3-chromatic, triangle-free (clique number 2). The clique bound can't
810    /// reach χ, but the odd-cycle bound (3) meets the greedy upper bound (3), so it is now solved
811    /// with zero SAT calls.
812    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        // Even cycle, star, path and disjoint edges are all bipartite → the 2-colouring IS the
826        // certified plan, no solver involved.
827        for g in [
828            graph(4, &[(0, 1), (1, 2), (2, 3), (3, 0)]),  // even cycle → 2
829            graph(5, &[(0, 1), (0, 2), (0, 3), (0, 4)]),  // star → 2
830            graph(4, &[(0, 1), (1, 2), (2, 3)]),          // path → 2
831            graph(4, &[(0, 1), (2, 3)]),                  // two disjoint edges → 2
832            graph(3, &[]),                                // edgeless → 1
833        ] {
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        // C5: the odd-cycle lower bound (3) meets the greedy upper bound (3) → no SAT solve at all
845        // (the previous clique-only designer still needed one refutation here).
846        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        // The lone case in the whole corpus that still needs the solver: χ=4 sits strictly above
868        // both the odd-cycle bound (3) and the clique bound (2), so exactly one refutation (k=3)
869        // closes the gap; the greedy witness covers k=4.
870        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        // Bipartite graphs are 2-coloured properly.
881        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        // Non-bipartite graphs yield a genuine odd closed walk (a checkable χ ≥ 3 certificate).
898        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        // The headline correctness guard for the gap scan: clique=2 ≪ χ=4. The designer must still
917        // return exactly 4 phases, a valid colouring, and RUP-certified minimality.
918        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}