🐛 fix(spherical): return the intrinsic metric as a dimensionless QuantityMatrix - #716
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #716 +/- ##
=======================================
Coverage 96.54% 96.55%
=======================================
Files 265 265
Lines 8780 8787 +7
=======================================
+ Hits 8477 8484 +7
Misses 303 303 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
71601bf to
0952ab1
Compare
There was a problem hiding this comment.
Pull request overview
This PR makes the intrinsic hyperspherical metric (S² / HyperSphericalManifold) return a dimensionless QuantityMatrix container (instead of a bare JAX array) so consumers/tests can unwrap metric matrices consistently, and updates related contraction/norm code paths accordingly.
Changes:
- Wrap the intrinsic hypersphere diagonal metric in a dimensionless
QuantityMatrixfor container consistency. - Update manifold metric tests to unwrap
QuantityMatrixvia.valuewhereallcloselacks overloads. - Adjust diagonal contraction (
quadratic_form._contract) and bare-arraynormhandling to account for dimensionlessQuantityMatrixmetrics.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
src/coordinax/_src/spherical/register_metric.py |
Changes intrinsic hypersphere metric diagonal to a dimensionless QuantityMatrix; updates doctest output. |
src/coordinax/_src/spherical/metric.py |
Updates RoundMetric docstring example to reflect QuantityMatrix output. |
src/coordinax/_src/manifolds/quadratic_form.py |
Adds special-casing so dimensionless QuantityMatrix diagonals can keep the O(n) contraction path for bare arrays. |
src/coordinax/_src/manifolds/norm.py |
Unwraps dimensionless dense QuantityMatrix metric for bare-array norm, and errors on unitful metrics with bare vectors. |
tests/unit/manifolds/test_metrics.py |
Updates assertions to use .value for dimensionless QuantityMatrix diagonals. |
tests/unit/manifolds/test_metric_pullback_consistency.py |
Normalizes dense-matrix comparisons by unwrapping both metrics via .value. |
tests/unit/manifolds/test_metric_matrix_dispatch.py |
Updates hypersphere diagonal checks to use .value; keeps cross-family normalization in JIT test. |
tests/unit/manifolds/test_metric_matrix_batch_invariant.py |
Simplifies value extraction assuming QuantityMatrix (but leaves an outdated comment). |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
e99964d to
2680b1a
Compare
…tityMatrix GalacticDynamics#628 reports that the intrinsic sphere and embedded rules disagree on units. They do not: they are different metrics. Embedded is the *induced* metric, `ds` an ambient length, so `[g] = L**2/rad**2` and it scales as `R**2`. Intrinsic is the *angular* metric on the unit sphere -- `cxm.S2` has no radius -- where `ds` is the great-circle angle, so `[g]` is dimensionless. Since `rad` carries dimension *angle*, forcing `1/rad**2` onto the intrinsic metric would be dimensionally incoherent, not a convention choice. What is inconsistent is the *container*. Two of the three intrinsic rules in this module already return a dimensionless `QuantityMatrix` -- one of them carrying the comment `angles -> angles, so g is dimensionless`. The main hypersphere rule returned a bare array. It now follows its own module. Units unchanged. `norm`'s bare-array overload unwraps the metric explicitly, since `array_norm` has no `(QuantityMatrix, Array)` overload and `QuantityMatrix.ustrip` will not take a `UnitsMatrix`; it checks the units are empty rather than peeking at `.value`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`_contract`'s bare-array diagonal route sent any `QuantityMatrix` diagonal to the dense einsum. Right for a *unitful* one, but the intrinsic sphere's is dimensionless, so the previous commit would have silently dropped the O(n) path for every sphere chart and returned a `Quantity` from bare inputs. Unwrap the dimensionless case and keep the fast path; unitful still goes dense. The two `getattr(g, "value", g)` workarounds GalacticDynamics#628 cites are now removable, and are removed: with the intrinsic metric a `QuantityMatrix` like its siblings, `test_metric_pullback_consistency` unwraps both sides identically instead of one bare and one `.value` -- which was the asymmetry the issue was about. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Doctests and asserts that read the intrinsic sphere's diagonal as a bare array now read a dimensionless `QuantityMatrix`. `jnp.allclose` and `qnp.allclose` both reject a `QuantityMatrix` -- `UnitsMatrix` has no `to` -- so these unwrap with `.value`, the idiom already used elsewhere for `g.matrix.value`. `test_jit` is parametrized across families and keeps a normalising `getattr(result, "value", result)`: flat charts still return a bare diagonal while curvilinear ones return a `QuantityMatrix`, so a test spanning both must still cope with either. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…metrics Copilot review caught a real defect in both dimensionless checks. A batched diagonal has `d.shape == (*batch, n)` while its units cover the component axis only, `d.unit.shape == (n,)`. Comparing against `UnitsMatrix.full(d.shape, "")` therefore never matched: - `_contract` sent every batched dimensionless metric to the dense einsum, silently losing the O(n) path this branch exists to keep - `norm`'s bare-array overload raised outright: `UnitsMatrix only supports 1D or 2D, but got ndim=3` Both now build the comparison from `unit.shape`. Batched bare-array `norm` on `sph2` returns `[1. 1.]` instead of raising. Also from review: two doctests asserted the exact `QuantityMatrix` repr, which is brittle across unxt versions -- and this PR expects upstream changes -- so they check values instead; and a test comment claiming "units unchanged" no longer described a test where both sides are now `QuantityMatrix`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…array`
The oldest-dependencies job failed on `jnp.asarray(Q(2., ''))`. Newer JAX
coerces a Quantity to a bare array; the oldest supported version raises.
The old version is the one that is right. `jnp.asarray(u.Q(2.0, 'm'))` returns
`Array(2.)` -- metres silently gone -- so relying on that coercion would let a
unit error pass as a number. `u.ustrip('', x)` converts to dimensionless and
raises `UnitConversionError` on anything else, so the assertion now fails loudly
where it used to succeed quietly.
The two paths genuinely differ in type, which is why the coercion was there: the
fast path unwraps a dimensionless diagonal and returns a bare array, the dense
path returns a dimensionless Quantity. That is documented on the test now
rather than papered over.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2680b1a to
49969a3
Compare
… path users cannot take codecov flagged the two lines of this guard as the patch's only misses. They are unreachable through any public route: a bare `at` yields a dimensionless metric, and the unitful metrics that exist -- an embedded sphere's pullback, m2 -- belong to charts `check_metric_is_charts` rejects further up. The two ways to make codecov green were a test that bypasses the public API to reach the overload directly, pinning a path no caller can take, or saying plainly that the branch is defensive. This is the second. Not deleted: silently dropping the metric's unit here would return a norm in the wrong dimension and look perfectly fine, and a chart carrying units at a bare base point is a supportable thing to add later. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.
Suppressed comments (2)
tests/unit/manifolds/test_metric_matrix_batch_invariant.py:61
- This comment is now internally inconsistent with the code below and with the updated S¹/S² metric rules: CURVILINEAR now yields a
QuantityMatrixdiagonal for both Euclidean-curvilinear and intrinsic-sphere charts (dimensionless for the spheres), so it no longer returns a bare array here. Please update/remove this comment to match the current container contract.
# The Euclidean rules return a united QuantityMatrix; the intrinsic sphere
# rules return a bare (dimensionless) Array. Compare whichever is carried.
src/coordinax/_src/spherical/register_metric.py:87
- The doctest was updated to use
g.diagonal.valuehere, but later in the same docstring the example still doesfloat(g.diagonal[1]). Now thatg.diagonalis aQuantityMatrix, indexing likely returns a scalarQuantityandfloat(quantity)is not a supported conversion pattern in this codebase (most tests use.valueoru.ustrip). This will make the doctest fail or become dependency-version sensitive; unwrap via.valueconsistently.
>>> bool(jnp.allclose(g.diagonal.value, jnp.array([1.0, 1.0])))
True
The oldest-dependencies job failed all seven `TestChordDistance` cases with
TypeError: Unexpected input type for array: Quantity
`chord_distance` returns a dimensionless `Quantity`, and the tests fed it to
raw `jax.numpy.asarray`, which on jax 0.7.2 raises.
Rebasing onto GalacticDynamics#725 narrows why that spelling was wrong. unxt 2.0.2 is the
floor now, and its `Quantity.__array__` raises `UnitConversionError` for
anything dimensionful rather than returning a bare array -- so `jnp.asarray`
is no longer the silent stripper the first version of this commit described,
and neither is `float`. The conclusion survives the correction: on the oldest
supported jax it still raises for *every* `Quantity`, dimensionless included,
which is the failure the `check_oldest` job reported. Same reasoning GalacticDynamics#716
records for the strips it touched.
`.ustrip("")` at the five call sites, naming the unit expected rather than
relying on a conversion that reads as incidental. One `jnp.asarray` wrapped
around an already-stripped `ustrip("rad")` goes too -- it was the last of the
pattern left in the class, and it converted nothing.
The doctests keep `float(...)`, which is now load-bearing rather than lax: it
raises if the unit-sphere chord ever stops being dimensionless, and the
embedded case prints `Distance(4., 'm')` with the unit intact.
This should also clear `codecov/project`: that job uploads the coverage
report, so its failure withheld it. `chord_distance.py` is at 100% and
`codecov/patch` passed throughout.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Addresses #628 — but not the way that issue proposes, because its premise is wrong.
The two families are not the same geometric object
dsis an ambient length, so[g] = L²/rad². It scales asR².cxm.S2has no radius parameter; distance on the unit sphere is the great-circle angle, so[ds] = radand[g]is dimensionless.They agree numerically at
R = 1only because the radius is 1. Sinceradcarries dimension angle, forcing1/rad²onto the intrinsic metric would leavegwith dimensionangle⁻²on a manifold that has no length scale — dimensionally incoherent, not a convention choice. So the issue's option 1 is unavailable, and option 2 would discard theR²scaling thatm²/rad²correctly records.Units are therefore unchanged by this PR.
What was actually inconsistent: the container
Of the three intrinsic
metric_matrixrules in_src/spherical/register_metric.py, two already returned a dimensionlessQuantityMatrix— one carrying the comment# angles -> angles, so g is dimensionless. Only the main hypersphere rule returned a bare array. It now follows its own module.That fixes the specific asymmetry #628 is about, which was visible in one place in the source:
Adjacent lines, one unwrapped and one not. Both are
QuantityMatrixnow, so both unwrap identically, and bothgetattr(g, "value", g)workarounds the issue cites are removed.Two things this surfaced
A silent performance regression, nearly shipped.
_contract's bare-array route sends anyQuantityMatrixdiagonal to the dense einsum — right for a unitful one, but the intrinsic sphere's is dimensionless, so every sphere chart would have quietly dropped theO(n)diagonal path (#686) forO(n²)while still returning correct numbers. Only the bare-in/bare-out type error exposed it. The dimensionless case is now unwrapped and keeps the fast path; unitful still goes dense.The bare-array contract runs deeper than #628 suggests.
norm's bare-array overload broke too:array_normhas no(QuantityMatrix, Array)overload, so an-> Arrayfunction tried to return aQuantity.Honest scope: the container is still not uniform
The split has moved from intrinsic-vs-embedded to Cartesian-vs-curvilinear. This PR removes two workarounds and
test_jit, which is parametrized across both families, still needs one. So this is not "one accessor for generic consumers" library-wide; making flat metrics matrix-valued too would be a much larger change.QuantityMatrixneeds upstream work — filed and fixedEvery unwrap in this PR is hand-rolled because
QuantityMatrixhas no working conversion API:u.uconvert(UnitsMatrix, qm)u.ustrip(UnitsMatrix, qm)NotFoundLookupErrorqm.ustrip("")AttributeError: 'UnitsMatrix' object has no attribute 'to'u.unit_of(qm)TypeErrorjnp.allclose(qm, arr)/qnp.allclose(qm, arr)AttributeErrorunxts.linalgregistereduconvertbut notustrip, so everything needing a conversion falls through to the scalar-unit astropy path (unxt/_interop/unxt_interop_astropy/quantity.py:303, which callsx.unit.to(...)).Filed as GalacticDynamics/unxt#879, fixed in GalacticDynamics/unxt#880.
Once that is released, three hand-rolled unwraps here collapse to one call each:
unit_ofships in unxt#880 too, so generic code can ask aQuantityMatrixfor its units. (An earlier revision of this description claimed it needed unxt's abstract return type widened — that was wrong; plum enforces each method's own annotation. The real, narrower problem is unxt#881: under a combined pytest session plum resolves-> UnitsMatrixto a second class object, so that one test isxfailed upstream.)Verification
O(n)diagonal fast path is preserved for dimensionless metrics; unitful still routes denseprek🤖 Generated with Claude Code