E14: mating systems — monogamy vs promiscuity (mate-pool breadth)

A new analytic experiment on an orthogonal evolution-of-sex axis: not the
recombination RATE (E9) but the population's mating STRUCTURE. Agents on a
ring recombine with a second parent drawn from a window of breadth b
(b->0 monogamous/isolation-by-distance, b=1 promiscuous/panmictic), under
local selection, swept against NK ruggedness K.

Finding: the optimal mate-pool breadth SHRINKS as skills get more
entangled. Wide/promiscuous merging wins the champion on additive
landscapes (K<=3, b=0.6), but on rugged ones (K>=6) it prematurely
converges to a worse champion and an intermediate breadth (b~0.35) wins;
pure monogamy over-fragments. Throughout, promiscuity monotonically lifts
the population MEAN but destroys diversity and parallel exploration. The
design rule extends E9: merge widely for additive skills, keep
island-structured sub-populations for entangled ones — a merging-native
axis the panmixia-assuming literature lacks.

- src/knowledge/mating_system.py + experiment.py dispatch (kind: mating_system)
- configs/layer1/E14.yaml (breadth x K sweep, 20 reps, bitwise-reproducible)
- figures/plot_E14.py; results/E14/ (figure, README, manifest, resolved config)
- tests/test_mating_system.py (+5, 147 green); make layer1 wired
- folded into both papers (full + accessible) as the third §5 result

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Giorgio Gilestro 2026-07-09 12:38:50 +01:00
parent 9fea375ff8
commit f5f68f5249
13 changed files with 472 additions and 2 deletions

View file

@ -16,7 +16,7 @@ test: ## correctness tests + scientific-validation tests (the spine
uv run pytest uv run pytest
layer1: ## run experiments E1-E6 + the learning-kernel bridge (analytic) layer1: ## run experiments E1-E6 + the learning-kernel bridge (analytic)
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 for e in E1 E2 E3 E4 E5 E6 E7 E8 E9 E10 E11 E12 E12_nk E14 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 MNIST/torchvision tiers) neural: ## run Layer 1.5 synthetic neural experiments (excludes the MNIST/torchvision tiers)
for c in configs/neural/*.yaml; do case "$$c" in *mnist*|*speciation_real*) ;; \ for c in configs/neural/*.yaml; do case "$$c" in *mnist*|*speciation_real*) ;; \

34
configs/layer1/E14.yaml Normal file
View file

@ -0,0 +1,34 @@
experiment: E14
kind: mating_system
seed: 20260709
n_replicates: 20
# (Mating systems — monogamy vs promiscuity): a finite population of genotypes evolves on a Kauffman
# NK landscape, recombining sexually, but the MATE-POOL BREADTH is swept. Agents sit on a ring; an
# offspring's second parent is drawn from a window of half-width ~ breadth*N/2 around the focal parent,
# and selection is LOCAL (offspring competes only against the incumbent at its ring position). breadth
# -> 0 is monogamous / structured (local mating, isolation by distance); breadth = 1 is promiscuous /
# panmictic (mate with anyone). Crossed with ruggedness K, this is the mating-system image of the E9
# design rule. Expect: on smooth landscapes (K low) promiscuity maximises the best fitness (spread the
# single good direction fastest); as ruggedness rises the OPTIMAL breadth SHRINKS toward an intermediate
# value (full promiscuity prematurely converges below it); and diversity + occupied local optima are
# monotonically destroyed by breadth at every K, most severely on rugged landscapes. Falsifier: the best
# breadth is independent of K (no crossover), or promiscuity is best at every ruggedness.
mating:
L: 12 # loci (genotype space 2^L)
N: 48 # population size (ring positions)
breadth: 1.0 # mate-pool breadth in [0,1] (overwritten by the sweep)
K: 0 # landscape ruggedness / epistasis (overwritten by the sweep)
recomb_rate: 0.5 # per-gap crossover rate (near-free reassortment within a mating)
mu: 0.003 # per-locus mutation rate
generations: 60
sweep:
- param: mating.K
values: [0, 3, 6, 10]
- param: mating.breadth
values: [0.03, 0.08, 0.17, 0.35, 0.6, 1.0]
output: {dir: results/E14}

68
figures/plot_E14.py Normal file
View file

@ -0,0 +1,68 @@
"""E14 figure — mating systems: monogamy vs promiscuity (mate-pool breadth) across ruggedness.
Three panels, each vs mate-pool breadth (log x: 0.03 = monogamous/structured -> 1.0 = promiscuous/
panmictic), one line per landscape ruggedness K:
(A) best fitness / global optimum the *champion*. On smooth landscapes (low K) it is maximised by
wide breadth; as ruggedness rises the peak shifts to an INTERMEDIATE breadth (full promiscuity
prematurely converges below it) the mating-system image of E9's "optimal recombination rate
shrinks with ruggedness".
(B) mean fitness / global optimum the *typical* individual. Monotonically favoured by breadth at
every K: panmixia lifts the whole population toward a good consensus.
(C) diversity (mean normalised pairwise Hamming) monotonically DESTROYED by breadth at every K
(promiscuity homogenises), the reservoir largest under monogamy and on rugged landscapes.
The tension between (A)/(C) is the result: promiscuity maximises the typical model and kills diversity;
on rugged landscapes the best model needs preserved diversity, so an intermediate breadth wins.
Usage: python figures/plot_E14.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import matplotlib.pyplot as plt
sys.path.insert(0, str(Path(__file__).parent))
from _figlib import load_bundle, savefig # noqa: E402
def main() -> None:
df, _ = load_bundle("results/E14")
last = df[df["generation"] == df["generation"].max()].copy()
last["best_n"] = last["best_fitness"] / last["global_opt"]
last["mean_n"] = last["mean_fitness"] / last["global_opt"]
Ks = sorted(last["K"].unique())
cmap = plt.get_cmap("viridis")
colors = {K: cmap(i / max(1, len(Ks) - 1)) for i, K in enumerate(Ks)}
fig, axes = plt.subplots(1, 3, figsize=(16, 5))
panels = [
("best_n", "best fitness / global optimum", "(A) the champion: best model in the population",
"best fitness peaks at INTERMEDIATE breadth\non rugged landscapes (the peak shifts left as K rises)"),
("mean_n", "mean fitness / global optimum", "(B) the typical model: population mean",
"monotonically favoured by wide breadth\n(panmixia lifts the whole population)"),
("diversity", "diversity (mean pairwise Hamming)", "(C) standing diversity",
"monotonically destroyed by breadth\n(promiscuity homogenises; monogamy preserves)"),
]
for ax, (col, ylab, title, subtitle) in zip(axes, panels):
for K in Ks:
g = (last[last["K"] == K].groupby("breadth")[col]
.agg(["mean", "sem"]).reset_index())
ax.errorbar(g["breadth"], g["mean"], yerr=1.96 * g["sem"].fillna(0.0),
marker="o", lw=1.8, capsize=2, color=colors[K], label=f"K={K}")
ax.set_xscale("log")
ax.set(xlabel="mate-pool breadth (monogamous ← → promiscuous)", ylabel=ylab)
ax.set_title(f"{title}\n{subtitle}", fontsize=9)
ax.legend(title="ruggedness", frameon=False, fontsize=8)
fig.suptitle("E14 — monogamy vs promiscuity: the best mate-pool breadth shrinks as skills get more entangled",
y=1.02, fontsize=13)
fig.tight_layout()
savefig(fig, "results/E14", "E14")
if __name__ == "__main__":
main()

View file

@ -187,6 +187,18 @@ only worked as a whole. Biologists call this **outbreeding depression**, and we
gets. The design rule: *combine freely when skills are independent; combine sparingly and carefully when gets. The design rule: *combine freely when skills are independent; combine sparingly and carefully when
they're tangled.* they're tangled.*
**How *widely* you mate matters too.** That last point was about *how much* to mix; a separate knob is
*who mixes with whom*. **Monogamy** = each model only ever combines within a small, fixed circle;
**promiscuity** = any model can combine with any other. Almost all model-merging today is promiscuous by
default — throw everything in one pot. But there's a catch: wide mixing spreads good traits fast, but it
also makes the whole population converge to the *same thing*, killing variety. Narrow, local mixing keeps
separate sub-groups exploring different solutions. We tested this against tangledness, and the best answer
*moves*: on simple (independent-skill) problems, wide promiscuous merging is best; but the more tangled
the skills, the more you want to *narrow* it — full promiscuity converges too fast onto one mediocre
solution and finds a *worse* champion, while keeping structured sub-groups preserves the variety a hard
problem needs. So the rule extends: *merge widely for independent skills; keep separate sub-populations
("island" merging) for tangled ones.*
**AI can do sex better than biology can.** Biology is stuck with two parents, mating more or less at **AI can do sex better than biology can.** Biology is stuck with two parents, mating more or less at
random, and can't inspect a child before it's born. AI has none of those limits. It can combine **many** random, and can't inspect a child before it's born. AI has none of those limits. It can combine **many**
parents at once; it can **choose** which parents to combine, for complementary skills; and it can parents at once; it can **choose** which parents to combine, for complementary skills; and it can
@ -449,6 +461,12 @@ real language models. Here's the shape of the evidence (a separate document has
level off below. On *tangled* problems, blind combining instead produces below-parent children level off below. On *tangled* problems, blind combining instead produces below-parent children
(outbreeding depression) — and *directed* combining (choose mates, screen offspring, many parents) (outbreeding depression) — and *directed* combining (choose mates, screen offspring, many parents)
reliably fixes it. reliably fixes it.
- **Monogamy vs promiscuity.** Sweeping how *widely* models merge — from local/monogamous to
everyone-with-everyone/promiscuous — against how tangled the skills are, the best breadth **shrinks as
the skills get more tangled**: wide promiscuous merging wins when skills are independent, but on tangled
problems it converges too fast onto one mediocre solution and finds a worse champion, so keeping
structured sub-populations wins. (Promiscuity always lifts the *typical* model but always destroys
variety.) A merging design knob the field, which throws everything in one pot, doesn't currently have.
- **The combining claims, in real language models — with a sharp condition.** Merging fine-tuned Qwen - **The combining claims, in real language models — with a sharp condition.** Merging fine-tuned Qwen
models (up to 7B on a GPU cluster) produces a generalist that beats every specialist parent; and models (up to 7B on a GPU cluster) produces a generalist that beats every specialist parent; and
keeping parents separate and *routing*, or *breeding and screening* offspring, beats the plain average keeping parents separate and *routing*, or *breeding and screening* offspring, beats the plain average

View file

@ -270,7 +270,7 @@ beats the average in exact proportion to how far the average is from the best at
that sharpens rather than weakens the claim, and that a practitioner needs before spending compute on that sharpens rather than weakens the claim, and that a practitioner needs before spending compute on
the fancier operator. the fancier operator.
Two caveats keep this honest, and both are results, not hand-waving. Three results keep this honest, and all are results, not hand-waving.
*Sex can backfire.* When the parents' skills are not cleanly separable but **entangled** — when the *Sex can backfire.* When the parents' skills are not cleanly separable but **entangled** — when the
value of one capability depends on which others are present (geneticists call this **epistasis**) — value of one capability depends on which others are present (geneticists call this **epistasis**) —
@ -280,6 +280,22 @@ reproduce it: on "rugged" (highly entangled) problems, naive merging drops offsp
parents, and the more you mix the worse it gets. The design rule that falls out is simple: *merge parents, and the more you mix the worse it gets. The design rule that falls out is simple: *merge
freely when skills are complementary; merge sparingly, and carefully, when they are entangled.* freely when skills are complementary; merge sparingly, and carefully, when they are entangled.*
*The mating system matters too — not just who mates, but how widely.* The result above is about the
recombination *rate*; a separate knob is the population's **mating structure** — whether reproduction is
**monogamous** (each model recombines within a narrow, local circle) or **promiscuous** (mates drawn
freely from the whole population). Almost all model-merging implicitly assumes promiscuity — fuse
everything, or route over one flat pool — but population genetics says the breadth of gene flow is itself
consequential, because wide flow spreads good variants fast while **homogenising** the population, and
narrow flow preserves the distinct sub-populations needed to explore several solutions at once (Wright's
*shifting balance*). We sweep exactly this breadth against landscape ruggedness, and the optimum moves:
on smooth (additive) landscapes wide, promiscuous mating is best (spread the one good direction fastest),
but as the landscape gets rugged the best breadth **shrinks to an intermediate value** — full promiscuity
prematurely converges onto one basin and finds a *worse* champion, while pure monogamy over-fragments.
Throughout, wide mating lifts the *typical* model but monotonically **destroys diversity** — so on rugged
problems, where the best model needs preserved diversity to be found, structured (partly monogamous)
merging wins. The design rule extends the one above: *merge widely when skills are additive; keep
structured sub-populations — island-style merging — when skills are rugged.* (Figure: `results/E14/E14.png`.)
*AI can do sex better than biology can.* Biology is stuck with two parents, mating roughly at random, *AI can do sex better than biology can.* Biology is stuck with two parents, mating roughly at random,
and cannot inspect an offspring before it is born. An AI has none of those limits. It can recombine and cannot inspect an offspring before it is born. An AI has none of those limits. It can recombine
**many** parents at once; it can **choose** which parents to combine, for complementarity; and it can **many** parents at once; it can **choose** which parents to combine, for complementarity; and it can
@ -599,6 +615,13 @@ shape.)
produces below-parent offspring (outbreeding depression) — and *directed* recombination (choose mates, produces below-parent offspring (outbreeding depression) — and *directed* recombination (choose mates,
screen offspring, unbounded parents) reliably fixes it. This is the concrete evidence for the paper's screen offspring, unbounded parents) reliably fixes it. This is the concrete evidence for the paper's
central reframing. central reframing.
- *The mating system, not just the mating.* Sweeping how *widely* models recombine — from monogamous
(local, structured) to promiscuous (panmictic) — against landscape ruggedness, the best breadth
**shrinks as skills get more entangled**: wide, promiscuous merging wins on additive landscapes, but on
rugged ones it prematurely converges to a worse champion and an intermediate, structured breadth wins,
because promiscuity monotonically destroys the diversity a rugged search needs. A merging-native design
axis — *merge widely for additive skills, keep island-structured sub-populations for entangled ones*
that the model-merging literature, which assumes panmixia, does not have.
- *The recombination claims, in real language models — with a sharp condition.* Merging LoRA-specialised - *The recombination claims, in real language models — with a sharp condition.* Merging LoRA-specialised
Qwen models (up to 7B on a GPU cluster) produces a generalist that beats every specialist parent Qwen models (up to 7B on a GPU cluster) produces a generalist that beats every specialist parent
(FisherMuller, for real); and keeping parents intact and *routing*, or *breeding and screening* (FisherMuller, for real); and keeping parents intact and *routing*, or *breeding and screening*

BIN
results/E14/E14.pdf Normal file

Binary file not shown.

BIN
results/E14/E14.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

68
results/E14/README.md Normal file
View file

@ -0,0 +1,68 @@
# E14 — Mating systems: monogamy vs promiscuity (mate-pool breadth)
**Claim tested.** The society experiments (E8E11) assumed **panmixia** — every offspring recombined
from parents sampled across the whole population. Biology's mating systems instead span a continuum
from **monogamy** (mating within a narrow, local circle) to **promiscuity** (mates drawn freely from
everyone), and population genetics says the choice matters: wide gene flow spreads a good allele fast
but **homogenises** the population, while restricted gene flow (population structure / *isolation by
distance*) keeps demes distinct so several fitness peaks can be explored in parallel (Wright's shifting
balance). E14 asks how the best mating system depends on how **entangled** the skills are.
**Setup.** A finite population of `N=48` genotypes (`L=12` biallelic loci) evolves on a Kauffman **NK**
landscape (ruggedness `K`). Agents sit on a **ring**; an offspring's second parent is drawn from a
window of half-width `≈ breadth·N/2` around the focal parent, so **mate-pool breadth** `b` is a single
scalar: `b→0` = monogamous / structured (local mating), `b=1` = promiscuous / panmictic. Selection is
**local** — an offspring replaces the incumbent at its own ring position only if strictly fitter — so
restricted mating can actually sustain distinct demes instead of being washed out. Sweep `b ∈ {0.03,
0.08, 0.17, 0.35, 0.6, 1.0}` × `K ∈ {0, 3, 6, 10}`, 60 generations, 20 replicates, `μ=0.003`,
crossover rate 0.5. Bitwise-reproducible from the master seed.
### Results — the best breadth shrinks as the landscape gets more rugged
`best_fitness / global_opt` (the *champion*), mean over 20 reps; **bold = best breadth at that K**:
| K \ breadth | 0.03 | 0.08 | 0.17 | 0.35 | 0.60 | 1.00 |
|---|---|---|---|---|---|---|
| **0** (additive) | 1.000 | 1.000 | 1.000 | 1.000 | 1.000 | 1.000 |
| **3** (mild) | 0.995 | 0.993 | 0.997 | 0.993 | **0.9997** | 0.993 |
| **6** (rugged) | 0.986 | 0.972 | 0.983 | **0.989** | 0.984 | 0.982 |
| **10** (very rugged) | 0.961 | 0.968 | 0.967 | **0.980** | 0.964 | 0.965 |
- **K=0** saturates: an additive (single-peak) landscape is solved by everyone regardless of mating, so
the champion metric can't discriminate (it only shows up in diversity, below).
- **K=3**: the optimum is at **wide** breadth (`b=0.6`) — near-promiscuous mating maximises the champion
when the landscape is mild.
- **K=6, K=10**: the optimum moves to an **intermediate** breadth (`b=0.35`), and *full promiscuity*
falls below it. Wide mating **prematurely converges** on rugged landscapes; pure monogamy
over-fragments (too little gene flow to combine complementary basins). The best of both is
intermediate structure — the mating-system image of E9's "optimal recombination rate shrinks with
ruggedness."
### Results — the diversity/mean tension that drives it
Two monotone effects, opposite in sign, at **every** K (mean over reps at K=10):
| breadth | 0.03 | 0.08 | 0.17 | 0.35 | 0.60 | 1.00 |
|---|---|---|---|---|---|---|
| mean fitness / opt | 0.890 | 0.914 | 0.926 | 0.941 | 0.941 | 0.943 |
| diversity (pairwise Hamming) | 0.441 | 0.413 | 0.384 | 0.346 | 0.265 | 0.282 |
| distinct local optima occupied | 11.0 | 8.6 | 7.4 | 7.3 | 7.2 | 6.9 |
- **Mean fitness** rises monotonically with breadth: panmixia lifts the *typical* individual toward a
good consensus fastest.
- **Diversity** and **occupied peaks** fall monotonically with breadth: promiscuity **homogenises**;
monogamy preserves the standing variation (and the parallel exploration of distinct basins) — most
strongly on rugged landscapes.
So promiscuity maximises the *typical* model and destroys diversity; on a rugged landscape the *best*
model needs that preserved diversity, so an intermediate breadth wins the champion even though the wide
breadth still wins the mean. (Panel A = champion, Panel B = mean, Panel C = diversity.)
### Positioning
This is the population-**structure** axis the model-merging literature does not have. Merging/soup work
implicitly assumes panmixia (fuse everything, or route among a flat pool); E14 says the *breadth* of who
merges with whom is itself a design knob, and its optimum is set by the entanglement of the skills:
**merge widely when skills are additive; keep sub-populations (structured / island merging) when skills
are rugged and diversity must be preserved to explore and later combine basins.** It complements E9
(recombination *rate*) and E11 (diversity is load-bearing) on a new, orthogonal axis. **Falsifier (not
triggered):** the best breadth independent of `K` (no crossover), or promiscuity best at every
ruggedness — instead the optimal breadth shifts from `0.6` (K=3) to `0.35` (K≥6), and diversity is
monotonically lost to breadth throughout.

14
results/E14/manifest.json Normal file
View file

@ -0,0 +1,14 @@
{
"experiment": "E14",
"master_seed": 20260709,
"git_commit": "9fea375ff89f2b88a4deb5881894087555558863",
"python": "3.14.5",
"libraries": {
"numpy": "2.5.0",
"scipy": "1.18.0",
"pandas": "3.0.3",
"pyarrow": "24.0.0"
},
"rows": 29280,
"results_sha256": "5766302157f44c0f27228f361b43c06b0651697a7dcc38848eb1d94d0502faa6"
}

View file

@ -0,0 +1,33 @@
experiment: E14
seed: 20260709
n_replicates: 20
source_config:
experiment: E14
kind: mating_system
seed: 20260709
n_replicates: 20
mating:
L: 12
N: 48
breadth: 1.0
K: 0
recomb_rate: 0.5
mu: 0.003
generations: 60
sweep:
- param: mating.K
values:
- 0
- 3
- 6
- 10
- param: mating.breadth
values:
- 0.03
- 0.08
- 0.17
- 0.35
- 0.6
- 1.0
output:
dir: results/E14

View file

@ -207,6 +207,46 @@ def run_dynamic_experiment(cfg: dict) -> pd.DataFrame:
return out return out
_MATING_KEYS = ("mating", "generations")
def run_mating_experiment(cfg: dict) -> pd.DataFrame:
"""Run the mating-system experiment across a ``breadth`` x ``K`` sweep x replicates (E14).
Mirrors ``run_dynamic_experiment``: assembles the base from the ``mating``/``generations`` blocks,
takes the Cartesian product of the swept params (typically ``mating.breadth`` and ``mating.K``,
plain dotted paths), and calls ``run_mating_system`` per grid point x replicate with paired seeds.
"""
from .mating_system import run_mating_system
base = {k: copy.deepcopy(cfg[k]) for k in _MATING_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_mating_system(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
def run_coverage(cfg: dict) -> pd.DataFrame: def run_coverage(cfg: dict) -> pd.DataFrame:
"""E4 runner: multi-teacher recombination coverage (blueprint 2.5-E4 / 2.7.1). """E4 runner: multi-teacher recombination coverage (blueprint 2.5-E4 / 2.7.1).
@ -377,6 +417,8 @@ def run_and_save(config_path: str | Path) -> Path:
elif kind == "speciation": elif kind == "speciation":
from .speciation import run_speciation # E12: reproductive isolation / merge limits from .speciation import run_speciation # E12: reproductive isolation / merge limits
df = run_speciation(cfg, int(cfg["seed"])) df = run_speciation(cfg, int(cfg["seed"]))
elif kind == "mating_system":
df = run_mating_experiment(cfg) # E14: monogamy vs promiscuity (mate-pool breadth)
else: else:
df = run_experiment(cfg) df = run_experiment(cfg)
save_artifacts(cfg, df, out_dir) save_artifacts(cfg, df, out_dir)

View file

@ -0,0 +1,113 @@
"""Mating systems — monogamy vs promiscuity as mate-pool breadth (E14).
The society experiments (E8E11) assumed **panmixia**: every offspring is recombined from parents
sampled across the *whole* population. But biology's mating systems span a continuum from **monogamy**
(each individual mates within a narrow, local circle) to **promiscuity** (mates drawn freely from the
whole population), and population genetics says the choice is consequential. Wide gene flow spreads a
beneficial allele across the population fast but **homogenises** it; restricted gene flow (population
structure / *isolation by distance*) keeps demes distinct so several fitness peaks can be explored in
parallel Wright's *shifting balance*.
Here the mating system is one scalar: mate-pool **breadth** ``b``. Agents sit on a ring; an offspring's
second parent is drawn from a window of half-width `` b·N/2`` around the focal parent. ``b0`` =
**monogamous / structured** (local mating, isolation by distance); ``b=1`` = **promiscuous / panmictic**
(mate with anyone). Selection is **local** an offspring competes only against the incumbent at its own
ring position so restricted mating can actually sustain distinct demes rather than being washed out by
global truncation.
Crossed with landscape ruggedness ``K`` (Kauffman NK epistasis), this is the mating-system image of the
E9 design rule. Prediction: **promiscuity wins on additive/smooth landscapes** (one peak spread the
single good direction fastest), while **structured/monogamous mating wins on rugged/epistatic
landscapes** (many peaks diversity must be preserved to explore basins that recombination can later
combine). Falsifier: the best mating system is independent of ruggedness (no crossover).
"""
from __future__ import annotations
from typing import Any, Mapping
import numpy as np
import pandas as pd
from .genotype import bits_to_index, crossover, hill_climb, nk_fitness
def _diversity(pop_bits: np.ndarray) -> float:
"""Mean normalised pairwise Hamming distance over the population (0 = clonal, 1 = maximal)."""
N, L = pop_bits.shape
if N < 2:
return 0.0
match = (pop_bits[:, None, :] == pop_bits[None, :, :]).sum(axis=2) # (N, N) locus agreements
ham = L - match # pairwise Hamming distances
return float(ham.sum() / (N * (N - 1)) / L) # mean over ordered pairs, /L
def _distinct_peaks(pop_bits: np.ndarray, fitness: np.ndarray, L: int) -> int:
"""Number of distinct local optima the population occupies (hill-climb each agent to its basin)."""
return len({hill_climb(fitness, L, bits_to_index(b)) for b in pop_bits})
def run_mating_system(cfg: Mapping[str, Any], seed: int) -> pd.DataFrame:
"""Run one mating-system lineage; return per-generation metrics.
Args:
cfg (Mapping): Config with a ``mating`` block (``L`` loci, ``K`` landscape ruggedness, ``N``
population, ``breadth`` mate-pool breadth ``b[0,1]``, ``recomb_rate`` crossover rate,
``mu`` per-locus mutation) and ``generations``.
seed (int): Replicate seed; the landscape and the run are a pure function of it.
Returns:
pd.DataFrame: One row per generation with ``best_fitness`` (real), ``mean_fitness`` (real),
``diversity`` (mean normalised pairwise Hamming), ``distinct_peaks`` (local optima occupied),
and ``global_opt``.
"""
ms = cfg["mating"]
L, K, N = int(ms["L"]), int(ms["K"]), int(ms["N"])
b = float(ms.get("breadth", 1.0))
rate = float(ms.get("recomb_rate", 0.5))
mu = float(ms.get("mu", 0.01))
generations = int(cfg.get("generations", 100))
fitness = nk_fitness(L, K, seed) # reality
global_opt = float(fitness.max())
rng = np.random.default_rng(seed)
# Population on a ring: position i is fixed ring slot i (so structure persists across generations).
pop = rng.integers(0, 2, size=(N, L)).astype(np.int8)
half = max(1, int(round(b * N / 2))) # mate-window half-width; b=1 -> whole ring
def fit_of(bits: np.ndarray) -> float:
return float(fitness[bits_to_index(bits)])
rows: list[dict] = []
def record(t: int) -> None:
tf = np.array([fit_of(g) for g in pop])
rows.append({
"generation": t,
"best_fitness": float(tf.max()),
"mean_fitness": float(tf.mean()),
"diversity": _diversity(pop),
"distinct_peaks": _distinct_peaks(pop, fitness, L),
"global_opt": global_opt,
})
record(0)
for t in range(1, generations + 1):
new = pop.copy()
for i in range(N):
# Second parent from a ring window of half-width `half` around i (isolation by distance).
offset = 0
while offset == 0:
offset = int(rng.integers(-half, half + 1))
j = (i + offset) % N
child = crossover(np.stack([pop[i], pop[j]]), rate, rng)
flip = rng.random(L) < mu
child = np.where(flip, 1 - child, child).astype(pop.dtype)
# Local selection: the child replaces the incumbent at i only if strictly fitter.
if fit_of(child) > fit_of(pop[i]):
new[i] = child
pop = new
record(t)
return pd.DataFrame(rows)

View file

@ -0,0 +1,57 @@
"""Mating-system tests (pure NumPy) — monogamy vs promiscuity as mate-pool breadth (E14).
Cover the diversity helpers and the two load-bearing behaviours: promiscuity (wide mate-pool breadth)
monotonically destroys standing diversity, and the run is deterministic and well-formed. The full
ruggedness crossover (intermediate breadth wins the champion on rugged landscapes) is a swept,
multi-replicate result asserted only in aggregate here to keep the test fast.
"""
from __future__ import annotations
import numpy as np
from knowledge.mating_system import _distinct_peaks, _diversity, run_mating_system
from knowledge.genotype import nk_fitness
def _run(breadth: float, K: int = 6, seed: int = 0, gens: int = 40, N: int = 32, L: int = 10):
cfg = {"mating": {"L": L, "N": N, "breadth": breadth, "K": K, "recomb_rate": 0.5, "mu": 0.005},
"generations": gens}
return run_mating_system(cfg, seed=seed)
def test_diversity_zero_for_clones_and_positive_for_spread():
clones = np.ones((5, 8), dtype=np.int8)
assert _diversity(clones) == 0.0 # identical -> no diversity
spread = np.array([[0] * 8, [1] * 8], dtype=np.int8)
assert np.isclose(_diversity(spread), 1.0) # opposite -> maximal diversity
def test_distinct_peaks_counts_basins():
fitness = nk_fitness(6, 2, seed=0)
pop = np.zeros((4, 6), dtype=np.int8) # all identical -> one basin
assert _distinct_peaks(pop, fitness, 6) == 1
def test_schema_and_bounds():
df = _run(0.5)
for col in ["generation", "best_fitness", "mean_fitness", "diversity", "distinct_peaks", "global_opt"]:
assert col in df.columns
assert (df["best_fitness"] <= df["global_opt"] + 1e-9).all() # nothing beats reality's optimum
assert (df["diversity"] >= 0).all() and (df["diversity"] <= 1).all()
assert df["distinct_peaks"].iloc[-1] >= 1
def test_deterministic_given_seed():
a = _run(0.3, seed=7)
b = _run(0.3, seed=7)
assert np.allclose(a["best_fitness"], b["best_fitness"]) # pure function of the seed
def test_promiscuity_destroys_diversity():
# Averaged over replicates, wide mate-pool breadth (promiscuity) leaves LESS standing diversity than
# narrow breadth (monogamy) — the homogenisation effect, robust on a rugged landscape.
def final_div(b):
return np.mean([_run(b, K=8, seed=s, gens=40, N=32, L=10)["diversity"].iloc[-1]
for s in range(6)])
assert final_div(1.0) < final_div(0.05) # panmixia < isolation-by-distance