MachineSex/src/knowledge/metrics.py
Giorgio Gilestro 1721d047fa 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>
2026-07-04 18:54:42 +02:00

146 lines
5.4 KiB
Python

"""Per-generation metrics (blueprint 2.3).
All metrics operate on a probability vector ``p`` (a distribution over the K knowledge
items / alleles). Global and per-region variants are provided; E3 needs the per-region
forms. Every function is a pure function of its inputs.
"""
from __future__ import annotations
import numpy as np
def heterozygosity(p: np.ndarray) -> float:
"""Expected heterozygosity / Simpson diversity ``H = 1 - sum_i p_i^2``.
The lineage-health metric and the quantity the re-mint gate reads. ``H=0`` at
fixation (one item), ``H=1-1/K`` at the uniform distribution.
Args:
p (np.ndarray): Probability vector over the K items.
Returns:
float: Heterozygosity in [0, 1).
"""
p = np.asarray(p, dtype=float)
return float(1.0 - np.sum(p * p))
def forward_kl(p_star: np.ndarray, p: np.ndarray, eps: float) -> float:
"""Forward KL to truth ``D_KL(p_star || p) = sum_i p*_i log(p*_i / p_i)``.
The correct primary collapse metric: it *diverges* when ``p`` drops mass that
``p_star`` has, i.e. it explicitly punishes forgetting the improbable (blueprint 2.3;
reverse KL is deliberately not used). ``p`` is floored at ``eps`` to stay finite.
Args:
p_star (np.ndarray): True distribution.
p (np.ndarray): Current distribution.
eps (float): Floor applied to ``p`` before the log.
Returns:
float: Forward KL divergence (nats).
"""
p_star = np.asarray(p_star, dtype=float)
p = np.asarray(p, dtype=float)
p_floored = np.maximum(p, eps)
mask = p_star > 0.0 # 0 * log 0 contributes nothing
return float(np.sum(p_star[mask] * np.log(p_star[mask] / p_floored[mask])))
def tail_mass(p: np.ndarray, tail_mask: np.ndarray) -> float:
"""Total probability mass ``p`` places on the designated tail items.
The direct measure of collapse: the tail is lost first, so ``tail_mass -> 0`` is the
signature of drift-driven collapse (blueprint 2.3).
Args:
p (np.ndarray): Current distribution.
tail_mask (np.ndarray): Boolean mask of tail items.
Returns:
float: Mass on tail items.
"""
p = np.asarray(p, dtype=float)
return float(p[np.asarray(tail_mask, dtype=bool)].sum())
def support_size(p: np.ndarray, eps: float) -> int:
"""Number of items with mass above ``eps`` (surviving items).
Args:
p (np.ndarray): Current distribution.
eps (float): Support threshold.
Returns:
int: Count of items with ``p_i > eps``.
"""
p = np.asarray(p, dtype=float)
return int(np.sum(p > eps))
def tail_band_metrics(p: np.ndarray, p_star: np.ndarray, tail_mask: np.ndarray,
n_bands: int = 4, alive_eps: float = 1e-9):
"""Stratify the tail into ``n_bands`` equal-count rarity bands (band 0 = rarest).
Makes the per-item survival threshold ``m·p*_i ≳ 1`` (blueprint prediction 4) visible
band-wise: deeper (rarer) bands sit strictly below shallower ones and the gap narrows
as grounding rises. Both returned arrays are bounded in [0, 1]; single-run values are
noisy, so average over replicates before plotting.
Args:
p (np.ndarray): Current distribution.
p_star (np.ndarray): True distribution.
tail_mask (np.ndarray): Boolean tail mask.
n_bands (int): Number of equal-count rarity bands.
alive_eps (float): An item is "alive" if ``p_i > alive_eps``.
Returns:
tuple[np.ndarray, np.ndarray]: ``frac_alive[b]`` (fraction of band-b items alive)
and ``truth_mass_alive[b]`` (share of band-b's TRUE mass carried by alive items),
each length ``n_bands``.
"""
p = np.asarray(p, dtype=float)
p_star = np.asarray(p_star, dtype=float)
idx = np.where(np.asarray(tail_mask, dtype=bool))[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
def per_region(func, p: np.ndarray, regions: np.ndarray, *args) -> dict[int, float]:
"""Apply a metric independently to each region's sub-vector.
Each region's slice of ``p`` is *not* renormalised; the metric sees the raw masses,
so per-region ``tail_mass`` and ``support_size`` are directly comparable across
regions (needed for E3). ``heterozygosity`` per region is therefore a within-``p``
quantity, documented as such.
Args:
func: One of the metric callables above (called as ``func(p_region, *args)``).
p (np.ndarray): Current distribution.
regions (np.ndarray): Length-K region index per item.
*args: Extra positional args forwarded to ``func`` (e.g. eps, or a per-region
tail mask which is sliced automatically if it is a length-K boolean array).
Returns:
dict[int, float]: Region index -> metric value.
"""
p = np.asarray(p, dtype=float)
regions = np.asarray(regions)
out: dict[int, float] = {}
for r in np.unique(regions):
sel = regions == r
sliced_args = tuple(
a[sel] if isinstance(a, np.ndarray) and a.shape == regions.shape else a
for a in args
)
out[int(r)] = func(p[sel], *sliced_args)
return out