diff --git a/CLAUDE.md b/CLAUDE.md index 9fb31c3..ed43ef3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,6 +84,8 @@ E4's whole purpose is to isolate the effect of teacher **decorrelation ρ**, so **Finding (2026-07-05, E9/E10 — the sexual-transmission model made rigorous: when sex helps, and directed sex).** Deepening the sexual metaphor (GG excited; wanted it robust before the full society). Added a **Kauffman NK landscape** (`genotype.nk_fitness`, tunable ruggedness `K`), finite **crossover** (`genotype.crossover`, n-parent, per-gap recombination rate), and **hill-climb** (parents = local optima = "trained models"). **E9 (`kind: recomb_landscape`) — landscape robustness / "why sex?":** E8's dramatic transgression used an *additive* landscape; on rugged (epistatic) landscapes, blindly recombining local optima causes **outbreeding depression** — mean offspring fall *below* the parents, worse with ruggedness AND recombination rate (`K=8`, free recomb: ≈ −0.23), and the **optimal recombination rate shrinks as ruggedness grows**. Design rule: *merge freely when skills are complementary/additive; sparingly + with selection when entangled.* **E10 (`kind: directed_sex`) — directed sex beats biological sex (the AI superpower):** biology is stuck with 2 random-mating parents and no offspring preview; an AI can **choose complementary mates + evaluate many recombinant offspring + keep the fittest + use unbounded parents** (iterated recombine-then-select). Result: random ("biological") sex craters with ruggedness (0.66→0.51), while **directed sex tracks/exceeds the best parent at every ruggedness** — converting the outbreeding-depression catastrophe into a win. This is the practical, distinctly-AI payoff and has no biological analog. `configs/layer1/{E9,E10}.yaml`, `plot_{E9,E10}.py`, READMEs, +5 tests (117 green). Complete sexual-transmission picture: **dramatic super-parent offspring when skills are complementary (E8); outbreeding-depression risk when entangled (E9); directed sex resolves the risk (E10).** +**Finding (2026-07-05, E11 — the dynamic Lamarckian society: the vertical claim / C3, realized).** The culmination: a finite population of `N` agents (genotypes, `L` loci) evolves on a rugged NK landscape that *is* reality (`knowledge/dynamic_society.py`), composing the four operators the whole study built toward — grounding, directed recombination (sex), quality-diversity selection, mutation. Grounding is made load-bearing via the **consensus-conformity (self-consumption)** mechanism (GG decision): selection acts on `g·true_fitness + (1−g)·conformity` (conformity = agreement with the population's own consensus), so `g=0` optimises fitting-the-crowd rather than reality. **4-arm ablation (12 reps), each breaking distinctly, only the full society climbing (global_opt≈0.79):** `full` 0.78 (climbs to the optimum, diversity maintained longest) · `no_sex` 0.77 (can't recombine to escape local optima) · `no_diversity`/greedy 0.74 (collapses diversity fastest, stuck at a worse local optimum) · **`no_grounding` 0.48 (self-consumption collapse to an unfit consensus** — trains on the crowd, regresses to a confident-but-wrong mean; conformity−true gap ≈0.5). This integrates E1–E6 + the kernel + E7–E10 into one system and shows the society needs **all** of grounding + directed sex + diversity: on a rugged landscape you need diversity to explore basins, sex to recombine them, grounding to select on reality — remove any and you fail differently. `configs/layer1/E11.yaml`, `plot_E11.py`, README, +5 tests (122 green). **This closes the C3 vertical claim analytically** (the LLM rung remains the eventual empirical instantiation). + ## Build order (blueprint §7) — respect the gate 1. Scaffold: repo layout (§5), container, pytest skeleton, config system, seeding utils. `make test` green. diff --git a/Makefile b/Makefile index 41d3ab5..02034e7 100644 --- a/Makefile +++ b/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 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 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*) ;; \ diff --git a/configs/layer1/E11.yaml b/configs/layer1/E11.yaml new file mode 100644 index 0000000..9caf284 --- /dev/null +++ b/configs/layer1/E11.yaml @@ -0,0 +1,40 @@ +experiment: E11 +kind: dynamic_society +seed: 20260705 +n_replicates: 12 + +# (The dynamic Lamarckian society — the vertical claim, C3): a finite population of agents (genotypes) +# evolves on a rugged NK fitness landscape that IS reality. The full society composes the four +# operators the whole study built toward — grounding, directed recombination (sex), quality-diversity +# selection, and mutation — and a 4-arm ablation shows each is load-bearing. Grounding is made load- +# bearing via the consensus-conformity (self-consumption) mechanism: selection acts on +# g*true_fitness + (1-g)*conformity, so at g=0 the society optimises agreement with its own majority +# rather than reality and drifts to a fit-looking but actually-poor consensus. Expect: FULL climbs to +# near the global optimum while maintaining diversity longest; NO_GROUNDING collapses to the unfit +# consensus; NO_SEX plateaus (can't recombine to escape local optima); NO_DIVERSITY (greedy) collapses +# diversity fast and stalls at a worse local optimum. Falsifier: an ablation matches the full society, +# or the full society fails to exceed every ablation. + +society: + L: 12 + K: 8 # landscape ruggedness (epistasis) — rugged enough that diversity + sex matter + N: 60 # population size + g: 0.85 # grounding fraction (overwritten to 0 in the no_grounding arm) + mu: 0.03 # per-locus mutation rate + novelty: 0.5 # quality-diversity weight (0 in the no_diversity/greedy arm) + n_off: 120 # directed-recombination offspring pool per generation + recomb_rate: 0.2 # crossover rate + sex: true # directed recombination on (false in the no_sex arm) + select: qd # quality-diversity survival (greedy in the no_diversity arm) + +generations: 80 + +sweep: + - param: arm + values: + - {name: full, set: {}} + - {name: no_grounding, set: {society.g: 0.0}} + - {name: no_sex, set: {society.sex: false}} + - {name: no_diversity, set: {society.select: greedy, society.novelty: 0.0}} + +output: {dir: results/E11} diff --git a/figures/plot_E11.py b/figures/plot_E11.py new file mode 100644 index 0000000..27955f9 --- /dev/null +++ b/figures/plot_E11.py @@ -0,0 +1,68 @@ +"""E11 figure — the dynamic Lamarckian society: the vertical claim (C3). + +A finite population of agents evolves on a rugged NK landscape (reality). The **full** society — +grounding + directed recombination (sex) + quality-diversity selection — climbs to the global optimum +while maintaining diversity longest. A 4-arm ablation shows every operator is load-bearing, each +breaking distinctly: **no_grounding** collapses to a fit-looking but actually-poor consensus +(self-consumption); **no_sex** plateaus (can't recombine to escape local optima); **no_diversity** +(greedy) collapses diversity fastest and stalls at a worse local optimum. + +Three panels over generations: (A) best real capability — the vertical climb, full highest, no_grounding +crashing below the rest; (B) population diversity — full explores longest, no_grounding collapses +almost immediately; (C) the self-consumption signature — conformity minus true fitness (how far the +population's mutual agreement exceeds its real capability), largest for no_grounding. Reads only the +committed bundle. + +Usage: python figures/plot_E11.py [results/E11] +""" + +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, mean_ci, savefig # noqa: E402 + +_ARMS = [("full", "#2ca02c", "full society"), + ("no_sex", "#ff7f0e", "no sex (no recombination)"), + ("no_diversity", "#9467bd", "no diversity (greedy)"), + ("no_grounding", "#d62728", "no grounding (self-consumption)")] + + +def main(results_dir: str = "results/E11") -> None: + df, _ = load_bundle(results_dir) + arms = [a for a in _ARMS if a[0] in set(df["arm"].unique())] + g_opt = df["global_opt"].mean() + + fig, axes = plt.subplots(1, 3, figsize=(16, 4.8)) + + def traj(ax, col, title, ylabel, hline=None): + for name, c, lab in arms: + sub = df[df["arm"] == name] + g, m, ci = mean_ci(sub, "generation", col) + ax.plot(g, m, "-", color=c, lw=1.9, label=lab) + ax.fill_between(g, m - ci, m + ci, color=c, alpha=0.15) + if hline is not None: + ax.axhline(hline[0], ls=":", color="gray", lw=1, label=hline[1]) + ax.set(xlabel="generation", ylabel=ylabel, title=title) + ax.legend(frameon=False, fontsize=8) + + traj(axes[0], "best_fitness", "The vertical climb: general capability\n" + "(full climbs highest; no-grounding collapses)", "best real fitness", + hline=(g_opt, "global optimum")) + traj(axes[1], "diversity", "Specialties maintained: diversity during search\n" + "(full explores longest; ablations collapse fast)", "population diversity") + traj(axes[2], "conformity_true_gap", "Self-consumption signature:\n" + "agreement minus real capability", "conformity − true fitness") + + fig.suptitle("E11 — the dynamic Lamarckian society: grounding + directed sex + diversity climb to " + "the optimum; remove any one and it breaks (the vertical claim, C3)", y=1.02, fontsize=12) + fig.tight_layout() + savefig(fig, results_dir, "E11") + + +if __name__ == "__main__": + main(*sys.argv[1:]) diff --git a/results/E11/E11.pdf b/results/E11/E11.pdf new file mode 100644 index 0000000..83d51d6 Binary files /dev/null and b/results/E11/E11.pdf differ diff --git a/results/E11/E11.png b/results/E11/E11.png new file mode 100644 index 0000000..edc9ef3 Binary files /dev/null and b/results/E11/E11.png differ diff --git a/results/E11/README.md b/results/E11/README.md new file mode 100644 index 0000000..7c93ab5 --- /dev/null +++ b/results/E11/README.md @@ -0,0 +1,42 @@ +# E11 — the dynamic Lamarckian society: the vertical claim (C3) + +**Claim tested.** The culmination. Everything before was a *single operator*; the society's +load-bearing, un-preempted claim is that composing them makes **general capability climb over +generations while specialties are re-earned**, and that **every operator is necessary** — remove one +and it breaks. This is blueprint C3, the "vertical." + +**Setup.** A finite population of `N=60` agents (genotypes of `L=12` biallelic loci) evolves on a +**rugged Kauffman NK landscape** (`K=8`) that *is* reality. Each generation composes: grounding, +**directed recombination (sex)**, **quality-diversity selection**, and mutation. Grounding is made +load-bearing by the **consensus-conformity (self-consumption)** mechanism — selection acts on +`g·true_fitness + (1−g)·conformity`, where conformity = agreement with the population's own consensus, +so at `g=0` the society optimises fitting-the-crowd rather than reality. A 4-arm ablation, 12 +replicate landscapes, 80 generations. + +### Symbols +- **agent** = a model (a genotype); **reality** = the NK fitness landscape; **grounding `g`** = share of the selection signal that is real fitness vs conformity. +- **directed sex** = recombine many parents + keep the fittest offspring (E10). **quality-diversity** = select for capability *and* novelty, so specialties persist. +- **conformity − true fitness** = how far the population's mutual agreement exceeds its real capability (the self-consumption "delusion" signature). + +### The three panels (over generations) +1. **The vertical climb.** Best real capability: the **full society (green) climbs to the global + optimum** (≈0.78 of 0.79), while **no_grounding (red) collapses** to a fit-looking but poor + consensus (0.48). `no_sex` and `no_diversity` plateau *below* the full society. +2. **Specialties maintained.** Population diversity: the **full society explores longest** (diversity + decays slowly as it searches), while every ablation collapses diversity fast — `no_grounding` + almost immediately. +3. **Self-consumption signature.** Conformity minus true fitness: **largest for `no_grounding`** — the + population strongly *agrees* while being *wrong*, the signature of training on its own consensus. + +### Takeaway — every operator is load-bearing +- **full** → climbs to the optimum with diversity maintained. **The vertical claim.** +- **no_grounding** → self-consumption collapse to an unfit consensus (train on the crowd → regress to a confident, wrong mean). +- **no_sex** → can't recombine complementary specialists to escape local optima → plateaus below full. +- **no_diversity (greedy)** → collapses diversity fastest, gets stuck at a *worse* local optimum. + +This integrates the whole study — E1–E6 (collapse/grounding/selection), the learning kernel, and +E7–E10 (sexual transmission) — into one system, and shows the Lamarckian society needs **all** of +grounding + directed sex + diversity to climb without collapsing. On a rugged landscape you need +diversity to explore basins, sex to recombine them, and grounding to select on reality; remove any and +you fail differently. **Falsifier (not triggered):** if any ablation had matched the full society, or +the full society had failed to exceed every ablation, the integration claim would fail. diff --git a/results/E11/manifest.json b/results/E11/manifest.json new file mode 100644 index 0000000..bc3dde5 --- /dev/null +++ b/results/E11/manifest.json @@ -0,0 +1,14 @@ +{ + "experiment": "E11", + "master_seed": 20260705, + "git_commit": "48181a1c846f41e9254abe4b1d0f843d1df9667f", + "python": "3.14.5", + "libraries": { + "numpy": "2.5.0", + "scipy": "1.18.0", + "pandas": "3.0.3", + "pyarrow": "24.0.0" + }, + "rows": 3888, + "results_sha256": "e8d2c3ecb5200578d16a977d147b6ee05ebf83e1a81d9287f6a740114ef3968a" +} \ No newline at end of file diff --git a/results/E11/resolved_config.yaml b/results/E11/resolved_config.yaml new file mode 100644 index 0000000..363c7fe --- /dev/null +++ b/results/E11/resolved_config.yaml @@ -0,0 +1,37 @@ +experiment: E11 +seed: 20260705 +n_replicates: 12 +source_config: + experiment: E11 + kind: dynamic_society + seed: 20260705 + n_replicates: 12 + society: + L: 12 + K: 8 + N: 60 + g: 0.85 + mu: 0.03 + novelty: 0.5 + n_off: 120 + recomb_rate: 0.2 + sex: true + select: qd + generations: 80 + sweep: + - param: arm + values: + - name: full + set: {} + - name: no_grounding + set: + society.g: 0.0 + - name: no_sex + set: + society.sex: false + - name: no_diversity + set: + society.select: greedy + society.novelty: 0.0 + output: + dir: results/E11 diff --git a/src/knowledge/dynamic_society.py b/src/knowledge/dynamic_society.py new file mode 100644 index 0000000..0ae2c1a --- /dev/null +++ b/src/knowledge/dynamic_society.py @@ -0,0 +1,143 @@ +"""The dynamic Lamarckian society — the vertical claim (E11 / C3). + +A finite population of ``N`` agents (genotypes of ``L`` biallelic loci) evolves on a Kauffman NK +landscape that *is* reality. The society climbs in real capability by composing the four operators the +whole study built toward — **grounding**, **directed recombination (sex)**, **quality-diversity +selection**, and mutation — and an ablation shows each is load-bearing. + +The crux is what happens WITHOUT grounding. A plain genetic algorithm on true fitness would just +improve, so grounding must corrupt the *selection signal* to cause collapse. Here selection acts on a +**grounded score** ``g·true_fitness + (1−g)·conformity``, where conformity is agreement with the +population's own consensus (modal genotype). At ``g=0`` selection rewards fitting the crowd rather +than reality — self-consumption — and the society drifts to a fit-looking but actually-poor consensus, +losing diversity: the direct analogue of training on the majority of AI-generated outputs. + +Four ablation arms, each breaking distinctly (only ``full`` avoids all three failures): +``full`` (climbs) · ``no_grounding`` (conformity collapse) · ``no_sex`` (stuck at local optima) · +``no_diversity`` (collapses to one lineage, recombination starves). +""" + +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, nk_fitness + + +def _consensus(pop_bits: np.ndarray) -> np.ndarray: + """Population consensus genotype: the modal allele at each locus (majority vote).""" + return (pop_bits.mean(axis=0) >= 0.5).astype(pop_bits.dtype) + + +def _conformity(pop_bits: np.ndarray, consensus: np.ndarray) -> np.ndarray: + """Per-agent agreement with the consensus (fraction of loci matching the majority).""" + return (pop_bits == consensus[None, :]).mean(axis=1) + + +def _novelty(pop_bits: np.ndarray) -> np.ndarray: + """Per-agent novelty: mean Hamming distance to the rest of the population (diversity signal).""" + N, L = pop_bits.shape + if N < 2: + return np.zeros(N) + # pairwise Hamming via allele agreement: distance_ij = L - matches; mean over j != i. + match = (pop_bits[:, None, :] == pop_bits[None, :, :]).sum(axis=2) # (N, N) matches + ham = L - match + return (ham.sum(axis=1) / (N - 1)) / L # normalised to [0,1] + + +def _directed_offspring(pop_bits, fitness, n_off, rate, rng): + """Directed sex: make ``n_off`` recombinants from the whole population, return them ranked-ready. + + Unbounded-parent crossover (the AI move); offspring selection happens in the survival step, so + here we just generate the candidate offspring bit-matrix. + """ + return np.stack([crossover(pop_bits, rate, rng) for _ in range(n_off)]) + + +def run_dynamic_society(cfg: Mapping[str, Any], seed: int) -> pd.DataFrame: + """Run one dynamic-society lineage; return per-generation metrics. + + Args: + cfg (Mapping): Config with a ``society`` block (``L``, ``K`` landscape ruggedness, ``N`` + population, ``g`` grounding, ``mu`` mutation, ``novelty`` QD weight, ``n_off`` offspring + pool, ``recomb_rate``, ``sex`` on/off, ``select`` in {``qd``, ``greedy``}) 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), ``consensus_fitness``, + ``conformity_true_gap`` (mean conformity − mean true fitness; exposes the no-grounding + collapse), and ``global_opt``. + """ + soc = cfg["society"] + L, K, N = int(soc["L"]), int(soc["K"]), int(soc["N"]) + g = float(soc.get("g", 1.0)) + mu = float(soc.get("mu", 0.02)) + novelty_w = float(soc.get("novelty", 0.0)) + n_off = int(soc.get("n_off", N)) + rate = float(soc.get("recomb_rate", 0.2)) + sex = bool(soc.get("sex", True)) + select = soc.get("select", "qd") + generations = int(cfg.get("generations", 100)) + + fitness = nk_fitness(L, K, seed) # reality + global_opt = float(fitness.max()) + all_bits = genotype_bits(L) + rng = np.random.default_rng(seed) + + # Initialise a diverse population of random genotypes. + pop = rng.integers(0, 2, size=(N, L)).astype(all_bits.dtype) + + def true_fit(bits): + return np.array([fitness[bits_to_index(b)] for b in bits]) + + rows: list[dict] = [] + + def record(t: int) -> None: + tf = true_fit(pop) + cons = _consensus(pop) + conf = _conformity(pop, cons) + rows.append({ + "generation": t, + "best_fitness": float(tf.max()), + "mean_fitness": float(tf.mean()), + "diversity": float(_novelty(pop).mean()), + "consensus_fitness": float(fitness[bits_to_index(cons)]), + "conformity_true_gap": float(conf.mean() - tf.mean()), + "global_opt": global_opt, + }) + + record(0) + for t in range(1, generations + 1): + # (1) candidate pool = current population + directed offspring (sex) or mutated clones. + if sex: + offspring = _directed_offspring(pop, fitness, n_off, rate, rng) + else: # asexual: offspring are mutated copies + idx = rng.integers(0, N, size=n_off) + offspring = pop[idx].copy() + # mutation on the offspring + flip = rng.random(offspring.shape) < mu + offspring = np.where(flip, 1 - offspring, offspring).astype(pop.dtype) + pool = np.concatenate([pop, offspring], axis=0) + + # (2) grounded score: g*true_fitness + (1-g)*conformity (conformity vs the *current* consensus). + cons = _consensus(pop) + tf = true_fit(pool) + conf = _conformity(pool, cons) + score = g * tf + (1.0 - g) * conf + + # (3) survival: QD (score + novelty) keeps diverse high-scorers; greedy keeps top score only. + if select == "qd" and novelty_w > 0.0: + nov = _novelty(pool) + merit = score + novelty_w * nov + else: + merit = score + keep = np.argsort(merit)[-N:] # elitist truncation survival + pop = pool[keep] + record(t) + + return pd.DataFrame(rows) diff --git a/src/knowledge/experiment.py b/src/knowledge/experiment.py index f4b71e4..b59c955 100644 --- a/src/knowledge/experiment.py +++ b/src/knowledge/experiment.py @@ -167,6 +167,46 @@ def run_genotype_experiment(cfg: dict) -> pd.DataFrame: return out +_DYNAMIC_KEYS = ("society", "generations") + + +def run_dynamic_experiment(cfg: dict) -> pd.DataFrame: + """Run the dynamic society across an ``arm`` ablation sweep x replicates (E11). + + Mirrors ``run_genotype_experiment`` but assembles the base from the ``society``/``generations`` + blocks and calls ``run_dynamic_society``. Arms are named override bundles (reuse ``_apply_param`` + ``arm`` handling), e.g. ``no_grounding`` sets ``society.g=0``. + """ + from .dynamic_society import run_dynamic_society + + base = {k: copy.deepcopy(cfg[k]) for k in _DYNAMIC_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_dynamic_society(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: """E4 runner: multi-teacher recombination coverage (blueprint 2.5-E4 / 2.7.1). @@ -332,6 +372,8 @@ def run_and_save(config_path: str | Path) -> Path: elif kind == "directed_sex": from .society import run_directed_sex # E10: directed sex beats biology df = run_directed_sex(cfg) + elif kind == "dynamic_society": + df = run_dynamic_experiment(cfg) # E11: the dynamic society (C3) else: df = run_experiment(cfg) save_artifacts(cfg, df, out_dir) diff --git a/tasks/todo.md b/tasks/todo.md index 8f10567..d99526f 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -296,11 +296,20 @@ C3 vertical claim deferred.* risk when entangled (E9); directed sex resolves it (E10). `configs/layer1/{E9,E10}.yaml`, `plot_{E9,E10}.py`, READMEs, +5 tests (117 green). -## Remaining (all optional / next) +**2026-07-05 — the dynamic Lamarckian society (E11): the vertical claim / C3 realized.** -- [ ] **The dynamic society:** an evolving *population of parents* (specialists) that ground + - recombine + select over generations — capability climbing while specialties are re-earned (the full - C3, dynamic). E7/E8 give the static + single-population pieces; the multi-parent *lineage* is next. +- `knowledge/dynamic_society.py`: finite population of N agents (genotypes) on a rugged NK landscape + (reality); composes grounding + directed sex + quality-diversity selection + mutation. Grounding + made load-bearing via consensus-conformity (self-consumption): selection on + `g·true_fitness + (1-g)·conformity` (GG decision). `kind: dynamic_society` dispatch. +- **4-arm ablation (12 reps), each breaks distinctly (global_opt≈0.79):** full 0.78 (climbs to optimum, + diversity maintained longest); no_sex 0.77; no_diversity/greedy 0.74; **no_grounding 0.48 + (self-consumption collapse to unfit consensus).** Only the full society climbs. Integrates E1-E6 + + kernel + E7-E10 into one system: needs ALL of grounding + directed sex + diversity. +- `configs/layer1/E11.yaml`, `plot_E11.py`, README, `tests/test_dynamic_society.py` (+5, 122 green). + Closes C3 analytically; the LLM rung remains the eventual empirical instantiation. + +## Remaining (all optional / next) - [ ] **NK/epistasis landscape** (sign epistasis can make recombination harmful — the honest limit of "sex always helps"); **multi-allelic loci**. Deepens the frame. - [ ] **Learning-kernel refinement:** truth-like smoothing prior (`prior="truth"`) + measurement floor diff --git a/tests/test_dynamic_society.py b/tests/test_dynamic_society.py new file mode 100644 index 0000000..7c31756 --- /dev/null +++ b/tests/test_dynamic_society.py @@ -0,0 +1,58 @@ +"""Dynamic-society tests (pure NumPy) — the culminating vertical claim (E11 / C3). + +Cover the finite-population operators (consensus, conformity, novelty) and the four ablation +behaviours: the full society climbs to near the optimum; removing grounding collapses it to an unfit +consensus (self-consumption); removing sex or diversity leaves it stuck below the full society. +""" + +from __future__ import annotations + +import numpy as np + +from knowledge.dynamic_society import _conformity, _consensus, _novelty, run_dynamic_society + + +def _run(arm_overrides: dict, seed: int = 0, gens: int = 50): + base = {"L": 10, "K": 6, "N": 50, "g": 0.85, "mu": 0.03, "novelty": 0.5, + "n_off": 100, "recomb_rate": 0.2, "sex": True, "select": "qd"} + base.update(arm_overrides) + return run_dynamic_society({"society": base, "generations": gens}, seed=seed) + + +def test_consensus_and_conformity(): + pop = np.array([[1, 1, 0, 0], [1, 0, 0, 1], [1, 1, 1, 0]], dtype=np.int8) + cons = _consensus(pop) + assert np.array_equal(cons, [1, 1, 0, 0]) # majority vote per locus + conf = _conformity(pop, cons) + assert np.isclose(conf[0], 1.0) # agent 0 == consensus + assert conf.min() >= 0.0 and conf.max() <= 1.0 + + +def test_novelty_is_zero_for_clones_and_high_for_spread(): + clones = np.ones((4, 8), dtype=np.int8) + assert np.allclose(_novelty(clones), 0.0) # identical -> no diversity + spread = np.array([[0] * 8, [1] * 8], dtype=np.int8) + assert np.allclose(_novelty(spread), 1.0) # opposite -> maximal diversity + + +def test_full_society_climbs_toward_optimum(): + df = _run({}) + go = df["global_opt"].iloc[0] + assert df["best_fitness"].iloc[-1] > df["best_fitness"].iloc[0] + 0.05 # it climbs + assert df["best_fitness"].iloc[-1] > 0.9 * go # ... to near the optimum + + +def test_no_grounding_collapses_to_unfit_consensus(): + full = _run({})["best_fitness"].iloc[-1] + dry = _run({"g": 0.0}) + assert dry["best_fitness"].iloc[-1] < full - 0.1 # far below the grounded society + assert dry["diversity"].iloc[-1] < 0.05 # diversity collapsed + assert dry["conformity_true_gap"].iloc[-1] > 0.3 # agreement >> real capability (delusion) + + +def test_ablations_stay_below_the_full_society(): + full = _run({})["best_fitness"].iloc[-1] + no_sex = _run({"sex": False})["best_fitness"].iloc[-1] + no_div = _run({"select": "greedy", "novelty": 0.0})["best_fitness"].iloc[-1] + assert no_sex <= full + 1e-6 and no_div <= full + 1e-6 # neither beats the full society + assert min(no_sex, no_div) < full # ... and at least one is strictly worse