Skip to main content

logicaffeine_web/ui/pages/
pigeonhole_viz.rs

1//! Studio easter egg — the pigeonhole principle, solved live in the browser by our prover.
2//!
3//! Pure data → SVG (no Z3, no JS): `n` pigeons fly toward `n-1` holes. One pigeon is always left
4//! with nowhere to land — and *that* is the whole point. Encoded as boolean SAT, PHP(n) needs an
5//! exponentially long resolution refutation (Haken 1985), so every resolution-class solver — Kissat,
6//! CaDiCaL, Glucose, Z3 — hits a `2^Ω(n)` wall. Our prover does not:
7//!
8//! * **Maximum bipartite matching** ([`logicaffeine_proof::matching`]) decides infeasibility in
9//!   polynomial time and returns a re-verified **Hall witness** — the `n` pigeons collectively reach
10//!   only `n-1` holes, so no assignment exists. The certificate the animation draws.
11//! * **Certified symmetry breaking** ([`logicaffeine_proof::sym_certify::heule_php_refutation`])
12//!   produces a *polynomial* PR proof (Heule–Kiesl–Biere 2017) that escapes the resolution lower
13//!   bound and re-checks against the original formula — machine-checked, in the browser, no Z3.
14//!
15//! Both run live here. For contrast we also run a plain CDCL solve (the Kissat-class algorithm
16//! family) on the small instances and report its conflict count — the search work our certified
17//! proof avoids entirely.
18
19use logicaffeine_proof::cdcl::{SolveResult, Solver};
20use logicaffeine_proof::families::php;
21use logicaffeine_proof::matching::{assign_or_hall, is_hall_witness, HallWitness, MatchOutcome};
22use logicaffeine_proof::pr::check_pr_refutation;
23use logicaffeine_proof::sym_certify::heule_php_refutation;
24
25/// Smallest instance worth showing (one pigeon, zero holes is degenerate).
26const SPEC_MIN_N: usize = 2;
27/// Largest instance the spec accepts — keeps the drawing legible and the CNF build bounded.
28const SPEC_MAX_N: usize = 20;
29/// Run the live certified Heule proof up to here (PHP(12) ≈ 60ms); beyond it the matching witness
30/// and the polynomial-proof note carry the story without a heavy per-render construction.
31const HEULE_MAX_N: usize = 12;
32/// Run the baseline (Kissat-class) CDCL solve for the conflict-count contrast only this far —
33/// PHP(7) is ~700 conflicts in milliseconds; the blow-up past it is the point, not something to run.
34const BASELINE_MAX_N: usize = 7;
35
36/// A parsed pigeonhole spec: `pigeons` pigeons into `pigeons - 1` holes — the canonical
37/// resolution-hard PHP instance, always unsatisfiable.
38pub struct PigeonSpec {
39    /// Number of pigeons (`n`). Holes are always `n - 1`.
40    pub pigeons: usize,
41}
42
43impl PigeonSpec {
44    /// The hole count: always one fewer than the pigeons (that is what makes it impossible).
45    pub fn holes(&self) -> usize {
46        self.pigeons - 1
47    }
48}
49
50/// Parse a spec of the form (a single `pigeons: N` line; comments and blanks ignored):
51/// ```text
52/// ## Pigeonhole
53/// pigeons: 6
54/// ```
55/// Returns `None` unless there is exactly a well-formed `pigeons:` count within `2..=20`, so other
56/// Hardware-mode inputs (SVA English, Verilog, register specs) are never hijacked.
57pub fn parse_pigeonhole_spec(spec: &str) -> Option<PigeonSpec> {
58    let mut pigeons: Option<usize> = None;
59    for raw in spec.lines() {
60        let line = raw.trim();
61        if line.is_empty() || line.starts_with('#') {
62            continue;
63        }
64        if let Some(rest) = line
65            .strip_prefix("pigeons:")
66            .or_else(|| line.strip_prefix("Pigeons:"))
67        {
68            if let Ok(n) = rest.trim().parse::<usize>() {
69                if (SPEC_MIN_N..=SPEC_MAX_N).contains(&n) {
70                    pigeons = Some(n);
71                }
72            }
73            continue;
74        }
75        // Any other non-comment content means this is not a clean pigeonhole spec.
76        return None;
77    }
78    pigeons.map(|pigeons| PigeonSpec { pigeons })
79}
80
81/// Whether `spec` is a pigeonhole spec — the single source of truth the Studio uses to route
82/// Hardware-mode input to this easter egg, so the wiring can never drift from the parser.
83pub fn is_pigeonhole_spec(spec: &str) -> bool {
84    parse_pigeonhole_spec(spec).is_some()
85}
86
87/// The bipartite adjacency for PHP(n): every one of the `n` pigeons may use any of the `n-1` holes.
88fn adjacency(pigeons: usize, holes: usize) -> Vec<Vec<usize>> {
89    (0..pigeons).map(|_| (0..holes).collect()).collect()
90}
91
92/// The certified refutation result for one instance, all re-checkable and computed live in WASM.
93pub struct Verdict {
94    /// The re-verified Hall witness: `pigeons` pigeons reach only `holes` holes (so `slots < items`).
95    pub hall: HallWitness,
96    /// PR clauses in the certified Heule symmetry-breaking proof, when it was run (`n ≤ HEULE_MAX_N`).
97    pub pr_clauses: Option<usize>,
98    /// Whether that proof independently re-checked against the original PHP(n) formula.
99    pub certified: bool,
100    /// Baseline (Kissat-class) CDCL conflicts on the same instance, when run (`n ≤ BASELINE_MAX_N`).
101    pub baseline_conflicts: Option<u64>,
102}
103
104/// Solve PHP(`spec.pigeons`) with our prover, live: the matching Hall witness (always), the certified
105/// Heule symmetry-breaking proof (small/medium `n`), and a baseline CDCL conflict count for contrast
106/// (small `n`). Every field is a re-verified artifact, never a trusted verdict.
107pub fn solve(spec: &PigeonSpec) -> Verdict {
108    let n = spec.pigeons;
109    let holes = spec.holes();
110    let (cnf, _) = php(n);
111
112    // Polynomial decision + certificate: n pigeons, n-1 holes ⇒ Hall witness (re-verified).
113    let hall = match assign_or_hall(&adjacency(n, holes), holes) {
114        MatchOutcome::Infeasible(w) => w,
115        // PHP(n) with n ≥ 2 is always infeasible; keep a faithful (re-checkable) witness regardless.
116        MatchOutcome::Feasible(_) => HallWitness { items: (0..n).collect(), slots: (0..holes).collect() },
117    };
118
119    // Certified symmetry-breaking proof (Heule PR), live, for the affordable range.
120    let (pr_clauses, certified) = if n <= HEULE_MAX_N {
121        let r = heule_php_refutation(n);
122        let ok = r.refuted && check_pr_refutation(cnf.num_vars, &cnf.clauses, &r.steps);
123        (Some(r.sbp_clauses), ok)
124    } else {
125        // The proof is polynomial at any size (Heule–Kiesl–Biere); we just don't reconstruct it on
126        // every render past the affordable range. The matching witness already certifies UNSAT.
127        (None, true)
128    };
129
130    // Baseline CDCL conflict count — the Kissat-class search work we avoid — for the small instances.
131    let baseline_conflicts = if n <= BASELINE_MAX_N {
132        let mut base = Solver::new(cnf.num_vars);
133        base.set_reduce(true);
134        for c in &cnf.clauses {
135            base.add_clause(c.clone());
136        }
137        match base.solve() {
138            SolveResult::Unsat => Some(base.conflicts()),
139            SolveResult::Sat(_) => None,
140        }
141    } else {
142        None
143    };
144
145    Verdict { hall, pr_clauses, certified, baseline_conflicts }
146}
147
148/// A looping SMIL translate that holds at the start, glides by `(dx, dy)`, holds there, then returns —
149/// what makes a pigeon descend into its hole and reset each cycle. `keyTimes` are clamped strictly
150/// increasing (the browser silently drops an animation with non-monotonic `keyTimes`). `additive="sum"`
151/// is mandatory: each pigeon's perch is set by a static `transform="translate(px, top_y)"` on its group,
152/// and the SMIL default (`additive="replace"`) would discard that perch and snap every pigeon to the SVG
153/// origin — so the `0 0` keyframes here are an offset *from* the perch, not an absolute position.
154fn fly(dx: f64, dy: f64, b0: f64, b1: f64, e1: f64, dur: f64) -> String {
155    let b0 = b0.clamp(0.01, 0.90);
156    let b1 = b1.clamp(b0 + 0.01, 0.95);
157    let e1 = e1.clamp(b1 + 0.01, 0.98);
158    format!(
159        r#"<animateTransform attributeName="transform" type="translate" additive="sum" dur="{dur:.2}s" repeatCount="indefinite" keyTimes="0;{b0:.4};{b1:.4};{e1:.4};1" values="0 0;0 0;{dx:.1} {dy:.1};{dx:.1} {dy:.1};0 0"/>"#
160    )
161}
162
163/// A looping SMIL translate for the doomed pigeon: it dives toward the holes by `(dx, dymid)`, finds
164/// no vacancy, and retreats — over and over. `keyTimes` clamped strictly increasing; `additive="sum"`
165/// so the dive is an offset from its perch (see [`fly`]), not an absolute jump to the origin.
166fn flutter(dx: f64, dymid: f64, b0: f64, bm: f64, be: f64, dur: f64) -> String {
167    let b0 = b0.clamp(0.01, 0.88);
168    let bm = bm.clamp(b0 + 0.01, 0.93);
169    let be = be.clamp(bm + 0.01, 0.98);
170    format!(
171        r#"<animateTransform attributeName="transform" type="translate" additive="sum" dur="{dur:.2}s" repeatCount="indefinite" keyTimes="0;{b0:.4};{bm:.4};{be:.4};1" values="0 0;0 0;{dx:.1} {dymid:.1};0 0;0 0"/>"#
172    )
173}
174
175/// A looping SMIL opacity pulse: invisible, then bright across `[bm, be]` (the moment the doomed
176/// pigeon is down at the full holes), then gone — for the flashing "NO ROOM" mark. `keyTimes`
177/// clamped strictly increasing.
178fn pulse(bm: f64, be: f64, dur: f64) -> String {
179    let a = bm.clamp(0.02, 0.90);
180    let b = (a + 0.02).clamp(a + 0.01, 0.94);
181    let c = be.clamp(b + 0.01, 0.98);
182    format!(
183        r#"<animate attributeName="opacity" dur="{dur:.2}s" repeatCount="indefinite" keyTimes="0;{a:.4};{b:.4};{c:.4};1" values="0;0;1;0;0"/>"#
184    )
185}
186
187/// The drawn pigeon: a small body + head + beak + wing around the origin, so a translate moves it.
188fn pigeon_glyph(body: &str, accent: &str) -> String {
189    use std::fmt::Write as _;
190    let mut s = String::new();
191    let _ = write!(s, r##"<ellipse cx="0" cy="0" rx="13" ry="9" fill="{body}"/>"##);
192    let _ = write!(s, r##"<path d="M -2,-3 q 12,-9 14,3 q -8,-3 -14,2 Z" fill="{accent}"/>"##);
193    let _ = write!(s, r##"<circle cx="9" cy="-5" r="6" fill="{body}"/>"##);
194    let _ = write!(s, r##"<circle cx="11" cy="-6" r="1.4" fill="#0f1117"/>"##);
195    let _ = write!(s, r##"<path d="M 14,-5 l 6,-1.5 l -6,3 Z" fill="#f59e0b"/>"##);
196    s
197}
198
199/// Render PHP(`spec.pigeons`) as `(svg, verdict)`. The SVG is an animated flight: `n` pigeons glide
200/// into `n-1` holes while the one left over flutters and flashes "NO ROOM". The verdict is the
201/// plain-language, certified summary.
202pub fn render(spec: &PigeonSpec) -> (String, String) {
203    use std::fmt::Write as _;
204    let n = spec.pigeons;
205    let holes = spec.holes();
206    let v = solve(spec);
207
208    // Geometry: a top row of pigeons gliding down into a bottom row of holes, three certified status
209    // lines below the shelf. The canvas height is derived from that layout so the last line is never
210    // clipped (a fixed height silently cut off the resolution/CDCL contrast line).
211    let w = 600.0_f64;
212    let margin = 40.0_f64;
213    let plot_w = w - 2.0 * margin;
214    let top_y = 64.0_f64;
215    let hole_y = 232.0_f64;
216    let hole_w = (plot_w / holes as f64 * 0.62).clamp(16.0, 46.0);
217    let hole_h = 30.0_f64;
218    let shelf_y = hole_y + hole_h + 6.0;
219    let status_top = shelf_y + 26.0;
220    let status_step = 20.0_f64;
221    let last_status_y = status_top + 2.0 * status_step;
222    let height = last_status_y + 14.0;
223    let dur = (1.6 + n as f64 * 0.35).clamp(3.0, 9.0);
224
225    // x of pigeon i (n across) and hole j (n-1 across).
226    let px = |i: usize| margin + (i as f64 + 0.5) * (plot_w / n as f64);
227    let hx = |j: usize| margin + (j as f64 + 0.5) * (plot_w / holes as f64);
228
229    let mut s = String::new();
230    let _ = write!(
231        s,
232        r#"<svg viewBox="0 0 {w:.0} {height:.0}" xmlns="http://www.w3.org/2000/svg" font-family="ui-sans-serif, system-ui" font-size="12">"#
233    );
234    let _ = write!(s, r##"<rect x="0" y="0" width="{w:.0}" height="{height:.0}" fill="#0f1117"/>"##);
235    let _ = write!(
236        s,
237        r##"<text x="14" y="26" fill="#e5e7eb" font-size="14" font-family="ui-monospace, monospace">Pigeonhole · {n} pigeons → {holes} holes</text>"##
238    );
239
240    // The dovecote: n-1 hole openings along the bottom, on a shelf.
241    let _ = write!(s, r##"<rect x="{margin:.0}" y="{shelf_y:.1}" width="{plot_w:.0}" height="6" rx="3" fill="#3a2f25"/>"##);
242    for j in 0..holes {
243        let cx = hx(j);
244        let x = cx - hole_w / 2.0;
245        let _ = write!(s, r##"<rect x="{x:.1}" y="{hole_y:.1}" width="{hole_w:.1}" height="{hole_h:.1}" rx="6" fill="#1b1f2a" stroke="#3a4150" stroke-width="1.5"/>"##);
246        let _ = write!(s, r##"<ellipse cx="{cx:.1}" cy="{:.1}" rx="{:.1}" ry="7" fill="#0a0c12"/>"##, hole_y + 11.0, hole_w / 2.0 - 5.0);
247    }
248
249    // The placed pigeons: pigeon i → hole i for i in 0..holes; each dives in, staggered.
250    let descend = hole_y + 6.0 - top_y;
251    for i in 0..holes {
252        let dx = hx(i) - px(i);
253        let frac = i as f64 / n as f64;
254        let b0 = 0.06 + 0.55 * frac;
255        let anim = fly(dx, descend, b0, b0 + 0.16, 0.9, dur);
256        let _ = write!(s, r##"<g transform="translate({:.1},{top_y:.1})">{anim}{}</g>"##, px(i), pigeon_glyph("#9fb3c8", "#7d93ab"));
257    }
258
259    // The doomed pigeon (the last one): dives at the nearest hole, finds it taken, retreats — flashing.
260    let doomed = n - 1;
261    let target_hole = holes - 1;
262    let ddx = hx(target_hole) - px(doomed);
263    let dmid = descend - 10.0;
264    let danim = flutter(ddx, dmid, 0.62, 0.78, 0.9, dur);
265    let _ = write!(s, r##"<g transform="translate({:.1},{top_y:.1})">{danim}{}</g>"##, px(doomed), pigeon_glyph("#f87171", "#dc6363"));
266
267    // The flashing "NO ROOM" mark over the contested hole.
268    let no_room = pulse(0.78, 0.9, dur);
269    let _ = write!(
270        s,
271        r##"<g opacity="0"><text x="{:.1}" y="{:.1}" fill="#fca5a5" font-size="13" font-weight="700" text-anchor="middle">✗ NO ROOM</text>{no_room}</g>"##,
272        hx(target_hole),
273        hole_y - 12.0
274    );
275
276    // Status lines: the certified result, and the search work we avoid. The canvas height above is
277    // derived from `status_top` + two `status_step`s so the third line below always fits.
278    let mut y = status_top;
279    let hall_line = format!(
280        "\u{2713} Maximum matching: {} pigeons reach only {} holes \u{2014} Hall witness, decided in polynomial time",
281        v.hall.items.len(),
282        v.hall.slots.len()
283    );
284    let _ = write!(s, r##"<text x="14" y="{y:.0}" fill="#86efac" font-size="12">{hall_line}</text>"##);
285    y += status_step;
286    let ours = match v.pr_clauses {
287        Some(k) if v.certified => format!(
288            "\u{2713} OURS: 0 conflicts \u{2014} certified symmetry-breaking proof ({k} PR clauses), machine-checked in your browser, no Z3"
289        ),
290        _ => "\u{2713} OURS: polynomial certified PR proof (Heule\u{2013}Kiesl\u{2013}Biere) \u{2014} no Z3".to_string(),
291    };
292    let _ = write!(s, r##"<text x="14" y="{y:.0}" fill="#86efac" font-size="12">{ours}</text>"##);
293    y += status_step;
294    let base = match v.baseline_conflicts {
295        Some(c) => format!("\u{2717} Resolution / CDCL (Kissat-class): {c} conflicts here \u{2014} and 2^\u{03a9}(n) as n grows"),
296        None => "\u{2717} Resolution / CDCL (Kissat, CaDiCaL, Z3): 2^\u{03a9}(n) \u{2014} exponential, provably (Haken 1985)".to_string(),
297    };
298    let _ = write!(s, r##"<text x="14" y="{y:.0}" fill="#fca5a5" font-size="12">{base}</text>"##);
299
300    let _ = write!(s, "</svg>");
301
302    let proof = match v.pr_clauses {
303        Some(k) if v.certified => format!(
304            " Our prover auto-refutes it with a certified symmetry-breaking proof ({k} PR clauses, machine-checked against the original formula) in the browser \u{2014} no Z3."
305        ),
306        _ => " Our prover refutes it with a polynomial certified PR proof (Heule\u{2013}Kiesl\u{2013}Biere) \u{2014} no Z3.".to_string(),
307    };
308    let verdict = format!(
309        "\u{2717} UNSAT \u{2014} {n} pigeons cannot fit {holes} holes. Maximum bipartite matching returns a re-verified Hall witness ({n} pigeons reach only {holes} holes), so no assignment exists.{proof} Every resolution-class solver (Kissat, CaDiCaL, Glucose, Z3) needs 2^\u{03a9}(n) time on this family (Haken 1985)."
310    );
311    (s, verdict)
312}
313
314/// A textual summary for the Studio output panel, paired with the [`render`] animation — the same
315/// certified result, in words.
316pub fn report(spec: &PigeonSpec) -> String {
317    use std::fmt::Write as _;
318    let v = solve(spec);
319    let n = spec.pigeons;
320    let holes = spec.holes();
321    let mut out = String::new();
322    let _ = writeln!(out, "PHP({n}): {n} pigeons into {holes} holes \u{2014} UNSAT.");
323    let _ = writeln!(
324        out,
325        "Hall witness (re-verified): {} pigeons collectively reach only {} holes.",
326        v.hall.items.len(),
327        v.hall.slots.len()
328    );
329    match v.pr_clauses {
330        Some(k) if v.certified => {
331            let _ = writeln!(out, "Certified symmetry-breaking proof: {k} PR clauses, 0 conflicts, machine-checked. No Z3.");
332        }
333        _ => {
334            let _ = writeln!(out, "Certified by a polynomial PR proof (Heule\u{2013}Kiesl\u{2013}Biere). No Z3.");
335        }
336    }
337    if let Some(c) = v.baseline_conflicts {
338        let _ = write!(out, "Baseline CDCL (Kissat-class) on the same instance: {c} conflicts \u{2014} the search our proof avoids.");
339    } else {
340        let _ = write!(out, "Resolution-class solvers (Kissat, CaDiCaL, Z3) need 2^\u{03a9}(n) here \u{2014} provably exponential.");
341    }
342    out
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    #[test]
350    fn parses_a_spec() {
351        let s = parse_pigeonhole_spec("pigeons: 6").expect("parses");
352        assert_eq!(s.pigeons, 6);
353        assert_eq!(s.holes(), 5);
354    }
355
356    #[test]
357    fn parser_is_robust_to_messy_input() {
358        let messy = "## Pigeonhole\n\n  Pigeons:  4  \n\n";
359        let s = parse_pigeonhole_spec(messy).expect("messy spec still parses");
360        assert_eq!(s.pigeons, 4);
361        assert_eq!(s.holes(), 3);
362    }
363
364    #[test]
365    fn rejects_out_of_range_and_malformed() {
366        assert!(parse_pigeonhole_spec("pigeons: 1").is_none(), "n=1 is degenerate");
367        assert!(parse_pigeonhole_spec("pigeons: 99").is_none(), "n=99 exceeds the spec cap");
368        assert!(parse_pigeonhole_spec("pigeons: lots").is_none(), "non-numeric count");
369        assert!(parse_pigeonhole_spec("holes: 3").is_none(), "no pigeons line");
370        assert!(parse_pigeonhole_spec("").is_none(), "empty input");
371    }
372
373    #[test]
374    fn recognizes_only_pigeonhole_specs() {
375        assert!(is_pigeonhole_spec("pigeons: 6"));
376        assert!(is_pigeonhole_spec("## Pigeonhole\npigeons: 8\n"));
377        // The OTHER Hardware-mode inputs must NOT be hijacked by the pigeonhole egg:
378        assert!(!is_pigeonhole_spec("Always, if request is high, then acknowledge is high."));
379        assert!(!is_pigeonhole_spec(
380            "module m(input clk); reg a; always @(posedge clk) a <= ~a; assert property (a); endmodule"
381        ));
382        assert!(!is_pigeonhole_spec("registers: 3\na: 0-5\nb: 1-6"));
383        assert!(!is_pigeonhole_spec("ns-through conflicts with ew-through and ew-left."));
384        assert!(!is_pigeonhole_spec(""));
385    }
386
387    #[test]
388    fn matching_returns_a_reverified_hall_witness() {
389        for n in SPEC_MIN_N..=8 {
390            let spec = PigeonSpec { pigeons: n };
391            let v = solve(&spec);
392            assert_eq!(v.hall.items.len(), n, "all n pigeons are deficient");
393            assert_eq!(v.hall.slots.len(), n - 1, "they reach only n-1 holes");
394            // The witness re-checks independently against the adjacency.
395            assert!(
396                is_hall_witness(&adjacency(n, n - 1), &v.hall),
397                "Hall witness must re-verify for PHP({n})"
398            );
399        }
400    }
401
402    #[test]
403    fn heule_proof_is_certified_for_the_live_range() {
404        for n in 3..=HEULE_MAX_N {
405            let v = solve(&PigeonSpec { pigeons: n });
406            assert!(v.certified, "PHP({n}) must carry a certified proof");
407            assert!(v.pr_clauses.unwrap_or(0) >= 1, "PHP({n}) needs PR clauses");
408        }
409    }
410
411    #[test]
412    fn baseline_cdcl_actually_blows_up_while_ours_does_not() {
413        // The contrast that makes the demo: plain CDCL accrues conflicts; our certified path has none.
414        for n in 4..=BASELINE_MAX_N {
415            let v = solve(&PigeonSpec { pigeons: n });
416            assert!(
417                v.baseline_conflicts.unwrap_or(0) >= 1,
418                "baseline CDCL must hit conflicts on PHP({n})"
419            );
420            assert!(v.certified, "ours stays certified with 0 conflicts on PHP({n})");
421        }
422    }
423
424    #[test]
425    fn renders_an_animated_unsat() {
426        let spec = parse_pigeonhole_spec("pigeons: 6").unwrap();
427        let (svg, verdict) = render(&spec);
428        assert!(svg.starts_with("<svg"), "valid SVG");
429        assert!(svg.contains("</svg>"));
430        assert!(svg.contains("<animateTransform"), "pigeons must fly");
431        assert!(svg.contains("NO ROOM"), "the doomed pigeon must be flagged");
432        assert!(svg.contains("Hall witness"), "the certificate must be shown");
433        assert!(verdict.starts_with('\u{2717}'), "UNSAT verdict: {verdict}");
434        assert!(verdict.contains("UNSAT") && verdict.contains("Hall witness"), "{verdict}");
435        assert!(verdict.contains("no Z3"), "{verdict}");
436        assert!(verdict.contains("6 pigeons") && verdict.contains("5 holes"), "{verdict}");
437    }
438
439    #[test]
440    fn renders_large_instances_without_running_the_heavy_paths() {
441        // Past the live caps the render must still be coherent (matching witness + theory), never panic.
442        let spec = PigeonSpec { pigeons: 18 };
443        let (svg, verdict) = render(&spec);
444        assert!(svg.starts_with("<svg") && svg.contains("</svg>"));
445        assert!(svg.contains("Hall witness"));
446        let v = solve(&spec);
447        assert!(v.pr_clauses.is_none(), "Heule not reconstructed past the cap");
448        assert!(v.baseline_conflicts.is_none(), "baseline not run past the cap");
449        assert!(verdict.starts_with('\u{2717}'));
450        assert!(verdict.contains("2^\u{03a9}(n)"), "theory framing present: {verdict}");
451    }
452
453    #[test]
454    fn report_agrees_with_the_rendered_verdict() {
455        for n in [3usize, 6, 14] {
456            let spec = PigeonSpec { pigeons: n };
457            let report = report(&spec);
458            let (_, verdict) = render(&spec);
459            assert!(report.contains("UNSAT"), "{report}");
460            assert!(verdict.starts_with('\u{2717}'));
461            assert!(report.contains("Hall witness"), "{report}");
462        }
463    }
464
465    #[test]
466    fn holes_are_always_pigeons_minus_one() {
467        for n in SPEC_MIN_N..=SPEC_MAX_N {
468            assert_eq!(PigeonSpec { pigeons: n }.holes(), n - 1);
469        }
470    }
471
472    /// The SMIL `keyTimes` MUST be strictly monotonic in [0,1] or the browser drops the animation.
473    /// Sweep the whole parameter space of every animation helper and parse the emitted keyTimes back.
474    fn assert_monotonic_keytimes(anim: &str) {
475        let kt = anim
476            .split("keyTimes=\"")
477            .nth(1)
478            .and_then(|s| s.split('"').next())
479            .expect("has keyTimes");
480        let times: Vec<f64> = kt.split(';').map(|x| x.parse().unwrap()).collect();
481        assert_eq!(times.len(), 5, "{anim}");
482        assert_eq!(times[0], 0.0);
483        assert_eq!(*times.last().unwrap(), 1.0);
484        for w in times.windows(2) {
485            assert!(w[0] < w[1], "keyTimes not increasing: {kt}");
486        }
487    }
488
489    #[test]
490    fn animation_helpers_emit_monotonic_keytimes_for_every_input() {
491        for a in 0..=24 {
492            for b in 0..=24 {
493                for c in 0..=24 {
494                    let (t0, t1, t2) = (a as f64 / 24.0, b as f64 / 24.0, c as f64 / 24.0);
495                    assert_monotonic_keytimes(&fly(40.0, 120.0, t0, t1, t2, 6.0));
496                    assert_monotonic_keytimes(&flutter(40.0, 100.0, t0, t1, t2, 6.0));
497                    // pulse only takes (bm, be) — sweep its two-parameter space.
498                    assert_monotonic_keytimes(&pulse(t0, t1, 6.0));
499                }
500            }
501        }
502    }
503
504    /// The flight helpers MUST be `additive="sum"`. Each pigeon's perch is a static
505    /// `transform="translate(px, top_y)"` on its group; the SMIL default (`additive="replace"`)
506    /// overrides that perch and snaps every pigeon to the SVG origin — the "pigeons don't fly to
507    /// the holes" bug. The `0 0` keyframes only mean "stay at the perch" when the animation sums.
508    #[test]
509    fn flight_animations_are_additive() {
510        assert!(fly(40.0, 120.0, 0.1, 0.3, 0.9, 6.0).contains(r#"additive="sum""#), "fly must sum onto the perch");
511        assert!(flutter(40.0, 100.0, 0.6, 0.8, 0.9, 6.0).contains(r#"additive="sum""#), "flutter must sum onto the perch");
512    }
513
514    /// Every `<animateTransform>` in a rendered scene animates the group's `transform`, so every one
515    /// must be additive or that pigeon leaves its perch and flies from the origin instead.
516    #[test]
517    fn every_rendered_transform_animation_is_additive() {
518        for n in [3usize, 6, 9, 12, 18, 20] {
519            let (svg, _) = render(&PigeonSpec { pigeons: n });
520            let transforms = svg.matches("<animateTransform").count();
521            // One flight per placed pigeon (`n-1`) plus the doomed pigeon's flutter = `n`.
522            assert_eq!(transforms, n, "expected one transform animation per pigeon for n={n}");
523            let additive = svg.matches(r#"additive="sum""#).count();
524            assert_eq!(additive, transforms, "every transform animation must be additive for n={n}");
525        }
526    }
527
528    /// Parse each flight group's static perch `translate(bx,by)` and its animation peak offset
529    /// `(dx,dy)` (the third of the five keyframe values). The rendered landing is `(bx+dx, by+dy)`
530    /// because the animation is additive.
531    fn flights(svg: &str) -> Vec<(f64, f64, f64, f64)> {
532        let mut out = Vec::new();
533        for seg in svg.split("<g transform=\"translate(").skip(1) {
534            let coords = &seg[..seg.find(')').expect("closing paren")];
535            let (bx, by) = coords.split_once(',').expect("two coords");
536            let vstart = seg.find("values=\"").expect("has values") + "values=\"".len();
537            let values = &seg[vstart..vstart + seg[vstart..].find('"').unwrap()];
538            let peak = values.split(';').nth(2).expect("five keyframe values");
539            let (dx, dy) = peak.split_once(' ').expect("peak is `dx dy`");
540            out.push((
541                bx.parse().unwrap(),
542                by.parse().unwrap(),
543                dx.parse().unwrap(),
544                dy.parse().unwrap(),
545            ));
546        }
547        out
548    }
549
550    /// The hole openings are the `ry="7"` ellipses (the pigeon body is `ry="9"`), in emission order.
551    fn hole_centers(svg: &str) -> Vec<f64> {
552        let mut out = Vec::new();
553        for seg in svg.split("<ellipse cx=\"").skip(1) {
554            let cx = &seg[..seg.find('"').unwrap()];
555            let tag = &seg[..seg.find('>').unwrap()];
556            if tag.contains(r#"ry="7""#) {
557                out.push(cx.parse().unwrap());
558            }
559        }
560        out
561    }
562
563    /// The whole point of the animation: pigeon `i` actually descends onto hole `i`. Reconstruct each
564    /// flight's landing from its perch + additive peak and assert the `n-1` placed pigeons land, one
565    /// each, exactly on the `n-1` distinct hole centers, all on a single row down inside the holes —
566    /// while the doomed `n`th pigeon only dives at the last hole and stops short (it never lands).
567    #[test]
568    fn each_placed_pigeon_lands_on_its_own_hole() {
569        for n in [3usize, 6, 9, 12, 20] {
570            let (svg, _) = render(&PigeonSpec { pigeons: n });
571            let flights = flights(&svg);
572            let holes = hole_centers(&svg);
573            assert_eq!(flights.len(), n, "one flight per pigeon (placed + doomed), n={n}");
574            assert_eq!(holes.len(), n - 1, "n-1 holes, n={n}");
575
576            // The perch and peak are each emitted at one decimal place, so `perch + peak`
577            // reconstructed from the strings sits within ~0.15px of the (also 1-dp) hole center —
578            // sub-pixel, i.e. visually dead on. (The buggy `additive="replace"` would instead land
579            // the pigeon at the raw peak, hundreds of px off — this tolerance never confuses the two.)
580            let landing_y = flights[0].1 + flights[0].3;
581            for i in 0..(n - 1) {
582                let (bx, by, dx, dy) = flights[i];
583                let land_x = bx + dx;
584                assert!(
585                    (land_x - holes[i]).abs() < 0.2,
586                    "placed pigeon {i} must land on hole {i} ({} vs {}) for n={n}",
587                    land_x,
588                    holes[i]
589                );
590                assert!(by + dy > by, "pigeon {i} must descend, not rise, for n={n}");
591                // `top_y` and the descent are both integer-valued and constant, so the landing row is exact.
592                assert!(
593                    (by + dy - landing_y).abs() < 1e-6,
594                    "all placed pigeons land on one row for n={n}"
595                );
596            }
597
598            let (dbx, _dby, ddx, _ddy) = flights[n - 1];
599            assert!(
600                (dbx + ddx - holes[n - 2]).abs() < 0.2,
601                "the doomed pigeon dives at the last, contested hole for n={n}"
602            );
603        }
604    }
605
606    /// A fixed canvas height silently clipped the third certified status line. The `viewBox` height
607    /// must cover the lowest `<text>` baseline for every instance size.
608    #[test]
609    fn no_status_line_is_clipped_below_the_canvas() {
610        for n in SPEC_MIN_N..=SPEC_MAX_N {
611            let (svg, _) = render(&PigeonSpec { pigeons: n });
612            let viewbox = svg.split("viewBox=\"").nth(1).unwrap();
613            let viewbox = &viewbox[..viewbox.find('"').unwrap()];
614            let height: f64 = viewbox.split_whitespace().nth(3).unwrap().parse().unwrap();
615
616            let mut lowest_text = 0.0_f64;
617            for seg in svg.split("<text ").skip(1) {
618                let y: f64 = seg.split("y=\"").nth(1).unwrap().split('"').next().unwrap().parse().unwrap();
619                lowest_text = lowest_text.max(y);
620            }
621            assert!(
622                height >= lowest_text,
623                "canvas height {height} clips a status line at y={lowest_text} for n={n}"
624            );
625        }
626    }
627}