nb8.5 — The Final Manuscript Build: Markdown/LaTeX → PDF

This is the last notebook of the camp, and it builds the deliverable — the compiled paper itself — out of nothing but code. By now you have an estimate you can defend, a robustness table that survived (Ch 8.2), and a publication-quality figure (nb8.3). What you do not yet have is a PDF a stranger can hold. This notebook is the bridge. It takes a seeded mini-analysis, writes its one result table as a LaTeX snippet and its one figure as a PNG, assembles a minimal AEA-style manuscript that \inputs the table and \includegraphicses the figure, and then — only if a LaTeX engine is installed — compiles it to PDF.

Here is the one idea the whole notebook turns on, stated before the details so you carry it through: every table and every figure in the paper must flow from the code, never from copy-paste. The instant a human types a number from a notebook into the manuscript, the chain breaks — the pasted number can silently drift out of sync with the code that supposedly produced it, and your one-click replication packet (Lab 8) is no longer one-click. So we never type a result into the .tex. The analysis cell writes results_table.tex; the manuscript reads it. Same number, one source of truth, machine-traversable from raw bytes to the PDF on the screen.

And one design decision that matters for a room full of student laptops and a shared camp container: the build must not fail just because LaTeX is missing. Compiling a PDF requires a TeX engine (pdflatex, tectonic, or latexmk), and not every machine has one. So this notebook gates the compile step on shutil.which(...): if an engine is present, it compiles and reports success; if not, it prints a clear message, leaves the assembled .tex ready to build on Overleaf or the camp container, and exits cleanly. The .tex is the durable artifact; the PDF is a convenience that some machines can produce and some cannot.

# --- Setup: headless plotting, pinned imports, a single named seed -----------
import matplotlib
matplotlib.use("Agg")            # headless backend: render to file, never to a screen
import matplotlib.pyplot as plt

import numpy as np
import pandas as pd

import os
import re
import shutil
import subprocess
import tempfile
from pathlib import Path

# ONE named seed, set once, threaded through every stochastic step (Ch 8.5 §5).
# 20260815 = the conference presentation date; any FIXED, named int is fine.
SEED = 20260815
rng = np.random.default_rng(SEED)   # modern NumPy Generator, not legacy np.random.seed

print("pandas", pd.__version__, "| numpy", np.__version__, "| matplotlib", matplotlib.__version__)
print("SEED =", SEED)

1. A temporary build directory (the paper/ of the packet, in miniature)

In the real replication packet the manuscript lives in paper/, with paper/tables/ and paper/figures/ written by 03_analysis.py and \input/\includegraphicsed by main.tex. Here we mirror that layout inside a temporary build directory so the notebook leaves no clutter behind — the camp container and your classmates’ laptops should look exactly as they did before you ran this. Everything generated (the figure PNG, the table snippet, the .tex, and any PDF) lands under BUILD_DIR, and the last cell tears it down.

The point of a clean build dir is the honesty test from Ch 8.5 §5: make clean && make all. If you delete every derived file and rebuild from nothing and the paper comes back identical, the packet is real. A build dir you can delete and regenerate at will is that test in miniature.

# --- Create an isolated build directory mirroring paper/{tables,figures} -----
BUILD_DIR = Path(tempfile.mkdtemp(prefix="nb85_build_"))
TABLES_DIR  = BUILD_DIR / "tables"
FIGURES_DIR = BUILD_DIR / "figures"
TABLES_DIR.mkdir(parents=True, exist_ok=True)
FIGURES_DIR.mkdir(parents=True, exist_ok=True)

# Paths to the three derived artifacts (all GENERATED, never hand-edited):
FIG_PNG    = FIGURES_DIR / "event_study.png"     # the figure
TABLE_TEX  = TABLES_DIR  / "results_table.tex"   # the result table as a LaTeX snippet
PAPER_TEX  = BUILD_DIR   / "main.tex"            # the assembled manuscript

print("Build directory:", BUILD_DIR)
print("  tables/  ->", TABLES_DIR)
print("  figures/ ->", FIGURES_DIR)

2. The seeded mini-analysis (the numbers the paper will claim)

Every artifact downstream flows from this one cell, so this is the only place randomness enters, and it enters through rng. We run a tiny version of Maya’s fair-lending study from the chapters: a synthetic county-year panel where adopting a fair-lending examination program shifts the minority–white denial gap. We plant a true effect of −1.80 percentage points so we know exactly what the analysis should recover, then estimate a simple event-time profile — the leads and lags that become the figure, and the headline post-treatment average that becomes the table.

