MachineSex/figures/plot_E12.py
Giorgio Gilestro db9452c9d4 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>
2026-07-08 22:40:02 +01:00

79 lines
3.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""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()