diff --git a/CLAUDE.md b/CLAUDE.md index 14d61f2..b034b24 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -76,6 +76,10 @@ E4's whole purpose is to isolate the effect of teacher **decorrelation ρ**, so **Finding (2026-07-05, real-MNIST `mnist_collapse`) — collapse and grounding-rescue reproduce on real images.** External-validity tier: a small **convolutional VAE** (the canonical generative-collapse model) is retrained each generation on its own generated digits. Modes = digit class × stroke-thickness bin (K=30, Zipf, ~18 tail modes); the oracle is a **frozen CNN + deterministic thickness** at **98.5% mode accuracy** (its 30×30 confusion matrix is recorded in the manifest as the measurement floor). Result (4 reps): the **dry (g=0) lineage collapses to a single mode** — forward-KL 0.5→18, support 30→1, tail truth-mass 1.0→0.06, H→0 — while **10% grounding holds all 30 modes** (KL≈0.6, full tail, H≈0.9). The VAE needs ~10% grounding here vs the synthetic histogram's ~5%, consistent with the `grounding` finding that trained neural models need more grounding than the exact operator. **Confirmation-only (signs, not magnitudes; blueprint §3.5)** — the exact synthetic oracle stays the quantitative anchor. `figures/mnist_montage.py` is an eyeball diagnostic (re-runs a short dry lineage; NOT a parquet figure). Build gates passed: CNN mode accuracy 98.5%; VAE gen-0 recovers full 30/30 support (over-smooths frequencies, KL≈0.5, no prior hole — unlike the *synthetic*-codeword VAE, which is why the MNIST VAE works where that one didn't). The MNIST tier is heavy (torchvision `--extra mnist`, downloads MNIST, ~5 min): `make mnist`, kept out of the `make neural` loop. +**Finding (2026-07-05, learning kernel) — neutral drift is a null both real models fail, oppositely; the estimator bias is a signed operator.** Layer-1 extension (`knowledge/kernel.py`, `LearningKernelCfg`): the refit becomes `p_{t+1} = T_θ(counts/n)` with two pop-gen knobs — **reset `u`** (mutation toward a prior = smoothing) and **temperature `τ`** (sharpening = mode-competition) — both identity at their defaults, so the histogram bridge and every scientific-validation test are unchanged (68 core tests still green). Result: **neutral Wright–Fisher fails both neural architectures, in opposite directions.** VAE regime (`n=6000, K=30`): neutral drift is *inert* (no collapse), yet the real VAE collapsed to one mode — **sharpening `τ=0.8` reproduces it** (the estimator ADDS collapse). RNN regime (`n=200, K=256`): neutral drives `H→0`, but the real RNN only partially collapses — **mutation `u=0.006` reproduces the `H`-floor** (the estimator REMOVES collapse). Honest caveat: uniform-mutation matches the RNN `H`-floor but overshoots its forward-KL (~5 vs ~2), evidence the RNN's smoothing prior is *truth-like, not uniform* (future refinement). Configs `configs/layer1/kernel_{sharpen,smooth}.yaml`, figure `plot_kernel.py`. This mechanistically explains the architecture-generality result and the softened neural `g*`. + +**Strategic positioning vs Riis 2026 (arXiv:2604.08554, "Drift and selection in LLM text ecosystems").** Riis independently formalizes **collapse = Wright–Fisher drift** (his Thm 1) with n-gram agents: minority-mass martingale, rare-first extinction, single-token dropout ≈ αe^{−α}, de Bruijn-polytope fixed points, plus descriptive-vs-normative *selection* (Thm 2). **Concede as prior art:** "collapse is literally Wright–Fisher", the martingale, rare-first loss, the WF/effective-population formalism — cite him; do **not** frame these as our contribution. **Crucial distinction that protects us:** his "mixed environment" *retains the lineage's own old synthetic tokens* — there is **no injection of fresh real data from a fixed `p*`**, so his headline is *pessimistic* (Thm 1c: extinction is independent of α — retention only changes speed). Our **grounding is immigration from a non-drifting external truth**, giving a stationary `H_eq>0` and a critical `g*≪1` that *prevents* collapse — the mechanism his closed loop lacks. **Our defensible novelty, ranked:** (1) **recombination + "merge, don't average" conservation law** (E4) — he has no model-merging operator; flagship; (2) **the learning-kernel / estimator-bias axis** — he *explicitly names it as future work*; we now build+measure it; (3) grounding threshold (solid anchor, but immigration–drift balance is classic — not a flagship); (4) architecture-generality in real weights + MNIST; (5) **the Lamarckian society + the vertical/cumulative C3 claim — wholly ours, not yet run.** Reposition the paper from *"collapse is drift"* (now contested) to **a population-genetic *control theory* for sustaining open-ended knowledge**: drift is the diagnosed disease (cite Riis), our contribution is the engineered remedies and their integration. + ## 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 9cc384f..96c9d0f 100644 --- a/Makefile +++ b/Makefile @@ -15,8 +15,8 @@ env-mnist: ## add torchvision for the real-MNIST confirmation tier test: ## correctness tests + scientific-validation tests (the spine of trust) uv run pytest -layer1: ## run experiments E1-E6 - for e in E1 E2 E3 E4 E5 E6; do uv run python -m knowledge.experiment configs/layer1/$$e.yaml; done +layer1: ## run experiments E1-E6 + the learning-kernel bridge (analytic) + for e in E1 E2 E3 E4 E5 E6 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/kernel_sharpen.yaml b/configs/layer1/kernel_sharpen.yaml new file mode 100644 index 0000000..dc59c92 --- /dev/null +++ b/configs/layer1/kernel_sharpen.yaml @@ -0,0 +1,29 @@ +experiment: kernel_sharpen +kind: lineage +seed: 20260705 +n_replicates: 24 + +# (Learning-kernel bridge, pro-collapse arm): does neutral Wright-Fisher explain the VAE's +# collapse on MNIST? NO -- and that is the point. This matches the MNIST regime (K=30, n=6000, +# Zipf) where drift is nearly inert: neutral (temperature=1.0) barely moves (H stays ~H*, ~all +# modes alive), yet the real VAE collapsed to a SINGLE mode (results/mnist_collapse). Adding the +# estimator's sharpening / mode-competition (temperature<1: p ~ p^(1/tau)) reproduces the +# catastrophic collapse. tau=0.8 is calibrated to reproduce collapse-to-one-mode. This is the +# axis Riis (2026) names as future work: the estimator, not the sampling, drives VAE collapse. + +truth: {K: 30, R: 1, tail: zipf, zipf_s: 1.5, tail_threshold: 0.01, init: truth} + +dynamics: + n: 6000 # huge vs K=30 -> neutral drift is essentially inert + grounding: {m: 0, policy: proportional} + kernel: {reset: 0.0, temperature: 1.0, floor: 0.0} + +generations: 15 + +metrics: {kl_floor: 1.0e-9, support_eps: 1.0e-9} + +sweep: + - param: dynamics.kernel.temperature + values: [1.0, 0.8] # neutral (no collapse) vs sharpened (catastrophic collapse) + +output: {dir: results/kernel_sharpen} diff --git a/configs/layer1/kernel_smooth.yaml b/configs/layer1/kernel_smooth.yaml new file mode 100644 index 0000000..714e8d6 --- /dev/null +++ b/configs/layer1/kernel_smooth.yaml @@ -0,0 +1,32 @@ +experiment: kernel_smooth +kind: lineage +seed: 20260705 +n_replicates: 24 + +# (Learning-kernel bridge, anti-collapse arm): neutral Wright-Fisher OVER-predicts the RNN's +# collapse. This matches the RNN grounding regime (K=256, n=200, Zipf): neutral (reset=0) drives +# H all the way to 0, but the real RNN only PARTIALLY collapses -- H plateaus ~0.68 of a possible +# 0.88, forward-KL plateaus ~2 (does not diverge), ~half the tail stays alive (results/grounding). +# The estimator's smoothing / regularisation supplies a diversity FLOOR. A mutation-toward-prior +# knob (reset=u: p <- (1-u)p + u*uniform) reproduces the H-floor. reset=0.006 is calibrated to the +# RNN's stationary dry H. Honest caveat carried in the write-up: uniform-mutation matches the +# H-floor but overshoots forward-KL (analytic ~6 vs RNN ~2), evidence the RNN's smoothing target +# is TRUTH-LIKE, not uniform -- a refinement for future work. The sign, though, is unambiguous: +# the estimator here REMOVES collapse pressure (opposite to the VAE's sharpening). + +truth: {K: 256, R: 1, tail: zipf, zipf_s: 1.3, tail_threshold: 0.001, init: truth} + +dynamics: + n: 200 + grounding: {m: 0, policy: proportional} + kernel: {reset: 0.0, temperature: 1.0, floor: 0.0} + +generations: 100 # long enough to show neutral -> 0 vs smoothed -> floor clearly + +metrics: {kl_floor: 1.0e-9, support_eps: 1.0e-9} + +sweep: + - param: dynamics.kernel.reset + values: [0.0, 0.006] # neutral (H -> 0) vs smoothed (H floors, like the RNN) + +output: {dir: results/kernel_smooth} diff --git a/figures/plot_kernel.py b/figures/plot_kernel.py new file mode 100644 index 0000000..f273d37 --- /dev/null +++ b/figures/plot_kernel.py @@ -0,0 +1,121 @@ +"""`kernel` figure — the learning kernel: neutral drift fails both neural models, oppositely. + +The Layer-1.5 architecture-generality result gets a mechanistic explanation. Neutral Wright-Fisher +(the histogram / Riis baseline) is the null; a real learner adds an estimator bias that can point +either way. Two regimes, each falsifying neutral drift in the OPPOSITE direction, each repaired by +one knob of the learning kernel: + +* **VAE regime (n=6000, K=30):** drift is nearly inert — neutral holds ~all modes — yet the real + VAE collapsed to a single mode. Sharpening (temperature<1) reproduces it. The estimator ADDS + collapse pressure. +* **RNN regime (n=200, K=256):** neutral drives H to 0, but the real RNN only partially collapses + (H floors, forward-KL plateaus). Mutation-toward-prior (reset>0) reproduces the floor. The + estimator REMOVES collapse pressure. + +Analytic arms are read from results/kernel_{sharpen,smooth}; the neural reference endpoints +(dashed) are read from the committed results/mnist_collapse and results/grounding parquets — so the +figure is a pure function of committed artifacts. + +Usage: python figures/plot_kernel.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 + +sys.path.insert(0, str(Path(__file__).parents[1] / "src")) +from knowledge.metrics import heterozygosity # noqa: E402 +from knowledge.truth import make_true_distribution # noqa: E402 + + +def _mean_traj(df, knob, val, col): + sub = df[df[knob] == val] + s = sub.groupby("generation")[col].mean() + return s.index.to_numpy(), s.to_numpy() + + +def _neural_dry(results_dir, col, stationary_frac=0.0): + """Mean of ``col`` on the dry (g=0) neural arm — endpoint, or stationary tail if frac>0.""" + df, _ = load_bundle(results_dir) + dry = df[df["g"] == 0.0] + if stationary_frac > 0: + dry = dry[dry["generation"] >= int(dry["generation"].max() * (1 - stationary_frac))] + return float(dry[col].mean()) + return float(dry[dry["generation"] == dry["generation"].max()][col].mean()) + + +def main() -> None: + sh, sh_cfg = load_bundle("results/kernel_sharpen") + sm, sm_cfg = load_bundle("results/kernel_smooth") + Hstar_sh = heterozygosity(make_true_distribution( + sh_cfg["truth"]["K"], 1, "zipf", 0.5, sh_cfg["truth"]["zipf_s"], 0, + tail_threshold=sh_cfg["truth"]["tail_threshold"]).p_star) + Hstar_sm = heterozygosity(make_true_distribution( + sm_cfg["truth"]["K"], 1, "zipf", 0.5, sm_cfg["truth"]["zipf_s"], 0, + tail_threshold=sm_cfg["truth"]["tail_threshold"]).p_star) + + # Neural reference endpoints (dashed) from the committed neural runs. + vae_H = _neural_dry("results/mnist_collapse", "heterozygosity") + vae_sup = _neural_dry("results/mnist_collapse", "support_size") + rnn_H = _neural_dry("results/grounding", "heterozygosity", stationary_frac=0.4) + rnn_KL = _neural_dry("results/grounding", "forward_kl", stationary_frac=0.4) + + fig, axes = plt.subplots(2, 2, figsize=(13, 9)) + NEU, KER = "#1f77b4", "#d62728" + + # --- VAE regime: sharpening --- + ax = axes[0, 0] + for val, c, lab in [(1.0, NEU, "neutral (τ=1)"), (0.8, KER, "sharpened (τ=0.8)")]: + g, y = _mean_traj(sh, "temperature", val, "heterozygosity") + ax.plot(g, y, "-o", color=c, ms=3, label=lab) + ax.axhline(Hstar_sh, ls=":", color="gray", lw=1, label="$H^*$") + ax.axhline(vae_H, ls="--", color="#2ca02c", lw=1.3, label=f"real VAE (dry): {vae_H:.2f}") + ax.set(xlabel="generation", ylabel="heterozygosity $H$", + title="VAE regime ($n$=6000, $K$=30): neutral drift is inert;\nsharpening collapses (like the VAE)") + ax.legend(frameon=False, fontsize=8) + + ax = axes[0, 1] + for val, c, lab in [(1.0, NEU, "neutral (τ=1)"), (0.8, KER, "sharpened (τ=0.8)")]: + g, y = _mean_traj(sh, "temperature", val, "support_size") + ax.plot(g, y, "-o", color=c, ms=3, label=lab) + ax.axhline(vae_sup, ls="--", color="#2ca02c", lw=1.3, label=f"real VAE (dry): {vae_sup:.0f}") + ax.set(xlabel="generation", ylabel="distinct modes alive", + title="Support: neutral holds ~all; sharpening → 1 mode") + ax.legend(frameon=False, fontsize=8) + + # --- RNN regime: smoothing --- + ax = axes[1, 0] + for val, c, lab in [(0.0, NEU, "neutral (u=0)"), (0.006, KER, "smoothed (u=0.006)")]: + g, y = _mean_traj(sm, "reset", val, "heterozygosity") + ax.plot(g, y, "-", color=c, lw=1.8, label=lab) + ax.axhline(Hstar_sm, ls=":", color="gray", lw=1, label="$H^*$") + ax.axhline(rnn_H, ls="--", color="#2ca02c", lw=1.3, label=f"real RNN (dry): {rnn_H:.2f}") + ax.set(xlabel="generation", ylabel="heterozygosity $H$", + title="RNN regime ($n$=200, $K$=256): neutral → 0;\nsmoothing floors $H$ (like the RNN)") + ax.legend(frameon=False, fontsize=8) + + ax = axes[1, 1] + for val, c, lab in [(0.0, NEU, "neutral (u=0)"), (0.006, KER, "smoothed (u=0.006)")]: + g, y = _mean_traj(sm, "reset", val, "forward_kl") + ax.plot(g, y, "-", color=c, lw=1.8, label=lab) + ax.axhline(rnn_KL, ls="--", color="#2ca02c", lw=1.3, label=f"real RNN (dry): {rnn_KL:.1f}") + ax.set(xlabel="generation", ylabel=r"forward-KL $D(p^*\Vert p)$", + title="Forward-KL: neutral diverges; smoothing plateaus\n(overshoots RNN → prior is truth-like, not uniform)") + ax.legend(frameon=False, fontsize=8) + + fig.suptitle("learning kernel — neutral Wright–Fisher fails both neural models, oppositely: " + "the estimator sharpens (VAE) or smooths (RNN)", y=1.0, fontsize=12) + fig.tight_layout() + for d in ("results/kernel_sharpen", "results/kernel_smooth"): + savefig(fig, d, "kernel") + + +if __name__ == "__main__": + main() diff --git a/results/kernel_sharpen/README.md b/results/kernel_sharpen/README.md new file mode 100644 index 0000000..83ee22c --- /dev/null +++ b/results/kernel_sharpen/README.md @@ -0,0 +1,42 @@ +# kernel — the learning kernel: why real learners deviate from neutral drift + +*(This legend covers both `results/kernel_sharpen/` and `results/kernel_smooth/`; the figure +`kernel.png` is written into both.)* + +**Claim tested.** Neutral Wright–Fisher drift (the histogram bridge, and the baseline of Riis +2026) is the *null* model of collapse. But Layer 1.5 showed real trained models deviate from it — +and in **opposite directions**. Can a single extension of Layer 1 — a parameterized *learning +kernel* on the refit step, `p_{t+1} = T_θ(counts/n)` — reproduce both deviations, and does neutral +drift genuinely fail without it? + +**Setup.** The kernel (`knowledge/kernel.py`) has two population-genetics knobs, both reducing to +neutral drift at their defaults (so the histogram and every scientific-validation test are +unchanged): **reset `u`** — mutation toward a prior (`p ← (1−u)p + u·π`), i.e. smoothing; and +**temperature `τ`** — sharpening (`p ∝ p^{1/τ}`, `τ<1` concentrates), i.e. mode-competition. Two +matched-to-neural regimes, 24 replicates each. + +### The four panels (`kernel.png`; blue = neutral, red = kernel-on, green dashed = the real neural model) +**Top row — VAE regime (`n=6000`, `K=30`), pro-collapse:** +1. **Heterozygosity.** Neutral drift is **inert** — at `n=6000` it barely moves (`H` stays at + `H*`). Yet the real VAE (green) collapsed to `H≈0`. **Sharpening (`τ=0.8`) reproduces the + collapse.** Neutral drift is *falsified*; the estimator's mode-competition is required. +2. **Support.** Neutral holds ~all 30 modes; sharpening → 1 mode, matching the VAE. + +**Bottom row — RNN regime (`n=200`, `K=256`), anti-collapse:** +3. **Heterozygosity.** Neutral drift drives `H → 0`, but the real RNN (green) only *partially* + collapses (`H` floors at ~0.68). **Mutation `u=0.006` reproduces the floor.** The estimator here + *removes* collapse pressure. +4. **Forward-KL.** Neutral diverges; smoothing plateaus. Honest caveat: uniform-mutation plateaus + *above* the RNN's KL (~5 vs ~2) — evidence the RNN's smoothing target is **truth-like, not + uniform** (a refinement for future work). The *sign* is unambiguous. + +### Takeaway +Model collapse in real learners = **neutral drift ⊕ an architecture-specific estimator-bias +operator that can point either way.** The histogram sits at the neutral null (`u=0, τ=1`); the VAE +**sharpens** (adds collapse); the RNN/MLP **smooth** (add a diversity floor). This mechanistically +explains the Layer-1.5 architecture-generality result and the softened neural `g*`, and develops +the exact axis Riis (2026) names as future work ("different smoothing schemes… each induce their +own fixed-point geometry… a natural direction for further work"). `u`/`τ` are calibrated from a +single neural diagnostic and pinned in the configs. **Falsifier (not triggered):** if neutral drift +had already reproduced the neural curves, the estimator axis would be superfluous — instead it fails +in *both* regimes, oppositely. diff --git a/results/kernel_sharpen/kernel.pdf b/results/kernel_sharpen/kernel.pdf new file mode 100644 index 0000000..fe6543b Binary files /dev/null and b/results/kernel_sharpen/kernel.pdf differ diff --git a/results/kernel_sharpen/kernel.png b/results/kernel_sharpen/kernel.png new file mode 100644 index 0000000..17dfbf8 Binary files /dev/null and b/results/kernel_sharpen/kernel.png differ diff --git a/results/kernel_sharpen/manifest.json b/results/kernel_sharpen/manifest.json new file mode 100644 index 0000000..9606ad8 --- /dev/null +++ b/results/kernel_sharpen/manifest.json @@ -0,0 +1,14 @@ +{ + "experiment": "kernel_sharpen", + "master_seed": 20260705, + "git_commit": "79bbc45f41822757e60d7f1994a82c5171a9e554", + "python": "3.14.5", + "libraries": { + "numpy": "2.5.0", + "scipy": "1.18.0", + "pandas": "3.0.3", + "pyarrow": "24.0.0" + }, + "rows": 768, + "results_sha256": "d5651a841d58f12abed1339476b1839fb03fe02329e38d675d0602ccb5691e52" +} \ No newline at end of file diff --git a/results/kernel_sharpen/resolved_config.yaml b/results/kernel_sharpen/resolved_config.yaml new file mode 100644 index 0000000..116f15e --- /dev/null +++ b/results/kernel_sharpen/resolved_config.yaml @@ -0,0 +1,82 @@ +experiment: kernel_sharpen +seed: 20260705 +n_replicates: 24 +source_config: + experiment: kernel_sharpen + kind: lineage + seed: 20260705 + n_replicates: 24 + truth: + K: 30 + R: 1 + tail: zipf + zipf_s: 1.5 + tail_threshold: 0.01 + init: truth + dynamics: + n: 6000 + grounding: + m: 0 + policy: proportional + kernel: + reset: 0.0 + temperature: 1.0 + floor: 0.0 + generations: 15 + metrics: + kl_floor: 1.0e-09 + support_eps: 1.0e-09 + sweep: + - param: dynamics.kernel.temperature + values: + - 1.0 + - 0.8 + output: + dir: results/kernel_sharpen +grid: +- label: + temperature: 1.0 + lineage_cfg: + truth: + K: 30 + R: 1 + tail: zipf + zipf_s: 1.5 + tail_threshold: 0.01 + init: truth + dynamics: + n: 6000 + grounding: + m: 0 + policy: proportional + kernel: + reset: 0.0 + temperature: 1.0 + floor: 0.0 + generations: 15 + metrics: + kl_floor: 1.0e-09 + support_eps: 1.0e-09 +- label: + temperature: 0.8 + lineage_cfg: + truth: + K: 30 + R: 1 + tail: zipf + zipf_s: 1.5 + tail_threshold: 0.01 + init: truth + dynamics: + n: 6000 + grounding: + m: 0 + policy: proportional + kernel: + reset: 0.0 + temperature: 0.8 + floor: 0.0 + generations: 15 + metrics: + kl_floor: 1.0e-09 + support_eps: 1.0e-09 diff --git a/results/kernel_smooth/README.md b/results/kernel_smooth/README.md new file mode 100644 index 0000000..83ee22c --- /dev/null +++ b/results/kernel_smooth/README.md @@ -0,0 +1,42 @@ +# kernel — the learning kernel: why real learners deviate from neutral drift + +*(This legend covers both `results/kernel_sharpen/` and `results/kernel_smooth/`; the figure +`kernel.png` is written into both.)* + +**Claim tested.** Neutral Wright–Fisher drift (the histogram bridge, and the baseline of Riis +2026) is the *null* model of collapse. But Layer 1.5 showed real trained models deviate from it — +and in **opposite directions**. Can a single extension of Layer 1 — a parameterized *learning +kernel* on the refit step, `p_{t+1} = T_θ(counts/n)` — reproduce both deviations, and does neutral +drift genuinely fail without it? + +**Setup.** The kernel (`knowledge/kernel.py`) has two population-genetics knobs, both reducing to +neutral drift at their defaults (so the histogram and every scientific-validation test are +unchanged): **reset `u`** — mutation toward a prior (`p ← (1−u)p + u·π`), i.e. smoothing; and +**temperature `τ`** — sharpening (`p ∝ p^{1/τ}`, `τ<1` concentrates), i.e. mode-competition. Two +matched-to-neural regimes, 24 replicates each. + +### The four panels (`kernel.png`; blue = neutral, red = kernel-on, green dashed = the real neural model) +**Top row — VAE regime (`n=6000`, `K=30`), pro-collapse:** +1. **Heterozygosity.** Neutral drift is **inert** — at `n=6000` it barely moves (`H` stays at + `H*`). Yet the real VAE (green) collapsed to `H≈0`. **Sharpening (`τ=0.8`) reproduces the + collapse.** Neutral drift is *falsified*; the estimator's mode-competition is required. +2. **Support.** Neutral holds ~all 30 modes; sharpening → 1 mode, matching the VAE. + +**Bottom row — RNN regime (`n=200`, `K=256`), anti-collapse:** +3. **Heterozygosity.** Neutral drift drives `H → 0`, but the real RNN (green) only *partially* + collapses (`H` floors at ~0.68). **Mutation `u=0.006` reproduces the floor.** The estimator here + *removes* collapse pressure. +4. **Forward-KL.** Neutral diverges; smoothing plateaus. Honest caveat: uniform-mutation plateaus + *above* the RNN's KL (~5 vs ~2) — evidence the RNN's smoothing target is **truth-like, not + uniform** (a refinement for future work). The *sign* is unambiguous. + +### Takeaway +Model collapse in real learners = **neutral drift ⊕ an architecture-specific estimator-bias +operator that can point either way.** The histogram sits at the neutral null (`u=0, τ=1`); the VAE +**sharpens** (adds collapse); the RNN/MLP **smooth** (add a diversity floor). This mechanistically +explains the Layer-1.5 architecture-generality result and the softened neural `g*`, and develops +the exact axis Riis (2026) names as future work ("different smoothing schemes… each induce their +own fixed-point geometry… a natural direction for further work"). `u`/`τ` are calibrated from a +single neural diagnostic and pinned in the configs. **Falsifier (not triggered):** if neutral drift +had already reproduced the neural curves, the estimator axis would be superfluous — instead it fails +in *both* regimes, oppositely. diff --git a/results/kernel_smooth/kernel.pdf b/results/kernel_smooth/kernel.pdf new file mode 100644 index 0000000..671073e Binary files /dev/null and b/results/kernel_smooth/kernel.pdf differ diff --git a/results/kernel_smooth/kernel.png b/results/kernel_smooth/kernel.png new file mode 100644 index 0000000..17dfbf8 Binary files /dev/null and b/results/kernel_smooth/kernel.png differ diff --git a/results/kernel_smooth/manifest.json b/results/kernel_smooth/manifest.json new file mode 100644 index 0000000..11c071a --- /dev/null +++ b/results/kernel_smooth/manifest.json @@ -0,0 +1,14 @@ +{ + "experiment": "kernel_smooth", + "master_seed": 20260705, + "git_commit": "79bbc45f41822757e60d7f1994a82c5171a9e554", + "python": "3.14.5", + "libraries": { + "numpy": "2.5.0", + "scipy": "1.18.0", + "pandas": "3.0.3", + "pyarrow": "24.0.0" + }, + "rows": 4848, + "results_sha256": "8e50c084f8b3ca707375e677a977c00ac36aa96b3363ebecefad2d96a9912f3f" +} \ No newline at end of file diff --git a/results/kernel_smooth/resolved_config.yaml b/results/kernel_smooth/resolved_config.yaml new file mode 100644 index 0000000..c07457e --- /dev/null +++ b/results/kernel_smooth/resolved_config.yaml @@ -0,0 +1,82 @@ +experiment: kernel_smooth +seed: 20260705 +n_replicates: 24 +source_config: + experiment: kernel_smooth + kind: lineage + seed: 20260705 + n_replicates: 24 + truth: + K: 256 + R: 1 + tail: zipf + zipf_s: 1.3 + tail_threshold: 0.001 + init: truth + dynamics: + n: 200 + grounding: + m: 0 + policy: proportional + kernel: + reset: 0.0 + temperature: 1.0 + floor: 0.0 + generations: 100 + metrics: + kl_floor: 1.0e-09 + support_eps: 1.0e-09 + sweep: + - param: dynamics.kernel.reset + values: + - 0.0 + - 0.006 + output: + dir: results/kernel_smooth +grid: +- label: + reset: 0.0 + lineage_cfg: + truth: + K: 256 + R: 1 + tail: zipf + zipf_s: 1.3 + tail_threshold: 0.001 + init: truth + dynamics: + n: 200 + grounding: + m: 0 + policy: proportional + kernel: + reset: 0.0 + temperature: 1.0 + floor: 0.0 + generations: 100 + metrics: + kl_floor: 1.0e-09 + support_eps: 1.0e-09 +- label: + reset: 0.006 + lineage_cfg: + truth: + K: 256 + R: 1 + tail: zipf + zipf_s: 1.3 + tail_threshold: 0.001 + init: truth + dynamics: + n: 200 + grounding: + m: 0 + policy: proportional + kernel: + reset: 0.006 + temperature: 1.0 + floor: 0.0 + generations: 100 + metrics: + kl_floor: 1.0e-09 + support_eps: 1.0e-09 diff --git a/src/knowledge/config.py b/src/knowledge/config.py index 664c418..6d6f45d 100644 --- a/src/knowledge/config.py +++ b/src/knowledge/config.py @@ -11,6 +11,8 @@ from __future__ import annotations from dataclasses import dataclass, field, replace from typing import Any, Mapping, Optional +from .kernel import LearningKernelCfg + # Runtime-tunable knobs live here, defaults chosen to match the blueprint's illustrative # schema. Nothing here is a magic number buried in algorithm code. @@ -61,6 +63,7 @@ class DynamicsCfg: grounding: GroundingCfg = field(default_factory=GroundingCfg) selection: SelectionCfg = field(default_factory=SelectionCfg) remint: RemintCfg = field(default_factory=RemintCfg) + kernel: LearningKernelCfg = field(default_factory=LearningKernelCfg) @dataclass(frozen=True) @@ -89,6 +92,7 @@ class LineageCfg: grounding=_sub(dyn_raw.get("grounding", {}), GroundingCfg), selection=_sub(dyn_raw.get("selection", {}), SelectionCfg), remint=_sub(dyn_raw.get("remint", {}), RemintCfg), + kernel=_sub(dyn_raw.get("kernel", {}), LearningKernelCfg), ) metrics = _sub(cfg.get("metrics", {}), MetricsCfg) return LineageCfg( diff --git a/src/knowledge/kernel.py b/src/knowledge/kernel.py new file mode 100644 index 0000000..fc53e3c --- /dev/null +++ b/src/knowledge/kernel.py @@ -0,0 +1,87 @@ +"""The learning kernel — the estimator/inductive-bias operator (Layer-1 extension). + +Neutral Wright-Fisher models the generational step as *resample and refit the raw empirical +distribution* (``p_{t+1} = counts/n``). A real learner does not refit the raw histogram: it +applies a **biased estimator** — it smooths (regularises toward a simpler distribution) and it +can sharpen (concentrate mass, drop weakly-supported modes). Layer 1.5 measured exactly these +biases: the RNN/MLP *over-smooth* (H resists collapse, spurious tail support stays alive), the +VAE *self-reinforces* (accelerating collapse to a single mode). This module turns the refit into +a parameterised kernel ``p_{t+1} = T_θ(counts/n)`` so the analytic core can reproduce those +deviations — the axis Riis (2026) names as future work. + +Two population-genetics knobs, both reducing to the neutral null at their defaults: + +* **reset ``u``** — mutation toward a prior: ``p <- (1-u)·p + u·π``. Models smoothing / + regularisation. Gives a diversity floor (H stops collapsing to 0), keeps rare modes alive, and + softens the grounding threshold — the RNN/MLP signature. +* **temperature ``τ`` + floor ``ε``** — sharpening / support pruning: ``p ∝ p^{1/τ}`` then drop + mass below ``ε``. Models the winner-take-all mode competition of a mode-dropping generator — + the VAE signature. ``τ<1`` sharpens (pro-collapse). + +Identity (``u=0, τ=1, ε=0``) recovers Layer 1 exactly, so the histogram bridge and every +scientific-validation test are unchanged. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + + +@dataclass(frozen=True) +class LearningKernelCfg: + """Estimator-bias knobs for the generational refit (all defaults = neutral Wright-Fisher). + + Attributes: + reset (float): Mutation rate ``u`` toward the prior (smoothing). 0 = off. + temperature (float): Sharpening temperature ``τ`` (``p ∝ p^{1/τ}``); <1 sharpens + (mode competition), >1 flattens, 1 = off. + floor (float): Hard support threshold ``ε``: modes below it are dropped. 0 = off. + prior (str): Smoothing target for ``reset``: currently ``uniform``. + """ + + reset: float = 0.0 + temperature: float = 1.0 + floor: float = 0.0 + prior: str = "uniform" + + @property + def is_identity(self) -> bool: + """True when the kernel is the neutral refit (recovers Layer 1 exactly).""" + return self.reset == 0.0 and self.temperature == 1.0 and self.floor == 0.0 + + +def _prior_vector(kind: str, K: int) -> np.ndarray: + """Return the smoothing-target distribution of length ``K``.""" + if kind == "uniform": + return np.full(K, 1.0 / K) + raise ValueError(f"unknown kernel prior {kind!r} (expected uniform)") + + +def apply_kernel(p: np.ndarray, cfg: LearningKernelCfg) -> np.ndarray: + """Apply the estimator-bias kernel to a refit distribution. + + Composition order: mutation toward the prior (keeps modes alive) -> sharpening (concentrates + mass) -> support floor (drops weak modes). Each step is a no-op at its default. + + Args: + p (np.ndarray): The raw refit distribution ``counts/n`` (length ``K``, sums to 1). + cfg (LearningKernelCfg): The kernel knobs. + + Returns: + np.ndarray: The estimator's distribution ``p_{t+1}`` (length ``K``, sums to 1). + """ + p = np.asarray(p, dtype=float) + if cfg.is_identity: + return p + K = p.size + if cfg.reset > 0.0: # mutation toward the prior (smoothing) + p = (1.0 - cfg.reset) * p + cfg.reset * _prior_vector(cfg.prior, K) + if cfg.temperature != 1.0: # sharpening / flattening + with np.errstate(divide="ignore"): + p = np.power(p, 1.0 / cfg.temperature) + if cfg.floor > 0.0: # hard support pruning + p = np.where(p < cfg.floor, 0.0, p) + total = p.sum() + return p / total if total > 0 else np.full(K, 1.0 / K) diff --git a/src/knowledge/lineage.py b/src/knowledge/lineage.py index 7a628f4..f27755a 100644 --- a/src/knowledge/lineage.py +++ b/src/knowledge/lineage.py @@ -66,6 +66,7 @@ def run_lineage(cfg: Mapping[str, Any] | LineageCfg, seed: int) -> pd.DataFrame: regions=regions, selection_mode=cfg.dynamics.selection.mode, novelty_alpha=cfg.dynamics.selection.novelty_alpha, + kernel=cfg.dynamics.kernel, ) remint = cfg.dynamics.remint diff --git a/src/knowledge/step.py b/src/knowledge/step.py index 29616a4..dce6304 100644 --- a/src/knowledge/step.py +++ b/src/knowledge/step.py @@ -7,10 +7,12 @@ in the perspective paper is one operator here; they compose in the order below. from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field import numpy as np +from .kernel import LearningKernelCfg, apply_kernel + @dataclass(frozen=True) class StepCtx: @@ -24,6 +26,8 @@ class StepCtx: regions (np.ndarray): Length-K region index per item. selection_mode (str): ``none`` | ``greedy`` | ``qd``. novelty_alpha (float): QD novelty exponent (0 recovers greedy). + kernel (LearningKernelCfg): Estimator-bias kernel applied to the refit (default + identity = neutral Wright-Fisher). """ n: int @@ -32,6 +36,7 @@ class StepCtx: regions: np.ndarray selection_mode: str = "none" novelty_alpha: float = 0.0 + kernel: LearningKernelCfg = field(default_factory=LearningKernelCfg) def _normed(p: np.ndarray) -> np.ndarray: @@ -166,6 +171,7 @@ def generation_step(teachers: list[np.ndarray], p_star_eff: np.ndarray, cfg.regions, cfg.policy, rng) counts = c_syn + c_real p_next = counts / counts.sum() + p_next = apply_kernel(p_next, cfg.kernel) # (estimator bias) p_next = apply_selection(p_next, p_star_eff, # (selection) cfg.selection_mode, cfg.novelty_alpha) return p_next diff --git a/tasks/todo.md b/tasks/todo.md index 803f72b..ac81273 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -245,8 +245,33 @@ C3 vertical claim deferred.* confirmed on real images. VAE needs ~10% grounding vs synthetic ~5% (cf. the `grounding` finding). **99 tests green** (+5 torchvision-gated). `make mnist` / `make env-mnist` (kept out of `make neural`). +**2026-07-05 — learning kernel (Layer-1 extension) + Riis positioning.** + +- Prompted by revisiting Layer 1 vs 1.5 and the Riis 2026 paper (arXiv:2604.08554). Added + `knowledge/kernel.py` (`LearningKernelCfg`: reset `u` = smoothing, temperature `τ` = sharpening, + floor `ε`), wired into `step.generation_step` (post-refit) / `StepCtx` / `DynamicsCfg` — **identity + by default, so the 68 Layer-1 scientific-validation + correctness tests are unchanged.** +- **Result:** neutral Wright–Fisher fails BOTH neural models, oppositely. VAE regime (n=6000,K=30): + neutral is inert, sharpening `τ=0.8` reproduces the collapse-to-one-mode. RNN regime (n=200,K=256): + neutral → H=0, mutation `u=0.006` reproduces the H-floor (~0.68). Uniform-mutation overshoots the + RNN's KL → its prior is truth-like, not uniform (honest caveat, future refinement). +- `configs/layer1/kernel_{sharpen,smooth}.yaml`, `figures/plot_kernel.py` (overlays analytic arms vs + the committed neural endpoints), READMEs, `tests/test_kernel.py` (+6). Wired into `make layer1`. +- **Strategic (see CLAUDE.md finding):** concede "collapse=drift" to Riis (prior art; cite); his + mixed environment retains OLD SYNTHETIC (no real-data injection) → pessimistic, no g* that prevents + collapse. Our defensible novelty: recombination "merge-don't-average" (flagship), the learning-kernel + axis (he flags as future work), grounding-as-immigration, real-weights+MNIST breadth, and the + Lamarckian society + vertical/cumulative C3 claim (not yet run). Reposition: from "collapse is drift" + to a population-genetic CONTROL THEORY for sustaining open-ended knowledge. + ## Remaining (all optional) +- [ ] **Learning-kernel refinement:** truth-like smoothing prior (`prior="truth"`) + measurement floor + for a quantitative RNN match; **multi-locus / linkage** modes (class×style) as the rigorous home for + recombination. Both enrich predictive power and separate us further from Riis's single-locus n-grams. +- [ ] **The Lamarckian society experiments** (multi-agent grounding + decorrelated specialists + + recombination + QD-selection + re-mint) and the **vertical/cumulative C3 claim** — the highest-ceiling, + wholly-novel frame; not yet entered. - [ ] **`region_matched`** grounding (R>1), **`remint`** re-mint gate (optional). - [ ] **VAE fidelity:** fix the prior-hole mismatch (KL-annealing / free-bits / larger latent) so it clears the gen-0 gate, then add to `architectures`. Or document as a known limitation. diff --git a/tests/test_kernel.py b/tests/test_kernel.py new file mode 100644 index 0000000..63eee7f --- /dev/null +++ b/tests/test_kernel.py @@ -0,0 +1,72 @@ +"""Learning-kernel tests (pure NumPy). + +The kernel is an additive Layer-1 extension: identity by default (so the neutral Wright-Fisher +core and every scientific-validation test are unchanged), a sharpening knob that ADDS collapse +where neutral drift is inert, and a smoothing knob that supplies a diversity FLOOR where neutral +drift would collapse to zero. These assert exactly those three behaviours. +""" + +from __future__ import annotations + +import numpy as np + +from knowledge.kernel import LearningKernelCfg, apply_kernel +from knowledge.lineage import run_lineage +from knowledge.metrics import heterozygosity + + +def test_apply_kernel_identity_is_noop(): + p = np.array([0.5, 0.3, 0.15, 0.05]) + assert np.array_equal(apply_kernel(p, LearningKernelCfg()), p) + + +def test_apply_kernel_reset_mixes_toward_uniform(): + p = np.array([1.0, 0.0, 0.0, 0.0]) + out = apply_kernel(p, LearningKernelCfg(reset=0.2)) + assert np.isclose(out.sum(), 1.0) + assert np.allclose(out, [0.8 + 0.2 / 4, 0.05, 0.05, 0.05]) # keeps dead modes alive + assert (out > 0).all() + + +def test_apply_kernel_sharpen_and_floor_prune(): + p = np.array([0.6, 0.3, 0.09, 0.01]) + sharp = apply_kernel(p, LearningKernelCfg(temperature=0.5)) # p^2, renormalised + assert sharp[0] > p[0] and sharp[-1] < p[-1] # mass concentrates + floored = apply_kernel(p, LearningKernelCfg(floor=0.05)) + assert floored[-1] == 0.0 and np.isclose(floored.sum(), 1.0) # weak mode dropped + + +def test_kernel_identity_leaves_lineage_unchanged(): + base = {"truth": {"K": 64, "init": "truth"}, "dynamics": {"n": 200}, + "generations": 20, "metrics": {"kl_floor": 1e-9, "support_eps": 1e-9}} + withk = {**base, "dynamics": {"n": 200, "kernel": {"reset": 0.0, "temperature": 1.0}}} + a = run_lineage(base, seed=3)["heterozygosity"].to_numpy() + b = run_lineage(withk, seed=3)["heterozygosity"].to_numpy() + assert np.array_equal(a, b) # identity kernel == neutral Wright-Fisher, bitwise + + +def test_sharpening_adds_collapse_where_neutral_is_inert(): + # Large n vs small K: neutral drift barely collapses; sharpening drives it to ~1 mode. + cfg = {"truth": {"K": 30, "tail": "zipf", "zipf_s": 1.5, "tail_threshold": 1e-2, + "init": "truth"}, + "dynamics": {"n": 6000, "grounding": {"m": 0}}, + "generations": 15, "metrics": {"kl_floor": 1e-9, "support_eps": 1e-9}} + neutral = run_lineage(cfg, seed=0) + sharp = run_lineage({**cfg, "dynamics": {**cfg["dynamics"], + "kernel": {"temperature": 0.8}}}, seed=0) + assert neutral["support_size"].iloc[-1] > 20 # neutral: ~all modes survive + assert sharp["support_size"].iloc[-1] <= 3 # sharpening: collapse to a point + assert sharp["heterozygosity"].iloc[-1] < 0.1 + + +def test_smoothing_floors_diversity_where_neutral_collapses(): + # Small n vs large K: neutral drift drives H toward 0; smoothing holds a positive floor. + cfg = {"truth": {"K": 256, "tail": "zipf", "zipf_s": 1.3, "tail_threshold": 1e-3, + "init": "truth"}, + "dynamics": {"n": 200, "grounding": {"m": 0}}, + "generations": 120, "metrics": {"kl_floor": 1e-9, "support_eps": 1e-9}} + neutral = run_lineage(cfg, seed=0) + smooth = run_lineage({**cfg, "dynamics": {**cfg["dynamics"], + "kernel": {"reset": 0.006}}}, seed=0) + assert neutral["heterozygosity"].iloc[-1] < 0.4 # neutral collapses + assert smooth["heterozygosity"].iloc[-1] > 0.55 # smoothing floors H well above neutral