Layer 1.5: architecture-general neural existence proof

Re-scopes Layer 2 into a cheaper, architecture-general neural collapse proof
before the LLM rung. Realises the same Wright–Fisher abstractions in real trained
generative models on a fully-synthetic sandbox with an exact oracle, reusing
knowledge.metrics/truth/seeding and the output contract so neural curves overlay
the Layer-1 analytic curves.

  - src/neural/: synthetic token-grammar sandbox (lossless identity + stochastic
    style), ExactOracle, HistogramModel bridge, generation loop, experiment runner
  - HARD GATE passed: histogram lineage reproduces Layer 1 exactly (neutral decay,
    exact H_eq, tracks run_lineage) — tests/test_neural_validation.py
  - torch models: autoregressive RNN + MLP (VAE implemented, not yet fidelity-
    passing); determinism seeding derived from the SeedSequence stream
  - N0 bridge (neural g*=0.047 ≈ Layer-1 0.048), N1 collapse-in-weights, N2 phase
    boundary, N5 architecture-generality (collapse + grounding-rescue in histogram
    + RNN + MLP). Manifests/configs committed; parquet gitignored, hashes tracked
  - additive backward-compatible save_artifacts extension; Makefile neural targets

Finding: neural smoothing partially resists H-collapse, so forward-KL and tail
survival are the sharp neural collapse metrics (H is smooth, per Layer 1).

92 tests green. Remaining (tasks/todo.md): N4 merge, N2 refine, N3/N6, VAE
fidelity, MNIST tier, figures. LLM/LoRA rung and C3 deferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Giorgio Gilestro 2026-07-04 21:02:49 +01:00
parent 1721d047fa
commit 840b6b00b3
35 changed files with 3679 additions and 23 deletions

13
src/neural/__init__.py Normal file
View file

@ -0,0 +1,13 @@
"""Layer 1.5 — the architecture-general neural existence proof.
Realises the Layer-1 (``knowledge``) Wright-Fisher abstractions in *real trained
generative models* on a fully-synthetic sandbox whose ground-truth ``p*`` is known
exactly. A model's knowledge is measured as its output distribution over ``K`` discrete
*modes* (via an oracle), so the same metrics (``knowledge.metrics``), the same closed
forms, and the same experiments carry over — a neural collapse curve can be overlaid on
a Layer-1 analytic curve.
The package is staged by cost: the histogram model (pure NumPy) reduces this layer
*exactly* to Layer 1 and is the validation bridge; the RNN/VAE/MLP models (torch, added
from Stage C) show that collapse is architecture-general.
"""

124
src/neural/config.py Normal file
View file