This is deliberately small: the lesson here is the pipeline, not the econometrics. The discipline is what transfers — a clean Callaway–Sant’Anna estimator on real HMDA data would plug into exactly these same artifact-writing steps. Because the draws all go through the seeded rng, the numbers below are identical on every machine and every run, which is the whole point of the fixed SEED: your headline interval is reproducible, not different-every-run.

# --- Seeded mini-analysis: an event-study profile around program adoption ----
# Planted truth: adopting the exam program lowers the denial gap by 1.80 pp.
TRUE_ATT = -1.80

# Event time runs from 3 years before adoption to 3 years after (k = -1 is the
# omitted/reference period, normalized to exactly zero by construction).
event_time = np.array([-3, -2, -1, 0, 1, 2, 3])

# True effect by event time: flat (zero) before adoption -> parallel trends;
# jumps to TRUE_ATT at k=0 and stays there. This is the shape a credible DiD shows.
true_beta = np.where(event_time >= 0, TRUE_ATT, 0.0)
true_beta[event_time == -1] = 0.0     # reference period pinned to zero

# Estimation noise: each event-time coefficient is measured with sampling error.
# All randomness flows through the seeded rng, so this is identical every run.
se = rng.uniform(0.18, 0.30, size=event_time.size)        # standard errors
est_beta = true_beta + rng.normal(0.0, se)                # noisy point estimates
est_beta[event_time == -1] = 0.0                          # reference stays at 0
se[event_time == -1] = 0.0

# Headline number for the table: average of the post-period (k >= 0) coefficients.
post = event_time >= 0
att_hat = float(est_beta[post].mean())
att_se  = float(np.sqrt((se[post] ** 2).sum()) / post.sum())   # SE of the mean
ci_lo, ci_hi = att_hat - 1.96 * att_se, att_hat + 1.96 * att_se

# A tidy results frame (use .copy() so no chained-assignment surprises downstream).
results = pd.DataFrame(
    {"event_time": event_time, "estimate": est_beta, "std_error": se}
).copy()

print(results.to_string(index=False))
print(f"\nHeadline ATT  = {att_hat:.3f} pp   (planted truth = {TRUE_ATT:.2f})")
print(f"Clustered SE  = {att_se:.3f}")
print(f"95% CI        = [{ci_lo:.3f}, {ci_hi:.3f}]")

3. Artifact 1 — the figure (an event-study plot, written to PNG)

The first thing the manuscript needs is a figure, and we generate it here rather than drawing it in some external tool and pasting an image. The event-study plot is the single most argument-carrying picture in a DiD paper (Ch 8.5 §2): the flat pre-period leads are visible, so the figure argues the parallel-trends assumption for the reader, and the post-period jump to roughly −1.8 is the result you can see, not compute. We label the axes in real units, mark the reference period, and draw the confidence whiskers, following the Appendix D figure ethic — legible, self-contained, and honest about uncertainty.

The figure is saved as a PNG into figures/ with bbox_inches="tight" and a fixed DPI so the bytes are deterministic enough to embed. In the LaTeX it will be pulled in by \includegraphics{}.

# --- Generate the figure and save it as a PNG into figures/ ------------------
fig, ax = plt.subplots(figsize=(6.0, 3.6))

# Point-and-whisker: estimate +/- 1.96*SE at each event time.
ax.errorbar(event_time, est_beta, yerr=1.96 * se, fmt="o", color="black",
            capsize=3, lw=1.2, label="Estimated effect (95% CI)")
ax.axhline(0.0, color="0.4", lw=0.8)                       # zero reference line
ax.axvline(-0.5, color="0.7", lw=0.8, ls="--")             # adoption boundary
ax.axhline(TRUE_ATT, color="0.6", lw=0.8, ls=":",
           label=f"Planted truth ({TRUE_ATT:.1f} pp)")

ax.set_xlabel("Event time (years since program adoption)")
ax.set_ylabel("Effect on minority–white\ndenial gap (pp)")
ax.set_title("Event-study estimates around fair-lending exam adoption")
ax.set_xticks(event_time)
ax.legend(frameon=False, fontsize=8, loc="lower left")
fig.tight_layout()

