| """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
|
| P = 8
|
| EPS_FD = 1e-3
|
| SEED = 1234
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
| 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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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}")
|
|
|
| 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()
|
|
|
| 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:
|
| 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()
|
|
|