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

192
src/knowledge/experiment.py Normal file
View file

@ -0,0 +1,192 @@
"""Experiment runner: sweep a parameter grid x replicates, write reproducible artifacts.
One YAML per experiment (blueprint 2.7 / 4). ``run_experiment`` expands the declared
sweep grid, runs ``run_lineage`` for every (combo, replicate) at seeds derived from a
single master seed, and returns long-form results. ``save_artifacts`` writes the output
contract: ``results.parquet`` + ``resolved_config.yaml`` + ``manifest.json``. Figures are
regenerated separately from ``results.parquet`` alone.
CLI: python -m knowledge.experiment configs/layer1/E2.yaml
"""
from __future__ import annotations
import argparse
import copy
import hashlib
import itertools
import json
import subprocess
import sys
from importlib.metadata import version
from pathlib import Path
from typing import Any
import numpy as np
import pandas as pd
import yaml
from .lineage import run_lineage
from .seeding import spawn_seeds
# Keys that make up a single-lineage configuration (everything else is experiment-level).
_LINEAGE_KEYS = ("truth", "dynamics", "generations", "metrics")
def _set_by_path(d: dict, path: str, value: Any) -> None:
"""Set a nested dict value by a dotted path, creating intermediate dicts."""
keys = path.split(".")
cur = d
for k in keys[:-1]:
cur = cur.setdefault(k, {})
cur[keys[-1]] = value
def _apply_param(lineage_cfg: dict, param: str, value: Any) -> dict:
"""Apply one swept parameter to a lineage config; return label columns to record.
Special-cases ``g`` (the grounding fraction): converts to an integer real-sample
budget ``m = round(n*g/(1-g))`` (so g=0 -> m=0), records both ``g`` and ``m``.
Any other param is a dotted path into the lineage config.
"""
if param == "g":
n = lineage_cfg["dynamics"]["n"]
g = float(value)
m = 0 if g <= 0.0 else int(round(n * g / (1.0 - g)))
lineage_cfg["dynamics"].setdefault("grounding", {})["m"] = m
return {"g": g, "m": m}
_set_by_path(lineage_cfg, param, value)
return {param.split(".")[-1]: value}
def expand_sweeps(cfg: dict) -> list[tuple[dict, dict]]:
"""Expand the sweep grid into (label, resolved_lineage_cfg) pairs.
``sweep`` is a list of ``{param, values}`` entries; the Cartesian product is taken
(so E4 can sweep K_T x rho). A missing/empty sweep yields a single run.
Returns:
list[tuple[dict, dict]]: One (label-columns, lineage-config) pair per grid point.
"""
base = {k: copy.deepcopy(cfg[k]) for k in _LINEAGE_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 the full experiment: every grid point x every replicate.
Replicate seeds are derived once from the master seed and *reused across grid points*,
so comparisons across sweep values are paired (shared drift noise) variance
reduction, and a pure function of the master seed.
Args:
cfg (dict): Parsed experiment YAML (experiment, seed, n_replicates, truth,
dynamics, generations, metrics, sweep, output).
Returns:
pd.DataFrame: Long-form results; 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, lineage_cfg in combos:
for rep, ss in enumerate(seeds):
df = run_lineage(lineage_cfg, ss)
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 _git_commit() -> str | None:
try:
return subprocess.check_output(
["git", "rev-parse", "HEAD"], stderr=subprocess.DEVNULL, text=True
).strip()
except Exception:
return None
def _content_hash(path: Path) -> str:
h = hashlib.sha256()
h.update(path.read_bytes())
return h.hexdigest()
def save_artifacts(cfg: dict, df: pd.DataFrame, out_dir: Path) -> None:
"""Write the reproducibility output contract (blueprint 2.7 / 4).
Writes ``results.parquet``, ``resolved_config.yaml`` (the fully-expanded config), and
``manifest.json`` (library versions, master seed, git commit, content hash).
"""
out_dir.mkdir(parents=True, exist_ok=True)
results_path = out_dir / "results.parquet"
df.to_parquet(results_path, index=False)
resolved = {
"experiment": cfg["experiment"],
"seed": cfg["seed"],
"n_replicates": cfg["n_replicates"],
"grid": [
{"label": label, "lineage_cfg": lineage_cfg}
for label, lineage_cfg in expand_sweeps(cfg)
],
"source_config": cfg,
}
(out_dir / "resolved_config.yaml").write_text(yaml.safe_dump(resolved, sort_keys=False))
manifest = {
"experiment": cfg["experiment"],
"master_seed": cfg["seed"],
"git_commit": _git_commit(),
"python": sys.version.split()[0],
"libraries": {
lib: version(lib) for lib in ("numpy", "scipy", "pandas", "pyarrow")
},
"rows": int(len(df)),
"results_sha256": _content_hash(results_path),
}
(out_dir / "manifest.json").write_text(json.dumps(manifest, indent=2))
def run_and_save(config_path: str | Path) -> Path:
"""Load an 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']}"))
df = run_experiment(cfg)
save_artifacts(cfg, df, out_dir)
return out_dir
def main(argv: list[str] | None = None) -> None:
parser = argparse.ArgumentParser(description="Run a Layer-1 experiment from a YAML config.")
parser.add_argument("config", help="Path to configs/layer1/EX.yaml")
args = parser.parse_args(argv)
out_dir = run_and_save(args.config)
print(f"wrote artifacts to {out_dir}/")
if __name__ == "__main__":
main()