Lands the synthetic-ablation infrastructure proposed in fce8826's ticket §7 amendment. Same pattern as #000035 Phase 1: ship the deterministic primitive + unit tests + KAT-pinned fixture on synthetic inputs ahead of v7 deployment ramp-up, so the infrastructure is unit-tested + bench-pinned the moment a real v7 checkpoint becomes available (Phase 1b). bench/scripts/phi_alignment_probe.py ==================================== Implements ``measure_alignment(W, hessian_eval, *, k_top, k_bot, epsilon) -> AlignmentReport`` per #000034 §3.1: - Lanczos top-k + bottom-k via ``scipy.sparse.linalg.eigsh`` over a user-supplied HVP closure. Probe never materializes H. - Alignment score: A(W, H) = Σ_j (Σ_i ⟨W·e_i, v_j⟩²) / (λ_j+ε) / ‖W‖_F², per ticket §2.1. Computed via W^T @ eigvecs and squared-column-norms (numerically stable + cheap). - Verdict thresholds (§3.3): STRUCTURAL_ALIGNMENT (ratio > 1.5) / NO_ALIGNMENT / ANTI_ALIGNED (ratio < 0.7). Defect caught + fixed during smoke-testing: the original "a_uniform" baseline used the mean of a_top + a_bot, which mechanically over-weights a_bot due to the 1/(λ+ε) term. Fix: analytical isotropic baseline, derived in 2026-05-10 docstring: E[A_k(W_uniform, H)] = (1/dim_d) Σ_{j in k-subset} 1/(λ_j+ε) Under the random-oracle modeling W's columns are isotropic Gaussians with E[‖W^T v_j‖²/‖W‖_F²] = 1/dim_d, so this is the expected score for a uniformly-distributed W. Smoke test post-fix: aligned → STRUCTURAL_ALIGNMENT (ratio ~7.97), uniform → NO_ALIGNMENT (ratio ~1.00), anti → ANTI_ALIGNED (ratio ~0.00). All three classes land cleanly in their expected verdict bucket. Module exports ``PROBE_VERSION = "phi-alignment-v1-lanczos"`` so future algorithm rotations are detectable at the call site without string-comparing module paths. Same convention as #000035's PHI_PRG_VERSION. bench/fixtures/phi-alignment/synthetic-checkpoints.jsonl ======================================================== 30 KAT entries — 10 per class (aligned / uniform / anti) — each pinning (seed, dim_d, k, class) → expected_verdict + observed_ratio for regression coverage. Deterministic-seeded so CI replays exactly. Algorithm change MUST bump PROBE_VERSION + emit a new fixture file under bench/fixtures/phi-alignment/. Class ratio ranges: - aligned: 7.77 - 8.27 (well above 1.5 STRUCTURAL_ALIGNMENT floor) - uniform: 0.95 - 1.04 (cleanly within NO_ALIGNMENT band) - anti: 0.00 (well below 0.7 ANTI_ALIGNED ceiling) tests/test_phi_alignment_probe.py ================================= 14 tests covering #000034 §3.2 + the strict-input-validation surface: - Determinism (verdict + ratio stable across calls within Lanczos float tolerance — eigsh uses randomized initial vectors). - Verdict thresholds (engineered cases land in correct bucket). - Lanczos convergence (top-k matches dense decomposition on synthetic diagonal Hessian within 1e-6). - Module export shape (AlignmentReport JSON-serializable; PROBE_VERSION + thresholds exported). - Validation rejects: non-2D W, dim_d mismatch, k_top+k_bot > dim_d, zero epsilon, zero-norm W, non-square H. - KAT regression against the 30-entry fixture. Tests skip via ``pytest.importorskip`` when ``[hessian]`` extras absent, same fail-soft pattern as the ``[math]``-extras tests for sympy. pyproject.toml — new [hessian] optional-deps block ================================================== Adds ``numpy>=1.26`` + ``scipy>=1.11`` under a new ``[hessian]`` extras gate. Same pattern as ``[math]`` for sympy: kept out of core deps to keep fresh installs lightweight (~80 MB combined). Operators install via ``pip install 'arborist[hessian]'``. #000034 status flip =================== Ticket §7: "open · awaiting go/no-go" → "in progress · Phase 1a landed 2026-05-10; Phase 1b parks for v7 deployment ramp-up". Phase 1b unchanged: closure criterion still requires a real v7 checkpoint measurement that resolves §9.1 of the soft-hash- channel-analysis. TICKETS.md index row refreshed. Hygiene ======= - make test → 1669 passed, 45 skipped (was 1643; +14 anchor_prg not in suite from Phase 1a, +14 phi_alignment from this commit — wait, +12 net since some tests were dropped/renamed in fox's parallel work. Bottom-line: 1669 stable.) - make chain-check-shards → 0 across all 7 shards. - arborist.substrate namespace untouched; this lands under bench/scripts/ since it's a measurement tool, not a substrate primitive — same dir as phi_alignment_probe's intended siblings.
280 lines
10 KiB
Python
280 lines
10 KiB
Python
"""φ_linear Hessian-alignment probe (ticket #000034 Phase 1a).
|
||
|
||
Measures whether the v7 reference frozen-seed linear projection
|
||
``W`` (dim_d × 256, mapping SHA-256-digest space to the dim_h
|
||
parameter-vector space) has structural alignment with the
|
||
low-eigenvalue subspace of the typical training-loss Hessian
|
||
``H(Θ)``.
|
||
|
||
Why this matters — from #000018 §5.1:
|
||
|
||
The v7 reference choice (frozen-seed Gaussian linear projection)
|
||
is **not obviously independent of parameter geometry**. If W's
|
||
column space aligns with low-eigenvalue directions of the loss
|
||
Hessian, the L2 anchor pull provides cheap parameter movement
|
||
in directions correlated with SHA-256 buckets. Cheap = "reachable
|
||
within a per-step gradient budget" = exploitable by a T2
|
||
gradient-shaping adversary.
|
||
|
||
This module ships only the probe + verdict logic. Phase 1a uses
|
||
synthetic Hessians (engineered eigenstructure + known W placement)
|
||
to validate the probe end-to-end. Phase 1b runs the same probe
|
||
against a real v7 checkpoint when one becomes available — that's
|
||
when the verdict actually informs the M1 (φ_PRG) vs M2 (anchor
|
||
nonce) priority decision.
|
||
|
||
Construction — the alignment score (#000034 §2.1):
|
||
|
||
A(W, H) = Σ_i ⟨W·e_i, v_i⟩² / (λ_i + ε) / ‖W‖_F²
|
||
|
||
where the sum runs over the columns ``W·e_i`` of W and the
|
||
eigenvectors ``v_i`` of H, ``λ_i`` are the matching eigenvalues,
|
||
and ε is a small regularizer to avoid division by 0 on flat
|
||
directions. Under random-oracle modeling A is uniform across the
|
||
spectrum; concentration in the low-λ tail is the alignment
|
||
signature.
|
||
|
||
Verdict thresholds (#000034 §3.3):
|
||
|
||
- ``A_low / A_uniform > 1.5`` → ``STRUCTURAL_ALIGNMENT`` (M1
|
||
priority).
|
||
- ``A_low / A_uniform`` near ``1.0`` → ``NO_ALIGNMENT`` (M2
|
||
alone sufficient).
|
||
- ``A_low / A_uniform < 0.7`` → ``ANTI_ALIGNED`` (φ_linear is
|
||
actually safe vs the loss landscape).
|
||
|
||
This module imports numpy + scipy at function call time (not
|
||
module load time) so the rest of arborist can import it without
|
||
the [hessian] extras installed; tests skip via
|
||
``pytest.importorskip`` when absent.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import asdict, dataclass, field
|
||
from typing import Callable
|
||
|
||
# Module-level marker so callers can detect probe version drift.
|
||
PROBE_VERSION = "phi-alignment-v1-lanczos"
|
||
|
||
# Verdict thresholds per #000034 §3.3.
|
||
STRUCTURAL_ALIGNMENT_RATIO_FLOOR = 1.5
|
||
ANTI_ALIGNED_RATIO_CEILING = 0.7
|
||
|
||
# Default Lanczos hyperparameters per #000034 §2.2 recommendation.
|
||
DEFAULT_K_TOP = 100
|
||
DEFAULT_K_BOT = 100
|
||
DEFAULT_EPSILON = 1e-6
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class AlignmentReport:
|
||
"""Output of :func:`measure_alignment`. Frozen so a caller's
|
||
JSON serialization is stable; round-trips via ``asdict``.
|
||
"""
|
||
verdict: str # STRUCTURAL_ALIGNMENT | NO_ALIGNMENT | ANTI_ALIGNED
|
||
ratio: float # A(W, H_low) / A(W, H_uniform)
|
||
a_top: float # alignment score concentrated on top-k (high λ)
|
||
a_bot: float # alignment score concentrated on bot-k (low λ)
|
||
a_uniform: float # baseline assuming uniform alignment
|
||
eigenvalues_top: tuple[float, ...]
|
||
eigenvalues_bot: tuple[float, ...]
|
||
w_frobenius_norm_squared: float
|
||
k_top: int
|
||
k_bot: int
|
||
epsilon: float
|
||
probe_version: str = field(default_factory=lambda: PROBE_VERSION)
|
||
|
||
def as_dict(self) -> dict:
|
||
d = asdict(self)
|
||
# tuples → lists for JSON friendliness
|
||
d["eigenvalues_top"] = list(self.eigenvalues_top)
|
||
d["eigenvalues_bot"] = list(self.eigenvalues_bot)
|
||
return d
|
||
|
||
|
||
def measure_alignment(
|
||
W,
|
||
hessian_eval: Callable,
|
||
*,
|
||
dim_d: int | None = None,
|
||
k_top: int = DEFAULT_K_TOP,
|
||
k_bot: int = DEFAULT_K_BOT,
|
||
epsilon: float = DEFAULT_EPSILON,
|
||
) -> AlignmentReport:
|
||
"""Run the alignment probe on ``W`` against the Hessian operator
|
||
``hessian_eval``.
|
||
|
||
Parameters
|
||
----------
|
||
W
|
||
Numpy array of shape ``(dim_d, 256)`` per the v7 reference
|
||
spec — the frozen-seed linear projection from SHA-256-digest
|
||
space to parameter-vector space.
|
||
hessian_eval
|
||
Callable ``x → H @ x`` representing the symmetric Hessian
|
||
``H(Θ)`` as a linear operator. The probe never materializes
|
||
the full Hessian; Lanczos only needs matrix-vector products.
|
||
dim_d
|
||
Parameter-space dimension. Defaults to ``W.shape[0]``.
|
||
k_top, k_bot
|
||
Number of top and bottom eigenpairs to extract. ``k_top +
|
||
k_bot`` should be ≤ dim_d; per #000034 §2.2,
|
||
100/100 is empirically tractable for ~10⁸-parameter
|
||
checkpoints.
|
||
epsilon
|
||
Regularizer inside the alignment score's
|
||
``1 / (λ_i + ε)`` term to avoid division by 0 on flat
|
||
directions of the loss landscape.
|
||
|
||
Returns
|
||
-------
|
||
AlignmentReport
|
||
Per #000034 §3.3 the verdict is
|
||
``STRUCTURAL_ALIGNMENT`` / ``NO_ALIGNMENT`` /
|
||
``ANTI_ALIGNED``.
|
||
|
||
Raises
|
||
------
|
||
ValueError
|
||
If ``W`` is not 2-D, if its shape doesn't agree with
|
||
``dim_d``, or if Lanczos can't extract the requested number
|
||
of eigenpairs.
|
||
"""
|
||
import numpy as np
|
||
from scipy.sparse.linalg import LinearOperator, eigsh
|
||
|
||
# Validation — fail loud on shape errors rather than producing a
|
||
# silent zero alignment.
|
||
if W.ndim != 2:
|
||
raise ValueError(
|
||
f"W must be 2-D (dim_d × 256); got shape {W.shape!r}"
|
||
)
|
||
if dim_d is None:
|
||
dim_d = W.shape[0]
|
||
if W.shape[0] != dim_d:
|
||
raise ValueError(
|
||
f"W.shape[0] = {W.shape[0]} but dim_d = {dim_d}"
|
||
)
|
||
if k_top + k_bot > dim_d:
|
||
raise ValueError(
|
||
f"k_top ({k_top}) + k_bot ({k_bot}) > dim_d ({dim_d}); "
|
||
"Lanczos cannot extract more eigenpairs than the matrix dim"
|
||
)
|
||
if epsilon <= 0:
|
||
raise ValueError(f"epsilon must be positive; got {epsilon!r}")
|
||
|
||
# Wrap the user closure in a scipy LinearOperator so eigsh can
|
||
# call it via matvec. The operator is symmetric by construction
|
||
# (Hessian) so eigsh's symmetric path applies.
|
||
op = LinearOperator(
|
||
shape=(dim_d, dim_d),
|
||
matvec=hessian_eval,
|
||
dtype=np.float64,
|
||
)
|
||
|
||
# Lanczos top-k via 'LA' (largest algebraic). Bottom-k via 'SA'
|
||
# (smallest algebraic). Both directions are needed — the
|
||
# alignment score weights inversely (1/(λ+ε)), so the bottom-k
|
||
# dominates for any non-flat spectrum but we report top-k to
|
||
# detect anti-alignment too.
|
||
eigvals_top, eigvecs_top = eigsh(op, k=k_top, which="LA")
|
||
eigvals_bot, eigvecs_bot = eigsh(op, k=k_bot, which="SA")
|
||
|
||
# Frobenius normalizer.
|
||
w_fro_sq = float(np.linalg.norm(W, "fro") ** 2)
|
||
if w_fro_sq == 0.0:
|
||
raise ValueError("W has zero Frobenius norm; degenerate input")
|
||
|
||
# Alignment score on a given (eigvals, eigvecs) pair.
|
||
# ⟨W·e_i, v_j⟩ = (W^T v_j)_i, so the inner products across all
|
||
# 256 columns of W are the entries of (W^T @ v_j). Squared, then
|
||
# weighted by 1/(λ_j + ε) and summed over j, then normalized.
|
||
def _score(eigvals: "np.ndarray", eigvecs: "np.ndarray") -> float:
|
||
# eigvecs.shape = (dim_d, k); each column is a v_j.
|
||
proj = W.T @ eigvecs # (256, k); col j is W^T v_j
|
||
# Squared norms per column = Σ_i ⟨W·e_i, v_j⟩²
|
||
col_sq = np.sum(proj ** 2, axis=0) # (k,)
|
||
weighted = col_sq / (eigvals + epsilon)
|
||
return float(np.sum(weighted) / w_fro_sq)
|
||
|
||
a_top = _score(eigvals_top, eigvecs_top)
|
||
a_bot = _score(eigvals_bot, eigvecs_bot)
|
||
|
||
# Isotropic baseline (#000034 §2.1, derived 2026-05-10).
|
||
#
|
||
# Under random-oracle modeling W's columns are isotropically
|
||
# distributed Gaussians; for each unit-norm eigenvector v_j,
|
||
# E[‖W^T v_j‖²] = 256 σ² and ‖W‖_F² = dim_d × 256 σ², so
|
||
# E[‖W^T v_j‖² / ‖W‖_F²] = 1/dim_d. Thus:
|
||
#
|
||
# E[A_k(W_uniform, H)] = (1/dim_d) Σ_{j in k-subset} 1/(λ_j+ε)
|
||
#
|
||
# This is the per-eigenvalue-subset baseline a uniformly-
|
||
# distributed W would produce; the verdict ratio is the
|
||
# observed alignment on the BOTTOM-k normalized by the
|
||
# bottom-k's isotropic baseline. A previous formulation
|
||
# (mean(a_top, a_bot)) was wrong because the 1/(λ+ε) weighting
|
||
# makes a_bot mechanically large for any W shape — so the
|
||
# ratio came out > 1 even for genuinely uniform W. The
|
||
# analytical baseline corrects this.
|
||
a_uniform_top = float(np.sum(1.0 / (eigvals_top + epsilon)) / dim_d)
|
||
a_uniform_bot = float(np.sum(1.0 / (eigvals_bot + epsilon)) / dim_d)
|
||
|
||
if a_uniform_bot == 0.0:
|
||
# Degenerate — bottom-k baseline is exactly zero (would
|
||
# require infinite eigenvalues, which Lanczos can't return).
|
||
# Defensive: treat as NO_ALIGNMENT.
|
||
ratio = 1.0
|
||
else:
|
||
ratio = a_bot / a_uniform_bot
|
||
|
||
if ratio > STRUCTURAL_ALIGNMENT_RATIO_FLOOR:
|
||
verdict = "STRUCTURAL_ALIGNMENT"
|
||
elif ratio < ANTI_ALIGNED_RATIO_CEILING:
|
||
verdict = "ANTI_ALIGNED"
|
||
else:
|
||
verdict = "NO_ALIGNMENT"
|
||
|
||
return AlignmentReport(
|
||
verdict=verdict,
|
||
ratio=ratio,
|
||
a_top=a_top,
|
||
a_bot=a_bot,
|
||
a_uniform=a_uniform_bot, # bottom-k isotropic baseline
|
||
eigenvalues_top=tuple(float(x) for x in eigvals_top),
|
||
eigenvalues_bot=tuple(float(x) for x in eigvals_bot),
|
||
w_frobenius_norm_squared=w_fro_sq,
|
||
k_top=k_top,
|
||
k_bot=k_bot,
|
||
epsilon=epsilon,
|
||
)
|
||
|
||
|
||
def hessian_from_dense(H):
|
||
"""Helper for unit tests + KAT generation: turn a dense
|
||
symmetric matrix into a ``hessian_eval`` closure.
|
||
|
||
Production callsites (real v7 checkpoints) should pass an
|
||
HVP closure instead of materializing the Hessian; this helper
|
||
is for the synthetic Phase 1a path only.
|
||
"""
|
||
import numpy as np
|
||
|
||
H = np.asarray(H, dtype=np.float64)
|
||
if H.ndim != 2 or H.shape[0] != H.shape[1]:
|
||
raise ValueError(f"H must be square 2-D; got shape {H.shape!r}")
|
||
return lambda x: H @ x
|
||
|
||
|
||
__all__ = [
|
||
"PROBE_VERSION",
|
||
"STRUCTURAL_ALIGNMENT_RATIO_FLOOR",
|
||
"ANTI_ALIGNED_RATIO_CEILING",
|
||
"DEFAULT_K_TOP",
|
||
"DEFAULT_K_BOT",
|
||
"DEFAULT_EPSILON",
|
||
"AlignmentReport",
|
||
"measure_alignment",
|
||
"hessian_from_dense",
|
||
]
|