E12: model speciation — the merge-compatibility limit of the sexual society
New analytic result for the evolution-of-sex paper: how far can two lineages diverge before recombination (model merging) stops working? Frames merge failure as biological reproductive isolation via Bateson-Dobzhansky-Muller incompatibilities. src/knowledge/speciation.py, kind: speciation, on the E7-E11 genotype machinery (pure seeded NumPy, bitwise-reproducible; no external simulator whose separate RNG would break that). - BDM construction (E12.yaml): ancestor + two lineages substituting disjoint loci (each parent adaptive, incompatibility-free), a fraction rho of cross-lineage pairs are BDMIs. Sweeping divergence d reproduces the predicted compatible -> outbreeding depression -> hybrid inviability curve; the isolation cliff moves to lower d as epistasis density rises (iso at d=20: 0.00/0.03/0.50 for rho 0.1/0.25/0.5); incompatibilities snowball ~ (d/2)^2 (Orr-Turelli). - NK variant (E12_nk.yaml): parents = hill-climbed local optima; the epistasis wedge — recombination gain flips 0 -> -0.13 and OD rate 0 -> 0.90 as ruggedness K rises. At matched divergence, mergeability is governed by epistasis, the axis no divergence-only ML merge predictor captures. plot_E12.py (3-panel), +7 tests (138 green), README with honest positioning (concedes the empirical phenomenon to Pari 2024 / Zhou 2026 + permutation artefacts to Git Re-Basin; claims the predictive theory + the epistasis wedge). Wired into make layer1. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
ae1779a9a8
commit
db9452c9d4
14 changed files with 470 additions and 1 deletions
2
Makefile
2
Makefile
|
|
@ -16,7 +16,7 @@ test: ## correctness tests + scientific-validation tests (the spine
|
|||
uv run pytest
|
||||
|
||||
layer1: ## run experiments E1-E6 + the learning-kernel bridge (analytic)
|
||||
for e in E1 E2 E3 E4 E5 E6 E7 E8 E9 E10 E11 kernel_sharpen kernel_smooth; do uv run python -m knowledge.experiment configs/layer1/$$e.yaml; done
|
||||
for e in E1 E2 E3 E4 E5 E6 E7 E8 E9 E10 E11 E12 E12_nk kernel_sharpen kernel_smooth; do uv run python -m knowledge.experiment configs/layer1/$$e.yaml; done
|
||||
|
||||
neural: ## run Layer 1.5 synthetic neural experiments (excludes the heavy MNIST tier)
|
||||
for c in configs/neural/*.yaml; do case "$$c" in *mnist*) ;; \
|
||||
|
|
|
|||
28
configs/layer1/E12.yaml
Normal file
28
configs/layer1/E12.yaml
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
experiment: E12
|
||||
kind: speciation
|
||||
seed: 12
|
||||
n_replicates: 15
|
||||
|
||||
# E12 — MODEL SPECIATION / reproductive isolation (the merge-compatibility limit of the sexual society).
|
||||
# The Bateson-Dobzhansky-Muller construction: an ancestor; two lineages each substitute a DISJOINT set
|
||||
# of loci (each parent adaptive, neither carrying an incompatibility); a fraction `rho` of cross-lineage
|
||||
# locus pairs are incompatibilities (penalty `s`) that only bite when a recombinant inherits BOTH derived
|
||||
# alleles. Sweeping the divergence d (total substitutions) gives the predicted signature
|
||||
# COMPATIBLE -> OUTBREEDING DEPRESSION -> HYBRID INVIABILITY, arriving earlier the denser the epistasis
|
||||
# (rho), with the Orr-Turelli snowball (# incompatibilities ~ (d/2)^2, so fitness falls super-linearly).
|
||||
# A merged model is a single recombinant (F2-like: hybrid breakdown / recombination load), so this maps
|
||||
# to postzygotic isolation, not F1 vigour. Falsifier: no outbreeding-depression/isolation progression as
|
||||
# d and rho grow. Pure seeded NumPy on the E7-E11 genotype machinery (bitwise-reproducible).
|
||||
|
||||
speciation:
|
||||
landscape: bdm
|
||||
L: 20
|
||||
rho: [0.1, 0.25, 0.5] # epistasis DENSITY: fraction of cross-lineage locus pairs that are BDMIs
|
||||
divergences: [0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20]
|
||||
s: 1.0 # incompatibility penalty per realised BDMI
|
||||
beta: 1.0 # additive benefit per derived (adaptive) allele — makes parents fit
|
||||
recomb_rate: 0.5 # free recombination (each locus ~ independent parent)
|
||||
n_offspring: 500
|
||||
|
||||
output:
|
||||
dir: results/E12
|
||||
22
configs/layer1/E12_nk.yaml
Normal file
22
configs/layer1/E12_nk.yaml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
experiment: E12_nk
|
||||
kind: speciation
|
||||
seed: 12
|
||||
n_replicates: 15
|
||||
|
||||
# E12 (NK variant) — the EPISTASIS WEDGE, the paper's distinct falsifiable claim: at matched divergence,
|
||||
# mergeability is governed by the EPISTASIS (ruggedness K) of the capability landscape, not by divergence
|
||||
# alone (every existing ML merge predictor is a divergence measure). Parents are LOCAL OPTIMA reached by
|
||||
# hill-climbing a Kauffman NK landscape from random starts; recombining them exposes broken co-adapted
|
||||
# blocks. As K rises, recombining two adapted parents flips from a gain (offspring above the worse parent)
|
||||
# to outbreeding depression (offspring below it). K=0 (additive) is the no-isolation control.
|
||||
|
||||
speciation:
|
||||
landscape: nk
|
||||
L: 16
|
||||
K: [0, 2, 4, 6, 8, 10] # ruggedness / epistasis knob
|
||||
n_pairs: 40 # random parent-pairs (local optima) aggregated per landscape
|
||||
recomb_rate: 0.5
|
||||
n_offspring: 200
|
||||
|
||||
output:
|
||||
dir: results/E12_nk
|
||||
79
figures/plot_E12.py
Normal file
79
figures/plot_E12.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
"""E12 figure — model speciation: the merge-compatibility limit of the sexual society.
|
||||
|
||||
Three panels, reading only the committed bundles. (A) BDM: mean recombinant (hybrid) fitness vs
|
||||
parental divergence, one line per epistasis density rho, against the rising parent fitness — the
|
||||
compatible -> outbreeding-depression -> hybrid-inviability trajectory, peaking then crashing sooner the
|
||||
denser the epistasis. (B) BDM: the reproductive-isolation rate (fraction of hybrids below the ancestor)
|
||||
vs divergence — the isolation cliff, moving to lower divergence as epistasis density rises. (C) NK: the
|
||||
epistasis wedge — as landscape ruggedness K grows, recombining two adapted local-optimum parents flips
|
||||
from a gain to outbreeding depression.
|
||||
|
||||
Usage: python figures/plot_E12.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from _figlib import load_bundle, savefig # noqa: E402
|
||||
|
||||
|
||||
def _agg(df, keys, value):
|
||||
g = df.groupby(keys)[value].agg(["mean", "std", "count"]).reset_index()
|
||||
g["se"] = g["std"] / np.sqrt(g["count"].clip(lower=1))
|
||||
return g
|
||||
|
||||
|
||||
def main() -> None:
|
||||
bdm, _ = load_bundle("results/E12")
|
||||
nk, _ = load_bundle("results/E12_nk")
|
||||
rhos = sorted(bdm["rho"].unique())
|
||||
colors = plt.cm.viridis(np.linspace(0.15, 0.85, len(rhos)))
|
||||
|
||||
fig, axes = plt.subplots(1, 3, figsize=(16, 5))
|
||||
|
||||
# Panel A: hybrid fitness vs divergence, per epistasis density, + parent fitness.
|
||||
ax = axes[0]
|
||||
par = _agg(bdm, "divergence", "parent_fitness")
|
||||
ax.plot(par["divergence"], par["mean"], "k--", lw=1.6, label="parent fitness")
|
||||
for rho, c in zip(rhos, colors):
|
||||
g = _agg(bdm[bdm["rho"] == rho], "divergence", "offspring_fitness")
|
||||
ax.plot(g["divergence"], g["mean"], "-o", color=c, lw=2, label=f"hybrid, ρ={rho}")
|
||||
ax.fill_between(g["divergence"], g["mean"] - g["se"], g["mean"] + g["se"], color=c, alpha=0.15)
|
||||
ax.axhline(0, color="#999", lw=0.8, ls=":")
|
||||
ax.set(xlabel="parental divergence (substitutions $d$)", ylabel="fitness",
|
||||
title="Hybrid fitness collapses as lineages diverge\n(compatible → outbreeding depression → inviability)")
|
||||
ax.legend(frameon=False, fontsize=8)
|
||||
|
||||
# Panel B: reproductive-isolation rate vs divergence, per epistasis density.
|
||||
ax = axes[1]
|
||||
for rho, c in zip(rhos, colors):
|
||||
g = _agg(bdm[bdm["rho"] == rho], "divergence", "isolation")
|
||||
ax.plot(g["divergence"], g["mean"], "-o", color=c, lw=2, label=f"ρ={rho}")
|
||||
ax.set(xlabel="parental divergence (substitutions $d$)", ylabel="reproductive isolation\n(P hybrid inviable)",
|
||||
ylim=(-0.02, 1.02),
|
||||
title="The isolation cliff moves to lower divergence\nas epistasis density rises")
|
||||
ax.legend(frameon=False, fontsize=9, title="epistasis density")
|
||||
|
||||
# Panel C: NK epistasis wedge — recombination gain vs ruggedness K.
|
||||
ax = axes[2]
|
||||
g = _agg(nk, "K", "offspring_minus_parent")
|
||||
ax.axhline(0, color="#999", lw=0.8, ls=":")
|
||||
ax.plot(g["K"], g["mean"], "-o", color="#d62728", lw=2)
|
||||
ax.fill_between(g["K"], g["mean"] - g["se"], g["mean"] + g["se"], color="#d62728", alpha=0.15)
|
||||
ax.set(xlabel="landscape ruggedness $K$ (epistasis)", ylabel="recombination gain\n(hybrid − worse parent)",
|
||||
title="Epistasis wedge: recombining adapted parents\nflips from gain to loss as ruggedness grows")
|
||||
|
||||
fig.suptitle("E12 — model speciation: when two diverged models are too incompatible to merge",
|
||||
y=1.02, fontsize=13)
|
||||
fig.tight_layout()
|
||||
savefig(fig, "results/E12", "E12")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
BIN
results/E12/E12.pdf
Normal file
BIN
results/E12/E12.pdf
Normal file
Binary file not shown.
BIN
results/E12/E12.png
Normal file
BIN
results/E12/E12.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 241 KiB |
47
results/E12/README.md
Normal file
47
results/E12/README.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# E12 — Model speciation: when two diverged models are too incompatible to merge
|
||||
|
||||
**Claim tested.** The sexual society (E7–E11) recombines complementary parents. E12 asks the limit:
|
||||
**how far can two lineages diverge before recombination (model merging) stops working?** In biology the
|
||||
answer is *reproductive isolation* via **Bateson–Dobzhansky–Muller incompatibilities** (BDMIs) — alleles
|
||||
benign on their own lineage's background but deleterious *in combination*, which a recombinant inherits
|
||||
untested. A merged model is a single recombinant (an F2-like *hybrid-breakdown / recombination-load*
|
||||
object, not an F1), so the predicted signature as parental divergence grows is
|
||||
**compatible → outbreeding depression → hybrid inviability**, arriving earlier the more epistatic the
|
||||
capability landscape.
|
||||
|
||||
**Setup.** Pure seeded NumPy on the E7–E11 genotype machinery (bitwise-reproducible; no external
|
||||
simulator, whose separate RNG would break that guarantee). Two landscapes:
|
||||
- **BDM** (`configs/layer1/E12.yaml`, headline): an ancestor; two lineages each substitute a *disjoint*
|
||||
set of loci (each parent adaptive, neither carrying an incompatibility); a fraction `ρ` of
|
||||
cross-lineage locus pairs are BDMIs (penalty `s`), biting only when a hybrid inherits *both* derived
|
||||
alleles. Sweep divergence `d` (substitutions) for several `ρ`; `L=20`, 15 reps.
|
||||
- **NK** (`configs/layer1/E12_nk.yaml`): parents are *local optima* (hill-climbed) on a Kauffman NK
|
||||
landscape; sweep ruggedness `K`. The emergent version.
|
||||
|
||||
### Results
|
||||
- **The three-regime collapse (BDM).** Parent fitness rises linearly with divergence; hybrid fitness
|
||||
*tracks it while compatible, then peels off, peaks, and crashes*. At dense epistasis (`ρ=0.5`) hybrids
|
||||
peak near `d≈8` and fall to **−1.0** by `d=20` (below the ancestor = inviable); at sparse epistasis
|
||||
(`ρ=0.1`) there is mild outbreeding depression and **no** isolation.
|
||||
- **The isolation cliff moves with epistasis density.** Reproductive-isolation rate (P hybrid inviable)
|
||||
at `d=20`: `ρ=0.1`→0.00, `ρ=0.25`→0.03, `ρ=0.5`→**0.50** — the cliff arrives at lower divergence the
|
||||
denser the epistasis.
|
||||
- **The Orr–Turelli snowball.** The number of incompatibilities grows ~`(d/2)²` (≈48 at `d=20`, `ρ=0.5`
|
||||
≈ `0.5·10²`), so hybrid fitness falls *super-linearly* — divergence is punished faster than it accrues.
|
||||
- **The epistasis wedge (NK).** At `K=0` (additive) recombination is neutral (no isolation — and the two
|
||||
parents can't even diverge, since there is one peak); as ruggedness rises, recombining two adapted
|
||||
local-optimum parents flips from a gain to **outbreeding depression** (recombination gain 0 → −0.13;
|
||||
OD rate 0 → 0.90 across `K=0→10`). *At matched divergence, mergeability is governed by epistasis* —
|
||||
the axis no divergence-only merge predictor captures.
|
||||
|
||||
### Why it matters / positioning
|
||||
The ML *phenomenon* that "specialization/divergence eventually breaks merging" is known empirically
|
||||
(Pari et al. 2024; Zhou et al. 2026), and part of the apparent incompatibility is a permutation artefact
|
||||
(Git Re-Basin). E12's contribution is the **predictive theory** those lack: the functional form
|
||||
(compatible→OD→inviability), the **snowball** onset, and the **epistasis wedge** — merge failure as a
|
||||
Dobzhansky–Muller phenomenon whose onset is set by divergence *and* epistasis, not divergence alone. The
|
||||
design rule: *before merging, check divergence against the landscape's ruggedness; beyond the cliff,
|
||||
route (allopatry), don't merge.* **Falsifier (not triggered):** no OD/isolation progression as `d` and
|
||||
`ρ` grow — instead the full progression appears, and the additive control shows none. Real-weight
|
||||
confirmation (merging at increasing divergence *with* permutation alignment, isolating the residual
|
||||
epistatic incompatibility) is the flagged next step; here the analytic model is the anchor.
|
||||
14
results/E12/manifest.json
Normal file
14
results/E12/manifest.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"experiment": "E12",
|
||||
"master_seed": 12,
|
||||
"git_commit": "ae1779a9a83fc8f9f36019875efed522bf488b9c",
|
||||
"python": "3.14.5",
|
||||
"libraries": {
|
||||
"numpy": "2.5.0",
|
||||
"scipy": "1.18.0",
|
||||
"pandas": "3.0.3",
|
||||
"pyarrow": "24.0.0"
|
||||
},
|
||||
"rows": 495,
|
||||
"results_sha256": "0af2062c00f606ad1c2e6ca9f5974e981e0c15d699510576f5e7c6c84831f2bb"
|
||||
}
|
||||
33
results/E12/resolved_config.yaml
Normal file
33
results/E12/resolved_config.yaml
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
experiment: E12
|
||||
seed: 12
|
||||
n_replicates: 15
|
||||
source_config:
|
||||
experiment: E12
|
||||
kind: speciation
|
||||
seed: 12
|
||||
n_replicates: 15
|
||||
speciation:
|
||||
landscape: bdm
|
||||
L: 20
|
||||
rho:
|
||||
- 0.1
|
||||
- 0.25
|
||||
- 0.5
|
||||
divergences:
|
||||
- 0
|
||||
- 2
|
||||
- 4
|
||||
- 6
|
||||
- 8
|
||||
- 10
|
||||
- 12
|
||||
- 14
|
||||
- 16
|
||||
- 18
|
||||
- 20
|
||||
s: 1.0
|
||||
beta: 1.0
|
||||
recomb_rate: 0.5
|
||||
n_offspring: 500
|
||||
output:
|
||||
dir: results/E12
|
||||
14
results/E12_nk/manifest.json
Normal file
14
results/E12_nk/manifest.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"experiment": "E12_nk",
|
||||
"master_seed": 12,
|
||||
"git_commit": "ae1779a9a83fc8f9f36019875efed522bf488b9c",
|
||||
"python": "3.14.5",
|
||||
"libraries": {
|
||||
"numpy": "2.5.0",
|
||||
"scipy": "1.18.0",
|
||||
"pandas": "3.0.3",
|
||||
"pyarrow": "24.0.0"
|
||||
},
|
||||
"rows": 90,
|
||||
"results_sha256": "c02706b6be6d2cd463d4af20efc1ff2d9cb2a98a1306d84cb07bbae517587e53"
|
||||
}
|
||||
23
results/E12_nk/resolved_config.yaml
Normal file
23
results/E12_nk/resolved_config.yaml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
experiment: E12_nk
|
||||
seed: 12
|
||||
n_replicates: 15
|
||||
source_config:
|
||||
experiment: E12_nk
|
||||
kind: speciation
|
||||
seed: 12
|
||||
n_replicates: 15
|
||||
speciation:
|
||||
landscape: nk
|
||||
L: 16
|
||||
K:
|
||||
- 0
|
||||
- 2
|
||||
- 4
|
||||
- 6
|
||||
- 8
|
||||
- 10
|
||||
n_pairs: 40
|
||||
recomb_rate: 0.5
|
||||
n_offspring: 200
|
||||
output:
|
||||
dir: results/E12_nk
|
||||
|
|
@ -374,6 +374,9 @@ def run_and_save(config_path: str | Path) -> Path:
|
|||
df = run_directed_sex(cfg)
|
||||
elif kind == "dynamic_society":
|
||||
df = run_dynamic_experiment(cfg) # E11: the dynamic society (C3)
|
||||
elif kind == "speciation":
|
||||
from .speciation import run_speciation # E12: reproductive isolation / merge limits
|
||||
df = run_speciation(cfg, int(cfg["seed"]))
|
||||
else:
|
||||
df = run_experiment(cfg)
|
||||
save_artifacts(cfg, df, out_dir)
|
||||
|
|
|
|||
144
src/knowledge/speciation.py
Normal file
144
src/knowledge/speciation.py
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
"""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)
|
||||
62
tests/test_speciation.py
Normal file
62
tests/test_speciation.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
"""Tests for E12 model speciation — the BDM construction, the snowball, and the isolation falsifiers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from knowledge.speciation import _bdm_point, _nk_point, run_speciation
|
||||
|
||||
|
||||
def test_bdm_parents_carry_no_incompatibility():
|
||||
# The BDM construction's defining property: each derived allele is benign on its OWN parent's
|
||||
# background (disjoint substitutions), so a parent's fitness is purely additive even at rho=1.
|
||||
rng = np.random.default_rng(0)
|
||||
r = _bdm_point(L=20, d=10, rho=1.0, s=5.0, beta=1.0, rate=0.5, n_off=200, rng=rng)
|
||||
assert r["parent_fitness"] == pytest.approx(1.0 * (10 // 2)) # beta * (d/2), no penalty
|
||||
|
||||
|
||||
def test_bdm_snowball_is_superlinear_in_divergence():
|
||||
# Orr-Turelli: # incompatibilities ~ (d/2)^2, so doubling divergence ~quadruples them.
|
||||
def mean_ndmi(d, reps=40):
|
||||
return np.mean([_bdm_point(24, d, 0.5, 1.0, 1.0, 0.5, 50,
|
||||
np.random.default_rng(i))["n_dmi"] for i in range(reps)])
|
||||
ratio = mean_ndmi(12) / max(mean_ndmi(6), 1e-9)
|
||||
assert ratio > 3.0 # ~4x (quadratic), well above linear (2x)
|
||||
|
||||
|
||||
def test_bdm_no_epistasis_means_no_isolation():
|
||||
rng = np.random.default_rng(1)
|
||||
r = _bdm_point(L=20, d=20, rho=0.0, s=1.0, beta=1.0, rate=0.5, n_off=300, rng=rng)
|
||||
assert r["n_dmi"] == 0 and r["isolation"] == 0.0 # no BDMIs -> hybrids always viable
|
||||
|
||||
|
||||
def test_bdm_isolation_rises_with_epistasis_density():
|
||||
# At fixed high divergence, denser epistasis (rho) -> more reproductive isolation.
|
||||
def iso(rho):
|
||||
return np.mean([_bdm_point(20, 20, rho, 1.0, 1.0, 0.5, 300,
|
||||
np.random.default_rng(i))["isolation"] for i in range(8)])
|
||||
assert iso(0.5) > iso(0.1)
|
||||
|
||||
|
||||
def test_nk_additive_landscape_has_no_isolation():
|
||||
# K=0 is a single-peak additive landscape: parents hill-climb to the same optimum (divergence 0),
|
||||
# and recombination cannot produce outbreeding depression.
|
||||
r = _nk_point(L=12, K=0, landscape_seed=3, rate=0.5, n_pairs=20, n_off=50,
|
||||
rng=np.random.default_rng(0))
|
||||
assert r["divergence"] == pytest.approx(0.0) and r["outbreeding_depression"] == pytest.approx(0.0)
|
||||
|
||||
|
||||
def test_nk_ruggedness_increases_outbreeding_depression():
|
||||
def od(K):
|
||||
return _nk_point(12, K, 3, 0.5, 30, 60, np.random.default_rng(0))["outbreeding_depression"]
|
||||
assert od(8) > od(0) # rugged landscapes punish recombination
|
||||
|
||||
|
||||
def test_run_speciation_is_deterministic():
|
||||
cfg = {"seed": 7, "n_replicates": 3,
|
||||
"speciation": {"landscape": "bdm", "L": 12, "rho": [0.3], "divergences": [0, 4, 8],
|
||||
"s": 1.0, "beta": 1.0, "recomb_rate": 0.5, "n_offspring": 80}}
|
||||
a = run_speciation(cfg, 7)
|
||||
b = run_speciation(cfg, 7)
|
||||
assert a.equals(b) # pure function of the resolved config + seed
|
||||
Loading…
Add table
Add a link
Reference in a new issue