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