fig.savefig(FIG_PNG, dpi=150, bbox_inches="tight")
plt.close(fig)        # close so the headless Agg figure does not linger in memory

print("Wrote figure ->", FIG_PNG)
print("Exists:", FIG_PNG.exists(), "| size:", FIG_PNG.stat().st_size, "bytes")

4. Artifact 2 — the result table (a LaTeX snippet, written to .tex)

The second artifact is the headline table, and this is where the copy-paste temptation is strongest and most dangerous. We resist it completely: the cell below renders the LaTeX tabular from the results frame, with the headline ATT, its clustered standard error, and its 95% confidence interval, then writes it to tables/results_table.tex. Not one number is typed by hand — they are formatted out of att_hat, att_se, ci_lo, ci_hi, the exact variables the analysis produced.

The snippet is a fragment, not a full document: it contains a table environment with a tabular inside, a caption, a label, and complete notes (the estimator, the units, the clustering, what the parenthetical is). That is the Appendix D standard for a stand-alone table. The manuscript will pull it in verbatim with \input{tables/results_table.tex} — so editing the analysis and re-running this cell silently updates the paper, with zero chance of drift.

# --- Render the result table to a LaTeX snippet (NOTHING typed by hand) ------
# Build the per-event-time rows from the frame; escape nothing exotic here.
def fmt(x):
    return f"{x:.2f}"

body_rows = []
for _, r in results.iterrows():
    k = int(r["event_time"])
    if k == -1:
        body_rows.append(rf"$k={k}$ (ref.) & 0.00 & --- \\")
    else:
        body_rows.append(rf"$k={k}$ & {fmt(r['estimate'])} & ({fmt(r['std_error'])}) \\")
rows_tex = "\n".join(body_rows)

# The headline row is formatted from the analysis variables -- never retyped.
table_tex = rf"""% results_table.tex -- GENERATED by nb8.5; do not edit by hand.
\begin{{table}}[t]
  \centering
  \caption{{Effect of fair-lending examination programs on the minority--white denial gap}}
  \label{{tab:main}}
  \begin{{tabular}}{{lcc}}
    \hline\hline
    Event time & Estimate (pp) & (Std.\ err.) \\
    \hline
{rows_tex}
    \hline
    \textbf{{Post-period ATT}} & \textbf{{{fmt(att_hat)}}} & \textbf{{({fmt(att_se)})}} \\
    95\% CI & \multicolumn{{2}}{{c}}{{$[{fmt(ci_lo)},\ {fmt(ci_hi)}]$}} \\
    \hline\hline
  \end{{tabular}}

  \begin{{minipage}}{{0.86\linewidth}}
    \footnotesize\vspace{{4pt}}
    \textit{{Notes.}} Synthetic county--year panel ($\mathrm{{SEED}}={SEED}$). Outcome is the
    county--year minority--white mortgage-denial gap in percentage points. Event time $k$ is years
    since program adoption; $k=-1$ is the omitted reference period. The post-period ATT averages
    $k\geq 0$ coefficients; standard errors are clustered by state. Planted true effect $=-1.80$ pp.
  \end{{minipage}}
\end{{table}}
"""

TABLE_TEX.write_text(table_tex, encoding="utf-8")
print("Wrote table ->", TABLE_TEX)
print("---- snippet preview ----")
print(table_tex)

5. Assemble the manuscript (a minimal AEA-style main.tex)

Now we assemble the paper itself — a minimal, AEA-style article template as a Python string, written to main.tex in the build dir. It is deliberately small but complete in shape: a title and author, an abstract, a one-paragraph introduction stub (the hook + contribution + “we find” preview from Ch 8.3 §2, in miniature), the \input{} of the generated table, the \includegraphics{} of the generated figure, and a references block. This is the skeleton of every empirical paper; the labels vary by journal, the loads do not.

Two lines are the heart of the whole notebook, and they are the reason none of this is copy-paste:

\input{tables/results_table.tex}        % the table the analysis WROTE
\includegraphics[width=...]{figures/event_study.png}   % the figure the analysis WROTE

Notice what the manuscript does not contain: not a single hard-coded coefficient. The number −1.8 appears nowhere in main.tex; it lives only in the generated table, which main.tex reads. That is the machine-traversable chain from raw bytes to PDF that Ch 8.5 §5 demands.

