main: keep only what reproduces the manuscript; everything else lives on dev

Removed from main (all preserved on the dev branch): the arXiv build and
its sources, design documents (blueprint, results summary, review responses,
essay drafts), tasks/ and CLAUDE.md, the cover letter and reference tooling,
two unused manuscript figures, and every experiment that feeds no figure or
number in the paper: the collapse null, the sexual-vs-asexual lineage, the
NK speciation variant, the 0.5B single-seed LLM prototypes, the compose and
society experiments with their calibration and pilot runs, and their
configs, runners, tests, figure scripts and PBS jobs. Their result bundles
are moved to results/_archive/ (ignored) so the parquets stay on disk.

Also: plot_llm_speciation reads the s{seed}/ layout; the mating-breadth
plot writes under its bundle name; Makefile targets reduced to the kept
experiments; REPRODUCING.md and README point to dev for the rest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y64o8FKP7rCuXzC48pxpMm
This commit is contained in:
Giorgio Gilestro 2026-09-13 17:07:23 +01:00
parent ab3dc10587
commit 6f8cef1ac5
292 changed files with 26 additions and 15590 deletions

View file

@ -130,45 +130,6 @@ def run_experiment(cfg: dict) -> pd.DataFrame:
_GENOTYPE_KEYS = ("genotype", "generations")
def run_genotype_experiment(cfg: dict) -> pd.DataFrame:
"""Run a genotype lineage across a sweep x replicates (E7, advantage of sex).
Mirrors ``run_experiment`` (paired replicate seeds) but assembles the base from the
``genotype``/``generations`` blocks and calls ``run_genotype_lineage``. Sweeps use the same
dotted-path ``_apply_param`` (e.g. ``genotype.recomb_rate`` for asexual vs sexual).
"""
from .genotype_lineage import run_genotype_lineage
base = {k: copy.deepcopy(cfg[k]) for k in _GENOTYPE_KEYS if k in cfg}
sweeps = cfg.get("sweep", [])
if isinstance(sweeps, dict):
sweeps = [sweeps]
params = [s["param"] for s in sweeps]
value_lists = [list(s["values"]) for s in sweeps]
combos = [({}, base)] if not sweeps else []
for values in itertools.product(*value_lists):
lin = copy.deepcopy(base)
label: dict = {}
for param, val in zip(params, values):
label.update(_apply_param(lin, param, val))
combos.append((label, lin))
seeds = spawn_seeds(int(cfg["seed"]), int(cfg["n_replicates"]))
frames: list[pd.DataFrame] = []
for label, lin in combos:
for rep, ss in enumerate(seeds):
df = run_genotype_lineage(lin, int(ss.generate_state(1)[0]))
for col, val in label.items():
df[col] = val
df["replicate"] = rep
frames.append(df)
out = pd.concat(frames, ignore_index=True)
out.insert(0, "experiment", cfg["experiment"])
return out
_DYNAMIC_KEYS = ("society", "generations")
def run_dynamic_experiment(cfg: dict) -> pd.DataFrame:
"""Run the dynamic society across an ``arm`` ablation sweep x replicates (E11).
@ -401,8 +362,6 @@ def run_and_save(config_path: str | Path) -> Path:
kind = cfg.get("kind", "lineage")
if kind == "coverage":
df = run_coverage(cfg)
elif kind == "genotype_lineage":
df = run_genotype_experiment(cfg) # E7: advantage of sex
elif kind == "society":
from .society import run_society # E8: multi-parent recombination
df = run_society(cfg)

View file

@ -1,82 +0,0 @@
"""Single-population genotype evolution — the advantage of sex (E7).
A population (distribution over the ``2^L`` genotypes) adapts toward a multi-locus optimum under
the composed generational step: **selection** (fitness-proportional, favouring correct alleles) +
**drift** (finite resample of ``n``) + **mutation** (per-locus flips) + **recombination** (asexual
``rate=0`` vs sexual ``rate>0``). Recombination reassorts beneficial alleles that arise in different
sub-lineages into one genotype; without it (asexual) those alleles suffer *clonal interference* and
adaptation is slower. So a sexual lineage climbs toward the optimum faster than an asexual one the
classical advantage of sex, and the dynamic counterpart of E8's one-shot multi-parent assembly.
Reuses ``step.apply_selection`` (fitness = number of correct loci) and the ``genotype`` operators;
emits the same tidy per-generation DataFrame contract as ``run_lineage`` (with genotype-aware
columns from ``genotype.locus_metrics``).
"""
from __future__ import annotations
from typing import Any, Mapping
import numpy as np
import pandas as pd
from .genotype import additive_fitness, locus_metrics, mutate, recombine
from .step import apply_selection
def run_genotype_lineage(cfg: Mapping[str, Any], seed: int) -> pd.DataFrame:
"""Run one genotype lineage and return per-generation genotype metrics.
Args:
cfg (Mapping): Config with a ``genotype`` block (``L``; drift ``n``; mutation ``mu``;
selection ``base`` for multiplicative fitness ``base^#correct``; recombination
``recomb_rate``; ``init`` in {``wrong``, ``uniform``, ``optimum``}) and
``generations``.
seed (int): Replicate seed; the run is a pure function of (cfg, seed).
Returns:
pd.DataFrame: One row per generation 0..T with ``generation`` plus the
``genotype.locus_metrics`` columns (``mean_fitness``, ``best_fitness``, ``opt_freq``,
``min_load``, ``mean_locus_correct``, ``locus_H``, ``ld``).
"""
g = cfg["genotype"]
L = int(g["L"])
n = int(g["n"])
mu = float(g.get("mu", 0.0))
base = float(g.get("base", 1.0))
rate = float(g.get("recomb_rate", 0.0))
generations = int(cfg.get("generations", 100))
K = 1 << L
report_fitness = additive_fitness(L) # # correct loci (0..L), for metrics
sel_fitness = base ** report_fitness # multiplicative selection weight
init = g.get("init", "wrong")
p = np.zeros(K)
if init == "wrong":
p[0] = 1.0 # all-wrong genotype (load L); adapt upward
elif init == "optimum":
p[-1] = 1.0 # all-correct (for degradation studies)
elif init == "uniform":
p[:] = 1.0 / K
else:
raise ValueError(f"unknown genotype init {init!r} (expected wrong|optimum|uniform)")
rng = np.random.default_rng(seed)
rows: list[dict] = []
def record(t: int) -> None:
row = {"generation": t}
row.update(locus_metrics(p, L, report_fitness, alive_eps=1.0 / n))
rows.append(row)
record(0)
for t in range(1, generations + 1):
p = apply_selection(p, sel_fitness, "greedy", 0.0) # fitness-proportional selection
counts = rng.multinomial(n, p) # drift
p = counts / counts.sum()
p = mutate(p, L, mu) # per-locus mutation
p = recombine(p, L, rate) # asexual (0) vs sexual (>0)
record(t)
return pd.DataFrame(rows)

View file

@ -1,348 +0,0 @@
"""Calibration gates for the v2 society (``kind: llm_society_calib``; prereg §4).
Four stages, each a config ``stage:``, each printing its gate table and writing the usual artifact
triple. Nothing in the campaign is chosen by feel: the family set, the inheritance pool size, and
the recombination operator are all fixed here, by measurement, before any campaign job is submitted.
* ``families`` (C1ac, C4): base and specialist accuracy per candidate family; specialist confidence
AUC (own- vs off-family prompts the routing precondition); pairwise confidence-weighted functional
conflict between specialists (the ``llm_epistasis`` measure; the grid's no-conflict axis sits at
0.200.26 and its conflict axis at 0.460.52, so the gate is < 0.35); gen-0 behavioural distance.
* ``transmission`` (C2): retention of a founder's own-family skill in a child distilled from the
founder's *own answers*, as a function of examples-per-family ``k`` and epochs. Sets ``k_inherit``.
* ``cross`` (C3): one two-founder cross, union-distil vs best-of-6 linear-merge-distil; both
families' accuracy in each child. Tests the operator choice for twenty minutes, not eighty hours.
* ``consensus`` (C5): consensus accuracy over the founders at gen 0 (must be low, else conformity
is a truth proxy and the ``no_grounding`` arm cannot fail by the predicted mechanism).
Specialists are cached under ``models/llm/society_v2/calib_s{seed}/`` and shared across stages.
"""
from __future__ import annotations
from pathlib import Path
import numpy as np
import pandas as pd
from . import families as _families # noqa: F401
from .society_ops import behavioural_distance, consensus_answers, conformity_scores, fitness_of, route_union
from .tasks import Task, make_tasks, verify, _normalise
def _auc(pos: np.ndarray, neg: np.ndarray) -> float:
"""Rank AUC: P(confidence on own-family prompt > confidence on off-family prompt)."""
if len(pos) == 0 or len(neg) == 0:
return float("nan")
return float(np.mean([(p > n) + 0.5 * (p == n) for p in pos for n in neg]))
def _specialists(cfg, fams, root, base):
from .specialise import train_specialist
dirs = []
n_tr, n_ep = int(cfg.get("spec_train", 600)), int(cfg.get("spec_epochs", 3))
r_ = int(cfg.get("lora", {}).get("r", 16))
for i, f in enumerate(fams):
d = root / (f"spec_{f}_n{n_tr}e{n_ep}" + ("" if r_ == 16 else f"_r{r_}")) # keyed by budget + rank
if not (d / "adapter_config.json").exists():
train_specialist(base, f, str(d), n_train=int(cfg.get("spec_train", 600)),
epochs=int(cfg.get("spec_epochs", 3)), seed=int(cfg["seed"]) * 100 + i,
r=int(cfg.get("lora", {}).get("r", 16)),
alpha=int(cfg.get("lora", {}).get("alpha", 32)))
dirs.append(str(d))
return dirs
def _stage_families(cfg, base, fams, root) -> list[dict]:
import torch
from .epistasis import generate_with_confidence
from .evaluate import generate, load_model
from .merge import load_specialists
n_test, n_probe = int(cfg.get("n_test", 100)), int(cfg.get("n_probe", 10))
spec_hi = float(cfg.get("spec_hi", 0.90)) # prereg §4 amendment 1: 1.0 in stage A2
tests = {f: make_tasks(f, n_test, seed=1000 + i) for i, f in enumerate(fams)}
probe = sum([make_tasks(f, n_probe, seed=5000 + i) for i, f in enumerate(fams)], [])
p_prompts = [x.prompt for x in probe]
p_fam = np.array([x.family for x in probe])
rows = []
model, tok = load_model(base) # base alone
base_acc = {f: fitness_of(generate(model, tok, [x.prompt for x in tests[f]]), tests[f], [f])[f]
for f in fams}
del model; torch.cuda.empty_cache()
dirs = _specialists(cfg, fams, root, base)
model, tok = load_specialists(base, dirs)
spec_acc, ans, conf = {}, {}, {}
for i, f in enumerate(fams):
model.set_adapter(f"a{i}")
spec_acc[f] = fitness_of(generate(model, tok, [x.prompt for x in tests[f]]), tests[f], [f])[f]
ans[f], conf[f] = generate_with_confidence(model, tok, p_prompts)
del model; torch.cuda.empty_cache()
for f in fams:
uniq = len({t.prompt for t in make_tasks(f, 600, seed=7)})
auc = _auc(conf[f][p_fam == f], conf[f][p_fam != f])
in_band = 0.05 <= base_acc[f] <= 0.40 and 0.60 <= spec_acc[f] <= spec_hi
for m, v in (("base_acc", base_acc[f]), ("spec_acc", spec_acc[f]), ("conf_auc", auc),
("unique_of_600", uniq), ("in_band", float(in_band))):
rows.append({"stage": "families", "family": f, "other": "", "metric": m, "value": float(v)})
norm = {f: [_normalise(a) for a in ans[f]] for f in fams}
dist = behavioural_distance([ans[f] for f in fams])
for i, fa in enumerate(fams):
for j, fb in enumerate(fams):
if j <= i:
continue
dis = np.array([a != b for a, b in zip(norm[fa], norm[fb])], dtype=float)
epi = float(np.mean(conf[fa] * conf[fb] * dis))
rows.append({"stage": "families", "family": fa, "other": fb, "metric": "epi_conf", "value": epi})
rows.append({"stage": "families", "family": fa, "other": fb, "metric": "dist", "value": float(dist[i, j])})
print(f"\n== C1 family band (base ∈ [0.05,0.40], specialist ∈ [0.60,{spec_hi:.2f}]) ==")
for f in fams:
flag = "OK " if 0.05 <= base_acc[f] <= 0.40 and 0.60 <= spec_acc[f] <= spec_hi else "-- "
print(f" {flag}{f:12s} base {base_acc[f]:.2f} spec {spec_acc[f]:.2f} "
f"confAUC {_auc(conf[f][p_fam == f], conf[f][p_fam != f]):.2f} "
f"unique/600 {len({t.prompt for t in make_tasks(f, 600, seed=7)})}")
epis = [r["value"] for r in rows if r["metric"] == "epi_conf"]
dists = [r["value"] for r in rows if r["metric"] == "dist"]
print(f"== C1b pairwise epi_conf: max {max(epis):.3f} (gate < 0.35) "
f"C1c min pairwise distance {min(dists):.2f} (gate ≥ 0.5)")
return rows
def _stage_transmission(cfg, base, fams, root) -> list[dict]:
import torch
from .evaluate import generate
from .merge import load_specialists
from .specialise import train_lora_on_tasks
probe_f = list(cfg.get("probe_families", fams[:3]))
ks, eps = list(cfg.get("ks", [25, 50, 100, 150])), list(cfg.get("epochs_grid", [2, 3]))
n_test = int(cfg.get("n_test", 100))
dirs = _specialists(cfg, fams, root, base)
rows = []
for f in probe_f:
i = fams.index(f)
test = make_tasks(f, n_test, seed=1000 + i)
model, tok = load_specialists(base, [dirs[i]])
founder_acc = fitness_of(generate(model, tok, [x.prompt for x in test]), test, [f])[f]
pools = {k: sum([make_tasks(g, k, seed=9000 + k * 31 + j) for j, g in enumerate(fams)], [])
for k in ks}
answers = {k: generate(model, tok, [x.prompt for x in pools[k]]) for k in ks}
del model; torch.cuda.empty_cache()
for k in ks:
supplied = fitness_of(answers[k], pools[k], [f])[f]
data = [Task(x.family, x.prompt, a.strip().split("\n")[0][:64]) for x, a in zip(pools[k], answers[k])
if a.strip()]
for ep in eps:
d = root / "transmission" / f"{f}_k{k}_e{ep}"
train_lora_on_tasks(base, data, str(d), epochs=ep, seed=int(cfg["seed"]) * 7 + k + ep)
m, t2 = load_specialists(base, [str(d)])
child_acc = fitness_of(generate(m, t2, [x.prompt for x in test]), test, [f])[f]
del m; torch.cuda.empty_cache()
ret = child_acc / founder_acc if founder_acc > 0 else float("nan")
for name, v in (("founder_acc", founder_acc), ("supplied_acc", supplied),
("child_acc", child_acc), ("retention", ret)):
rows.append({"stage": "transmission", "family": f, "other": f"k{k}_e{ep}",
"metric": name, "value": float(v), "k": k, "epochs": ep})
print(f" {f:12s} k={k:3d} ep={ep}: founder {founder_acc:.2f} supplied {supplied:.2f} "
f"child {child_acc:.2f} retention {ret:.2f}", flush=True)
df = pd.DataFrame(rows)
ret = df[df.metric == "retention"].groupby(["k", "epochs"]).value.mean().unstack()
print("\n== C2 mean retention (gate: choose k* = min k with retention ≥ 0.85) ==")
print(ret.round(2).to_string())
return rows
def _stage_transmission_conf(cfg, base, fams, root) -> list[dict]:
"""C2b — confidence-gated inheritance: retention when the child learns only the prompts its source
is confident on (verifier-free). Also reports the τ that separates own- from off-family confidence
(Youden's J on the founder's own confidences, using family labels for calibration only) and the
off-family harm of ungated inheritance (child off-family accuracy vs base)."""
import torch
from .epistasis import generate_with_confidence
from .evaluate import generate, load_model
from .merge import load_specialists
from .specialise import train_lora_on_tasks
probe_f = list(cfg.get("probe_families", fams[:3]))
k, ep = int(cfg.get("k_pool", 300)), int(cfg.get("epochs", 3))
taus = list(cfg.get("taus", [0.5, 0.7, 0.85]))
n_test = int(cfg.get("n_test", 100))
dirs = _specialists(cfg, fams, root, base)
tests = {f: make_tasks(f, n_test, seed=1000 + i) for i, f in enumerate(fams)}
pool = sum([make_tasks(g, k, seed=9300 + j) for j, g in enumerate(fams)], [])
p_fam = np.array([x.family for x in pool])
model, tok = load_model(base)
base_acc = {f: fitness_of(generate(model, tok, [x.prompt for x in tests[f]]), tests[f], [f])[f] for f in fams}
del model; torch.cuda.empty_cache()
rows = []
for f in probe_f:
i = fams.index(f)
model, tok = load_specialists(base, [dirs[i]])
founder_acc = fitness_of(generate(model, tok, [x.prompt for x in tests[f]]), tests[f], [f])[f]
ans, conf = generate_with_confidence(model, tok, [x.prompt for x in pool])
del model; torch.cuda.empty_cache()
own, off = conf[p_fam == f], conf[p_fam != f]
# Youden-optimal τ over a grid — calibration only (uses family labels)
grid = np.linspace(0.05, 0.99, 95)
J = [np.mean(own >= t) - np.mean(off >= t) for t in grid]
tau_star = float(grid[int(np.argmax(J))])
rows.append({"stage": "transmission_conf", "family": f, "other": "", "metric": "tau_youden", "value": tau_star})
rows.append({"stage": "transmission_conf", "family": f, "other": "", "metric": "own_conf_median", "value": float(np.median(own))})
rows.append({"stage": "transmission_conf", "family": f, "other": "", "metric": "off_conf_median", "value": float(np.median(off))})
print(f" {f:12s} founder {founder_acc:.2f} own-conf median {np.median(own):.2f} off-conf median "
f"{np.median(off):.2f} τ* {tau_star:.2f} (keeps {np.mean(own >= tau_star):.0%} own, "
f"{np.mean(off >= tau_star):.0%} off)", flush=True)
for tau in [None] + taus:
keep = np.ones(len(pool), bool) if tau is None else conf >= tau
data = [Task(x.family, x.prompt, a.strip().split("\n")[0][:64])
for x, a, kp in zip(pool, ans, keep) if kp and a.strip()]
if len(data) < 8:
print(f" {f:12s} τ={tau}: only {len(data)} prompts kept — skipped"); continue
d = root / "transmission_conf" / f"{f}_tau{tau}"
train_lora_on_tasks(base, data, str(d), epochs=ep, seed=int(cfg["seed"]) * 13 + int((tau or 0) * 100))
m, t2 = load_specialists(base, [str(d)])
child_own = fitness_of(generate(m, t2, [x.prompt for x in tests[f]]), tests[f], [f])[f]
off_f = [g for g in fams if g != f][:3] # off-family harm on three other families
child_off = float(np.mean([fitness_of(generate(m, t2, [x.prompt for x in tests[g]]), tests[g], [g])[g] for g in off_f]))
base_off = float(np.mean([base_acc[g] for g in off_f]))
del m; torch.cuda.empty_cache()
ret = child_own / founder_acc if founder_acc > 0 else float("nan")
lab = "none" if tau is None else f"{tau:.2f}"
for name, v in (("retention", ret), ("child_own_acc", child_own), ("n_kept", len(data)),
("child_off_acc", child_off), ("base_off_acc", base_off)):
rows.append({"stage": "transmission_conf", "family": f, "other": f"tau{lab}", "metric": name, "value": float(v)})
print(f" {f:12s} τ={lab}: kept {len(data):4d}/{len(pool)} child own {child_own:.2f} "
f"retention {ret:.2f} off-family child {child_off:.2f} vs base {base_off:.2f}", flush=True)
df = pd.DataFrame(rows)
r = df[df.metric == "retention"].groupby("other").value.mean()
print("\n== C2b mean retention by gate (target ≥ 0.85) ==\n" + r.round(2).to_string())
return rows
def _stage_cross(cfg, base, fams, root) -> list[dict]:
import torch
from .directed import sample_merge_weights
from .epistasis import generate_with_confidence
from .evaluate import generate
from .merge import load_specialists
from .specialise import train_lora_on_tasks
fa, fb = cfg["cross"]
k, ep = int(cfg.get("k_inherit", 100)), int(cfg.get("epochs", 3))
n_cand = int(cfg.get("n_candidates", 6))
ia, ib = fams.index(fa), fams.index(fb)
tests = {f: make_tasks(f, int(cfg.get("n_test", 100)), seed=1000 + fams.index(f)) for f in (fa, fb)}
val = sum([make_tasks(g, 10, seed=3000 + j) for j, g in enumerate(fams)], [])
inherit = sum([make_tasks(g, k, seed=9100 + j) for j, g in enumerate(fams)], [])
inh_p = [x.prompt for x in inherit]
dirs = _specialists(cfg, fams, root, base)
rows = []
model, tok = load_specialists(base, [dirs[ia], dirs[ib]])
parent_acc = {}
for j, f in enumerate((fa, fb)):
model.set_adapter(f"a{j}")
for g in (fa, fb):
parent_acc[(f, g)] = fitness_of(generate(model, tok, [x.prompt for x in tests[g]]), tests[g], [g])[g]
model.set_adapter("a0"); ans_a, cf_a = generate_with_confidence(model, tok, inh_p)
model.set_adapter("a1"); ans_b, cf_b = generate_with_confidence(model, tok, inh_p)
union_ans, src = route_union(ans_a, cf_a, ans_b, cf_b)
rng = np.random.default_rng(int(cfg["seed"]))
w = sample_merge_weights(2, n_cand, rng)
best_i, best_v = 0, -np.inf
for ci in range(n_cand):
model.add_weighted_adapter(["a0", "a1"], w[ci].tolist(), f"c{ci}", combination_type="linear")
model.set_adapter(f"c{ci}")
v = fitness_of(generate(model, tok, [x.prompt for x in val]), val, fams)["overall"]
if v > best_v:
best_i, best_v = ci, v
model.set_adapter("a0"); model.delete_adapter(f"c{ci}")
model.add_weighted_adapter(["a0", "a1"], w[best_i].tolist(), "win", combination_type="linear")
model.set_adapter("win"); linear_ans, cf_lin = generate_with_confidence(model, tok, inh_p)
del model; torch.cuda.empty_cache()
# optional confidence gate (C2b amendment): each child keeps only prompts its own source is
# confident on — the union by max(parent confidences), the linear merge by its own confidence.
gate = cfg.get("conf_gate")
gate = None if gate is None else float(gate)
keep_u = np.ones(len(inherit), bool) if gate is None else np.maximum(cf_a, cf_b) >= gate
keep_l = np.ones(len(inherit), bool) if gate is None else cf_lin >= gate
out = {}
for op, answers, keep in (("union", union_ans, keep_u), ("linear", linear_ans, keep_l)):
supplied = fitness_of(answers, inherit, [fa, fb])
data = [Task(x.family, x.prompt, a.strip().split("\n")[0][:64])
for x, a, kp in zip(inherit, answers, keep) if kp and a.strip()]
rows.append({"stage": "cross", "family": "", "other": op, "metric": "n_kept", "value": float(len(data))})
r_, a_ = int(cfg.get("lora", {}).get("r", 16)), int(cfg.get("lora", {}).get("alpha", 32))
d = root / "cross" / f"{fa}_{fb}_{op}_g{gate}_e{ep}_r{r_}"
train_lora_on_tasks(base, data, str(d), epochs=ep, seed=int(cfg["seed"]) * 11, r=r_, alpha=a_)
m, t2 = load_specialists(base, [str(d)])
for g in (fa, fb):
acc = fitness_of(generate(m, t2, [x.prompt for x in tests[g]]), tests[g], [g])[g]
out[(op, g)] = acc
rows.append({"stage": "cross", "family": g, "other": op, "metric": "child_acc", "value": float(acc)})
rows.append({"stage": "cross", "family": g, "other": op, "metric": "supplied_acc", "value": float(supplied[g])})
del m; torch.cuda.empty_cache()
for f in (fa, fb):
rows.append({"stage": "cross", "family": f, "other": "parent", "metric": "parent_own_acc",
"value": float(parent_acc[(f, f)])})
rows.append({"stage": "cross", "family": fa, "other": fb, "metric": "union_share_b", "value": float(src.mean())})
print(f"\n== C3 cross {fa} × {fb} (gate: union ≥ 0.85×parent on each; union ≥ linear on the min) ==")
for g in (fa, fb):
print(f" {g:12s} parent {parent_acc[(g, g)]:.2f} union child {out[('union', g)]:.2f} "
f"linear child {out[('linear', g)]:.2f}")
print(f" union routed {src.mean():.2f} of prompts to {fb}")
return rows
def _stage_consensus(cfg, base, fams, root) -> list[dict]:
import torch
from .evaluate import generate
from .merge import load_specialists
probe = sum([make_tasks(f, int(cfg.get("n_probe", 10)), seed=5000 + i) for i, f in enumerate(fams)], [])
dirs = _specialists(cfg, fams, root, base)
model, tok = load_specialists(base, dirs)
outs = []
for i in range(len(fams)):
model.set_adapter(f"a{i}"); outs.append(generate(model, tok, [x.prompt for x in probe]))
del model; torch.cuda.empty_cache()
cons = consensus_answers(outs)
cons_acc = float(np.mean([verify(c, x) for c, x in zip(cons, probe)]))
conf = conformity_scores(outs, cons)
dist = behavioural_distance(outs)
fit = [fitness_of(o, probe, fams)["overall"] for o in outs]
rows = [{"stage": "consensus", "family": "", "other": "", "metric": "consensus_acc", "value": cons_acc},
{"stage": "consensus", "family": "", "other": "", "metric": "min_pair_dist",
"value": float(dist[np.triu_indices(len(fams), 1)].min())}]
for i, f in enumerate(fams):
rows.append({"stage": "consensus", "family": f, "other": "", "metric": "conformity", "value": float(conf[i])})
rows.append({"stage": "consensus", "family": f, "other": "", "metric": "probe_acc", "value": float(fit[i])})
print(f"\n== C5 consensus accuracy at gen 0: {cons_acc:.2f} (gate < 0.35) "
f"corr(conformity, accuracy) = {np.corrcoef(conf, fit)[0, 1]:+.2f} "
f"min pairwise distance {rows[1]['value']:.2f}")
return rows
def run_calibration(cfg: dict) -> pd.DataFrame:
"""Dispatch on ``stage`` and return the stage's rows as a DataFrame."""
base = cfg["base_model"]
fams = list(cfg["families"])
root = Path(cfg.get("adapters_dir", "models/llm")) / "society_v2" / f"calib_s{int(cfg['seed'])}"
stage = cfg["stage"]
fn = {"families": _stage_families, "transmission": _stage_transmission,
"transmission_conf": _stage_transmission_conf,
"cross": _stage_cross, "consensus": _stage_consensus}[stage]
rows = fn(cfg, base, fams, root)
df = pd.DataFrame(rows)
df["experiment"] = cfg["experiment"]
return df

