"""φ_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", ]