MachineSex/src/knowledge/dynamic_society.py
Giorgio Gilestro 0f7b775ae5 society: the dynamic Lamarckian society — the vertical claim (E11 / C3)
The culmination. A finite population of 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, and mutation.
Grounding is made load-bearing via the consensus-conformity (self-
consumption) mechanism (GG decision): 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.

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 0.74  greedy: collapses diversity fastest, worse local optimum
- no_grounding 0.48  self-consumption collapse to an unfit consensus
                     (trains on the crowd -> confident-but-wrong mean;
                      conformity-true gap ~ 0.5)

This integrates E1-E6 + the learning kernel + E7-E10 into one system and
shows the Lamarckian society needs ALL of grounding + directed sex +
diversity: on a rugged landscape you need diversity to explore basins, sex
to recombine them, and grounding to select on reality -- remove any one and
you fail differently. Closes the C3 vertical claim analytically; the LLM
rung remains the eventual empirical instantiation.

New: knowledge/dynamic_society.py, configs/layer1/E11.yaml, figures/
plot_E11.py, README, tests/test_dynamic_society.py (+5). kind:
dynamic_society dispatch; make layer1 wired. 122 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 12:34:01 +01:00

143 lines
6.3 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.

"""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 + (1g)·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)