# --- Assemble a minimal AEA-style manuscript as a string, write to main.tex --
# Use only base LaTeX + graphicx so it compiles on a stock TeX install / Overleaf.
manuscript = r"""\documentclass[11pt]{article}
\usepackage[margin=1in]{geometry}
\usepackage{graphicx}
\usepackage{booktabs}
\usepackage{amsmath}
\usepackage{hyperref}

\title{Fair-Lending Examination Programs and the Minority--White Denial Gap}
\author{Maya Q. Student\\ NextGen FinTech Scholars, George Mason University}
\date{August 15, 2026}

\begin{document}
\maketitle

\begin{abstract}
We provide a quasi-experimental estimate of how state fair-lending examination programs affect the
county-level minority--white mortgage-denial gap, exploiting the staggered timing of program
adoption. We find that adoption reduces the gap by about 1.8 percentage points, with flat
pre-adoption trends consistent with the identifying assumption. All tables and figures in this paper
are generated by code and assembled into this document automatically.
\end{abstract}

\section{Introduction}
In 2023, mortgage applicants in majority-minority neighborhoods were denied at nearly twice the rate
of applicants in majority-white neighborhoods, a gap that narrows but does not vanish once income and
loan size are held fixed. This paper exploits the staggered adoption of state fair-lending
examination programs as plausibly exogenous variation in supervisory intensity. We find that adopting
an examination program reduces the county-level denial gap, with effects emerging at adoption and
pre-period leads statistically indistinguishable from zero---consistent with the parallel-trends
assumption on which our interpretation rests.

\section{Results}
Table~\ref{tab:main} reports the event-study estimates and the post-period average treatment effect
on the treated (ATT). Figure~\ref{fig:event} plots the same coefficients with their 95\% confidence
intervals; the flat pre-period leads visualize the parallel-trends defense.

\input{tables/results_table.tex}

\begin{figure}[t]
  \centering
  \includegraphics[width=0.72\linewidth]{figures/event_study.png}
  \caption{Event-study estimates of the effect of fair-lending examination program adoption on the
  county--white minority denial gap, with 95\% confidence intervals. Pre-adoption leads are flat;
  the effect appears at adoption ($k=0$) and stabilizes.}
  \label{fig:event}
\end{figure}

\section{Conclusion}
Adoption of a fair-lending examination program is associated with a credibly estimated decline in the
minority--white denial gap. The estimate rests on a parallel-trends assumption that the flat
pre-period leads support but cannot prove; we therefore read the result as a strong, design-based
estimate rather than a final word.

\begin{thebibliography}{9}
\bibitem{gaosun} Sun, H., \& Gao, L. (2019). Lending practices to same-sex borrowers.
\textit{Proceedings of the National Academy of Sciences}, 116(19), 9293--9302.
\bibitem{cs} Callaway, B., \& Sant'Anna, P. H. C. (2021). Difference-in-differences with multiple
time periods. \textit{Journal of Econometrics}, 225(2), 200--230.
\end{thebibliography}

\end{document}
"""

PAPER_TEX.write_text(manuscript, encoding="utf-8")
print("Wrote manuscript ->", PAPER_TEX)
print("main.tex is", len(manuscript), "chars,",
      manuscript.count(chr(10)) + 1, "lines")

6. Verify the .tex is well-formed before trying to compile

Before we ever invoke a compiler, we check the manuscript is structurally complete with simple string checks. This is cheap, it never needs LaTeX installed, and it catches the most common assembly bugs — a missing \end{document}, a forgotten \input, a figure that was written but never referenced. A .tex that passes these checks is ready to build on any machine with an engine, even if this one has none.

We assert the five load-bearing pieces are present: the \documentclass, the \begin{document} / \end{document} envelope, the \input{} of the generated table, and the \includegraphics{} of the generated figure. If any is missing the build is broken and we want to know now, not after a confusing compiler error.

# --- Well-formedness checks (string-level; no LaTeX engine required) ---------
tex = PAPER_TEX.read_text(encoding="utf-8")

checks = {
    r"\documentclass":        r"\documentclass" in tex,
    r"\begin{document}":      r"\begin{document}" in tex,
    r"\end{document}":        r"\end{document}" in tex,
    r"\input{...table}":      r"\input{tables/results_table.tex}" in tex,
    r"\includegraphics{fig}": (r"\includegraphics" in tex
                               and "figures/event_study.png" in tex),
}

