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:
commit
a6eb9b7512
33 changed files with 4356 additions and 0 deletions
192
src/knowledge/experiment.py
Normal file
192
src/knowledge/experiment.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue