potential/backend: centrally coerce forced-backend scalar leaks#987
Conversation
Under the forced-backend all-backend suite, plain numpy/Python scalars leak into strict torch ops (torch.cos(0.0) / torch.sqrt(numpy.float64) / numpy.ndarray*Tensor all raise; jax is permissive). coerce_coords already handles passed coordinates; this closes the three remaining scalar-leak channels, all guarded so numpy stays byte-identical: * conversion.py potential_physical_input: also coerce the phi/t SIGNATURE DEFAULTS (bound via inspect.signature) when omitted, strictly inside the `xp is not numpy and _check_backend_compatible` guard -- so internally-derived scalars (Ferrers tp=_pa+_omegab*t, DehnenBar _smooth(t), CosmphiDisk/HenonHeiles /Logarithmic sin/cos(phi), ...) see backend tensors. numpy path never rebinds. * backend/_coerce.py promote_scalars: use is_backend_array() (not hasattr ndim) as the "leave it" test, so a numpy.float64 (has .ndim but torch rejects) is promoted. * backend/_namespaces.py asarray_on_device: translate a numpy dtype arg to the backend dtype (torch.asarray(dtype=numpy.float64) raises) -- fixes SpiralArms. * 19 potentials: move self._backend_compatible=True ABOVE self.normalize(...) in __init__ so construction-time normalize()->Rforce(1.,0.) is coerced. * FerrersPotential: axisymmetric phi-reset 0.0 -> zeros_like_backend(xp, R). Flips ~30+ sampled torch test_potential entries (forceAsDeriv/evaluateAndDerivs/ 2ndDeriv/poisson/normalize/toVertical_toPlanar). numpy byte-identical (verified: 281/281 + bit-identical Phi/forces across MN/NFW/Ferrers/PSPwC/CosmphiDisk/Log); all backend unit tests pass (3325). Deferred (separate work): AnySphericalPotential (scipy interior, pinned unmigrated by test_backend_conventions), AnyAxisymmetric RazorThin (scipy), SpiralArms FD-precision. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## feat/backends #987 +/- ##
===============================================
Coverage 99.93% 99.93%
===============================================
Files 254 254
Lines 39820 40286 +466
Branches 837 835 -2
===============================================
+ Hits 39795 40261 +466
Misses 25 25 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
All-backend test status (jax / torch)Commit Green is achieved via the checked-in xfail-ledger ( Overall: jax: 1056 passed · 266 xfail · 725 deferred | torch: 879 passed · 1164 xfail · 1 deferred Ledger size: 2357 entries (jax=284, torch=2073).
Per-shard counts
|
…r _backend_dtype) The all-backend coverage shard left three patch lines uncovered: * _coerce.py promote_scalars: the asarray(device=) try/except fallback (the jax-string-'cpu' guard) is dead for current jax/torch (their .device is an object xp.asarray accepts), so promote it to delegate to asarray_on_device -- the same helper coerce_coords uses -- which handles device + numpy->backend dtype in one place. Removes the dead except; the delegation sits on the already- covered path. * _namespaces._backend_dtype: the not-a-generic branch is genuinely reachable -- numpy>=2.0 StringDType resolves to the Python str scalar, so numpy.issubdtype(StringDType, numpy.generic) is False and it must be returned unchanged (no backend has a same-named dtype). Add a test exercising it under jax AND torch (self-skips on numpy<2.0). numpy path byte-identical (both helpers early-return / pass-through on numpy). Validated: 465 passed under the backend suite. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…backend promote_scalars passed its inputs through unchanged when none was a backend array to anchor on (`ref is None`), assuming "the namespace's functions handle scalars". That holds for jax but NOT for torch: torch.cos/sqrt/... reject numpy.float64 / python floats, so under a forced torch backend every promote_scalars caller (the coords transforms cyl_to_rect / rect_to_cyl / ..., OblateStaeckelWrapperPotential) crashed on all-numpy inputs. Coerce the operands via coerce_coords in that branch instead. Routing that branch through coerce_coords surfaced that #987's promote_scalars refactor had silently dropped the device-reject fallback (the device-less asarray retry when a namespace rejects the ref's .device value -- array-api jax exposes .device as the string 'cpu' and jnp.asarray(device='cpu') raises ValueError), leaving its test a no-op (the mock ref is no longer detected as a backend array after #987's is_backend_array switch). Restore the fallback in asarray_on_device (catch TypeError / ValueError -> device-less asarray; a genuine dtype error re-raises from the fallback so it is not masked) and rewrite the test to exercise asarray_on_device directly and deterministically. The numpy path is byte-identical (the `xp is numpy` guard short-circuits, and asarray_on_device's device branch is only taken when a backend array supplies a device). jax value-identical under x64. Fixes the migrated RotateAndTilt / Offset / OblateStaeckel / Kuzmin wrapper torch entries that route coordinates through these transforms, plus 11 test_coords and 11 test_quantity torch cases. New tests/test_backend_coerce.py covers the coercion branch. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
What
Burndown PR-3b — the central scalar-coercion fix, built on #983's
galpy.backend._coerce. Under the forced-backend all-backend suite, plain numpy/Python scalars leak into strict torch ops (torch.cos(0.0)/torch.sqrt(numpy.float64)/numpy.ndarray*Tensorall raise; jax is permissive).coerce_coordsalready handles passed coordinates; this closes the three remaining scalar-leak channels — all guarded so numpy stays byte-identical:conversion.pypotential_physical_input: also coerce thephi/tsignature defaults (bound viainspect.signature) when omitted, strictly inside thexp is not numpy and _check_backend_compatibleguard — so internally-derived scalars (Ferrers_pa+_omegab*t, DehnenBar_smooth(t), CosmphiDisk/HenonHeiles/Logarithmicsin/cos(phi), …) see backend tensors.backend/_coerce.pypromote_scalars: useis_backend_array()(nothasattr(v,'ndim')) as the leave-it test, so anumpy.float64(has.ndimbut torch rejects) is promoted.backend/_namespaces.pyasarray_on_device: translate a numpy dtype arg to the backend dtype (torch.asarray(dtype=numpy.float64)raises) — fixes SpiralArms.Plus 19 potentials: move
self._backend_compatible=Trueaboveself.normalize(...)in__init__(so construction-timenormalize()→Rforce(1.,0.)is coerced); and FerrersPotential axisymmetric phi-reset0.0 → zeros_like_backend(xp,R).Impact
Flips ~331 torch
test_potentialledger entries to pass (forceAsDeriv / evaluateAndDerivs / 2ndDeriv / poisson / normalize / toVertical_toPlanar, across the potential families). The ledger entries themselves are pruned by the separate ledger-regen PR.Byte-identity + review
numpy path byte-identical (the whole mechanism is gated on
xp is not numpy): verified hash-matched Phi/forces/dens across the 19 reordered potentials + Ferrers + planar default-phi paths; 281/281 numpy parity; all 3325 backend unit tests pass. Independently adversarially reviewed across 4 angles — zero blockers: byte-identity, theinspect.signaturedefault-injection correctness (every signature shape: positional/kwarg/omitted, double-decoration via@wraps, DF-skip,Nonedefaults, no kwonly/posonly), the 19__init__reorders, and regression (no previously-passing test breaks; deferred cases honestly still ledgered).Deferred (separate follow-ups)
test_backend_conventionspins it as the canonical unmigrated potential, so it needs a coordinated source+test migration.🤖 Generated with Claude Code