Repository navigation
NDArray: go-ndarray v0.12.0 (Repr prints exactly what NumPy prints) - #821
Merged
Merged
Conversation
go-ndarray v0.12.0 ports NumPy 2.5.3's arrayprint.py and is checked against NumPy's own test strings (ndarray#49), so `p`/`inspect` of an NDArray now print what NumPy's repr prints, character for character. One expectation encoded the old output: an empty array prints its dtype in NumPy (array([], dtype=float64)), as _array_repr_implementation adds dtype= whenever arr.size == 0. The other literal reprs in the tests were already NumPy's. The node smoke passes on the rbgo_core build. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tannevaled
added a commit
that referenced
this pull request
Oct 18, 2026
…asure everything #821 landed the upgrade #822 asked for: go-ndarray v0.12.0, whose Repr prints what NumPy prints, with rbgo's `array([])` expectation corrected to `array([], dtype=float64)`. That removes the only reason this branch pinned go-numerics/{integrate,optimize} at v0.1.0 -- their v0.2.0 required v0.12.0 and would have dragged the whole numeric stack with them. They are now at v0.2.0, two releases of performance work whose own notes claim results unchanged to the last bit. That claim is checked here rather than taken: the 113 closed forms, 42 identities and every keyword test pass unchanged on the new pair. Nothing in the four bindings' tests depends on an array repr, so v0.12.0's printing rework touches none of them. Every size figure is re-measured, because all of them were taken on v0.11.0 and v0.1.0: was (v0.11.0) now (v0.12.0 + v0.2.0) baseline (main) 23_172_890 23_195_192 this branch 27_393_739 27_479_576 delta +4_220_231 +4_284_384 headroom to bound 1_606_261 1_520_424 The attribution too, both columns, same worktree: one module ALONE added IN TURN Special +591_000 +591_000 Stats +2_685_365 +2_438_092 Integrate +267_470 +266_797 Optimize +1_020_511 +987_989 ---------- ---------- +4_564_346 +4_283_878 Two things in that table are the model confirming itself. Special is IDENTICAL in both columns, which is what "whoever goes first pays for the shared code" predicts for whoever goes first. And the isolated sum overcounts the real +4_284_384 by 279_962, against 279_063 for the same pair of measurements on v0.11.0 -- 899 bytes apart across a dependency upgrade, so the shared-code residual is a property of the code and not of one run. The bound stays at 29_000_000: the growth moved by 64_153 bytes and the headroom is 1.5 MB. README's wasm table is re-measured in full and re-dated (107.4 / 28.2 unstripped, 104.5 / 27.5 stripped, 22.2 / 6.4 gzipped, 256 / 47 modules). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tannevaled
added a commit
that referenced
this pull request
Oct 18, 2026
… cgo-free scipy-style modules (#819) * Special: bind go-numerics/special, scipy.special's functions as ufuncs Ruby gets scipy.special's function set with no SciPy and no cgo: 67 of the module's 67 exported functions, under scipy's own names. The names are taken from the correspondence the Go module MEASURES itself against -- bench/scipy_server.py and testdata/gen/fam_*.py pair each Go function with the scipy function it is compared to in ULPs -- rather than transliterated from the Go name. That is why Log1pExp is #softplus and not #log1pexp, which is what guessing would have produced, and why LogGamma is #gammaln. The two functions with no scipy counterpart keep the Go name: #log_factorial and #loggamma_sign. Each binding is a ufunc, as scipy's is. The module ships both a float64 form and an element-wise form over *ndarray.Array -- in two packages, since Go cannot hold two functions of one name -- and rbgo already binds go-ndarray at the same v0.11.0. So one Ruby name serves both: a Float takes the scalar path, and as soon as one argument is an NDArray every argument goes through go-ndarray, a Float becoming a 0-d array, which broadcasts against any shape. Special.gamma(5.0) answers a Float, Special.gamma(arr) an NDArray of the same shape, and Special.xlogy(2, arr) broadcasts. Nothing here reimplements the module: the element-wise path calls its own nd package, which parallelises and broadcasts already, and argument checking reuses NDArray's helpers (ndArgc, ndFloat, ndInt, mustArray, ndRaise). Verification is against values that are mathematical facts, not against the Go functions being bound -- comparing a binding to the code it calls proves only that the call happened. 56 closed forms (gamma(5)=24, zeta(2)=pi^2/6, digamma(1)=-euler_gamma, ellipk(0)=pi/2, xlogy(0,0)=0, ...), the five non-finite answers scipy gives outside a domain, jn_zeros against the tabulated zeros of J0 AND against j0 vanishing there, and the property that the scalar and element-wise paths agree -- the one part of this that is not the module's own code -- at every arity. 98 subtests. Three negative controls, each checked to have really changed the file: binding #softplus to Logit, pointing #gamma's array path at LogGamma, and dropping an arity check all turn the suite red. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Stats: bind go-statistics/stats, scipy.stats's 32 univariate distributions Ruby gets scipy.stats's univariate distributions with no SciPy and no cgo: all 32 the Go module has Continuous or Discrete constructors for, under scipy's own names, with scipy's argument order and scipy's method set. n = Stats.norm(0.0, 1.0) n.ppf(0.975) # => 1.9599639845400538 Stats.poisson(3.0).pmf(2) # => 0.22404180765538775 Stats.t(10.0).cdf(arr) # element-wise over an NDArray As with Special, the names are the ones the module MEASURES itself against: each distribution's doc comment states its scipy.stats equivalent and the fixtures are generated from it. So StudentT is Stats.t, ChiSquared is Stats.chi2, DiscreteUniform is Stats.randint, Weibull is Stats.weibull_min, Gumbel is Stats.gumbel_r and NoncentralChiSquared is Stats.ncx2 -- none of which transliteration would have produced. The method set is not invented either. The module's Continuous and Discrete interfaces name their scipy counterpart method by method, and differ only in the density, so #pdf/#logpdf and #pmf/#logpmf are defined per class and the other seventeen methods once, for both, through a Go interface holding exactly what the two share. Every per-point method is a ufunc, over a Float or an NDArray, through the module's own stats.Map. #rvs answers a Float with no size and an NDArray with one, as scipy's does. Without seed: it draws from one generator per VM that advances across calls -- scipy's module-level random state -- and seed: asks for a fresh reproducible stream. A parameter outside its domain is refused at CONSTRUCTION, with the module's own message, as an ArgumentError where SciPy raises ValueError. Verification is against mathematical facts, not the code being bound: 57 closed forms covering all 32 distributions -- each constructor is written out separately in scipy's order, so a table of known moments is what catches a swapped parameter. norm.ppf(0.975)=1.96, norm.entropy=(1/2)log(2 pi e), poisson.var=mean, binom(10,.5).pmf(5)=252/1024, zipf(2).pmf(1)=1/zeta(2), randint(0,10).var=8.25, gumbel_r.mean=euler_gamma, rayleigh.mean=sqrt(pi/2), f(5,10).mean=1.25, ncx2(2,3).mean=5, ... Plus 42 identities checked across four continuous and two discrete laws (sf=1-cdf, ppf inverts cdf, std^2=var, interval covers its confidence), loc/scale behaving as scipy says, the ufunc property at every method, the method SET of each class, a 20000-draw check that #rvs really draws from the law asked for (within 6 standard errors), and the parameter and arity errors. A README guard is added with them: internal/vm/{special,stats}.go register their Ruby names in string literals and the README lists them one by one, so the two sets must be equal in both directions. Writing it found two defects in the README text committed minutes earlier -- the Special entry claimed all 67 functions while omitting log_factorial and loggamma_sign from its list, and it backticked log1pexp and log_gamma as counter-examples, which reads as a promise. Both fixed; the counter-examples are now italic, because a name that deliberately does not exist is not code. 95 Stats subtests, 193 with Special. Seven negative controls, each checked to have really changed the file: swapping loc and scale, pointing Stats.t at ChiSquared, wiring #pdf to CDF, dropping a shared method, ignoring seed:, drawing from the wrong law, and skipping parameter validation all turn the suite red. The README guard was broken in all three of its directions too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Special, Stats: assert the surface is complete and that arity is always checked Two gaps in the tests just added, both found by measuring rather than reading. A census over the live VM -- every method of every numeric module called with zero arguments, classified by its message -- says Special defines 66 methods, not the 67 the module exports. The two counts differ in one place and it is scipy's: the Riemann and Hurwitz zeta are ONE scipy name with an optional second argument, so Zeta and HurwitzZeta meet in #zeta. Nothing asserted that, so a function added to the module and forgotten here, or a name quietly dropped, would have gone unnoticed. TestSpecialCoversEveryFunction pins the 66 names and the reason there are 66. The same census found a defect in the modules NEXT DOOR, filed as #820: all 17 of FFT's methods and 14 of Image's index args[0] with no arity check, so FFT.fft() answers "ArgumentError: fft: runtime error: index out of range [0] with length 0" -- a Go fault converted into a Ruby error, where MRI says wrong number of arguments. NDArray is clean because it uses ndArgc, which is what Special and Stats use. So the census becomes the test, for these two modules. It is better than a list because a method added later is covered without being named, and it asserts the number of methods WALKED as well as the empty result, so a census that reads nothing fails rather than passing -- 66 for Special, 70 for Stats (32 distributions and 19 methods on each of the two classes). Verified to bite: removing the arity check from Special's one-argument helper makes it name the exact 33 methods that helper registers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Integrate, Optimize: the other two go-numerics modules, and 100% on all four The request named go-numerics, which is THREE modules. Special covered one; these are the other two, and with them the four symbols the notebook census ranks highest inside scipy are bound: integrate.quad, optimize.minimize, minimize_scalar and brentq. Integrate.quad(0.0, 1.0) { |x| x * x } # => [0.333..., 3.7e-15] Integrate.quad(0.0, Float::INFINITY) { |x| Math.exp(-x) } Optimize.brentq(0.0, 2.0) { |x| x * x - 2 } # => 1.4142135623730951 Optimize.minimize([-1.2, 1.0]) { |v| 100*(v[1]-v[0]**2)**2 + (1-v[0])**2 } The function is a BLOCK, which is how Ruby passes a callable and which takes &f for a Proc or lambda with no further surface -- so there is no second spelling to document or test. Return shapes are scipy's under one rule: one number is a Float, a tuple an Array (quad is (value, abserr), curve_fit is (popt, pcov)), and an OptimizeResult or RootResults a Hash with symbol keys, which full_output: true asks a root finder for. Verified against integrals and roots known exactly, not against the Go code: 16 definite integrals including the Gaussian integral over the whole line, an integrable singularity, and the Gauss-Legendre exactness property with the two-point rule's +/-1/sqrt(3) nodes; five root finders on sqrt(2) and the Dottie number, each checked against the known value AND by evaluating the function at the answer; Rosenbrock through all five minimisers, whose minimum is exactly (1,1) so descending somewhere is not enough; curve_fit recovering its own model's parameters from exact data. Every keyword is tested in the "given" direction with an OBSERVABLE effect -- points: naming a kink, divmax: 1 producing the one reachable non-convergence, a callable inner limit distinguishing the triangle's 1/2 from the square's 1, bounds: binding minimize to 1 where the free minimum is 5. The first push failed CI on the per-function coverage ratchet, not on a test. Recording the five functions in BELOW100 was the wrong fix, and the right one needed three different answers: - untested: Truthy on both distribution classes (only a boolean context calls it) and factorial2 on an array -> tests. - unreachable, so DELETED: an ErrFit arm with no Fit* function bound; cycle_ier, which is only non-nil for the QAWF weight that is not bound; nd.LogSumExp's error, which reports a COMPLEX array where rbgo's NDArray is float64 only. Each was a branch nothing could reach, and BELOW100 would have preserved it as a promise. #logsumexp now calls the root function with the slice it already holds: fewer layers and no dead arm. - looked unreachable and was NOT: #rvs's error arm. I validate negative sizes myself, so I was about to call it unreachable; measuring instead, Stats.norm.rvs(2 ** 40, 2 ** 40) answers "shape is too big", refused by go-ndarray at validation without allocating. It has a test. Twenty-one inline `if err != nil { raiseNumerics(err) }` arms are now one raiseIf, which the refusals that ARE reachable cover -- twenty-one half-covered functions became one covered branch. The rewrite that did it matched its own helper and briefly made raiseIf call raiseIf: infinite recursion that compiled and that go build accepted without a word. Caught by counting both sides of the replacement; the helper is now shaped so the pattern cannot match it. All four files are at 100% of statements, located with covblocks, which names the blocks go tool cover -func rounds away. The README guard now covers all four modules, with each entry's non-method words named beside their reason (scipy's loc/scale, the method: values, the block-pass idiom) rather than a shared list that would also excuse a real omission. It was broken in three directions again and went red for each. go-numerics/integrate and /optimize stay at v0.1.0 deliberately: v0.2.0 is performance work whose own notes say results are unchanged to the last bit, AND a bump to go-ndarray v0.12.0, which Go would then apply to the whole numeric stack. Filed separately with the measurement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * rbgo_core: the size bound moves with the four modules, attributed not absorbed The rbgo_core lane failed on its size bound: the core wasm module is 27 393 739 bytes against a 27 000 000 bound. The four scipy-style modules cost 4.2 MB between them, and the bound had 3.8 MB of headroom. A concurrent session reached this first and did the better experiment -- each module added to origin/main ALONE rather than cumulatively down a branch, which attributes the cost instead of ordering it -- and raised the bound to 29 000 000 with the table in ci.yml. Its argument is why that is right rather than indulgent: the size bound exists to notice growth NOBODY INTENDED, while a gem coming back is caught by the module-path check two lines below, by name and exactly. Growth that is intended, measured and attributed is what should move a bound; growth that is none of those is what the bound is for. I had implemented the other answer -- Stats behind `!rbgo_core`, joining Image outside the core build, which lands at 24 987 852 and needs no new bound. It is dropped here. It cost the browser build the single most-used part of scipy (stats.norm leads the notebook census), and it was the conservative choice rather than the argued one. What I did add is the reconciliation, because two independent measurements of the same thing did not agree to the byte and a reader should not have to find that out. "Special added to main alone" came out at +590 200 for me against +631 646 there -- 7% apart -- from baselines that themselves differ by 411 bytes between two worktrees of the same commit. So ci.yml now says to read the SHARES as measured and the exact bytes as one run's: Stats over half, Special under a fifth, the four together ~4.2 MB. Verified independently, every figure in the README's core table, to the digit: 28 081 523 unstripped (28.1 MB), 27 393 739 stripped (27.4 MB), 6 418 318 gzip -9 (6.4 MB), 47 modules. Also checked, and NOT a defect: the core build links 28 go-ruby-* modules while the README says "no gem". All 28 are standard-library bindings -- the rows the capability table calls "preloaded by MRI" and "small standard libraries" -- except go-ruby-timecop, which vm.go imports for the VM's own clock rather than as a registered gem; gems.go, which registers the Timecop module, is tagged out as it should be. Builds, vets and tests green under both tags. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * rbgo_core: the size bound moves with the numeric stack, and the cost is attributed The core build grew past its bound: 27_393_387 bytes against 27_000_000. The gate was right to stop it, and raising the number without saying what bought the growth would have turned a budget into a formality. So the cost is measured, each module added to origin/main alone (js/wasm, -s -w, 2026-10-17): Special +631_646 15.0% go-numerics/special Stats +2_416_448 57.3% go-statistics/stats Integrate +238_387 5.6% go-numerics/integrate Optimize +933_406 22.1% go-numerics/optimize --------- +4_219_887 23_173_500 -> 27_393_387 Stats is more than half of it, which is what 36 distributions and 27_599 lines of Go cost; the four bindings themselves are ~1_500 lines. The attribution sits in ci.yml beside the bound it moved, so the next person reads why rather than guessing. The new bound is 29_000_000 -- about 1.6 MB above the measurement. It does not restore the original proportional headroom (3.8 MB on 23.1 MB) on purpose: this number only has to notice growth nobody intended, and a gem coming back is caught by the module-path check in the same step, by name and exactly. That check was run against this branch and passes: 47 modules linked, none on the list, with the six numeric ones (go-ndarray, go-fft, go-numerics/{special, integrate,optimize}, go-statistics/stats) present as intended. The first attribution attempt was wrong and said so plausibly: a perl `s/.../ if !$d++` applies to the first LINE of a file, not the first match, so no register call was inserted and four builds reported deltas of 37 KB, 6 KB and 60 bytes while the total sat 4.2 MB below the branch. Redone with the insertion verified at each step, the figures close to within 344 bytes of the branch's own build. README's wasm table is re-measured in full rather than patched in one cell -- both columns moved, and the default build grew by the same 4.2 MB: unstripped 102.6 -> 107.3 MB | 23.7 -> 28.1 MB stripped 99.9 -> 104.5 MB | 23.1 -> 27.4 MB stripped, gzip -9 22.3 -> 22.2 MB | 5.7 -> 6.4 MB Go modules 253 -> 256 | 44 -> 47, no gem and the table is now dated, with a line pointing at the attribution. core.go's statement of what core IS names the four modules too: it listed the numeric stack as "NDArray and FFT", which was the whole of it when written. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * rbgo_core: the two ways to attribute the growth disagree, and the gap is the shared code a20114f's comment, which I wrote, said the per-module figures were "each module added to origin/main alone". They were not: the loop that produced them kept the previous module in place, so each delta is the MARGINAL cost in that order. The peer who reviewed it measured Special in isolation, got +590_200 against my +631_646, and could only report the two as 7% apart from an unstable baseline. They were not noisy. They are two different quantities, and measuring both settles it -- isolated, one worktree, one baseline, 2026-10-17: one module added in turn alone down the branch Special +590_255 +631_646 Stats +2_685_070 +2_416_448 Integrate +258_839 +238_387 Optimize +965_130 +933_406 ---------- ---------- +4_499_294 +4_219_887 +590_255 reproduces the peer's +590_200 to 55 bytes, so the 7% was the method, not the measurement. And neither column is "the" per-module cost, which is the part worth writing down: - ISOLATED overcounts. The four share go-ndarray, the special/nd layer and the argument helpers, so each pays for the same code. The sum exceeds the real +4_220_231 by 279_063 -- and that residual IS the shared part, which is a figure neither method reports on its own. - CUMULATIVE undercounts all but the first: whoever goes first pays for the shared code and the rest ride free. It depends on the order. What both agree on is the ranking and the total, so the comment now says to read those: Stats ~60%, Optimize ~21%, Special ~13%, Integrate ~6%, together ~4.2 MB. One more thing the comparison turned up: the build is NOT byte-reproducible across worktrees. The same commit gave 23_173_500 in one and 23_172_890 in another, 610 bytes apart, which is why the peer's baseline and mine differed and why every figure above comes from a single worktree. A cross-worktree delta of a few hundred bytes means nothing. The bound stays at 29_000_000 and the module-path check is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Rebase onto go-ndarray v0.12.0, take integrate/optimize v0.2.0, re-measure everything #821 landed the upgrade #822 asked for: go-ndarray v0.12.0, whose Repr prints what NumPy prints, with rbgo's `array([])` expectation corrected to `array([], dtype=float64)`. That removes the only reason this branch pinned go-numerics/{integrate,optimize} at v0.1.0 -- their v0.2.0 required v0.12.0 and would have dragged the whole numeric stack with them. They are now at v0.2.0, two releases of performance work whose own notes claim results unchanged to the last bit. That claim is checked here rather than taken: the 113 closed forms, 42 identities and every keyword test pass unchanged on the new pair. Nothing in the four bindings' tests depends on an array repr, so v0.12.0's printing rework touches none of them. Every size figure is re-measured, because all of them were taken on v0.11.0 and v0.1.0: was (v0.11.0) now (v0.12.0 + v0.2.0) baseline (main) 23_172_890 23_195_192 this branch 27_393_739 27_479_576 delta +4_220_231 +4_284_384 headroom to bound 1_606_261 1_520_424 The attribution too, both columns, same worktree: one module ALONE added IN TURN Special +591_000 +591_000 Stats +2_685_365 +2_438_092 Integrate +267_470 +266_797 Optimize +1_020_511 +987_989 ---------- ---------- +4_564_346 +4_283_878 Two things in that table are the model confirming itself. Special is IDENTICAL in both columns, which is what "whoever goes first pays for the shared code" predicts for whoever goes first. And the isolated sum overcounts the real +4_284_384 by 279_962, against 279_063 for the same pair of measurements on v0.11.0 -- 899 bytes apart across a dependency upgrade, so the shared-code residual is a property of the code and not of one run. The bound stays at 29_000_000: the growth moved by 64_153 bytes and the headroom is 1.5 MB. README's wasm table is re-measured in full and re-dated (107.4 / 28.2 unstripped, 104.5 / 27.5 stripped, 22.2 / 6.4 gzipped, 256 / 47 modules). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Special: 63 of 67 names are the module's measurement, not all of them The module comment and the README both said the Ruby names "come from the correspondence the Go module measures itself against". Auditing that sentence rather than repeating it: 63 of special's 67 functions carry such a pairing in bench/scipy_server.py or testdata/gen/fam_*.py, and FOUR do not. For those the name is my reading of scipy, which is a weaker claim and is now made as one. GammaSign -> gammasgn scipy has it; the module pairs nothing Kn -> kn scipy has it; unpaired here In -> in scipy has NO `in` LogGammaSign -> loggamma_sign no scipy counterpart at all LogFactorial is a fifth of the kind, and the module's own fixture says so in words: "log n! (SciPy: gammaln(n+1))". So Special.in is a name I INVENTED that merely looks like it came from a spec. That is the failure worth naming: a plausible name sitting inside an otherwise measured table inherits the table's authority. scipy cannot have an `in` -- it is a Python keyword -- and iv(0, x) is the faithful spelling. The three relationships those names stand on are asserted now instead of asserted in prose: in(n, x) == iv(n.to_f, x) EXACTLY, the same for kn against kv, and gammasgn against the sign of gamma itself on the intervals where it alternates. Exact rather than approximate, because they are the same Go call and a tolerance would hide a wrong conversion. The audit's first answer was wrong, by my own instrument, and it is worth recording how: I extracted the paired fixture names with a pattern anchored on exactly four spaces of indentation. fam_bessel.py indents its table by eight, so fourteen Bessel functions looked unpaired and the count came out 49/18 instead of 63/4. The Go program doing the join was correct and faithfully reported my filter's view. The answer was implausible against a grep I had already read, and `^[[:space:]]+` would have cost nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
go-ndarray v0.12.0 ports NumPy 2.5.3's
arrayprint.pyand is checked against NumPy's owntest_arrayprint.pystrings (go-ndarray/ndarray#49). With it,pandinspectof anNDArrayprint what NumPy'sreprprints, character for character. That includes digits padded to a common precision, the scientific switch (1e8, or 1e6 for float32) andTruepadding.Only one expectation here encoded the old output. NumPy prints the dtype of an empty array,
array([], dtype=float64):_array_repr_implementationaddsdtype=wheneverarr.size == 0. The other literal reprs in the tests were already NumPy's.Local checks:
rbgo_corewasm builds.node web/smoke.mjs <core wasm> --corepasses all its checks.This PR changes only the ndarray pin and that one test; go-fft stays on v0.23.0. Renovate's grouped #792 is left alone.
🤖 Generated with Claude Code