View file

@ -1,348 +0,0 @@
"""`llm_compose` — does a composed capability survive inheritance? (prereg v3)
Two single-skill LoRA lineages on a shared frozen base **math** and **code**. Each generation both
lineages reproduce by self-consumption (a fresh LoRA distilled from their own confidence-gated answers
on fresh prompts, optionally mixed with a fraction ``g`` of verified real examples E2's immigration
in the training mix). Each generation the *current* two parents are merged and evaluated on the
held-out composed target, GSM8k-Hard, program-aided with an execution verifier.
The composed model is a **measurement, not a lineage**: it is re-formed each generation from whatever
the parents currently are, which separates "does composition survive parental drift?" from "does the
composed model itself drift?". The ``composed`` arm makes it a lineage as well.
Measured every generation (§1.6): ``q_math`` (GSM8K), ``q_code`` (MBPP, execution-verified),
``rho`` (behavioural agreement on a shared probe + LoRA-delta cosine), composed accuracy for the
merge *and each parent alone*, hence the **surplus** (merge best parent) and the
**union-exceedance** (composed-solved items neither parent solves the super-linear signature).
"""
from __future__ import annotations
import json
import shutil
from pathlib import Path
import numpy as np
import pandas as pd
from .compose_data import (ProgTask, code_train, composed_target, gsm8k_probe, gsm_hard,
math_train, mbpp_probe, probe_pool)
from .execute import extract_code, numeric_match, run_solution, verify_program
from .tasks import Task, _normalise
LINEAGES = ("math", "code")
# ------------------------------------------------------------------ scoring helpers (pure-ish)
def score_composed(completions: list[str], tasks: list[ProgTask]) -> tuple[float, np.ndarray, float]:
"""Program-aided accuracy over the composed target.
Returns:
(accuracy, per-item correctness, executable-rate) the last is the fraction whose code ran
at all, which is the code sub-skill isolated from the maths.
"""
ok, ran = [], []
for c, t in zip(completions, tasks):
good, res = verify_program(c, t.target)
ok.append(bool(good)); ran.append(bool(res.ok))
return float(np.mean(ok)), np.array(ok, dtype=bool), float(np.mean(ran))
_NUM = __import__("re").compile(r"-?\d[\d,]*\.?\d*")
def _last_number(text: str) -> float | None:
"""The last number in a completion, commas stripped — the conventional GSM8K readout."""
m = _NUM.findall(text)
for tok in reversed(m):
try:
return float(tok.replace(",", "").rstrip("."))
except ValueError:
continue
return None
def score_gsm8k(completions: list[str], tasks: list[Task]) -> float:
"""Math own-skill: the number after '####' if the model used it, else the last number.
Graded numerically rather than by string equality, so "18" and "18.00" both count and a model
that reasons past its answer is not punished for formatting.
"""
hit = []
for c, t in zip(completions, tasks):
tail = c.split("####")[-1] if "####" in c else c
got, want = _last_number(tail), _last_number(t.answer)
hit.append(got is not None and want is not None and numeric_match(got, want))
return float(np.mean(hit))
def score_mbpp(completions: list[str], items: list[dict], timeout_s: float = 6.0) -> float:
"""Code own-skill: the emitted function must pass MBPP's reference asserts."""
hit = []
for c, it in zip(completions, items):
prog = "\n".join(list(it["imports"]) + [extract_code(c)] + list(it["tests"])
+ ["def solution():\n return 1"])
hit.append(run_solution(prog, timeout_s=timeout_s).ok)
return float(np.mean(hit))
def union_exceedance(merged_ok: np.ndarray, parent_ok: list[np.ndarray]) -> float:
"""Fraction of all items that the merge solves and *no* parent solves (super-linear signature)."""
any_parent = np.zeros_like(merged_ok)
for p in parent_ok:
any_parent |= p
return float(np.mean(merged_ok & ~any_parent))
def mean_logprob(model, tok, prompts: list[str], completions: list[str], *,
batch_size: int = 8, device: str = "cuda") -> np.ndarray:
"""Mean token log-probability of each completion under the model, teacher-forced.
``generate(..., output_scores=True)`` keeps one (batch × vocab) tensor per step, which at 320 new
tokens is gigabytes; one extra forward pass over prompt+completion costs a fraction of that and
gives the same quantity. Returns exp(mean logprob) (0, 1] the same self-certainty scale the
epistasis module uses.
"""
import torch
out = np.zeros(len(prompts))
for i in range(0, len(prompts), batch_size):
p_chunk, c_chunk = prompts[i:i + batch_size], completions[i:i + batch_size]
ids, labels = [], []
from .evaluate import format_prompt
for p, c in zip(p_chunk, c_chunk):
pi = tok(format_prompt(tok, p), add_special_tokens=False).input_ids
ci = tok(c if c.strip() else " ", add_special_tokens=False).input_ids[:512]
ids.append(pi + ci); labels.append([-100] * len(pi) + ci)
L = max(len(x) for x in ids)
pad = tok.pad_token_id
inp = torch.full((len(ids), L), pad, dtype=torch.long)
lab = torch.full((len(ids), L), -100, dtype=torch.long)
att = torch.zeros((len(ids), L), dtype=torch.long)
for b, (x, y) in enumerate(zip(ids, labels)): # right-pad (scoring, not generation)
inp[b, :len(x)] = torch.tensor(x); lab[b, :len(y)] = torch.tensor(y)
att[b, :len(x)] = 1
with torch.no_grad():
logits = model(input_ids=inp.to(device), attention_mask=att.to(device)).logits[:, :-1]
tgt = lab[:, 1:].to(device)
mask = tgt != -100
# cross_entropy streams the log-partition internally: no float32 copy of the logits and
# no second tensor for log_softmax, which together were ~8 GB at batch 8 x 800 x 152k.
nll = torch.nn.functional.cross_entropy(
logits.reshape(-1, logits.size(-1)).float(),
tgt.reshape(-1).clamp(min=0), reduction="none").view(tgt.shape)
n = mask.sum(-1).clamp(min=1)
out[i:i + len(p_chunk)] = torch.exp(-(nll * mask).sum(-1) / n).cpu().numpy()
del logits, nll
return out
def predicted_composition(q_math: np.ndarray, q_code: np.ndarray, rho: np.ndarray,
observed0: float) -> np.ndarray:
"""The framework's forecast Ĉ_t (prereg §2), one free scale fixed at generation 0.
Ĉ_t = c0 · q_math_t · q_code_t · (1 rho_t)/(1 rho_0), c0 chosen so Ĉ_0 = observed_0.
"""
q_math, q_code, rho = map(np.asarray, (q_math, q_code, rho))
shape = q_math * q_code * (1.0 - rho) / max(1e-9, 1.0 - rho[0])
c0 = observed0 / shape[0] if shape[0] > 1e-9 else 0.0
return c0 * shape
# ------------------------------------------------------------------ the GPU loop
def _state_path(root: Path, arm: str) -> Path:
return root / arm / "state.json"
def run_compose(cfg: dict) -> pd.DataFrame:
"""Run every configured arm; return tidy long-form rows (resumable per arm)."""
import torch
from .epistasis import delta_geometry
from .evaluate import generate
from .merge import load_specialists
from .specialise import train_lora_on_tasks
name, base, seed = cfg["experiment"], cfg["base_model"], int(cfg["seed"])
G = int(cfg.get("generations", 6))
arms = list(cfg.get("arms", ["dry", "grounded", "dry_linear"]))
n_hard = int(cfg.get("n_hard", 200))
n_hard_val = int(cfg.get("n_hard_val", 0)) # >0 enables directed merge-weight selection
n_gsm = int(cfg.get("n_gsm8k", 150))
n_mbpp = int(cfg.get("n_mbpp", 100))
n_probe = int(cfg.get("n_probe", 60))
k_inh = int(cfg.get("k_inherit", 300))
epochs = int(cfg.get("epochs", 3))
conf_gate = cfg.get("conf_gate")
conf_gate = None if conf_gate is None else float(conf_gate)
spec_train = int(cfg.get("spec_train", 1200))
spec_epochs = int(cfg.get("spec_epochs", 3))
max_new = int(cfg.get("max_new_tokens", 320))
tb = int(cfg.get("train_batch_size", 2)) # the causal-LM loss upcasts logits to fp32:
tlen = int(cfg.get("train_max_len", 448)) # memory ~ batch * len * vocab(152k) * 4 B
r, alpha = (int(cfg.get("lora", {}).get(k, v)) for k, v in (("r", 16), ("alpha", 32)))
root = Path(cfg.get("adapters_dir", "models/llm")) / "compose" / f"{name}_s{seed}"
out_dir = Path(cfg.get("output", {}).get("dir", f"results/{name}"))
out_dir.mkdir(parents=True, exist_ok=True)
# fixed evaluation sets (identical across arms, generations and seeds-within-a-config)
target_kind = str(cfg.get("target", "gsm_hard"))
hard = composed_target(target_kind, n_hard, 1000)
hard_prompts = [t.prompt for t in hard]
# Directed recombination (E10): candidate merge weights are screened on a DISJOINT validation
# split and only the winner is reported on the test split, so nothing is selected on the numbers
# we report. n_hard_val = 0 keeps the fixed 0.5/0.5 blend of the original design.
hard_val = composed_target(target_kind, n_hard_val, 5000, split="val") if n_hard_val else []
val_prompts = [t.prompt for t in hard_val]
weight_grid = [list(w) for w in cfg.get("merge_weights", [[0.5, 0.5]])]
gsm = gsm8k_probe(n_gsm, 2000)
mbpp = mbpp_probe(n_mbpp, 3000)
probes = probe_pool(n_probe, 4000)
def gen(model, tok, prompts, **kw):
return generate(model, tok, prompts, max_new_tokens=max_new,
batch_size=int(cfg.get("batch_size", 16)), **kw)
# ---- founders: one specialist per lineage, cached and shared across arms
founders = {}
for lin in LINEAGES:
d = root / "founders" / lin
if not (d / "adapter_config.json").exists():
data = (math_train(spec_train, seed * 100 + 1, with_answers=True) if lin == "math"
else code_train(spec_train, seed * 100 + 2, with_answers=True))
train_lora_on_tasks(base, data, str(d), epochs=spec_epochs, seed=seed * 100 + 7,
r=r, alpha=alpha, batch_size=tb, max_len=tlen)
founders[lin] = str(d)
frames = []
for a_idx, arm in enumerate(arms):
# Operator per arm, explicit: the gen-0 sweep showed the blend ratio dominates the operator,
# so which operator an arm uses is a stated choice, not an inference from its name.
op = str(cfg.get("arm_ops", {}).get(arm, "linear" if arm.endswith("_linear") else "cat"))
g_frac = float(cfg.get("g", 0.10)) if arm.startswith("grounded") else 0.0
rows: list[dict] = []
parents, t0 = dict(founders), 0
st_path = _state_path(root, arm)
partial = out_dir / f"partial_{arm}_s{seed}.parquet"
if bool(cfg.get("resume", True)) and st_path.exists():
st = json.loads(st_path.read_text())
if all(Path(p, "adapter_config.json").exists() for p in st["parents"].values()):
parents, t0 = dict(st["parents"]), int(st["generation"])
if partial.exists():
rows = pd.read_parquet(partial).to_dict("records")
print(f"[{arm}] resuming at generation {t0}", flush=True)
for t in range(t0, G + 1):
rng = np.random.default_rng([seed, a_idx, t])
tag = {"experiment": name, "arm": arm, "seed": seed, "generation": t, "operator": op}
model, tok = load_specialists(base, [parents["math"], parents["code"]])
# ---- own-skill retention q_t, and each parent alone on the composed target
model.set_adapter("a0")
q_math = score_gsm8k(gen(model, tok, [x.prompt for x in gsm]), gsm)
math_comp = gen(model, tok, hard_prompts)
_, math_ok, math_exec = score_composed(math_comp, hard)
probe_math = gen(model, tok, probes)
model.set_adapter("a1")
q_code = score_mbpp(gen(model, tok, [x["prompt"] for x in mbpp]), mbpp)
code_comp = gen(model, tok, hard_prompts)
_, code_ok, code_exec = score_composed(code_comp, hard)
probe_code = gen(model, tok, probes)
rho_behav = float(np.mean([_normalise(a) == _normalise(b)
for a, b in zip(probe_math, probe_code)]))
rho_geom = float(delta_geometry(parents["math"], parents["code"])["delta_cos"])
# ---- merge and measure the composition (optionally selecting the weights on val)
chosen = weight_grid[0]
if hard_val and len(weight_grid) > 1:
best_v = -np.inf
for wi, w in enumerate(weight_grid):
cn = f"cand{wi}"
model.add_weighted_adapter(["a0", "a1"], w, cn, combination_type=op)
model.set_adapter(cn)
v, _, _ = score_composed(gen(model, tok, val_prompts), hard_val)
if v > best_v:
best_v, chosen = v, w
model.set_adapter("a0"); model.delete_adapter(cn)
rows.append({**tag, "metric": "chosen_weight_math", "value": float(chosen[0])})
model.add_weighted_adapter(["a0", "a1"], chosen, "merged", combination_type=op)
model.set_adapter("merged")
merged_comp = gen(model, tok, hard_prompts)
comp_acc, merged_ok, merged_exec = score_composed(merged_comp, hard)
model.set_adapter("a0"); model.delete_adapter("merged")
best_parent = max(float(math_ok.mean()), float(code_ok.mean()))
surplus = comp_acc - best_parent
uex = union_exceedance(merged_ok, [math_ok, code_ok])
for metric, value in (
("q_math", q_math), ("q_code", q_code),
("rho_behav", rho_behav), ("rho_geom", rho_geom),
("composed_acc", comp_acc), ("composed_exec", merged_exec),
("parent_math_composed", float(math_ok.mean())),
("parent_code_composed", float(code_ok.mean())),
("parent_math_exec", math_exec), ("parent_code_exec", code_exec),
("best_parent_composed", best_parent), ("surplus", surplus),
("union_exceedance", uex),
):
rows.append({**tag, "metric": metric, "value": float(value)})
print(f"[{arm}] gen {t}/{G}: composed {comp_acc:.3f} (best parent {best_parent:.3f}, "
f"surplus {surplus:+.3f}, uex {uex:.3f}) q_math {q_math:.2f} q_code {q_code:.2f} "
f"rho {rho_behav:.2f}", flush=True)
if t == G:
del model; torch.cuda.empty_cache()
break
# ---- reproduce: each lineage distils from its own gated answers (+ g real examples)
new_parents = {}
for li, lin in enumerate(LINEAGES):
pool = (math_train(k_inh, seed * 9001 + t * 31 + li, with_answers=False)
if lin == "math" else
code_train(k_inh, seed * 9001 + t * 31 + li, with_answers=False))
model.set_adapter(f"a{li}")
pool_prompts = [x.prompt for x in pool]
answers = gen(model, tok, pool_prompts)
if conf_gate is None:
keep = np.ones(len(pool), bool)
else:
conf = mean_logprob(model, tok, pool_prompts, answers,
batch_size=int(cfg.get("score_batch_size", 8)))
keep = conf >= conf_gate
data = [Task(lin, x.prompt, a.strip()[:512])
for x, a, kp in zip(pool, answers, keep) if kp and a.strip()]
n_self = len(data)
if g_frac > 0: # immigration: verified real examples, fresh each gen
n_real = max(1, int(round(g_frac / (1 - g_frac) * n_self)))
real = (math_train(n_real, seed * 7717 + t * 13 + li, with_answers=True)
if lin == "math" else
code_train(n_real, seed * 7717 + t * 13 + li, with_answers=True))
data += [x for x in real if x.answer.strip()]
rows.append({**tag, "metric": f"n_inherit_{lin}", "value": float(n_self)})
rows.append({**tag, "metric": f"n_real_{lin}", "value": float(len(data) - n_self)})
d = root / arm / f"gen{t + 1}" / lin
if len(data) >= 8:
train_lora_on_tasks(base, data, str(d), epochs=epochs,
seed=seed * 31 + t * 7 + li, r=r, alpha=alpha,
batch_size=tb, max_len=tlen)
else: # terminal degeneration: inherit unchanged
shutil.copytree(parents[lin], d, dirs_exist_ok=True)
rows.append({**tag, "metric": f"degenerate_{lin}", "value": 1.0})
new_parents[lin] = str(d)
del model; torch.cuda.empty_cache()
for lin in LINEAGES: # disk hygiene: drop the superseded generation
if parents[lin] != founders[lin]:
shutil.rmtree(parents[lin], ignore_errors=True)
parents = new_parents
st_path.parent.mkdir(parents=True, exist_ok=True)
st_path.write_text(json.dumps({"generation": t + 1, "parents": parents}))
pd.DataFrame(rows).to_parquet(partial, index=False)
frames.append(pd.DataFrame(rows))
return pd.concat(frames, ignore_index=True)

