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 PyFC are documented in this file.
[0.20.0]
A quality-and-packaging release rather than a feature one: no public API changes, and no change to any number PyFC computes. It adds test-coverage measurement, acts on the three real gaps that first measurement exposed, and fixes two defects and one packaging error that the work surfaced along the way.
The coverage figure is the visible outcome (64% at the start of the work to 81%
locally, higher in CI), but the more useful result is that the figure is now
honest. Three separate mechanisms were causing code that runs on every test to
be reported as untested – Numba’s @njit compilation, ProcessPoolExecutor
child processes, and ThreadPoolExecutor threads – and each, taken at face
value, would have sent someone to write tests for code that was already covered.
The measurement is now arranged so that none of those lie, and the reasoning is
recorded in pyproject.toml and scripts/run_coverage.sh so it does not have
to be rediscovered.
Of the new tests, the ones worth knowing about are the self-discovering structural sweeps over the configuration layer, which check that every analysis parameter lines up across all four places it is written down. Every new test in this release was verified to fail against a deliberately broken version of the code before being kept.
Upgrading from 0.10.0 — one manual cleanup step.
0.10.0, the version currently on PyPI, installed
tests,scripts,docsandxbranch_compareintosite-packagesas top-level packages (see the packaging entry under Fixed). 0.20.0 installs onlypyfc, but upgrading does not fully undo the damage, and neither doespip uninstall. Measured on a clean virtualenv: pip removes the files it recorded and prunes three of the four directories, but leaves an emptydocs/behind — and an empty directory is still an importable namespace package, soimport docsin that environment continues to resolve intosite-packagesand can shadow a realdocspackage belonging to another project.To check whether an environment is affected:
python -c "import docs; print(docs.__path__)"If that succeeds and points into
site-packageswith nothing underneath, the directory is PyFC’s leftover and is safe to delete. If it points anywhere else, or contains files, it belongs to something else — leave it alone.Environments that never installed 0.10.0 are unaffected, and nothing inside PyFC itself depends on these names.
Added
Test-coverage measurement.
pytest-covjoins thetestanddevextras, the settings live in[tool.coverage.run]inpyproject.tomlso a local run measures exactly what CI measures, and a separate Coverage job inpytest.ymlreports the figure on each run’s summary page and uploadscoverage.xmlas an artifact. Branch coverage is enabled: a line-coverage number flatters this codebase, because the orchestrator is a dispatch machine whose failure modes are branches only ever taken one way –strategy="grid"vs"adaptive", a resumed checkpoint vs a fresh run, the disconnected-interval reporting path, theif ultranest is Noneguards – and line coverage counts all of those as covered the moment the function runs at all. The job reports rather than gates: there is deliberately no--cov-fail-under, since a threshold invented before the first measurement either sits below the real figure and never fires, or above it and blocks unrelated work. It also carries a Codecov upload step gated on aCODECOV_TOKENsecret, skipped entirely when that secret is absent so the workflow stays safe to run in a fork; the secret now exists, so uploads are live, the coverage badge resolves, and Codecov comments per-PR diff coverage.scripts/run_coverage.sh, which splits the suite around Numba’s JIT.coverage.pytraces Python bytecode, and Numba executes none: it compiles@njitfunctions to machine code, so every line insidebinned.py– six compiled functions includingcalc_nll, the NLL everything else is built on – is reported as missed no matter how hard the suite hits it. Measured over the three test files that targetcalc_nlldirectly,binned.pyreports 10% with the JIT on and 90% underNUMBA_DISABLE_JIT=1; publishing the former would be actively misleading about the part of PyFC that most needs to be trustworthy. Disabling the JIT wholesale is not an option either, sincestrategy="grid"’s@njit(parallel=True)toy generators degrade into plain Python loops and the suite then fails to finish in any reasonable time. The script therefore runs the pathological files with the JIT on, everything else with it off, and letscoveragecombine the halves. The classification lives in the script rather than in the workflow so that there is exactly one place it can go stale, and its failure mode is benign: a newly added test file falls into the JIT-disabled half automatically, so it is measured correctly and at worst runs slowly, never silently skipped.Structural and behavioural tests for the configuration layer, closing what that first coverage run found:
config.pysat at 10% andgenerate_config.pyat 6%, meaning essentially nothing tested the CLI/JSON config layer – a layer edited on almost every release, whose correctness had until now been established by running the CLI by hand. They take those two modules to 100% and 98% respectively, and the package as a whole from 66% to 75%. The structural sweeps check that every analysis parameter lines up across all four places it is written down: the hardcoded defaults, theargparseflags, the interactive wizard’s questions, andcompute_fc_intervals’ own signature and defaults. They are self-discovering – each derives its subjects from the code rather than from a hardcoded list – so a parameter added later is swept without anyone remembering to extend a list, which is the only reason they would have caught the two real v0.10.0 bugs they are modeled on (scipy_methodreachingparse_argumentsbut never being added to the wizard, and a wizard default disagreeing with the onecompute_fc_intervalsactually uses). Alongside them, the layer’s statedDefaults -> JSON -> CLIprecedence chain is now tested in both directions, as isget_input’s rejection of a bad cast, a value outsidechoices, and a failed validator. Every one of these tests was verified to fail against a deliberately broken version of the code before being kept.Tests for the unbinned grid-search path (
tests/test_unbinned_grid.py), the second gap the measurement exposed.unbinned.pysat at 34%, and reading which lines were cold showed the shortfall was not scattered: four whole functions –conditional_fit_grid_unbinned_1d,conditional_fit_grid_unbinned_2d,generate_and_fit_toys_grid_unbinned_1dandgenerate_and_fit_toys_grid_unbinned_2d– were executed by nothing at all, despite every one being imported and dispatched to bycompute_fc_intervals. Across the entire suitelikelihood_type="unbinned"appeared exactly once, and that test usedstrategy="scipy", so the unbinned grid path shipped and was reachable by users while never being run. That combination is the one place in PyFC where profiling is exhaustive rather than numerical, which makes a silent error there especially hard to notice: a scan that quietly profiles over the wrong axis still returns plausible, finite, monotonic-looking numbers. The tests therefore compare against oracles computed independently in the test body – an explicit loop over the same grid callingcalc_nll_unbinneddirectly – and against the invariants that define a profile likelihood ratio: the conditional NLL never undercuts the unconditional minimum, and the two coincide exactly at the best-fit point. The end-to-end run reconstructs the expectedt_dataarray rather than asserting finiteness, becauset_datais clamped at zero and a mis-wired dispatch would otherwise hide behind a valid-looking array of zeros. Also closes the unbinned twins of two guardsbinned.pyhas had tested since v0.10.0: the NaN per-event-density rejection and the quadratic barrier for unphysical densities. Takesunbinned.pyfrom 34% to 100%; all 13 mutations tried against these tests were detected.Tests for the toy pool’s
ThreadPoolExecutorfallback (tests/test_toy_pool_fallback.py). The unbinned toy path dispatches each toy to a separate process, which imposes a requirement users are never told about at the call site: every callable they pass must be picklable, and a lambda or a closure is not.toys.pyalready recovers from that by warning and retrying the batch on threads, but nothing exercised the recovery, since it only runs once the pool has already failed. Both halves of its contract are now covered: that unpicklable closures still yield a complete set of test statistics, and that a genuine bug in user code surfaces uncaught on the thread retry rather than being converted into a warning plus a plausible-looking array of numbers. Known issue, found by writing them: on Python 3.9 that recovery does not recover – it deadlocks. Breaking the pool while Numba’s threads are live in the forked parent leaves a worker that never exits, and the implicitshutdown(wait=True)on the way out of the failed block waits on it forever. Observed as a two-hour CI hang on the 3.9 runner, with orphaned workers reported at cleanup, while 3.10 and 3.11 passed in minutes. The tests are skipped below 3.10 rather than removed, since what they document is a real exposure for users on 3.9 and not a defect in the tests.requires-pythonis still>=3.8; narrowing it, or making the fallback non-blocking, is a deliberate decision that has not been taken here.pyfc.__version__, read from the installed distribution’s metadata rather than written down a second time.pyproject.tomlis the single source: the package exposes it, anddocs/source/conf.pyimports that value instead of repeating theimportlib.metadatalookup, so there is one implementation and one place where the distribution-vs-import name distinction can be got wrong.tests/test_version.pypins the whole chain – that the attribute exists and is exported, that it is not the uninstalled fallback, that it agrees withpyproject.toml, that the docs take their version from the package, and that no module docstring reintroduces a hand-written version string. All five links fail silently when broken, which is how the docs came to advertise 0.1.0 for nine releases.Tests that actually run the three UltraNest fits (
tests/test_ultranest_fits.py). Installingultranestmovedoptimizers.pyfrom 59% only to 63%, and the cold lines said why: nothing ran the fits.test_ultranest_retry.pyexercises the retry wrapper against a mocked sampler, andtest_constraints.pyreaches the two conditional fits only in the zero-free-parameters case, which returns before sampling begins. Sounconditional_fit_ultranest’s body was entirely unrun and both conditional fits were entered but never sampled – roughly the last third of the module, shipped and reachable viastrategy="ultranest"/"hybrid". The new tests fit an Asimov dataset, where the generating parameters are an oracle rather than something read back out of the fit, and check MLE recovery, that the tested parameters are pinned exactly, the profile-likelihood invariants in both directions, thebounds_funchook, the unbinned likelihood branch, the toy generators’ UltraNest paths through both pools, and agreement with SciPy on the same optimum – the last being the check that would catch the two backends being wired to different likelihoods, which no single-backend test can see. Tolerances come from measurement: the sampler lands within 0.004 of the truth, and the test statistic reads about -1e-4 at the true value, so the invariant assertions carry that much slack rather than demanding exactly zero.optimizers.py59% -> 92%,toys.py78% -> 88%.Tests for the JSON results export (
tests/test_json_export.py).orchestrator.py’s cold lines were not in the statistics but in the serialisation layer:NumpyEncoder’s type conversions and the entire 2D branch of the payload were unrun. That layer is what a user actually reads, and aTypeErrorthere arrives at the very end of a long run, after all the expensive work is done. Each encoder branch is now checked individually, along with NaN/infinity handling and the fallback that must keep rejecting genuinely unserialisable objects, and a real 2D run is read back from disk and re-dumped through a stockjson.dumpto prove nothing NumPy-typed survived.orchestrator.py84% -> 89%.Tests for the last two cold paths in
orchestrator.py(tests/test_2d_resume_and_sparsify.py): the 2D half of the checkpoint-resume block, andsparsify_grid’s nearest-neighbour interpolation fallback. Both were unrun because of a condition no existing test satisfied –test_checkpoint_resume.pypassescompute_2D_intervals=Falseon every run, and the fallback is theelseofif SCIPY_AVAILABLE, which never fires because SciPy is a hard dependency of the test extra. 2D contours are the expensive half of a run, so they are exactly what a user restarting after a walltime kill most needs back, and silently recomputing them would look identical to working. Both tests needed a second attempt to become meaningful: the resume test first used two parameters and asserted no 2D fit was recomputed, which is the wrong claim – checkpoints are written per completed pair, so a crash mid-pair legitimately loses that pair – and now uses three parameters and asserts on the amount of work skipped. The sparsify test first used a 5x5 grid, at which size the edge-refinement pass re-evaluates every cell and overwrites the interpolation entirely, so deleting the fallback changed nothing; at 15x15 the interior survives, and removing it leaves 79 of 225 cells at NaN.orchestrator.py89% -> 94%, package total 92% -> 93%.A Downloads badge, in
README.mdand on the documentation homepage, matching the placement used in the author’s Magnus project: between the Python-version and ruff badges. It reports PyPI installs ofPyFeldmanCousinsvia pepy.tech. Note the figure it shows counts mirrors and CI traffic alongside real users, so it is an upper bound rather than a readership number, and until 0.20.0 ships it reflects 0.10.0 alone.
Changed
orchestrator.py’s 13E701lint findings are fixed, soruff checknow reports zero. They wereif condition: statementone-liners in the logging helper, theS_sumw2/B_sumw2defaulting, and the checkpoint-restore block – style rather than defects, but they were also the only findings standing between the project and a clean lint signal, andlint.ymlrunsruff checkwithcontinue-on-error: true, so they could never fail a build and were never going to be fixed by accident. Verified purely cosmetic rather than assumed: the file’s abstract syntax tree is byte-identical before and after, the suite passes, and the cross-branch numerical harness reports zero mismatches.ruff format --checkis untouched and still reports 49 files, as it did before – that is a separate, much larger decision about reformatting a codebase that predates the formatter.Releases are gated on the test suite.
publish.ymltriggers onrelease: publishedand went straight to building and uploading;pytest.ymlruns on pushes and pull requests, not on releases, so a release cut from a commit with a failing suite reached PyPI exactly as readily as a green one. Atestjob now runs the full matrix on the commit being published andbuild-and-publishcarriesneeds: test. It repeats the matrix rather than linking topytest.ymlviaworkflow_run, which would tie publication to whatever that workflow last did on some branch instead of to the commit actually going out. This matters more than a normal CI gate because the action is irreversible: a PyPI release can be yanked but its version number can never be reused, so a bad upload is corrected only by burning the number.Seven README links now point at absolute GitHub URLs. The example-notebook links were repository-relative, which renders correctly on GitHub and 404s on PyPI, where there is no file tree beside the description. Verified against the built wheel’s metadata rather than the source file: the published description now contains no repository-relative links at all. Badges and the logo were already absolute.
PyPI classifiers list the Python versions actually tested. The package advertised only a generic
Programming Language :: Python :: 3, so it did not appear under any version filter on PyPI despite CI running three. Added 3.9, 3.10 and 3.11 – and deliberately not 3.12, which works locally but that nothing tests; advertising an untested interpreter is the mistakerequires-python = ">=3.8"already made once. Also addedOperating System :: OS Independent.docs/source/changelog.rstnow renders this file directly instead of mirroring it by hand. It was a 1037-line RST duplicate of these 988 lines, kept in step manually and policed byscripts/check_changelog_sync.pyand a dedicated CI workflow. The check worked – it caught real drift twice while 0.20.0 was being written – but it could only ever detect divergence, never prevent it, and every entry had to be written twice. The page is now eight lines that.. include::the Markdown throughmyst-parser, so this file is the single source and there is nothing to keep in step and nothing to run afterwards.scripts/check_changelog_sync.pyand.github/workflows/changelog-sync.ymlare removed as obsolete, andmyst-parserjoins thedocsextra. The trade is presentational and was checked entry by entry before making it: six.. warning::admonitions become inline bold text and fifteen:doc:/:ref:cross-references become plain prose. No information is lost – every one of those passages already existed in this file, since the sync check only ever compared each bullet’s first four words and the RST’s extra wording was rendering rather than content. The docs build emits exactly the same 28 warnings as before, none of them from the changelog page.The
Last modified: vX.Y.Zline is gone from every module docstring. It was hand-maintained and nothing could derive it, so it drifted exactly as a duplicated number always does: when 0.20.0 was prepared, seven of the nine modules still readv0.10.0, and no reader could tell whether that meant “genuinely unchanged since then” or “somebody forgot to update it” – which makes the annotation worse than absent, because it invites a conclusion it cannot support. Git already records when a file last changed, accurately and without anyone having to remember.pyfc.__version__is the one version a caller needs, and a test now fails if a hand-written version string reappears in a module docstring. TheCreated:lines stay: those are fixed historical facts that cannot go stale.requires-pythonnarrowed from>=3.8to>=3.9. The 3.8 claim was never checked by anything: CI has only ever run 3.9/3.10/3.11, and this release is the first time a real 3.8 environment was built and the suite run against it. The result is that 141 of 148 tests pass on 3.8, buttoys.py’sProcessPoolExecutorrecovery path deadlocks – it hangs indefinitely instead of falling back to threads. That path is reachable by any user who passes a lambda or a closure, so it is not an obscure corner. It is also not fixable from here: CPython rewroteconcurrent.futures.processin 3.9 (_ExecutorManagerThread, plus thecancel_futuresargument toshutdown), and 3.8’s legacy queue-management implementation is what hangs – the same non-blocking shutdown that fixes 3.9 does not rescue it. With 3.8 also end-of-life since October 2024, the declaration now matches what is actually tested. Users on 3.8 keep resolving to 0.10.0.CI now runs on
devanddev-*branches, not onlymain. The previous filter meant a topic branch got no CI signal at all until a pull request was opened, which is the point at which a failure is most expensive to discover.The Coverage job installs
.[test,optimizers]while the Python-version matrix deliberately stays on.[test]alone. Withoutultranestthe three*_ultranestfit functions count as missed and the reported figure foroptimizers.pyunderstates its real coverage, which invites someone to go and “fix” coverage that is not actually missing; but thatultranestis optional is a promise PyFC makes to its users, and the matrix is where that promise is checked.
Fixed
unconditional_fit_ultranestnow returns anndarraylike every other fit function. It passed UltraNest’s result dict straight through, so it alone among the six returned a plain Python list.compute_fc_intervalsassigns whichever fit it called directly intoresults["best_fit"], which it initialises as an ndarray, so the type of that entry silently depended on which strategy ran. Nothing broke, because the value is only indexed and serialised – but any NumPy operation added to it later would have worked under three strategies and failed under the fourth, at the end of a long run. Found by writing the first test that actually calls the function.The wheel no longer installs
tests,scripts,docsandxbranch_compareas top-level packages.[tool.setuptools.packages.find]hadwhere = ["."]with no include filter, so setuptools treated every top-level directory in the repository as something to install: the 0.10.0 wheel declares eight top-level names and ships four of them with real content. The practical consequence is thatpip install PyFeldmanCousinsdropped a top-leveltestspackage into the user’ssite-packages, where it shadows or is shadowed by any other project’stests– a collision that is hard to diagnose precisely because nothing about it points back at this package. Nowinclude = ["pyfc*"], verified by building the wheel before and after and installing it into a clean virtualenv:import pyfcresolves tosite-packagesand none of the other names are importable. Thepyfc-configentry point and the bundled licence are unaffected, and the sdist still carriestests/so a source checkout can run the suite.Coverage now traces inside
ProcessPoolExecutorworkers and threads.toys.pyreported 62% with_worker_unbinned_toycounted as entirely missed. It was not missed: the end-to-end unbinned test dispatches every toy through it, but coverage does not follow child processes by default. This is the same class of error as the Numba problemscripts/run_coverage.shexists for, and the more dangerous of the two: the Numba figure was absurd enough to force a second look, whereas a plausible 62% instead invites someone to write tests for code that is already covered. Note thatconcurrencyreplaces coverage’s default rather than adding to it, so both values are needed – naming onlymultiprocessingstops tracing threads, andtoys.py’s binned path runs every toy through aThreadPoolExecutor. Measured overtest_adaptive_toys.py+test_unbinned_toys.py, which between them exercise both pools: 58% with the default, 61% withmultiprocessingalone (a different blind spot, not a smaller one), 70% with both. No new test was written to produce any of that; the lines were always running.pyfc-configno longer spins forever when input runs out.get_input’s catch-allexcept Exceptionswallowed theEOFErrorthatinput()raises on a closed or exhausted stdin, andwhile Trueretried immediately. Since stdin stays at EOF the retry fails identically with no delay, so the loop span at full CPU re-printing its prompt: ~7.4 million iterations in 5 seconds, and one such process was found still running after nearly three hours. That made the wizard unusable anywhere without a terminal – CI, cron, a container, or behind a pipe whose input ran out – andyes "" | pyfc-confighid it completely, becauseyesnever stops. EOF is now caught ahead of the catch-all and ends the run with exit status 1, naming the question that ran out, which is the useful detail when a script piped too few answers. It is deliberately not treated as “accept the default”: that would write a configuration the user never saw, and a wizard that invents answers when nobody is listening fails more quietly than one that spins.The toy pool’s fallback no longer deadlocks on Python 3.9. When the
ProcessPoolExecutorbroke, thewithblock calledshutdown(wait=True)on the way out – waiting on exactly the workers that were wedged, rather than falling through to theThreadPoolExecutorretry a few lines below. On 3.9 that never returned: a CI run sat for two hours before being cancelled, with orphaned workers reported at cleanup, while 3.10 and 3.11 passed in minutes. The pool’s lifetime is now managed explicitly and the two exits differ: the success path still waits, since those results are the return value, while the failure path abandons the pool withshutdown(wait=False). Verified against a real 3.9 + Numba environment, where the reproduction went from a 120-second timeout to passing in 0.95 s;tests/test_toy_pool_fallback.pyis no longer skipped below 3.10. Note the failure mode was a hang rather than a red test, and it required the full pytest context – neither a stdlib reduction of the sametry/with/map/exceptshape nor a standalone script calling the same code path reproduces it – so what actually guards against a regression is thetimeout-minutesnow set on the CI matrix job.
[0.10.0]
Hardens the optimizer/likelihood layer against joint (non-box) parameter
constraints, informed by a concrete failure case from a downstream project
that needed to fit flavor fractions subject to f_e + f_mu <= 1. Also
removes the S_model/B_model templating mechanism in favor of a more
general pdf_components list, and adds support for N-dimensional binned
data.
BREAKING CHANGES
S_model/B_modelremoved entirely fromcompute_fc_intervalsand every optimizer/toy-generation function (unconditional_fit_scipy,conditional_fit_1d_scipy,conditional_fit_2d_scipy,unconditional_fit_ultranest,conditional_fit_1d_ultranest,conditional_fit_2d_ultranest,generate_and_fit_toys_python, and the binned/unbinned grid-search and toy-generation functions inbinned.py/unbinned.py). An audit established that for binned models,S_model/B_modelwere pure pass-through intocompute_rates_func–calc_nllnever read them itself. For unbinned models they were a hardcoded signal/background pair, which was itself an unnecessary rigidity on top of being pass-through.compute_fc_intervalsnow takes onlydataandgridsas required positional arguments (down from 4:data, S_model, B_model, grids). Migration hazard: becausegridsmoves from position 4 to position 2, any caller still using positional arguments will silently pass the wrong value into the wrong parameter instead of raising an error. Convert all calls to fully keyword-form (compute_fc_intervals(data=..., grids=..., compute_rates_func=..., ...)).New
pdf_componentsmechanism replacesS_model/B_modelfor unbinned models.pdf_componentsis alist[callable]of any length (no longer hardcoded to exactly 2), passed tocompute_fc_intervalsand to every optimizer/toy function. Each is evaluated exactly once per fit aspdf(data)and the resulting arrays are reused across every subsequent NLL evaluation in that fit – the same caching behavior as before, generalized beyond a fixed signal/background pair. Required (and validated) whenlikelihood_type="unbinned"; ignored with a warning for"binned".compute_rates_funcsignature changes for both likelihood types:Binned:
(params, S_template, B_template, S_sigma2, B_sigma2)becomes(params, S_sumw2, B_sumw2). Fixed template arrays that used to be passed as arguments must now be referenced via closure (module-level constant or nested-function capture) instead – this is already numba-@njit-compatible and is the pattern used throughoutexamples/pyfc_joint_constraints_tutorial.ipynb’s own templates.Unbinned:
(params, s_probs, b_probs)becomes(params, probs), whereprobsis alist[np.ndarray], one entry perpdf_components[i], in the same order.
S_sigma2/B_sigma2renamed toS_sumw2/B_sumw2everywhere they appear (compute_fc_intervals, every optimizer/toy-generation function,calc_nll, and the binned grid-search functions). These arrays are the per-bin sum of squared MC weights (sum(w_i^2)) – the standard variance estimator for a weighted MC sample – used by the finite-MC (Poisson-Gamma) correction. TheS_/B_prefixes now dangle less meaningfully withoutS_model/B_modelas their counterpart, but thesigma2suffix was also imprecise (it names a derived quantity computed bycompute_rates_func, not what these arguments actually hold):sumw2is unambiguous and matches the term HEP users already know fromTH1::Sumw2()-style per-bin weight-squared tracking.data,mu, andsigma2now support arbitrary N-dimensional shapes for binned models, not just flat 1D vectors – a genuine 2D(E, cos_theta)histogram (or any other shape) can be evaluated directly, with no need to flatten it yourself first.calc_nllflattens internally before summing, so the NLL is identical regardless of how the bins are laid out spatially.grids(the parameter scan, as opposed to the data) is unaffected – it was already N-parameter-general and remains a list of 1D per-parameter arrays. Unbinned events already supported N-D feature vectors (data.shape == (n_events, n_features)); this is now documented as the indexing convention, along with a gotcha: bootstrapping an N-D MC pool requires index-based resampling (idx = np.random.choice(len(mc_pool), size=n, replace=True); toy_events = mc_pool[idx]), sincenp.random.choiceapplied directly to an N-D pool silently samples along the wrong axis.Migration: see the rewritten Quick Start Guide in
README.mdanddocs/source/quickstart.rstfor both binned and unbinned examples under the new signatures.fc_results.json’s 1Dinterval_boundsschema changed from a flat[lo, hi]pair to a list of[lo, hi]pairs, one per disconnected accepted interval. Previously,_save_fc_jsoncomputed a single[min(accepted), max(accepted)]span across every accepted grid point. Under the Feldman-Cousins unified construction, the accepted region for a parameter can in principle be genuinely disconnected near certain physical boundaries (e.g. a non-monotonic rate function crossing the data at more than one point); a flat min/max span silently merges those separate intervals into one, including whatever rejected gap sits between them – misrepresenting the actual confidence region. A new_find_contiguous_intervalshelper inorchestrator.pynow scans the scan-orderedtest_points/acceptedarrays for every maximal contiguous accepted run and reports each as its own[lo, hi]pair. This always applies, even in the single-interval case (still a one-element list,[[lo, hi]], not flattened) – a consistent schema regardless of how many disjoint pieces the accepted region has. Migration: any downstream code readinginterval_boundsas[lo, hi]directly (e.g.lo, hi = interval_bounds) must be updated to iterate the list of pairs instead. 2D intervals are unaffected – nointerval_bounds-style precomputed field existed there before or now;2d_intervalsstores the full accepted boolean grid, from which any region shape can already be reconstructed directly.fc_results.npzandgenerate_corner_plotare both unaffected – neither ever consumedinterval_bounds(the.npzstores raw per-point arrays, and plotting shades directly from those, not from the JSON’s precomputed field). Documented in README.md’s “Stored Results & Custom Plotting” section anddocs/source/outputs.rst, with a worked example added topyfc_algorithmic_features_tutorial.ipynb. New tests for_find_contiguous_intervalsand the JSON-writing logic intests/test_disconnected_intervals.py, including a genuine (not hand-mocked) end-to-end disconnected-region example.
Changed
examples/pyfc_tutorial.ipynbreplaced by four focused notebooks:pyfc_quickstart_tutorial.ipynb(binned + unbinned, the runnable counterpart to the README Quick Start Guide),pyfc_high_dimensional_ tutorial.ipynb(scaling to 5 and 10 parameters, including the combinatorial cost of 2D contours),pyfc_algorithmic_features_ tutorial.ipynb(sparsify_grid/smooth_1d/smooth_2d/use_finite_mc_correction_binned, each demonstrated on vs. off), andpyfc_strategy_comparison_tutorial.ipynb("grid"/"scipy"/"hybrid"timing comparison, plus building a custom Matplotlib contour directly from PyFC’s saved.json/.npzoutput). The old monolithic notebook’s heaviest cells (n_toys=100, dense 40-point grids,sparsify_grid=False, 2D contours) took 20+ minutes each to execute, which made it impractical to keep re-running end-to-end; the four replacements use small grids/n_toys(in the style already established bypyfc_joint_constraints_tutorial.ipynb) so each runs in well under a minute, and each carries more narrative markdown/comments explaining why a cell does what it does, not just what it does.calc_nll(binned.py) andcalc_nll_unbinned(unbinned.py): behavior change in the unphysical (mu_i <= 0/p_events[k] <= 0) region. Previously, both functions returned a flat constant penalty (1e10) the instant a bin/event’s expected rate went non-positive –binned.py’s version was additionally an early return that discarded whatever NLL had already accumulated from other, physical bins in the same call. Becausescipy.optimize.minimize(..., method='L-BFGS-B')estimates gradients by finite differences, that flat region reads as “gradient ~ 0, already converged,” and could permanently trap the optimizer if its starting guess (the bounds midpoint, by default) landed there. Both functions now instead add a smooth, continuously-varying contribution for the unphysical region: a continuous extension of the real NLL term at a tiny positive floor, plus a quadratic barrier that grows with how far into the unphysical region the point actually is. NLL values in the previously-unphysical region will differ from prior runs (they were an arbitrary constant before, so this is not a regression, but anyone diffing raw NLL outputs against pre-fix runs where the fit passed through an unphysical region should be aware of this.) For all physical inputs (mu_i > 0/ allp_events[k] > 0), output is practically identical to before (matches to within 1e-12 relative/absolute tolerance; seetests/test_smoothing.py).Example notebooks renamed with a
01-06numeric prefix reflecting suggested reading order, and a new “Tutorial Notebooks” section added to README.md (plus a mirroringdocs/source/tutorials.rst, linked from the Sphinx toctree) describing what each one covers and when to reach for it. Old name -> new name:pyfc_quickstart_tutorial.ipynb->01_pyfc_quickstart_tutorial.ipynb,pyfc_high_dimensional_tutorial.ipynb->02_pyfc_high_dimensional_tutorial.ipynb,pyfc_non_contiguous_data_tutorial.ipynb->03_pyfc_non_contiguous_data_tutorial.ipynb,pyfc_algorithmic_features_tutorial.ipynb->04_pyfc_algorithmic_features_tutorial.ipynb,pyfc_strategy_comparison_tutorial.ipynb->05_pyfc_strategy_comparison_tutorial.ipynb,pyfc_joint_constraints_tutorial.ipynb->06_pyfc_joint_constraints_tutorial.ipynb. Migration: update any bookmarks, scripts, or CI steps that reference the old filenames directly (nothing in this repository’s own code or CI did). Each notebook’s own “Next steps” cross-references and every mention in README.md/docs/source/installation.rstwere updated to match; past CHANGELOG entries below that predate this rename still use the old names, as a historical record of what was true when they were written.README.md’s “Salient Features” list, and its (previously absent) counterpart on the Sphinx docs homepage, audited for accuracy against the current codebase and augmented. Fixes: “Advanced optimizers” now names the
"hybrid"strategy explicitly (it was already documented elsewhere and used throughout the example notebooks, just missing from this list and from both files’ shared intro paragraph, which said only “SciPy, UltraNest, Grid”); “Dynamic 2D sparsification” no longer claims an unqualified “radically reducing computational overhead” now thatsparsify_gridis known to default toFalseand not scale reliably past ~20x20 grid nodes per axis (both established earlier in this release). Additions: “Unified binned and unbinned analysis” now mentions N-dimensional/non-contiguous binned data support; “Exact coverage” now mentions disconnected-interval reporting; a new “Joint/ simplex-constrained parameters” bullet coversbounds_func/constraints, previously undocumented at this level despite having its own dedicated tutorial notebook; “Massive parallelization” now mentions the validatedadaptive_toys/toy_batch_sizebehavior from this same release.docs/source/index.rstgained its own “Salient Features” section mirroring README.md’s (it previously had none), plus an “Important Links” pointer to :doc:tutorials.
Fixed
calc_nll(binned.py) and the two grid-strategy toy generators (generate_and_fit_toys_grid_1d/_2d) crashed on non-contiguous N-D binned arrays – e.g. a transposed histogram (data.T) – with a low-levelnumbaNotImplementedError: incompatible shape for arrayinstead of working correctly.numba’snopython-mode.reshape(-1)only accepts C-contiguous input, and this was never triggered during the original N-D-support work, so the guard was never added. Since the entire point of N-D binned support is letting users lay out bins however is natural for them, this is a plausible real-world trigger, not a contrived edge case: anyone who transposes their histogram before callingcompute_fc_intervalshit this crash. Fixed by copying to a contiguous layout first withnp.ascontiguousarray(...), a no-op (zero cost, no copy) on the already-contiguous common path. Covered by new regression tests intests/test_nd_binned.py, both at thecalc_nlllevel and end-to-end throughcompute_fc_intervalsunder both the"scipy"and"grid"toy-generation strategies.save_directory’s default was inconsistent across the codebase.compute_fc_intervals’s own Python keyword default ("fc_output") andgenerate_config.py’s interactive-wizard default disagreed withpyfc/config.py’s CLI/JSON-config default ("output/example_fc_output"), which every doc and example already assumed was the default. Callingcompute_fc_intervalsdirectly without a config dict or an explicitsave_directory– the style the README’s own Quick Start Guide uses for its inline code snippets – silently wrote output somewhere different from what the docs describe. Unified all three to"output/example_fc_output".strategy="ultranest"/"hybrid"(2D intervals) crashed withIndexError: too many indices for array: array is 1-dimensional, but 2 were indexed, raised insideultranest.ReactiveNestedSampler’s own internal_find_strategymethod (confirmed present through at least UltraNest v4.5.0 and the current GitHub master, so this is not fixed by upgrading). An internal random subsample can occasionally select too few saved iterations, producing a degenerate 1D array where UltraNest’s own code expects 2D. Since this stems from UltraNest’s own internal exploration randomness rather than anything about the model being fitted,optimizers.py’s three*_ultranestfit functions now retry with a fresh sampler instance on this specific error (new_run_ultranest_with_retryhelper, mirroring_minimize_with_restarts’s existing retry-on-failure pattern for the scipy path; covered by new tests intests/test_ultranest_retry.py). Separately,pyfc_strategy_comparison_tutorial.ipynb’s grid/n_toyswere scaled down, since its original size didn’t finish even in 30 minutes withultranestactually installed – as far as could be determined, this strategy combination had never been exercised end-to-end before.sparsify_griddefaulted toTrueincompute_fc_intervals’s own Python keyword default, whileconfig.py’s CLI/JSON default and every doc already documentedFalseas the default. Anyone callingcompute_fc_intervalsdirectly without an explicitsparsify_gridargument silently got the (buggy, see below)Truebehavior. Unified toFalseeverywhere, includinggenerate_config.py’s interactive wizard, which independently defaulted to"y".sparsify_grid=True’s 2D boundary-refinement pass was a complete no-op.eval_2d_point’s memoization guard checked2d_t_criticalforNaN-ness to decide whether a cell had already been evaluated, but the coarse-to-fineRectBivariateSplineinterpolation step fills in2d_t_criticalfor every cell (real or not) before the refinement pass runs – so the guard always fired, and every non-coarse cell’s2d_t_datastayedNaNforever. Sinceaccepted = t_data <= t_criticalis alwaysFalseagainstNaN, this silently excluded the vast majority of the grid from the accepted region regardless of its true classification (confirmed by direct reproduction: 220/256 cells force-excluded on a 16x16 grid, accepted count 8/256 instead of the true, much larger region). Fixed by keying the guard off2d_t_datainstead, which is only ever set by an actual conditional fit and untouched by interpolation. New regression tests intests/test_sparsify_grid.py. Known remaining limitation: boundary refinement is still a single, non-iterative pass with a 1-cell-wide halo around ~5-per-axis coarse nodes, so for grids much larger than ~20x20 per axis (the scale this feature is meant for) it still under-counts the true accepted region – see the caveat now documented incompute_fc_intervals’s docstring and README’s “Contour Edge Tracing” section. Tracked as follow-up work.compute_fc_intervals’s own Python keyword defaults disagreed with README.md’s documented defaults for six parameters:n_toys(2000vs. documented500),save_log(Falsevs.True),smooth_1d/smooth_2d(False/Falsevs.True/True), andcl(resolved to[0.90]when omitted, vs. documented[0.68, 0.90]). Anyone callingcompute_fc_intervalsdirectly without a config dict – again, the README’s own Quick Start Guide style – silently got different behavior than what the docs describe (e.g. 4x fewer toys, no persistent log, unsmoothed plots, only a single CL). Unified all six to match README.md.num_cores,output_file, andparam_namesare deliberately left as their existingNone-sentinel defaults (auto- detect hardware threads, skip writing a results file, and auto-generateparam{i}names matching the model’s actualn_params, respectively) rather than hardcoded to README’s example-config values, since those three sentinels encode real auto-detect/opt-out behavior that a literal copy would remove or, forparam_names, actively break on any model wheren_params != 3; this is now spelled out explicitly in the docstring for each. Five example notebooks that omittedsave_log/smooth_1d/smooth_2d(relying on the old implicit defaults) were re-executed to refresh their embedded (now-smoothed) plot images:pyfc_algorithmic_features_tutorial.ipynb,pyfc_high_dimensional_tutorial.ipynb,pyfc_joint_constraints_tutorial.ipynb,pyfc_non_contiguous_data_tutorial.ipynb,pyfc_quickstart_tutorial.ipynb, andpyfc_strategy_comparison_tutorial.ipynb.A razor-thin (~1e-12-wide) NLL discontinuity in the unphysical-region smoothing (
binned.calc_nll,unbinned.calc_nll_unbinned), left over from the smoothing fix documented above. For a bin/event with real observed data, the physical branch’s Poisson term correctly diverges to+infasmu_i/p_events-> 0+, but the unphysical branch’s base term is evaluated at a fixed floor (mu_floor/p_floor=1e-12) rather than at the actual value, and was triggered only atmu_i <= 0/p_events <= 0. That left the tiny physical sliver(0, 1e-12)on the diverging physical branch, so crossing from+epsilonto-epsilonmade the NLL drop right at the boundary instead of continuing to climb – the opposite of the smoothing fix’s whole purpose. Very unlikely to be hit in practice (no realistic optimizer step lands within1e-12of exactly zero), but fixed defensively anyway: the trigger is nowmu_i <= mu_floor/p_events <= p_floor, so the transition point is single and consistent, approached from both directions.mu_floor/p_floorare astronomically smaller than any physically meaningful value, so this has no practical effect on real fits (verified: existing backward-compatibility tests for the physical-branch formula,test_binned_calc_nll_identical_for_physical_bins/test_unbinned_calc_nll_identical_for_physical_events, are unaffected). New fine-resolution monotonicity-sweep tests intests/test_smoothing.pycovering the exact old discontinuity point.README.md’s and
docs/source/methodology.rst’s displayed finite-MC correction formula did not matchcalc_nll’s actual implementation at all (not just a missing citation): the docs’ formula had nolgamma/Gammaterms whatsoever, while the code implements an exact Negative Binomial log-likelihood built fromlgamma. Numerically evaluating both at the same(mu, sigma2, n)gives substantially different values – the docs’ formula was simply wrong, not an approximation of the code. Checked the code’salpha = mu^2/sigma^2 + 1(previously flagged as possibly a bug, since a naive mean-and-variance-matching derivation givesalpha = mu^2/sigma^2with no+1) against Argüelles, Schneider & Yuan, “A binned likelihood for stochastic models”, JHEP 06 (2019) 030 [arXiv:1901.04645]:calc_nll’s formula is exactly the paper’sL_Eff(Eq. 3.16, their main, recommended result) – the+1is a deliberate, load-bearing part of that specific (best-coverage) parameterization, not a bug, and no code change was needed. New hand-computed-reference tests intests/test_finite_mc_likelihood.pyverifycalc_nll’s finite-MC branch against the paper’s Eq. 3.16 directly. Added an explicit citation and provenance note tocalc_nll’s docstring (this paper was already cited indocs/source/methodology.rst/docs/source/refs.biband README.md’s own “Methodology References” section, just not from the code itself), and corrected both docs’ displayed formula to match the paper/code exactly. Also corrected README.md’s feature-list description, which called this the “Beeston-Barlow technique” – a different, profiled (not marginalized) likelihood that the paper itself distinguishes fromL_Eff(and which this codebase does not implement).adaptive_toysandtoy_batch_sizewere documented, threaded throughconfig.py/generate_config.py’s CLI/JSON/wizard machinery, and part ofcompute_fc_intervals’s own signature – but neither was ever read anywhere to make an actual decision. README’s own OOM-troubleshooting advice (“reducetoy_batch_sizefrom 200 to 50”) would have had zero effect. Made both real, forstrategyin"scipy"/"ultranest"/"hybrid"(toys.generate_and_fit_toys_python); no effect forstrategy="grid", whose toy generators are a single vectorizednumba@njit(parallel=True)call per point rather than a pool of per-toy tasks, not a natural fit for either batching or a sequential early-exit check.toy_batch_size: toys are now submitted/collected from theThreadPoolExecutor/ProcessPoolExecutorin batches of this size instead of oneexecutor.mapcall for alln_toys, bounding how many toy datasets / in-flight results are alive at once (matters most forlikelihood_type="unbinned", matching README’s OOM scenario). Same totaln_toysare always generated – purely a dispatch/memory change, zero effect on results (proved directly: for binned toys, the same seed produces bit-for-bit identical output regardless oftoy_batch_size, since the underlyingnp.random.poissoncall that generates the toy data happens once up front either way).adaptive_toys: after eachtoy_batch_sizebatch, computes the running fraction of toys witht_toy >= t_data(a p-value estimate for that point’s accept/reject decision) and a strict 99.9% Wilson score confidence interval around it. If that interval lies entirely on one side of the target significancealpha = 1 - CL(using the largest requestedclwhen several are given, so stopping there guarantees every less-stringentcl’s decision is also settled), the verdict cannot plausibly flip with more toys and generation stops early for that point. Never considered before 100 toys (toys.ADAPTIVE_MIN_TOYS), regardless of how extreme the running estimate looks – small-sample binomial confidence intervals are unreliable. Meaningfully saves time only for points far from the accept/reject boundary; points near it correctly run the fulln_toys.compute_fc_intervals’s own quantile-index computation (t_stats[int(cl * n_toys)]) now uses the actual number of toys generated for that point instead of the originaln_toys, sinceadaptive_toyscan make that shorter.Validated, not just implemented (per the risk this fix was flagged with – a wrong stopping rule would silently bias confidence intervals, worse than the previous do-nothing state): new tests in
tests/test_adaptive_toys.pycover the Wilson-interval/stopping- decision primitives directly, confirm well-inside/well-outside points stop at exactly the minimum toy count while a point near the true critical value runs the fulln_toys, and – the real test of “did this introduce bias” – confirmadaptive_toys=Truereaches the same accept/reject verdict asadaptive_toys=Falsewith the samen_toyscap, both for isolated toy-generation calls across many independent trials and for a full end-to-endcompute_fc_intervalsrun. README’sadaptive_toys/toy_batch_sizetable rows, the OOM- troubleshooting paragraph, anddocs/source/configuration.rst’s table are updated to describe what actually happens now, plus a new README “Adaptive Toy Generation” section (mirroring the existing “Contour Edge Tracing” section’s depth).
generate_config.py’s interactive wizard still offered four default answers that disagreed withcompute_fc_intervals’s own (already README-aligned) defaults, missed when those defaults were unified earlier in this release:n_toys(wizard default2000vs.500),smooth_1d/smooth_2d(wizard defaultnvs.True), andsave_log(wizard defaultnvs.True). Anyone who ran the wizard and accepted every default (the documented, intended workflow) would get a config that silently diverged from what README.md andpyfc/config.py’s own base config dict both call “the” default. Unified all four; the wizard’s defaults now match everywhere else exactly, so pressing Enter on every question reproducescompute_fc_intervals’s own defaults.README’s “Optimizer Strategy” section claimed PyFC discards a non-converged toy and automatically generates a replacement to preserve exact
N_toysstatistics – no such mechanism exists anywhere in the codebase. Confirmed by exhaustive grep acrosstoys.py/binned.py/unbinned.py: toy-generation code never inspects a toy’s own fit convergence, andoptimizers.py’s_minimize_with_restarts(which does checkres.success, retry once, and warn on non-convergence) applies identically to data fits and toy fits alike – it does not discard or regenerate anything. Whatevert_statcomes out of a non-converged toy fit is used exactly like any other toy’s result. Corrected the README bullet to describe actual behavior (awarnings.warnmessage on persistent non-convergence, no discard/regeneration, noverbosegating on the warning either – also inaccurately claimed). Actually implementing discard-and-replace was considered and deliberately deferred as a separate, carefully-scoped follow-up if ever wanted, since it would be a genuine new statistical feature (changing which toys contribute to the empirical critical-value distribution) rather than a documentation fix.README.md’s and
docs/source/methodology.rst’s displayed “Binned Likelihood” formula did not matchcalc_nll’s actual implementation. The docs showed the simple, non-saturated, single (not doubled) Poisson NLL (mu_i - n_i*ln(mu_i)), whilecalc_nll’s own docstring and its actualn_obs > 0branch compute the saturated, doubled Baker-Cousins form (2*(mu_i - n_i + n_i*ln(n_i/mu_i))). Verified numerically: for a single-bin toy(mu_i=5, n_i=3), the old displayed formula gives0.172whilecalc_nllactually returns0.935, matching the corrected formula exactly. Becauset_data = NLL_cond - NLL_uncondis a difference evaluated at the same data in both fits, the missing saturation/doubling terms cancel exactly – this was a documentation-only bug; the FC interval construction itself was never affected – butresults["data_uncond_nll"]and any other absolute NLL value PyFC reports would not match a reader’s by-hand calculation from the old formula. Corrected both docs to the saturated, doubled formula, with an added note on the factor-of-2 convention (calc_nllcomputes-2 ln Ldirectly, matching the Baker-Cousins/Wilks convention used fortitself, so no separate doubling is needed when formingt).n_restartsandneighbor_seeding(compute_fc_intervalsparameters, tested sincetests/test_restarts.py) were never wired into the CLI/JSON config system – the only two parameters (excluding inherently non-serializable ones) missing fromconfig.py’s argparse/base-config dicts,generate_config.py’s interactive wizard, README’s anddocs/source/configuration.rst’s parameter tables, andconfig/example_fc_config.json, unlike every other tunable knob. Reachable only via a direct Python call, not through the documented CLI/JSON workflow. Wired both through end to end, matching the existing pattern used forwarm_start/toy_batch_size; the new wizard questions default ton_restarts=1/neighbor_seeding=True, matchingcompute_fc_intervals’s own defaults.calc_nll’s finite-MC (Poisson-Gamma) branch lost catastrophic floating-point precision for well-simulated templates (largealpha = mu^2/sigma^2 + 1). Evaluatingln(Gamma(n+alpha)) - ln(Gamma(alpha))as a literal difference of twolgammacalls loses precision once both terms are individually huge (~alpha*ln(alpha)) while their true difference is onlyO(n*ln(alpha))– double precision’s ~16 significant digits get eaten by the leading digits both terms share. Verified against a 50-digitmpmathreference run through the actual compiledcalc_nll: atmu=1000, sigma^2=1e-9(a realistic well-simulated-template regime), the old formula was off by 7.5 in absolute NLL, large enough to distortt = NLL_cond - NLL_uncondand bias interval boundaries. Fixed via two algebraically exact (not approximated) reformulations: the lgamma difference is now computed as the exact integer-recurrence sumSUM_{k=0}^{n-1} ln(alpha + k), andalpha*ln(beta) - (n+alpha)*ln(1+beta)is rewritten usinglog1pto avoid an analogous (smaller but real) cancellation. Re-verified against the same 50-digit reference across 500 randomized(mu, sigma2, n)trials spanning ordinary-to-extremealpha: worst-case error now 2.6e-8, down from an unbounded-growing error before. New regression test intests/test_finite_mc_likelihood.pypins the exact bug-report case and asserts the naive formula is still measurably wrong there (guards against this test silently losing its teeth if the regime changes). Thesigma2 <= 1e-10Poisson fallback for near-zero simulation variance is unaffected by this fix and was confirmed to be a deliberate, mathematically-correct limit (NB collapses onto Poisson assigma^2 -> 0), not a numerical workaround – documented as such incalc_nll’s own docstring.toys.generate_and_fit_toys_pythoncrashed withValueError: range() arg 3 must not be zerowhen called withn_toys=0and the defaulttoy_batch_size=None.compute_fc_intervals’s own entry point is shielded (itstoy_batch_sizedefault is200, always positive), but this is public API with non_toysvalidation of its own. Fixed by flooring the fallback batch size at 1 (max(n_toys, 1)), son_toys=0now correctly returns an empty result array instead of crashing. New regression test intests/test_adaptive_toys.py.constraintswere silently unenforced whenever a 1D/2D scan fixed every parameter, leaving zero free parameters to optimize over (a natural simplification of this codebase’s own flavor-fraction constraint example, down to exactly 2 parameters with no extra nuisance parameters). All fourconditional_fit_*_scipy/conditional_fit_*_ ultranestfunctions have alen(free_bounds) == 0early-return path that bypasses their normal optimizer machinery entirely (scipy’s_minimize_with_restarts/UltraNest’slog_likelihoodclosure, which is whereconstraintsis otherwise projected/checked) – so a scan point that individually violated a joint constraint silently returned an ordinary finite NLL instead of being rejected like every other constraint-violating point. Fixed by checking_constraints_satisfieddirectly against the fully-assembled point in all four early-return branches, returning the same1e10flat penalty already used elsewhere in this module for a violated constraint. New regression tests intests/test_constraints.pycovering all four functions (the two UltraNest ones gated onultranestbeing installed).orchestrator.py’s own__main__demo block never forwardedn_restarts/neighbor_seedingto either of its twocompute_fc_intervals(...)calls, even thoughconfig.pycorrectly parses both from the CLI (added earlier in this release) – the residual gap in that change.python -m pyfc.orchestrator --n_restarts 5 --neighbor_seeding falseparsed without error but silently had zero effect end to end. Now both calls passconfig.get("n_restarts", 1)/config.get("neighbor_seeding", True), matching every other config key already forwarded there.scipy_method(fully wired intoconfig.pyand both README’s/configuration.rst’s parameter tables) had nogenerate_config.pywizard question and no key inconfig/example_fc_config.json– the same bug class as then_restarts/neighbor_seedinggap above, just older (predates this release) and missed by earlier sweeps. Added wizard question 5 ("auto"sentinel mapping back toNone, matching thenum_cores0-to-Nonepattern already used there) and the corresponding JSON key.NaN
mu_i(binned) /p_events(unbinned) silently bypassed the unphysical-region barrier instead of triggering it:mu_i <= mu_floor/p_events <= p_floorare bothFalsefor NaN, so a NaN arising from a bug in the user’scompute_rates_func(division by zero, sqrt/log of a negative number) fell through intomath.log(NaN)/np.log(NaN), silently producing a NaN NLL with no warning and no gradient signal for the optimizer to recover from (a quadratic barrier needs a finite distance-from-floor to mean anything; NaN has none). Both now raise an explicitValueErroridentifying the likely cause. Subtlety worth flagging for future work on this module:calc_nllis@njit(fastmath=True), and numba’sfastmathcompiles under LLVM’s “assume no NaN” flag, which makesmath.isnan(x),np.isnan(x), and even thex != xself-inequality trick all silently returnFalsefor a genuine NaN underfastmath– verified directly before landing this fix. The actual check reinterprets the float64’s raw bits as an int64 and tests the IEEE-754 NaN bit pattern directly (exponent all 1s, mantissa nonzero), since integer bitwise ops aren’t affected byfastmath’s floating-point assumptions.calc_nll_unbinnedis plain Python/NumPy (not@njit), so its fix uses ordinarynp.isnanwith no such caveat. New regression tests intests/test_smoothing.py, including one that pins the fastmath/isnan interaction directly so a future “simplification” back to a standard NaN check would be caught.Three inaccurate behavioral claims in the docs, found by re-verifying every specific claim against the actual current code: (1) README’s “press Enter on every question to reproduce [
compute_fc_intervals’s defaults] exactly” was false fornum_cores/output_file/param_names, which the wizard correctly maps toNone-sentinels while the adjacent example JSON block shows illustrative literal values (8,"fc_results", etc.) – reworded to carve out this exception explicitly rather than overclaim exactness, confirmed by actually running the wizard end to end. (2) README’s andoutputs.rst’s checkpoint-resume descriptions claimed resuming happens “from the exact point of interruption”; the real granularity (confirmed via_save_fc_archive’s call sites) is per-1D-parameter and per-2D-pair, matching whatindex.rstandexamples/07_pyfc_checkpointing_tutorial.ipynbalready said correctly – an internal inconsistency, not just loose phrasing. (3)plotting.generate_corner_plot’s docstring claimed it returnsNoneand closes the matplotlib figure to free memory; it actually returns the liveFigureand never closes it (an intentional prior change that the docstring was never updated to match) – corrected, and covered by a new regression test intests/test_plotting.py(a previously fully untested function).
Added
generate_corner_plotnow importable asfrom pyfc import generate_corner_plot(previously only reachable asfrom pyfc.plotting import generate_corner_plot), matchingcompute_fc_intervals’s existing top-level export. README.md,docs/source/quickstart.rst, and all example notebooks now import both functions this way – code copied from the docs/tutorials no longer needs to know PyFC’s internal module layout (orchestrator.py/plotting.py).bounds_funchook onunconditional_fit_scipy/unconditional_fit_ultranestandconditional_fit_1d/2d_scipy/conditional_fit_1d/2d_ultranest(optimizers.py), plus abounds_func=Nonekwarg oncompute_fc_intervals(orchestrator.py): lets the box bounds of the profiled (free) parameters depend on whatever parameter(s) the scan currently has fixed, so a joint constraint likef_mu <= 1 - f_e_testcan be expressed without a user-side penalty function. DefaultNonepreserves prior behavior exactly.constraintssupport on the same scipy/ultranest fit functions, plusconstraints=None/scipy_method=Nonekwargs oncompute_fc_intervals: accepts a list ofscipy.optimize.LinearConstraint/NonlinearConstraintobjects (expressed in the full parameter-vector space) for constraints among multiple simultaneously-free nuisance parameters, whichbounds_funccannot express. Automatically switches the scipy method fromL-BFGS-BtoSLSQPwhen constraints are supplied (overridable viascipy_method, e.g."trust-constr"); a new--scipy_methodCLI/JSON config option is also available. A shared_project_linear_constrainthelper projects full-space constraints down to the free-parameter subspace for the conditional fit functions. DefaultNone/empty preserves prior behavior and method exactly.Optimizer restarts and
res.successchecking (optimizers.py’s new_minimize_with_restartshelper, used by all three scipy fit functions):res.success/res.statusfromscipy.optimize.minimizewere previously read nowhere in the codebase – a silently non-converged fit’s result was used exactly like a converged one. Now, a non-converged result triggers one cheap perturbed-restart retry and a warning if that also fails. A newn_restartsparameter (default1, unchanged behavior aside from the check above) allows trying multiple starting points and keeping the best-converged one.Neighbor warm-starting for the data fit (orchestrator.py): Phase 1’s 1D scan and Phase 2’s 2D scan previously started every single grid point’s data fit fresh from the bounds midpoint, with zero information sharing between adjacent, already-solved grid points, even though these loops already iterate the grid in order. (MC toy fits were already well-seeded from the conditional MLE and are unaffected.) A new
neighbor_seeding=Truekwarg oncompute_fc_intervalsseeds each grid point’s data fit from an adjacent, already-evaluated point’s profiled parameters; whenever used,n_restartsis forced to at least 2 (the neighbor-seeded start plus a fresh bounds-midpoint start) so a bad neighbor optimum can’t silently cascade forward. SetFalseto restore the always-fresh-start behavior.README section “Handling Joint/Simplex-Constrained Parameters” (also mirrored, with the same worked code examples, in
docs/source/methodology.rst): a worked end-to-end tutorial for thef_e + f_mu <= 1example, covering bothbounds_funcandconstraintsand when to use each.docs/source/configuration.rstandquickstart.rstcross-reference it, and a newdocs/source/changelog.rstpage (wired into the Sphinx toctree) mirrors this file.New example notebook,
examples/pyfc_joint_constraints_tutorial.ipynb: a hands-on walkthrough of both mechanisms using a toy 3-parameter neutrino flavor-fraction model (f_e,f_mu,norm) constrained to the standard 2-simplex. Reproduces the flat-gradient plateau trap with a hard-cutoff rate function and no constraint handling, then fixes it withbounds_func; demonstratesconstraintsfor the casebounds_funccannot express (two simultaneously-free parameters); runs a realcompute_fc_intervalspipeline and plots the resulting 2D confidence region confined to the simplex; and includes a bonusNonlinearConstraint(unit-disk) example.tests/test_nd_binned.py: flatten-invariance checks (a genuine 2D histogram and its 1D-flattened equivalent must give byte-identical NLL) plus a fullcompute_fc_intervalssmoke test on 2D-shaped data.tests/test_pdf_components.py: correctness checks for the newpdf_componentsmechanism with both 2 and 3+ components, including a hand-computed reference NLL for the 3-component case (no old-API equivalent to diff against).xbranch_compare/: a reusable cross-branch regression harness (kept in the repo for future breaking API changes, not a one-off validation) that empirically proves this refactor changed signatures only, never numerics, by running equivalent models against bothdev(old API, via a temporarygit worktree) anddev-no-templates(new API) and diffing the results.New example notebook,
examples/pyfc_non_contiguous_data_tutorial.ipynb: demonstrates the non-contiguous-array fix above – passes a transposed histogram straight intocompute_fc_intervalsand confirms the result matches a contiguous copy of the identical data, after first illustrating the underlyingnumba/numpycontiguity restriction in isolation..github/workflows/changelog-sync.ymlandscripts/check_changelog_sync.py: a CI check that fails the build if this file anddocs/source/changelog.rststructurally drift out of sync (version/ subsection headings, bullet counts, and each bullet’s opening wording), since the two are hand-maintained in parallel and RST-only enhancements (admonitions, cross-references) mean a byte-for-byte diff or a generic Markdown-to-RST converter isn’t viable. See the script’s module docstring for exactly what is and isn’t checked.New example notebook,
examples/07_pyfc_checkpointing_tutorial.ipynb: demonstrateswarm_startcheckpointing/resume against a genuinely interrupted run, not a simulated one. Launches a realcompute_fc_intervalscall in amultiprocessing.Process, lets it run long enough for one parameter’s 1D scan to checkpoint, sends itSIGTERM(via.terminate(), the same signal a Slurm walltime kill sends), inspects the resultingcheckpoint_fc.npzto confirm a genuine partial state, then resumes with the identical call andwarm_start=True. Verifies correctness (not just “it finished”) two ways: the resumed run completes measurably faster than a fresh reference run (the checkpointed parameter wasn’t recomputed), and the checkpointed parameter’s data test statistic (a deterministic quantity, no MC randomness involved) matches the reference run’s value exactly.
[0.9.2] and earlier
See git history.