- 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
144 lines
7.5 KiB
Python
144 lines
7.5 KiB
Python
"""E12 — model speciation: when two diverged models are too incompatible to recombine (merge).
|
||
|
||
The evolution-of-sex frame (E7–E11) 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 **Bateson–Dobzhansky–Muller
|
||
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 **Orr–Turelli 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)
|