View file

@ -1,170 +0,0 @@
"""Datasets and prompts for the composition experiment (prereg v3 §1.1).
Two single-skill training corpora and three evaluation sets, all pinned to fixed subsets by seed so
every generation sees *fresh* prompts from a *fixed* pool and no split ever leaks into another:
* **math** lineage trains on MetaMathQA (natural-language chain-of-thought); own-skill probe is
GSM8K test (numeric answer).
* **code** lineage trains on CodeAlpaca-20k (instruction code); own-skill probe is MBPP
(sanitized) with execution against the reference tests.
* **composed** target GSM8k-Hard, program-aided: emit ``solution()``, execute, compare numerically.
Out-of-domain for both lineages, which is the point (LoRA Soups, COLING 2025).
The composed prompt is fixed here rather than tuned per model, so the operator contrast is never
confounded with prompt search.
"""
from __future__ import annotations
from dataclasses import dataclass
from functools import lru_cache
import numpy as np
from .tasks import Task
COMPOSED_PROMPT = (
"{question}\n\n"
"Write a Python function `solution()` that takes no arguments and returns the numeric answer. "
"Reply with only the code, inside a ```python code block."
)
MATH_PROMPT = "{question}\n\nSolve this step by step, then give the final numeric answer after '####'."
CODE_PROMPT = "{instruction}\n\nReply with only the Python code, inside a ```python code block."
@dataclass(frozen=True)
class ProgTask:
"""A program-aided task: prompt, numeric target, and its source id."""
prompt: str
target: float
idx: int
@lru_cache(maxsize=8)
def _load(name: str, split: str, config: str | None = None):
from datasets import load_dataset
return load_dataset(name, config, split=split) if config else load_dataset(name, split=split)
def _pick(n_total: int, n: int, seed: int) -> np.ndarray:
return np.random.default_rng(seed).choice(n_total, size=min(n, n_total), replace=False)
def _split_pool(n_total: int, split: str) -> np.ndarray:
"""Deterministic disjoint halves of an index pool.
The composed target's val split screens merge weights and the test split reports them; with a
pool as small as MATH-500's 271 usable items, sampling both by seed alone would overlap and leak
selection into the reported number. Splitting first makes the disjointness structural.
"""
idx = np.random.default_rng(20260907).permutation(n_total)
cut = int(0.7 * n_total)
return idx[:cut] if split == "test" else idx[cut:]
def gsm_hard(n: int, seed: int, split: str = "test") -> list[ProgTask]:
"""The composed target: GSM8k-Hard, program-aided. ``split`` picks a disjoint val/test half."""
d = _load("reasoning-machines/gsm-hard", "train")
pool = _split_pool(len(d), split)
return [ProgTask(COMPOSED_PROMPT.format(question=d[int(pool[j])]["input"]),
float(d[int(pool[j])]["target"]), int(pool[j]))
for j in _pick(len(pool), n, seed)]
_FRAC = __import__("re").compile(r"-?\\d?frac\{(-?\d+)\}\{(-?\d+)\}")
def _numeric_answer(a: str) -> float | None:
"""Parse a MATH-500 answer to a float, or None if it is not a plain number/simple fraction."""
import re
a = (a.strip().replace("\\!", "").replace("{,}", "").replace(",", "")
.replace("\\%", "").replace("$", "").replace("\\dfrac", "\\frac"))
if re.fullmatch(r"-?\d+(\.\d+)?", a):
return float(a)
f = re.fullmatch(r"-?\\frac\{(-?\d+)\}\{(-?\d+)\}", a)
if f and int(f.group(2)) != 0:
v = int(f.group(1)) / int(f.group(2))
return -v if a.startswith("-") else v
return None
def math500(n: int, seed: int, split: str = "test", min_level: int = 3) -> list[ProgTask]:
"""Harder composed target: MATH-500 (competition maths), program-aided, numeric answers only.
The alternative target for when the base can already do the *reasoning* in GSM8k-Hard once code
removes the arithmetic burden (measured 2026-09-07: code-only 0.427 there). MetaMathQA is built
from GSM8K **and** MATH, so the math specialist is trained on exactly this reasoning while the
base is weak at it which restores E8's premise that each parent supplies something scarce.
``min_level`` filters MATH's 15 difficulty scale.
"""
d = _load("HuggingFaceH4/MATH-500", "test")
pool = [(i, _numeric_answer(d[i]["answer"])) for i in range(len(d))]
pool = [(i, v) for i, v in pool if v is not None and int(d[i]["level"]) >= min_level]
half = _split_pool(len(pool), split)
idx = [int(half[j]) for j in _pick(len(half), n, seed)]
return [ProgTask(COMPOSED_PROMPT.format(question=d[pool[j][0]]["problem"]),
float(pool[j][1]), int(pool[j][0])) for j in idx]
def composed_target(kind: str, n: int, seed: int, split: str = "test", **kw) -> list[ProgTask]:
"""Dispatch the composed target by name: ``gsm_hard`` (default) or ``math500``."""
return {"gsm_hard": gsm_hard, "math500": math500}[kind](n, seed, split=split, **kw)
def gsm8k_probe(n: int, seed: int) -> list[Task]:
"""Math own-skill probe: GSM8K test, answer after '####'."""
d = _load("openai/gsm8k", "test", "main")
out = []
for i in _pick(len(d), n, seed):
r = d[int(i)]
out.append(Task("math", MATH_PROMPT.format(question=r["question"]),
r["answer"].split("####")[-1].strip().replace(",", "")))
return out
def mbpp_probe(n: int, seed: int) -> list[dict]:
"""Code own-skill probe: MBPP sanitized, with the reference asserts as the verifier."""
d = _load("google-research-datasets/mbpp", "test", "sanitized")
out = []
for i in _pick(len(d), n, seed):
r = d[int(i)]
out.append({"prompt": CODE_PROMPT.format(instruction=r["prompt"]),
"tests": list(r["test_list"]), "imports": list(r.get("test_imports") or []),
"idx": int(i)})
return out
def math_train(n: int, seed: int, *, with_answers: bool) -> list[Task]:
"""MetaMathQA examples. ``with_answers=False`` returns prompts only (self-consumption pool)."""
d = _load("meta-math/MetaMathQA", "train")
out = []
for i in _pick(len(d), n, seed):
r = d[int(i)]
# Reason: MetaMathQA responses end "The answer is: X"; rewrite to the '####' convention the
# probe grades on, so training format and evaluation format agree.
body = r["response"].strip()
ans = body.split("The answer is:")[-1].strip()
target = (body[:480] + f"\n#### {ans}") if with_answers else ""
out.append(Task("math", MATH_PROMPT.format(question=r["query"]), target))
return out
def code_train(n: int, seed: int, *, with_answers: bool) -> list[Task]:
"""CodeAlpaca examples (input-free subset, so the prompt is self-contained)."""
d = _load("sahil2801/CodeAlpaca-20k", "train")
idx = [int(i) for i in _pick(len(d), n * 3, seed) if not d[int(i)]["input"].strip()][:n]
return [Task("code", CODE_PROMPT.format(instruction=d[i]["instruction"]),
d[i]["output"].strip()[:512] if with_answers else "") for i in idx]
def probe_pool(n: int, seed: int) -> list[str]:
"""Shared prompts both lineages answer, for the behavioural correlation rho_t (§1.6).
Half composed-task prompts, half a mix of each lineage's own domain — so rho reflects agreement
on ground both lineages actually walk on, not on prompts only one has ever seen.
"""
half = n // 2
return ([t.prompt for t in gsm_hard(half, seed + 71)]
+ [t.prompt for t in gsm8k_probe(half // 2, seed + 72)]
+ [t.prompt for t in code_train(n - half - half // 2, seed + 73, with_answers=False)])

View file

@ -268,36 +268,18 @@ def run_epistasis_dispatch(cfg: dict) -> pd.DataFrame:
return run_epistasis_experiment(cfg)
def run_society_dispatch(cfg: dict) -> pd.DataFrame:
from .society import run_society_experiment # local import: torch-heavy
return run_society_experiment(cfg)
def run_society_v2_dispatch(cfg: dict) -> pd.DataFrame:
from .society_v2 import run_society_v2 # local import: torch-heavy
return run_society_v2(cfg)
def run_compose_dispatch(cfg: dict) -> pd.DataFrame:
from .compose import run_compose # local import: torch-heavy
return run_compose(cfg)
def run_curriculum_dispatch(cfg: dict) -> pd.DataFrame:
from .curriculum import run_curriculum # local import: torch-heavy
return run_curriculum(cfg)
def run_calib_dispatch(cfg: dict) -> pd.DataFrame:
from .calibrate import run_calibration # local import: torch-heavy
return run_calibration(cfg)
_RUNNERS = {"llm_merge": run_merge_experiment, "llm_moe": run_moe_experiment,
"llm_directed": run_directed_experiment, "llm_speciation": run_speciation_dispatch,
"llm_epistasis": run_epistasis_dispatch, "llm_society": run_society_dispatch,
"llm_society_v2": run_society_v2_dispatch, "llm_society_calib": run_calib_dispatch,
"llm_compose": run_compose_dispatch,
"llm_epistasis": run_epistasis_dispatch,
"llm_curriculum": run_curriculum_dispatch}
@ -332,24 +314,11 @@ def run_and_save(config_path: str | Path) -> Path:
extra["seeds"] = [int(s) for s in seeds]
if kind == "llm_moe":
extra["operators"] = list(cfg.get("operators", []))
if kind == "llm_society":
extra["society"] = {k: cfg.get(k) for k in
("agents", "generations", "arms", "g", "lam", "n_candidates")}
if kind == "llm_society_v2":
extra["society_v2"] = {k: cfg.get(k) for k in
("families", "agents", "generations", "arms", "g", "lam",
"k_inherit", "epochs", "n_test", "n_val", "n_conf")}
if kind == "llm_compose":
extra["compose"] = {k: cfg.get(k) for k in
("arms", "generations", "g", "k_inherit", "conf_gate", "epochs",
"n_hard", "n_gsm8k", "n_mbpp", "lora")}
if kind == "llm_curriculum":
extra["curriculum"] = {k: cfg.get(k) for k in
("families", "lineages", "generations", "arms", "baselines",
"n_new", "n_replay", "operator", "ancestor_depth", "lora",
"allow_veto", "merge_until", "orders", "cull")}
if kind == "llm_society_calib":
extra["calibration"] = {"stage": cfg.get("stage"), "families": cfg.get("families")}
if kind == "llm_directed":
extra["directed"] = {"n_candidates": int(cfg.get("n_candidates", 16)),
"concentration": float(cfg.get("concentration", 0.5)),

View file

@ -1,232 +0,0 @@
"""The composed society at LLM scale (C3) — E11 re-instantiated in a population of LoRA agents.
A population of `N` agents (LoRA adapters on a shared frozen base) evolves for `G` non-overlapping
generations under the four operators the paper composes: grounded evaluation, directed recombination
(sex), diversity-preserving selection, and retraining (mutation). The single grounding knob acts in
the *evaluation* channel, exactly as in E11: selection scores each agent by
``g * verifier_fitness + (1 - g) * conformity``, where conformity is agreement with the population's
own modal answer. The inheritance channel is identical in every arm and deliberately ungrounded
each child is a fresh LoRA distilled from its source model's *own answers* (self-consumption made
literal), so knowledge survives only through the data channel.
Arms (four-arm ablation, mirroring E11): ``full`` / ``no_grounding`` (g=0; the verifier never enters
that arm's loop — offspring screening also falls back to conformity) / ``no_sex`` (children are
redistilled copies of selected parents) / ``no_diversity`` (plain top-P selection).
Pure, testable pieces live at module top (consensus, conformity, behavioural distance,
quality-diversity selection, complementary pairing); the GPU loop is
:func:`run_society_experiment`. ``python -m llm.experiment configs/llm/society_smoke.yaml``.
"""
from __future__ import annotations
import shutil
from pathlib import Path
import numpy as np
import pandas as pd
from .tasks import Task, make_tasks, _normalise
# Pure operators live in society_ops (shared with the v2 loop); re-exported here so the v1 code
# path and its tests are unchanged.
from .society_ops import ( # noqa: E402,F401
answer_of as _answer_of, arm_settings as _arm_settings_v2, behavioural_distance,
complementary_pairs, conformity_scores, consensus_answers, fitness_of as _fitness,
select_parents,
)
def arm_settings(arm: str, g: float) -> dict:
"""v1 four-arm switches (boolean ``sex``), resolved from the shared table."""
s = _arm_settings_v2(arm, g)
if s["sex"] == "linear":
raise ValueError("sex_linear is a v2 arm (kind: llm_society_v2)")
return {"g": s["g"], "sex": s["sex"] is not None, "diversity": s["diversity"]}
# ---------------------------------------------------------------------------- the GPU loop
def run_society_experiment(cfg: dict) -> pd.DataFrame:
"""Run the society loop for every configured arm; return tidy long-form rows.
Config keys (with defaults): ``agents`` (6), ``generations`` (8), ``arms`` (all four),
``g`` (0.5), ``lam`` (0.3), ``n_test``/``n_val``/``n_conf``/``n_inherit`` pool sizes,
``n_candidates`` (6) offspring screened per pair, ``epochs`` (2) child SFT epochs,
``spec_train``/``spec_epochs`` gen-0 specialist budget, ``hard`` (False),
``keep_all_adapters`` (False).
"""
import torch
from .evaluate import generate, load_model
from .merge import load_specialists
from .specialise import train_lora_on_tasks, train_specialist
name = cfg["experiment"]
base = cfg["base_model"]
fams = list(cfg.get("families", ["lists", "strings", "arith"]))
N = int(cfg.get("agents", 6))
G = int(cfg.get("generations", 8))
arms = list(cfg.get("arms", ["full", "no_grounding", "no_sex", "no_diversity"]))
g_val = float(cfg.get("g", 0.5))
lam = float(cfg.get("lam", 0.3))
n_test = int(cfg.get("n_test", 40))
n_val = int(cfg.get("n_val", 30))
n_conf = int(cfg.get("n_conf", 60))
n_inherit = int(cfg.get("n_inherit", 240))
n_cand = int(cfg.get("n_candidates", 6))
epochs = int(cfg.get("epochs", 2))
elitism = int(cfg.get("elitism", 0))
n_parents = int(cfg.get("n_parents", max(2, N // 2)))
spec_train = int(cfg.get("spec_train", 300))
spec_epochs = int(cfg.get("spec_epochs", 2))
hard = bool(cfg.get("hard", False))
keep_all = bool(cfg.get("keep_all_adapters", False))
seed = int(cfg["seed"])
lora = cfg.get("lora", {})
r, alpha = int(lora.get("r", 16)), int(lora.get("alpha", 32))
root = Path(cfg.get("adapters_dir", "models/llm")) / "society" / f"{name}_s{seed}"
# fixed pools: test (reporting only), val (grounded selection signal + offspring screening)
test = sum([make_tasks(f, n_test, seed=1000 + i, hard=hard) for i, f in enumerate(fams)], [])
val = sum([make_tasks(f, n_val, seed=3000 + i, hard=hard) for i, f in enumerate(fams)], [])
rng = np.random.default_rng(seed)
# gen-0 founders: light per-family specialists, shared across arms (same starting population)
founders = []
for i in range(N):
fam = fams[i % len(fams)]
d = root / "founders" / f"agent{i}_{fam}"
if not (d / "adapter_config.json").exists():
train_specialist(base, fam, str(d), n_train=spec_train, epochs=spec_epochs,
seed=seed * 100 + i, hard=hard, r=r, alpha=alpha)
founders.append(str(d))
rows: list[dict] = []
for arm in arms:
s = arm_settings(arm, g_val)
agents = list(founders)
for t in range(G):
conf_pool = sum([make_tasks(f, n_conf // len(fams), seed=seed * 7919 + t * 13 + i,
hard=hard) for i, f in enumerate(fams)], [])
conf_prompts = [x.prompt for x in conf_pool]
# ---- produce & score: one base, all agents attached as adapters
model, tok = load_specialists(base, agents)
fit_test, fit_val, conf_outs = [], [], []
for i in range(N):
model.set_adapter(f"a{i}")
fit_test.append(_fitness(generate(model, tok, [x.prompt for x in test]), test, fams))
fit_val.append(_fitness(generate(model, tok, [x.prompt for x in val]), val, fams))
conf_outs.append(generate(model, tok, conf_prompts))
consensus = consensus_answers(conf_outs)
conf = conformity_scores(conf_outs, consensus)
dist = behavioural_distance(conf_outs)
cons_acc = float(np.mean([verify(c, x) for c, x in zip(consensus, conf_pool)]))
fitness = np.array([fv["overall"] for fv in fit_val])
scores = s["g"] * fitness + (1.0 - s["g"]) * conf
parents = select_parents(scores, dist, n_parents, diversity=s["diversity"], lam=lam)
# ---- rows (reporting uses the verifier in every arm; the loop does not)
for i in range(N):
base_row = {"experiment": name, "arm": arm, "seed": seed, "generation": t,
"agent": i, "selected": i in parents,
"conformity": float(conf[i]), "score": float(scores[i])}
for k, v in fit_test[i].items():
rows.append({**base_row, "metric": f"test_{k}", "value": v})
rows.append({**base_row, "metric": "val_overall", "value": float(fitness[i])})
rows.append({"experiment": name, "arm": arm, "seed": seed, "generation": t,
"agent": -1, "selected": False, "conformity": float("nan"),
"score": float("nan"), "metric": "consensus_acc", "value": cons_acc})
rows.append({"experiment": name, "arm": arm, "seed": seed, "generation": t,
"agent": -1, "selected": False, "conformity": float("nan"),
"score": float("nan"), "metric": "diversity_behav",
"value": float(dist[np.triu_indices(N, 1)].mean())})
if t == G - 1:
del model
torch.cuda.empty_cache()
break
# ---- breed: pick each child's source (merged offspring, or a copied parent)
inherit_pool = sum([make_tasks(f, n_inherit // len(fams),
seed=seed * 104729 + t * 17 + i, hard=hard)
for i, f in enumerate(fams)], [])
n_bred = N - elitism
child_sources: list[dict] = [] # per child: parent names + weights
if s["sex"]:
from .directed import sample_merge_weights
pairs = complementary_pairs(parents, dist, n_bred)
for c, (pa, pb) in enumerate(pairs):
w = sample_merge_weights(2, n_cand, rng)
best_i, best_v = 0, -np.inf
for ci in range(n_cand):
cname = f"g{t}c{c}k{ci}"
model.add_weighted_adapter([f"a{pa}", f"a{pb}"], w[ci].tolist(), cname,
combination_type="linear")
model.set_adapter(cname)
if s["g"] > 0: # grounded screening: verifier on val
v = _fitness(generate(model, tok, [x.prompt for x in val]),
val, fams)["overall"]
else: # ungrounded screening: conformity only
outs_c = generate(model, tok, conf_prompts)
v = float(np.mean([_normalise(o) == cc
for o, cc in zip(outs_c, consensus)]))
if v > best_v:
best_i, best_v = ci, v
model.set_adapter(f"a{pa}") # never delete the active adapter
model.delete_adapter(cname)
cname = f"g{t}c{c}win"
model.add_weighted_adapter([f"a{pa}", f"a{pb}"], w[best_i].tolist(), cname,
combination_type="linear")
model.set_adapter(cname)
answers = generate(model, tok, [x.prompt for x in inherit_pool])
model.set_adapter(f"a{pa}")
model.delete_adapter(cname)
child_sources.append({"parents": (pa, pb), "answers": answers})
else:
for c in range(n_bred):
p = parents[c % len(parents)]
model.set_adapter(f"a{p}")
answers = generate(model, tok, [x.prompt for x in inherit_pool])
child_sources.append({"parents": (p, p), "answers": answers})
del model
torch.cuda.empty_cache()
# ---- reproduce: fresh LoRA per child, trained on its source's own answers.
# Elites (overlapping generations): the top-scoring parents survive as unmodified
# copies, applied identically in every arm — reproduction here is lossy distillation,
# so without a survivor the mutation load erases the champion each generation.
new_agents = []
for e in range(elitism):
d = root / arm / f"gen{t + 1}" / f"elite{e}"
shutil.copytree(agents[parents[e]], d, dirs_exist_ok=True)
new_agents.append(str(d))
rows.append({"experiment": name, "arm": arm, "seed": seed, "generation": t + 1,
"agent": e, "selected": False, "conformity": float("nan"),
"score": float("nan"), "metric": "parents",
"value": float(parents[e] * 100 + parents[e])})
for c, src in enumerate(child_sources, start=elitism):
data = [Task(x.family, x.prompt, _answer_of(a))
for x, a in zip(inherit_pool, src["answers"]) if _answer_of(a)]
d = root / arm / f"gen{t + 1}" / f"agent{c}"
if len(data) >= 8:
train_lora_on_tasks(base, data, str(d), epochs=epochs,
seed=seed * 31 + t * N + c, r=r, alpha=alpha)
else: # terminal degeneration: copy the source
# Reason: a fully-degenerate source emits no usable answers; crashing would kill
# a long sweep (cf. the neural terminal-collapse sentinel) — inherit unchanged.
shutil.copytree(agents[src["parents"][0]], d, dirs_exist_ok=True)
new_agents.append(str(d))
rows.append({"experiment": name, "arm": arm, "seed": seed, "generation": t + 1,
"agent": c, "selected": False, "conformity": float("nan"),
"score": float("nan"), "metric": "parents",
"value": float(src["parents"][0] * 100 + src["parents"][1])})
if not keep_all and t > 0: # disk hygiene: drop generation t
for d in agents: # (founders at t=0 are kept — shared)
shutil.rmtree(d, ignore_errors=True)
agents = new_agents
return pd.DataFrame(rows)

View file

@ -1,209 +0,0 @@
"""Pure, testable operators for the LLM society (v1 and v2 — see ``tasks/prereg-llm-society-v2.md``).
Everything here is NumPy on strings and floats; nothing touches a model. The v2 additions are the
E11-faithful pieces the v1 design lacked:
* :func:`pooled_survival` selection acts on *survival over parents + offspring*, keeping the top N
by ``score + λ·novelty`` (E11's rule), instead of truncating parents before breeding, which in v1
discarded half the families at generation 1 with no operator able to restore them (E6).
* :func:`mating_plan` complementary pairing over the whole population with a per-agent use cap, so
every founder's family can reach the next generation.
* :func:`route_union` the union-preserving recombination operator in the *inheritance data*: per
prompt, the child learns the answer of the more confident parent. This is E4's ``max`` per item,
verifier-free (legal in ``no_grounding``), and directed in E10's sense.
* :func:`choose_single_parent` score-proportional parent sampling for the ``no_sex`` arm, so that
arm still has selection without recombination.
"""
from __future__ import annotations
import numpy as np
from .tasks import Task, _normalise, verify
# ------------------------------------------------------------------ population readouts (v1)
def consensus_answers(outputs: list[list[str]]) -> list[str]:
"""The population's modal (normalised) answer per prompt; ties broken lexicographically."""
n_prompts = len(outputs[0])
cons = []
for p in range(n_prompts):
votes: dict[str, int] = {}
for out in outputs:
key = _normalise(out[p])
votes[key] = votes.get(key, 0) + 1
top = max(votes.values())
cons.append(min(k for k, v in votes.items() if v == top))
return cons
def conformity_scores(outputs: list[list[str]], consensus: list[str]) -> np.ndarray:
"""Each agent's agreement rate with the population consensus (the E11 conformity signal)."""
return np.array([np.mean([_normalise(o) == c for o, c in zip(out, consensus)])
for out in outputs], dtype=float)
def behavioural_distance(outputs: list[list[str]]) -> np.ndarray:
"""Pairwise disagreement rate between agents' normalised answers (verifier-free diversity)."""
n = len(outputs)
norm = [[_normalise(o) for o in out] for out in outputs]
d = np.zeros((n, n))
for i in range(n):
for j in range(i + 1, n):
d[i, j] = d[j, i] = float(np.mean([a != b for a, b in zip(norm[i], norm[j])]))
return d
def novelty(dist: np.ndarray) -> np.ndarray:
"""Per-agent mean behavioural distance to the rest of the pool (E11's ``_novelty``)."""
n = dist.shape[0]
if n < 2:
return np.zeros(n)
return dist.sum(axis=1) / (n - 1)
# ------------------------------------------------------------------ selection
def select_parents(scores: np.ndarray, dist: np.ndarray, k: int, *, diversity: bool,
lam: float = 0.3) -> list[int]:
"""v1 parent truncation (kept for the v1 code path and its tests): greedy QD or plain top-k."""
if not diversity:
return list(np.argsort(-scores, kind="stable")[:k])
chosen = [int(np.argmax(scores))]
while len(chosen) < k:
best_i, best_v = -1, -np.inf
for i in range(len(scores)):
if i in chosen:
continue
v = scores[i] + lam * float(np.mean([dist[i, j] for j in chosen]))
if v > best_v:
best_i, best_v = i, v
chosen.append(best_i)
return chosen
def pooled_survival(scores: np.ndarray, dist: np.ndarray, n_keep: int, *, lam: float) -> list[int]:
"""E11 survival: keep the top ``n_keep`` of the pool by ``score + lam * novelty``.
``lam = 0`` is the greedy (``no_diversity``) arm. Parents and children compete on equal terms, so
a strong parent survives by out-scoring its children (elitism is emergent, not a knob) and no
lineage is excluded before it has bred.
Args:
scores (np.ndarray): per-member selection score over the pooled parents + children.
dist (np.ndarray): pairwise behavioural distance over the pool.
n_keep (int): population size to keep.
lam (float): novelty weight (E11 ``novelty``).
Returns:
list[int]: indices into the pool, best merit first (deterministic).
"""
merit = np.asarray(scores, dtype=float) + lam * novelty(dist)
order = np.argsort(-merit, kind="stable")
return [int(i) for i in order[:n_keep]]
def choose_single_parent(scores: np.ndarray, rng: np.random.Generator) -> int:
"""Score-proportional parent sampling (``no_sex``): selection without recombination."""
s = np.asarray(scores, dtype=float)
w = s - s.min() + 1e-6
return int(rng.choice(len(s), p=w / w.sum()))
# ------------------------------------------------------------------ mating and recombination
def mating_plan(dist: np.ndarray, n_pairs: int, *, max_use: int = 2) -> list[tuple[int, int]]:
"""Complementary pairing over the whole population with a per-agent use cap.
Pairs are taken in descending behavioural distance (directed mate choice, E10) subject to each
agent appearing in at most ``max_use`` pairs, so no single agent monopolises reproduction and
every agent's knowledge has a route to the next generation. If the cap exhausts the candidates
before ``n_pairs`` is reached, the remaining slots cycle the most-distant pairs (degenerate but
never empty).
"""
n = dist.shape[0]
cands = sorted(((i, j) for i in range(n) for j in range(i + 1, n)), key=lambda ij: -dist[ij])
if not cands:
return [(0, 0)] * n_pairs
use = np.zeros(n, dtype=int)
plan: list[tuple[int, int]] = []
for i, j in cands:
if len(plan) >= n_pairs:
break
if use[i] < max_use and use[j] < max_use:
plan.append((i, j)); use[i] += 1; use[j] += 1
k = 0
while len(plan) < n_pairs: # cap exhausted: cycle the best pairs
plan.append(cands[k % len(cands)]); k += 1
return plan
def route_union(ans_a: list[str], conf_a: np.ndarray, ans_b: list[str],
conf_b: np.ndarray) -> tuple[list[str], np.ndarray]:
"""Union-preserving recombination of two parents' answer sets: per prompt, the more confident wins.
Confidence is the parent's own self-certainty (exp mean token log-prob) — no verifier. Ties go to
parent A (deterministic).
Returns:
(answers, source): the child's inheritance answers and a 0/1 array naming the parent per
prompt (for the H6 diagnostic of *where* a skill is lost).
"""
src = (np.asarray(conf_b) > np.asarray(conf_a)).astype(int)
out = [b if s else a for a, b, s in zip(ans_a, ans_b, src)]
return out, src
def complementary_pairs(parents: list[int], dist: np.ndarray, n_children: int) -> list[tuple[int, int]]:
"""v1 mating plan over a parent subset (kept for the v1 code path and its tests)."""
pairs = sorted(((a, b) for i, a in enumerate(parents) for b in parents[i + 1:]),
key=lambda ab: -dist[ab[0], ab[1]])
if not pairs:
pairs = [(parents[0], parents[0])]
return [pairs[i % len(pairs)] for i in range(n_children)]
# ------------------------------------------------------------------ arms and readouts
def arm_settings(arm: str, g: float) -> dict:
"""Resolve an arm name to its operator switches.
``sex`` is ``"union"`` (confidence-routed union inheritance, the v2 default), ``"linear"`` (v1's
screened 2-parent LoRA blend the H2 control arm ``sex_linear``), or ``None``.
"""
table = {
"full": {"g": g, "sex": "union", "diversity": True},
"no_grounding": {"g": 0.0, "sex": "union", "diversity": True},
"no_sex": {"g": g, "sex": None, "diversity": True},
"no_diversity": {"g": g, "sex": "union", "diversity": False},
"sex_linear": {"g": g, "sex": "linear", "diversity": True},
}
if arm not in table:
raise ValueError(f"unknown arm {arm!r} (expected one of {list(table)})")
return dict(table[arm])
def fitness_of(outs: list[str], tasks: list[Task], fams: list[str]) -> dict:
"""Overall / per-family / worst-family verifier accuracy from precomputed outputs."""
corr = np.array([verify(o, t) for o, t in zip(outs, tasks)])
fam = np.array([t.family for t in tasks])
acc = {"overall": float(corr.mean())}
acc.update({f: float(corr[fam == f].mean()) if (fam == f).any() else float("nan") for f in fams})
acc["worst_family"] = min(acc[f] for f in fams)
return acc
def families_alive(per_family_acc: list[dict], fams: list[str], threshold: float = 0.6) -> int:
"""Number of families on which at least one agent is competent (≥ ``threshold``) — the count of
'alleles' still present in the population; loss is permanent (E6)."""
return int(sum(any(a.get(f, 0.0) >= threshold for a in per_family_acc) for f in fams))
def answer_of(raw: str) -> str:
"""Trim a raw completion to a single short answer line for the inheritance data."""
return raw.strip().split("\n")[0][:64].strip()

View file

@ -1,319 +0,0 @@
"""The composed society at LLM scale, v2 — E11 re-instantiated faithfully (``kind: llm_society_v2``).
Pre-registered in ``tasks/prereg-llm-society-v2.md``; this module is its §3. What changed from v1 and
why is in that document's §1. In one paragraph: ``L`` disjoint task families and **one founder per
family** (ρ = 0 by construction); recombination is a **confidence-routed union** of two parents'
inheritance answers (E4's ``max`` per item, verifier-free) rather than a linear LoRA blend; selection
acts on **survival over the pooled parents + children** by ``score + λ·novelty`` (E11's rule) rather
than on breeding eligibility; the run **checkpoints every generation** and resumes.
One generation (§3.4): produce & score the population mating plan by complementarity each child's
inheritance data from its parents' own answers (union / linear / single) → source diagnostics →
train N fresh LoRAs score the children against the *parents'* consensus → keep the top N of the
2N pool checkpoint.
Row schema (long form): ``experiment, arm, seed, generation, agent, role, parents, selected,
conformity, score, metric, value``. ``role`` {population, child, child_source, summary}.
"""
from __future__ import annotations
import json
import shutil
from pathlib import Path
import numpy as np
import pandas as pd
from . import families as _families # noqa: F401 (registers the extra task families)
from .society_ops import (answer_of, arm_settings, behavioural_distance, choose_single_parent,
conformity_scores, consensus_answers, families_alive, fitness_of,
mating_plan, novelty, pooled_survival, route_union)
from .tasks import Task, make_tasks, verify, _normalise
def _pool(fams: list[str], per_family: int, seed: int, hard: bool) -> list[Task]:
return sum([make_tasks(f, per_family, seed=seed + i, hard=hard) for i, f in enumerate(fams)], [])
def _row(base: dict, **kw) -> dict:
r = dict(base); r.update(kw); return r
def _train_founder_locked(d: Path, train, timeout_s: int = 3600) -> None:
"""Train the founder at ``d`` exactly once across concurrent jobs (lock file, O_EXCL)."""
import os, time
done = d / "adapter_config.json"
if done.exists():
return
d.parent.mkdir(parents=True, exist_ok=True)
lock = d.parent / (d.name + ".lock")
try:
fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
except FileExistsError:
t0 = time.time()
while not done.exists(): # another job is training it
if time.time() - t0 > timeout_s:
raise TimeoutError(f"waited {timeout_s}s for founder {d}")
time.sleep(15)
return
try:
os.write(fd, str(os.getpid()).encode()); os.close(fd)
train()
finally:
lock.unlink(missing_ok=True)
class _State:
"""Per-arm checkpoint: the current population's adapter dirs and the generation reached."""
def __init__(self, path: Path):
self.path = path
def load(self) -> dict | None:
return json.loads(self.path.read_text()) if self.path.exists() else None
def save(self, generation: int, agents: list[str]) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
self.path.write_text(json.dumps({"generation": generation, "agents": agents}))
def run_society_v2(cfg: dict) -> pd.DataFrame:
"""Run every configured arm of the v2 society; return tidy rows (resumable per arm)."""
import torch
from .epistasis import generate_with_confidence
from .evaluate import generate
from .merge import load_specialists
from .specialise import train_lora_on_tasks, train_specialist
name = cfg["experiment"]
base = cfg["base_model"]
fams = list(cfg["families"])
L = len(fams)
N = int(cfg.get("agents", L))
G = int(cfg.get("generations", 12))
arms = list(cfg.get("arms", ["full", "no_grounding", "no_sex", "no_diversity"]))
g_val = float(cfg.get("g", 0.85))
lam = float(cfg.get("lam", 0.3))
n_test = int(cfg.get("n_test", 20)) # per family
n_val = int(cfg.get("n_val", 10)) # per family
n_conf = int(cfg.get("n_conf", 10)) # per family, fresh each generation
k_inh = int(cfg.get("k_inherit", 100)) # per family, fresh each generation (gate C2)
epochs = int(cfg.get("epochs", 3))
n_cand = int(cfg.get("n_candidates", 6)) # sex_linear only
spec_train = int(cfg.get("spec_train", 600))
spec_epochs = int(cfg.get("spec_epochs", 3))
hard = bool(cfg.get("hard", False))
resume = bool(cfg.get("resume", True))
seed = int(cfg["seed"])
lora = cfg.get("lora", {})
r, alpha = int(lora.get("r", 16)), int(lora.get("alpha", 32))
root = Path(cfg.get("adapters_dir", "models/llm")) / "society_v2" / f"{name}_s{seed}"
out_dir = Path(cfg.get("output", {}).get("dir", f"results/{name}"))
out_dir.mkdir(parents=True, exist_ok=True)
max_use = int(cfg.get("max_mate_use", 2))
# Confidence-gated inheritance (prereg §4a, C2b): a child learns only the prompts its source is
# confident on (exp mean token log-prob ≥ conf_gate). Verifier-free; identical in every arm;
# None = ungated (the pre-registered v2 default, which C2 showed loses 20-40%/generation).
conf_gate = cfg.get("conf_gate")
conf_gate = None if conf_gate is None else float(conf_gate)
test = _pool(fams, n_test, 1000, hard)
val = _pool(fams, n_val, 3000, hard)
# ---- founders: one specialist per family (agent i -> family i mod L), cached and shared by arms.
# Arm-jobs of one seed may start together on the cluster: an O_EXCL lock makes the first train
# and the rest wait on the finished adapter, so no two jobs write the same founder.
founders = []
for i in range(N):
fam = fams[i % L]
d = root / "founders" / f"agent{i}_{fam}"
_train_founder_locked(d, lambda d=d, fam=fam, i=i: train_specialist(
base, fam, str(d), n_train=spec_train, epochs=spec_epochs, seed=seed * 100 + i,
hard=hard, r=r, alpha=alpha))
founders.append(str(d))
frames: list[pd.DataFrame] = []
for a_idx, arm in enumerate(arms):
s = arm_settings(arm, g_val)
state = _State(root / arm / "state.json")
partial = out_dir / f"partial_{arm}_s{seed}.parquet"
rows: list[dict] = []
agents, t0 = list(founders), 0
st = state.load() if resume else None
if st and all(Path(d, "adapter_config.json").exists() for d in st["agents"]):
agents, t0 = list(st["agents"]), int(st["generation"])
if partial.exists():
rows = pd.read_parquet(partial).to_dict("records")
print(f"[{arm}] resuming at generation {t0}")
for t in range(t0, G + 1):
rng = np.random.default_rng([seed, a_idx, t]) # resume-safe per-generation RNG
conf_pool = _pool(fams, n_conf, seed * 7919 + t * 13, hard)
conf_prompts = [x.prompt for x in conf_pool]
tag = {"experiment": name, "arm": arm, "seed": seed, "generation": t}
# ---- (1) produce & score the population
model, tok = load_specialists(base, agents)
fit_test, fit_val, conf_outs = [], [], []
for i in range(N):
model.set_adapter(f"a{i}")
fit_test.append(fitness_of(generate(model, tok, [x.prompt for x in test]), test, fams))
fit_val.append(fitness_of(generate(model, tok, [x.prompt for x in val]), val, fams))
conf_outs.append(generate(model, tok, conf_prompts))
consensus = consensus_answers(conf_outs)
conf = conformity_scores(conf_outs, consensus)
dist = behavioural_distance(conf_outs)
nov = novelty(dist)
cons_acc = float(np.mean([verify(c, x) for c, x in zip(consensus, conf_pool)]))
fitness = np.array([fv["overall"] for fv in fit_val])
scores = s["g"] * fitness + (1.0 - s["g"]) * conf
for i in range(N):
b = _row(tag, agent=i, role="population", parents=Path(agents[i]).name,
selected=True, conformity=float(conf[i]), score=float(scores[i]))
for k, v in fit_test[i].items():
rows.append(_row(b, metric=f"test_{k}", value=v))
rows.append(_row(b, metric="val_overall", value=float(fitness[i])))
rows.append(_row(b, metric="novelty", value=float(nov[i])))
summ = _row(tag, agent=-1, role="summary", parents="", selected=False,
conformity=float("nan"), score=float("nan"))
rows.append(_row(summ, metric="consensus_acc", value=cons_acc))
rows.append(_row(summ, metric="diversity_behav", value=float(nov.mean())))
rows.append(_row(summ, metric="families_alive", value=float(families_alive(fit_test, fams))))
rows.append(_row(summ, metric="gap_conformity_minus_truth",
value=float(conf.mean() - np.mean([f["overall"] for f in fit_test]))))
if t == G:
del model; torch.cuda.empty_cache()
break
# ---- (2)-(3) mating plan and each child's inheritance data
inherit = _pool(fams, k_inh, seed * 104729 + t * 17, hard)
inh_prompts = [x.prompt for x in inherit]
child_src: list[dict] = []
if s["sex"] == "union":
plan = mating_plan(dist, N, max_use=max_use)
needed = sorted({p for ab in plan for p in ab})
ans, cf = {}, {}
for p in needed: # each parent answers the pool once
model.set_adapter(f"a{p}")
ans[p], cf[p] = generate_with_confidence(model, tok, inh_prompts)
for pa, pb in plan:
routed, src = route_union(ans[pa], cf[pa], ans[pb], cf[pb])
keep = np.maximum(cf[pa], cf[pb]) >= conf_gate if conf_gate is not None else None
child_src.append({"parents": (pa, pb), "answers": routed,
"share_b": float(src.mean()), "keep": keep})
elif s["sex"] == "linear":
from .directed import sample_merge_weights
plan = mating_plan(dist, N, max_use=max_use)
for c, (pa, pb) in enumerate(plan):
w = sample_merge_weights(2, n_cand, rng)
best_i, best_v = 0, -np.inf
for ci in range(n_cand):
cname = f"g{t}c{c}k{ci}"
model.add_weighted_adapter([f"a{pa}", f"a{pb}"], w[ci].tolist(), cname,
combination_type="linear")
model.set_adapter(cname)
if s["g"] > 0:
v = fitness_of(generate(model, tok, [x.prompt for x in val]), val,
fams)["overall"]
else:
oc = generate(model, tok, conf_prompts)
v = float(np.mean([_normalise(o) == cc for o, cc in zip(oc, consensus)]))
if v > best_v:
best_i, best_v = ci, v
model.set_adapter(f"a{pa}"); model.delete_adapter(cname)
cname = f"g{t}c{c}win"
model.add_weighted_adapter([f"a{pa}", f"a{pb}"], w[best_i].tolist(), cname,
combination_type="linear")
model.set_adapter(cname)
answers = generate(model, tok, inh_prompts)
model.set_adapter(f"a{pa}"); model.delete_adapter(cname)
child_src.append({"parents": (pa, pb), "answers": answers, "share_b": float("nan")})
else: # no_sex: single parent, score-proportional
picks = [choose_single_parent(scores, rng) for _ in range(N)]
ans, cf = {}, {}
for p in sorted(set(picks)):
model.set_adapter(f"a{p}")
ans[p], cf[p] = generate_with_confidence(model, tok, inh_prompts)
for p in picks:
keep = cf[p] >= conf_gate if conf_gate is not None else None
child_src.append({"parents": (p, p), "answers": ans[p], "share_b": float("nan"),
"keep": keep})
# ---- source diagnostics (H6): what each child was *supplied*, per family
for c, src in enumerate(child_src):
supplied = fitness_of(src["answers"], inherit, fams)
b = _row(tag, agent=c, role="child_source", parents=f"{src['parents'][0]}+{src['parents'][1]}",
selected=False, conformity=float("nan"), score=float("nan"))
for k, v in supplied.items():
rows.append(_row(b, metric=f"source_{k}", value=v))
rows.append(_row(b, metric="source_share_b", value=src["share_b"]))
del model; torch.cuda.empty_cache()
# ---- (4) inherit: a fresh LoRA per child on its source's own answers
children, degenerate = [], 0
for c, src in enumerate(child_src):
keep = src.get("keep")
if keep is None:
keep = np.ones(len(inherit), dtype=bool)
data = [Task(x.family, x.prompt, answer_of(a))
for x, a, kp in zip(inherit, src["answers"], keep) if kp and answer_of(a)]
rows.append(_row(tag, agent=c, role="child_source", parents=f"{src['parents'][0]}+{src['parents'][1]}",
selected=False, conformity=float("nan"), score=float("nan"),
metric="n_inherit_kept", value=float(len(data))))
d = root / arm / f"gen{t + 1}" / f"child{c}"
if len(data) >= 8:
train_lora_on_tasks(base, data, str(d), epochs=epochs,
seed=seed * 31 + t * N + c, r=r, alpha=alpha)
else: # terminal degeneration: inherit unchanged
shutil.copytree(agents[src["parents"][0]], d, dirs_exist_ok=True); degenerate += 1
children.append(str(d))
# ---- (5) survive: score children against the PARENTS' consensus; keep top N of 2N
model, tok = load_specialists(base, agents + children)
c_test, c_val, c_conf = [], [], []
for j in range(N):
model.set_adapter(f"a{N + j}")
c_test.append(fitness_of(generate(model, tok, [x.prompt for x in test]), test, fams))
c_val.append(fitness_of(generate(model, tok, [x.prompt for x in val]), val, fams))
c_conf.append(generate(model, tok, conf_prompts))
del model; torch.cuda.empty_cache()
conf_c = conformity_scores(c_conf, consensus)
fit_c = np.array([fv["overall"] for fv in c_val])
scores_c = s["g"] * fit_c + (1.0 - s["g"]) * conf_c
pool_scores = np.concatenate([scores, scores_c])
pool_dist = behavioural_distance(conf_outs + c_conf)
keep = pooled_survival(pool_scores, pool_dist, N, lam=lam if s["diversity"] else 0.0)
keep_set = set(keep)
for j in range(N):
b = _row(tag, agent=j, role="child",
parents=f"{child_src[j]['parents'][0]}+{child_src[j]['parents'][1]}",
selected=(N + j) in keep_set, conformity=float(conf_c[j]),
score=float(scores_c[j]))
for k, v in c_test[j].items():
rows.append(_row(b, metric=f"test_{k}", value=v))
rows.append(_row(b, metric="val_overall", value=float(fit_c[j])))
rows.append(_row(summ, metric="best_newborn_overall",
value=float(max(f["overall"] for f in c_test))))
rows.append(_row(summ, metric="n_degenerate", value=float(degenerate)))
rows.append(_row(summ, metric="n_parents_survive",
value=float(sum(1 for k in keep if k < N))))
pool_dirs = agents + children
survivors = [pool_dirs[k] for k in keep]
# disk hygiene: drop non-survivors (founders are shared across arms — keep them)
for k, d in enumerate(pool_dirs):
if k not in keep_set and not d.startswith(str(root / "founders")):
shutil.rmtree(d, ignore_errors=True)
agents = survivors
state.save(t + 1, agents)
pd.DataFrame(rows).to_parquet(partial, index=False)
print(f"[{arm}] gen {t + 1}/{G}: best {max(f['overall'] for f in fit_test):.3f} "
f"newborn {max(f['overall'] for f in c_test):.3f} cons {cons_acc:.2f} "
f"alive {families_alive(fit_test, fams)} deg {degenerate}", flush=True)
frames.append(pd.DataFrame(rows))
return pd.concat(frames, ignore_index=True)