|
| 1 | +# -*- coding: utf-8 -*- |
| 2 | +"""Regression tests for the 2026-06-19 ``math-compat`` audit (P3-* findings). |
| 3 | +
|
| 4 | +Covered findings (see ``docs/issues-found-20260619-math-compat.md``): |
| 5 | +
|
| 6 | +* P3-H1 (``activations.py``) -- ``gelu`` must promote integer inputs to a |
| 7 | + floating dtype before computing; both the approximate and exact branches were |
| 8 | + silently wrong on integer input. |
| 9 | +* P3-H2 (``compat_pytorch.py``) -- ``unflatten`` must honour a negative ``dim`` |
| 10 | + (PyTorch semantics) and reject out-of-range dims. |
| 11 | +* P3-M1 (``compat_tensorflow.py``) -- ``segment_mean`` / ``unsorted_segment_mean`` |
| 12 | + / ``unsorted_segment_sqrt_n`` must convert ``data`` to a jax array before |
| 13 | + ``jnp.ones_like`` (do not rely on the deprecated implicit ``__jax_array__``). |
| 14 | +""" |
| 15 | + |
| 16 | +import jax |
| 17 | +import jax.nn as jnn |
| 18 | +import jax.numpy as jnp |
| 19 | +import numpy as np |
| 20 | +import pytest |
| 21 | + |
| 22 | +import brainpy.math as bm |
| 23 | +from brainpy.math import ( |
| 24 | + activations as act, |
| 25 | + compat_pytorch as cpt, |
| 26 | + compat_tensorflow as ctf, |
| 27 | +) |
| 28 | + |
| 29 | + |
| 30 | +def _j(x): |
| 31 | + return bm.as_jax(x) |
| 32 | + |
| 33 | + |
| 34 | +def _finite(x): |
| 35 | + return bool(jnp.all(jnp.isfinite(_j(x)))) |
| 36 | + |
| 37 | + |
| 38 | +# --------------------------------------------------------------------------- |
| 39 | +# P3-H1: gelu integer-input promotion |
| 40 | +# --------------------------------------------------------------------------- |
| 41 | + |
| 42 | +def test_gelu_integer_input_matches_float_approximate(): |
| 43 | + """P3-H1: approximate gelu on int input must equal the float computation.""" |
| 44 | + xi = jnp.array([1, 2, 3], dtype=jnp.int32) |
| 45 | + xf = jnp.array([1., 2., 3.]) |
| 46 | + ri = np.asarray(_j(act.gelu(xi, approximate=True))) |
| 47 | + rf = np.asarray(_j(act.gelu(xf, approximate=True))) |
| 48 | + np.testing.assert_allclose(ri, rf, atol=1e-6) |
| 49 | + # and it must agree with jax.nn.gelu (the reference implementation) |
| 50 | + np.testing.assert_allclose(ri, np.asarray(jnn.gelu(xi, approximate=True)), atol=1e-6) |
| 51 | + # specifically: NOT the truncated x/2 result the bug produced |
| 52 | + assert not np.allclose(ri, np.asarray(xf) / 2.0) |
| 53 | + |
| 54 | + |
| 55 | +def test_gelu_integer_input_matches_float_exact(): |
| 56 | + """P3-H1: exact gelu on int input must not be truncated back to int.""" |
| 57 | + xi = jnp.array([1, 2, 3], dtype=jnp.int32) |
| 58 | + xf = jnp.array([1., 2., 3.]) |
| 59 | + ri = act.gelu(xi, approximate=False) |
| 60 | + assert jnp.issubdtype(_j(ri).dtype, jnp.floating) |
| 61 | + np.testing.assert_allclose( |
| 62 | + np.asarray(_j(ri)), |
| 63 | + np.asarray(_j(act.gelu(xf, approximate=False))), |
| 64 | + atol=1e-6, |
| 65 | + ) |
| 66 | + # reference parity |
| 67 | + np.testing.assert_allclose( |
| 68 | + np.asarray(_j(ri)), np.asarray(jnn.gelu(xi, approximate=False)), atol=1e-5) |
| 69 | + |
| 70 | + |
| 71 | +def test_gelu_float_unchanged(): |
| 72 | + """The float path must be unchanged by the promotion fix.""" |
| 73 | + x = bm.asarray([-1., 0., 1., 2.]) |
| 74 | + for approx in (True, False): |
| 75 | + np.testing.assert_allclose( |
| 76 | + np.asarray(_j(act.gelu(x, approximate=approx))), |
| 77 | + np.asarray(jnn.gelu(_j(x), approximate=approx)), |
| 78 | + atol=1e-5, |
| 79 | + ) |
| 80 | + |
| 81 | + |
| 82 | +def test_gelu_accepts_brainpy_array_and_is_finite(): |
| 83 | + x = bm.asarray([-3., -1., 0., 1., 3.]) |
| 84 | + assert _finite(act.gelu(x, approximate=True)) |
| 85 | + assert _finite(act.gelu(x, approximate=False)) |
| 86 | + |
| 87 | + |
| 88 | +# --------------------------------------------------------------------------- |
| 89 | +# P3-H2: unflatten negative dim |
| 90 | +# --------------------------------------------------------------------------- |
| 91 | + |
| 92 | +def test_unflatten_negative_dim(): |
| 93 | + """P3-H2: negative dim must be normalised like torch.unflatten.""" |
| 94 | + x = bm.asarray(jnp.arange(6.)) |
| 95 | + r = cpt.unflatten(x, -1, (2, 3)) |
| 96 | + assert _j(r).shape == (2, 3) |
| 97 | + # equivalent to the positive-dim call |
| 98 | + np.testing.assert_allclose( |
| 99 | + np.asarray(_j(r)), np.asarray(_j(cpt.unflatten(x, 0, (2, 3))))) |
| 100 | + |
| 101 | + |
| 102 | +def test_unflatten_negative_dim_higher_rank(): |
| 103 | + x = bm.asarray(jnp.arange(24.).reshape(2, 12)) |
| 104 | + r = cpt.unflatten(x, -1, (3, 4)) |
| 105 | + assert _j(r).shape == (2, 3, 4) |
| 106 | + r2 = cpt.unflatten(x, -2, (1, 2)) |
| 107 | + assert _j(r2).shape == (1, 2, 12) |
| 108 | + |
| 109 | + |
| 110 | +def test_unflatten_positive_dim_still_works(): |
| 111 | + x = bm.asarray(jnp.arange(6.)) |
| 112 | + assert _j(cpt.unflatten(x, 0, (2, 3))).shape == (2, 3) |
| 113 | + assert _j(cpt.unflatten(x, 0, (-1, 3))).shape == (2, 3) |
| 114 | + |
| 115 | + |
| 116 | +def test_unflatten_dim_out_of_range(): |
| 117 | + x = bm.asarray(jnp.arange(6.)) |
| 118 | + with pytest.raises((ValueError, AssertionError, IndexError)): |
| 119 | + cpt.unflatten(x, 5, (2, 3)) |
| 120 | + with pytest.raises((ValueError, AssertionError, IndexError)): |
| 121 | + cpt.unflatten(x, -5, (2, 3)) |
| 122 | + |
| 123 | + |
| 124 | +# --------------------------------------------------------------------------- |
| 125 | +# P3-M1: TF segment helpers must not lean on implicit __jax_array__ |
| 126 | +# --------------------------------------------------------------------------- |
| 127 | + |
| 128 | +def test_segment_mean_array_input(): |
| 129 | + data = bm.asarray([1., 2., 3., 4.]) |
| 130 | + seg = bm.asarray([0, 0, 1, 1]) |
| 131 | + np.testing.assert_allclose(np.asarray(_j(ctf.segment_mean(data, seg))), [1.5, 3.5]) |
| 132 | + |
| 133 | + |
| 134 | +def test_unsorted_segment_mean_array_input(): |
| 135 | + data = bm.asarray([1., 2., 3., 4.]) |
| 136 | + seg = bm.asarray([0, 0, 1, 1]) |
| 137 | + np.testing.assert_allclose( |
| 138 | + np.asarray(_j(ctf.unsorted_segment_mean(data, seg, 2))), [1.5, 3.5]) |
| 139 | + |
| 140 | + |
| 141 | +def test_unsorted_segment_sqrt_n_array_input(): |
| 142 | + data = bm.asarray([1., 1., 1., 1.]) |
| 143 | + seg = bm.asarray([0, 0, 1, 1]) |
| 144 | + # sum over 2-element segments divided by sqrt(2) |
| 145 | + np.testing.assert_allclose( |
| 146 | + np.asarray(_j(ctf.unsorted_segment_sqrt_n(data, seg, 2))), |
| 147 | + [2.0 / np.sqrt(2.0), 2.0 / np.sqrt(2.0)], |
| 148 | + atol=1e-6, |
| 149 | + ) |
| 150 | + |
| 151 | + |
| 152 | +def test_unsorted_segment_mean_under_jit(): |
| 153 | + """The denominator (``jnp.ones_like``) must trace cleanly under jit. |
| 154 | +
|
| 155 | + ``unsorted_segment_mean`` takes a static ``num_segments`` so it is |
| 156 | + jit-compatible (unlike ``segment_mean`` which infers it from the data). |
| 157 | + """ |
| 158 | + data = jnp.array([1., 2., 3., 4.]) |
| 159 | + seg = jnp.array([0, 0, 1, 1]) |
| 160 | + f = jax.jit(lambda d: bm.as_jax(ctf.unsorted_segment_mean(bm.asarray(d), bm.asarray(seg), 2))) |
| 161 | + np.testing.assert_allclose(np.asarray(f(data)), [1.5, 3.5]) |
0 commit comments