Layer 1 core: Wright-Fisher knowledge-transmission model with E1-E2

Scaffold plus the Layer 1 analytical core and the first two experiments.

- knowledge/: truth, metrics, teachers (2.7.1 shared-switch construction),
  step, lineage, experiment, config, seeding (imported as `knowledge`).
- Validation spine green: neutral decay (Pred 1), fixation (Pred 2), exact
  mutation-drift equilibrium (Pred 3), union coverage (Pred 5). 68 tests pass.
- E1 reproduces tail-first collapse. E2 delivers the headline: a grounding
  phase boundary g* << 1, with stationary H tracking the exact H_eq closed
  form (g=0.005 -> 68% of truth diversity; g=0.05 -> 96%).
- Reproducibility: uv venv from a hash-pinned uv.lock is the source of truth;
  every run writes results.parquet + resolved_config.yaml + manifest.json
  (lib versions, git commit, sha256). Figures and manifests tracked; the
  large regenerable parquet is gitignored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Giorgio Gilestro 2026-07-04 18:10:18 +02:00
commit a6eb9b7512
33 changed files with 4356 additions and 0 deletions

169
tests/test_correctness.py Normal file
View file

@ -0,0 +1,169 @@
"""Correctness tests (blueprint 4): shapes, normalisation, determinism.
These check that each module *does what it says* distinct from the scientific-validation
suite, which checks that the dynamics reproduce the analytic targets. Cheap and fast.
"""
from __future__ import annotations
import numpy as np
import pytest
from knowledge.config import LineageCfg
from knowledge.metrics import forward_kl, heterozygosity, support_size, tail_mass
from knowledge.step import allocate_m, apply_selection, generation_step, StepCtx, \
structured_multinomial
from knowledge.teachers import make_correlated_teachers, make_retention_matrix
from knowledge.truth import make_true_distribution, uniform_init
from knowledge.lineage import run_lineage
# ---- truth --------------------------------------------------------------------------
def test_true_distribution_normalised_and_shaped():
td = make_true_distribution(K=100, R=10, tail="zipf", tail_frac=0.5, zipf_s=1.1, seed=0)
assert td.p_star.shape == (100,)
assert td.p_star.sum() == pytest.approx(1.0)
assert np.all(td.p_star > 0)
# 10 equal regions of 10 items each
assert np.bincount(td.regions).tolist() == [10] * 10
assert td.tail_mask.dtype == bool
def test_regions_equal_mass():
td = make_true_distribution(K=100, R=10, tail="zipf", tail_frac=0.5, zipf_s=1.1, seed=0)
masses = [td.p_star[td.regions == r].sum() for r in range(10)]
assert np.allclose(masses, 0.1) # each region carries 1/R
def test_true_distribution_requires_divisible():
with pytest.raises(ValueError):
make_true_distribution(K=100, R=7, tail="zipf", tail_frac=0.5, zipf_s=1.1, seed=0)
def test_twocomponent_tail_below_threshold():
td = make_true_distribution(K=100, R=1, tail="twocomponent", tail_frac=0.5,
zipf_s=1.1, seed=0, tail_threshold=1e-3)
assert td.tail_mask.sum() > 0
assert np.all(td.p_star[td.tail_mask] < 1e-3)
# ---- metrics ------------------------------------------------------------------------
def test_heterozygosity_extremes():
assert heterozygosity(np.array([1.0, 0.0, 0.0])) == pytest.approx(0.0)
assert heterozygosity(np.full(10, 0.1)) == pytest.approx(1 - 1 / 10)
def test_forward_kl_zero_when_equal_and_positive_otherwise():
p = np.array([0.5, 0.3, 0.2])
assert forward_kl(p, p, eps=1e-9) == pytest.approx(0.0, abs=1e-12)
q = np.array([0.6, 0.3, 0.1])
assert forward_kl(p, q, eps=1e-9) > 0
def test_tail_mass_and_support():
p = np.array([0.7, 0.2, 0.1, 0.0])
mask = np.array([False, False, True, True])
assert tail_mass(p, mask) == pytest.approx(0.1)
assert support_size(p, eps=1e-9) == 3
# ---- teachers -----------------------------------------------------------------------
def test_retention_matrix_shape_and_dtype():
rng = np.random.default_rng(0)
R = make_retention_matrix(T=1000, K_T=3, rho=0.5, q=0.3, rng=rng)
assert R.shape == (3, 1000)
assert set(np.unique(R)).issubset({0, 1})
def test_correlated_teachers_normalised():
td = make_true_distribution(K=200, R=4, tail="zipf", tail_frac=0.5, zipf_s=1.1, seed=1)
teachers = make_correlated_teachers(td.p_star, td.tail_mask, K_T=3, rho=0.0, q=0.5, seed=2)
assert len(teachers) == 3
for p in teachers:
assert p.sum() == pytest.approx(1.0)
assert np.all(p > 0)
def test_region_specialisation_retains_home_tails():
td = make_true_distribution(K=200, R=4, tail="zipf", tail_frac=0.5, zipf_s=1.1, seed=1)
teachers = make_correlated_teachers(
td.p_star, td.tail_mask, K_T=4, rho=0.0, q=0.0,
region_assignment=td.regions, region_specialisation=True, seed=3)
# teacher k fully retains its home region's tails even at q=0
tail_idx = np.flatnonzero(td.tail_mask)
home0_tail = tail_idx[td.regions[tail_idx] == 0]
assert np.all(teachers[0][home0_tail] > 1e-6) # kept at p_star, not floored
# ---- step ---------------------------------------------------------------------------
def test_generation_step_sums_to_one():
td = make_true_distribution(K=50, R=1, tail="zipf", tail_frac=0.5, zipf_s=1.1, seed=0)
rng = np.random.default_rng(0)
ctx = StepCtx(n=200, m_vector=allocate_m(20, 1, "uniform"), policy="uniform",
regions=td.regions)
p = generation_step([uniform_init(50)], td.p_star, ctx, rng)
assert p.sum() == pytest.approx(1.0)
assert np.all(p >= 0)
def test_structured_multinomial_counts_sum_to_budget():
td = make_true_distribution(K=100, R=10, tail="zipf", tail_frac=0.5, zipf_s=1.1, seed=0)
rng = np.random.default_rng(0)
m_vec = allocate_m(50, 10, "uniform")
counts = structured_multinomial(m_vec, td.p_star, td.regions, "uniform", rng)
assert counts.sum() == 50
# each region gets exactly its budget
for r in range(10):
assert counts[td.regions == r].sum() == m_vec[r]
def test_allocate_m_splits_remainder():
m = allocate_m(23, 10, "uniform")
assert m.sum() == 23
assert m.max() - m.min() <= 1 # as even as possible
def test_selection_none_is_identity():
p = np.array([0.5, 0.3, 0.2])
assert np.allclose(apply_selection(p, p, "none", 0.0), p)
def test_greedy_selection_concentrates_on_high_fitness():
p = np.array([0.4, 0.4, 0.2])
f = np.array([0.1, 0.1, 0.8]) # item 2 is fittest
out = apply_selection(p, f, "greedy", 0.0)
assert out[2] > p[2] # fitness-proportional shifts mass toward the fit item
# ---- lineage ------------------------------------------------------------------------
def _cfg(**over):
base = {
"truth": {"K": 50, "R": 1, "tail": "zipf", "zipf_s": 1.1,
"tail_frac": 0.5, "tail_threshold": 1e-3},
"dynamics": {"n": 100, "grounding": {"m": 0, "policy": "uniform"}},
"generations": 20,
"metrics": {"kl_floor": 1e-9},
}
base.update(over)
return base
def test_run_lineage_rows_and_columns():
df = run_lineage(_cfg(), seed=0)
assert len(df) == 21 # generations 0..20
for col in ("generation", "heterozygosity", "forward_kl", "tail_mass", "support_size"):
assert col in df.columns
assert df["generation"].tolist() == list(range(21))
def test_run_lineage_is_deterministic():
a = run_lineage(_cfg(), seed=42)
b = run_lineage(_cfg(), seed=42)
assert a.equals(b)
def test_run_lineage_different_seeds_differ():
a = run_lineage(_cfg(), seed=1)
b = run_lineage(_cfg(), seed=2)
assert not a["heterozygosity"].equals(b["heterozygosity"])
def test_per_region_columns_present_when_multiregion():
df = run_lineage(_cfg(truth={"K": 100, "R": 5, "tail": "zipf", "zipf_s": 1.1,
"tail_frac": 0.5, "tail_threshold": 1e-3}), seed=0)
assert "H_region_0" in df.columns
assert "tail_region_4" in df.columns
def test_config_rejects_unknown_keys():
with pytest.raises(ValueError):
LineageCfg.from_dict({"truth": {"K": 10, "bogus": 1}})