@ -0,0 +1,124 @@
"""Resolved run configuration for a neural (Layer 1.5) lineage.
Mirrors the ``knowledge.config`` idiom exactly: frozen dataclasses with a ``from_dict``
that fills defaults and rejects unknown keys via ``knowledge.config._sub``. The grounding,
re-mint, and metrics blocks are *reused verbatim* from ``knowledge.config`` so the neural
runner speaks the same schema as Layer 1 (grounding ``m``, the ``g -> m`` conversion, the
re-mint gate, and the KL/support floors are all identical). Only the data source
(``synthetic``) and the model (``model``) are neural-specific.
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field, replace
from typing import Any, Mapping
from knowledge.config import GroundingCfg, MetricsCfg, RemintCfg, _sub
@dataclass(frozen=True)
class SyntheticCfg:
"""The fully-synthetic mode-truth and observation grammar.
The first seven fields are the Layer-1 ``TruthCfg`` knobs (they build ``p*`` over the
``K`` modes via ``knowledge.truth.make_true_distribution``). The remaining fields
define how a mode is rendered to a categorical token sequence: an *identity* segment
that encodes the mode losslessly (read by the exact oracle) followed by a *style*
segment of within-mode stochastic tokens (so a real generative model has a
distribution to learn, not just a lookup table).
"""
K: int
R: int = 1
tail: str = "zipf"
zipf_s: float = 1.1
tail_frac: float = 0.5
tail_threshold: float = 1e-3
init: str = "uniform" # initial p_0 over modes: {uniform, truth}
style_len: int = 4 # style-segment length (within-mode entropy)
style_vocab: int = 6 # style token alphabet size
id_base: int = 2 # identity segment encodes the mode in this base
@property
def id_len(self) -> int:
"""Identity-segment length: fewest base-``id_base`` digits to index ``K`` modes."""
if self.K <= 1:
return 1
return max(1, math.ceil(math.log(self.K, self.id_base)))
@property
def vocab(self) -> int:
"""Token alphabet size (shared by identity and style segments)."""
return max(self.id_base, self.style_vocab)
@property
def seq_len(self) -> int:
"""Total observation length in tokens."""
return self.id_len + self.style_len
@dataclass(frozen=True)
class ModelCfg:
"""The generative learner. ``kind`` selects the architecture behind a thin adapter.
Neural hyperparameters are ignored by the ``histogram`` bridge model.
"""
kind: str = "histogram" # {histogram, rnn, vae, mlp}
hidden: int = 64
embed: int = 16
epochs: int = 30
lr: float = 1.0e-3
batch_size: int = 256
device: str = "auto" # {auto, cpu, cuda}
latent: int = 16 # VAE latent dimension (VAE only)
beta: float = 1.0 # VAE KL weight (VAE only)
# Samples used to estimate a neural model's mode distribution by generate-and-classify
# (ignored by the exact histogram bridge). Larger -> less measurement noise on p_hat.
n_eval: int = 8000
@dataclass(frozen=True)
class NeuralDynamicsCfg:
"""Generational dynamics: drift strength ``n`` + reused grounding/re-mint blocks."""
n: int = 4000 # pupil training-sample size (drift strength ~ 1/n)
grounding: GroundingCfg = field(default_factory=GroundingCfg)
remint: RemintCfg = field(default_factory=RemintCfg)
@dataclass(frozen=True)
class NeuralLineageCfg:
"""A fully-resolved neural lineage configuration."""
synthetic: SyntheticCfg
model: ModelCfg = field(default_factory=ModelCfg)
dynamics: NeuralDynamicsCfg = field(default_factory=NeuralDynamicsCfg)
generations: int = 30
metrics: MetricsCfg = field(default_factory=MetricsCfg)
@staticmethod
def from_dict(cfg: Mapping[str, Any]) -> "NeuralLineageCfg":
"""Build a validated NeuralLineageCfg from a nested mapping, filling defaults."""
if isinstance(cfg, NeuralLineageCfg):
return cfg
synthetic = _sub(cfg.get("synthetic", {}), SyntheticCfg)
model = _sub(cfg.get("model", {}), ModelCfg)
dyn_raw = dict(cfg.get("dynamics", {}))
dynamics = NeuralDynamicsCfg(
n=dyn_raw.get("n", NeuralDynamicsCfg.n),
grounding=_sub(dyn_raw.get("grounding", {}), GroundingCfg),
remint=_sub(dyn_raw.get("remint", {}), RemintCfg),
)
metrics = _sub(cfg.get("metrics", {}), MetricsCfg)
return NeuralLineageCfg(
synthetic=synthetic,
model=model,
dynamics=dynamics,
generations=int(cfg.get("generations", NeuralLineageCfg.generations)),
metrics=metrics,
)
def replace(self, **kw) -> "NeuralLineageCfg":
return replace(self, **kw)

77
src/neural/evaluate.py Normal file
View file

@ -0,0 +1,77 @@
"""Metrics for a neural lineage — the *same* row schema as ``knowledge.lineage``.
``measure_metrics`` takes a model's oracle-measured mode distribution ``p_hat`` and emits a
row with exactly the columns Layer 1 logs per generation (``knowledge.lineage.record``),
computed with the *same* ``knowledge.metrics`` functions. Identical columns are what let a
neural collapse curve be plotted on top of an analytic one, and let the same figure and
analysis code (``knowledge.analysis``) run unchanged.
"""
from __future__ import annotations
import numpy as np
from knowledge.config import MetricsCfg
from knowledge.lineage import N_BANDS
from knowledge.metrics import (
forward_kl,
heterozygosity,
per_region,
support_size,
tail_band_metrics,
tail_mass,
)
def measure_metrics(p: np.ndarray, p_star_orig: np.ndarray, tail_mask: np.ndarray,
regions: np.ndarray, R: int, metrics_cfg: MetricsCfg) -> dict:
"""Compute every per-generation metric for a measured mode distribution.
Mirrors ``knowledge.lineage.record`` field-for-field. ``forward_kl`` and the tail set
are always measured against the *original* truth, so a re-minted lineage that has lost
tails is penalised exactly as in Layer 1's E6.
Args:
p (np.ndarray): The model's measured mode distribution ``p_hat`` (length ``K``).
p_star_orig (np.ndarray): The original true distribution over modes.
tail_mask (np.ndarray): Boolean tail mask on the original truth.
regions (np.ndarray): Length-``K`` region index per mode.
R (int): Number of regions.
metrics_cfg (MetricsCfg): KL floor and support epsilon.
Returns:
dict: One row of metrics (no ``generation``/label columns; the runner adds those).
"""
eps = metrics_cfg.support_eps
kl_floor = metrics_cfg.kl_floor
head_mask = ~tail_mask
n_tail = int(tail_mask.sum())
n_head = int(head_mask.sum())
row = {
"heterozygosity": heterozygosity(p),
"forward_kl": forward_kl(p_star_orig, p, kl_floor),
"tail_mass": tail_mass(p, tail_mask),
"support_size": support_size(p, eps),
"tail_support": int(np.sum(p[tail_mask] > eps)),
"head_support": int(np.sum(p[head_mask] > eps)),
"tail_frac_alive": (float(np.mean(p[tail_mask] > eps)) if n_tail else 0.0),
"head_frac_alive": (float(np.mean(p[head_mask] > eps)) if n_head else 0.0),
"tail_truth_mass_alive": (
float(p_star_orig[tail_mask][p[tail_mask] > eps].sum()
/ p_star_orig[tail_mask].sum()) if n_tail else 0.0),
}
if n_tail >= N_BANDS:
fa, _ = tail_band_metrics(p, p_star_orig, tail_mask, n_bands=N_BANDS, alive_eps=eps)
for b in range(N_BANDS):
row[f"band{b}_alive"] = fa[b]
if R > 1:
for r, v in per_region(heterozygosity, p, regions).items():
row[f"H_region_{r}"] = v
for r, v in per_region(tail_mass, p, regions, tail_mask).items():
row[f"tail_region_{r}"] = v
for r in range(R):
region_tail = (regions == r) & tail_mask
row[f"tailalive_region_{r}"] = (
float(np.mean(p[region_tail] > eps)) if region_tail.any() else 0.0)
return row

124
src/neural/experiment.py Normal file
View file

@ -0,0 +1,124 @@
"""Neural (Layer 1.5) experiment runner: sweep a grid x replicates, write artifacts.
Mirrors ``knowledge.experiment`` and reuses its sweep-expansion primitives
(``_apply_param`` — including the ``g -> m`` conversion — and ``_set_by_path``), its
provenance helpers, and its output contract (``save_artifacts``). Only the per-run call and
the config key set differ: a neural run trains generative models rather than resampling a
frequency vector, and its config groups are ``synthetic``/``model``/``dynamics``/... .
CLI: python -m neural.experiment configs/neural/N0.yaml
"""
from __future__ import annotations
import argparse
import copy
import itertools
from pathlib import Path
from typing import Any
import pandas as pd
import yaml
from knowledge.experiment import _apply_param, save_artifacts
from knowledge.seeding import spawn_seeds
from .generation_loop import run_generative_lineage
# Config groups that make up a single neural lineage (everything else is experiment-level).
_NEURAL_KEYS = ("synthetic", "model", "dynamics", "generations", "metrics", "n_eval")
# Libraries recorded in the manifest on top of the Layer-1 core set (skipped if absent).
_EXTRA_LIBS = ("torch", "torchvision")
def expand_sweeps(cfg: dict) -> list[tuple[dict, dict]]:
"""Expand the sweep grid into (label, resolved_neural_cfg) pairs.
Identical semantics to ``knowledge.experiment.expand_sweeps`` (Cartesian product of the
declared ``{param, values}`` entries, reusing ``_apply_param`` for the ``g -> m`` and
``arm`` special cases) but assembling the base from the neural config groups.
Returns:
list[tuple[dict, dict]]: One (label-columns, neural-config) pair per grid point.
"""
base = {k: copy.deepcopy(cfg[k]) for k in _NEURAL_KEYS if k in cfg}
sweeps = cfg.get("sweep", [])
if isinstance(sweeps, dict):
sweeps = [sweeps]
if not sweeps:
return [({}, base)]
params = [s["param"] for s in sweeps]
value_lists = [list(s["values"]) for s in sweeps]
combos: list[tuple[dict, dict]] = []
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))
return combos
def run_experiment(cfg: dict) -> pd.DataFrame:
"""Run every grid point x every replicate; return long-form results.
Replicate seeds are derived once from the master seed and reused across grid points, so
comparisons across sweep values are paired (shared drift noise) — as in Layer 1.
Args:
cfg (dict): Parsed experiment YAML.
Returns:
pd.DataFrame: One row per (combo, replicate, generation).
"""
name = cfg["experiment"]
master = int(cfg["seed"])
n_rep = int(cfg["n_replicates"])
combos = expand_sweeps(cfg)
seeds = spawn_seeds(master, n_rep)
frames: list[pd.DataFrame] = []
for label, neural_cfg in combos:
for rep, ss in enumerate(seeds):
df = run_generative_lineage(neural_cfg, 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", name)
return out
def run_and_save(config_path: str | Path) -> Path:
"""Load a neural experiment YAML, run it, and write artifacts. Returns the output dir."""
config_path = Path(config_path)
cfg = yaml.safe_load(config_path.read_text())
out_dir = Path(cfg.get("output", {}).get("dir", f"results/{cfg['experiment']}"))
kind = cfg.get("kind", "gen_lineage")
if kind == "recombination":
from .recombine import run_recombination # Stage C (N4); imported lazily
df = run_recombination(cfg)
grid = None
elif kind == "gen_lineage":
df = run_experiment(cfg)
grid = [{"label": label, "neural_cfg": c} for label, c in expand_sweeps(cfg)]
else:
raise ValueError(f"unknown neural experiment kind {kind!r}")
model_kind = cfg.get("model", {}).get("kind", "histogram")
save_artifacts(cfg, df, out_dir, extra_libs=_EXTRA_LIBS,
extra_manifest={"layer": "1.5", "model_kind": model_kind}, grid=grid)
return out_dir
def main(argv: list[str] | None = None) -> None:
parser = argparse.ArgumentParser(description="Run a Layer-1.5 neural experiment from a YAML config.")
parser.add_argument("config", help="Path to configs/neural/NX.yaml")
args = parser.parse_args(argv)
out_dir = run_and_save(args.config)
print(f"wrote artifacts to {out_dir}/")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,113 @@
"""The neural analogue of ``knowledge.lineage.run_lineage``.
Runs ``T`` generations of *train-a-model-on-the-previous-model's-samples*, the neural
image of the Wright-Fisher generational step. Each generation the pupil is trained on a
pool of (i) ``n`` observations drawn from the parent model (drift) and (ii) ``m`` fresh
observations drawn from the grounding reference (immigration, ``g = m/(n+m)``), then its
oracle-measured mode distribution is logged with the *same* metric schema Layer 1 uses.
Grounding structure (proportional / uniform / matched over regions) and the re-mint gate
reuse ``knowledge.step`` and mirror ``run_lineage`` exactly, so a histogram-model lineage
reproduces the analytic core and a neural-model lineage tests whether the same signs hold
in real weights.
"""
from __future__ import annotations
from typing import Any, Mapping
import numpy as np
import pandas as pd
from knowledge.metrics import heterozygosity
from knowledge.step import allocate_m, structured_multinomial
from knowledge.truth import uniform_init
from .config import NeuralLineageCfg
from .evaluate import measure_metrics
from .models import make_model
from .oracle import ExactOracle
from .synthetic import id_codewords, make_mode_truth, render_modes
def _counts_to_observations(counts: np.ndarray, cfg, rng, codewords) -> np.ndarray:
"""Expand a per-mode count vector into rendered token sequences."""
modes = np.repeat(np.arange(counts.size), counts)
return render_modes(modes, cfg, rng, codewords)
def run_generative_lineage(cfg: Mapping[str, Any] | NeuralLineageCfg,
seed: int) -> pd.DataFrame:
"""Run one neural lineage and return per-generation metrics.
Args:
cfg (Mapping | NeuralLineageCfg): Resolved neural-lineage configuration.
seed (int): Seed for this replicate; the run is a pure function of (cfg, seed) for
the histogram model (statistically reproducible for torch models).
Returns:
pd.DataFrame: One row per generation 0..T with the same metric columns as
``knowledge.lineage.run_lineage``.
"""
cfg = NeuralLineageCfg.from_dict(cfg)
syn = cfg.synthetic
td = make_mode_truth(syn)
p_star_orig = td.p_star # forward_kl is always vs the original truth
regions = td.regions
tail_mask = td.tail_mask
R = syn.R
rng = np.random.default_rng(seed)
oracle = ExactOracle(syn)
codewords = id_codewords(syn)
# Initial distribution over modes (exact, like Layer 1).
if syn.init == "uniform":
p0 = uniform_init(syn.K)
elif syn.init == "truth":
p0 = p_star_orig.copy()
else:
raise ValueError(f"unknown init {syn.init!r} (expected uniform|truth)")
# Grounding wiring (reused verbatim from Layer 1).
grounding = cfg.dynamics.grounding
exercised = np.asarray(grounding.exercised) if grounding.exercised is not None else None
m_vector = allocate_m(grounding.m, R, grounding.policy, exercised)
p_star_eff = p_star_orig.copy() # grounding reference; may be re-minted (N6)
remint = cfg.dynamics.remint
n = cfg.dynamics.n
model = make_model(cfg.model, syn, oracle)
model.initialise(p0, rng)
rows: list[dict] = []
def record(t: int, p: np.ndarray) -> None:
row = {"generation": t}
row.update(measure_metrics(p, p_star_orig, tail_mask, regions, R, cfg.metrics))
rows.append(row)
record(0, model.mode_distribution(rng))
for t in range(1, cfg.generations + 1):
X_syn = model.sample(n, rng) # drift: n from the parent
if m_vector is not None: # immigration: m grounded samples
counts_real = structured_multinomial(
m_vector, p_star_eff, regions, grounding.policy, rng)
X_real = _counts_to_observations(counts_real, syn, rng, codewords)
pool = np.concatenate([X_syn, X_real], axis=0)
else:
pool = X_syn
pupil = make_model(cfg.model, syn, oracle)
pupil.fit(pool, rng)
model = pupil
p = model.mode_distribution(rng)
if remint.enabled and remint.period and t % remint.period == 0:
# Founder event: current distribution becomes the new grounding reference and
# the original truth is discarded for grounding. Gated on diversity (N6).
if remint.H_gate is None or heterozygosity(p) >= remint.H_gate:
p_star_eff = p.copy()
record(t, p)
return pd.DataFrame(rows)

119
src/neural/models.py Normal file
View file

@ -0,0 +1,119 @@
"""Generative models behind a thin adapter, so architecture is a config switch.
Every model implements the same three-method protocol: ``fit`` on a batch of token
sequences, ``sample`` fresh token sequences, and report its ``mode_distribution`` (the
model's ``p_t``). Keeping the interface identical is what makes "collapse is
architecture-general" (experiment N5) a single sweep over ``model.kind``.
``HistogramModel`` is the bridge: its ``fit`` is a maximum-likelihood mode histogram and
its ``sample`` is a multinomial draw, so a lineage of histogram models is *exactly*
neutral Wright-Fisher drift with immigration — the analytic core in disguise. The
torch-backed RNN/VAE/MLP models are added in Stage C and reuse this same protocol.
"""
from __future__ import annotations
from typing import Protocol, runtime_checkable
import numpy as np
from .config import ModelCfg, SyntheticCfg
from .oracle import Oracle, measure_distribution
from .synthetic import id_codewords, render_modes
@runtime_checkable
class GenerativeModel(Protocol):
"""A learner of ``p(x)`` over the synthetic observation space."""
def initialise(self, p0: np.ndarray, rng: np.random.Generator) -> None:
"""Initialise generation 0 to represent the mode distribution ``p0``.
The histogram bridge sets ``p0`` exactly (matching Layer 1's exact ``p_0`` start);
a neural model trains on a sample drawn from ``p0`` (its gen-0 fidelity is checked
by the Stage-C fidelity gate).
"""
...
def fit(self, X: np.ndarray, rng: np.random.Generator) -> None:
"""Train (from scratch) on a batch of token sequences ``X``."""
...
def sample(self, n: int, rng: np.random.Generator) -> np.ndarray:
"""Draw ``n`` fresh token sequences of shape ``(n, seq_len)``."""
...
def mode_distribution(self, rng: np.random.Generator) -> np.ndarray:
"""Return the model's length-``K`` distribution over modes (its ``p_t``)."""
...
class HistogramModel:
"""MLE mode-histogram generator — reduces Layer 1.5 exactly to Layer 1.
``fit`` counts oracle-labelled modes in the training pool and stores the empirical
distribution; ``sample`` draws modes multinomially and renders them; the stored
distribution *is* the model's ``p_t`` (read exactly, no eval-sampling noise). Composed
over generations this is neutral Wright-Fisher drift with immigration.
Args:
cfg (SyntheticCfg): The synthetic grammar (for ``K`` and rendering).
oracle (Oracle): The mode adjudicator used to label the training pool.
model_cfg (ModelCfg): Present for interface symmetry; unused by the histogram.
"""
def __init__(self, cfg: SyntheticCfg, oracle: Oracle,
model_cfg: ModelCfg | None = None) -> None:
self.cfg = cfg
self.oracle = oracle
self._codewords = id_codewords(cfg)
self._p: np.ndarray | None = None
def initialise(self, p0: np.ndarray, rng: np.random.Generator) -> None:
"""Set the stored distribution to ``p0`` exactly (no gen-0 sampling noise)."""
p0 = np.asarray(p0, dtype=float)
self._p = p0 / p0.sum()
def fit(self, X: np.ndarray, rng: np.random.Generator) -> None:
"""Store the empirical mode distribution of the (oracle-labelled) pool ``X``."""
self._p = measure_distribution(X, self.oracle, self.cfg.K)
def sample(self, n: int, rng: np.random.Generator) -> np.ndarray:
"""Draw ``n`` observations whose modes follow the stored distribution."""
if self._p is None:
raise RuntimeError("HistogramModel.sample called before fit")
counts = rng.multinomial(n, self._p)
modes = np.repeat(np.arange(self.cfg.K), counts)
return render_modes(modes, self.cfg, rng, self._codewords)
def mode_distribution(self, rng: np.random.Generator) -> np.ndarray:
"""Return the stored mode distribution (exact; no eval sampling)."""
if self._p is None:
raise RuntimeError("HistogramModel.mode_distribution called before fit")
return self._p.copy()
def make_model(model_cfg: ModelCfg, cfg: SyntheticCfg, oracle: Oracle) -> GenerativeModel:
"""Construct a generative model of the requested ``kind``.
Args:
model_cfg (ModelCfg): Selects the architecture and its hyperparameters.
cfg (SyntheticCfg): The synthetic grammar.
oracle (Oracle): The mode adjudicator (needed by the histogram bridge; the neural
models estimate their mode distribution by generate-and-classify).
Returns:
GenerativeModel: A fresh, untrained model.
Raises:
ValueError: If ``kind`` is unknown.
"""
kind = model_cfg.kind
if kind == "histogram":
return HistogramModel(cfg, oracle, model_cfg)
if kind in ("rnn", "vae", "mlp"):
# Torch-backed models arrive in Stage C; imported lazily so Stages A-B need no GPU.
from .torch_models import make_torch_model # noqa: PLC0415
return make_torch_model(model_cfg, cfg, oracle)
raise ValueError(f"unknown model kind {kind!r} (expected histogram|rnn|vae|mlp)")

82
src/neural/oracle.py Normal file
View file

@ -0,0 +1,82 @@
"""The oracle — the neural analogue of Layer 1's "reality's no".
An oracle maps an observation to the mode it belongs to. For the fully-synthetic sandbox
the oracle is **exact** (it decodes the lossless identity segment), so the measured mode
distribution ``p_hat`` carries zero measurement noise — this is what lets a trained model's
collapse be read directly against the known ``p*``. (The MNIST tier will add a
``ClassifierOracle`` wrapping a frozen network plus its confusion matrix; that arrives with
Stage C and is confirmation-only.)
"""
from __future__ import annotations
from typing import Protocol, runtime_checkable
import numpy as np
from .config import SyntheticCfg
@runtime_checkable
class Oracle(Protocol):
"""Adjudicates which mode an observation belongs to."""
def classify(self, X: np.ndarray) -> np.ndarray:
"""Return the length-``n`` mode index for each row of ``X``."""
...
class ExactOracle:
"""Zero-error oracle for the synthetic sandbox: decodes the identity segment.
Args:
cfg (SyntheticCfg): The synthetic configuration whose grammar produced ``X``.
"""
def __init__(self, cfg: SyntheticCfg) -> None:
self.cfg = cfg
self.id_len = cfg.id_len
self.base = cfg.id_base
self.K = cfg.K
# Positional weights for base-`base` decoding, most-significant digit first.
self._weights = self.base ** np.arange(self.id_len - 1, -1, -1, dtype=np.int64)
def classify(self, X: np.ndarray) -> np.ndarray:
"""Decode mode indices from the identity segment of each observation.
Args:
X (np.ndarray): Token sequences of shape ``(n, seq_len)``.
Returns:
np.ndarray: Length-``n`` decoded indices. Grammar-valid data always decodes into
``[0, K)``; a *neural* model may emit an invalid codeword that decodes to
``>= K`` — such samples are dropped by :func:`measure_distribution` rather than
being clipped onto a real mode (which would bias ``p_hat``).
"""
X = np.asarray(X, dtype=np.int64)
ident = X[:, : self.id_len]
return ident @ self._weights
def measure_distribution(X: np.ndarray, oracle: Oracle, K: int) -> np.ndarray:
"""Measure the empirical mode distribution ``p_hat`` of a sample.
This is the neural readout of ``p_t``: classify every observation and normalise the
mode histogram. Modes absent from ``X`` receive zero mass (support shrinks exactly as
in Layer 1). Invalid codewords (decoded index ``>= K``, only producible by a neural
model) are dropped, so ``p_hat`` is renormalised over grammar-valid samples.
Args:
X (np.ndarray): Token sequences of shape ``(n, seq_len)``.
oracle (Oracle): The mode adjudicator.
K (int): Number of modes.
Returns:
np.ndarray: Length-``K`` probability vector summing to 1.
"""
modes = oracle.classify(X)
counts = np.bincount(modes, minlength=K)[:K].astype(float)
total = counts.sum()
if total <= 0:
raise ValueError("measure_distribution received an empty sample")
return counts / total

109
src/neural/synthetic.py Normal file
View file

@ -0,0 +1,109 @@
"""The fully-synthetic sandbox: a known ``p*`` over modes + a lossless observation grammar.
The mode-truth (``p*``, regions, tail mask) comes straight from Layer 1's
``knowledge.truth.make_true_distribution`` — so "mode", "region", and "tail" are *the same
objects* as in the analytic core. Each mode is rendered to a categorical token sequence:
* an **identity** segment of ``id_len`` base-``id_base`` digits that encodes the mode
index exactly (the exact oracle reads these back with zero error), and
* a **style** segment of ``style_len`` tokens drawn uniformly at random, giving genuine
within-mode entropy so a real generative model must learn a *distribution* ``p(x|mode)``
rather than memorise ``K`` fixed strings.
Because the identity segment is lossless, the measured mode distribution ``p_hat`` is a
noise-free readout of the model's output — the property that lets the histogram model
reduce this layer exactly to Wright-Fisher drift.
"""
from __future__ import annotations
import numpy as np
from knowledge.truth import TrueDist, make_true_distribution
from .config import SyntheticCfg
def make_mode_truth(cfg: SyntheticCfg) -> TrueDist:
"""Build the true distribution over modes (thin wrapper over Layer 1's truth).
Args:
cfg (SyntheticCfg): The synthetic configuration (its first seven fields are the
Layer-1 ``TruthCfg`` knobs).
Returns:
TrueDist: ``p_star`` (length ``K``), ``regions``, and ``tail_mask`` over modes.
"""
return make_true_distribution(
cfg.K, cfg.R, cfg.tail, cfg.tail_frac, cfg.zipf_s, 0,
tail_threshold=cfg.tail_threshold,
)
def id_codewords(cfg: SyntheticCfg) -> np.ndarray:
"""Return the ``(K, id_len)`` matrix of base-``id_base`` identity codewords.
Codeword of mode ``k`` is ``k`` written in base ``id_base``, most-significant digit
first, zero-padded to ``id_len``. Deterministic and invertible.
Args:
cfg (SyntheticCfg): The synthetic configuration.
Returns:
np.ndarray: Integer array of shape ``(K, id_len)`` with tokens in
``[0, id_base)``.
"""
k = np.arange(cfg.K, dtype=np.int64)
id_len, base = cfg.id_len, cfg.id_base
digits = np.empty((cfg.K, id_len), dtype=np.int64)
for pos in range(id_len - 1, -1, -1): # least-significant digit last
digits[:, pos] = k % base
k //= base
return digits
def render_modes(modes: np.ndarray, cfg: SyntheticCfg, rng: np.random.Generator,
codewords: np.ndarray | None = None) -> np.ndarray:
"""Render an array of mode indices to token sequences.
Args:
modes (np.ndarray): Length-``n`` integer array of mode indices in ``[0, K)``.
cfg (SyntheticCfg): The synthetic configuration.
rng (np.random.Generator): Random source for the style segment.
codewords (np.ndarray | None): Optional precomputed identity codewords.
Returns:
np.ndarray: Integer array of shape ``(n, seq_len)`` — identity segment followed by
a freshly-sampled style segment.
"""
modes = np.asarray(modes, dtype=np.int64)
if codewords is None:
codewords = id_codewords(cfg)
ident = codewords[modes] # (n, id_len)
style = rng.integers(0, cfg.style_vocab, size=(modes.shape[0], cfg.style_len))
return np.concatenate([ident, style], axis=1)
def sample_synthetic(p_over_modes: np.ndarray, n: int, cfg: SyntheticCfg,
rng: np.random.Generator,
codewords: np.ndarray | None = None) -> tuple[np.ndarray, np.ndarray]:
"""Draw ``n`` observations whose modes follow ``p_over_modes``.
This is the *grounding* generator (draw from a fixed distribution and render) and the
reference sampler used to seed generation 0.
Args:
p_over_modes (np.ndarray): Distribution over the ``K`` modes to sample from.
n (int): Number of observations.
cfg (SyntheticCfg): The synthetic configuration.
rng (np.random.Generator): Random source.
codewords (np.ndarray | None): Optional precomputed identity codewords.
Returns:
tuple[np.ndarray, np.ndarray]: ``(X, modes)`` — token sequences of shape
``(n, seq_len)`` and the length-``n`` true mode indices.
"""
p = np.asarray(p_over_modes, dtype=float)
modes = rng.choice(cfg.K, size=n, p=p / p.sum())
X = render_modes(modes, cfg, rng, codewords)
return X, modes

109
src/neural/torch_mlp.py Normal file
View file

@ -0,0 +1,109 @@
"""Autoregressive MLP generative model (Stage C, for the N5 architecture-generality axis).
A causal feed-forward next-token model: token ``i`` is predicted from the concatenated
(causally-masked) embeddings of all earlier tokens. Deliberately a *different* inductive
bias from the GRU — if collapse appears here too, it is a property of the transmission
operator, not of any one architecture.
"""
from __future__ import annotations
import numpy as np
from .config import ModelCfg, SyntheticCfg
from .oracle import Oracle
from .torch_models import _BaseTorchGenerator
from .train import device_generator, seed_everything
def _make_mlp_net(V: int, L: int, embed: int, hidden: int):
import torch
import torch.nn as nn
class MLPNet(nn.Module):
"""Predict every position from a causally-masked flatten of prior embeddings."""
def __init__(self) -> None:
super().__init__()
self.V, self.L, self.E = V, L, embed
self.bos = V
self.embed = nn.Embedding(V + 1, embed)
self.net = nn.Sequential(
nn.Linear(L * embed, hidden), nn.ReLU(),
nn.Linear(hidden, hidden), nn.ReLU(),
nn.Linear(hidden, V),
)
# lower-triangular INCLUSIVE mask over input positions: position i sees inputs
# 0..i (the input is already shifted by one, so this is strictly causal on x).
mask = torch.tril(torch.ones(L, L))
self.register_buffer("mask", mask)
def _context(self, inp): # inp: (B, L) input tokens
B = inp.shape[0]
emb = self.embed(inp) # (B, L, E)
m = self.mask.to(emb.dtype) # (L, L)
# ctx[b, i] = concat_j ( emb[b, j] * mask[i, j] ) -> (B, L, L*E)
ctx = emb.unsqueeze(1) * m.unsqueeze(0).unsqueeze(-1) # (B, L, L, E)
return ctx.reshape(B, self.L, self.L * self.E)
def forward(self, x): # x: (B, L) targets
B = x.shape[0]
bos = torch.full((B, 1), self.bos, dtype=torch.long, device=x.device)
inp = torch.cat([bos, x[:, :-1]], dim=1)
ctx = self._context(inp)
return self.net(ctx) # (B, L, V)
def step_logits(self, prefix): # prefix: (B, pos) tokens so far
"""Logits for the next token given the tokens generated so far."""
B, pos = prefix.shape
bos = torch.full((B, 1), self.bos, dtype=torch.long, device=prefix.device)
inp = torch.cat([bos, prefix], dim=1)[:, : self.L] # (B, <=L)
if inp.shape[1] < self.L:
pad = torch.zeros((B, self.L - inp.shape[1]), dtype=torch.long,
device=prefix.device)
inp = torch.cat([inp, pad], dim=1)
ctx = self._context(inp) # (B, L, L*E)
return self.net(ctx[:, pos, :]) # logits at position `pos`
return MLPNet()
class MLPGenerator(_BaseTorchGenerator):
"""Autoregressive feed-forward generative model over token sequences."""
def fit(self, X: np.ndarray, rng: np.random.Generator) -> None:
import torch
g = seed_everything(int(rng.integers(2 ** 31)))
net = _make_mlp_net(self.V, self.L, self.mcfg.embed, self.mcfg.hidden).to(self.device)
net.train()
opt = torch.optim.Adam(net.parameters(), lr=self.mcfg.lr)
loss_fn = torch.nn.CrossEntropyLoss()
data = torch.as_tensor(np.asarray(X), dtype=torch.long, device=self.device)
n, bs = data.shape[0], self.mcfg.batch_size
for _ in range(self.mcfg.epochs):
perm = torch.randperm(n, generator=g).to(self.device)
for i in range(0, n, bs):
batch = data[perm[i:i + bs]]
logits = net(batch)
loss = loss_fn(logits.reshape(-1, self.V), batch.reshape(-1))
opt.zero_grad()
loss.backward()
opt.step()
net.eval()
self.net = net
def sample(self, n: int, rng: np.random.Generator) -> np.ndarray:
import torch
if self.net is None:
raise RuntimeError("MLPGenerator.sample called before fit")
g = device_generator(int(rng.integers(2 ** 31)), self.device)
prefix = torch.empty((n, 0), dtype=torch.long, device=self.device)
with torch.no_grad():
for pos in range(self.L):
logits = self.net.step_logits(prefix)
probs = torch.softmax(logits, dim=-1)
tok = torch.multinomial(probs, 1, generator=g)
prefix = torch.cat([prefix, tok], dim=1)
return prefix.cpu().numpy()

146
src/neural/torch_models.py Normal file
View file

@ -0,0 +1,146 @@
"""Torch-backed generative models over the synthetic token grammar (Stage C).
Each model implements the same ``GenerativeModel`` protocol as the histogram bridge
(``initialise`` / ``fit`` / ``sample`` / ``mode_distribution``), so a lineage is
architecture-agnostic and N5 is a single sweep over ``model.kind``. Unlike the histogram
model, a neural model's ``mode_distribution`` is *estimated* by generate-and-classify
(``n_eval`` samples), which is the honest, slightly-noisy neural readout of ``p_t``.
Implemented so far: ``RNNGenerator`` (autoregressive GRU). ``VAEGenerator`` and
``MLPGenerator`` follow and reuse the shared measure/initialise helpers.
"""
from __future__ import annotations
import numpy as np
from .config import ModelCfg, SyntheticCfg
from .oracle import Oracle, measure_distribution
from .synthetic import sample_synthetic
from .train import device_generator, resolve_device, seed_everything, set_determinism
def _measure(model, rng: np.random.Generator, oracle: Oracle, K: int, n_eval: int) -> np.ndarray:
"""Estimate a model's mode distribution by generate-and-classify."""
X = model.sample(n_eval, rng)
return measure_distribution(X, oracle, K)
class _BaseTorchGenerator:
"""Shared plumbing: device, gen-0 initialisation, and mode measurement."""
def __init__(self, cfg: SyntheticCfg, model_cfg: ModelCfg, oracle: Oracle) -> None:
self.cfg = cfg
self.mcfg = model_cfg
self.oracle = oracle
self.device = resolve_device(model_cfg.device)
self.V = cfg.vocab
self.L = cfg.seq_len
self.net = None
set_determinism()
def initialise(self, p0: np.ndarray, rng: np.random.Generator) -> None:
"""Train generation 0 on a sample drawn from ``p0`` (fidelity-gated in Stage C)."""
n_init = max(self.mcfg.n_eval, 4000)
X, _ = sample_synthetic(p0, n_init, self.cfg, rng)
self.fit(X, rng)
def mode_distribution(self, rng: np.random.Generator) -> np.ndarray:
return _measure(self, rng, self.oracle, self.cfg.K, self.mcfg.n_eval)
# --- autoregressive GRU -----------------------------------------------------------------
def _make_ar_net(V: int, embed: int, hidden: int):
"""Build an autoregressive GRU next-token network (built lazily to avoid a torch import
at module load)."""
import torch.nn as nn
class ARNet(nn.Module):
"""Predict token ``i`` from tokens ``0..i-1`` via a GRU (BOS-prefixed)."""
def __init__(self) -> None:
super().__init__()
self.bos = V # extra input id for the start token
self.embed = nn.Embedding(V + 1, embed)
self.gru = nn.GRU(embed, hidden, batch_first=True)
self.out = nn.Linear(hidden, V)
def forward(self, x): # x: (B, L) target tokens
import torch
B = x.shape[0]
bos = torch.full((B, 1), self.bos, dtype=torch.long, device=x.device)
inp = torch.cat([bos, x[:, :-1]], dim=1) # teacher forcing
h, _ = self.gru(self.embed(inp))
return self.out(h) # (B, L, V)
return ARNet()
class RNNGenerator(_BaseTorchGenerator):
"""Autoregressive GRU generative model over token sequences."""
def _train_net(self, X, rng: np.random.Generator):
import torch
g = seed_everything(int(rng.integers(2 ** 31)))
net = _make_ar_net(self.V, self.mcfg.embed, self.mcfg.hidden).to(self.device)
net.train()
opt = torch.optim.Adam(net.parameters(), lr=self.mcfg.lr)
loss_fn = torch.nn.CrossEntropyLoss()
data = torch.as_tensor(np.asarray(X), dtype=torch.long, device=self.device)
n = data.shape[0]
bs = self.mcfg.batch_size
for _ in range(self.mcfg.epochs):
perm = torch.randperm(n, generator=g).to(self.device)
for i in range(0, n, bs):
idx = perm[i:i + bs]
batch = data[idx]
logits = net(batch) # (b, L, V)
loss = loss_fn(logits.reshape(-1, self.V), batch.reshape(-1))
opt.zero_grad()
loss.backward()
opt.step()
net.eval()
return net
def fit(self, X: np.ndarray, rng: np.random.Generator) -> None:
self.net = self._train_net(X, rng)
def sample(self, n: int, rng: np.random.Generator) -> np.ndarray:
import torch
if self.net is None:
raise RuntimeError("RNNGenerator.sample called before fit")
g = device_generator(int(rng.integers(2 ** 31)), self.device)
out = torch.empty((n, self.L), dtype=torch.long, device=self.device)
tok = torch.full((n, 1), self.V, dtype=torch.long, device=self.device) # BOS
h = None
with torch.no_grad():
for pos in range(self.L):
emb = self.net.embed(tok)
hid, h = self.net.gru(emb, h)
logits = self.net.out(hid[:, -1, :]) # (n, V)
probs = torch.softmax(logits, dim=-1)
tok = torch.multinomial(probs, 1, generator=g)
out[:, pos] = tok[:, 0]
return out.cpu().numpy()
# --- dispatch ---------------------------------------------------------------------------
def make_torch_model(model_cfg: ModelCfg, cfg: SyntheticCfg, oracle: Oracle):
"""Construct a torch generative model of the requested ``kind``."""
kind = model_cfg.kind
if kind == "rnn":
return RNNGenerator(cfg, model_cfg, oracle)
if kind == "vae":
from .torch_vae import VAEGenerator # noqa: PLC0415
return VAEGenerator(cfg, model_cfg, oracle)
if kind == "mlp":
from .torch_mlp import MLPGenerator # noqa: PLC0415
return MLPGenerator(cfg, model_cfg, oracle)
raise ValueError(f"unknown torch model kind {kind!r}")

104
src/neural/torch_vae.py Normal file
View file

@ -0,0 +1,104 @@
"""Sequence VAE generative model (Stage C, for the N5 architecture-generality axis).
A GRU encoder maps a token sequence to a Gaussian latent ``z``; a GRU decoder (its initial
hidden state projected from ``z``) reconstructs the sequence. Trained by the ELBO
(reconstruction CE + ``beta`` * KL). A latent-variable generator is a third, distinct
inductive bias — and the canonical model in which generative collapse was first studied — so
its collapse under dry self-training is strong evidence the effect is operator-driven.
"""
from __future__ import annotations
import numpy as np
from .config import ModelCfg, SyntheticCfg
from .oracle import Oracle
from .torch_models import _BaseTorchGenerator
from .train import device_generator, seed_everything
def _make_vae(V: int, L: int, embed: int, hidden: int, latent: int):
import torch
import torch.nn as nn
class SeqVAE(nn.Module):
def __init__(self) -> None:
super().__init__()
self.V, self.L, self.bos = V, L, V
self.embed = nn.Embedding(V + 1, embed)
self.enc = nn.GRU(embed, hidden, batch_first=True)
self.to_mu = nn.Linear(hidden, latent)
self.to_lv = nn.Linear(hidden, latent)
self.z_to_h = nn.Linear(latent, hidden)
self.dec = nn.GRU(embed, hidden, batch_first=True)
self.out = nn.Linear(hidden, V)
def encode(self, x):
_, h = self.enc(self.embed(x)) # h: (1, B, H)
h = h[-1]
return self.to_mu(h), self.to_lv(h)
def decode_logits(self, z, x): # teacher forcing
B = x.shape[0]
bos = torch.full((B, 1), self.bos, dtype=torch.long, device=x.device)
inp = torch.cat([bos, x[:, :-1]], dim=1)
h0 = torch.tanh(self.z_to_h(z)).unsqueeze(0) # (1, B, H)
out, _ = self.dec(self.embed(inp), h0)
return self.out(out)
def forward(self, x):
mu, lv = self.encode(x)
std = torch.exp(0.5 * lv)
z = mu + std * torch.randn_like(std)
logits = self.decode_logits(z, x)
kl = -0.5 * torch.sum(1 + lv - mu.pow(2) - lv.exp(), dim=1).mean()
return logits, kl
return SeqVAE()
class VAEGenerator(_BaseTorchGenerator):
"""Sequence VAE generative model over token sequences."""
def fit(self, X: np.ndarray, rng: np.random.Generator) -> None:
import torch
g = seed_everything(int(rng.integers(2 ** 31)))
net = _make_vae(self.V, self.L, self.mcfg.embed, self.mcfg.hidden,
self.mcfg.latent).to(self.device)
net.train()
opt = torch.optim.Adam(net.parameters(), lr=self.mcfg.lr)
ce = torch.nn.CrossEntropyLoss()
data = torch.as_tensor(np.asarray(X), dtype=torch.long, device=self.device)
n, bs, beta = data.shape[0], self.mcfg.batch_size, self.mcfg.beta
for _ in range(self.mcfg.epochs):
perm = torch.randperm(n, generator=g).to(self.device)
for i in range(0, n, bs):
batch = data[perm[i:i + bs]]
logits, kl = net(batch)
recon = ce(logits.reshape(-1, self.V), batch.reshape(-1))
loss = recon + beta * kl / batch.shape[0]
opt.zero_grad()
loss.backward()
opt.step()
net.eval()
self.net = net
def sample(self, n: int, rng: np.random.Generator) -> np.ndarray:
import torch
if self.net is None:
raise RuntimeError("VAEGenerator.sample called before fit")
g = device_generator(int(rng.integers(2 ** 31)), self.device)
z = torch.randn(n, self.mcfg.latent, generator=g, device=self.device)
h = torch.tanh(self.net.z_to_h(z)).unsqueeze(0) # (1, n, H)
tok = torch.full((n, 1), self.net.bos, dtype=torch.long, device=self.device)
out = torch.empty((n, self.L), dtype=torch.long, device=self.device)
with torch.no_grad():
for pos in range(self.L):
dec_out, h = self.net.dec(self.net.embed(tok), h)
logits = self.net.out(dec_out[:, -1, :])
probs = torch.softmax(logits, dim=-1)
tok = torch.multinomial(probs, 1, generator=g)
out[:, pos] = tok[:, 0]
return out.cpu().numpy()

79
src/neural/train.py Normal file
View file

@ -0,0 +1,79 @@
"""Torch determinism, seeding, and device helpers for the neural (Stage C) models.
Layer 1.5's neural tiers are *statistically* reproducible, not bitwise (blueprint 4 rider):
we derive every torch seed from the same ``SeedSequence`` stream the rest of the study uses,
set all available determinism flags, and report per-seed points. ``CUBLAS_WORKSPACE_CONFIG``
must be set before the first CUDA op, so it is set at import time.
"""
from __future__ import annotations
import os
os.environ.setdefault("CUBLAS_WORKSPACE_CONFIG", ":4096:8")
import numpy as np
_DETERMINISM_SET = False
def torch_seed_from(seed) -> int:
"""Reduce an int or ``np.random.SeedSequence`` to a 32-bit torch seed."""
if isinstance(seed, np.random.SeedSequence):
return int(seed.generate_state(1)[0])
return int(seed) & 0xFFFFFFFF
def resolve_device(device: str):
"""Resolve ``{auto, cpu, cuda}`` to a concrete ``torch.device``."""
import torch
if device == "auto":
device = "cuda" if torch.cuda.is_available() else "cpu"
return torch.device(device)
def set_determinism() -> None:
"""Set torch/cuDNN determinism flags once per process (best-effort)."""
global _DETERMINISM_SET
if _DETERMINISM_SET:
return
import torch
torch.use_deterministic_algorithms(True, warn_only=True)
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False
_DETERMINISM_SET = True
def seed_everything(seed) -> "object":
"""Seed torch (CPU+CUDA) from ``seed`` and return a seeded CPU ``torch.Generator``.
Args:
seed: An int or ``np.random.SeedSequence`` from the study's seed stream.
Returns:
torch.Generator: A CPU generator seeded for sampling ops (e.g. ``randperm``).
"""
import torch
s = torch_seed_from(seed)
torch.manual_seed(s)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(s)
g = torch.Generator()
g.manual_seed(s)
return g
def device_generator(seed, device) -> "object":
"""Return a ``torch.Generator`` on ``device`` seeded from ``seed``.
``torch.multinomial`` requires the generator to live on the same device as the
probabilities, so sampling ops use this rather than the CPU generator.
"""
import torch
g = torch.Generator(device=device)
g.manual_seed(torch_seed_from(seed))
return g