MachineSex/tasks/workorder-E2-analysis-addons.md
Giorgio Gilestro ab3dc10587 Restructure: descriptive tier and experiment names, paper/manuscript
- paper/pnas -> paper/manuscript (venue-neutral)
- configs/layer1 -> configs/inheritance, src/knowledge -> src/inheritance
  (imported as `inheritance`), make layer1 -> make inheritance; layer2 alias dropped
- inheritance and trained-network bundles named after the manuscript figure
  they feed (fig2_grounding_sweep, figS3_rebaselining, ...), or descriptively
  where they feed none; configs keep their `experiment:` value so parquet
  hashes are unchanged, only output.dir moves
- figure scripts, SI figure sources, notebooks, REPRODUCING.md, README and the
  SI Methods/tables updated; make clean no longer deletes tracked manifests;
  reproduce.sh hashes the s{seed}/ layouts too

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y64o8FKP7rCuXzC48pxpMm
2026-09-13 17:00:40 +01:00

253 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Work order: E2 analysis add-ons
*Increment to the Lamarckian-Society Layer-1 build. Assumes the blueprint conventions
(seeded RNG, config-driven, results in tidy DataFrames, figures are pure functions of
`results.parquet`). All code below was written and tested against NumPy 2.x / pandas 3.x
before hand-off; the "verified numbers" section gives the expected outputs so you can
confirm your wiring reproduces them.*
## Context
E1 and E2 are validated: E1 reproduces the analytic geometric decay `H0(1-1/n)^t`
(tail-first, support collapse, KL divergence); E2's stationary simulation points lie on
the exact closed form `H_eq = H* * m(2n+m-1)/(n+2nm+m^2)`. Three refinements make the E2
figure publication-honest. None change the dynamics; they are analysis + plotting only.
1. **Operational `g*` with a bootstrap CI.** The middle E2 panel is a *smooth saturating*
curve, not a sharp transition, so "critical g*" must be *defined*, not asserted:
`g*` = the grounding fraction at which stationary `H` first reaches `frac`×`H*`
(default `frac=0.95`). Report it with a percentile-bootstrap CI over replicates.
2. **Tail-mass companion curve.** The right E2 panel plots fraction of tail *items* alive,
which saturates low (~6%) because the deep Zipf tail is unrescuable at any feasible
grounding. Add the frequency-weighted **tail-mass-retained** curve alongside it: mass
is carried by the shallow tail and *is* rescued, so it looks healthy where item-count
does not. Both are correct; showing both is the honest picture. (`tail_mass` is already
a logged metric — this is a second series, no new storage.)
3. **`g=0` point is a finite-time artifact.** In the middle panel it sits at `H≈0.10`,
above the exact `H_eq(0)=0`, because at `g=0` there is no stationary state (still
collapsing at the last generation). Either exclude `g=0` from the stationary fit or
annotate that its true value is 0; don't let it read as a datatheory miss.
**Optional but recommended (turns point 2 into a mechanism panel):** a per-rarity-band
tail metric showing the deep band stays dead while the shallow band recovers — the
`m·p*_i ≳ 1` threshold acting band-wise, and the direct motivation for E4/E6.
---
## 1. New module: `src/inheritance/analysis.py`
Post-hoc analysis of E2 results. Pure NumPy/pandas, seeded, deterministic.
```python
import numpy as np
import pandas as pd
def reduce_to_stationary(df, value_col="heterozygosity", sweep_col="g",
replicate_col="seed", gen_col="generation", last_frac=0.33):
"""Per-generation frame -> one stationary value per (sweep, replicate), averaging
`value_col` over the final `last_frac` of generations. Use for any logged metric
(heterozygosity, tail_mass, ...)."""
rows = []
for (gval, rep), sub in df.groupby([sweep_col, replicate_col]):
v = sub.sort_values(gen_col)[value_col].to_numpy()
k = max(1, int(round(last_frac * v.size)))
rows.append({sweep_col: gval, replicate_col: rep, value_col: v[-k:].mean()})
return pd.DataFrame(rows)
def _interp_crossing(g, H, target):
"""First upward crossing of `target` by the (monotone-ish) curve H(g), by linear
interpolation between grid points. Returns (g_star, status) with status in
{'ok', 'below_grid', 'above_grid'}."""
g = np.asarray(g, float); H = np.asarray(H, float)
o = np.argsort(g); g, H = g[o], H[o]
if H[0] >= target:
return g[0], "below_grid" # already above at smallest g swept
idx = np.where(H >= target)[0]
if idx.size == 0:
return g[-1], "above_grid" # never reaches target within swept range
i = idx[0]
g0, g1, H0, H1 = g[i - 1], g[i], H[i - 1], H[i]
if H1 == H0:
return g1, "ok"
return g0 + (target - H0) * (g1 - g0) / (H1 - H0), "ok"
def critical_grounding(stationary_df, H_star, frac=0.95, sweep_col="g",
value_col="heterozygosity", n_boot=2000,
ci=(2.5, 97.5), seed=0):
"""Operational critical grounding fraction g*: the g at which stationary
heterozygosity first reaches `frac` * `H_star`, with a percentile-bootstrap CI
over replicates.
`stationary_df`: one row per (sweep_col, replicate_col) with the stationary value
(e.g. the output of reduce_to_stationary). `H_star`: heterozygosity of the truth,
= metrics.heterozygosity(p_star). Returns a dict with g_star (point estimate on the
replicate means), ci_low, ci_high, status, target_H, frac, n_boot.
Note: 'status' flags right/left censoring. If the sweep does not bracket the target,
widen the g grid rather than trusting a censored g*."""
target = frac * H_star
gs = np.sort(stationary_df[sweep_col].unique())
by_g = {gv: stationary_df.loc[stationary_df[sweep_col] == gv, value_col].to_numpy()
for gv in gs}
mean_H = np.array([by_g[gv].mean() for gv in gs])
g_star, status = _interp_crossing(gs, mean_H, target)
rng = np.random.default_rng(seed)
boots = np.empty(n_boot)
for b in range(n_boot):
Hb = np.array([rng.choice(by_g[gv], by_g[gv].size, replace=True).mean()
for gv in gs])
boots[b], _ = _interp_crossing(gs, Hb, target)
lo, hi = np.percentile(boots, ci)
return {"g_star": float(g_star), "ci_low": float(lo), "ci_high": float(hi),
"status": status, "target_H": float(target), "frac": frac,
"n_boot": n_boot}
```
---
## 2. New online metric in `src/inheritance/metrics.py` (optional band panel)
Compute this each generation from the current `p` and log the per-band arrays exactly
like the existing per-region metrics (e.g. columns `tail_frac_alive_band{b}` and
`tail_mass_alive_band{b}`, or long form). Both returned quantities are bounded in [0,1]
— do **not** use a raw mass ratio (tiny deep-band denominators make it explode).
```python
def tail_band_metrics(p, p_star, tail_mask, n_bands=4, alive_eps=1e-9):
"""Stratify the tail into `n_bands` equal-count rarity bands (band 0 = rarest /
deepest). Return two BOUNDED [0,1] arrays of length n_bands:
frac_alive[b] fraction of band-b items with p > alive_eps
truth_mass_alive[b] share of band-b's TRUE mass (sum p_star) carried by
still-alive items
Averaged over replicates, deeper bands sit strictly below shallower ones and the
gap narrows as grounding rises -- the per-item m*p*_i >~ 1 survival threshold made
visible (blueprint prediction 4). Single-run values are noisy; always average over
replicates before plotting."""
import numpy as np
idx = np.where(tail_mask)[0]
order = idx[np.argsort(p_star[idx])] # rarest first
bands = np.array_split(order, n_bands)
frac_alive = np.empty(n_bands)
truth_mass_alive = np.empty(n_bands)
for b, items in enumerate(bands):
alive = p[items] > alive_eps
frac_alive[b] = alive.mean()
ps = p_star[items]
truth_mass_alive[b] = ps[alive].sum() / ps.sum() if ps.sum() > 0 else np.nan
return frac_alive, truth_mass_alive
```
---
## 3. Figure updates: `figures/plot_fig2_grounding_sweep.py`
- **Middle panel:** call `critical_grounding(reduce_to_stationary(df), H_star, frac=0.95)`
and draw a vertical line/marker at `g_star` with a shaded CI band; annotate
`g* ≈ {g_star:.3f} (95% CI [...])`. Also report `frac=0.90` in the caption so the
"35%" range is explicit. Soften the title from "critical grounding ratio" to
e.g. "grounding saturates by g* ≈ 0.05 (95% of H*)".
- **Right panel:** add stationary **tail-mass-retained** vs g as a second series
(`reduce_to_stationary(df, value_col="tail_mass")`), on a twin axis if scales differ,
so mass-healthy vs items-poor is visible in one panel.
- **`g=0`:** drop it from the stationary fit *or* mark it hollow with a note
"pre-convergence; true H_eq(0)=0".
- **Optional band panel:** if `tail_band_metrics` is logged, add a small-multiples or
grouped-bar panel of `frac_alive` per band across the g sweep (replicate-averaged).
---
## 4. Tests to add (`tests/test_analysis.py`)
```python
import numpy as np, pandas as pd, pytest
from inheritance.analysis import reduce_to_stationary, critical_grounding
from inheritance.metrics import tail_band_metrics
def _H_eq(n, m, Hs): return Hs * m * (2 * n + m - 1) / (n + 2 * n * m + m * m)
def _synthetic_E2(n=200, Hs=0.95, reps=100, seed=1):
gs = [0.0, 0.005, 0.01, 0.02, 0.05, 0.1, 0.2, 0.4]
rng = np.random.default_rng(seed); rows = []
for g in gs:
m = 0 if g == 0 else int(round(g * n / (1 - g)))
Htrue = 0.10 if g == 0 else _H_eq(n, m, Hs) # g=0: finite-time artifact
for s in range(reps):
rows.append({"g": g, "seed": s, "heterozygosity": Htrue + rng.normal(0, 0.004)})
return pd.DataFrame(rows)
def test_critical_grounding_matches_known_crossing():
stat = _synthetic_E2()
r = critical_grounding(stat, H_star=0.95, frac=0.95, seed=7)
assert r["status"] == "ok"
assert 0.03 < r["g_star"] < 0.07 # ~0.047 for frac=0.95
assert r["ci_low"] <= r["g_star"] <= r["ci_high"]
r90 = critical_grounding(stat, H_star=0.95, frac=0.90, seed=7)
assert r90["g_star"] < r["g_star"] # lower bar -> smaller g*
def test_reduce_to_stationary_recovers_plateau():
# constant plateau + noise -> mean ~ plateau
rng = np.random.default_rng(0); rows = []
for g, plateau in [(0.02, 0.843), (0.05, 0.908)]:
for s in range(20):
for t in range(300):
v = (0.95 if t < 50 else plateau) + rng.normal(0, 0.003)
rows.append({"g": g, "seed": s, "generation": t, "heterozygosity": v})
red = reduce_to_stationary(pd.DataFrame(rows), last_frac=0.33)
means = red.groupby("g")["heterozygosity"].mean()
assert means[0.02] == pytest.approx(0.843, abs=0.01)
assert means[0.05] == pytest.approx(0.908, abs=0.01)
def test_tail_band_deep_below_shallow():
# grounded dynamics on a Zipf tail: deepest band <= shallowest band, replicate-avg
K = 1000; s = 1.1
w = 1.0 / np.arange(1, K + 1) ** s; p_star = w / w.sum()
tail_mask = p_star < 1e-3
n, m = 200, 10
FA = np.zeros(4)
for r in range(20):
rng = np.random.default_rng(1000 + r); p = p_star.copy()
for _ in range(600):
c = rng.multinomial(n, p) + rng.multinomial(m, p_star); p = c / c.sum()
fa, _ = tail_band_metrics(p, p_star, tail_mask, n_bands=4)
FA += fa
FA /= 20
assert FA[0] <= FA[-1] # deepest no better than shallowest
assert FA[-1] > FA[0] # and strictly worse on average
```
---
## Verified numbers (expected outputs — confirm your wiring reproduces these)
On the synthetic E2 set (n=200, H*=0.95, exact H_eq + N(0,0.004) noise, 100 reps):
| frac | g* (point) | 95% CI (tight, low-noise synthetic) |
|------|-----------|--------------------------------------|
| 0.90 | ~0.026 | ~[0.025, 0.026] |
| 0.95 | ~0.047 | ~[0.047, 0.048] |
| 0.99 | ~0.25 | ~[0.22, 0.27] |
(Real E2 replicate spread will widen these CIs — that is expected and correct.)
`reduce_to_stationary` on a plateau frame recovers 0.843 (g=0.02) and 0.908 (g=0.05).
`tail_band_metrics`, grounded Zipf tail (K=1000, tail=p*<1e-3, 889 tail items), 40 reps,
`frac_alive` per band [0=deepest .. 3=shallowest]:
| g | band0 | band1 | band2 | band3 |
|------|-------|-------|-------|-------|
| 0.0 | 0.000 | 0.000 | 0.000 | 0.000 |
| 0.01 | 0.002 | 0.002 | 0.004 | 0.010 |
| 0.05 | 0.004 | 0.009 | 0.012 | 0.033 |
| 0.20 | 0.015 | 0.022 | 0.032 | 0.075 |
Monotone (deeper = worse) at every g>0; all bands rise with g; deep band lags throughout.