Skip to content

Evaluate at the basis's own precision, and check the node differences - #25

Merged
michakraus merged 5 commits into
mainfrom
evaluation-precision-and-node-checks
Aug 12, 2026
Merged

Evaluate at the basis's own precision, and check the node differences#25
michakraus merged 5 commits into
mainfrom
evaluation-precision-and-node-checks

Conversation

@michakraus

@michakraus michakraus commented Aug 12, 2026

Copy link
Copy Markdown
Member

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:

b = Legendre(BigFloat, 6)
b[0.3, 5] == b[BigFloat(0.3), 5]        # false before, true now

Legendre widened only the trailing √(2j+1) factor, so Bonnet's recurrence itself ran in Float64 — the returned BigFloat was correct to 1.3e-16 relative and no further. 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, 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, because basis(b)[j](x) == b[x,j] is documented and basis hands 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. Lagrange needed 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-1 rather than y, 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 of Chebyshev's — which promoted, but promoted the shifted value.

What made it easy to miss is that 2y-1 is exact in Float64 for y ≥ 0.25 by Sterbenz, so every point the tests sampled (0.3, 0.75, 1.0) agreed regardless. At 0.1 it rounds, and the frozen-in error is 5.6e-17:

setprecision(BigFloat, 256) do
    b = Legendre(BigFloat, 6)
    b[0.1, 5] == b[BigFloat(0.1), 5]    # false with the conversion after the shift
end

basis_tests.jl now samples 0.1 alongside 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.

Lagrange accepted degenerate node sets

allunique compares with isequal, which asks a different question from the one the denominators need answered:

allunique([0.0, -0.0])      # true — yet 0.0 - (-0.0) is 0.0
Lagrange([0.0, -0.0])       # accepted, denominators Inf
Lagrange([0.0, NaN, 1.0])   # accepted, every value NaN

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 NaN still throws, and -0.0 is 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:

Lagrange([0.0, 1e160, 2e160, 3e160])
# ArgumentError: the nodes of a Lagrange basis are distinct and finite, but the product
# of the differences from node 1 is -Inf in Float64, so the denominator it gives is not
# usable; rescale the nodes or widen the element type

Both of those used to construct silently, with every denominator 0 or Inf. 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.toml drops 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.
  • The five @elapsed guards 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.
  • Verified the precision fix changes what it should: the old Legendre{BigFloat} path is off by 1.3e-16 relative against a BigFloat reference, the new one agrees bitwise, at 0.1 as well as above a quarter.
  • No inference or allocation regression from the promotion: the closures do not capture T as a field, and @inferred succeeds for all five bases and their derivatives.

🤖 Generated with Claude Code

michakraus and others added 4 commits August 13, 2026 02:13
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>
Copilot AI lite review requested due to automatic review settings August 12, 2026 17:14
@codecov

codecov Bot commented Aug 12, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.91%. Comparing base (9911a63) to head (f2a555d).

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copilot AI left a comment

Copy link
Copy Markdown

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 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 Lagrange node validation by rejecting node sets whose difference products are zero or non-finite (e.g., 0.0 vs -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.

Comment thread src/basis.jl
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)
Comment thread CHANGELOG.md


[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>
@michakraus
michakraus merged commit 7f52474 into main Aug 12, 2026
15 checks passed
@michakraus
michakraus deleted the evaluation-precision-and-node-checks branch August 12, 2026 17:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants