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

95
src/knowledge/truth.py Normal file
View file

@ -0,0 +1,95 @@
"""The true distribution p* over K knowledge items (blueprint 2.1, 2.7).
``p*`` is fixed and deliberately heavy-tailed: most mass on common ("head") items, a
long thin tail of rare items whose loss *is* model collapse. Items are partitioned into
R disjoint regions; regions are what grounding and specialisation target.
Region design: each region is an identical, independently-normalised block carrying
mass 1/R, so regions are symmetric and every region has its own head and tail. This
makes region-matched grounding (E3) well posed and, at R=1, reduces to a single global
Zipf identical to the reference used by the scientific-validation suite.
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
@dataclass(frozen=True)
class TrueDist:
"""The fixed ground truth for a lineage.
Attributes:
p_star (np.ndarray): Length-K probability vector (sums to 1).
regions (np.ndarray): Length-K int array; region index of each item.
tail_mask (np.ndarray): Length-K bool array; True for designated tail items.
"""
p_star: np.ndarray
regions: np.ndarray
tail_mask: np.ndarray
def make_true_distribution(
K: int,
R: int,
tail: str,
tail_frac: float,
zipf_s: float,
seed: int,
*,
tail_threshold: float = 1e-3,
) -> TrueDist:
"""Construct the true distribution, region assignment, and tail mask.
Args:
K (int): Number of knowledge items. Must be divisible by R.
R (int): Number of regions (disjoint contiguous blocks of size K/R).
tail (str): Tail family, ``"zipf"`` or ``"twocomponent"``.
tail_frac (float): For ``twocomponent``, the fraction of each region's items
placed in the low-mass tail component. Unused for ``zipf``.
zipf_s (float): Zipf exponent (larger -> heavier head, thinner tail).
seed (int): Present for interface symmetry; the construction is deterministic,
so the seed only matters if a randomised item ordering is added later.
tail_threshold (float): Items with ``p*_i < tail_threshold`` are the tail
(blueprint 2.3). Keyword-only.
Returns:
TrueDist: p_star, regions, tail_mask.
Raises:
ValueError: If ``K`` is not divisible by ``R`` or ``tail`` is unknown.
"""
if K % R != 0:
raise ValueError(f"K={K} must be divisible by R={R} (contiguous equal regions).")
per = K // R
regions = np.repeat(np.arange(R), per)
if tail == "zipf":
block = 1.0 / np.arange(1, per + 1, dtype=float) ** zipf_s
block = block / block.sum() # each region normalised to 1
elif tail == "twocomponent":
n_tail = max(1, int(round(tail_frac * per)))
block = np.ones(per, dtype=float)
# Tail items sit safely below threshold; heads carry the rest. Per-region mass
# is 1/R after the global normalisation below.
block[per - n_tail:] = tail_threshold * 0.5 * R
block = block / block.sum()
else:
raise ValueError(f"unknown tail family {tail!r} (expected zipf|twocomponent)")
p_star = np.tile(block, R) / R # R blocks, total mass 1
p_star = p_star / p_star.sum() # guard float drift
tail_mask = p_star < tail_threshold
return TrueDist(p_star=p_star, regions=regions, tail_mask=tail_mask)
def uniform_init(K: int) -> np.ndarray:
"""The maximum-entropy initial distribution p_0 = 1/K (blueprint: the fresh base).
The default lineage start. The neutral-decay law E[H_t]=H_0(1-1/n)^t holds from any
start, so this choice fixes H_0 = 1 - 1/K without loss of generality.
"""
return np.full(K, 1.0 / K)