Skip to content

✨ feat(curveframes): infer tau_unit from the parameter instead of requiring it - #771

Open
nstarman wants to merge 1 commit into
GalacticDynamics:mainfrom
nstarman:claude/infer-tau-unit
Open

✨ feat(curveframes): infer tau_unit from the parameter instead of requiring it#771
nstarman wants to merge 1 commit into
GalacticDynamics:mainfrom
nstarman:claude/infer-tau-unit

Conversation

@nstarman

Copy link
Copy Markdown
Contributor

Follow-up to #756, as flagged in its description.

Why

#756 removed a bad default by making tau_unit required. This removes it a second way, which costs no migration and is strictly safer: a Quantity parameter already states its unit, so the builders read it off the parameter instead of asking the caller to declare it again.

BishopBuilder(curve)(u.Q(2.0, "Gyr"))   # tau_unit inferred: Gyr
BishopBuilder(curve, "Gyr")             # still accepted, unchanged

A declaration is an independent second statement of a fact the parameter already carries, and two statements can disagree. Removing the second statement leaves nothing to disagree.

What the required unit was actually protecting

Narrower than #756 assumed, and worth stating precisely because it decides the design.

unxt.experimental.jacfwd strips with ustrip(unit, arg) — a conversion, not a reinterpretation. Q(2.0, "Gyr") declared as "s" reaches the curve as Q(6.3e16, "s"); the curve's own ustrip("Gyr") converts it back. Measured on a Gyr/kpc helix, declared-"s" and declared-"Gyr" builders return bit-identical rotation_matrix, tangent and location.

The declaration is load-bearing in exactly two cases, both now covered by tests:

curve style wrong declared unit inferred
tau.ustrip("Gyr") — converts harmless, absorbed same answer
tau.value — reads the magnitude silently wrong correct by construction
raw, unitless parameter required TypeError naming both fixes

For the .value curve only tangent fails quietly — the parallel-transport ODE gives up outright on a curve made 3.15e16× stiffer, which is why the test pins tangent rather than rotation_matrix.

Inference cannot be wrong in the .value case: it hands the curve back exactly the numbers the caller passed.

Nothing is given up

Where a declaration is still required

ArcLength/LagrangianArcLength keep a required tau_unit. Their $\tau$ is internal — generated by the reparametrisation ODE, never passed in — so nothing at call time carries it. This is a real asymmetry, not an oversight.

TubularChart.coord_dimensions is structural and has no call parameter, so it reads tau_bounds[0], a required field that carries $\tau$'s unit anyway. nearest_tau likewise reads its bounds. Neither needs the builder to declare anything.

The one cost

BishopBuilder.__post_init__ still materialises tau_0 when the unit is declared, so the default tau_0 stays a differentiable pytree leaf. When the unit is inferred it cannot — the unit is not known until a parameter arrives — so an inferring builder's default tau_0 is not a leaf. Pass tau_0 explicitly to differentiate through it; that also declares its unit.

Found by 5 failing tests that were right to fail, and confined to the new opt-in path.

Verification

  • 976 coordinaxs.curveframes tests pass
  • prek run clean on all touched files, including ty
  • new tests pin: declared ≡ inferred for both builders; the compatible-mismatch absorption; the .value failure; the unitless TypeError; the guard on an inferred unit

🤖 Generated with Claude Code

…equiring it

A `Quantity` parameter already states its unit, so the builders read it off
the parameter rather than making the caller declare it a second time. A
declared `tau_unit` still wins and is unchanged.

Inferring is safer than any default, including a correct one: a declaration
is an independent second statement of the same fact, and two statements can
disagree. Removing the second statement leaves nothing to disagree.

What the declaration was actually protecting was narrower than believed.
`unxt.experimental.jacfwd` strips with `ustrip(unit, arg)` -- a conversion,
not a reinterpretation -- so on a curve that converts its argument, declaring
"s" for a Gyr curve gives a bit-identical rotation matrix, tangent and
location. It corrupts the answer only for a curve that reads `.value`, and
only `tangent` fails quietly there: the transport ODE gives up outright.
Both cases are now pinned by tests, along with the third case that still
needs a declaration -- a raw, unitless parameter, which now raises a message
naming both ways out instead of an `AttributeError` from inside the solve.