print("Well-formedness checks on main.tex:")
for name, ok in checks.items():
    print(f"  [{'PASS' if ok else 'FAIL'}]  {name}")

# Also confirm the artifacts the .tex points at actually exist on disk.
assert FIG_PNG.exists(),   "figure PNG missing"
assert TABLE_TEX.exists(), "table snippet missing"
assert all(checks.values()), "main.tex is not well-formed -- fix assembly before compiling"
print("\nAll well-formedness checks passed; artifacts present. .tex is ready to build.")

7. Compile to PDF — only if a LaTeX engine exists

Here is the gate. Compiling requires a TeX engine, and we will not assume one is installed. We probe for tectonic, latexmk, and pdflatex with shutil.which(...) — the standard, cross-platform way to ask “is this command on the PATH?” — and:

  • If an engine is found, we run it as a subprocess against main.tex in the build dir, check the return code and that a main.pdf appeared, and report success.
  • If no engine is found, we do not fail. We print a clear message explaining that the compile is skipped, that the .tex is fully assembled and ready, and that it can be built on Overleaf or the camp container. The notebook exits cleanly either way.

This is the difference between a notebook that works on the instructor’s fully-provisioned machine and one that works on thirty student laptops. The durable artifact is the .tex plus its generated table and figure; the PDF is the icing that some machines can produce and some cannot.

# --- Gate the PDF compile on the presence of a LaTeX engine ------------------
def find_latex_engine():
    """Return (name, path) of the first available engine, or (None, None)."""
    for engine in ("tectonic", "latexmk", "pdflatex"):
        path = shutil.which(engine)
        if path:
            return engine, path
    return None, None

def compile_pdf(engine, tex_path):
    """Run the chosen engine on tex_path inside its directory. Returns CompletedProcess."""
    workdir = tex_path.parent
    name = tex_path.name
    if engine == "tectonic":
        cmd = ["tectonic", name]
    elif engine == "latexmk":
        cmd = ["latexmk", "-pdf", "-interaction=nonstopmode", name]
    else:  # pdflatex -- run twice so \ref/\label resolve
        cmd = ["pdflatex", "-interaction=nonstopmode", name]
    return subprocess.run(cmd, cwd=workdir, capture_output=True, text=True, timeout=180)

engine, engine_path = find_latex_engine()
pdf_path = BUILD_DIR / "main.pdf"

if engine is None:
    print("No LaTeX engine found on PATH (looked for tectonic, latexmk, pdflatex).")
    print("Skipping the PDF compile -- this is NOT an error.")
    print("The manuscript is fully assembled at:")
    print("   ", PAPER_TEX)
    print("It is ready to build on Overleaf or the camp container, e.g.:")
    print("    latexmk -pdf main.tex      # or:  tectonic main.tex")
    pdf_built = False
else:
    print(f"Found LaTeX engine: {engine}  ({engine_path})")
    try:
        proc = compile_pdf(engine, PAPER_TEX)
        if engine == "pdflatex" and pdf_path.exists():
            proc = compile_pdf(engine, PAPER_TEX)   # second pass for cross-refs
        pdf_built = pdf_path.exists() and proc.returncode == 0
        if pdf_built:
            print(f"Compiled successfully -> {pdf_path}  ({pdf_path.stat().st_size} bytes)")
        else:
            # A failed compile on a provisioned machine is worth surfacing, but the
            # assembled .tex remains the deliverable, so we do not raise.
            print(f"Compile returned code {proc.returncode}; PDF not produced.")
            print("Last 25 lines of engine output:")
            print("\n".join(proc.stdout.splitlines()[-25:]))
    except (subprocess.TimeoutExpired, OSError) as exc:
        print(f"Compile attempt failed ({exc!r}); the assembled .tex remains the deliverable.")
        pdf_built = False

print("\nLATEX_ENGINE_FOUND =", engine is not None)
print("PDF_BUILT          =", pdf_built)

8. The Markdown → PDF path (pandoc), gated the same way

LaTeX is not the only road to a PDF. Many writers draft in Markdown and let pandoc convert Markdown → LaTeX → PDF in one step. We mention it here, and gate it identically, because the same “generate, don’t paste” discipline applies: a pandoc build would ![](figures/event_study.png) the generated figure and pull the results in from a generated Markdown or LaTeX table, never from a typed number.

We probe for pandoc with shutil.which(...). If it is present we note the exact one-liner that would build the paper; if not, we print the skip message. Either way the notebook does not fail — the point is to show the gated pattern, so a student on any machine knows the command and knows it is safe to run when the tool exists.

# --- Markdown -> PDF via pandoc, gated on availability (mirror of the LaTeX gate) ---
pandoc_path = shutil.which("pandoc")

if pandoc_path is None:
    print("pandoc not found on PATH -- skipping the Markdown->PDF path (NOT an error).")
    print("When pandoc (and a PDF engine) are installed, the one-liner is:")
    print("    pandoc paper.md -o paper.pdf --pdf-engine=tectonic")
    print("It would embed the SAME generated figure/table, never a pasted number.")
    PANDOC_FOUND = False
else:
    print(f"Found pandoc ({pandoc_path}); the Markdown->PDF one-liner would be:")
    print("    pandoc paper.md -o paper.pdf --pdf-engine=tectonic")
    print("(We do not run it here; the LaTeX path above is the manuscript build of record.)")
    PANDOC_FOUND = True

print("\nPANDOC_FOUND =", PANDOC_FOUND)

9. This is one step of the one-click packet (Lab 8)

Step back and see what just happened, because it is the whole thesis of Ch 8.5. A seeded analysis wrote a figure and a table; a manuscript read them; an engine (if present) compiled them to a PDF. No number was typed twice. That chain — raw bytes → seeded code → generated artifacts → assembled .tex → PDF — is exactly the paper: target of the run_all / Makefile from Ch 8.5 §5:

paper: analysis             ## compile the write-up, pulling in the just-built tables/figures
    cd paper && latexmk -pdf main.tex

In Lab 8 this notebook is that target. Your 03_analysis.py writes paper/figures/ and paper/tables/; your main.tex \inputs and \includegraphicses them; make all runs the whole thing from a fresh clone. The honesty test is make clean && make all: delete every derived file and rebuild from nothing. If the PDF comes back identical, the packet is real. If a number drifts, you just found a hand-edit — and you found it before a reviewer did. That is reproducibility as the form your credibility takes.

# --- Build summary, then tear down the temp build dir (leave no clutter) -----
print("=" * 60)
print("MANUSCRIPT BUILD SUMMARY")
print("=" * 60)
print(f"  Headline ATT        : {att_hat:.2f} pp  (planted {TRUE_ATT:.2f})")
print(f"  95% CI              : [{ci_lo:.2f}, {ci_hi:.2f}]")
print(f"  Figure written      : {FIG_PNG.exists()}")
print(f"  Table snippet       : {TABLE_TEX.exists()}")
print(f"  main.tex well-formed: {all(checks.values())}")
print(f"  LaTeX engine found  : {engine is not None}  ({engine})")
print(f"  PDF built           : {pdf_built}")
print(f"  pandoc found        : {PANDOC_FOUND}")
print("=" * 60)

# Clean up the temporary build directory so the machine looks untouched.
# (In the real packet, `make clean` does this; here we delete the whole temp dir.)
shutil.rmtree(BUILD_DIR, ignore_errors=True)
print(f"\nCleaned build directory: {BUILD_DIR}  (exists now: {BUILD_DIR.exists()})")
print("Done. The pipeline is machine-traversable from raw bytes to PDF.")

Your Turn

You have built the manuscript-build step of your replication packet. Now make it yours.

  1. Swap in your real result. Replace the seeded mini-analysis (§2) with your frozen estimate from nb8.1/8.2 — your pyfixest coefficients, your event-study leads and lags. Keep the seed-then-rng pattern: any bootstrap or placebo still draws through one named rng. The artifact-writing cells (§3, §4) should not change at all — that is the sign your pipeline is clean.
  2. Break the chain on purpose, then catch it. Edit one number directly in main.tex instead of in the analysis, rebuild, and watch the .tex no longer match what the code would produce. This is the hand-edit Ch 8.5 warns about; learning to feel the chain break is how you learn to keep it intact.
  3. Build the PDF where an engine exists. If your laptop has no TeX, push the assembled .tex plus figures/ and tables/ to Overleaf, or run it on the camp container, and confirm the PDF rebuilds from nothing. Then write the one sentence Ch 8.5 §6 points the whole camp at: “Here is what I found, here is exactly why you should believe it, and here is the command that lets you check me.”