✨ feat(manifolds): add chord_distance, the straight line through the ambient - #720
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds a new manifold-level distance verb, chord_distance, for measuring straight-line (ambient) distance through an embedding, and completes the separation → geodesic_distance API transition across code, tests, and docs/spec.
Changes:
- Introduces
chord_distancemanifold dispatches (with deliberate refusals for Euclidean and non-embedded manifolds) and exports it in the public API. - Replaces the old
separationimplementation withgeodesic_distance(manifold-geometry based), and updates vectorPointoverload wiring and tests accordingly. - Updates documentation/tutorials/spec to reflect
geodesic_distancesemantics and Minkowski refusals (with a few doc mismatches noted in review comments).
Reviewed changes
Copilot reviewed 24 out of 24 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/unit/vectors/test_point.py | Updates Point-level distance tests to call cx.geodesic_distance. |
| tests/unit/manifolds/test_separation_dispatch.py | Removes obsolete separation dispatch tests. |
| tests/unit/manifolds/test_metric_arg_is_honoured.py | Updates metric-arg honoring test to use geodesic_distance. |
| tests/unit/manifolds/test_interval.py | Updates Minkowski interval tests/docs to reference geodesic_distance. |
| tests/unit/manifolds/test_geodesic_distance_dispatch.py | Adds dispatch coverage for geodesic_distance and chord_distance. |
| tests/unit/charts/test_jit_parameterized_paths.py | Updates JIT regression test to use geodesic_distance. |
| src/coordinax/vectors/_src/register_geodesic_distance.py | Implements Point overload for geodesic_distance and delegates to manifold API. |
| src/coordinax/vectors/_src/init.py | Switches registration import from register_separation to register_geodesic_distance. |
| src/coordinax/vectors/init.py | Re-exports geodesic_distance instead of separation. |
| src/coordinax/manifolds/lorentzian.py | Updates lorentzian namespace docstring wording around Minkowski/interval. |
| src/coordinax/manifolds/init.py | Re-exports geodesic_distance and new chord_distance. |
| src/coordinax/_src/minkowski/causality.py | Updates guidance text to reference geodesic_distance instead of separation. |
| src/coordinax/_src/manifolds/separation.py | Removes legacy separation implementation. |
| src/coordinax/_src/manifolds/quadratic_form.py | Updates references from separation to geodesic_distance. |
| src/coordinax/_src/manifolds/interval.py | Clarifies interval semantics vs geodesic_distance and updates wording. |
| src/coordinax/_src/manifolds/geodesic_distance.py | Adds manifold-based geodesic_distance dispatch implementations and fast paths. |
| src/coordinax/_src/manifolds/chord_distance.py | Adds new chord_distance dispatch implementations via embedding + ambient distance. |
| src/coordinax/_src/manifolds/init.py | Registers new manifold dispatch modules (geodesic_distance, chord_distance). |
| src/coordinax/init.py | Exports geodesic_distance and chord_distance at top-level coordinax. |
| packages/coordinaxs.api/src/coordinaxs/api/manifolds.py | Renames abstract API to geodesic_distance and adds abstract chord_distance. |
| docs/tutorials/special_relativity.md | Updates tutorial to use geodesic_distance refusal + interval guidance. |
| docs/spec.md | Updates authoritative spec section for geodesic_distance. |
| docs/guides/charts.md | Updates Minkowski chart guidance to reference geodesic_distance. |
| docs/api/manifolds.md | Updates API docs list for geodesic_distance/interval (needs chord_distance mention). |
Suppressed comments (3)
packages/coordinaxs.api/src/coordinaxs/api/manifolds.py:87
- The abstract
geodesic_distancedocstring still describes the oldseparationsemantics (norm of coordinate difference in the given chart). This is now incorrect:geodesic_distanceis computed from manifold geometry (symmetric, chart-invariant), and Minkowski/unknown manifolds can refuse rather than approximate. Updating this docstring will prevent API users from relying on the removed behavior.
"""Distance between two points on a manifold.
The straight-line distance is the manifold `norm` of the two points'
coordinate difference, evaluated in the chart they are given in (exact for a
flat manifold). Dispatches on the inputs: two `~coordinax.vectors.Point`
docs/spec.md:3002
chord_distanceis introduced as a new public manifold measurement, butdocs/spec.md(the authoritative spec) has no corresponding spec entry. Given the repo’s spec-first contract, this new API should be specified here alongsidegeodesic_distance(including its refusals for Euclidean/no-embedding and the fact it currently has noPointoverload).
```
(software-spec-abstractatlas)=
tests/unit/manifolds/test_geodesic_distance_dispatch.py:72
- The test class name
TestIndefiniteMetricSeparationstill refers toseparation, but the behavior under test isgeodesic_distancerefusing indefinite metrics. Renaming reduces ambiguity (especially now thatseparationno longer exists).
class TestIndefiniteMetricSeparation:
"""`geodesic_distance` inherits `norm`'s guard instead of returning ``nan``.
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #720 +/- ##
==========================================
- Coverage 96.55% 96.55% -0.01%
==========================================
Files 265 266 +1
Lines 8793 8849 +56
==========================================
+ Hits 8490 8544 +54
- Misses 303 305 +2 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
343586a to
91ad13f
Compare
91ad13f to
6694ed3
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
docs/api/manifolds.md:52
chord_distanceis introduced as a new public API (exported fromcoordinax.manifolds/coordinax). Per the repo’s spec-first workflow, this should also be documented in the authoritativedocs/spec.md(and/or the relevant spec section for manifolds/embeddings), otherwise the implementation and user-facing docs can drift from the source of truth.
- `chord_distance`: length of the straight line between two points _through the ambient space_ they are embedded in -- the tunnel rather than the surface path. A different measurement from `geodesic_distance`, not an approximation to it: on a sphere of radius $R$ separated by a central angle $\theta$, the geodesic is $R\theta$ and the chord is $2R\sin(\theta/2)$. Needs an embedding, so a manifold that is its own ambient (anything Euclidean) raises `NotImplementedError` and points at `geodesic_distance`
5d658a4 to
510e636
Compare
…e ambient
`geodesic_distance` measures along the manifold. The other honest answer is
the straight line *through* the space it is embedded in -- the tunnel rather
than the surface path. On a sphere of radius R separated by a central angle t:
geodesic = R t, chord = 2 R sin(t / 2)
They agree to first order and diverge as the points separate: 0.927 against
0.964 at t = 1 on the unit sphere, and 2R against pi R at antipodes. Which one
is wanted depends on the question -- a great-circle flight path is the
geodesic, a line of sight through the body is the chord -- so it is a separate
verb rather than a mode of the first.
Implemented by embedding both points and taking the ambient manifold's
`geodesic_distance`, which for flat ambient space *is* the straight line. There
is no second distance formula to keep in step, and it works for any embedding,
not only spheres.
A Euclidean manifold is refused: it is its own ambient, so its chord is its
geodesic, and returning the same number under a second name invites the reader
to think two things were measured. A manifold with no embedding is refused too.
Matches `2 R sin(t / 2)` to 1.1e-16, symmetric and chart-invariant to 1e-16.
No fast path, deliberately: ~99.5% of an eager call is the ambient
`geodesic_distance`, and ~0.5% the embedding steps a shortcut could skip.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
510e636 to
64674a4
Compare
* 📝 docs(spec): specify `chord_distance` and `interval` Of the manifold measurement verbs, `norm`, `angle_between` and `geodesic_distance` each carry a `software-spec-*` section; `chord_distance` (#720) never had one, and `interval` had eight passing mentions but no entry of its own. Both are public, both are reachable as `cx.*`, and neither was specified. `chord_distance` follows the `geodesic_distance` entry, since the two are a pair: same signature shape, opposite question. Its section records that the result carries the ambient's length unit for an `EmbeddedManifold` but is *dimensionless* on the bare `HyperSphericalManifold` -- whose canonical embedding is the unit sphere, so the chord is a pure ratio. That is a real asymmetry with `geodesic_distance`, which returns an `Angle` on the same manifold, and it is the sort of thing a spec exists to pin: an arc is an angle, a chord is not. `interval`'s section leads with what it is for -- the signed form is defined where `norm` has no real value and `geodesic_distance` refuses outright -- and then states plainly that it is **not** a distance. It is neither chart-invariant nor symmetric, and both were measured rather than asserted: same two points, flat plane: cart2d 5.0 m^2 polar2d 3.467 m^2 geodesic_distance 2.236 m in both sph2, one pair: (a,b) 0.813 rad^2 (b,a) 0.999 rad^2 So `interval == geodesic_distance**2` holds only on a flat manifold *in Cartesian coordinates*; flatness alone is not enough, since a curvilinear chart on flat space has a varying metric. Same correction #724 made to the docstrings, now stated where the contract lives. Also updates `geodesic_distance`'s "sits alongside `norm` and `angle_between` as the third manifold measurement", which stopped being complete when `chord_distance` landed, and cross-links the two. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
`geodesic_distance` has one and `chord_distance` did not, so the two verbs were usable at different levels -- GalacticDynamics#720 flagged the asymmetry and left it open. It could not be written the same way. `geodesic_distance`'s overload brings both operands into a Cartesian chart, which for a chord is exactly wrong: a Euclidean manifold is its own ambient, so every call would land on the case `chord_distance` refuses, and an intrinsic sphere chart has no global Cartesian representation to convert to at all -- `geodesic_distance(p, q)` on `sph2` raises `NoGlobalCartesianChartError` today for that reason. The second operand is mapped into the first's chart instead, and the measurement delegated to the manifold-level rule. The chord is a property of the embedding, and the intrinsic chart is what carries it. Frame-strict, matching `geodesic_distance`, and refusing a cross-manifold pair. Eleven tests: the analytic `2 sin(dphi / 2)` across five separations, symmetry, chart-invariance with operands in different charts, that it differs from the geodesic (sqrt(2) against pi/2, the point of two verbs), flat space refusing, and the cross-frame refusal. 241 passed across `tests/unit/vectors`; `nox -s precommit` clean.
The follow-up promised in #715, which is now merged; this stands alone against
main.geodesic_distancemeasures along the manifold. The other honest answer is the straight line through the space it is embedded in — the tunnel rather than the surface path. On a sphere of radiusThey agree to first order and diverge as the points separate: 0.927 against 0.964 at$\theta = 1$ on the unit sphere, and $2R$ against $\pi R$ at antipodes. Which one is wanted depends on the question — a great-circle flight path is the geodesic, a line of sight through the body is the chord — so it is a separate verb rather than a mode of the first.
Implementation
Embed both points, then take the ambient manifold's
geodesic_distance, which for flat ambient space is the straight line. So there is no second distance formula to keep in step, and it works for any embedding rather than only spheres.Refusals are deliberate:
geodesic_distance.Verification
Seven tests pin the analytic form across five separations, symmetry and chart-invariance, that it differs from the geodesic (√2 vs π/2 at a quarter turn — the point of two verbs), the embedded sphere carrying its radius, and flat space refusing.
prek run --all-filesclean, includingtyA fast path where it does pay
geodesic_distancegains one, in the same diff. Its packed overloads unpack their operands into component dicts so a curvilinear chart can be mapped to Cartesian first. In a Cartesian chart there is nothing to map, and plum can skip the unpacking entirely by dispatching on the chart type — 33x for arrays, 3.3x for quantities.Registered as two signatures rather than one with a
Quantity | Arrayunion: each generic overload it overrides is typed for one of them, and a union would be narrower in the chart but wider in the operands, so plum would rank neither more specific and refuse the call as ambiguous.No matching
CDictfast path. The twopt_mapcalls it would skip are 4.8 µs of a ~3000 µs call — the time is innormand in wrapping the result as aDistance— so it measured as no gain at all, and would have been dispatch surface bought with nothing.No fast path for the chord, deliberately
Profiling an eager call:
The embedding steps a chord-specific shortcut could skip are 0.5% of the call. Hand-rolling the sphere's embedding formula to dodge one
pt_mapwould duplicateTwoSphereIn3Dfor a fraction of a percent, so the module says so instead.That 99.5% is one chart transition, which is #719: eager
pt_mapcosts ~5.9 ms against ~21 µs jitted, with 178 dispatch resolutions per call. Fixing it there fixes it here.Scope
Manifold-level only.
geodesic_distancehas aPointoverload and this does not, so there is an ergonomic asymmetry — whethercx.chord_distance(p, q)should exist at the vector level is a separate decision, happy to add it if you want it.🤖 Generated with Claude Code
Doc and test-name corrections that came out of review here were split into #724, which is now merged. This branch has been rebased onto it and the duplicate commit dropped, so the diff is
chord_distanceand the fast path only —tests/unit/vectors/test_point.pyandtest_jit_parameterized_paths.pyare no longer touched here at all.