Changelog
This page renders the project’s root CHANGELOG.md directly, so there is a single source of truth kept up to date in one place.
Changelog
All notable changes to NuOscProbExact are documented in this file.
The format follows Keep a Changelog, and the project uses Semantic Versioning.
[1.11.0] - 2026-08-02
The pre-publishing audit of src/. Two passes over all ten modules,
7378 lines, checking the code, the comments and the docstrings against each
other and against what the code actually does when run. A minor release:
it adds public API — four-flavor propagation through layered matter and the
Earth, a Lorentz-violating four-flavor term, and a Hermiticity check — and
fixes six docstrings that stated the opposite of the code.
Added
Four flavors through layered matter and the Earth.
slabs.evolution_operator_4nu_slabs,slabs.probabilities_4nu_slabs,earth.probabilities_4nu_earthandearth.probabilities_4nu_between_locations. Everything they need already existed —hamiltonians4nu.hamiltonian_4nu_matteraccepts an array of potentials, which is the shapeearthfeeds it — so a 3+1 user could compute a probability but could not propagate one through the Earth, which is the case the sterile matter resonance lives in.earth.matter_potential_nc, the neutral-current potential. It is flavor-universal across the three active states, so at two and three flavors it is proportional to the identity and drops out, which is whyearthnever needed it. A sterile state does not feel it, so it stops cancelling and leaves-V_NCon the sterile entry — the entry that places the resonance. It reproducesglobaldefs.VNC_EARTH_CRUSTexactly.hamiltonians4nu.hamiltonian_4nu_liv, the missing counterpart to the three-flavor LIV term.b4is an eigenvalue like the others, so a sterile state may couple to the LIV background whether or not it couples to matter.CHECK_HERMITICITY, in each ofoscprob2nu,oscprob3nuandoscprob4nu. A non-Hermitian Hamiltonian was accepted silently, and nothing downstream revealed it: the probabilities returned still sum to one, so the check a caller would actually apply could not tell the answer was meaningless. All seven public entry points now verify it.It is not free, and the cost is stated rather than buried: validating a stack is a pass over it, the same order as evaluating it, and the compiled kernel has made evaluating it fast. Interleaved, best of fifteen, the check costs 1.3x–1.8x on a 2000-point scan and 3.2x–5.7x on a 200 000-point one. It defaults to
Trueanyway, because a library that silently returns meaningless numbers costs its user more than the check does; set it toFalsefor scans whose Hamiltonians are Hermitian by construction.44 tests, covering the four-flavor slab and Earth paths, the Hermiticity refusal on every entry point and both backends, the mixing-angle and
costhzdomain checks, theearthedge cases below, and a guard that the two helpers duplicated across three modules each do not drift apart —oscprob2nuandoscprob3nuare documented as self-contained, so a shared module would break the property that makes copying them work, and duplication is the deliberate cost.
Fixed
Every image and link in
README.mdwould have been broken on PyPI. The README is the PyPI long description, and PyPI resolves relative URLs againstpypi.org— so the ten-image gallery the file opens with, and thirty-two links to notebooks, examples,LICENSEand the changelog, would every one have 404ed on the page thatpip installsends people to. They are now absolute, images throughraw.githubusercontent.comand the rest throughblob/tree, which changes nothing about how GitHub renders them. The twenty-seven in-page anchors are left alone.Worth knowing why nothing caught this:
twine checkpasses, and passed before the fix. It validates that the markup parses as PyPI will parse it, not that anything it points at resolves. This was found by building the distribution and reading theMETADATAout of the wheel.A refusal branch lost its only test. Giving
_check_hermitiana path for a single matrix took the scalar and batched routes off a shared implementation, and the test for an imaginary entry on the diagonal passes a nested list — so it stopped covering the batched version of that refusal, which was then exercised by nothing. Coverage fell from 100% to 99.62%, comfortably above the floor and therefore green. The test now checks both paths, as its non-finite counterpart already does for the same reason.CHECK_HERMITICITYmade a single probability nine times dearer, and nothing measured it. The check was written for stacks, as a dozen whole-array reductions, and that is the right shape at two hundred thousand elements and pure overhead at one: 59 µs to validate a single 3x3, against 0.63 µs per element on a stack of two thousand. A scalar three-flavor probability went from 8 µs to 143 µs — andprobabilities_3nupaid it twice, being the only one of the three that reached its answer through the publicevolution_operator_3nu, which validates the same matrix again.Two consequences, neither visible from the test suite, which checks that the documented figures agree with each other and never against the code. The 8 µs quoted in five places became wrong by a factor of nine. And
SMALL_BATCH, which exists to send short stacks down the cheaper scalar path, inverted: at its own threshold of ten the scalar path was 17.7x slower than batching, so the optimisation had become a pessimisation at every size._check_hermitiannow takes a separate path for a single matrix, comparing the entries as Python complex numbers, andprobabilities_3nuassembles the operator through a private helper rather than through the public routine. Measured after: a scalar three-flavor probability costs 17.5 µs against 143, a two-flavor one 4.3 against 34, and the check itself 3.6 µs against 59. The check now costs 1.35x on the scalar path rather than 9.4x, in line with the 1.3x–1.8x it costs on a stack.The thresholds were then re-measured, interleaved, best of seven, since the numbers they were set from predate the check: the crossover is thirteen elements at three flavors and twelve at two, so
SMALL_BATCHgoes from 10 to 12 and from 6 to 11. Note these govern the NumPy path only — with the compiled backend installed,fastkernels.MIN_BATCHis one at three and four flavors, so the kernel takes every stack beforeSMALL_BATCHis consulted.A later pass caught what that re-measurement broke around it.
oscprob4nu.SMALL_BATCHwas documented as matching the three-flavor threshold, which stopped being true the moment the three-flavor one moved; its docstring also described a scalar path that four flavors does not have, the quickstart having said so for two releases. Nothing reads that constant, and it now says as much.methodology.rstexplained the two thresholds as differing because two flavors amortises its overhead sooner — an explanation for a gap of four elements that survived the gap becoming one, and one that contradicts theoscprob2nudocstring beside it: they are close despite the difference in work per element, because what is amortised is the array machinery’s fixed cost. And_check_hermitian’s own docstring described only the stack strategy, never the single-matrix branch that is now half of it.A nan could pass the scalar Hermiticity check. The first version of the fast path above built its scale with
max, which keeps its running value when the comparison is against a nan — every comparison with one being false — so a nan never reachedisfinite, and a Hamiltonian the batched path refuses came back with probabilities instead. The two paths disagreeing about what is valid is worse than the cost the fast path was written to remove.The whole suite passed. The existing guard makes its matrix non-finite and non-Hermitian, so it is refused on the second ground and cannot tell whether the first works; and it never exercised the batched path. Both gaps are now covered by a test that is Hermitian apart from being non-finite and runs each path separately, confirmed to fail without the guard. Non-finite entries are now caught per entry, before
max.Six docstring claims that the code contradicts.
pmns_mixing_matrixandmixing_matrix_2nueach said 1.1.0 made them return anumpy.ndarray“rather than a nested list”; both return a nested list, and always have.hamiltonian_2nu_livandhamiltonian_3nu_liveach said 1.3.0 let “the matter potential be an array too”; neither has a matter potential.psi_rootssaid 1.5.0 formed the arc-cosine argument “as a division rather than a power of -1.5”, where the line readspow(h2, -1.5).distance_traveled_inside_earthcalledcosthz >= 0“upward-going” two lines above callingcosthz = -1“straight up”.sin(theta)outside [-1, 1] behaved three different ways: two and three flavors raisedmath domain error, naming neither the parameter nor the value; four flavors returnednanand let it run silently into the probabilities. One helper now serves all three.CONV_EV_TO_Gwas1.783e-33against CODATA’s1.78266192e-33— off by1.9e-4relative, three orders of magnitude worse than every other constant inglobaldefs. It reachesVCC_EARTH_CRUSTand every Earth crossing. The nuSQuIDS cross-check is unmoved by it, at 3.9e-16 and 3.0e-10 as before.earth.dms_to_decimalcould not express a coordinate between 0 and −1 degrees. The sign rides on the degree part, and-0is0, so 0°30′S came back North. No predefined location reaches that band; a user-supplied one can, so the minutes may now carry the sign.earth.earth_radial_distance_from_depthtooksqrt(abs(r2)), which absorbs endpoint round-off and would equally hide a genuinely negative radicand. It clips at zero.earth.coordinates_of_named_locationraised its helpful message from insideexcept KeyErrorwithoutfrom None, burying it under a chained traceback.probabilities_4nuand friends carried noversionchangedfor 1.10.1, although that release changed their results for a nearly degenerate spectrum.A non-finite entry disabled the Hermiticity check. Found by a later pass over the check itself: the tolerance is relative to the largest entry, so a single infinity made the scale infinite, the tolerance infinite, and every comparison false — a Hamiltonian that was both non-finite and non-Hermitian passed a check whose purpose is to refuse the second. Caught for the cost of one
isfinite, since the scale is computed anyway.earth’scosthzwas not required to be a cosine. The chord length is-2 R costhz, which accepts anything:costhz = -1.5gave a chord of 19 113 km against an Earth diameter of 12 742 km, and that chord then acquired seventy-six plausible slabs spanning the whole PREM density range. Nothing downstream noticed. The geometry routines now refuse a value outside [-1, 1], inclusive at both ends.
Changed
The NSI defaults are described accurately. Their comment claimed compatibility “at 2 sigma with LMA+coherent”; with
EPS_EE = 0.06andEPS_MM = 1.2the combination matter oscillations are sensitive to iseps_ee - eps_mm = -1.14, which is the LMA-D solution. The values are unchanged — they are deliberately large so the worked examples show a visible effect — and the comment now says so and warns against reusing them as a fit.The version is written in one place.
docs/source/conf.pyderivesreleasefrompyproject.tomlandversionfrom that, rather than restating both by hand — the short form was never a fact in the first place, only a derivation someone had to remember to redo. What cannot be derived is guarded instead, intests/test_version_consistency.py: the changelog must lead with the declared version, its headings must descend without duplicates down to 1.0.0, everyversionaddeddirective must name a released version and none may name an unreleased one, andsrc/must not restate the version at all. Six of those seven checks existed only as scripts run by hand during this audit.14 parameters annotated
Optional[T]with non-None defaults now sayT; three private functions gained theParameterssections every other private function insrc/has;globaldefsno longer describes itself as serving “plotting modules”, which no longer exist, nor omitsoscprob4nufrom the modules that do not need it.Notebook 16 said, in a code comment, that
earthdoes the full PREM profile — which it could not do at four flavors. It now measures the averaged and PREM answers against each other.README.md’s list of installed module names omittedoscprob4nuandhamiltonians4nu, which have been installed since 1.9.0. Theslabsandearthbullets now say which flavor counts they cover, and both the README and the quickstart documentCHECK_HERMITICITYand what it costs — a user-facing switch worth 3.2x to 5.7x on a large scan was described nowhere but its own docstring, whereUSE_NUMBAhas been in the quickstart since 1.6.0.A core module copied out on its own works again.
README.mdandinstallation.rstboth call copyingsrc/oscprob3nu.pyinto your own project “a supported way to use NuOscProbExact”. It stopped being one in 1.6.0, when the optional compiled backend arrived and was imported unconditionally: a lone copy raisedImportError: No module named 'fastkernels'. Six releases, unnoticed, because any check run from inside the repository findssrc/on the path and imports the real module. The import is now guarded, an absent backend answered the same way switching it off is, and a test copies each of the three modules out into a subprocess with the repository stripped fromsys.path.The accuracy table on the landing page claimed “200 random Hermitian Hamiltonians” where the fixture provides 100, “2000” where the test does 400,
4e-19for an agreement that cannot be smaller than one ulp of a probability (measured: 7e-16 and 1e-14), and7e-12where the measurement is 3e-14 — that last appears to be the test’s assertion threshold recorded as if it were the result.Two documents gave different speedups for the same four scans.
methodology.rstsaid ~30x, ~25x, ~40x, ~70x; the timings tabulated inindex.rstandREADME.mdgive 21x, 23x, 37x, 99x. The ratios are now derived from those timings and guarded together.The SU(3) star-product identity described as “37% off” at n=4 — one draw quoted as characteristic. Over two hundred random Hamiltonians the deviation has a median of 56% and a range of 30% to 230%.
Four flavors reached the documentation.
quickstart.rsthad a “Two flavors” section mirroring three and nothing for four;recipes.rst, which the landing page links as “What it can compute, with code”, mentioned it once in passing. Both now carry executed examples, including the sterile matter entry and a PREM crossing.CHECK_HERMITICITYreachedindex.rstandmethodology.rst, where the measured cost of the check and the two attempts to reduce it are now recorded.A diagram of how slabs compose, in the quickstart section above, drawn as SVG rather than generated: a neutrino entering as
nu_alphaand leaving asnu_beta, four slabs of differing width and density, the Hamiltonian and exactly-solvedU_keach one contributes, and two dashed ties that cross to show the ordering — the slab crossed first is the rightmost factor in the product. That reversal is the part of the API most easily got wrong, and it is the one thing prose states and a picture shows. Hand-written SVG keeps the labels as real text, adds no plotting code to a page whose point is the shortest path to a probability, and costs the build nothing. It carries atitleanddescfor screen readers and an explicit white background, so that opening the file on its own in a dark-mode viewer does not leave the slate text invisible.slabsreached the quickstart too. The page named it once, in a clause inside the four-flavor section, so a reader could finish it without learning that the library handles piecewise-constant matter at all — the answer to “what about the Earth?”, which is the first question the constant-density example provokes. A section now follows the evolution operator, where the group propertyU(L_1+L_2) = U(L_2) U(L_1)was already stated and then left unused, and shows a three-layer profile and a uniform one cut into four slabs, which returns the same probability to 6e-16.recipes.rstwas deliberately left alone: its “An arbitrary matter profile” already is that recipe, down to the castle wall that changes the answer at 0.44 GeV while the mean density does not, and a second entry would only duplicate it.The documentation’s inert code is now run by a test. Snippets shown as
jupyter-executeare executed by the documentation build and cannot rot silently; snippets shown ascode-blockare rendered and never run. The landing page’s Getting started example — the first code a reader sees — was one of the latter. So were the two switch snippets inquickstart.rst, where a renamed attribute would render perfectly and do nothing, which is the worst way for a documented escape hatch to fail.The documentation now says who wrote it.
grep Bustamante docs/sourcereturned exactly one hit, theauthorfield inside the BibTeX entry on the references page — so the docs site, the one artifact a reader reaches without seeingREADME.mdorpyproject.toml, credited its author only incidentally and gave no way to reach him.index.rstgains an Author section next to Citing and License, mirroring the lineREADME.mdhas carried all along, with the address already declared inpyproject.tomland in every module’s__email__, and a pointer to GitHub issues as the route that leaves a public record. It is deliberately not folded into Citing: the name is already in the BibTeX entry directly below, and no citation format carries an e-mail address.The PyPI project page gained the links it was missing.
HomepageandPaperwere the only two, so the deployed documentation and the release history — both of which exist — were reachable from PyPI only through the repository.Documentation,ChangelogandIssuesare now declared, along with theDevelopment StatusandOperating Systemclassifiers that the page filters on. Verified by reading them back out of a built wheel rather than by trusting the source.A two-pass audit of every documentation file and the README, run before publishing, checking each claim against the code rather than against the neighbouring prose. Twenty-three findings, of which these are the ones a reader would have acted on:
README.mdwarned, in a call-out box, that a non-Hermitian matrix “will output nonsensical results”. It has raisedValueErrorsinceCHECK_HERMITICITYlanded earlier in this release — the box described exactly the behaviour this release removed, and contradicted the Performance section sixty lines below it.installation.rstomittedoscprob4nuandhamiltonians4nufrom the modulespip installputs on the path, and both Requirements tables omitted four flavors entirely. This is the same omission recorded as fixed above: it was fixed inREADME.mdand left in the sibling document that tells the same reader the same thing.The link to Magnus 404s, from both
index.rstandREADME.md, because that repository is private. The recommendation stays; the hyperlink goes until it resolves. Found bysphinx linkcheck, which is worth a place in the release checklist: 29 external URLs, one real failure.README.mdannotated its own benchmark row “~93x” where the two timings on that line give 99, which is also whatmethodology.rstquotes for the same scan.fastkernelsandmethodology.rstpublished different tables under the same heading: ~9x against ~13x for one row, ~1.5x against ~1.4x for another, and a different fifth scan in each. The module is where they are measured, so the page now quotes it.recipes.rstsaid the castle-wall profile gives “nearly three times” the appearance probability of a uniform one; the block above it prints 0.0104 against 0.0028, which is 3.7.
Two guards are widened as a result, because in each case the drift happened underneath a test that covered part of the same table: the kernel-speedup check ran on the two four-flavor rows and now runs on all six, and the README’s inline ratios, which nothing checked at all, are now derived from the timings beside them. Both new checks were confirmed to fail on the values they replace.
The API reference documented the inert copy of
SMALL_BATCH.automodulehonours__all__, and onlyoscprob4nuexported it — the one flavor count where nothing reads it. The two that govern dispatch on every call, inoscprob2nuandoscprob3nu, appeared nowhere and were cross-referenced twice frommethodology.rstas targets that did not exist. Exporting them also takes Sphinx in nitpicky mode from 23 unresolved references to zero; the remainder werenumpy.sqrtandnumpy.arccosgiven as:func:, which NumPy’s inventory does not register as functions because they are ufuncs. The ordinary build under-Wis silent about every one of these.The README gained a License section — the landing page had one and it did not — a pointer to the Zenodo DOI as the way to cite the software rather than the paper, which covers only two and three flavors, and a table of contents that lists its sections in the order they appear. Three
refs.bibentries that were rendered but never cited — PREM, NuFit 4.0 and the LMA-D analysis — are now cited where the prose already relies on them.Smaller corrections: “the two core modules” in three documents where there are three; “every routine above accepts a stack” where the coefficient routines raise
TypeError; “returns probabilities” of routines that return an operator; the short-stack shortcut described as universal when four flavors has none.
Known limits
The electron number density uses the free-nucleon mean mass,
(m_p + m_n)/2, rather than the atomic mass unit. Nucleons in nuclei are bound, so this undercountsn_e, and henceV_CC, by about 0.85%. It is applied consistently inglobaldefsandearth.matter_potential, so it is a modelling choice rather than an inconsistency, and it is left alone deliberately.
[1.10.1] - 2026-08-02
A Newton step that could throw a latent root across the spectrum. A
correctness fix in oscprob4nu._polish_roots, present since 1.9.0 and
affecting the NumPy path and the compiled kernel alike. Found by mutation
testing the four-flavor kernel, not caused by it.
Fixed
The root refinement is refused where it would cross a neighbour. The Newton step divides by
chi'(psi_m) = prod_l!=m (psi_m - psi_l), a product of gaps, and was guarded only byderivative != 0.0. That is the right guard with its threshold on a knife edge: a pair separated by one unit in the last place gives a derivative of order 1e-16 and a step of order one. Observed, a root at0.8793was refined to0.0180, and the sixteen numbers that followed were not probabilities — reaching 21.7 and summing to 69.Whether a nearly degenerate pair lands on identical bits or on adjacent ones is decided by the last bit of a square root taken near zero, so the old test gave different answers for a stack and a scalar call, and for the NumPy path and the kernel — which is how the two disagreed by 186 on quantities that cannot exceed one.
The guard is the standard one for polishing polynomial roots: a step for a simple root may not carry it more than halfway to its nearest neighbour, and a step that wants to is evidence the root belongs to a cluster, where refining against a nearly singular
chi'only destroys what the closed form had.On synthetic spectra with pair separations drawn between 1e-16 and 1e-6 relative, roots wrong by more than 1e-6 relative went from 10.2% to none, and the worst error from 4.8 to 2.2e-6. Kernel-against-NumPy on the same population went from 186 to 5.6e-11.
What the fix does not do, stated because the difference matters: it does not make a nearly degenerate pair accurate. Euler’s reduction recovers the pair’s separation as
sqrt(z)for a resolvent rootzthat vanishes as the pair closes, so the epsilon onzbecomessqrt(epsilon)on the separation, of order 1e-8 relative. No Newton step againstchirecovers that. The guarantee is the weaker and correct one — refining never leaves the roots worse than the closed form left them — and a test asserts that ordering case by case, which is where the content is. The guard refuses the step outright for about forty per cent of that sweep, so which spectrum carries the worst error is decided by the last bit; the aggregate comparison is<=rather than<for that reason, a distinction CI found under two of the five Python versions after the strict form passed locally.
Unchanged
Two and three flavors, verified byte-for-byte: 94 records covering both backends, scalar calls, stacks straddling every dispatch threshold, scans, grids, evolution operators,
earthandslabs, hashing identically to 1.9.0.Every physical configuration tested. A 400 000-point scan through the sterile matter resonance at twelve times crust density holds a smallest relative eigenvalue gap of 7.7e-4, four orders above where the guard fires; both paths stay unitary to 1.6e-13 across it, exactly as before. Over three thousand ordinary random Hermitian spectra the guard changed nothing, bit for bit, and on the stiff 3+1 spectrum the refined error stays at 5.5e-16.
[1.10.0] - 2026-08-02
A compiled kernel for four neutrinos. fastkernels covered two and
three flavors; oscprob4nu was pure NumPy whether or not Numba was
installed. It is now the third member of the family, and it is the one that
gains the most — 18x to 19x on large stacks, against 15x at three flavors.
A minor rather than a patch release, matching 1.6.0 and 1.9.0: it adds
public API, fastkernels.probabilities_4nu_kernel. No existing module
changes behaviour, and nothing changes at all without Numba installed.
Added
fastkernels.probabilities_4nu_kernel, and the compiled machinery behind it:_one_4nu, which transcribes the whole four-flavor chain for a single element — traceless part, the three invariants from traces of powers, the quartic by Euler’s reduction, the Newton refinement of the roots against the matrix, the divided differences of the exponential over them, and the Newton-form reconstruction ofU_4— plus serial and parallel runners over a stack.Two decisions in it are worth recording.
The kernel refines the roots, and
POLISH_ROOTSis threaded through as an argument rather than read from module state, which an@njitfunction cannot do at call time without recompiling._rungrew anextratuple for it, which two and three flavors leave empty. A test pins both settings, so a kernel that ignored the flag would fail whichever way it was set.chi(psi) = det(psi·1 − H~)is evaluated by Gaussian elimination with partial pivoting, written out for a 4×4 in a caller-supplied scratch buffer — the same factorization LAPACK performs fornumpy.linalg.deton the NumPy path, with no allocation and no call. It beatsnumpy.linalg.detunder Numba by 3.5x.A Laplace expansion in the six 2×2 minors of the first two rows was written first, is 5.9x cheaper still, and was rejected. The determinant is evaluated at a root, where it is meant to vanish: on a stiff 3+1 spectrum the true value sits seventeen orders of magnitude below the products being summed, so an expansion that cancels them only at the end has nothing left, while elimination cancels while the entries are still full precision. Measured against
mpmathat sixty digits, on the clustered roots wherechi'is 6e-35, the expansion was a thousand times the less accurate, refining those roots to 4.4e-15 relative against 5.5e-16; on a spectrum whose cluster is 1e-3 wide the gap is 54x.POLISH_ROOTStabulates 1.1e-16, and a backend quietly delivering forty times that whenever an optional dependency is installed would make that table false.What the measurement did not show is any of this reaching the probabilities, and no test here distinguishes the two. Below
psi·L ~ 1both sit on the one-ulp floor; above it the reconstruction cancels by ~1e6 and swamps them, and which scores better is then noise — on the stiff spectrum at 1300 km the rejected expansion won, 3.1e-11 against 1.5e-10. The case for elimination is fidelity to the roots the NumPy path computes, not a demonstrated gain in the numbers handed back. It costs about 40% of the kernel’s serial runtime.
fastkernels.MIN_BATCH[4] = 1. Measured the way the other two thresholds were — alternating the paths throughoscprob4nu.probabilities_4nu, best of nine rounds each. The kernel leads by 15x at a single element, falls to 5x just belowPARALLEL_THRESHOLDwhere it is still single-threaded, and settles at 18x once the threads are in use. It is never behind, so the threshold is one.Note that
worthwhilealready fell back toMIN_BATCH.get(n, 1), so four flavors returnedTrueat every size before the entry existed. Adding the dispatch without a measured threshold would have enabled the kernel everywhere by accident rather than by measurement; the entry is explicit so that it is on the record.25 tests, in
tests/test_fastkernels.pyand one parameterization intests/test_oscprob4nu.py. Thebackendfixture moved toconftest.py, since the four-flavor module’s own suite now needs it too: with a kernel installed, every batched assertion there was otherwise being made about whichever backend happened to be present.A unit test for
_chi_4nu, againstnumpy.linalg.detdirectly, including a matrix whose leading entry vanishes at the evaluation point and so requires the pivoting. Mutation testing found that disabling the pivoting left the whole suite green, which follows from the determinant’s accuracy not surviving into the probabilities: no end-to-end test can see it, so it is checked where it is observable.A guard on the new figures, in
tests/test_documented_figures.py. The four-flavor speedups are stated infastkernelsand again inmethodology.rst, and the one-sentence span inREADME.mdandquickstart.rsthas to bracket them — which is the shape of every drift that module already exists to catch.
Changed
tests/test_oscprob4nu.py::test_batched_agrees_with_scalarasks for round-off rather than exactness. Its bar was1e-15, which held only while both sides ran the same NumPy code; the batched call is now the compiled kernel and the one-by-one calls stay on the NumPy path, and two implementations of the same expansion agree to a few ulp — 4.2e-15 on probabilities of order one. It runs on both backends now, which makes it the element-by-element comparison of the two.Notebook 09’s four-flavor section no longer says there is no compiled kernel for four flavors, and measures the ratio twice: 9.3x on the NumPy path, 4.9x with both compiled. The narrowing is not the algebra getting cheaper but the fixed cost of driving it as forty whole-array passes, which the kernel does not pay.
Known limits
A stiff 3+1 spectrum makes the two paths differ by ~1e-10, and this is a property of the expansion rather than of either path. The Newton-form reconstruction of
U_4cancels by ~1e6 there — its four terms reach 9.7e5 and sum to a matrix of modulus one — so a last-bit difference anywhere in that sum arrives at 1e-10. The amplification predicts 2.3e-10 and the paths differ by 2.4e-10. Both stay inside the 1e-9 thatPOLISH_ROOTSdocuments; the equivalence test asserts that, and says why it cannot assert round-off._polish_rootsis unsafe when two latent roots very nearly coincide, on both paths and onmainbefore this release. Found by mutation testing this kernel, not caused by it. The Newton step divides bychi'(psi_m) = prod_l!=m (psi_m - psi_l), guarded only byderivative != 0.0, which is a knife-edge test: a pair separated by one ulp gives a derivative of ~1e-16 and a step that throws the root across the spectrum. On synthetic spectra with pair separations drawn between 1e-16 and 1e-6 relative, the NumPy path’s_latent_rootsreturns roots wrong by more than 1e-6 relative for ~10% of them, onmainunchanged.The kernel inherits this faithfully — handed the kernel’s roots, the NumPy
_polish_rootsreturns bit-identically wrong values — but it changes which inputs trigger it, because the resolvent root that vanishes for a degenerate pair is computed to a different last bit andsqrtnear zero amplifies that into the pair’s whole separation.Not reached by any physical configuration tested: a 400 000-point scan through the sterile matter resonance at twelve times crust density holds a smallest relative eigenvalue gap of 7.7e-4, and both paths stay unitary to 1.6e-13 across it. A real fix belongs in
_polish_rootsand is its own change.A single four-flavor probability still costs ~262 µs, against 13.5 µs at three, because
oscprob4nuhas no scalar closed form and a scalar call runs the array machinery on a stack of one. The kernel does not change this: the dispatch excludes scalar calls, which have to keep returning a tuple of sixteen.oscprob4nu.SMALL_BATCHis documented as sending short stacks through “the scalar path” and is in fact read by nothing. Left alone deliberately, as its own decision.
[1.9.0] - 2026-08-01
Four-neutrino oscillations. The Ohlsson–Snellman method is carried to
n = 4 through the SU(4) algebra, which brings 3+1 sterile scenarios into
scope — and which is the last n where the method exists in closed form at
all.
A minor rather than a patch release, matching 1.8.0, which added slabs and
earth: this adds public API rather than correcting anything. No existing
module changes behaviour.
Added
oscprob4nu, the four-flavor expansion. Everything structural carries over from three flavors — expand in the fifteen generalized Gell-Mann matrices, factor out the global phase, interpolate the exponential over the latent roots, read the probabilities off|U|². Three ingredients are new:A third invariant. SU(4) has rank three, so the traceless part carries
I2,I3andI4, and the cubic characteristic equation becomes a quartic. All three come from traces of powers, which avoids ever building the SU(4)dtensor — a 15×15×15 table nothing else would need.A quartic that still solves in closed form. Euler’s reduction gives a resolvent cubic whose roots are real and non-negative because the Hamiltonian is Hermitian, so the same trigonometric construction that
oscprob3nu.psi_rootsuses solves it. The SU(3) machinery is literally nested inside the SU(4) solution.A longer star-product tower. The three-flavor identity
(h*h)*h = |h|² h/3is a Cayley–Hamilton accident ofn = 3and is false atn = 4— 37% off on a random Hamiltonian — so((h*h)*h)_aenters theu_aas independent data. A test asserts it stays false, since if it ever held the extra term would be dead weight.
hamiltonians4nu, sample 3+1 Hamiltonians: the 4×4 mixing matrix with three extra angles, the energy-independent vacuum term, matter, and matter with NSI. As at three flavors these are examples, not limitations.globaldefs.VNC_EARTH_CRUST. The neutral-current potential is flavor-universal across the active states, so at three flavors it is proportional to the identity and drops out entirely — which is why it has never been needed. With a sterile state it does not drop out: removing it from all four costs only a global phase and leaves-V_NCon the sterile entry, positive and half ofV_CCin the crust. Getting that entry wrong is the four-flavor analogue of the antineutrino sign trap — invisible in vacuum, and it moves the resonance — so a test pins its sign and size.Notebook 16, working a 3+1 scenario through: the sixteen probabilities, the sterile entry in the matter potential, a short-baseline energy scan, the sterile matter resonance through the Earth, the three new algebraic ingredients evaluated live, the accuracy comparison, and the decoupling check against
oscprob3nu. Two gallery figures come from it.tests/test_oscprob4nu.py, 30 tests. The strongest is the last kind: switching the three sterile angles off must reproduceoscprob3nuexactly, which it does to 7e-12 in vacuum and in matter — an independent module, written years earlier against a different algebra, computing the same numbers.
Changed
The documentation no longer says the library is limited to two and three flavors. There were more than a dozen such claims, including a bullet in “What it is not” reading “Not a four-flavor code … a sterile fourth is outside what they cover”. The paper’s title is left exactly as published in all four places it appears; the extension is described as an extension.
When to use Magnus is now stated up front, in both
README.mdand the documentation landing page, as a table rather than a remark buried in “What it does not do”. The rule is one line — reach for Magnus when the Hamiltonian varies appreciably over an oscillation length — and the table gives five cases with the reason for each, including the one neither tool handles, which is open-system evolution needing Lindblad.methodology.rstgains a four-flavor section, including why the method stops at four: the eigenvalues come from solving the characteristic polynomial in radicals, and Abel–Ruffini says degree five has no such solution,S₅not being soluble whereS₂,S₃andS₄are. A theorem, not a gap. The philosophy survives even where the closed form does not — numerically computed eigenvalues feed the same machinery at anyn; it simply stops being a closed form, which is this library’s reason to exist.
Notes on accuracy
Worth reading before using four flavors in anger, and worth keeping in proportion: none of what follows is near a measurable effect, since probabilities meet data at the per-cent level.
A generic four-flavor Hamiltonian agrees with scipy.linalg.expm to 3.7e-14.
A stiff 3+1 spectrum — an eV-scale Δm²₄₁, so eigenvalues spanning four
orders of magnitude with three clustered — is different: forming I2, I3
and I4 in double precision compresses the 4×4 matrix into three numbers and
loses what separates the cluster. Perturbing the three invariants at the
1e-16 level moves the roots by 6e-11 relative, which is ordinary
ill-conditioning of polynomial roots against their coefficients, so no better
root-finder helps. Deflating the quartic to a cubic first was tried, and
does not.
The roots are therefore refined against the matrix, by one Newton step on
χ(ψ) = det(ψ𝟙 - H̃). Measured against mpmath at fifty decimal digits:
Strategy for the roots |
Relative error |
Cost, 200k points |
|---|---|---|
Closed form alone |
8.3e-11 |
0.17 s |
Closed form + one Newton step |
1.1e-16 |
0.41 s |
|
7.4e-16 |
0.17 s |
Closed form in |
4.5e-11 |
0.43 s |
The Newton step is about seven times more accurate than LAPACK, since
eigvalsh reduces by similarity transforms each carrying a backward error of
order ε‖H‖. Extended precision was rejected for buying under a digit, for
being slower, and for silently being float64 on Apple Silicon and Windows.
eigvalsh was rejected for being less accurate and for meaning the module
would take its eigenvalues from LAPACK, which is the one thing this library
exists not to do.
In probabilities that is 5e-7 unrefined against 1e-9 refined. The reasons to
want the smaller number are the exactness claim, error accumulating when
slabs and earth compose operators across many layers, and a regression
suite with no room for a bug to hide in. It costs about 40% of the runtime,
bringing the four-flavor closed form to parity with a batched eigh rather
than ahead of it. oscprob4nu.POLISH_ROOTS records the trade and switches
it off.
Applying the refinement selectively was measured and rejected. Skipping
it where the spectrum is not stiff — the way SMALL_BATCH and MIN_BATCH
dispatch on a threshold — would recover that 40%. Two criteria were tried on
6300 Hamiltonians: the gap-based amplification perturbation theory suggests,
which misjudges doubly degenerate pairs by four orders of magnitude, and a
matrix residual comparing prod(ψ_m) with det(H̃), which is one constraint
on four roots and misses errors that cancel in the product. Neither can
safely skip a single element. The reason is structural: a criterion complete
enough to certify four roots must evaluate χ at four roots, which is the
refinement — the check and the fix are the same computation. And since a 3+1
scan is stiff at every point, even a working criterion would skip nothing on
the workload that motivates the module.
Fixed
Degenerate spectra at four flavors. The first implementation reconstructed
U₄by solving a Vandermonde system for the Cayley–Hamilton coefficients, which is singular the moment two latent roots coincide — not an exotic case, but one that includes a Hamiltonian proportional to the identity, a zero Hamiltonian, and any triply degenerate spectrum, all of which raisedLinAlgError. It is replaced by Newton interpolation with divided differences, where a repeated node is a derivative and for the exponential that derivative is known exactly. No special branch, no tolerance-dependent switch between formulas, and six degenerate spectra now agree withexpmto 1e-12.
[1.8.6] - 2026-08-01
A pre-publication audit, before the first upload to PyPI. Nothing here changes a probability; it is documentation that had drifted from the code, metadata that had gone stale, and one extra that could not do what it promised.
Two of the entries below deserve reading together, because they are the same
decision applied twice. The file tree and the performance figures were both
kept in more than one place, both drifted, and both were caught by hand
rather than by the suite. Neither copy is deleted — README.md is the PyPI
long description and has to stand alone — so instead the duplication is made
safe: the tree is now generated from one table, and the figures are now
checked against one table. Duplication that cannot drift is not the same
problem as duplication that can.
Fixed
Three recipes on the “Numerical recipes” page stopped short of what they promised. The page opens by saying each recipe is “a few lines and a figure”, that the code shown is the same calculation as the notebook linked beside it, and that anything short enough to run is executed at build time and shows its output.
“Between two places on the Earth” said “the chord between two named sites, and the probability along it”, then printed only chords. The probability was the point, and
earth.probabilities_3nu_between_locations— the reason the named-location table exists at all — appeared nowhere on the page. It is now called, and the section links notebook 07, which it had also been missing while every other recipe carried a link.“An oscillogram” was a static
code-blockwhose last statement assignedgridand stopped, so it produced no output even in principle. Nothing justified the exception: the 240×240 grid evaluates in 0.14 s. It now runs and prints its shape and range.“Mass ordering and the octant” promised two open questions in its title and printed two mass-squared differences, with no probability anywhere and no mention of the octant, beneath a figure captioned with a claim the code did not support. Both are now computed, following notebook 12. The octant block is evaluated at 5 GeV rather than at the 2.5 GeV used for the ordering: at 2.5 GeV the two octants give
P_mumuof 0.019 against 0.0067, which is the deep disappearance minimum rather than an error, but would have read as contradicting the octant degeneracy it illustrates. At 5 GeVP_mumuis 0.4816 against 0.4768 — one per cent apart — whileP_mueis 0.0245 against 0.0299, twenty-two per cent apart.The
docsextra could not build the documentation.pip install -e '.[docs]'installed four of the ten packagescd docs && make htmlneeds, and failed on the first missing Sphinx extension.conf.pyloadssphinx-copybutton,sphinxcontrib-bibtexandjupyter-sphinx, setshtml_themetosphinx_rtd_theme, and executes the narrative blocks in a real kernel, which needsipykernel,matplotlibandscipy. Nothing caught this because nothing uses the extra — both documentation jobs installdocs/requirements.txt, which has always been complete. The two lists are now the same set, barnumpy, which the extra omits as already a hard dependency.methodology.rstcontradicted itself on the scalar timing. The page states it twice, and the 1.8.4 reconciliation updated only the second occurrence, so one page has since said “about thirteen microseconds” and “about sixteen microseconds” a hundred lines apart.fastkernels’s “Routine listings” omittedavailableandworthwhile, two of its four public functions, andworthwhileis the one that decides whether the compiled path is taken at all. The other seven modules were checked the same way, by parsing each listing against the public functions defined in the file; this was the only gap, in both directions.index.rstcontradicted its own benchmark table. It described the gain from passing arrays as “roughly 25 to 60 times” three lines above a table whose rows give 21×, 23×, 37× and 99×.README.md, which carries the same table, had it right at “20–90×”. The page now says 20 to 90 as well, and a test derives the span from the table rather than trusting the prose.
Added
tests/test_documented_figures.py, so the repeated performance figures cannot drift apart again. The same measurements appear inREADME.md,index.rst,quickstart.rst,methodology.rstandfastkernels, and they have to: the README is the PyPI long description and must stand alone. The figures are now held once in the test and checked against every document that quotes them — the scalar timing in digits or in words, whichever that file uses; the twelve numbers of the 2000-point table, compared as the strings the documents print, so that “0.20 ms” drifting to “0.2 ms” is caught too; the quoted speedup span against the table’s own ratios; and an explicit refusal of the superseded 13 and 16, so a stale copy-paste cannot reintroduce one quietly. Each failure names the file that disagrees and the value it should carry.
Changed
The scalar timings are re-measured: about 8 microseconds for three flavors and 1 for two, against the 16 and 2 quoted since 1.8.4. The figure appeared in five files and seven places, all agreeing with each other and none reproducing. Measured after a warm-up loop, fastest of fifteen rounds of two thousand calls, timing overhead subtracted, repeated three times end to end: 8.18, 7.99 and 8.12 µs for three flavors, 1.10, 1.11 and 1.15 for two. The spread within a case is under four per cent, far tighter than the gap to the figure replaced, so this is not the run-to-run variation the surrounding prose already warns about.
The four batched speedups measured at the same 1.8.4 sitting are not re-checked here. They are ratios between two paths measured together, which is more robust than an absolute time, and notebook 09 measures them on whatever machine runs it.
The file tree is generated rather than maintained by hand in two places. It appears in
README.mdand ininstallation.rst, and both were edited by hand. The old tests compared the two and checked that every tracked file’s basename appeared in one of them, which left three ways to be wrong: the comparison was between stripped lines, so indentation could drift unnoticed; matching on basenames meant a file listed under the wrong directory still passed; and adding a file meant editing two documents, in the right place, with the box-drawing characters aligned.Both are now rendered from one ordered table in
tests/test_file_tree.py, which pairs each path with the comment beside it. Order and comments stay written down, since neither can be derived from the filesystem; membership is derived, andtest_tree_matches_gitrequires the file entries to be exactlygit ls-filesin both directions.python tests/test_file_tree.py --writeupdates both documents; with no arguments it reports whether they are current. The renderer was written against the existing tree, and reproduces all 94 lines of both copies byte for byte, so the change altered no rendered output at all.docs/requirements.txtis now just-e .[docs]. Two lists of documentation dependencies, which this same release had just finished making agree — and the reason they disagreed for so long is that nothing installed the extra, so nothing noticed. There is now one list, inpyproject.toml. The editable install that requirements.txt performs makes the separatepip install -e .step redundant, so the two steps are merged into one inlint.ymlandpages.yml, keeping the note about why it must be editable: jupyter-sphinx executes the narrative blocks in a real kernel, a separate process thatconf.py’ssys.path.insertnever reaches.Verified in a fresh virtualenv:
pip install -r docs/requirements.txtfrom the repository root installs the package plus all ten documentation packages,oscprob3nu.__file__resolves into the working tree, and Sphinx then builds clean under-Wwith no separate package install.README.mdno longer lists the optional extras twice. “Requirements” gave two of them as install commands and “Installation”, twenty lines below, gave all four again. Requirements now says what each task needs and which extra provides it, as a table; Installation owns the commands. No fact is dropped, and thedocsrow is new — Requirements had never mentioned it.
Removed
The module
__version__strings. Eight modules carried one by hand: five said"1.1",fastkernelssaid"1.6",slabsandearthsaid"1.8", against a project at 1.8.5. No release commit had ever touched them, so all eight had drifted —oscprob3nuclaimed"1.1"while holding the vectorised batch path, the degeneracy handling and the Numba dispatch, none of which existed at 1.1.Syncing them would add eight more places every release must update, which is what produced the drift. Deriving them from
importlib.metadatawould raisePackageNotFoundErrorfor a copy used offsys.pathwithout being installed, which is what the scripts inexamples/do and what the paper tells readers to do. So they are deleted. Nothing in the repository read them: the documentation takes its version fromdocs/source/conf.pyand the package metadata frompyproject.toml.__author__and__email__are kept — still true, and they do not drift.from __future__ import print_function, from the eight modules. A Python 2 shim in a package whose floor is 3.9, and a no-op on every interpreter this project has ever supported. The nine scripts inexamples/keep theirs: they are published alongside arXiv:1904.12391, and the repository already treats their period character as deliberate — the star imports they open with are ruff-exempted inpyproject.tomlfor that reason.
[1.8.5] - 2026-08-01
Changed
test/is nowexamples/. Havingtest/andtests/side by side was a standing invitation to open the wrong one, and tab-completion could not tell them apart. The new name says what the directory holds: nine runnable scripts, the onesREADME.mdwalks through line by line.Nothing functional depended on the old name.
pytestnever collected from it (testpaths = ["tests"]), so the clash was cosmetic rather than real, and the scripts locatesrc/relative to themselves, so they run from the new location unchanged.git mvkeeps their history.The directory is called
test/in version 1.0.0 of the code and in version 2 of arXiv:1904.12391.README.mdand the installation and quickstart pages say so, for anyone arriving from the paper. The layout had already moved away from what the paper describes — 1.8.1 removedrun_testsuite.pyand the four plotting modules, andsrc/has gained three modules since — so this is one more difference in a list, not a new kind of divergence.Entries below this one still say
test/. They are dated records of what was true when written, and are left alone.
[1.8.4] - 2026-08-01
Changed
Docstring examples are executed when the documentation is built. Every
Examplesblock is now a.. jupyter-execute::directive, following the pattern the sibling Magnus package uses: self-contained, importing what it needs, so it can be copied straight out of the page. The API reference no longer shows>>>prompts with results pasted beside them — 42 blocks, 0 prompts left.Each converted block was checked against the output its doctest documented, and all 42 reproduce it exactly, so nothing about what the examples do changed.
tests/test_docstrings.pyis repurposed rather than left hollow. It now extracts and executes the same blocks on every supported Python — the documentation is built by one job on one interpreter, and an example that works on 3.12 and not on 3.9 would otherwise reach the published page unnoticed. A second test refuses any>>>example that creeps back, since the run-test would not see it.The performance figures are re-measured and reconciled. Three places quoted different numbers for the same four benchmarks:
fastkernelssaid ~20x, ~15x, ~5x and ~4x; the 1.6.0 changelog entry said 12.5×, 15.0×, 2.3× and 3.7×; and neither matched what the code does now, which is ~15x, ~9x, ~3.5x and ~1.5x.The live claims — in
fastkernels, the README,index.rst,quickstart.rstandmethodology.rst— now carry the same measured figures, taken best of seven with the two paths interleaved, and say plainly that they move by tens of per cent between runs. The 1.6.0 entry below is left as it was written: it records what was measured then, and rewriting it would falsify the record rather than correct it.The scalar timings drifted too, and are corrected: about 16 µs for three flavors and 2 µs for two, against the 13 µs and 1.3 µs previously quoted.
Notebook 09 measures the same comparison on whatever machine runs it, and the documentation now points at it as the figure to trust.
Fixed
Two
test/examples were referenced inREADME.mdunder names that do not exist —example_2nu_vacuum_coefficients.pyand its three-flavor counterpart, where the files are..._coeffs.py.Nothing executed the worked examples in
test/, which is why the broken references went unnoticed; a step inlint.ymlnow runs all nine. They are the examples the paper refers to and the onesREADME.mdwalks through, so they are kept rather than removed, but they are now checked.README.mdstill described the notebooks as nine; there are fifteen.
[1.8.3] - 2026-08-01
Changed
Installation instructions in
README.mdanddocs/source/installation.rstnow lead withpip install nuoscprobexactand are split three ways: from PyPI, from a clone of GitHub, and without installing anything at all.Cloning was previously the only route described, which made the ordinary case — somebody who wants to use the library rather than work on it — read the longest set of instructions. The clone is still documented, and is still what you want for the notebooks, the worked examples from the paper, the regression suite, or an unreleased version.
The third route is kept because it is genuinely supported: the two core modules need only
numpyandcmath, so copying one into a project of your own works, and is what the paper assumes.pip install nuoscprobexactdoes not work until the first release is published. The instructions describe the intended state; publishing is what makes them true.
[1.8.2] - 2026-08-01
A pass over the README and the documentation, so that both say what the library does now rather than what it did in 2019.
Added
A What you can compute gallery at the top of
README.md: eight figures, each linking to the notebook that drew it. The figures are extracted from the executed notebooks bynotebooks/make_notebooks.py, so there is one piece of code behind each picture rather than a second copy that can drift.docs/source/recipes.rst, a numerical-recipes page collecting the same material with runnable snippets. Eight of its examples are executed when the documentation is built, so the numbers on the page are produced by the code being documented.A statement of scope in both
README.mdand the documentation landing page — what the library does, and what it does not. The second list is the one that was missing: no continuously varying Hamiltonians, no fourth flavor, no fluxes or cross sections, no fitting.notebooks/make_notebooks.py, the generator that produces and executes the fifteen notebooks and extracts the gallery figures. It was scaffolding outside the repository until now.
Changed
The narrative documentation pages execute their examples at build time through
jupyter-sphinx, rather than quoting output written by hand beside them.quickstart.rsthad one such block; its numbers were correct, and are now generated.Docstrings keep their
>>>examples deliberately. Those are run as doctests by the regression suite on every supported Python, and they are whathelp()shows in a terminal; converting them would have moved the check to a single interpreter and made the interactive reading worse.The scope description in
README.mdandindex.rstnow coversslabs,earth, PREM and the named locations, none of which existed when that text was written.
Removed
Created:andLast modified:lines from every module docstring. They are version control’s job, they had already drifted, and Sphinx rendered them into the API reference, where each appeared eight times.
[1.8.1] - 2026-08-01
Seven worked notebooks, a logo, and fig/ out of version control. No change
to the library.
Added
notebooks/, fifteen Jupyter notebooks numbered in reading order: the basics, vacuum oscillations, matter and NSI and LIV, oscillograms, bi-probability plots, the Earth and PREM, probabilities through the Earth, unusual matter profiles, performance, the paper’s own figures, exact versus the textbook approximations, mass ordering and the octant, antineutrinos, solar neutrinos, and numerical edge cases. They carry their figures inline, so they render on GitHub without being run.Two of them go beyond what the old figure suite covered. The unusual-profile notebook builds castle-wall, serrated and shuffled profiles by hand and holds their mean density fixed, so that any difference in the probabilities is due to the arrangement of the matter alone — including the parametric enhancement that a periodic profile produces. The performance notebook measures, on the machine that runs it, the cost of looping against broadcasting and of the NumPy path against the compiled kernel.
The solar notebook is as much a warning as a demonstration. It validates the slab machinery against the analytic adiabatic MSW result — averaged over a narrow energy band, the two agree to 0.0009 — and then shows why the approach is impractical there anyway: a single averaged point costs 800 000 slab evaluations, and a realistic calculation multiplies that by the production region, the spectrum and a third flavor. It points at the Magnus package for profiles that vary smoothly over an oscillation length.
A
notebooksextra — Jupyter, matplotlib and scipy — and anotebooksjob inlint.ymlthat executes every one of them. A notebook is documentation that claims to work, and stored outputs make that claim persuasive without making it true — they were correct whenever the notebook was last run, which may predate the change that broke it. The job also refuses a notebook stripped of its outputs, which would execute cleanly while showing a reader nothing.The project logo, wired in as
html_logo, at the top of the documentation sidebar.
Removed
run_testsuite.pyand the four plotting modules it drove (test/oscprob2nu_plot.py,test/oscprob3nu_plot.py, and the two*_plotpaper.py). The notebooks cover the same figures and show them without anyone having to run anything or go looking in a directory afterwards, and the plotting modules had no other caller. The worked examples intest/, which the paper refers to, are untouched.Two code blocks in
README.mdwent with them. Both called a module namedoscprob3nu_tests, which has not existed under that name for years, so they had been broken well before this release; they now point at the notebook that draws the same curve.fig/, which is no longer tracked or written. It held one committed.gitignorewhose only job was to keep an empty directory alive.The
plotsextra, which existed for the plotting modules and had nothing left to install for.matplotlibis still available through the newnotebooksextra.
[1.8.0] - 2026-08-01
Piecewise-constant matter. The exact expansions assume a Hamiltonian that does not change along the trajectory; a neutrino crossing the Earth does not have one. Two new modules close that gap without giving up exactness: the path is cut into slabs, each is solved exactly, and the operators are multiplied. Within a slab nothing is approximated.
Nothing in the existing modules changed behaviour.
Added
slabs, which propagates across a sequence of adjacent slabs of arbitrary width and Hamiltonian, for two and three flavors:evolution_operator_2nu_slabs,evolution_operator_3nu_slabs,probabilities_2nu_slabs,probabilities_3nu_slabs.The per-slab operators are evaluated in one batched call, so
nslabs cost one vectorised evaluation plusn-1matrix products rather thannseparate evaluations.Note that each slab drops the phase carried by the trace of its Hamiltonian, as the single-slab routines do. Their product therefore differs from the product of full matrix exponentials by one overall phase, which leaves every probability unchanged but matters to anyone comparing the operator itself against
scipy.linalg.expm.earth, which builds those slabs from the Preliminary Reference Earth Model:density_prem,matter_potential, the chord geometry (distance_traveled_inside_earth,earth_radial_distance_from_depth,prem_layer_edges_along_chord,chord_length_inside_earth,costhz_between_points_on_surface),earth_slabs, and the probabilities across the Earth for a given zenith angle (probabilities_2nu_earth,probabilities_3nu_earth) or between two named locations (probabilities_2nu_between_locations,probabilities_3nu_between_locations).Two things decide where the slabs are cut. Between shells the density jumps, so the chord is first split at every PREM boundary crossing — no amount of subdivision recovers a discontinuity that straddles a slab. That gives a set of chord segments, which are not the same as shells: a chord enters and leaves each shell it reaches, so a diametric chord has 19 segments across 10 shells. Within a shell the density varies smoothly, by as much as 21% over a single 2200 km mantle segment, so each segment is divided further into
n_slabs_per_segmentequal sub-slabs with the density taken at the midpoint.Midpoint sampling converges at second order — measured, not assumed: past 32 sub-slabs per segment each doubling cuts the error by about four.
The 15 predefined locations are the same set as the sibling Magnus package, so a trajectory named in one can be reproduced in the other.
globaldefs.EARTH_RADIUS.68 tests for the two modules. The ones that matter are the independent checks: slab composition against a product of
scipy.linalg.expmfactors, splitting one Hamiltonian into many slabs reproducing the single-slab answer, PREM integrating to the mass of the Earth to 0.02%,matter_potentialreproducingglobaldefs.VCC_EARTH_CRUSTfrom the crust density, and a uniform-density Earth reproducing the ordinary constant-Hamiltonian result.
[1.7.0] - 2026-07-31
Continuous integration, an automatic coverage gate, and linting. Nothing in
the library changed: no module in src/ was touched except for a coverage
pragma, the 279 tests are the same 279 tests, and every probability this code
computes is the one it computed before. What changed is that all of it is now
checked on every push instead of whenever someone remembered.
The one thing users will notice is the supported Python range.
Added
Four GitHub Actions workflows, in
.github/workflows/.Workflow
What it does
tests.ymlThe suite on Python 3.9–3.13, plus a job for each of the three backend configurations, plus coverage
lint.ymlruff check, and a Sphinx build under-W --keep-goingpages.ymlBuilds and deploys the documentation to GitHub Pages on push to
mainpublish.ymlBuilds and publishes to PyPI on a published GitHub Release
The three backend configurations are separate jobs because they are separate claims: that the compiled kernels work, that the NumPy path gives the same answers across the whole suite, and that a plain
pip installwith nonumbastill works. Each of the two backend jobs asserts its own preconditions, so neither can quietly become a duplicate of another and keep reporting success.publish.ymlcan also rehearse a release against TestPyPI, run manually from the Actions tab. Worth doing because the real upload cannot be retried: PyPI refuses a second upload of a version permanently, even after the file is deleted. The event picks the index — a published Release goes to PyPI, a manual run goes to TestPyPI — so neither can reach the other, and a rehearsal is stamped.devNso it can be repeated as often as needed.An automatic coverage gate, configured in
[tool.coverage]inpyproject.tomlso that a localpytest --covmeasures and gates exactly as CI does. Branch coverage is on, and the floor is 98%.The
@njitkernels infastkernelsare excluded, because Numba compiles them to machine code thatcoverage.pycannot trace at all — they were reported as 118 missing lines that no test could ever close, despite being exercised against the NumPy path on every run. Excluding the decorator rather than omitting the file keepsavailable,worthwhile,_runand both public wrappers measured, which is where a dispatch bug would hide. Coverage of what can be traced is 100%.A
[tool.ruff]configuration andruff checkin CI. The rule selection is pinned rather than left to ruff’s default, which moves between releases. Three codebase-wide conventions are exempted with the reason recorded at each:lfor the baseline, one-line guards in thed_ijklookup table, and the star imports in the paper’s worked examples.pytest-covin thetestextra, and version classifiers for Python 3.9 through 3.13.Seven badges in
README.mdanddocs/source/index.rst: tests, Code Quality, codecov, Documentation, PyPI, Downloads, and Code style: ruff. The PyPI and Downloads badges will not resolve until the first release is published.A
.. versionadded::directive on all 32 public functions, so the API reference says when each one entered the library. The answer is mostly 1.0.0: 28 of them have been there since the first release in 2019, and the only later additions are the four that came with the Numba backend in 1.6.0. That the public surface has been stable for seven years is worth being able to see at a glance.57
.. versionchanged::directives across 25 of those functions, recording what changed and when, for every release that changed how a function behaves or how fast it runs.33 of them are changes a caller can observe: a changed signature, a newly accepted input type, a changed return type, or a changed returned value. Most are 1.1.0, where the audit corrected results; the rest are the batched interface in 1.2.0 and the batched Hamiltonian builders in 1.3.0.
The other 24 are the performance releases — 1.4.0, 1.5.0 and 1.6.0 — which rewrote much of the library without moving a single number. Each says so: the 1.4.0 directives note that all 42 figures are byte-for-byte those of 1.3.0, and the 1.5.0 ones that the probabilities agree with 1.4.0 to 1.6e-13.
Speedups are given as ratios, not absolute timings. The 1.4.0 and 1.5.0 tables in this file were measured in separate sessions and do not chain — 1.4.0 ends at 14.5 µs where 1.5.0 begins at 40.4 µs — so a pair of absolute figures in a docstring would invite a comparison that is not valid. The ratio is what each release actually established.
Changed
requires-pythonis now>=3.9, raised from>=3.7. The old floor was never tested and its stated justification — the@operator — has held since Python 3.5, so it did not pin 3.7 or anything else. 3.9 is what the code actually needs: the batched paths callnumpy.broadcast_shapes, which arrived in NumPy 1.20, and 3.9 is the oldest interpreter for which the optionalnumbabackend still has a wheel. All five supported versions are now in the CI matrix.
Removed
Eight unused imports, in three of the worked examples in
test/and two modules intests/. These were what running a linter for the first time actually found.
[1.6.0] - 2026-07-31
An optional Numba backend for the batched paths, and a shortcut for very short
stacks. The library’s only required dependency is still NumPy, and the
results are unchanged either way: all 42 figures generated by
run_testsuite.py remain byte-for-byte identical, and the two backends agree
to round-off.
Added
fastkernels, which tries to import Numba at module scope. If it is present the batched expansions are compiled into fused machine-code loops spread over the available cores; if it is not,HAVE_NUMBAisFalse, nothing else in the module is defined, and the NumPy path is used exactly as before. Install withpip install "nuoscprobexact[fast]".Measured against the NumPy path:
Stack
Speedup
200 000 energies, three flavors
12.5×
20 000 energies, three flavors
15.0×
200 000 baselines, two flavors
3.7×
100 × 100 oscillogram
2.3×
The gain comes from replacing roughly fifteen passes over N-element arrays, each writing a temporary the next reads back, with one loop that keeps its intermediates in registers.
The kernels are declared
cache=True, so the few seconds of first-call compilation are paid once per machine; later runs load from disk in milliseconds. Stacks belowPARALLEL_THRESHOLDrun single-threaded, since waking the thread pool costs more than it saves there.Two things stay on the NumPy path deliberately. The scalar routine is not compiled: one probability takes about thirteen microseconds, which is not worth a compilation pause on a first call. And
fastkernels.USE_NUMBA = Falseforces the NumPy path at any time.fastkernels.worthwhile, which declines the kernel for stacks it would not speed up. This was not in the first draft, and the first draft was wrong for it: with the backend installed, a two-flavor baseline scan of 2000 points ran 2.6× slower than without. That expansion reduces to a square root and a sine per element, which NumPy already does about as well as compiled code can, and the kernel additionally has to materialise the Hamiltonian stack — for a fixed-Hamiltonian scan, the same matrix repeated, costing 2.5 ms to copy at 200 000 points.The thresholds in
MIN_BATCHwere measured by alternating the two paths and taking the best of nine rounds each: three flavors wins at every size, two flavors from about fifty thousand elements. A backend that is sometimes slower than the path it replaces is worse than no backend.Tests for both backends in
tests/test_fastkernels.py. Every equivalence check runs with the compiled path forced off as well as on, so the NumPy path is exercised even where Numba is installed rather than rotting unnoticed; the whole suite passes both ways, and in a third configuration where Numba is genuinely unimportable. The kernel carries its own copy of the degeneracy branch, so that is checked separately against the scalar path, and agrees exactly.A
kernel_spyfixture, which counts entries into the compiled kernels so a test that means to exercise them can assert that it did.This exists because one did not. Raising the two-flavor dispatch threshold to fifty thousand elements meant the two-flavor equivalence tests, which run stacks of at most a thousand, stopped reaching the kernel altogether: they were comparing the NumPy path against itself, and passing. Instrumenting the suite showed the two-flavor kernel entered once in a whole run against thirty-nine entries for the three-flavor one. A test that passes for the wrong reason is worse than a missing one, because it reads as coverage.
tests/test_physical_scales.py, which compares the evaluation paths at the scales the library is actually used at. Everything else compares them on random Hermitian matrices with entries of order one and baselines of order ten; a real Hamiltonian has entries around 10⁻¹³ eV and a baseline around 10¹³ eV⁻¹, and agreement in one regime does not imply agreement in the other. These run the bundled vacuum, matter, NSI and LIV Hamiltonians at NuFit parameters over energies from 10 MeV to 100 GeV and baselines from 1 km to 10⁷ km, on both backends, including an oscillogram and a near-degenerate spectrum split by one part in 10¹⁴. Agreement is 5e-15.Tests for three branches that measurement showed were never executed: the fixed-Hamiltonian matrix product and its axis padding, which live on the NumPy path and which a default run sends to the kernel instead;
psi_rootsreturning zeros for a vanishing invariant; and the expansion core recomputing the star product when not given one.Line and branch coverage of the four core modules is now 100% on both backends — 170 branches in
oscprob3nualone — where line coverage was 98% and 99%. The compiled kernels cannot be traced bycoverage.py, so the spy counts entries into them instead.fastkernelsis also brought under the docstring and annotation guards that every other module was already subject to, which immediately caught an unannotated helper in it.A Performance section in the README, and one on the documentation landing page, built around the two things a user can act on: pass arrays instead of looping, which is the larger win and needs no extra dependency, and install the optional extra if the scans are large.
methodology.rstgains sections on the short-stack shortcut and on the compiled backend, including why it is conditional. All of them state the two-flavor caveat rather than quoting only the flattering rows.
Changed
Stacks of at most
SMALL_BATCHelements are evaluated one at a time through the scalar path. A batched call carries a couple of hundred microseconds of fixed cost whatever its length, so for a handful of points it spends more on the machinery than the scalar path spends on the whole job: a single Hamiltonian passed as a stack of one cost 211 µs through the array path against 66 through the scalar one.The thresholds are measured, and differ between the expansions because the two-flavor one does much less work per element and so amortises sooner: eleven elements for three flavors, seven for two. An earlier draft used sixteen for both, from a measurement that prepared the nested lists outside the timed region; the real path has to convert them, which is most of the per-element cost.
That conversion is worth making explicitly: the scalar path is quicker on Python complex numbers than on NumPy scalars, by more than
.tolist()costs.
[1.5.0] - 2026-07-31
A deep pass over the whole computation, looking for work that was being done
and thrown away. Nothing about the results changes: all 42 figures generated
by run_testsuite.py remain byte-for-byte identical, and the probabilities
agree with 1.4.0 to 1.6e-13 across every code path.
Measured best-of-seven, interleaved against 1.4.0:
Benchmark |
1.4.0 |
1.5.0 |
Speedup |
|---|---|---|---|
Energy scan, 2000 points |
4.91 ms |
1.42 ms |
3.5x |
Two-flavor scan, 2000 points |
0.23 ms |
0.05 ms |
4.6x |
Oscillogram, 100 x 100 |
6.61 ms |
3.65 ms |
1.8x |
Baseline scan, 2000 points |
1.12 ms |
0.70 ms |
1.6x |
Three-flavor probability, scalar |
40.4 µs |
12.5 µs |
3.2x |
Two-flavor probability, scalar |
4.95 µs |
1.51 µs |
3.3x |
Cumulatively, against the 1.1.0 audit release — which had no batched interface, so the comparison is a Python loop against a single call — a 2000-point energy scan with the Hamiltonians built goes from 93 ms to 1.9 ms, a factor of 48. The same loop written today, still one point at a time, takes 39 ms: the scalar path accounts for 2.4x of that and vectorising for the rest.
Changed
The batched star product no longer contracts the dense 8x8x8
dtensor throughnp.einsum, which with no path plan walks the whole table for every element: for a 2000-point energy scan that single line was 70% of the total._star_all, the sparse expansion the scalar path already used, vectorises unchanged and is 14x quicker there. (optimize=Truewas tried first: 1.1x.)probabilities_2nuandprobabilities_3nuno longer build the evolution operator, square it, and then transpose and reshape the result into the order they return. They form the entries and square them as they go.For two flavors the saving is larger still. The coefficients of a Hermitian 2x2 Hamiltonian are real, so |U_ee|² = u₀²+u₃² and |U_μe|² = |U_eμ|² = u₁²+u₂² — two distinct numbers, the second the complement of the first by unitarity. Neither the operator nor the coefficients are needed.
The batched coefficients are laid out with the component index first, so that each h_k is contiguous. Every downstream step works one component at a time, and reading those from a strided
(..., 8)view costs about a third more. On an oscillogram: forming u_k, 746 → 480 µs; the nine entries and their moduli, 2175 → 1598 µs. The two-flavor module gets the same layout, so the two mirror each other as their documentation claims.The coefficients u₀ and u_k are returned separately rather than concatenated into a
(..., 9)array that every caller took apart again.Around the latent roots: √(|h|²) is taken once rather than three times; the arc-cosine argument is a division rather than a power of −1.5 (26.9 → 3.8 µs); and the degeneracy test uses two calls to
np.minimuminstead of stacking three gap arrays to reduce along the axis it just created (106 → 13 µs).The scalar exponentials use
cmath.rect(1, t)rather thancmath.exp(1j*t)— the same value by construction, a third quicker.
Added
Tests for two-flavor broadcasting: one Hamiltonian against many baselines, and a grid. Neither had coverage, and the first is the shape that the layout change initially broke. Also: stacks with more than one leading axis, and shapes that do not broadcast, which must raise rather than quietly produce the wrong thing.
Note that the ratio between a vectorised scan and the equivalent Python loop
has narrowed across releases, from about 80x to about 30x for a baseline scan,
even though the vectorised scan itself keeps getting quicker: the loop got
quicker too. docs/source/methodology.rst now carries measured ratios rather
than ones carried forward from an earlier release.
Not changed, having been measured
Two plausible-looking optimisations were tested and rejected:
|z|²asz.real² + z.imag²or(z·z̄).realbeatsnp.abs(z)**2below about 10⁵ elements but is up to 1.6x worse above it, where the extra temporary stops fitting in cache.np.abs(z)**2is kept.Computing the batched complex exponential as
cos + i·sinis slower thannp.expat every size that matters here.
[1.4.0] - 2026-07-31
Type annotations throughout, and a pass over the arithmetic that was spending more time in NumPy’s dispatch machinery than in the calculation.
Nothing about the results changes: all 42 figures generated by
run_testsuite.py are byte-for-byte identical to those from 1.3.0.
Added
Type annotations on every parameter and return value, including the private helpers, in the style used by Magnus. Sphinx renders them into the signature, so the API page now states the polymorphism of the core routines rather than leaving it to the prose.
tests/test_annotations.py, which keeps the annotations and the docstrings in step: everything is annotated, every annotation resolves to a real type, every public parameter is documented, and an annotation that admits an array paired with a docstring that promises a scalar fails the suite — in either direction. That bidirectional check immediately caught a fix that had landed on the wrong function; a one-directional audit had passed it.oscprob3nu._star_all, the sparse expansion of :math:(h \star h)_i = d_{ijk} h_j h_kwritten out. The dense 8x8x8 table is kept for the batched path, where the array machinery pays for itself, but for eight numbers it spends several microseconds of dispatch on a few dozen multiplications. The explicit form is six times quicker and agrees to 1.1e-16; a test checks it againsttensor_dterm by term.
Changed
The scalar paths no longer dispatch NumPy for single numbers:
np.realandnp.imagon one complex number,np.arccos,np.clipandnp.sqrton one float, all give way to attribute access and themathmodule.The sum over the three latent roots is rewritten using the fact that two of its factors do not depend on k,
u_k = i [ (sum_m w_m psi_m) h_k - (sum_m w_m) (h*h)_k ] ,
which forms those combinations once instead of inside the loop over the eight k, and removes an
(N, 3, 8)intermediate — eight times the size of the result — from the batched path.The star product was computed twice per call, once to form the invariant ⟨h⟩ and again inside the expansion. It is now computed once and passed on.
abs(z)**2becomes_abs2(z), which skips the square root that the square immediately undoes.The closed-form three-flavor vacuum Hamiltonian rebuilt the CP phase fifteen times across its nine entries, and recomputed two products in five places. Hoisting them shortens the expressions materially and takes the routine from 75.0 to 32.1 microseconds.
Measured best-of-seven, interleaved against 1.3.0:
Benchmark |
1.3.0 |
1.4.0 |
Speedup |
|---|---|---|---|
Three-flavor probability, scalar |
46.4 µs |
14.5 µs |
3.2x |
Two-flavor probability, scalar |
5.7 µs |
1.7 µs |
3.3x |
Energy scan, 2000 points |
5.75 ms |
4.99 ms |
1.15x |
Oscillogram, 100 x 100 |
8.05 ms |
6.70 ms |
1.20x |
Baseline scan, 2000 points |
1.33 ms |
1.29 ms |
1.03x |
Vacuum Hamiltonian |
75.0 µs |
32.1 µs |
1.9x |
The scalar paths gain most, because they were the ones paying dispatch overhead on every operation; the batched paths were already NumPy-bound, and gain only where an intermediate array was removed. The baseline scan needed care in the other direction: for a single Hamiltonian the old expression was a 3-by-8 matrix product that BLAS does better than three broadcasts, and keeping it as such is what took that case from a 6% regression back to parity.
[1.3.0] - 2026-07-31
Completes the vectorisation started in 1.2.0. The expansions could already take a stack of Hamiltonians, but the routines that build those Hamiltonians could not, so an energy scan still had to loop before the fast path ever saw it. Now the whole scan is two calls.
Added
hamiltonian_2nu_matter,hamiltonian_2nu_nsi,hamiltonian_2nu_livand their three-flavor counterparts accept an array of energies and return one Hamiltonian per energy, stacked along a leading axis — which is the shapeprobabilities_3nuconsumes. A scalar energy still returns a singlen-by-nmatrix. The matter potential may also be an array, for a scan across a density profile alongside the energy.The results are bit-for-bit identical to the previous loop, not merely close: the arithmetic per element is unchanged. A 200-point three-flavor energy scan in matter, Hamiltonians included, goes from 24.7 ms to 0.71 ms.
9 tests for the batched builders (
tests/test_vectorized_hamiltonians.py), including the end-to-end scan that the plotting modules perform, an energy-by-baseline grid, and an array-valued matter potential.
Changed
Each builder is now a single expression — the vacuum term divided by the energy, plus a constant matrix scaled by the potential — rather than a sequence of entry-by-entry additions. Indexing the energy with two trailing axes lets the same expression serve a scalar energy and an array of them. The matter potential multiplies a named module-level projector, and the NSI and LIV terms are written as the matrices they are.
The four plotting modules use the batched interface: eighteen list comprehensions become single calls, and the nine
[x[k] for x in prob]column extractions becomeprob[:, k]. All 42 figures were generated both ways and are byte-for-byte identical.This makes the computation in those modules about 35x faster but the suite only about 6% faster — 24.9 s to 23.3 s — because matplotlib dominates the wall time. The case for the change is consistency with the library’s own recommended usage, not speed.
The default branch is renamed from
mastertomain, and the documentation links that named it are updated. GitHub redirects the old URLs, but a redirect is not a reason to keep serving stale links from our own docs.
[1.2.0] - 2026-07-31
Performance. The exact SU(2) and SU(3) expansions now evaluate a whole stack of Hamiltonians and baselines in one pass, which is what a scan over energy or baseline, or an oscillogram, actually needs.
Nothing about the method changes, and nothing about the existing interface changes: a single Hamiltonian with a scalar baseline returns exactly what it returned before, at the same speed.
Added
evolution_operator_2nu,evolution_operator_3nu,probabilities_2nuandprobabilities_3nuaccept a stack of Hamiltonians of shape(..., n, n), an array of baselines, or both, broadcast against each other. The result is an array with the broadcast leading axes:(..., 9)for the three-flavor probabilities,(..., 3, 3)for the evolution operator. Measured against the equivalent Python loop over 2000 points:Scan
Speedup
Versus baseline: one Hamiltonian, many baselines
80-100x
Versus energy: many Hamiltonians, one baseline
20-30x
Oscillogram, 100 energies x 100 baselines
~80x
Two flavors, versus baseline
~50x
The two scans differ because the latent roots depend on the Hamiltonian alone: scanning one Hamiltonian over many baselines solves the characteristic equation once and then evaluates only the phases, whereas an energy scan changes the Hamiltonian at every point. The vectorised path agrees with the scalar one to 1.2e-13, and with
scipy.linalg.expmto 3.2e-14.For context, the vectorised expansion is now about as fast as diagonalising with LAPACK — one
eighplus phases for a baseline scan, batchedeighfor an energy scan. That is the honest comparison to draw: the SU(3) route’s advantage is that it is a closed form, not that it outrunseigh. What the vectorisation removes is the disadvantage it used to carry.24 tests covering the vectorised path (
tests/test_vectorized.py): agreement with the scalar path element by element, all three broadcasting patterns, unitarity and normalization on the batched path, empty and length-one stacks, and degenerate Hamiltonians mixed into an otherwise ordinary stack.A section on scanning in
docs/source/quickstart.rst, and a revised cost section indocs/source/methodology.rstcarrying the measurements above.
Changed
Degenerate spectra are still handled exactly on the vectorised path, but the branch cannot be taken elementwise inside a vectorised expression. The general formula is evaluated everywhere with vanishing denominators replaced by one, and the affected elements are then recomputed individually with the scalar routine. Degeneracy is measure-zero among floating-point Hamiltonians, so that fallback loop is empty in essentially every real use; the tests exercise it by constructing degenerate cases deliberately.
The scalar path is unchanged in speed. Dispatching between the scalar and vectorised paths is done by inspecting the argument types rather than by calling
numpy.ndim, which would convert a nested list to an array on every call: the first implementation did exactly that and cost the scalar path 47%, which is why the dispatch is written the way it is. Measured best-of-five, interleaved against the previous release, the overhead is now -1%.The scalar three-flavor routine is refactored so that its core takes the SU(3) coefficients and invariants rather than the Hamiltonian matrix, since the vectorised path reuses it for degenerate elements.
[1.1.0] - 2026-07-31
An audit of the code released alongside arXiv:1904.12391, covering the mathematics, the implementation, and the documentation.
The exact SU(2) and SU(3) machinery at the heart of the method was found to be
correct: the evolution operators agree with scipy.linalg.expm to 8e-15 over
200 random Hermitian Hamiltonians, they are unitary to 5e-15, and all 512
entries of the hard-coded d tensor reproduce
dijk = ¼Tr({λi,λj}λk) to 2e-16. The
defects were in the layers around it: a shortcut expression for the two-flavor
probability, the sign convention of the two-flavor vacuum Hamiltonian, and
essentially all of the validation and documentation infrastructure.
Two of the fixes below change published numbers. If you have results from a previous version, the ones to re-check are two-flavor probabilities for any Hamiltonian with a complex off-diagonal entry, and any two-flavor computation in matter, with NSI, or with LIV. Three-flavor results are unaffected.
Added
A regression test suite in
tests/, run withpytest: 132 tests covering the SU(3) algebra, both evolution operators, the probabilities, the sample Hamiltonians, the standard oscillation formulas, degenerate Hamiltonians, and the docstring examples. Previously nothing in the repository checked a computed probability against an independent calculation —test/holds worked examples and plot generators, but not a single assertion. Every defect fixed in this release has a test that fails without the fix.Handling of degenerate Hamiltonians in the SU(3) expansion. The coefficients uk come from Lagrange interpolation over the three latent roots, which divides by 3ψm² − |h|², the derivative of the characteristic polynomial; that factor vanishes exactly at a repeated root. Two cases are now handled explicitly and exactly: |h|² = 0, where the Hamiltonian is proportional to the identity and U₃ = 𝟙, and a doubly degenerate root, where the confluent limit of the same expansion collapses the spectral decomposition onto a single projector.
diag(1, 1, −2)now agrees withscipy.linalg.expmto 3.5e-15 where it previously returned NaN. The SU(2) counterpart takes the limit sin(|h|L)/|h| → L.pyproject.toml, so the package can be installed withpip install -e .. It keeps the flat module layout that the examples and the paper both assume, soimport oscprob3nuworks unchanged whether or not you install it. Optional extras separate matplotlib (figures), pytest and scipy (tests), and sphinx and numpydoc (documentation).A
Raisescontract ontensor_d, which now validates its indices.This changelog, rendered into the documentation by
docs/source/changelog.rst.
Changed
The standard oscillation formulas
probabilities_2nu_vacuum_std,probabilities_2nu_matter_stdandprobabilities_3nu_vacuum_stdnow take the neutrino energy in eV and the baseline in eV⁻¹, like the rest of the library. This changes the signature of three routines. They previously took GeV and km and folded the conversion into the rounded constants 1.27 and 2.54. Since these routines exist solely to validate the exact computation, the rounding mattered: the exact prefactor implied by this repository’s ownCONV_KM_TO_INV_EVis 1.266933…, so 1.27 overstates every oscillation phase by 0.242%, and near an oscillation minimum that becomes a large relative error in the probability. At L = 1000 km, E = 1 GeV the reference formula returned 0.004125 against the exact 0.003204 — a 29% discrepancy in the quantity meant to confirm the exact result. Agreement is now 4e-19.hamiltonian_2nu_coefficientsandhamiltonian_3nu_coefficientsreturn real floats. The coefficients hk of a Hermitian Hamiltonian are real by construction, but the routines returned a mixture: h₁ and h₂ as floats, h₃ and h₈ as complex, because they were built from arithmetic on the complex diagonal entries. The docstrings claimed the coefficients “are complex numbers, in general”, which is not true for a Hermitian Hamiltonian.The sample Hamiltonians are returned as complex
numpy.ndarraythroughout, rather than a mixture of nested lists and real arrays.The
dtensor is tabulated once at import time as a dense 8×8×8 array, and the star product is computed once per call instead of once per (k, m) pair. A singleprobabilities_3nucall used to evaluatetensor_d2048 times — 512 from the 8³ loop for ⟨h⟩, and 1536 becausestar(k, h_coeffs)sat inside the loop over m, rebuilding a baseline-independent vector three times for each of eight k. None remain on the hot path, and an evaluation goes from roughly 700 to roughly 40 microseconds. The algebra is unchanged, to 1e-14. (Corrected in 1.2.0: the commit message for this change, and the description of pull request #2, quote “757 to 308 microseconds”. That measurement was taken without a warm-up and is wrong; re-measured carefully, best of five runs, the figures are ~700 µs before and ~40 µs after — a factor of ~17 rather than ~2.5.)The library docstrings are rewritten in numpydoc format, so Sphinx can render them through
sphinx.ext.napoleonand thenumpydocextension; a trial autodoc build completes with no warnings. Each module gains__all__, an explicit units section, and — for the vacuum Hamiltonians — a statement of the sign convention and why it matters.Every worked example in the docstrings is now executable and is run as a doctest by
tests/test_docstrings.py, so the numbers quoted in the documentation cannot drift from what the code returns.The README’s quoted output for the two-flavor trivial example changes from
Pee = 0.93213, Pem = 0.06787toPee = 0.66063, Pem = 0.33937. That example uses H₁₂ = 1 + 2i, so it was itself an instance of the missing h₂ contribution below: the README was documenting the bug. The two-flavor coefficient example changes sign, following the vacuum-Hamiltonian correction. All three-flavor outputs were already correct and are unchanged.np.matrix.transposeis replaced by the@operator and.T/.conj().T. The three call sites transposed an ndarray through an unbound method ofnp.matrix, which has carried aPendingDeprecationWarningsince NumPy 1.15.
Fixed
probabilities_2nudropped the h₂ contribution. It computed Peμ = |h₁|²/|h|² sin²(|h|L), but the transition probability is |Uμe|² = u₁² + u₂², so the |h₂|² term is also required. Since h₂ = −Im(H₁₂) vanishes whenever the off-diagonal entry is real, oscillations in vacuum and in matter of constant density were unaffected and the error went unnoticed. It affected every Hamiltonian with a complex off-diagonal entry: NSI with a complex εeμ, and any CP-violating two-flavor scenario. The function’s own docstring example is such a case, documenting Peμ = 0.495179 while the code returned exactly 0.The two-flavor vacuum Hamiltonian used the opposite sign convention. It was built from M² = diag(Δm², −Δm²), assigning the larger mass-squared value to the first mass eigenstate, which yields the negative of the textbook Hamiltonian. In vacuum this is invisible — for a real Hamiltonian the probabilities are invariant under H → −H — but it stops being invisible the moment
hamiltonian_2nu_matteradds the matter potential to the ee entry, because that flips the sign of the potential relative to the vacuum term. The result satisfied, exactly, P[Hcode + V] = P[Htextbook − V]: every two-flavor computation in matter, with NSI, or with LIV returned the antineutrino probability when asked for the neutrino one, and the Mikheyev-Smirnov-Wolfenstein resonance sat on the wrong side. For θ₁₂ at a 3000 km baseline it appeared at 0.011 GeV instead of 0.162 GeV. The three-flavor Hamiltonian already used the correct convention.cos ξ in
hamiltonian_2nu_livwas computed assqrt(1 - sxi - sxi)instead ofsqrt(1 - sxi*sxi). For 0 < sin ξ < ½ the LIV term is not a rotation of diag(b₁, b₂) at all — at sin ξ = 0.3 it gives sin² + cos² = 0.49 — and for sin ξ ≥ ½ the square root turns negative and the whole Hamiltonian becomes NaN.globaldefssetsSXI12 = 0, the one value at which the typo is harmless, which is why every bundled example survived it.hamiltonian_2nu_nsisilently discarded the imaginary part of a complex εeμ. The two-flavor vacuum Hamiltonian is real, so the array wasfloat64and the in-place addition truncated the value, emitting only aComplexWarning.probabilities_2nu_matter_stdcomputed cos 2θ as sqrt(1 − sin²2θ), discarding its sign. For θ > π/4 — which includes the best-fit θ₂₃, whose cos 2θ is −0.164 — the matter resonance was placed on the wrong side.psi_rootsevaluatedpow(h2, -1.5), which is infinite when the traceless part of the Hamiltonian vanishes; every latent root, the evolution operator, and all nine probabilities came back NaN. It also passed the arc-cosine argument unclipped, so round-off could push it outside [−1, 1] andcmath.acoswould quietly return a complex angle, giving complex roots and a non-unitary evolution operator.tensor_ddispatched through a chain ofelifbranches with no finalelse, so any index outside 0–7 fell off the end and the function returnedNone. The failure surfaced far from its cause, as aTypeErrorin whatever arithmetic consumed the result.run_testsuite.py, the entry point documented in the README, crashed on its first plot withNameError: name 'pylab' is not defined. The four plotting modules callpylab.savefig, but import pylab withfrom pylab import *, which binds the names inside pylab and neverpylabitself. pyplot is now imported explicitly. The suite runs end to end and writes 42 figures.plot_probability_3nu_vacuum_vs_l_stdcalledprobabilities_3nu_std, which is defined nowhere in the repository, so the routine raisedNameErroron every invocation. Its baseline conversion was also inverted, dividing byCONV_KM_TO_INV_EVwhere it should multiply.The commented-out cross-checks against the standard formulas in the two paper-figure scripts referenced
D21_BFandD31_BF, which do not exist, and converted their baselines the wrong way. They are corrected but left commented, since enabling them changes published figures; the cross-check itself now lives intests/test_reference_formulas.py.Documentation errors: every worked example in the docstrings was stale (
hamiltonian_2nu_coefficientsdocumented[2j, 0.0, -1]and returns[0.0, -2.0, -1];hamiltonian_3nu_coefficientsdocumented h₈ = −1.732 and returns 4.041;evolution_operator_2nuprinted a 3×3 matrix copied from the three-flavor module; all nine probabilities ofprobabilities_3nuwere wrong). The placeholderarXiv:1904.XXXXXsurvived in eleven files. The routine listing ofhamiltonians3nunamed the two-flavor routines.globaldefsdocumented the Fermi constant in eV⁻¹ rather than eV⁻², describedCONV_EV_TO_Gas converting eV⁻¹ to grams, and labelled several inverted-ordering constants as normal ordering.tensor_dcited a reference that lived in the module docstring, which numpydoc scopes per docstring, leaving the citation dangling.
Removed
from numpy import *from the five library modules, where it shadowed the builtinsum,absandmin, and supplied the deprecatednp.matrixunder the bare namematrix.The duplicated
import cmath as cmathalongsideimport cmath, the unusedimport oscprob3nuinhamiltonians2nu, and the now-unnecessarycopy.deepcopyin the six Hamiltonian builders, which preceded annp.multiplythat already allocated a new array.The module-level
sigma_1,sigma_2,sigma_3,sigma,identityandbaselists inoscprob2nu, which no routine referenced.The stale “In the works: optimizing the code to run faster, using Cython” note and the Python 2 compatibility note from the README. The code requires Python 3.7 or newer and uses the
@operator.
[1.0.0] - 2019-04-30
Initial release, accompanying NuOscProbExact: a general-purpose code to compute exact two-flavor and three-flavor neutrino oscillation probabilities (arXiv:1904.12391).
Added
oscprob2nu: exact two-flavor oscillation probabilities for an arbitrary time-independent Hermitian 2×2 Hamiltonian, via the SU(2) exponential expansion.oscprob3nu: exact three-flavor oscillation probabilities for an arbitrary time-independent Hermitian 3×3 Hamiltonian, via the SU(3) exponential expansion, using thedtensor of the SU(3) algebra and the latent roots of the characteristic equation.hamiltonians2nuandhamiltonians3nu: sample Hamiltonians for oscillations in vacuum, in matter of constant density, in matter with non-standard interactions, and in a CPT-odd Lorentz invariance-violating background, together with the standard oscillation formulas.globaldefs: physical constants, unit-conversion factors, and the NuFit 4.0 best-fit oscillation parameters for both mass orderings.Worked examples in
test/, andrun_testsuite.pyto generate the figures.