Skip to content

Commit 858ed27

Browse files
thisiscamclaude
andcommitted
tests/log_jet: replace stale raw-NaN probes with mpmath reference
The two extreme-input tests (\`test_log_jet_finite_grad_at_d75_extreme\` and \`test_log_jet_finite_grad_nested_sp500_pattern\`) gated on raw bell mode producing NaN gradients. That assertion captured GPU semantics (where dot_general's reverse pass hits 0*inf and produces NaN) but on CPU raw produces a finite-but-wildly-wrong result instead — the catastrophic cancellation runs to completion rather than tripping NaN. The tests therefore failed on CPU even though log_jet itself worked correctly on both platforms. For the single-layer d=75 case, the Frank generator's d-th derivative has a clean closed form via Stirling numbers of the second kind: \`psi^{(d)}(t) = ((-1)^d/theta) * sum_{k=1}^{d} (k-1)! S(d,k) (x/(1-x))^k\` with \`x = (1-e^{-theta}) e^{-t}\`. Evaluate at 200-digit mpmath precision and assert log_jet matches to 1e-8 in both forward and gradient. This is a direct correctness anchor. For the nested 11x9 case, an mpmath reference would require reimplementing the Bell-polynomial nested scan at arbitrary precision (~hundreds of lines). Use log_jet as the de-facto reference (its correctness is anchored by the single-layer test that runs the same machinery), keep the finite-ness assertion, and replace the brittle raw-NaN probe with a unified raw-unreliable check that covers both platform failure modes (NaN gradient on GPU, forward disagreement with log_jet on CPU). Both tests now pass on CPU and GPU. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7dd2ca0 commit 858ed27

1 file changed

Lines changed: 93 additions & 35 deletions

File tree

‎tests/test_log_jet.py‎

Lines changed: 93 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222
jax.config.update("jax_enable_x64", True)
2323

24-
from acopula import compile_model, defmodel, marginal, copula
24+
from acopula import compile_model, marginal, copula
2525
from tensorflow_probability.substrates import jax as tfp
2626

2727
Uniform = tfp.distributions.Uniform
@@ -47,7 +47,6 @@ def generator(self, t):
4747
def test_log_jet_matches_raw_benign(d, u_value):
4848
"""At moderate d and non-extreme u, log_jet must agree with raw to
4949
rel err 1e-10 on both forward and gradient."""
50-
@defmodel
5150
def model(theta, obs):
5251
return Frank(theta[0])([
5352
marginal(Uniform(0.0, 1.0), obs=obs[j]) for j in range(d)
@@ -72,14 +71,17 @@ def model(theta, obs):
7271
# Stability on pathological inputs (the bug log-jet exists to fix)
7372
# ---------------------------------------------------------------------------
7473

75-
def test_log_jet_finite_grad_at_d75_extreme():
76-
"""Flat Frank at d=75 with all leaves at u=1e-3 makes raw produce
77-
NaN gradient via the (denormal Taylor coefficient → 1/x → inf →
78-
0×inf in dot_general) cascade. log_jet must produce a finite
79-
gradient."""
74+
def test_log_jet_matches_mpmath_at_d75_extreme():
75+
"""Flat Frank at d=75 with all leaves at u=1e-3 sits in the high-d
76+
extreme regime where the raw bell scan suffers catastrophic
77+
cancellation (NaN gradient on GPU; finite-but-wrong forward on CPU,
78+
off from truth by ~10^3). log_jet is the correct path here, and
79+
must match a 200-digit mpmath reference computed via Frank's
80+
closed-form psi^{(d)} expansion in Stirling numbers."""
81+
from mpmath import mp, mpf, exp, log, fabs, fsum, factorial, stirling2
82+
8083
d = 75
8184

82-
@defmodel
8385
def model(theta, obs):
8486
return Frank(theta[0])([
8587
marginal(Uniform(0.0, 1.0), obs=obs[j]) for j in range(d)
@@ -88,27 +90,77 @@ def model(theta, obs):
8890
init = jnp.array([1.2])
8991
obs = jnp.full((d,), 1e-3)
9092

91-
cm_raw = compile_model(model, template=init, method="bell")
9293
cm_log = compile_model(model, template=init, method="bell", log_jet=True)
9394

94-
g_raw = jax.grad(cm_raw.ll_fn, argnums=1)(obs, init)
95-
g_log = jax.grad(cm_log.ll_fn, argnums=1)(obs, init)
96-
97-
# Document the bug: raw produces NaN here.
98-
assert bool(jnp.any(jnp.isnan(g_raw))), \
99-
"raw should NaN at d=75 flat-Frank with all u=1e-3 — if this " \
100-
"starts passing, the bug got fixed elsewhere; either way, log_jet " \
101-
"should still be finite."
102-
assert bool(jnp.all(jnp.isfinite(g_log))), \
103-
f"log_jet grad must be finite, got {g_log}"
104-
105-
106-
def test_log_jet_finite_grad_nested_sp500_pattern():
107-
"""11x9 nested Frank with 5/9 extreme leaves per sector. This is
108-
the structure that originally triggered the sp500 d=99 failure."""
95+
# 200-digit mpmath reference. Frank's psi^{(d)}(t) has the closed form
96+
# psi^{(d)}(t) = ((-1)^d / theta) * sum_{k=1}^{d} (k-1)! S(d,k) (x/(1-x))^k
97+
# with x = (1 - e^{-theta}) e^{-t}. Single-layer log-density:
98+
# log c = log|psi^{(d)}(t)| + sum_j log|d psi^{-1}/du(u_j)|
99+
# where t = sum_j psi^{-1}(u_j).
100+
mp.dps = 200
101+
th = mpf("1.2")
102+
u_val = mpf("0.001")
103+
104+
def _alpha(t):
105+
return 1 - exp(-t)
106+
107+
def _psi_inv(u, t):
108+
return -log((1 - exp(-t * u)) / _alpha(t))
109+
110+
def _log_psi_d_at_t(t_val, t, n):
111+
x = _alpha(t) * exp(-t_val)
112+
one_minus_x = 1 - x
113+
terms = [factorial(k - 1) * stirling2(n, k) * (x / one_minus_x) ** k
114+
for k in range(1, n + 1)]
115+
psi_d = ((-1) ** n / t) * fsum(terms)
116+
return log(fabs(psi_d))
117+
118+
def _log_psi_inv_prime(u, t):
119+
return log(fabs(t * exp(-t * u) / (1 - exp(-t * u))))
120+
121+
def _log_density(t):
122+
t_inner = d * _psi_inv(u_val, t)
123+
return _log_psi_d_at_t(t_inner, t, d) + d * _log_psi_inv_prime(u_val, t)
124+
125+
ll_ref = float(_log_density(th))
126+
eps = mpf("1e-30")
127+
g_ref = float((_log_density(th + eps) - _log_density(th - eps)) / (2 * eps))
128+
129+
ll_log = float(cm_log.ll_fn(obs, init))
130+
g_log = float(jax.grad(cm_log.ll_fn, argnums=1)(obs, init)[0])
131+
132+
np.testing.assert_allclose(
133+
ll_log, ll_ref, rtol=1e-8,
134+
err_msg=f"log_jet forward {ll_log} vs mpmath {ll_ref}",
135+
)
136+
np.testing.assert_allclose(
137+
g_log, g_ref, rtol=1e-8,
138+
err_msg=f"log_jet grad {g_log} vs mpmath {g_ref}",
139+
)
140+
141+
142+
def test_log_jet_robust_at_nested_sp500_pattern():
143+
"""11x9 nested Frank with 5/9 extreme leaves per sector — the d=99
144+
structure that originally triggered the sp500 NaN. Raw bell scan is
145+
unreliable here in two platform-dependent ways, both manifestations of
146+
the same catastrophic-cancellation fragility in the polynomial-powering
147+
accumulator:
148+
149+
- On GPU: raw forward stays approximately correct, but reverse-mode
150+
autodiff hits 0 * inf inside dot_general and produces NaN gradients.
151+
- On CPU: forward already loses precision in the polynomial scan
152+
(BLAS denormal handling differs from CUDA), so the wrong-but-finite
153+
forward propagates to a wrong-but-finite gradient.
154+
155+
log_jet is robust on both platforms. Its correctness is anchored by
156+
`test_log_jet_matches_mpmath_at_d75_extreme`, which validates the same
157+
Bell-polynomial machinery against a 200-digit mpmath reference. An
158+
mpmath reference for the nested 11x9 case would require reimplementing
159+
acopula's nested scan at arbitrary precision, so we use log_jet as the
160+
de-facto reference here and lock in the *finite-ness* of its gradient
161+
plus the *unreliability* of raw."""
109162
n_sec, sec_size = 11, 9
110163

111-
@defmodel
112164
def model(theta, obs):
113165
outer = Frank(theta[0])
114166
return outer([
@@ -125,23 +177,30 @@ def model(theta, obs):
125177
cm_raw = compile_model(model, template=init, method="bell")
126178
cm_log = compile_model(model, template=init, method="bell", log_jet=True)
127179

128-
# Forward agreement (raw forward stays finite even where backward NaNs).
129-
# Looser tolerance than the benign case: log-space accumulation
130-
# through ~100 signed-logsumexp ops on extreme inputs picks up a
131-
# few ULPs of drift per op. 1e-7 rel tolerance is the realistic
132-
# bound for this kind of high-d extreme-input pipeline.
133180
ll_raw = cm_raw.ll_fn(obs, init)
134181
ll_log = cm_log.ll_fn(obs, init)
135-
np.testing.assert_allclose(float(ll_log), float(ll_raw), rtol=1e-7)
136-
137182
g_raw = jax.grad(cm_raw.ll_fn, argnums=1)(obs, init)
138183
g_log = jax.grad(cm_log.ll_fn, argnums=1)(obs, init)
139184

140-
assert bool(jnp.any(jnp.isnan(g_raw))), \
141-
"raw should NaN on the nested 11x9 pattern with 5 extremes per sector"
185+
# Primary correctness: log_jet stays finite at the extreme.
142186
assert bool(jnp.all(jnp.isfinite(g_log))), \
143187
f"log_jet grad must be finite, got {g_log}"
144188

189+
# Secondary regression lock: raw is unreliable here. Either failure
190+
# mode counts as "unreliable" — the test passes as long as raw fails
191+
# in one of the two documented ways, and raises if raw silently starts
192+
# producing the right answer (which would mean either the underlying
193+
# bug got fixed elsewhere or the test point drifted out of the
194+
# extreme regime).
195+
raw_nan_grad = bool(jnp.any(jnp.isnan(g_raw)))
196+
raw_wrong_forward = not bool(jnp.allclose(ll_log, ll_raw, rtol=1e-3))
197+
assert raw_nan_grad or raw_wrong_forward, (
198+
f"raw should be unreliable at this point: expected NaN gradient "
199+
f"(GPU) or forward disagreement with log_jet by > 1e-3 (CPU). "
200+
f"Got ll_raw={float(ll_raw):.6f}, ll_log={float(ll_log):.6f}, "
201+
f"g_raw[0]={float(g_raw[0]):.6f}, g_log[0]={float(g_log[0]):.6f}."
202+
)
203+
145204

146205
# ---------------------------------------------------------------------------
147206
# Regression: log_jet=False (default) is byte-identical to before
@@ -152,7 +211,6 @@ def test_default_path_unchanged():
152211
— the log_jet plumbing is invisible when not opted into."""
153212
d = 30
154213

155-
@defmodel
156214
def model(theta, obs):
157215
return Frank(theta[0])([
158216
marginal(Uniform(0.0, 1.0), obs=obs[j]) for j in range(d)

0 commit comments

Comments
 (0)