Evaluate at the basis's own precision, and check the node differences - #25
Conversation
A basis of extended precision reported a precision it did not carry. `Legendre(BigFloat, 6)[0.3, 5]` returned a `BigFloat` that disagreed with `Legendre(BigFloat, 6)[BigFloat(0.3), 5]` by 1.3e-16 relative: the closure widened only the trailing sqrt(2j+1) factor, so Bonnet's recurrence itself ran in `Float64`. This is the same class of bug as the `Lagrange` buffers fixed in 0.3.0, which allocated in `Float64` regardless of `T`. `Bernstein` and `Chebyshev` did not widen at all and returned a `Float64` outright — while their derivative evaluators promoted the argument, so a basis and its own derivative disagreed about their element type. The four evaluators that lacked it now convert the argument first, as `Legendre`'s and both `Chebyshev` derivatives already did. The conversion goes in the closures rather than in `getindex`, because `basis(b)[j](x) == b[x,j]` is documented and `basis` hands those closures out. The promotion only widens: an argument more precise than the basis keeps its own precision, and every value at matching precision is unchanged — which is why no existing assertion moves. `Lagrange` needed nothing; it promotes elementwise against its own nodes. `promote_type` is abstract for an abstract `T`, where the conversion is a no-op, so `ChebyshevU(Integer, 2)` still works. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`allunique` compares with `isequal`, which asks a different question from the one the Lagrange denominators need answered. `allunique([0.0, -0.0])` is `true`, since the two are not `isequal` — yet `0.0 - (-0.0)` is `0.0`, so the basis was built with `Inf` denominators, which is exactly what the check exists to prevent. In the other direction a lone `NaN` or `Inf` node is `isequal` to nothing at all, passed straight through, and poisoned every difference it took part in. The constructor already forms the product of differences that each denominator inverts, so that product is now what is tested: rejected when zero or when not finite. Repeated nodes throw as before, repeated `NaN` still throws — through the finiteness branch now rather than through `isequal(NaN, NaN)` — and `-0.0` remains a perfectly good node among distinct ones. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two leftovers from the 0.3.0 review. `docs/Project.toml` kept a `[sources]` block after 09eaa20 removed the root one: the documentation workflow already develops the package from the checkout, and `[sources]` is understood only by Pkg 1.11 and later while this package supports Julia 1.10. The five `@elapsed` assertions read like benchmarks and are not. At degree 79 the cost of a return to the recursive formulation exceeds any wall clock, so what they assert is that the evaluation finishes at all; the four orders of magnitude between the bound and the measured cost are what keep them off a loaded runner, and are not slack to be tightened away. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #25 +/- ##
==========================================
+ Coverage 98.89% 98.91% +0.02%
==========================================
Files 7 7
Lines 271 276 +5
==========================================
+ Hits 268 273 +5
Misses 3 3 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
This PR tightens numerical correctness by ensuring basis-function evaluation is performed at the appropriate arithmetic precision and by rejecting degenerate Lagrange node sets that lead to invalid denominators, with corresponding documentation, tests, and release metadata updates.
Changes:
- Promote evaluation inputs so basis evaluations run in the wider of the basis element type and argument type (aligning basis and derivative element types / precision).
- Strengthen
Lagrangenode validation by rejecting node sets whose difference products are zero or non-finite (e.g.,0.0vs-0.0,NaN,Inf). - Update tests/docs/changelog and bump version to 0.3.1.
Reviewed changes
Copilot reviewed 15 out of 16 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| test/legendre_tests.jl | Clarifies performance guard intent (liveness vs benchmark) for Legendre evaluation. |
| test/lagrange_tests.jl | Adds regression tests for degenerate / non-finite node rejection and signed-zero behavior. |
| test/chebyshev_tests.jl | Clarifies performance guard intent (liveness vs benchmark) for Chebyshev evaluation. |
| test/bernstein_tests.jl | Documents elapsed-time guard as a liveness check with deliberate slack. |
| test/basis_tests.jl | Adds assertions that evaluation/derivatives promote to the wider precision and that basis(b)[j](x) matches b[x,j]. |
| src/legendre.jl | Promotes argument type used in Legendre recurrence evaluation within stored basis closures. |
| src/lagrange.jl | Replaces allunique check with product-of-differences validation (zero/non-finite) for node safety. |
| src/chebyshev.jl | Promotes argument type used in Chebyshev recurrence evaluation within stored basis closures. |
| src/bernstein.jl | Promotes argument type for Bernstein basis evaluation and derivative evaluation for type consistency. |
| src/basis.jl | Introduces _evaltype helper (promotion rule) and documents evaluation-precision behavior. |
| Project.toml | Bumps package version to 0.3.1. |
| docs/src/usage.md | Documents evaluation precision behavior with doctested examples. |
| docs/src/lagrange.md | Documents and demonstrates degenerate-node rejection (-0.0, NaN/Inf). |
| docs/src/chebyshev.md | Notes evaluation arithmetic behavior relative to element type and point type. |
| docs/Project.toml | Removes [sources] block to avoid requiring newer Pkg features for docs. |
| CHANGELOG.md | Adds 0.3.1 release notes describing precision promotion + Lagrange node validation changes. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| For an abstract `T` such as the `Integer` of `ChebyshevU(Integer, 2)` this is an abstract type, | ||
| and the conversion is then a no-op that leaves the argument as it is. | ||
| """ | ||
| @inline _evaltype(::Type{T}, ::Type{S}) where {T, S} = promote_type(T, S) |
|
|
||
|
|
||
| [0.3.0]: https://github.com/JuliaGNI/CompactBasisFunctions.jl/compare/v0.2.15...main | ||
| [0.3.1]: https://github.com/JuliaGNI/CompactBasisFunctions.jl/compare/v0.3.0...main |
The promotion was applied to `2y-1` rather than to `y`, so the shift itself still ran at the argument's precision and widening only recorded its rounding in more digits. For `y >= 0.25` the shift is exact by Sterbenz, which is why every point the tests sampled agreed anyway; at `y = 0.1` it rounds, and the error frozen in is 5.6e-17. This also reaches the three evaluators that were already promoting before 0.3.1 — both Chebyshev derivatives and the Legendre derivative — which converted `2x-1` for the same reason and carried the same rounding. `basis_tests.jl` now samples 0.1 alongside the points above a quarter, so the ordering cannot be lost again. The Lagrange node check tells its three failure modes apart rather than reporting one message for all of them: a degenerate product is equally what distinct, finite nodes give when their differences are not representable, and calling that a repeated node sends the reader after the wrong thing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four items left open as non-blocking in the #24 review. Two are real defects, two are cosmetic.
Pkg.test()goes from 2903 assertions to 3519, all green, and no existing assertion moves.Evaluation ran at the argument's precision, not the basis's
A basis of extended precision reported a precision it did not carry:
Legendrewidened only the trailing√(2j+1)factor, so Bonnet's recurrence itself ran inFloat64— the returnedBigFloatwas correct to1.3e-16relative and no further. This is the same class of bug as theLagrangebuffers fixed in 0.3.0, which allocated inFloat64regardless ofT.BernsteinandChebyshevdid not widen at all and returned aFloat64outright, although their derivative evaluators already promoted the argument — so a basis and its own derivative disagreed about their element type.All six evaluators now convert the argument first. The conversion lives in the closures rather than in
getindex, becausebasis(b)[j](x) == b[x,j]is documented andbasishands those closures out.The promotion only ever widens, so an argument more precise than the basis is untouched and every value at matching precision is unchanged — which is why no existing assertion moves.
Lagrangeneeded no change; it promotes elementwise against its own nodes.The conversion has to precede the shift
The first pass at this put the conversion in the wrong place: it widened
2y-1rather thany, so the shift onto[-1,1]still ran at the argument's precision and the widening only recorded its rounding in more digits. That reaches the three evaluators listed above as already correct —Legendre's derivative and both ofChebyshev's — which promoted, but promoted the shifted value.What made it easy to miss is that
2y-1is exact inFloat64fory ≥ 0.25by Sterbenz, so every point the tests sampled (0.3,0.75,1.0) agreed regardless. At0.1it rounds, and the frozen-in error is5.6e-17:basis_tests.jlnow samples0.1alongside the points above a quarter, and asserts the widening direction on the derivatives as well as the bases, so the ordering cannot be lost again.Lagrangeaccepted degenerate node setsalluniquecompares withisequal, which asks a different question from the one the denominators need answered:The constructor already forms the product of differences that each denominator inverts, so that product is now what has to come out usable. Repeated nodes throw as before, repeated
NaNstill throws, and-0.0is still a good node among distinct ones.The three ways it can fail are told apart, because a degenerate product does not on its own say which one occurred. Distinct, finite nodes give exactly the same product when their differences are not representable — packed into a narrow range it underflows, spread over a wide one it overflows — and calling that a repeated node sends the reader after something that is not there:
Both of those used to construct silently, with every denominator
0orInf. The nodes are asked about before the product, so a repeated node is still reported as one even when the surviving differences overflow around it.Cosmetic
docs/Project.tomldrops its[sources]block; the docs workflow already develops the package from the checkout, and[sources]needs Pkg ≥ 1.11 while this package supports Julia 1.10. 09eaa20 removed the root one for the same reason.@elapsedguards gain a comment saying they are liveness checks against a return to exponential evaluation, not benchmarks, and that the slack is deliberate.Test plan
Pkg.test()— 3519 pass (2903 before).julia --project=docs docs/make.jl— clean, all doctests pass, including the new element-type and node-rejection examples.Legendre{BigFloat}path is off by1.3e-16relative against aBigFloatreference, the new one agrees bitwise, at0.1as well as above a quarter.Tas a field, and@inferredsucceeds for all five bases and their derivatives.🤖 Generated with Claude Code