manifoldgl-cp3000 / kappa_invariance_test.py
jesusvilela's picture
Add reproducible kappa weight-invariance test (CPU, seconds)
d50e1b3 verified
Raw
History Blame Contribute Delete
10.1 kB
"""kappa_invariance_test.py — Reproducible verification of §2.6 of
draft_paper_falsification.md: the legacy curvature telemetry is invariant
w.r.t. trained weights (an architecture constant), and the 2026-07 remediation
(learnable conformal factor) makes curvature weight-sensitive.
CPU-only, seconds to run. Requires only the cp3000 adapter_weights.pt
(58,705,739 bytes, SHA256 2004373636B049FC03771EE087FF1E4053D8003C18C8F3FA94668D598145DB14).
Usage:
python scripts/kappa_invariance_test.py \
[--checkpoint ../igbundle_phase8_training/checkpoint-3000/adapter_weights.pt]
Test A (legacy replica): runs the pre-audit estimator logic
kappa = -0.5 * Lap(log det g) / D, g(x) = lambda(x) * (LL^T + eps*I),
lambda(x) = 1 + 0.5 * tanh||x|| (hardcoded)
under four metric_chol settings at IDENTICAL positions and finite-difference
directions:
1. trained (cp3000 checkpoint)
2. identity (untrained init, no noise)
3. analytic (chol-free closed form: -(D/2) * d^2/dx_k^2 log lambda)
4. random (control; expected to deviate via log-det clamp saturation)
Expected: (1) ~= (2) ~= (3) to <0.1% — proving the published K values contained
no trained parameter.
Test B (remediation): the corrected estimator in the current codebase responds
to the (now optionally trainable) conformal amplitude. Skipped gracefully if
the igbundle package cannot be imported.
Test C (radius sweep): reproduces the magnitude gradient that explains the
published values (large |kappa| near origin -> small at the r=0.95 projection
boundary, where -5.63/-5.72 were read).
"""
import argparse
import math
import os
import sys
import torch
DEFAULT_CHECKPOINT = os.path.join(
os.path.dirname(__file__), "..", "..",
"igbundle_phase8_training", "checkpoint-3000", "adapter_weights.pt",
)
D = 64 # latent_dim of cp3000 (train_refined_hf.py)
P = 8 # num_components of cp3000
EPS_FD = 1e-3
SEED = 1234
# ----------------------------------------------------------------------------
# Legacy estimator replica (verbatim logic of the pre-audit
# RiemannianGeometry.get_metric + estimate_sectional_curvature_stochastic)
# ----------------------------------------------------------------------------
def legacy_metric(positions: torch.Tensor, metric_chol: torch.Tensor) -> torch.Tensor:
"""g(x) = lambda(x) * (tril(clamp(L)) tril(clamp(L))^T + 1e-5 I)."""
B, T, Pn, Dn = positions.shape
L = torch.tril(metric_chol.to(positions.dtype))
L = torch.clamp(L, min=-5.0, max=5.0)
metric = torch.matmul(L, L.transpose(-1, -2))
eye = torch.eye(Dn, dtype=positions.dtype).expand_as(metric)
metric = metric + 1e-5 * eye
metric = metric.unsqueeze(0).unsqueeze(0).expand(B, T, -1, -1, -1)
norm_x = torch.norm(positions, dim=-1, keepdim=True)
conformal = (1.0 + 0.5 * torch.tanh(norm_x)).unsqueeze(-1)
return metric * conformal
def safe_log_det(metric: torch.Tensor) -> torch.Tensor:
_, logabsdet = torch.slogdet(metric)
return logabsdet.clamp(-50.0, 50.0)
def legacy_kappa(positions: torch.Tensor, metric_chol: torch.Tensor,
directions: list) -> torch.Tensor:
"""kappa = -0.5 * Lap(log det g) / D with the legacy D-scaling."""
base = safe_log_det(legacy_metric(positions, metric_chol))
lap = torch.zeros_like(base)
for k in directions:
plus = positions.clone(); plus[..., k] += EPS_FD
minus = positions.clone(); minus[..., k] -= EPS_FD
f_p = safe_log_det(legacy_metric(plus, metric_chol))
f_m = safe_log_det(legacy_metric(minus, metric_chol))
lap = lap + (f_p + f_m - 2.0 * base) / (EPS_FD * EPS_FD)
lap = lap * (D / len(directions))
return -0.5 * lap / D
def analytic_kappa(positions: torch.Tensor, directions: list) -> torch.Tensor:
"""Chol-free closed form: -(D/2) * mean_k d^2/dx_k^2 log(1 + 0.5 tanh||x||).
Contains NO trained parameter. Agreement with legacy_kappa(trained) is the
proof of weight-invariance: log det g = D log lambda + const(x), and the
constant cancels in finite differences.
"""
def log_lam(x):
return torch.log(1.0 + 0.5 * torch.tanh(torch.norm(x, dim=-1)))
base = log_lam(positions)
lap = torch.zeros_like(base)
for k in directions:
plus = positions.clone(); plus[..., k] += EPS_FD
minus = positions.clone(); minus[..., k] -= EPS_FD
lap = lap + (log_lam(plus) + log_lam(minus) - 2.0 * base) / (EPS_FD * EPS_FD)
lap = lap * (D / len(directions))
return -0.5 * D * lap / D # = -(D/2) * mean second derivative, legacy scaling
def find_metric_chol(state_dict: dict) -> torch.Tensor:
candidates = [k for k in state_dict if k.endswith("metric_chol")]
if not candidates:
raise KeyError(
f"No 'metric_chol' key in checkpoint. Keys sample: {list(state_dict)[:10]}")
chol = state_dict[candidates[0]].double().cpu()
print(f" metric_chol key: {candidates[0]} shape: {tuple(chol.shape)}")
return chol
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--checkpoint", default=DEFAULT_CHECKPOINT)
args = parser.parse_args()
torch.manual_seed(SEED)
# Shared positions and FD directions — identical for every metric setting.
# Positions must be BALL-INTERIOR (||x|| < 0.95, as produced by
# _project_to_ball in the adapter): with raw randn in D=64 the norm is
# ~sqrt(D)*sigma, deep in tanh saturation, and the finite differences of a
# float32 slogdet reduce to cancellation noise. float64 + realistic norms
# are required for a clean invariance readout.
raw = torch.randn(1, 4, P, D, dtype=torch.float64)
target_norms = torch.rand(1, 4, P, 1, dtype=torch.float64) * 0.75 + 0.15 # in [0.15, 0.9]
positions = raw / raw.norm(dim=-1, keepdim=True) * target_norms
directions = [int(i) for i in torch.randint(0, D, (16,))]
print("=" * 72)
print("TEST A — legacy estimator invariance w.r.t. metric_chol")
print("=" * 72)
results = {}
ckpt_path = os.path.abspath(args.checkpoint)
if os.path.exists(ckpt_path):
print(f"Loading checkpoint: {ckpt_path}")
# weights_only=True: never unpickle arbitrary objects from a checkpoint.
state = torch.load(ckpt_path, map_location="cpu", weights_only=True)
if not isinstance(state, dict):
state = state.state_dict()
trained_chol = find_metric_chol(state)
results["trained (cp3000)"] = legacy_kappa(positions, trained_chol, directions).mean().item()
# Report how far the trained metric sits from identity (audit: ~= I)
ident = torch.eye(D, dtype=torch.float64).unsqueeze(0).repeat(trained_chol.shape[0], 1, 1)
rel = (trained_chol - ident).norm() / ident.norm()
print(f" ||trained_chol - I|| / ||I|| = {rel.item():.4f}")
else:
print(f"Checkpoint not found at {ckpt_path} — skipping trained row.")
identity_chol = torch.eye(D, dtype=torch.float64).unsqueeze(0).repeat(P, 1, 1)
results["identity"] = legacy_kappa(positions, identity_chol, directions).mean().item()
results["analytic (chol-free)"] = analytic_kappa(positions, directions).mean().item()
random_chol = torch.randn(P, D, D, dtype=torch.float64)
results["random (control)"] = legacy_kappa(positions, random_chol, directions).mean().item()
width = max(len(k) for k in results)
for name, val in results.items():
print(f" {name:<{width}} : mean kappa = {val: .3f}")
if "trained (cp3000)" in results:
rel_dev = abs(results["trained (cp3000)"] - results["identity"]) / abs(results["identity"])
print(f"\n trained vs identity relative deviation: {rel_dev:.2e}")
verdict = "WEIGHT-INVARIANT (architecture constant)" if rel_dev < 1e-3 \
else "weight-sensitive (unexpected — investigate)"
print(f" VERDICT: {verdict}")
print()
print("=" * 72)
print("TEST C — radius sweep (explains published magnitudes)")
print("=" * 72)
for r in (0.1, 0.3, 0.5, 0.7, 0.9):
pos_r = torch.zeros(1, 1, P, D, dtype=torch.float64)
pos_r[..., 0] = r
k_r = analytic_kappa(pos_r, list(range(0, D, 8))).mean().item()
print(f" r = {r:.1f} : kappa = {k_r: .3f}")
print(" (the published -5.63/-5.72 are this curve read near the r=0.95")
print(" projection boundary; the spread across published values is the")
print(" coordinate-norm distribution shifting between evals)")
print()
print("=" * 72)
print("TEST B — remediation: corrected estimator responds to conformal scale")
print("=" * 72)
src = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src"))
if src not in sys.path:
sys.path.insert(0, src)
try:
from igbundle.core.config import IGBundleConfig
from igbundle.geometry.riemannian import RiemannianGeometry
except Exception as exc: # pragma: no cover
print(f" SKIPPED (igbundle import failed: {exc})")
return
cfg = IGBundleConfig(latent_dim=D, num_components=P, num_categories=16,
manifold_type="riemannian", learnable_conformal=True)
geo = RiemannianGeometry(cfg)
pos32 = positions.float()
torch.manual_seed(SEED)
k_a = geo.estimate_sectional_curvature_stochastic(pos32, num_samples=8).mean().item()
with torch.no_grad():
geo.conformal_scale.fill_(0.05)
torch.manual_seed(SEED)
k_b = geo.estimate_sectional_curvature_stochastic(pos32, num_samples=8).mean().item()
print(f" conformal_scale=0.50 : kappa = {k_a: .6f}")
print(f" conformal_scale=0.05 : kappa = {k_b: .6f}")
ok = abs(k_a - k_b) > 1e-8
print(f" VERDICT: {'RESPONSIVE — regularizer can bind' if ok else 'unresponsive (unexpected)'}")
if __name__ == "__main__":
main()