Layer 1 complete: E3-E6 + E2 analysis add-ons

Finishes the Layer 1 analytical core. All six experiments run with honest,
publication-quality figures; 71 tests green.

- E3 region-matched grounding: `grounding.exercised` knob + per-region tail
  survival. Matched holds the exercised region's tail (0.49) where uniform
  spreads thin and lets it collapse (0.07).
- E4 multi-teacher recombination: `run_coverage` runner. Union coverage matches
  U(K_T,rho,q) exactly. Finding: mean-mixture distillation shows NO surviving
  benefit (a conservation law — 1/K_T dilution cancels the union gain); a
  union-preserving max-merge (M2N2-style) does. E4 reports both operators.
- E5 QD vs greedy: greedy drives fixation (H~0.01); QD holds H at 0.48-0.88,
  rising with the novelty exponent.
- E6 re-mint gate: `arm` multi-override sweep. Re-minting a collapsed lineage
  locks in divergence of KL-to-original; gating on diversity prevents it.
- E2 analysis add-ons (from the companion work order, numbers verified): new
  analysis.py (reduce_to_stationary, critical_grounding with bootstrap CI ->
  g*=0.048, 95% CI [0.047,0.050]); tail_band_metrics + per-band logging; the
  E2 figure rebuilt as a 2x2 (defined g*+CI, g=0 flagged as a finite-time
  artifact, tail item-vs-mass, per-rarity-band panel). Uses truth-mass-weighted
  tail coverage rather than the raw (martingale) tail_mass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Giorgio Gilestro 2026-07-04 18:54:42 +02:00
parent a6eb9b7512
commit 1721d047fa
42 changed files with 1938 additions and 135 deletions

View file

@ -28,6 +28,8 @@ import yaml
from .lineage import run_lineage
from .seeding import spawn_seeds
from .teachers import make_correlated_teachers
from .truth import make_true_distribution
# Keys that make up a single-lineage configuration (everything else is experiment-level).
_LINEAGE_KEYS = ("truth", "dynamics", "generations", "metrics")
@ -55,6 +57,12 @@ def _apply_param(lineage_cfg: dict, param: str, value: Any) -> dict:
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}
if param == "arm":
# A named arm bundling several overrides applied together (e.g. E6 varies
# grounding + remint settings jointly). value = {name, set: {dotted.path: v}}.
for path, v in value.get("set", {}).items():
_set_by_path(lineage_cfg, path, v)
return {"arm": value["name"]}
_set_by_path(lineage_cfg, param, value)
return {param.split(".")[-1]: value}
@ -119,6 +127,75 @@ def run_experiment(cfg: dict) -> pd.DataFrame:
return out
def run_coverage(cfg: dict) -> pd.DataFrame:
"""E4 runner: multi-teacher recombination coverage (blueprint 2.5-E4 / 2.7.1).
A single distillation step, not a lineage. For each (K_T, rho) grid point and
replicate: build K_T correlated teachers (marginal retention q, pairwise
correlation rho), then measure two coverages of the tail:
* ``union_coverage`` fraction of tail items retained by >=1 teacher (the
construction-level union U(K_T, rho, q); must match the closed form). This is the
recombination *supply*.
* ``surviving_mean`` / ``surviving_max`` fraction of tail items that survive the
pupil's size-n resampling (+ optional grounding m) under two recombination
operators: ``mean`` (blueprint mean-mixture distillation) and ``max`` (union-
preserving model-merge, à la M2N2). Under ``mean`` the union gain is diluted by
1/K_T and (in the rare-tail linear regime) is exactly cancelled expected pupil
tail mass is conserved at q·(tail mass) regardless of K_T, so surviving is flat.
Under ``max`` each item keeps its strongest teacher, so surviving rises with K_T
and with decorrelation. The gap ``union - surviving`` is the tail recombination
supplied but drift/dilution re-erased.
Matched budget: the pupil draws n samples total from the combined teachers
(equivalently n/K_T each), so more teachers != more data.
"""
truth, cov = cfg["truth"], cfg["coverage"]
td = make_true_distribution(
truth["K"], truth["R"], truth["tail"], truth["tail_frac"], truth["zipf_s"], 0,
tail_threshold=truth["tail_threshold"],
)
tail_idx = np.flatnonzero(td.tail_mask)
n, q = int(cov["n"]), float(cov["q"])
retain_thresh = 1e-8 # dropped tails sit at ~tail_floor (1e-9); retained at ~p*_j
sweeps = cfg["sweep"]
if isinstance(sweeps, dict):
sweeps = [sweeps]
params = [s["param"] for s in sweeps]
value_lists = [list(s["values"]) for s in sweeps]
seeds = spawn_seeds(int(cfg["seed"]), int(cfg["n_replicates"]))
rows: list[dict] = []
for combo in itertools.product(*value_lists):
d = dict(zip(params, combo))
K_T, rho = int(d["K_T"]), float(d["rho"])
g = float(d.get("g", cov.get("g", 0.0))) # g may be swept or fixed
m = 0 if g <= 0.0 else int(round(n * g / (1.0 - g)))
for rep, ss in enumerate(seeds):
child = int(ss.generate_state(1)[0])
teachers = np.asarray(make_correlated_teachers(
td.p_star, td.tail_mask, K_T, rho, q, seed=child))
retained = teachers[:, tail_idx] > retain_thresh # (K_T, T)
union = float(np.mean(retained.any(axis=0)))
surviving = {}
for offset, combine in ((1, teachers.mean), (2, teachers.max)):
p = combine(axis=0)
p = p / p.sum()
rng = np.random.default_rng(child + offset)
counts = rng.multinomial(n, p)
if m > 0:
counts = counts + rng.multinomial(m, td.p_star)
surviving[offset] = float(np.mean(counts[tail_idx] > 0))
rows.append({
"experiment": cfg["experiment"], "K_T": K_T, "rho": rho,
"replicate": rep, "union_coverage": union,
"surviving_mean": surviving[1], "surviving_max": surviving[2],
"g": g, "q": q,
})
return pd.DataFrame(rows)
def _git_commit() -> str | None:
try:
return subprocess.check_output(
@ -148,12 +225,13 @@ def save_artifacts(cfg: dict, df: pd.DataFrame, out_dir: Path) -> None:
"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,
}
if cfg.get("kind", "lineage") == "lineage":
resolved["grid"] = [
{"label": label, "lineage_cfg": lineage_cfg}
for label, lineage_cfg in expand_sweeps(cfg)
]
(out_dir / "resolved_config.yaml").write_text(yaml.safe_dump(resolved, sort_keys=False))
manifest = {
@ -175,7 +253,7 @@ def run_and_save(config_path: str | Path) -> Path:
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)
df = run_coverage(cfg) if cfg.get("kind") == "coverage" else run_experiment(cfg)
save_artifacts(cfg, df, out_dir)
return out_dir