Skip to content

feat(utils): port the scipy special functions to rust - #59

Merged
LoganAMorrison merged 2 commits into
masterfrom
claude/cython-to-rust/task-3.2-special-functions
Aug 10, 2026
Merged

feat(utils): port the scipy special functions to rust#59
LoganAMorrison merged 2 commits into
masterfrom
claude/cython-to-rust/task-3.2-special-functions

Conversation

@LoganAMorrison

@LoganAMorrison LoganAMorrison commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Summary

  • Adds rust/src/special.rsspence, bessel_k1, bessel_kn over the cephes-lineage spec_math crate, PyO3-free per rules.md rule 8 — plus rust/src/special_probe.rs, which registers hazma._core.special purely so the scipy comparison can run from Python. These are the three functions the Cython layer cimports from scipy.special.cython_special, and the reason pyproject.toml pins scipy>=1.13 at build time.
  • kn is deliberately not spec_math's cephes kn. scipy.special.kn dispatches integer orders to kv; only k0/k1 are still cephes there, so the faithful cephes routine misses scipy by up to 5.055e-9 relative over x ∈ [1e-8, 300] (at x = 9.531, just below its own x = 9.55 branch switch). That is four orders past this task's 1e-13 gate, and it would land inside the parity corpus's 1e-8 budget for thermal_cross_section, whose prefactor squares the value — a Phase 05 swap could have shipped it inside budget and moved published numbers. Kₙ is built from the upward recurrence K_{m+1} = K_{m-1} + (2m/x)·K_m on cephes k0/k1 seeds instead, tracking scipy to ≤ 3.4e-15 across orders 0–5. ADR-0002's vendoring fallback would have reproduced the miss rather than fixed it, so the phase file and references/numerics-replacements.md are patched to record that its "scipy's spence, k1, kn are themselves cephes wrappers" is false for kn.
  • The probe would otherwise have disabled the parity corpus's bit-equality mode. cases.rust_core_kernels() counts every public callable on the extension, so registering the submodule flipped the corpus to exact=False, detail='hazma._core serves 3 kernel(s)' for the rest of the port with nothing turning red (docs/agents/lessons.md, [gate-disabled-stays-green], second instance in this project). cases._CORE_TEST_ONLY_MODULES exempts the submodule — not the names, which would also cover a future real kernel — and test_test_only_core_submodules_have_no_importer makes the exemption conditional on nothing under hazma/ importing it.
  • No public value changes. The only diff under hazma/ is a comment block in the non-executable hazma/_core.pyi stub (+8/−0). spence and k1 agree with scipy to 2.425e-15 and 1.215e-15; kn(2, ·) to 9.786e-16 over hazma's live domain. One declared divergence, outside anything hazma reaches: above x ≈ 698 scipy's kn flushes to zero while K₂ is still 3.9e-305, and the recurrence keeps returning values to x ≈ 742; thermal_cross_section short-circuits above x = 300, and the boundary is pinned rather than only documented.

Review round 1 (2026-08-10)

  • Fixed a real defect: bessel_kn(n, -0.0) returned nan for every order ≥ 2 where scipy returns +inf. -0.0 < 0.0 is false, so IEEE routes negative zero to the zero branch — cephes k0/k1 hand the recurrence +inf seeds while its 2m/x term is -inf, and inf + -inf is nan. Orders 0 and 1 return the seeds directly and were always right, which is why an edge test covering +0.0 and -1.0 missed it. Fixed with an x == 0.0 short-circuit, and swept as a class: both signs of zero re-measured against scipy for all three functions at orders 0–5, establishing that spence, bessel_k1 and the kn seed arms were never affected. No hazma code path reaches the input — the crate is still unreferenced by any wrapper.
  • Corrected typed test counts in the task note, both working-memory READMEs, and this body: they read "53 tests in 7 classes" and "15 passed (7 new)" against 53 in 8 classes and 8 new Rust tests. Every count is now taken from a command, quoted beside it. Ledger: PR feat(utils): port the scipy special functions to rust #59 added to [derived-count-not-rederived], plus a new [signed-zero-lost-by-a-derived-formula].
  • Not a defect: a reviewer's full suite of 1141 passed, 14 skipped against this PR's 1142 / 13 is the parity corpus's mode signal, not a discrepancy — their environment resolved NumPy 2.5.2 where the manifest records 2.5.1, which drops the runner into budget mode and converts one test into a skip (1141 + 14 == 1142 + 13 == 1155).

Project

projects/cython-to-rust/ — Task 3.2: Special functions.

See projects/cython-to-rust/task-notes/phase-03/task-3.2-specfun.md for implementation detail, decisions, the per-sweep measurements, the eleven-mutation test-validity campaign, and the round-1 record.

Two canonical patches, both in this PR: phases/phase-03-numerics-foundation.md's Task 3.2 block gained three "criteria added during execution" bullets (the kn deviation, the bound on where the underflow criterion can hold, and keeping the served-kernel predicate sound), and references/numerics-replacements.md gained the measured block. No ADR — nothing revises ADR-0002, since the recurrence is original work over cephes seeds.

Test plan

  • scripts/agents/preflight.sh --paths "test/test_core_special.py test/parity/cases.py test/parity/test_parity.py hazma/_core.pyi"RESULT: PASS:
