✨ feat(curveframes): infer tau_unit from the parameter instead of requiring it - #771
Open
nstarman wants to merge 1 commit into
Open
✨ feat(curveframes): infer tau_unit from the parameter instead of requiring it#771nstarman wants to merge 1 commit into
tau_unit from the parameter instead of requiring it#771nstarman wants to merge 1 commit into
Conversation
…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>
Contributor
There was a problem hiding this comment.
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-timeQuantity. - Update Bishop/Frenet-Serret builders and call sites (
nearest_tau,TubularChart) to use_tau_unit_atrather than assumingbuilder.tau_unitis 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:tangentcalls.astype(float)before_tau_unit_at, so a unitless scalar parameter can raiseAttributeErrorinstead of the intended_MSG_TAU_UNIT_UNINFERABLETypeError. 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]) |
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.
Follow-up to #756, as flagged in its description.
Why
#756 removed a bad default by making
tau_unitrequired. This removes it a second way, which costs no migration and is strictly safer: aQuantityparameter already states its unit, so the builders read it off the parameter instead of asking the caller to declare it again.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.jacfwdstrips withustrip(unit, arg)— a conversion, not a reinterpretation.Q(2.0, "Gyr")declared as"s"reaches the curve asQ(6.3e16, "s"); the curve's ownustrip("Gyr")converts it back. Measured on a Gyr/kpc helix, declared-"s"and declared-"Gyr"builders return bit-identicalrotation_matrix,tangentandlocation.The declaration is load-bearing in exactly two cases, both now covered by tests:
tau.ustrip("Gyr")— convertstau.value— reads the magnitudeTypeErrornaming both fixesFor the
.valuecurve onlytangentfails quietly — the parallel-transport ODE gives up outright on a curve made 3.15e16× stiffer, which is why the test pinstangentrather thanrotation_matrix.Inference cannot be wrong in the
.valuecase: it hands the curve back exactly the numbers the caller passed.Nothing is given up
BishopBuilder(ArcLength(c, "s"))(u.Q(1.0, "s"))raises exactly asBishopBuilder(ArcLength(c, "s"), "s")does.Where a declaration is still required
ArcLength/LagrangianArcLengthkeep a requiredtau_unit. TheirTubularChart.coord_dimensionsis structural and has no call parameter, so it readstau_bounds[0], a required field that carriesnearest_taulikewise reads its bounds. Neither needs the builder to declare anything.The one cost
BishopBuilder.__post_init__still materialisestau_0when the unit is declared, so the defaulttau_0stays 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 defaulttau_0is not a leaf. Passtau_0explicitly 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
coordinaxs.curveframestests passprek runclean on all touched files, includingty.valuefailure; the unitlessTypeError; the guard on an inferred unit🤖 Generated with Claude Code