society: multi-locus recombination frame — the vertical claim (E7/E8)
Enter the Lamarckian society with a robust theoretical frame. The single-
locus, fixed-p* model can only express recovery toward a ceiling; the
society's load-bearing claim is vertical -- capability that EXCEEDS any
component. Generalize knowledge to a distribution over genotypes (L
biallelic loci, K=2^L, additive fitness = # correct loci), reusing all the
K-mode machinery. The one new operator is recombination: free recombination
sends p -> product of per-locus marginals (linkage equilibrium).
E8 (star, kind: society) -- the vertical claim / Fisher-Muller: decorrelated
PARENTS (specialists, expert on their loci, agnostic elsewhere) are
recombined; sexual merge assembles a genotype fitter than any parent,
climbing to the optimum (12/12, a genotype no parent had) as parent count
grows and rho->0, while the best single parent (~8.7) and the mean-mixture
"model soup" (~11.6) plateau below. Reuses make_retention_matrix (locus
mastery replaces tail-item retention).
E7 (kind: genotype_lineage) -- the advantage of sex: a single population
adapts toward the optimum; the sexual lineage adapts faster than asexual
(clonal interference) by keeping loci in linkage equilibrium (LD->0 vs LD
spike). Honest scope: a speed advantage, not a permanent Muller's-ratchet
gap (subtle to force); E8 carries the headline.
Metaphor shift (per GG): the society is sexual reproduction with UNBOUNDED
parents, not teacher->pupil. Teacher->pupil caps at the ceiling; n-parent
recombination is combinatorial and generative, and unlike biology there is
no two-parent limit. Collapse = asexual degradation; the cure = sex. This
unifies E4 (merge != average) + E6 (irreversibility) under evolution-of-sex
theory and reaches ground Riis's single-locus n-grams cannot.
New: knowledge/{genotype,genotype_lineage,society}.py, configs/layer1/{E7,
E8}.yaml, figures/plot_{E7,E8}.py, READMEs, tests/test_genotype.py (+7).
experiment.py dispatch (kind in {genotype_lineage, society}); make layer1
wired. 112 tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
871bc39ec6
commit
62c68d6c8c
22 changed files with 879 additions and 3 deletions
90
tests/test_genotype.py
Normal file
90
tests/test_genotype.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"""Multi-locus / recombination tests (pure NumPy) — the spine of the society frame.
|
||||
|
||||
Cover the genotype algebra (bits, additive fitness, linkage equilibrium), the recombination
|
||||
operators (identity at rate 0, product-of-marginals at rate 1), and the two headline behaviours:
|
||||
E8 — sexual recombination of decorrelated parents *exceeds* the best parent; E7 — a sexual lineage
|
||||
adapts faster than an asexual one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from knowledge.genotype import (
|
||||
additive_fitness, genotype_bits, linkage_equilibrium, locus_marginals, mutate,
|
||||
recombine, recombine_teachers,
|
||||
)
|
||||
from knowledge.genotype_lineage import run_genotype_lineage
|
||||
from knowledge.society import make_specialist, run_society
|
||||
from knowledge.teachers import make_retention_matrix
|
||||
|
||||
|
||||
def test_genotype_bits_and_additive_fitness():
|
||||
bits = genotype_bits(3)
|
||||
assert bits.shape == (8, 3)
|
||||
assert np.array_equal(bits[0], [0, 0, 0]) and np.array_equal(bits[7], [1, 1, 1])
|
||||
f = additive_fitness(3)
|
||||
assert f[0] == 0 and f[7] == 3 and f[1] == 1 # MSB-first: g=1 -> [0,0,1]
|
||||
|
||||
|
||||
def test_linkage_equilibrium_is_product_and_normalised():
|
||||
q = np.array([0.9, 0.5, 0.2])
|
||||
p = linkage_equilibrium(q)
|
||||
assert np.isclose(p.sum(), 1.0)
|
||||
# P(all-correct) = ∏ q; P(all-wrong) = ∏ (1-q)
|
||||
assert np.isclose(p[-1], np.prod(q)) and np.isclose(p[0], np.prod(1 - q))
|
||||
assert np.allclose(locus_marginals(p, 3), q) # marginals round-trip
|
||||
|
||||
|
||||
def test_recombine_identity_and_free():
|
||||
rng = np.random.default_rng(0)
|
||||
p = rng.dirichlet(np.ones(16)) # L=4
|
||||
assert np.array_equal(recombine(p, 4, 0.0), p) # rate 0 = asexual (unchanged)
|
||||
free = recombine(p, 4, 1.0) # rate 1 = product of marginals
|
||||
assert np.allclose(free, linkage_equilibrium(locus_marginals(p, 4)))
|
||||
assert np.isclose(free.sum(), 1.0)
|
||||
|
||||
|
||||
def test_mutate_normalises_and_spreads():
|
||||
L = 4
|
||||
p = np.zeros(1 << L); p[0] = 1.0 # point mass on all-wrong
|
||||
out = mutate(p, L, 0.1)
|
||||
assert np.isclose(out.sum(), 1.0)
|
||||
assert out[0] < 1.0 and (out > 0).sum() > 1 # mass leaks to neighbours
|
||||
|
||||
|
||||
def test_recombine_teachers_assembles_optimum_only_sexually():
|
||||
# Two complementary parents: one masters the low loci, the other the high loci. Sexual merge
|
||||
# assembles the all-correct optimum (a genotype NEITHER parent has as its mode); clonal cannot.
|
||||
L = 8
|
||||
lo = np.array([1, 1, 1, 1, 0, 0, 0, 0], dtype=bool)
|
||||
parents = [make_specialist(lo, 0.9, 0.45), make_specialist(~lo, 0.9, 0.45)]
|
||||
f = additive_fitness(L)
|
||||
best_parent = max(f[int(np.argmax(p))] for p in parents)
|
||||
sexual = f[int(np.argmax(recombine_teachers(parents, L, 1.0)))]
|
||||
clonal = f[int(np.argmax(recombine_teachers(parents, L, 0.0)))]
|
||||
assert sexual == L # sexual assembles the optimum
|
||||
assert sexual > best_parent # ... which exceeds either parent
|
||||
assert clonal <= best_parent + 0 # clonal (soup) does not assemble it
|
||||
|
||||
|
||||
def test_e8_sexual_exceeds_best_parent_and_soup():
|
||||
cfg = {"experiment": "e8t", "seed": 1, "n_replicates": 8,
|
||||
"society": {"L": 10, "q": 0.5, "hi": 0.9, "lo": 0.45},
|
||||
"sweep": [{"param": "K_T", "values": [8]}, {"param": "rho", "values": [0.0]}]}
|
||||
df = run_society(cfg)
|
||||
m = df.mean(numeric_only=True)
|
||||
assert m["sexual"] > m["best_parent"] + 1.5 # clearly exceeds the best parent
|
||||
assert m["sexual"] >= m["average"] # and is at least as good as the soup
|
||||
|
||||
|
||||
def test_e7_sexual_adapts_at_least_as_fast():
|
||||
base = {"genotype": {"L": 10, "n": 150, "mu": 0.02, "base": 1.3, "init": "wrong"},
|
||||
"generations": 25}
|
||||
asex = run_genotype_lineage({**base, "genotype": {**base["genotype"], "recomb_rate": 0.0}}, 0)
|
||||
sex = run_genotype_lineage({**base, "genotype": {**base["genotype"], "recomb_rate": 1.0}}, 0)
|
||||
mid = 12
|
||||
a = asex[asex["generation"] == mid]["mean_fitness"].iloc[0]
|
||||
s = sex[sex["generation"] == mid]["mean_fitness"].iloc[0]
|
||||
assert s >= a - 1e-9 # sexual adapts at least as fast mid-run
|
||||
assert sex["ld"].max() < asex["ld"].max() # ... by keeping loci in linkage equilibrium
|
||||
Loading…
Add table
Add a link
Reference in a new issue