PASS   black --check / isort --check-only / ruff check   <the four Python files>
PASS   cargo fmt --check / cargo clippy / cargo test     rust/
PASS   pytest                  1154 passed, 13 skipped, 5 warnings in 578.91s (0:09:38)
PASS   import hazma            version 2.1.0
SKIP   markdownlint            no --md files given
SKIP   version bump            not a closing PR (pass --closing)
PASS   forbidden tokens        none added

markdownlint --dot was run separately over the seven touched docs in their final state (clean); the ten substantive gates read only hazma/, rust/ and test/, which were final at that run.

  • The bare suite ran the parity corpus in bit-equality modertol = 0 across all 41 consumed entry points, 179,695 pinned values. 1154 is 1142 + 12 (the new TestSignedZero cases) and the skip count is still 13, which is what proves the mode held across the round-1 fix. Environment is the corpus's capturing one (CPython 3.12.12, macOS/arm64, numpy==2.5.1, scipy==1.18.0), confirmed by tolerances.provenance(...)Provenance(exact=True, detail='').
  • pytest test/test_core_special.py -q65 passed in 0.50s — 9 classes, counted with pytest --collect-only -q | awk -F'::' '{print $2}' | sort | uniq -c.
  • cargo test --manifest-path rust/Cargo.toml --no-default-features16 passed (9 new, counted with grep -c '^ #\[test\]' rust/src/special.rs).
  • Test validity: eleven mutations, each rebuilt into the tree and run against both suites — nine against rust/src/special.rs (wrong spence convention, cephes kn fallback, swapped seeds, dropped order factor, k1K₀, an extra recurrence step, unfolded negative order, wrong seed arm, and the removed signed-zero guard) and two against the corpus guard. All eleven were caught by the test whose name claims them. Two are worth naming: dropping the order factor initially passed cargo test — at n = 2 that factor is 1 — which is why the Rust Wronskian now also runs at ν = 2; and removing the signed-zero guard fails 1 cargo test and 6 pytest cases while orders 0–1 keep passing, which is the shape the diagnosis predicts.

🤖 Generated with Claude Code

LoganAMorrison and others added 2 commits August 9, 2026 18:28
Adds rust/src/special.rs: spence, bessel_k1 and bessel_kn over the
cephes-lineage spec_math crate, PyO3-free per rules.md rule 8, plus
rust/src/special_probe.rs registering hazma._core.special so the scipy
comparison can run from Python. These are the three functions the
Cython layer cimports from scipy.special.cython_special, and the reason
pyproject.toml pins scipy>=1.13 at build time.

kn is deliberately not spec_math's cephes kn. scipy.special.kn
dispatches integer orders to kv; only k0/k1 are still cephes there, so
the faithful cephes routine misses scipy by up to 5.055e-9 relative
over x in [1e-8, 300] — four orders past this task's 1e-13 gate, and
inside the parity corpus's 1e-8 budget for thermal_cross_section, whose
prefactor squares the value. K_n is built from the upward recurrence
K_{m+1} = K_{m-1} + (2m/x) K_m on cephes k0/k1 seeds instead, tracking
scipy to 3.4e-15 across orders 0-5. ADR-0002's vendoring fallback would
have reproduced the miss rather than fixing it, so the phase file and
references/numerics-replacements.md are patched to say so.

The probe otherwise reads as three ported kernels to the parity
corpus's served-kernel predicate, which would have dropped the corpus
out of bit-equality mode for the rest of the port with nothing turning
red. cases._CORE_TEST_ONLY_MODULES exempts the submodule, and
test_test_only_core_submodules_have_no_importer makes that exemption
conditional on nothing under hazma/ importing it.

No public value changes: the only diff under hazma/ is a comment in the
non-executable _core.pyi stub, and the bare suite ran the parity corpus
in bit-equality mode across all 41 consumed entry points.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bessel_kn(n, -0.0) returned NaN for every order from 2 up, where scipy
returns +inf. IEEE routes -0.0 to the zero branch (-0.0 < 0.0 is false),
so cephes k0/k1 hand the recurrence +inf seeds while its 2m/x term is
-inf, and inf + -inf is NaN. Orders 0 and 1 return the seeds directly
and were always right, which is why an edge test covering +0.0 and -1.0
did not catch it. bessel_kn now short-circuits x == 0.0 to +inf.

Swept as a class rather than patched at the reported input: both signs
of zero were re-measured against scipy for all three functions and for
kn at orders 0-5, which is what establishes that spence, bessel_k1 and
the kn seed arms were never affected. Reverting the guard alone fails
one cargo test and six pytest cases, and leaves orders 0-1 passing.

Also corrects the test counts in the task note, both working-memory
READMEs and (on push) the PR body: they said "53 tests in 7 classes"
and "15 passed (7 new)" against 53 in 8 classes and 8 new Rust tests.
Every count is now taken from a command and the command is quoted
beside it. Post-fix: 65 tests in 9 classes, 16 cargo tests, and a bare
suite of 1154 passed / 13 skipped with the parity corpus still in
bit-equality mode.

Two lessons ledger entries: PR #59 added to
[derived-count-not-rederived], and a new
[signed-zero-lost-by-a-derived-formula].

No hazma code path reaches the fixed input — the crate is still
unreferenced by any wrapper, and the only diff under hazma/ remains a
comment in the non-executable _core.pyi stub.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@LoganAMorrison
LoganAMorrison merged commit d336587 into master Aug 10, 2026
8 checks passed
@LoganAMorrison
LoganAMorrison deleted the claude/cython-to-rust/task-3.2-special-functions branch August 10, 2026 03:17
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.

1 participant