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
This commit is contained in:
Giorgio Gilestro 2026-09-13 17:00:40 +01:00
parent 84124de143
commit ab3dc10587
240 changed files with 477 additions and 476 deletions

View file

@ -0,0 +1,144 @@
"""E12 — model speciation: when two diverged models are too incompatible to recombine (merge).
The evolution-of-sex frame (E7E11) says recombining complementary parents beats copying. This module
asks the geneticist's question underneath it: **how far can two lineages diverge before recombination
stops working?** In biology the answer is *reproductive isolation* via **BatesonDobzhanskyMuller
incompatibilities** (BDMIs): alleles that are each benign on their own lineage's background but
deleterious *in combination*, so a recombinant (a hybrid) inherits combinations selection never tested.
A merged model is a single recombinant genotype an F2-like *hybrid breakdown / recombination-load*
object, not an F1 so the predicted signature as divergence grows is
**compatible outbreeding depression hybrid inviability**, arriving *earlier* the more epistatic the
capability landscape. This is the analytic core of the paper's speciation claim; it is confirmed in
sign, not magnitude, by real weights elsewhere.
Two landscapes, one runner:
- ``bdm`` the controllable, canonical construction. An ancestor; two lineages each substitute a
*disjoint* set of loci (so each parent is adapted and neither carries an incompatibility); a fraction
``rho`` of the cross-lineage locus pairs are BDMIs with penalty ``s``. Sweeping the divergence ``d``
(total substitutions) yields the fitness curve and the **OrrTurelli snowball** the number of
incompatibilities grows ~``(d/2)^2``, so hybrid fitness falls *super-linearly*.
- ``nk`` the emergent version. Parents are *local optima* (hill-climbed) on a Kauffman NK landscape;
recombining them exposes broken co-adapted blocks. Sweeping the ruggedness ``K`` at matched divergence
isolates the paper's wedge: **at equal divergence, mergeability is governed by epistasis**, which no
divergence-only ML predictor captures.
Pure seeded NumPy on the existing genotype machinery bitwise-reproducible from one master seed, no
external simulator (whose separate, version-unstable RNG would break that guarantee).
"""
from __future__ import annotations
from typing import Any, Mapping
import numpy as np
import pandas as pd
from .genotype import bits_to_index, crossover, genotype_bits, hill_climb, nk_fitness
from .seeding import spawn_seeds
# --------------------------------------------------------------------------- BDM construction
def _bdm_point(L: int, d: int, rho: float, s: float, beta: float, rate: float,
n_off: int, rng: np.random.Generator) -> dict:
"""One divergence point of the BDM model (one replicate's random locus assignment + offspring)."""
half = min(d, L - (L % 2)) // 2
loci = rng.permutation(L)
S1, S2 = loci[:half], loci[half:2 * half] # disjoint substituted loci per lineage
p1 = np.zeros(L, dtype=np.int8); p1[S1] = 1 # parent 1: derived at S1
p2 = np.zeros(L, dtype=np.int8); p2[S2] = 1 # parent 2: derived at S2
dmi = [(int(a), int(b)) for a in S1 for b in S2 if rng.random() < rho] # cross-lineage BDMIs
def fitness(bits: np.ndarray) -> np.ndarray:
b = np.atleast_2d(bits)
val = beta * b.sum(1).astype(float) # additive: each derived allele is adaptive
for a, bl in dmi: # BDMI: penalty only if BOTH derived alleles present
val -= s * ((b[:, a] == 1) & (b[:, bl] == 1))
return val
f1, f2 = float(fitness(p1)[0]), float(fitness(p2)[0]) # parents carry no incompatibility (disjoint)
parents = np.stack([p1, p2])
offs = np.stack([crossover(parents, rate, rng) for _ in range(n_off)])
fo = fitness(offs)
worse_parent = min(f1, f2)
realized = np.zeros(n_off)
for a, bl in dmi:
realized += (offs[:, a] == 1) & (offs[:, bl] == 1)
return {"divergence": int(2 * half), "n_dmi": len(dmi),
"parent_fitness": (f1 + f2) / 2.0,
"offspring_fitness": float(fo.mean()),
"outbreeding_depression": float((fo < worse_parent).mean()), # hybrid worse than either parent
"isolation": float((fo < 0.0).mean()), # hybrid below the ancestor = inviable
"incompatibilities": float(realized.mean())}
# --------------------------------------------------------------------------- NK construction
def _nk_point(L: int, K: int, landscape_seed: int, rate: float, n_pairs: int,
n_off: int, rng: np.random.Generator) -> dict:
"""Aggregate over ``n_pairs`` random parent-pairs (local optima) on one NK landscape at ruggedness K."""
F = nk_fitness(L, K, landscape_seed)
bits = genotype_bits(L)
divs, gaps, od = [], [], []
for _ in range(n_pairs):
g1 = hill_climb(F, L, int(rng.integers(1 << L)))
g2 = hill_climb(F, L, int(rng.integers(1 << L)))
b1, b2 = bits[g1], bits[g2]
parents = np.stack([b1, b2])
offs = [crossover(parents, rate, rng) for _ in range(n_off)]
fo = np.array([F[bits_to_index(o)] for o in offs])
worse = min(F[g1], F[g2])
divs.append(int((b1 != b2).sum()))
gaps.append(float(fo.mean() - worse)) # >0: recombination helps; <0: it hurts
od.append(float((fo < worse).mean()))
return {"K": K, "divergence": float(np.mean(divs)),
"offspring_minus_parent": float(np.mean(gaps)),
"outbreeding_depression": float(np.mean(od))}
# --------------------------------------------------------------------------- runner
def run_speciation(cfg: Mapping[str, Any], seed: int) -> pd.DataFrame:
"""Run the speciation sweep (``landscape`` = ``bdm`` or ``nk``); return a tidy per-point DataFrame.
Args:
cfg (Mapping): resolved config with a ``speciation`` block.
seed (int): master seed (all randomness derives from it via ``spawn_seeds``).
Returns:
pd.DataFrame: one row per swept point x replicate, with the metric columns.
"""
spec = dict(cfg["speciation"])
landscape = spec.get("landscape", "bdm")
L = int(spec.get("L", 16))
rate = float(spec.get("recomb_rate", 0.5))
n_off = int(spec.get("n_offspring", 400))
reps = int(cfg.get("n_replicates", spec.get("reps", 20)))
rows: list[dict] = []
seeds = spawn_seeds(seed, reps)
if landscape == "bdm":
rhos = spec.get("rho", [0.1, 0.25, 0.5])
rhos = rhos if isinstance(rhos, (list, tuple)) else [rhos]
divergences = spec.get("divergences", list(range(0, L + 1, 2)))
s, beta = float(spec.get("s", 1.0)), float(spec.get("beta", 1.0))
for rep, ss in enumerate(seeds):
rng = np.random.default_rng(int(ss.generate_state(1)[0]))
for rho in rhos:
for d in divergences:
row = _bdm_point(L, int(d), float(rho), s, beta, rate, n_off, rng)
row.update({"landscape": "bdm", "rho": float(rho), "replicate": rep})
rows.append(row)
elif landscape == "nk":
Ks = spec.get("K", [0, 2, 4, 6, 8])
Ks = Ks if isinstance(Ks, (list, tuple)) else [Ks]
n_pairs = int(spec.get("n_pairs", 40))
for rep, ss in enumerate(seeds):
state = ss.generate_state(2)
rng = np.random.default_rng(int(state[0]))
for K in Ks:
row = _nk_point(L, int(K), int(state[1]), rate, n_pairs, n_off, rng)
row.update({"landscape": "nk", "replicate": rep})
rows.append(row)
else:
raise ValueError(f"unknown speciation landscape {landscape!r} (expected bdm|nk)")
return pd.DataFrame(rows)