diff --git a/README.md b/README.md index a97858e..ff150c2 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,30 @@ lake build Solutions # compiles all solution files as a check Chapters 2–4 need no Mathlib at all — you can start them with any Lean 4 install while the cache downloads. +## The button + +Like every repository in this estate, the book has one command that earns +its claims — and it is the only source of the words "ALL GREEN" here: + +```bash +./check-book.sh +``` + +It rebuilds the PDF from the committed sources and then verifies ~90 +countable claims printed in the book against reality measured at run +time: chapter and week-plan counts, exercise↔solution pairing per chapter, +every chapter ending on its checkpoint, the recomputed SLH-DSA arithmetic +(digest split, signature size, the 3,824-call worst case), the +transparency log's 19 leaves and per-leaf certificate counts, leaf 18's +axiom cones, the first dual-signed head at size 14, the extracted +SLH-DSA-SHA2-128s parameter card, and chapter 7's 71-digit Q — digit for +digit against `P25519.lean`. Numbers are parsed out of the prose and +compared to measurements, so editing either side alone turns the button +red. Cross-repo checks need the sibling estate repos checked out next to +this one (`BOOK_LOCAL_ONLY=1` skips them, loudly, and never prints ALL +GREEN). `./check-book.sh --selftest` mutates copies of the sources seven +ways and proves each mutation is caught by its own check. + ## Building the book The repo's own recipe (tectonic, user-space, no root — installs itself on diff --git a/check-book.py b/check-book.py new file mode 100644 index 0000000..4df181f --- /dev/null +++ b/check-book.py @@ -0,0 +1,372 @@ +#!/usr/bin/env python3 +"""check-book.py — the measured half of the book's button (see check-book.sh). + +Every check compares a claim PRINTED IN THE BOOK against a value MEASURED +from the sources, the built PDF, or the sibling repositories at the moment +it runs. No expected value is carried in this file when it can be derived; +where the book's prose states a number, the number is parsed OUT OF THE +PROSE and compared against the measurement — so editing either side alone +turns the button red. + +Populations are derived by property, never by glob or label (the log's +entries/ directory contains convenience copies; leaves are the files whose +names are six digits AND whose index field matches their position). + +Exit 0 only if every applicable check passed. A check that could not +measure (missing sibling repo without BOOK_LOCAL_ONLY=1) is a FAILURE, +not a skip: a gate that read nothing must not look like a clean gate. +""" + +import json +import os +import re +import subprocess +import sys + +FAILS = [] +PASSES = [] + + +def ok(label, detail=""): + PASSES.append(label) + print(f" ok {label}" + (f" [{detail}]" if detail else "")) + + +def fail(label, detail=""): + FAILS.append(label) + print(f" FAIL {label}" + (f" [{detail}]" if detail else "")) + + +def check(cond, label, detail=""): + (ok if cond else fail)(label, detail) + + +def read(path): + with open(path, "r", errors="replace") as fh: + return fh.read() + + +WORDS = { + "one": 1, "two": 2, "three": 3, "four": 4, "five": 5, "six": 6, + "seven": 7, "eight": 8, "nine": 9, "ten": 10, "eleven": 11, + "twelve": 12, "thirteen": 13, "fourteen": 14, "fifteen": 15, + "sixteen": 16, "seventeen": 17, "eighteen": 18, "nineteen": 19, + "twenty": 20, "forty-four": 44, "sixty-one": 61, +} + + +def main(): + book = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else ".") + no_pdf = os.environ.get("SKIP_BUILD") == "1" + local_only = os.environ.get("BOOK_LOCAL_ONLY") == "1" + estate = os.environ.get("ESTATE_ROOT", os.path.dirname(book)) + ltl = os.environ.get("LTL_DIR", os.path.join(estate, "lean-transparency-log")) + fips = os.environ.get("FIPS205_DIR", os.path.join(estate, "fips205-slhdsa-verified")) + p25519 = os.environ.get("P25519_FILE", os.path.join( + estate, "dalek-ed25519-verified", "verification", "Proofs", "P25519.lean")) + + main_tex = read(os.path.join(book, "main.tex")) + readme = read(os.path.join(book, "README.md")) + chdir = os.path.join(book, "chapters") + + # ── Phase 0: source hygiene ──────────────────────────────────────────── + print("=== Phase 0: source hygiene ===") + + inputs = re.findall(r"\\input\{(chapters/[^}]+)\}", main_tex) + missing = [i for i in inputs if not os.path.exists(os.path.join(book, i + ".tex"))] + check(not missing, "every \\input'd chapter file exists", + ",".join(missing) or f"{len(inputs)} inputs") + on_disk = {f"chapters/{f[:-4]}" for f in os.listdir(chdir) if f.endswith(".tex")} + orphans = sorted(on_disk - set(inputs)) + check(not orphans, "no orphan .tex under chapters/", ",".join(orphans) or "none") + + chapter_files = [i for i in inputs if re.search(r"chapters/ch\d\d-", i)] + nums = [int(re.search(r"ch(\d\d)-", c).group(1)) for c in chapter_files] + n_ch = len(chapter_files) + check(nums == list(range(1, n_ch + 1)) and n_ch > 0, + "chapter files contiguous ch01..chNN in input order", f"N={n_ch}") + + for cf in chapter_files + ["chapters/interlude-by-hand"]: + src = read(os.path.join(book, cf + ".tex")) + envs = re.findall(r"\\end\{([a-z]+)\}", src) + check(envs and envs[-1] == "checkpoint", + f"{cf.split('/')[-1]}: last environment is checkpoint", + envs[-1] if envs else "no environments") + + for cf in chapter_files: + num = int(re.search(r"ch(\d\d)-", cf).group(1)) + src = read(os.path.join(book, cf + ".tex")) + n_ex = len(re.findall(r"\\exercise\{", src)) + sols = re.findall(r"\\solhead\{(\d+)\.(\d+)\}", src) + n_sol = len(sols) + label = f"ch{num:02d}: exercises == solutions" + if n_ex == 0 and n_sol == 0: + ok(label, "none (allowed)") + continue + good = (n_ex == n_sol + and all(int(a) == num for a, _ in sols) + and [int(b) for _, b in sols] == list(range(1, n_sol + 1))) + check(good, label, f"{n_ex} exercises, solheads {[a+'.'+b for a, b in sols]}") + + isrc = read(os.path.join(book, "chapters/interlude-by-hand.tex")) + i_ex = len(re.findall(r"Exercise I\.\d", isrc)) + i_sol = re.findall(r"\\solhead\{I\.(\d+)\}", isrc) + check(i_ex == len(i_sol) and [int(x) for x in i_sol] == list(range(1, len(i_sol) + 1)), + "interlude: exercises == solutions", f"{i_ex} vs {len(i_sol)}") + + # ── Phase 1 leftovers: claims about the built PDF ────────────────────── + print("=== Phase 1b: built-PDF claims ===") + if no_pdf: + print(" (skipped: SKIP_BUILD=1 — build phase runs in check-book.sh)") + else: + pdf = os.path.join(book, "main.pdf") + info = subprocess.run(["pdfinfo", pdf], capture_output=True, text=True).stdout + pages = int(re.search(r"Pages:\s+(\d+)", info).group(1)) + check(pages >= 100, "PDF built and non-trivial", f"{pages} pages") + txt = subprocess.run(["pdftotext", pdf, "-"], + capture_output=True, text=True).stdout + bad = [l for l in txt.splitlines() if "??" in l] + check(not bad, "no unresolved references ('??') in rendered PDF", + bad[0][:60] if bad else "clean") + page_claims = re.findall(r"(\d{2,4}) pages", readme + main_tex) + if page_claims: + for pc in page_claims: + check(int(pc) == pages, f"page-count claim {pc} == built {pages}") + else: + ok("no page-count claim in prose (nothing to bind)") + + # ── Phase 2: internal countable claims ───────────────────────────────── + print("=== Phase 2: internal countable claims ===") + + m = re.search(r"(\w+(?:-\w+)?) chapters", readme) + check(m and WORDS.get(m.group(1)) == n_ch, + f"README chapter count == {n_ch}", m.group(0) if m else "no claim found") + + ch01 = read(os.path.join(book, "chapters/ch01-why-verify.tex")) + m = re.search(r"the next (\w+)\s*\nchapters|the next (\w+) chapters", ch01) + word = (m.group(1) or m.group(2)) if m else None + check(word is not None and WORDS.get(word) == n_ch - 1, + f"ch01 'the next N chapters' == {n_ch - 1}", word or "claim not found") + + # stale-total scan is scoped to the front matter, where whole-book totals + # live; inside a chapter, "N chapters" is a positional count checked next + for stale in ("twelve chapters", "thirteen chapters"): + hits = [f for f in ("main.tex", "README.md") + if stale in read(os.path.join(book, f))] + check(not hits, f"no stale '{stale}' in front matter", ",".join(hits) or "clean") + # any "spent N chapters" phrase inside chapter chNN counts its predecessors + for cf in chapter_files: + num = int(re.search(r"ch(\d\d)-", cf).group(1)) + for word in re.findall(r"spent (\w+) chapters", + read(os.path.join(book, cf + ".tex"))): + check(WORDS.get(word) == num - 1, + f"ch{num:02d} 'spent {word} chapters' == its {num - 1} predecessors") + + m = re.search(r"A (\w+)-week plan", main_tex) + plan_word = WORDS.get(m.group(1)) if m else None + weeks = [int(x) for x in re.findall(r"^(\d+)\s+&", main_tex, re.M)] + ranges = [int(b) for _, b in re.findall(r"^(\d+)--(\d+)\s+&", main_tex, re.M)] + max_week = max(weeks + ranges) if (weeks or ranges) else 0 + check(plan_word == max_week and plan_word is not None, + "week-plan heading == max week row", f"{plan_word} vs {max_week}") + check(f"{m.group(1)}-week" in main_tex.replace("A " + m.group(1), "", 1), + "instructors paragraph agrees with plan heading") + + m = re.search(r"Chapters ([\d, ]+ and \d+) carry one", main_tex) + if not m: + fail("discussion-exercise roster claim parseable", "pattern not found") + else: + claimed = set(int(x) for x in re.findall(r"\d+", m.group(1))) + measured = set() + for cf in chapter_files: + num = int(re.search(r"ch(\d\d)-", cf).group(1)) + src = read(os.path.join(book, cf + ".tex")) + if re.search(r"\\exercise\{\(Discussion\)", src): + measured.add(num) + check(claimed == measured, "discussion-exercise roster == measured", + f"claimed {sorted(claimed)}, measured {sorted(measured)}") + + ch13 = read(os.path.join(book, "chapters/ch13-second-summit.tex")) + n, h, d, hp, a, k, w, ln2, m_dig = 16, 63, 7, 9, 12, 14, 16, 3, 30 + length = 2 * n + ln2 + split = ((k * a + 7) // 8, (h - h // d + 7) // 8, (h + 8 * d - 1) // (8 * d)) + check(sum(split) == m_dig and split == (21, 7, 2), "digest split recomputes", + str(split)) + sig_bytes = n * (1 + k * (1 + a) + d * (length + hp)) + check(sig_bytes == 7856, "signature size recomputes", str(sig_bytes)) + fixed = k * a + d * hp + d + 1 + 1 + k # H paths + T's + Hmsg + FORS leaves + best = 0 + for csum in range(0, 32 * (w - 1) + 1): + sh = csum << 4 + digs = [(sh >> 12) & 0xF, (sh >> 8) & 0xF, (sh >> 4) & 0xF] + best = max(best, csum + sum((w - 1) - x for x in digs)) + worst = fixed + d * best + check(fixed == 254 and best == 510 and worst == 3824, + "oracle pricing recomputes (fixed/per-layer-max/worst)", + f"{fixed}/{best}/{worst}") + + def see_saw(csum): + sh = csum << 4 + return ((sh >> 12) & 0xF, (sh >> 8) & 0xF, (sh >> 4) & 0xF) + check(see_saw(480) == (1, 14, 0) and see_saw(479) == (1, 13, 15) + and see_saw(256) == (1, 0, 0), "checksum worked examples recompute") + + for token, why in [("7{,}856", "signature size"), ("254", "fixed oracle calls"), + ("510", "per-layer max"), ("3{,}824", "worst total"), + ("(1, 14, 0)", "csum 480 digits"), + ("(1, 13, 15)", "csum 479 digits"), + ("57{,}344", "FORS forest"), ("231", "H count"), + ("$21 + 7 + 2 = 30$", "digest split")]: + check(token in ch13, f"ch13 prints {why}", token) + check(len(str(2**255 - 19)) == 77, "'77-digit prime' recomputes") + + # ── Phase 3: cross-repo congruence ───────────────────────────────────── + print("=== Phase 3: cross-repo congruence ===") + if local_only: + print(" (SKIPPED: BOOK_LOCAL_ONLY=1 — cross-repo claims NOT verified)") + else: + # the log: leaves by property, not by glob + entdir = os.path.join(ltl, "entries") + if not os.path.isdir(entdir): + fail("lean-transparency-log present", entdir) + else: + leaf_files = sorted(f for f in os.listdir(entdir) + if re.fullmatch(r"\d{6}\.json", f)) + leaves = [json.load(open(os.path.join(entdir, f))) for f in leaf_files] + idx_ok = all(lf["index"] == i for i, lf in enumerate(leaves)) + n_leaves = len(leaves) + check(idx_ok, "leaf indexes contiguous and match filenames", + f"{n_leaves} leaves") + + for where, src, pat in [ + ("title page '19 entries'", main_tex, r"(\d+) entries and counting"), + ("preface 'nineteen pieces'", main_tex, r"lists (\w+)\s*\npieces|lists (\w+) pieces"), + ("ch01 tryit '19 entries'", ch01, r"(\d+) entries, each one"), + ("ch14 'nineteen leaves'", + read(os.path.join(book, "chapters/ch14-attestation-protocol.tex")), + r"log's (\w+) leaves")]: + mm = re.search(pat, src) + val = None + if mm: + g = next(g for g in mm.groups() if g) + val = int(g) if g.isdigit() else WORDS.get(g) + check(val == n_leaves, f"{where} == measured {n_leaves}", str(val)) + + def certs(i): + return leaves[i]["leaf"]["attestation"]["certificates"] + + def comp(i): + return leaves[i]["leaf"]["attestation"]["subject"]["component"] + + ed = {"dalek-ed25519-verified", "anza-ed25519-verified", + "risc0-ed25519-verified", "betrusted-ed25519-verified"} + check({comp(i) for i in (13, 14, 15, 16)} == ed, + "leaves 13-16 subjects are the four ed25519 forks") + check(all(len(certs(i)) == 44 for i in (13, 14, 15, 16)), + "'forty-four certificates each' == measured", + str([len(certs(i)) for i in (13, 14, 15, 16)])) + check(27 + 13 + 4 == 44, "27 main + 13 scalar + 4 apex == 44") + check(comp(17) == "ltl-accumulator-verified" + and len(certs(17)) == 61 and len(certs(12)) == 61, + "'sixty-one' accumulator certificates == measured (leaves 12, 17)") + check(all(len(certs(i)) == 16 for i in range(0, 12)), + "'sixteen' early-leaf certificates == measured (leaves 0-11)") + + s18 = leaves[18]["leaf"]["attestation"] + check(s18["subject"]["component"] == "fips205-slhdsa-verified" + and s18["subject"]["kind"] == "slh_dsa", + "leaf 18 subject is fips205-slhdsa-verified / slh_dsa") + check(len(certs(18)) == 11 and "eleven certificates" in ch13, + "'eleven certificates' == measured", str(len(certs(18)))) + by_name = {c["name"]: c for c in certs(18)} + for want in ("fips205.chain_free_loop_eq", "fips205.xmss_loop_eq", + "fips205.ht_loop_eq", "fips205.wots_csum_loop_eq", + "fips205.slh_verify_128s_accepts_iff"): + check(want in by_name, f"leaf 18 carries {want}") + kernel3 = {"propext", "Classical.choice", "Quot.sound"} + apex = set(by_name["fips205.slh_verify_128s_accepts_iff"]["observed_axioms"]) + oracles = {f"verify_mono.oracle.{x}" + for x in ("f", "h", "h_msg", "t_l", "t_len")} + check(apex == kernel3 | oracles, "apex cone == kernel-3 + five oracles") + ht = set(by_name["fips205.ht_loop_eq"]["observed_axioms"]) + check(ht == kernel3 | {"verify_mono.oracle.f", "verify_mono.oracle.h", + "verify_mono.oracle.t_l"}, + "ht cone is f,h,t_l (the book's table row, by property not name)") + plumb = sum(1 for c in certs(18) if set(c["observed_axioms"]) == kernel3) + check(plumb == 4, "four kernel-3-only plumbing certificates", str(plumb)) + check(all(c["status"] == "proven" and c["axiom_status"] == "clean" + and set(c["observed_axioms"]) == set(c["expected_axioms"]) + for c in certs(18)), "leaf 18: all proven, clean, cones exact") + + heads = [json.loads(l) for l in + open(os.path.join(ltl, "sth-history.jsonl")) if l.strip()] + dual = [hd["tree_size"] for hd in heads + if hd["signatures"].get("slh_dsa", {}).get("status") == "signed"] + check(dual and min(dual) == 14, + "'since tree 14' dual-signed heads == measured", + f"first dual head size {min(dual) if dual else None}") + check(heads[-1]["tree_size"] == n_leaves, + "final head size == leaf count") + + # fips205 parameter card + funs = os.path.join(fips, "verification", "gen", "SlhVerify", "Funs.lean") + if not os.path.exists(funs): + fail("fips205-slhdsa-verified present", funs) + else: + fsrc = read(funs) + mm = re.search( + r"def verify_mono\.slh_verify_128s.*?types\.SlhDsaSig " + r"(\d+)#usize (\d+)#usize (\d+)#usize (\d+)#usize (\d+)#usize " + r"(\d+)#usize.*?slh_verify_internal_free (\d+)#usize (\d+)#usize", + fsrc, re.S) + if not mm: + fail("fips205 entry-point parameters parseable") + else: + A, D, HP, K, LEN, N = (int(mm.group(i)) for i in range(1, 7)) + H, M = int(mm.group(7)), int(mm.group(8)) + W = int(re.search(r"def W : Std\.U32 := (\d+)#u32", fsrc).group(1)) + repo = {"a": A, "d": D, "h'": HP, "k": K, "len": LEN, + "n": N, "h": H, "m": M, "w": W} + card = {"a": a, "d": d, "h'": hp, "k": k, "len": length, + "n": n, "h": h, "m": m_dig, "w": w} + check(repo == card, "ch13 parameter card == extracted entry point", + str(repo)) + for sym, val in card.items(): + tok = f"{sym} = {val}" + check(tok in ch13 or f"= {val}" in ch13, + f"ch13 prints {sym} = {val}") + + # the 71-digit Q, digit for digit + if not os.path.exists(p25519): + fail("P25519.lean present for Q comparison", p25519) + else: + psrc = read(p25519) + mq = re.search(r"theorem prime_(\d{60,})", psrc) + ch07 = read(os.path.join(book, "chapters/ch07-primality-certificates.tex")) + mb = re.search(r"Q = ([0-9\\a-z{} ]+?),\s*\n?\\\]", ch07, re.S) + q_repo = mq.group(1) if mq else None + q_book = re.sub(r"\D", "", mb.group(1)) if mb else None + check(q_repo is not None and q_book == q_repo, + "ch07's printed Q == repository's Q, digit for digit", + f"book {len(q_book or '')} digits, repo {len(q_repo or '')} digits") + check(q_repo is not None and len(q_repo) == 71 and "71-digit" in ch07, + "'71-digit' claim recomputes") + + # ── verdict ──────────────────────────────────────────────────────────── + print() + total = len(PASSES) + len(FAILS) + if FAILS: + print(f"RED: {len(FAILS)} of {total} claims diverge from measured reality:") + for f in FAILS: + print(f" - {f}") + sys.exit(1) + if local_only: + print(f"LOCAL CHECKS GREEN ({total} checks) — cross-repo claims NOT " + f"verified (BOOK_LOCAL_ONLY=1). This is not ALL GREEN.") + sys.exit(0) + print(f"ALL CLAIM CHECKS GREEN ({total} checks)") + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/check-book.sh b/check-book.sh new file mode 100755 index 0000000..6c7de81 --- /dev/null +++ b/check-book.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# check-book.sh — THE button for verifying-crypto-with-lean. +# +# This script is the only source of the words "ALL GREEN" for this +# repository. It rebuilds the book from the committed sources and then +# verifies that every countable claim printed in the book matches reality +# measured at run time: chapter counts, the week plan, exercise/solution +# pairing, the recomputed SLH-DSA arithmetic, the transparency log's leaf +# and certificate counts, the first dual-signed head, the extracted +# parameter card, and the 71-digit Q — digit for digit. +# +# Phases +# 0 source hygiene (check-book.py) +# 1 build (tectonic via build.sh; fails on TeX errors) +# 1b built-PDF claims (pages, unresolved refs, page-count claims) +# 2 internal congruence (counts and arithmetic inside the book) +# 3 cross-repo congruence (log + fips205 + P25519 siblings) +# +# Environment +# BOOK_LOCAL_ONLY=1 skip phase 3; verdict is downgraded, never ALL GREEN +# ESTATE_ROOT parent dir of the sibling repos (default: ../) +# LTL_DIR / FIPS205_DIR / P25519_FILE override individual siblings +# +# Modes +# ./check-book.sh full run +# ./check-book.sh --selftest adversarial self-test: mutates copies of the +# sources and asserts the button turns RED +set -euo pipefail +cd "$(dirname "$0")" +HERE="$(pwd)" + +selftest() { + echo "=== SELFTEST: the button must go red for the right reasons ===" + command -v python3 >/dev/null || { echo "python3 required"; exit 1; } + # the mutated copy keeps the REAL sibling repos: only the book is mutated, + # so a red verdict proves the mutation was caught, not that a repo was lost + local ESTATE; ESTATE="${ESTATE_ROOT:-$(dirname "$HERE")}" + local tmp out pass=0 fail=0 + run_copy() { + tmp="$(mktemp -d)"; mkdir -p "$tmp/chapters" + cp main.tex README.md "$tmp/"; cp chapters/*.tex "$tmp/chapters/" + } + run_mutated() { # $1 description, $2 mutation cmd, $3 expected FAIL substring + run_copy + ( cd "$tmp" && eval "$2" ) + out="$(SKIP_BUILD=1 ESTATE_ROOT="$ESTATE" python3 "$HERE/check-book.py" "$tmp" 2>&1)" \ + && { echo " FAIL mutation NOT caught: $1"; fail=$((fail+1)); rm -rf "$tmp"; return; } + if echo "$out" | grep -q "FAIL.*$3"; then + echo " ok caught for the right reason: $1"; pass=$((pass+1)) + else + echo " FAIL red, but not on the expected check ('$3'): $1"; fail=$((fail+1)) + echo "$out" | grep " FAIL" | head -3 + fi + rm -rf "$tmp" + } + # control: the unmutated copy must pass (proves the harness can go green) + run_copy + if SKIP_BUILD=1 ESTATE_ROOT="$ESTATE" python3 "$HERE/check-book.py" "$tmp" >/dev/null 2>&1; then + echo " ok control: unmutated copy passes"; pass=$((pass+1)) + else + echo " FAIL control: unmutated copy should pass but is red"; fail=$((fail+1)) + SKIP_BUILD=1 ESTATE_ROOT="$ESTATE" python3 "$HERE/check-book.py" "$tmp" | grep FAIL || true + fi + rm -rf "$tmp" + + run_mutated "chapter-count claim drifts (fourteen -> thirteen)" \ + "sed -i 's/fourteen chapters/thirteen chapters/' README.md" \ + "README chapter count" + run_mutated "a solution deleted (ch13 solhead 13.6 dropped)" \ + "sed -i 's/\\\\solhead{13.6}/% gone/' chapters/ch13-second-summit.tex" \ + "ch13: exercises == solutions" + run_mutated "one digit of the 71-digit Q changed in ch07" \ + "sed -i 's/740582127325613583022312264370627886761/740582127325613583022312264370627886762/' chapters/ch07-primality-certificates.tex" \ + "printed Q == repository" + run_mutated "leaf-count claim drifts (nineteen leaves -> twenty)" \ + "sed -i \"s/log's nineteen leaves/log's twenty leaves/\" chapters/ch14-attestation-protocol.tex" \ + "ch14 'nineteen leaves'" + run_mutated "worst-case arithmetic drifts (3,824 -> 3,689)" \ + "sed -i 's/3{,}824/3{,}689/' chapters/ch13-second-summit.tex" \ + "ch13 prints worst total" + run_mutated "a chapter stops ending on its checkpoint" \ + "printf '\n\\\\begin{aha}\nstray box after the checkpoint\n\\\\end{aha}\n' >> chapters/ch05-numbers-and-automation.tex" \ + "ch05.*last environment is checkpoint" + run_mutated "week plan and heading diverge (heading says fifteen)" \ + "sed -i 's/A fourteen-week plan/A fifteen-week plan/' main.tex" \ + "week-plan heading" + + echo + if [ "$fail" -gt 0 ]; then + echo "SELFTEST RED: $fail defect(s) in the button itself"; exit 1 + fi + echo "SELFTEST GREEN: $pass/$pass (control + 7 mutations, each caught on its own check)" + exit 0 +} + +[ "${1:-}" = "--selftest" ] && selftest + +echo "=== Phase 1: build ===" +./build.sh +command -v pdfinfo >/dev/null && command -v pdftotext >/dev/null || { + echo "FAIL: poppler-utils (pdfinfo/pdftotext) required"; exit 1; } + +python3 "$HERE/check-book.py" "$HERE" + +echo +if [ "${BOOK_LOCAL_ONLY:-0}" = "1" ]; then + echo "VERDICT: build green + local claims green; cross-repo NOT verified." +else + echo "ALL GREEN — the book builds and every countable claim matches" + echo "measured reality (sources, PDF, transparency log, extracted code)." +fi diff --git a/main.pdf b/main.pdf index 3caae07..ffd93b0 100644 Binary files a/main.pdf and b/main.pdf differ