Nothing is given up. The GalacticDynamics#718 dimension guard runs on an inferred unit too,
moving from construction to the first call that supplies a parameter --
still before any wrong number is returned.

Also fixes three `Defaults to "s"` statements in `spec.md` that survived
declared unit: the bounds and `tau_bounds` carry tau's unit themselves.

`ArcLength` keeps a required `tau_unit` -- its tau is internal, generated by
the ODE, so nothing at call time carries it. `BishopBuilder.__post_init__`
still materialises a declared-unit `tau_0` so the default stays a
differentiable pytree leaf; only the inferring path defers it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 20, 2026 04:14
@github-actions github-actions Bot added the ✨ Introduce new features Introduce new features. label Aug 20, 2026
@nstarman nstarman added this to the v0.24.0 milestone Aug 20, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR makes tau_unit optional for BishopBuilder and FrenetSerretBuilder (and their from_curve constructors) by inferring it from the Quantity parameter passed at call time, while keeping explicit tau_unit declarations supported. It also updates curveframe utilities/docs/spec and adds tests to pin the intended unit-inference behavior and guard semantics.

Changes:

  • Add AbstractCurveFrameBuilder._tau_unit_at(...) plus supporting converters/messages to resolve a declared unit or infer it from a call-time Quantity.
  • Update Bishop/Frenet-Serret builders and call sites (nearest_tau, TubularChart) to use _tau_unit_at rather than assuming builder.tau_unit is always present.
  • Expand unit-behavior tests and update curveframes spec/docs to reflect the new default.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
packages/coordinaxs.curveframes/tests/unit/test_builder_curve_arity.py Adds tests for inferred vs declared units, mismatch absorption, and unitless-parameter error paths.
packages/coordinaxs.curveframes/src/coordinaxs/curveframes/_src/nearest.py Derives tau unit from bounds instead of requiring a declared builder unit.
packages/coordinaxs.curveframes/src/coordinaxs/curveframes/_src/frenetserret.py Makes tau_unit optional and resolves derivative units via _tau_unit_at.
packages/coordinaxs.curveframes/src/coordinaxs/curveframes/_src/chart.py Computes tau dimension/unit via bounds/parameter rather than assuming declared tau_unit.
packages/coordinaxs.curveframes/src/coordinaxs/curveframes/_src/bishop.py Makes tau_unit optional and defers default tau_0 materialization when inferring units.
packages/coordinaxs.curveframes/src/coordinaxs/curveframes/_src/base.py Introduces _tau_unit_at, unit_or_none, and shifts dimension checks to declared-or-inferred resolution time.
packages/coordinaxs.curveframes/src/coordinaxs/curveframes/init.py Updates top-level docs snippet to reflect optional tau_unit.
packages/coordinaxs.curveframes/docs/spec.md Updates package spec to document `tau_unit: AbstractUnit
Suppressed comments (1)

packages/coordinaxs.curveframes/src/coordinaxs/curveframes/_src/frenetserret.py:224

  • Same issue as in rotation_matrix: tangent calls .astype(float) before _tau_unit_at, so a unitless scalar parameter can raise AttributeError instead of the intended _MSG_TAU_UNIT_UNINFERABLE TypeError. Resolve the unit from the original parameter first, then cast.
        b, p = self._resolve(tau)
        g = b._param(p).astype(float)
        dcurve = u.experimental.jacfwd(b.curve, units=(b._tau_unit_at(g),))
        return u.Q(_normalize(dcurve(g)).value, "")

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +174 to +179
# Unit-aware first and second derivatives via unxt. `g` is built
# first: it is what the parameter unit is read off when undeclared.
g = b._param(p).astype(float)
tau_unit = b._tau_unit_at(g)
dcurve = u.experimental.jacfwd(b.curve, units=(tau_unit,))
d2curve = u.experimental.jacfwd(dcurve, units=(tau_unit,))
Comment on lines +412 to +414
with pytest.raises(TypeError, match="carries no unit"):
cxfc.BishopBuilder(curve_gyr).tangent(2.0)

Comment on lines +27 to +28
fs_frame = cxfc.FrenetSerretFrame.from_curve(base_frame, curve[, tau_unit])
b_frame = cxfc.BishopFrame.from_curve(base_frame, curve[, tau_unit])
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

✨ Introduce new features Introduce new features.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants