MachineSex/paper/manuscript/build.py
Giorgio Gilestro ab3dc10587 Restructure: descriptive tier and experiment names, paper/manuscript
- paper/pnas -> paper/manuscript (venue-neutral)
- configs/layer1 -> configs/inheritance, src/knowledge -> src/inheritance
  (imported as `inheritance`), make layer1 -> make inheritance; layer2 alias dropped
- inheritance and trained-network bundles named after the manuscript figure
  they feed (fig2_grounding_sweep, figS3_rebaselining, ...), or descriptively
  where they feed none; configs keep their `experiment:` value so parquet
  hashes are unchanged, only output.dir moves
- figure scripts, SI figure sources, notebooks, REPRODUCING.md, README and the
  SI Methods/tables updated; make clean no longer deletes tracked manifests;
  reproduce.sh hashes the s{seed}/ layouts too

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y64o8FKP7rCuXzC48pxpMm
2026-09-13 17:00:40 +01:00

271 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

r"""Build the manuscript-draft PDF from main.md (Markdown stays the source of truth).
Adapted from paper/arxiv/md2tex.py (same Markdown subset + pipe tables), with one addition: standalone
`*(FIG:name)*` markers place the publication figures produced by make_figs.py (unified, lettered,
codename-free panels re-plotted from the committed artifacts). Run make_figs.py before building when
results change.
Usage: python paper/manuscript/build.py && (cd paper/manuscript && tectonic main.tex)
"""
from __future__ import annotations
import re
import shutil
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
HERE = Path(__file__).resolve().parent
SRC = HERE / "main.md"
OUT = HERE / "body.tex"
SI_FIG_DIR = HERE / "figs" / "si"
import sys # noqa: E402
sys.path.insert(0, str(HERE))
from si_figures import SI_FIGURES # noqa: E402 (supplementary figures, numbered by order in si.md)
# figure name -> (single publication PDF from make_figs.py, caption)
FIGURES: dict[str, tuple[list[str], str]] = {
"fig1": (["paper/manuscript/figs/fig1a.pdf", "paper/manuscript/figs/fig1b.pdf"],
"A map of the study. (A) Each row is a biological mechanism the paper borrows, each column a level "
"of realism at which it is tested: an inheritance model (an exact simulation of knowledge "
"transmission, green), trained neural networks measured against exact oracles (blue), and language "
"models (blue). Filled cells name the experiments run at each level and, in the corner, the figure "
"or table reporting them; grey cells were not run, either because the result is "
"established in prior work (23, 34) or because that level adds no new test for that question. The "
"inheritance model is the reference column: it sets the expectation the real-model experiments are "
"read against. (B) The change of viewpoint the transfer rests on. A group of models is usually "
"pictured as a society in space, contemporaries exchanging messages. The couplings studied here run "
"between generations: training on model output (inheritance), weight-space merging (recombination), "
"and verified real data entering each generation (immigration from reality). That is a society in "
"time, which is what population genetics describes. Dots are capabilities: the rare one (gold) is "
"lost under single-parent inheritance, reassembled by merging complementary parents, and re-supplied "
"by grounding."),
"fig2": (["paper/manuscript/figs/fig2.pdf"],
"How much real data stops model collapse. (A) An image-generating network (a variational "
"autoencoder) is trained on handwritten digits, then a fresh copy is trained only on the digits the "
"previous one drew, for fifteen generations, with no real data added. Each row is a later generation "
"(0, 4, 8, 12, 15) and each column a randomly chosen drawing. The thirty kinds of digit (ten digits "
"$\\times$ three stroke thicknesses, some kinds rare) collapse to one blurred shape; an independent "
"classifier confirms that the number of kinds still drawn falls from 30 to 1, while adding 10\\% real "
"digits each generation keeps all 30 (Fig.~S7; 4 replicates). (B) The same question in the "
"inheritance model, the exact simulation: 1,000 knowledge items, 200 samples drawn per generation, "
"and a fraction $g$ of fresh real samples mixed in. Points are the diversity the population settles "
"at after 500 generations (mean and 95\\% CI over 100 lineages), the dashed line the exact prediction "
"(the immigration--drift equilibrium), the dotted line the diversity of the real data itself. The "
"curve is smooth, so any threshold is a choice: the red line marks the $g$ at which 95\\% of the real "
"data's diversity is kept, about 0.05 (bootstrap CI shaded). The hollow point at $g = 0$ has not yet "
"reached its equilibrium of zero. The trained image model needed about twice this fraction, because "
"a trained network is not the exact copier the simulation assumes (Fig.~S2)."),
"fig4": (["paper/manuscript/figs/fig4.pdf"],
"A population of language models over six generations. (A) The set-up. Three lineages start "
"from one frozen 1.5-billion-parameter base (Qwen2.5-1.5B). Each generation, every lineage learns "
"one new skill from a public dataset by continuing to train its parent's adapter (300 new examples "
"plus 150 replayed from earlier skills), may merge with a partner according to its arm's rule "
"(weights averaged at a ratio chosen on validation data), and is tested on all six skills by a "
"verifier; the child becomes the next parent. The six skills are taken in rotated order, so a "
"partner knows things a lineage lacks early on (complementarity 1.0) and nothing it lacks by the "
"end (0.0). Three training seeds. (B) Accuracy over all six skills of the best lineage (mean and "
"95\\% CI). Never merging and merging only when it beats keeping the parent finish level (0.80 and "
"0.79); merging with a contemporary every generation collapses to 0.27, beginning when partners "
"stop being complementary; a control that merges through generation 2 and then stops (dashed) "
"matches the declinable arm in every seed, and a single model taught the whole syllabus alone "
"(diamond) matches the population. (C) How often the declinable lineages refused a merge (bars) "
"against partner complementarity (lines), under the rotated syllabus and under a second syllabus in "
"which complementarity is zero at the start, peaks mid-way and returns to zero. Refusals rise with "
"generation under both; with generation held fixed they do not track complementarity (partial "
"Spearman $\\rho = -0.07$, 95\\% CI $-0.21$ to $0.09$, $n = 36$). (D--F) The simulation that "
"motivated the design: 60 agents evolving on a rugged fitness landscape with all four mechanisms "
"(grounded evaluation, recombination, diversity preservation, mutation) and one removed per arm (12 "
"replicates; mean and 95\\% CI). Removing grounded evaluation, so that agents are scored on "
"agreement with the crowd instead of on the truth, collapses the population onto a confident but "
"wrong consensus (D, F); removing recombination or diversity preservation strands it below the "
"optimum (D) and drains diversity fastest (E). Each removal fails in its own way."),
"fig5": (["paper/manuscript/figs/fig5.pdf"],
"Model speciation: when two lineages can no longer merge. (A, B) Small image classifiers "
"(multilayer perceptrons) forked from one trained base. Two networks that compute the same function "
"can still differ in their weights, because hidden units can be renumbered and rescaled without "
"changing the output; alignment undoes this before averaging. The merge error barrier is how much "
"worse the average of two networks is than the networks themselves. (A) Two copies trained from "
"different random starts on the same task have a barrier that alignment removes almost entirely "
"(0.04 to 0.001); two trained on conflicting labels (the same images, some classes relabelled) keep "
"theirs (0.50), and their average is useless (3 replicates). (B) Sweeping the share of classes in "
"conflict moves the merged model's accuracy from 0.97 to 0.03. (C) Language models: two specialists "
"share a set of ambiguous questions (``sort this list'', direction unstated) and are taught opposite "
"conventions. As the share of conflicting training grows, each parent stays good under its own "
"convention while the merged model falls below both, in all three seeds (95\\% CI shaded). (D) The "
"control: specialists trained longer and longer on different tasks, with no conflict, merge better, "
"not worse, in every seed. (E, F) The simulation: 20-position genotypes carrying incompatibilities "
"of the Bateson--Dobzhansky--Muller kind. Hybrid fitness tracks the parents while lineages are "
"compatible, then crashes, sooner the denser the incompatibilities (E), and the probability of a "
"non-viable hybrid rises with divergence (F). What breaks merging is conflicting conventions on "
"shared machinery, not distance or specialisation as such."),
"fig3": (["paper/manuscript/figs/fig3.pdf"],
"Merging language-model specialists: when it helps, and predicting when it will hurt. All models "
"are built from one frozen base (Qwen2.5) plus a LoRA adapter, a small set of extra weights trained "
"on one family of tasks (list puzzles, string puzzles or arithmetic); a verifier marks every answer "
"right or wrong, and accuracy is the share marked right on held-out questions. (A) The models "
"compared: the base alone; three specialists (one adapter each); their merge by averaging the "
"adapters; their merge after dropping the changes on which the parents conflict (TIES); and routing, "
"which keeps the specialists separate and sends each question to the one that owns it. (B) Easy "
"tasks, 0.5-billion-parameter base, five training seeds (fixed test sets; mean and 95\\% CI). Both "
"merges beat the best single specialist on the weakest task family (paired t-test over seeds, "
"$p < 10^{-4}$), and the interference-aware merge beats it overall ($p = 0.006$; the plain average "
"$p = 0.09$, ahead in 4 of 5 seeds); the two merges do not differ from each other. Only merged models "
"are competent on every family. (C) Deliberately hard tasks, 7-billion-parameter base, three seeds. "
"Averaging only matches the best specialist overall ($p = 0.96$) although it lifts the weakest "
"family ($p = 0.009$); routing beats averaging overall ($p = 0.007$, ahead in every seed) and beats "
"the best specialist on both measures ($p = 0.018$ and $0.014$). With three seeds, some comparisons "
"that hold in every seed are not significant (ns). (D) Predicting merge damage before merging: 39 "
"pairs of specialists built along three axes, parents taught contradictory conventions (red), "
"parents merely trained longer on different tasks (blue), and parents sharing training data without "
"conflict (green). The horizontal axis is how often the two parents confidently disagree when asked "
"the same questions before merging; the vertical axis is the merge penalty, the accuracy the merged "
"model loses relative to answering each task with the parent that owns it. Damage concentrates in "
"the conflicting pairs. (E) Six pre-merge measures ranked by how strongly they track the penalty "
"(absolute Spearman correlation): measures taken by asking the parents questions carry the signal, "
"measures taken on the parents' weights do not; differences between individual predictors are not "
"significant at this sample size (Table~S2)."),
}
UNICODE = {
# Multi-character sequences first: esc() applies these in insertion order.
"⁽ᵏ⁾": r"\(^{(k)}\)", "⁻³": r"\(^{-3}\)", "⁻⁴": r"\(^{-4}\)",
"": "---", "": "--", "": r"\(\rightarrow\)", "": r"\(\approx\)", "": r"\(\geq\)",
"": r"\(\gtrsim\)", "×": r"\(\times\)", "·": r"\(\cdot\)", "μ": r"\(\mu\)",
"ρ": r"\(\rho\)", "": r"\(\leq\)", "": r"\(\ll\)", "": r"\(\propto\)",
# Greek and math symbols: the typewriter font has no Greek, so these must become math.
"α": r"\(\alpha\)", "β": r"\(\beta\)", "ε": r"\(\varepsilon\)", "Δ": r"\(\Delta\)",
"Σ": r"\(\Sigma\)", "": r"\(\in\)", "": r"\(\neq\)", "±": r"\(\pm\)",
"": r"\(\sim\)", "": r"\(-\)", "²": r"\(^{2}\)", "³": r"\(^{3}\)", "": r"\(^{4}\)",
}
SPECIALS = {"&": r"\&", "%": r"\%", "#": r"\#", "_": r"\_", "$": r"\$",
"~": r"\textasciitilde{}", "^": r"\textasciicircum{}"}
def esc(s: str) -> str:
s = s.replace("\\", r"\textbackslash{}")
for k, v in SPECIALS.items():
s = s.replace(k, v)
for k, v in UNICODE.items():
s = s.replace(k, v)
return s
def inline(s: str) -> str:
# Code spans are stashed behind sentinels *before* emphasis is applied, so that an italic or
# bold span containing `code` is still matched as one span (splitting first would break it).
codes: list[str] = []
def stash(m: re.Match) -> str:
codes.append(r"\texttt{" + esc(m.group(0)[1:-1]) + "}")
return f"\x00{len(codes) - 1}\x00"
s = re.sub(r"`[^`]*`", stash, s)
s = esc(s)
s = re.sub(r"\[([^\]]+)\]\((https?://[^)]+)\)", r"\\href{\2}{\1}", s)
s = re.sub(r"\*\*([^*]+)\*\*", r"\\textbf{\1}", s)
s = re.sub(r"\*([^*]+)\*", r"\\emph{\1}", s)
s = re.sub(r'"([^"]+)"', r"``\1''", s)
return re.sub(r"\x00(\d+)\x00", lambda m: codes[int(m.group(1))], s)
def figure_env(name: str) -> str:
if name in SI_FIGURES:
# Supplementary figure: copy the committed per-experiment PDF beside the SI build, so the SI
# stays a pure function of the results artifacts; numbered by the LaTeX counter (S-prefixed).
src, caption = SI_FIGURES[name]
SI_FIG_DIR.mkdir(parents=True, exist_ok=True)
dst = SI_FIG_DIR / f"{name}_{(ROOT / src).name}"
shutil.copyfile(ROOT / src, dst)
return "\n".join([f"\\begin{{figure}}[p]\\centering % {name}",
f"\\includegraphics[width=\\textwidth]{{figs/si/{dst.name}}}",
f"\\caption{{{caption}}}\\label{{{name}}}",
"\\end{figure}"])
pdfs, caption = FIGURES[name]
incl = "\\\\[6pt]\n".join(f"\\includegraphics[width=\\textwidth]{{figs/{(ROOT / p).name}}}"
for p in pdfs)
lines = [f"\\begin{{figure*}}[p]\\centering % {name}",
incl,
f"\\caption{{{caption}}}\\label{{{name}}}",
"\\end{figure*}"]
return "\n".join(lines)
def convert(text: str) -> str:
lines = text.split("\n")
# Skip the title block only when the document opens with one (main.md separates it with a rule
# in the first few lines); si.md has no such block, so nothing is dropped there.
i = 0
head = [n for n, ln in enumerate(lines[:10]) if ln.strip() == "---"]
if head:
i = head[0] + 1
blocks: list[list[str]] = []
cur: list[str] = []
for line in lines[i:]:
if line.strip() == "":
if cur:
blocks.append(cur); cur = []
else:
cur.append(line)
if cur:
blocks.append(cur)
def emit_table(block, out):
rows = [[c.strip() for c in line.strip().strip("|").split("|")] for line in block]
header, body = rows[0], rows[2:]
n = len(header)
widths = " ".join([f"p{{{0.92 / n:.3f}\\textwidth}}"] * n)
out += ["\\medskip\\noindent\\begin{center}\\footnotesize",
f"\\begin{{tabular}}{{{widths}}}", "\\hline",
" & ".join(inline(c) for c in header) + " \\\\ \\hline"]
for r in body:
r = (r + [""] * n)[:n]
out.append(" & ".join(inline(c) for c in r) + " \\\\[3pt]")
out += ["\\hline\\end{tabular}\\end{center}\\medskip", ""]
out: list[str] = []
for block in blocks:
first = block[0].strip()
m = re.match(r"^\*?\(FIG:(\w+)\)\*?$", first)
if m:
out.append(figure_env(m.group(1))); out.append("")
elif first.startswith("|") and len(block) >= 2 and set(block[1].strip()) <= set("|-: "):
emit_table(block, out)
elif first == "---" and len(block) == 1:
out.append("\\medskip\\hrule\\medskip"); out.append("")
elif first.startswith("# ") and not first.startswith("## "):
continue # document title: the wrapper supplies it
elif first.startswith("## "):
out.append(f"\\section*{{{inline(first[3:])}}}"); out.append("")
elif first.startswith("### "):
out.append(f"\\subsection*{{{inline(first[4:])}}}"); out.append("")
elif re.match(r"^(- |\d+\. )", first):
env = "itemize" if first.startswith("- ") else "enumerate"
out.append(f"\\begin{{{env}}}")
items: list[str] = []
for l in block:
s = l.strip()
if re.match(r"^(- |\d+\. )", s):
items.append(re.sub(r"^(- |\d+\. )", "", s))
else:
items[-1] += " " + s
for it in items:
out.append("\\item " + inline(it.strip()))
out.append(f"\\end{{{env}}}"); out.append("")
else:
joined = re.sub(r"\s{2,}", " ", " ".join(l.strip() for l in block)).strip()
out.append(inline(joined)); out.append("")
return "\n".join(out) + "\n"
if __name__ == "__main__":
import sys
doc = sys.argv[1] if len(sys.argv) > 1 else "main"
src = HERE / f"{doc}.md"
out = HERE / ("body.tex" if doc == "main" else f"{doc}_body.tex")
out.write_text(convert(src.read_text()))
print(f"wrote {out}")