1use 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
25const SPEC_MIN_N: usize = 2;
27const SPEC_MAX_N: usize = 20;
29const HEULE_MAX_N: usize = 12;
32const BASELINE_MAX_N: usize = 7;
35
36pub struct PigeonSpec {
39 pub pigeons: usize,
41}
42
43impl PigeonSpec {
44 pub fn holes(&self) -> usize {
46 self.pigeons - 1
47 }
48}
49
50pub 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 return None;
77 }
78 pigeons.map(|pigeons| PigeonSpec { pigeons })
79}
80
81pub fn is_pigeonhole_spec(spec: &str) -> bool {
84 parse_pigeonhole_spec(spec).is_some()
85}
86
87fn adjacency(pigeons: usize, holes: usize) -> Vec<Vec<usize>> {
89 (0..pigeons).map(|_| (0..holes).collect()).collect()
90}
91
92pub struct Verdict {
94 pub hall: HallWitness,
96 pub pr_clauses: Option<usize>,
98 pub certified: bool,
100 pub baseline_conflicts: Option<u64>,
102}
103
104pub fn solve(spec: &PigeonSpec) -> Verdict {
108 let n = spec.pigeons;
109 let holes = spec.holes();
110 let (cnf, _) = php(n);
111
112 let hall = match assign_or_hall(&adjacency(n, holes), holes) {
114 MatchOutcome::Infeasible(w) => w,
115 MatchOutcome::Feasible(_) => HallWitness { items: (0..n).collect(), slots: (0..holes).collect() },
117 };
118
119 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 (None, true)
128 };
129
130 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
148fn 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
163fn 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
175fn 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
187fn 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
199pub 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 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 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 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 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 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 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 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
314pub 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 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 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 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 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 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 assert_monotonic_keytimes(&pulse(t0, t1, 6.0));
499 }
500 }
501 }
502 }
503
504 #[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 #[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 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 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 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 #[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 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 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 #[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}