Changelog¶
Newest first. Every version below was published as a GitHub release.
From v0.25.0 to v0.40.0 those are -dev pre-releases cut from
the dev branch, while main stayed at v0.22.0; v1.0.0 is
where main catches up with all of it. Version numbers bumped in flight but never
released are folded into the release that carried them.
The record starts at v0.3.0 – v0.1 and v0.2.0 predate it. The
entries up to v0.25.0 were kept in the README’s “Project Status”
section and were moved here unchanged.
Each entry says what changed and, where it matters more, how it was found out to be wrong – a bug that produced a plausible number is worth more words than a feature that worked first time.
v2.0.0¶
The rewrite the 1.x line grew towards: the library as components –
theories that compute, likelihoods that compare, samplers
that explore – assembled from a YAML input, run with cosmofit run,
and written as getdist chains. It takes its shape from cobaya, and
depends on nothing of it.
It came in six phases, each pushed on its own and each held to the results of the one before:
An audit before the rewrite. Every model, likelihood and statistic was checked against its source, and what was wrong was fixed first, so the new core would be compared against correct numbers. Some results move because of it: Pantheon+ is the published sample, DES-SN5YR the Dovekie recalibration, the CC covariance keeps its systematic correlations, and a computed
r_dsees each model’s own early universe. Each fix is an entry below, with how it was found.The core: parameters with priors, references and derived values; components that declare what they need and provide; a model that wires them; a parameter-keyed cache.
The theories: one expansion history with radiation and massive neutrinos in it, every model as a dark sector on it, growth of structure, the early universe, CAMB.
The likelihoods: all 21 datasets as native likelihoods, held to the old
chi2for every model, and one description of each from which the pairs that must not be combined follow.The samplers: adaptive Metropolis-Hastings stopped by Gelman-Rubin, emcee, dynesty, minimize, profile, Fisher, with fast and slow parameter blocking, chains in several processes or under
mpirun, resumable getdist output, and reweighting a finished run.The library around it: the package imported as
cosmofit, thecosmofitcommand, the GUI as a package that samples on the core,Fitter.to_info(), and the notebooks sampling on the core.
Breaking, with a release’s warning: import CosmoFit (now
cosmofit), and Fitter’s own samplers – run_mcmc, run_nested,
profile, fisher – in favour of the core’s. Both keep working and
say what to use instead; the README’s “Upgrading from 1.x” has the
whole list. The CMB likelihoods want a CAMB with cmbant/CAMB#211,
merged but not yet released.
A distance past a jump depended on the platform’s maths library¶
CI had been red on Linux and macOS since phase 4b, while every
Windows run, and every run of the suite on the machine the library
is developed on, was green. Three failures, all LsCDM’s: distances
just past the sign switch at z_dagger were off by 4e-5.
The native distance table is split at a jump in E(z), and each piece
is integrated on its own side of it. The piece above the jump started
from nextafter(z_dagger, +inf), with a comment explaining that
expm1(log1p(z)) can round below z. The piece below had no such
guard: its last node was expm1(log1p(1.8)). Which way that rounds is
the platform’s choice. Windows gives 1.7999999999999998; glibc and
macOS’s libm round up, past the jump, so there the last slope below it
was taken from above it. That end is now pinned to
nextafter(z_dagger, -inf) too.
One more test failed on one Linux runner only: an f(R) fit asserted
scipy’s success flag. L-BFGS-B reported a failed line search at
chi2 = 23.28, a good minimum. The test now checks where the fit
landed rather than what the optimizer said about it.
Fitter’s own samplers are deprecated; the notebooks sample on the core¶
Fitter.run_mcmc, run_nested, profile and fisher now raise a
DeprecationWarning that names their replacement on the 2.0 core:
cosmofit.compat.sample_on_core, evidence_on_core,
profile_on_core and fisher_on_core. They keep working for a
release. Fitter itself stays, as the model, the datasets and the
figures. The core has no plotting or analysis layer of its own yet,
and deprecating the fitter whole would have put the warning on every
notebook and on the GUI.
The notebooks now do their calculations on the core:
Re-executed (10):
quickstart,model_zoo_comparison,holographic_family,modified_gravity_growth,custom_models,models_from_an_action,profile_likelihood_and_fisher,evidence_and_model_selection,tension_statisticsandcpl_mcmc_analysis. Their text was checked against the new outputs. Short demonstration chains that used to say “not converged” now run toR - 1 < 0.01, or 0.02 for the GCG’s slowly mixing degeneracy, and the text that apologised for them is gone.Code only (4):
cmb_from_scratch,scalar_field_models,cpl_mcmc_tfd42andlscdm_mcmc. Each would take from most of an hour to many hours to re-execute: CAMB calls, a field integration per step, Planck priors in every step. Their code samples on the core, and a note at the top says their outputs came from the old engine.Untouched (3):
dataset_zoo,s8_tension_cmbanddark_energy_evidence_auditnever called a sampler.
Several of the re-executed notebooks turned out to carry outputs older
than the phase-0 fixes. They predated Pantheon+’s z > 0.01 cut, for
instance: 1668 data points where there are now 1635. Their text was
brought in line with what the data now give. evidence_and_model_selection
now reads Delta chi2 = 4.5, p = 0.034, 2.1 sigma where it had
3.5, 0.063, 1.5 sigma; ADE’s n sits 1% above the published
range, not 2%. Every qualitative claim still held: the three model
comparisons still disagree in the same directions, and HDE’s
c is still split between supernovae and BAO.
profile_likelihood_and_fisher samples with the core’s emcee, and
says why. With rd free, CPL on CC + DESI + Pantheon+ has a long,
curved tail to low Omega_m and positive wa that reaches the prior
edge. The adaptive Metropolis-Hastings chains wandered into it one at
a time and had not agreed after 100 000 steps each; the ensemble
converges in five minutes. The old emcee run, 3000 steps and flagged
unconverged, had not explored that tail.
minimize could stop well short of the minimum. The same notebook
showed it. Its w0 profile had a kink: at w0 = -0.7, L-BFGS-B
reported convergence at chi2 = 1440.9, where Nelder-Mead and Powell
both find 1432.7. A gradient taken by finite differences, on a
chi2 of ~1500, stops when a step no longer reduces it by a relative
1e-9. minimize, and through it profile and fisher’s search for
the best fit, now polishes every gradient-based result with
Nelder-Mead from where it stopped, and keeps the better (polish: true, the default). The profile is now smooth through the best fit’s
chi2, and a test holds it there.
Two things that had gone stale were corrected on the way:
profile_likelihood_and_fishersaid that freeingMBmade the Fisher matrix raiseLinAlgError. That stopped being true when the Fisher matrix learned to name a flat direction and return a NaN error. The cell now shows that instead.cpl_mcmc_analysisstill said the library had four datasets.
The README, the API reference’s front page and the package docstring now sample on the core. “Saved Chains” describes the core’s getdist output, with a short note on reopening chains 1.x saved as HDF5.
A derived parameter could come back NaN from the cache. A theory’s cache is keyed on its inputs, not on whether derived parameters were asked for. A point first evaluated without them, as every minimizer step is, was then served from the cache, empty, when they were wanted. Minimizing and then reporting at the same point could therefore give a NaN derived column. A cached state now records whether it holds its derived parameters, and is recomputed if not.
Running the notebooks also showed a warning that should not have
been there. The mcmc, emcee and nested samplers now ask the
theories for derived parameters only when a chain column needs one.
A model evaluated directly still computes them when asked. The legacy model’s sound horizon had been
evaluated at every step, including for fits that never use it, and
warned that omega_b h^2 was outside its calibration on a fit where
rd was fixed.
The GUI samples on the 2.0 core¶
The GUI still builds a Fitter, for its checks, its warnings and its
figures. The calculations now run on the new core, through
cosmofit.compat, on fit.to_info(exact=True). The fitter’s own
model and likelihood classes are what is evaluated, so the physics,
and every number the page showed before, are unchanged.
Sampling. The sidebar offers the adaptive Metropolis-Hastings sampler (chains, the
R - 1to stop at, a ceiling on steps, burn-in as a fraction, processes) or emcee’s ensemble (walkers, a ceiling on steps). The chains start at the best fit, found first.sample_on_corehands the result back asfitter.sampler, aCoreChains. That object expands each Metropolis-Hastings chain’s weighted rows into one row per step; every chain takes the same number, so together they are emcee’s(steps, walkers, parameters).summary(),convergence(), the derived posteriors,best_fit()and every figure read it unchanged.Convergence is judged by the rule the run stopped by.
Fitter.convergence()reports the sampler’s own rule (stopping_rule) when it has one, as doessummary()’s warning. Emcee’s 50 autocorrelation times would otherwise have called a run unconverged that had metR - 1 < 0.01.Saved chains are now the core’s output, in getdist’s format, one folder per configuration. A run that had met its stopping rule is read back without a single new step. Resuming checks the chains before moving, for both
mcmcandemcee; for emcee, the earliertauis recomputed from the saved rows, since convergence there meanstauhas stopped moving. One that had not is continued.run(..., callback=...)reports each check, which is how the page’s progress bar showsR - 1ortauas the run goes.The inference tabs. Profile, Fisher and evidence run the core’s
profile,fisherandnestedsamplers. The profile is the fitter’s to 1e-3 inchi2. Fisher errors agree within 5%; the core sizes its steps from the likelihood. The evidence agrees within its error.“The same fit in Python” now samples on the core (
fit.to_info(exact=True), thenrun(info)). For a built-in model it also gives the input as YAML, forcosmofit run.
Phase 5f: example inputs, CI on Windows and macOS, .coverage untracked¶
examples/yaml/holds inputs forcosmofit run:LCDM from DESI DR2 + Pantheon+ + BBN with the sound horizon computed;
CPL against DESI DR2, DES-SN5YR and the CMB distance priors;
the LCDM fit by its Fisher matrix, which gives
H0 = 68.60 +- 0.60andOmega_m = 0.304 +- 0.008(68.74 when first run, beforeminimizelearned to polish its best fit);a post-processing input that adds SH0ES to the LCDM chains.
A test builds and evaluates each one, so what the documentation points to runs.
CI runs the suite on Windows and macOS. The test runs install every extra but CAMB, whose PyPI release still has the lensing bug. Two things are checked there and not on Linux: the
CosmoFit.pyalias beside thecosmofitpackage on a case-insensitive filesystem, and thecosmofitconsole script as each platform installs it..coverageis no longer tracked: a local coverage database had been committed. It is in.gitignorenow, withhtmlcov/.
Phase 5e: the GUI is a package, started with cosmofit gui¶
app/streamlit_app.py was one 4249-line script, outside the package,
so a pip installed library had no GUI. It is now cosmofit.gui:
app: the page, which Streamlit runs top to bottom;reference: models, datasets, presets, parameters and plot labels;helpers: the sidebar’s choices turned into a fit;render: a finished fit drawn.
cosmofit gui starts it from anywhere. Anything after gui goes to
streamlit run. The theme and the usage-statistics setting are
passed on the command line, so they hold outside the repository,
where .streamlit/config.toml is not read. The double-click launchers
use the command. app/streamlit_app.py remains as the entry point in
a checkout and runs the packaged page on every rerun.
The page behaves exactly as before. A script split it, and the 76
top-level statements of the new modules have the old script’s syntax
trees, in the same order. The one change is that matplotlib.use("Agg")
now sits only where it is needed: before anything imports pyplot.
Two tests used to compile functions out of the script’s source,
because a Streamlit page cannot be imported. They now import
_equivalent_script and the configuration filters from gui.render.
Phase 5d: a Fitter written as a 2.0 input¶
Fitter.to_info() writes a fitter as an input for the new core. The
input carries the fitter’s model, datasets, free parameters with
their bounds, fixed values and dataset options. With a sampler
block added and saved as YAML, cosmofit run takes over from it.
Fitter itself is unchanged.
There are two ways to write it:
to_info()is the analysis in 2.0. The model becomes its dark sector on the nativebackground(CPLisdark_energy: cpl), the datasets become their native likelihoods, and the theories they need are added for what they provide (early_universe,growth,camb). The GCG’s and the power-law and Hu-Sawicki models’ parameters get their new names. Radiation is now part of the late-time expansion, sochi2moves by a few hundredths to about one: 0.4 to 1.1 for LCDM on CC + DESI + Pantheon+. Withradiation: falsethe native input gives the fitter’schi2to 1e-7, so the whole difference is radiation.to_info(exact=True)runs the fitter’s own classes through thelegacy_cosmologytheory, and gives the fitter’schi2at every point. This includescompute_rd, growth datasets and models defined outside the library.
A model built outside the library has no native sector. The native
input wraps it radiation-free, and is refused, with exact=True
named, when the datasets need the sound horizon. A free parameter
that no component takes is refused too: the fitter samples it to no
effect, as with Omega_m in ADE, which derives it.
The DeprecationWarning on Fitter waits until the GUI and the
notebooks run on the new core.
Phase 5c: the dataset loaders, one module per family¶
data/loader.py had grown to 3416 lines: every dataset’s file table,
its reader and its loader in one module. It is now the package
data/loaders/:
_common: the data directory, version lookup and the shared readers;cc,bao,sn,growth,cmbandpriors: each family’s file tables and itsload_<dataset>functions;_index:available_versions,dataset_referenceandavailable_datasetsacross all of them.
A script made the split and wrote each module’s imports from the names it uses. Comparing every definition’s syntax tree with the old module’s shows two changes, both intended:
DATA_DIRis now the package’s parent directory;_validate_versionimports the registries inside the function. The registries collect every family’s tables, and every family imports the shared helpers, so a module-level import would be circular.cosmofit.data.loaderre-exports every name the old module defined, private ones included. Imports written against it keep working, and all 21 datasets load as before (219 dataset and likelihood tests, golden chi2 included).
The 2.0 command line, phase 5b: cosmofit run, list and doc¶
pip install now provides a cosmofit command (python -m cosmofit
is the same):
cosmofit run input.yamlruns an input. An input with apostblock reweights a finished run instead.-ooverrides the output prefix, and--force,--resumeand--seeddo what their names say. A summary is printed: the best fit and its chi2, the samples’ means and widths withR - 1or the autocorrelation time,ln Z, or the effective sample size. Undermpirun -n 4 cosmofit run ..., themcmcchains are shared between the processes.cosmofit listnames every component an input can use without an import path, with a one-line description each. The old dataset names say which native likelihood to use instead.cosmofit doc NAMEdescribes one component: its documentation, a sampler’s options with their defaults, and a theory’s or likelihood’s parameters, provided quantities and requirements.
An error is one line naming the exception; --debug shows the full
traceback.
The 2.0 rename, phase 5a: the package is imported as cosmofit¶
src/CosmoFit is now src/cosmofit, so the import name matches the
PyPI name. Every import in the library, the tests, the GUI, the
notebooks and the API reference changed with it. Prose still calls
the project CosmoFit, and the repository URL is unchanged.
import CosmoFit keeps working for one release, with a
DeprecationWarning. The alias is complete:
every
CosmoFit.<module>is thecosmofit.<module>object itself, not a copy, so a class imported either way is one class;pickles that name the old modules load;
saved chains, whose metadata records the model’s module as
CosmoFit.cosmology..., reopen.
The alias is a single module, src/CosmoFit.py, rather than a
directory. On Windows and macOS a directory CosmoFit and a directory
cosmofit are the same directory, and the rename could not have
been made at all with both. That is also why the move went through a
temporary name.
The 2.0 core, phase 4c: reweighting a finished run¶
An input with a post block reweights the run its output names
instead of sampling. It can:
add likelihoods, theories or derived parameters;
remove likelihoods.
Every kept sample is evaluated once on the new model. Its weight is
multiplied by L_new / L_old, where L_old comes from the chain’s
own chi2__ columns. The result is written like a run under
<output>.post.<suffix> (chains, .paramnames, .ranges and the new
.updated.yaml), so getdist reads it as one.
What would make the samples meaningless is refused:
changing a sampled parameter’s prior;
adding a sampled parameter, a direction the samples never explored;
removing a theory, or every likelihood.
Points the new model rejects are dropped and counted. The effective
sample size (sum w)^2 / sum w^2 is reported before and after. When
it falls below a tenth of what it was, a warning says the new
posterior overlaps the samples poorly.
Validation:
Toy Gaussian: with a Gaussian constraint added on one parameter, the closed-form posterior is recovered to 0.2 sigma.
Real data: DESI + CC, reweighted with SH0ES, gives
H0 = 71.31 +- 0.67andOmega_m = 0.2991 +- 0.0079from 450 effective samples. Sampling DESI + CC + SH0ES directly gives71.34 +- 0.72and0.2995 +- 0.0083.
.updated.yaml could not be read back. Post-processing starts
from the run’s .updated.yaml. Checking what that file holds before
relying on it showed that a parameter declared as
"lambda a, b: a + b" was written back as <lambda>, its function’s
name, so any run with such a parameter could not have been
post-processed. A lambda read from a
string now keeps that string, and is written as it was given.
The 2.0 core, phase 4c: the MCMC’s chains in several processes¶
processes: n runs the mcmc chains in n processes started with
spawn, which is the only method Windows has. Under mpirun with
mpi4py, the chains are shared between the processes it was given.
Both use one protocol from CosmoFit.core.mpi. The processes meet
only at each check:
the first process gathers every chain’s second half;
it decides
R - 1and the learned covariance;it hands both back to the others.
Every chain now draws from its own random stream, spawned from the run’s seed. A run split over processes therefore writes the same chains, byte for byte, as the same run in one. The tests hold it to that, on the toy Gaussian and on LCDM with DESI + Pantheon+ + CC.
This changes the random numbers a serial run draws. The note in the
fast/slow entry below, that unblocked runs repeat earlier runs
exactly, no longer holds. One test then landed on a draw where a
correlation came out 0.115 off at R - 1 < 0.01. A variance can
still be 10% off at that point, because R - 1 compares means. That
test now runs to R - 1 < 0.005 rather than shopping for a seed.
A process that fails stops the run. It sends its traceback where its
share of the check was due, and the first process raises it as a
WorkerError. A process that dies without reporting is noticed by
its exit. The test suite covers:
a likelihood that raises only in a started process;
one that cannot even be built there;
a stuck chain in the first process, which raises its own error.
Under mpirun, a sampler that cannot share a run (anything but
mcmc) is refused rather than run once per process into the same
output.
Starting a process rebuilds the model and reloads its data. On LCDM with distance likelihoods, four processes took 16 s for what one does in 6 s. They pay off where an evaluation is slow, as with a Boltzmann code.
The 2.0 core, phase 4c: fast and slow parameters in the MCMC¶
Model.slow_and_fast() splits the sampled parameters by what moving
them costs:
A slow parameter changes a theory’s inputs, directly or through a dependent parameter, so moving it recomputes the theory.
A fast parameter reaches only likelihoods:
rdwithrd: free,MB,A_planck. Moving it is served from every theory’s cache.
When a run has both kinds, the mcmc sampler blocks its proposal the
way the covariance is blocked. With the parameters ordered slow
first, the Cholesky factor L of the covariance gives two moves:
a slow move,
L[:, slow] z, which also carries the fast parameters along their regression on the slow ones;a fast move,
L[fast, fast] z, a step in the fast parameters given the slow ones.
Each slow move is followed by fast_steps fast ones. That number is
(t_slow / t_fast) ** 0.4, from timing the starting point, or a
number given in the input.
drag: true uses the fast moves for Neal’s (2005) dragging instead. A
slow proposal is decided only after the fast parameters have been
walked along distributions interpolating between the old slow point
and the new one.
On a Gaussian whose nuisance parameter is 0.9-correlated with a slow one, both modes recover the mean to 0.15 sigma and the correlations to 0.1. The theory is computed for under half the likelihood’s evaluations. Without both kinds the proposal is the single block it was, drawing the same random numbers, so earlier runs repeat exactly.
The first timing measured the cache, not the theory. The first version timed the slow moves first. Three slow moves pushed the starting point’s theory state out of a three-state cache, so the first “fast” move recomputed the theory. A 5 ms theory then looked only three times slower than its likelihood, and the run got 2 fast steps where it now gets 9 or 10. The fast moves are now timed first, from the freshly evaluated start.
The 2.0 core, phase 4b: profile, Fisher, emcee and nested sampling on the new core¶
Four more samplers now run on the new core, and each writes the same
output as mcmc:
profileholds one parameter at each of a list of values (or{min, max, n}) and minimizeschi2over the others. It warm-starts each value from the previous one’s optimum, and it profiles the likelihood unless told otherwise. On a Gaussian it gives the marginal((x - mean) / sigma)^2and the conditional means exactly. It writes.profile.txt.fisherreturns the curvature at the best fit (found first withminimizeunless apointis given) and its inverse. The inverse goes to.covmatonly when it is positive definite, so anmcmccan start from it. On LCDM with DESI + Pantheon+ + CC it givesOmega_m = 0.3003 +- 0.0075in 17 evaluations. The MCMC gave 0.303 +- 0.008.emceeis the affine-invariant ensemble. It stops once the run is 50 autocorrelation times long and the estimate oftauhas stopped moving. Every step is written, soresume: truerestarts the walkers from where the file leaves them.nestedruns dynesty, with every prior kind mapped from the unit cube through its inverse CDF. A Gaussian prior is therefore the Gaussian itself, not a box around it. It writes the weighted dead points as a chain andln Zto.evidence.yaml. On the test Gaussian,ln Zmatches the closed form within its quoted error.
minimize now writes .minimum.txt, and it can hold parameters
fixed, which is how profile is built.
The Fisher step is now set by the likelihood, not the prior. The
old Fitter.fisher stepped by 1e-3 of each prior width. That is
arbitrary for an analytic likelihood and lost under CAMB’s numerical
noise. Here each parameter’s step is tuned until it moves chi2 by
about one, starting from the parameter’s scale. The step is then
checked by recomputing the diagonal at half the step. A flat direction
is named rather than inverted.
A forward f(R) model solves its closure once, not once per instance¶
Profiling the slowest test in the suite found two wastes in the closure solve:
Duplicate cold solve. The build checks
E(0) = 1on an instance of its own, at the defaults. Every test then made its first instance at those same defaults and solved the closure again, cold. That cost four seconds per instance, and atz_init = 60it was most of two minutes. The closure and the history are now cached on the built model, which all its instances share.Brent bisecting noise. Brent was asked for
xtol = 1e-13on shots integrated atrtol = 1e-7. Below about 1e-8, the shot’s own error decides the sign, so Brent spent 24 evaluations bisecting noise where 12 do. The full-tolerance polish that follows was always what set the root’s accuracy.
The closure moves by 1e-11 relative, and the module now takes a minute and a half instead of four minutes 18 seconds.
The 2.0 core, phase 4a: an adaptive MCMC, and output getdist reads¶
sampler: {mcmc: ...} is adaptive Metropolis-Hastings. Several chains
run side by side. Each one is a Gaussian random walk at the optimal
scaling (2.4/sqrt(d))^2 times a proposal covariance, which works as
follows:
Start: the covariance starts from a
covmatfile, or from each parameter’sproposal,refor prior width.Learning: once the chains roughly agree, the covariance is replaced by their pooled covariance.
Stopping: the run stops when the multivariate Gelman-Rubin
R - 1falls belowRminus1_stop.R - 1is the largest eigenvalue ofW^-1 B, computed on the second half of each chain. A single chain is compared against itself split into four.
On a correlated three-parameter Gaussian, the run stops at R - 1 < 0.01 within a few thousand steps per chain. It recovers the mean to
0.15 sigma and the correlations to 0.1. LCDM on DESI + Pantheon+ + CC,
with rd computed, converges to R - 1 = 0.02 in under two minutes.
With output: chains/run, a run writes:
run.input.yaml, the input as given;run.updated.yaml, with every default filled in;run.1.txtand the other chains, in getdist’s format (weight,-log posterior, then the sampled and derived parameters,-log priorand each likelihood’s chi2);run.paramnamesandrun.ranges;the learned
run.covmat;a
run.progresslog.
A run will not write over existing output unless told to:
force: true starts over, and resume: true continues from the chains
and the covmat. Resuming is refused when the input that wrote the
output differs from the current one. The stopping rules
(Rminus1_stop, max_samples, max_time) are the exception and may
differ.
Each parameter can now be written back as the declaration that reads
it in: ParamSpec.declaration(), and as_dict() for each prior.
CAMB’s intermittent NaN spectra came from CAMB, and it is fixed
upstream. The full test suite has failed on CMB tests now and then
for as long as those tests have existed. Tracing the failures pinned
down what was happening. After one matplotlib figure with text was
drawn in a process, every lensed CAMB result in that process came back
NaN, while the unlensed spectra stayed fine. The floating-point control
state (x87, MXCSR) did not change, so the cause was not there. CAMB
was reading past the end of an array in its non-linear lensing spline,
and the result depended on whatever memory lay there. The fix,
cmbant/CAMB#211, was merged on 2026-09-25 but is not in a CAMB release
yet. Until the next release, install CAMB from its main branch
(pip install git+https://github.com/cmbant/CAMB). With it, the
figure-then-CAMB reproduction stays finite and the full suite passes.
The 2.0 core, phase 3c: one description of each dataset, and conflicts that follow from it¶
CosmoFit.data.metadata now describes every dataset in one place:
its legend label and its GUI title;
its probe family and its native likelihood;
the arXiv identifiers of the measurement;
whether it needs CAMB;
the samples it is built from.
Fitter’s labels, the GUI’s titles and sidebar groups, and the native
likelihood names all read this module.
Which datasets must not be combined is now derived from the samples they share. Each dataset lists what it measures: the same BOSS/eBOSS galaxies, the same patch of sky, the same supernovae, or the same CMB measurement in compressed form. Two datasets that share a sample are not independent. Previously the 17 pairs were written by hand, and the GUI had kept its own copy, which held 7 of them by the time anyone compared the two. The derived set is exactly those 17 pairs, and a test pins it so that a change has to be made on purpose. A new dataset declares its samples and gets its conflicts with no further work.
The native likelihoods now give the warnings Fitter gives:
pairs that share a sample, named by their likelihood names;
Pantheon+’s Cepheid hosts together with a SH0ES
H0;the full-posterior
tautogether withplik_lite.
One reason was wrong. The pair fsigma8 with the eBOSS ELG full
shape was justified by “the compilation includes eBOSS growth-rate
measurements”. The compilation’s eBOSS point comes from DR14
quasars, not the DR16 ELGs. The two still overlap: they measure
growth in the same eBOSS volume near z ~ 0.85. The pair stands, and
its reason now says that.
The GUI used to show the raw keys sdss_fsbao, eboss_elg,
eboss_elg_fs and eboss_lya in the sidebar, because its own titles
had no entry for them. Those four now have titles.
The 2.0 core, phase 3b: the CMB spectra likelihoods¶
The four likelihoods that need CMB spectra now read them from the
camb theory: cmb.planck_lite, cmb.planck_lowe,
cmb.planck_lensing and cmb.act_lensing.
Each likelihood asks for the highest multipole and the lensing accuracy
it needs, and CAMB computes the largest of those. When nobody asks,
CAMB uses lmax = 2508. Low-l EE alone used to cost a full l = 2508
run, and now CAMB stops at l = 30.
A_planck, Planck’s absolute calibration, is now a parameter of the
bandpower and low-l EE likelihoods. It defaults to 1, and the two
likelihoods share it. planck_lite counts its Gaussian prior once.
tau_reio is shared the same way between CAMB and the external.tau
prior.
At Planck’s best fit, all four give the old likelihoods’ chi2 to within
0.003. The residual is the massive-neutrino density, which the old
backend converted from m_nu with 93.14 eV.
The CAMB failures in the test suite were CAMB’s. The new CAMB tests failed now and then in full runs and never on their own. A full run has now recorded the reason: CAMB returned NaN spectra and the point was rejected. That is the same intermittent CAMB failure the old backend already documented, and the tests that need clean spectra now skip when it happens.
The 2.0 core, phase 3a: the datasets read the native theories¶
Every dataset that needs no Boltzmann code is now a likelihood of the
new core (CosmoFit.likelihoods.native). Each one reads its
predictions from the theories through the provider. They are listed
by family:
Family |
Likelihoods |
|---|---|
BAO |
|
Supernovae |
|
Other probes |
|
External measurements |
|
The data, covariances and chi2 are the datasets’ own classes, unchanged. Each class is handed a read-only view of the provider where it used to be handed a model, so a likelihood cannot change a theory’s state.
Nuisance parameters now belong to the likelihoods that use them, not to the cosmology:
rd: a BAO likelihood takes the sound horizon from theearly_universetheory by default. It takesrdas a parameter only withrd: free. Several BAO likelihoods with a freerdshare one parameter.MB: a supernova likelihood takesMBonly when it does not marginalize it analytically. Pantheon+ with its Cepheid hosts is one such case.
lss.s8 reads S8 as a quantity. It comes from the growth theory, or
from CAMB when the growth amplitude is CAMB’s, and in both cases it is
the S8 of all matter.
How it was checked: the golden reference holds 21 models and 17
datasets. With radiation off and each model wrapped by the legacy
sector, the native likelihoods reproduce it to 2e-7 or better. The
one exception is LsCDM on the three datasets beyond its sign switch,
where the old distance table smeared the jump and was off by 2e-5 in
D_M. The native table is exact there.
With radiation on, at the same point, LCDM’s chi2 moves by at most
0.08 on the distance datasets and by 0.2 on f sigma8. The f sigma8
shift comes from neutrinos no longer counting as clustering matter.
The distance priors keep their own conventions. Chen, Huang & Wang
(2019) compressed Planck with radiation Omega_m / (1 + z_eq) and with
massive neutrinos inside Omega_m. That puts l_A 0.1% below the
exact pi / theta_*. The native background gives the exact value, and
at Planck’s own best fit that value misses the prior by 2.7 sigma (chi2
9.4). So cmb.distance_priors predicts in CHW19’s conventions, as the
old likelihood did (chi2 0.39 there), and takes only the dark sector’s
evolution from the background.
Wrapped ADE had two matter densities. Old-style ADE derives
Omega_m from n and ignores the value it is given, but the legacy
sector reported the given value to the background. Its densities and
S8 then described a different universe from its E(z). The legacy
sector now derives the matter density whenever the wrapped model does.
The old dataset names (desi, pantheon, …) still run the old
likelihoods on the old models.
The 2.0 core, phase 2d: CMB spectra from CAMB, on the native background¶
CosmoFit.theories.CAMB (camb in an input) provides the lensed CMB
spectra (Cl, as C_l or D_l, with the lensing potential) and
sigma8. It takes everything from the background theory: the
densities it solved with, and the dark sector’s own w(z), which goes
to CAMB’s PPF module as a table. The old backend converted a neutrino
mass back into a density with its own 93.14 eV. Here omega_cdm is
omega_cb - omega_b, exactly as the background has it.
Which models CAMB accepts is decided from the sector’s hooks
(cmb_support). A sector is refused if it:
changes the gravitational coupling (DGP, f(Q), f(T), f(R,T), Hu-Sawicki);
moves energy into matter (IDE, running vacuum);
makes
E(z)jump (LsCDM);has no equation of state to give (Cardassian, holographic).
The refusal names the reason and points to the compressed distance priors. These are the same models the old gate refused.
Growth now has one amplitude. With growth: {amplitude: boltzmann}, the growth theory scales by CAMB’s sigma8 of the cold
matter, which is computed from ln1e10As. Taking a free sigma8
alongside CAMB is now an error. The old library only warned that the
two amplitudes were not tied together. S8 then comes from CAMB’s
all-matter sigma8, the value usually quoted.
How it was checked:
Background: CAMB, given these parameters, solves the native background to 4e-7 in
H(z)up toz = 1100. This holds for Lambda, curved Lambda, CPL acrossw = -1, GEDE and GCG.Spectra: they match the old backend to about 1e-5. The residual is the neutrino-density conversion.
Growth: the native growth theory solves one equation, where CAMB solves the Boltzmann hierarchy. Their
sigma8(z)for the cold matter agrees to 2e-5 up toz = 5, for flat and curved Lambda and for CPL. This is an independent check of the radiation-era initial condition from phase 2c. With 0.06 eV of neutrinos the two differ by 1e-4, which is the scale-dependent suppression one equation does not carry.
Every CAMB call used to run CAMB twice. The old backend passed its
parameters to get_cmb_power_spectra as well as to get_results, and
that recomputes everything. The problem showed up while timing the new
theory: half of each call went into the second run. Both backends now
read the spectra from the results they already have. CMB-spectra fits
are about twice as fast (LCDM: 0.6 s to 0.3 s per evaluation).
The 2.0 core, phase 2c: growth of structure on the native background¶
CosmoFit.theories.Growth solves the linear growth equation on the
background’s own expansion and provides growth_factor,
growth_rate, sigma8_z and fsigma8, with S8 as a derived
parameter. What a model does to growth comes from three hooks on its
dark sector:
clustering_matter: the matter that clusters. This is the cold matter, plus whatever an interaction has moved into it.mu(z, k): the effective gravitational coupling. DGP, f(Q), f(T), f(R,T) and Hu-Sawicki f(R) define it.matter_exchange: the rate at which matter gains energy, for IDE and the running vacuum. It enters the equation’s friction and source terms as well asOmega_m(a).
The defaults are cold matter, mu = 1 and no exchange. f(R)
Hu-Sawicki is now a sector, hu_sawicki (f_R0, n_hs). Its
background is LCDM’s, and its mu(k) reads the curvature off the
trace of the standard fluids. Old-style models run through the
legacy sector, which forwards each hook to the wrapped model’s own
Omega_matter, mu and matter_exchange.
Radiation changes where growth starts. The old calculator set
D proportional to a at a = 1e-4, which is the growing mode only
when there is no radiation. The equation now starts at a = 1e-5
from Meszaros’s growing mode, D proportional to 1 + 3y/2 with
y = rho_m / rho_rel. That is the exact solution for matter plus
radiation. A matter-and-radiation-only universe reproduces it through
equality to 5e-8, which is the RK4’s own error: it falls 16-fold when
the step count is doubled.
Neutrinos no longer cluster. On the scales growth data probe,
massive neutrinos free-stream, so Omega_m(a) in the equation counts
cold matter only. The old calculator included them. For LCDM with
m_nu = 0.06 eV, this lowers f by about 0.3%. Radiation itself
moves f by only 0.02-0.05% at z < 2.
How each part was checked:
Without radiation: every model equals the old calculator to 1e-9 in
Dandf, curved or flat. IDE agrees to 2e-8, because its exchange rate is differenced with a different step.Across a jump (LsCDM):
Dis continuous, andffalls by exactlyH_before / H_after.IDE and the running vacuum: the second-order equation agrees to 1e-7 with the first-order continuity and Euler equations, when the exchange rate is taken from the matter density itself.
The legacy sector now passes on its model’s jumps. Before this
change it reported none, so a wrapped LsCDM integrated its distance
table straight across the sign switch, and growth missed the
H_before / H_after matching. The comparison against the old
calculator found it: f was 10% off below z_dagger.
The 2.0 core, phase 2b: every model’s expansion, with radiation¶
The native background now takes any of the library’s models, not
only the w(z) family. Each one is a dark sector: it receives the
standard fluids (cold matter, photons, neutrinos, with density and
pressure) and returns E(z)^2, so every model sees radiation without
writing it out. Radiation was generalized into each model’s own
equations. With it switched off, every model is its old self again.
Model |
How radiation enters |
Checked against (with radiation) |
|---|---|---|
LsCDM, GCG, IDE |
added to the dark energy as before; IDE’s transfer feeds cold matter only, in a form exact through the |
|
DGP, Cardassian, f(Q), f(T) |
the modified Friedmann equation acts on the total standard density |
f(Q)’s equation to 1e-12; f(T) at |
f(R,T) |
each fluid counts as |
|
HDE |
|
its definition, |
ADE |
|
|
RDE |
|
|
Running vacuum |
|
its continuity equations integrated directly, to 1e-8 |
Each equation was derived from its model’s energy density and the
continuity equation, with -2 H'/H = 3(1 + w_total) carrying the
fluids’ pressure. With dust alone, each reduces to its old model’s
equation exactly.
One prediction changes. ADE fixes Omega_m from n through its
early-time attractor, and radiation changes that attractor. For
n = 2.8, the prediction moves from Omega_m = 0.280 to 0.270.
LsCDM distances are now exact across the jump. The old distance
table smeared the jump in E(z) over one grid cell, an error of about
1e-4 in every distance beyond z_dagger. The new table is split at
the jump and agrees with a piecewise quad to 1e-11. Getting there
exposed a rounding trap: expm1(log1p(z)) can come back a hair below
z, and then the side of the jump was taken wrongly.
Renamed for 2.0: GCG’s A_s, alpha become A_gcg, alpha_gcg, so
that A_s is free for the primordial amplitude. f(T)’s n becomes
n_ft.
Models built from an action or with define_model run through a
legacy sector, with radiation off only, until they too are native.
f(R) Hu-Sawicki, whose background is LCDM’s, joins with the growth
theory.
The 2.0 core, phase 2a: one expansion history, with radiation in it¶
The old models each wrote out `E(z)^2 = Omega_m (1+z)^3 + Omega_k (1+z)^2
Omega_de(z)`, with no photons and no neutrinos. The parts of the library that could not do without radiation each built their own: the CMB distance priors one way, the sound horizon another. That gave three expansion histories for one cosmology, agreeing only where radiation did not matter.
CosmoFit.theories.Background is the one expansion history:
E(z)^2 = [omega_gamma a^-4 + omega_nu(a) + omega_cb a^-3] / h^2
+ Omega_k a^-2 + Omega_de f(z)
Contents: photons come from
T_CMB; the neutrinos carry their exact Fermi-Dirac density; the dark energy is atheories.dark_energycomponent that supplies onlyf(z)andw(z). The w(z) family is there: Lambda, wCDM, CPL, JBP, BA, logarithmic, PEDE and GEDE.Omega_decloses the budget, radiation included.Parameters:
H0, Omega_m, Omega_b(as before), orH0, omega_b, omega_cdmwithparameterization: physical.Derived:
h,Omega_m,omega_cdm,Omega_de,Omega_nu,Omega_rand the age.
CosmoFit.theories.EarlyUniverse computes rdrag, zdrag, rstar,
zstar and thetastar by integrating the background’s own E(z). A
model that changes the early universe therefore needs no patch: the
correction added to the old sound horizon earlier in this release is
simply what happens here.
How they were checked:
Radiation off (
radiation: false): each of the eight dark energies, flat, open and closed, reproduces the old model classes:E(z)exactly,D_MandD_Vto 1e-9.Radiation on, against CAMB’s own background:
Quantity
Agreement
H(z)and distances up to z = 11002e-7
rdrag,zdrag2e-5
thetastar1e-5
The age agrees to 1e-6 with CAMB’s
H(z)integrated exactly. CAMB’s own reportedagesits 2e-5 below that integral, which is its integration tolerance; ours matchesquadand the analytic LCDM age to 1e-12.The CAMB drag-epoch grid:
rdragis held to the same 1e-4 the old calculator is, across the grid it was calibrated on.
Fixed in the phase-1 core along the way: a theory’s cache was
keyed on its own parameters alone. A theory with no parameters of its
own, one that reads everything from another theory, therefore served
its first state forever. The early-universe theory found it at once:
rdrag came back up to 40% off across the CAMB grid. The cache key now
includes the current states of the theories it reads. The phase-1
test of a theory chain had evaluated one point only. The new test
moves the upstream theory and checks both the recomputation and the
reuse.
The z_drag and z_star fits moved out of their classes into the
functions z_drag_fit and z_star_fit, which both old and new code
call. This is unchanged behaviour: the golden chi2 reference still
holds to 1e-10.
The 2.0 core, phase 1: theories, likelihoods and parameters from an input¶
This is the first piece of the rewrite toward a component-based
library: theories that compute, likelihoods that compare, samplers
that explore, all assembled from one input, a dict or a YAML file.
It lives in CosmoFit.core and CosmoFit.samplers. Fitter is
untouched and remains the complete interface for now. MCMC, nested
sampling, chains and plots move over in later phases.
theory:
legacy_cosmology: {model: LCDM, compute_rd: true}
likelihood:
desi:
omega_b:
params:
H0: {prior: {min: 50, max: 90}}
Omega_m: {prior: {min: 0.1, max: 0.6}}
Omega_b: {prior: {dist: norm, loc: 0.049, scale: 0.005}}
sampler:
minimize: {starts: 3}
That input, passed to CosmoFit.core.run, gives the familiar
BAO + BBN result: H0 = 68.5 from DESI DR2.
What is new, as opposed to moved:
Normalized priors: uniform, Gaussian (optionally truncated) and log-uniform, each with an inverse CDF for nested sampling. The old path had only a box that returned 0 inside it.
Four parameter roles: sampled, fixed, dependent and derived. A dependent parameter is a lambda of other parameters, computed before evaluation. That is how a fit samples
omega_b(markeddrop: True) and hands the cosmology theOmega_bit expects. A derived parameter is recorded with each point, either from a component’s output or as a lambda.Requirements: a likelihood says what it needs, and the model finds the one theory that provides it. A theory can require another, and theories are ordered accordingly.
Caching by parameter value: a theory keeps its recent states keyed on the exact inputs it was computed at. A changed nuisance parameter does not recompute the cosmology, and a result can never come from different parameters than the ones asked for. The old path relied on every caller remembering
refresh(), and the one that did not (stats.derived) was a bug fixed earlier in this release.Errors at assembly: everything checkable is checked before a point is evaluated, with a message naming the problem:
a requirement nobody provides;
a requirement two theories both claim;
a cycle between theories;
a parameter no component takes (a typo, usually);
a derived parameter nothing computes.
Counted rejections: a point that cannot be evaluated is rejected, and the reason is recorded and counted, where the old path turned it into a silent
-inf.A registry: built-in names,
package.module:Classimport paths, and entry points (cosmofit.theories,cosmofit.likelihoods,cosmofit.samplers), so another package can contribute components.Two samplers:
evaluate(one point, every chi2 and derived value) andminimize.minimizeworks in coordinates scaled by each parameter’s typical width, maximizes the posterior by default (ignore_prior: truefor the likelihood), and takes several starts.
Every existing model and dataset already runs on it through
core.legacy. legacy_cosmology wraps a Cosmology subclass, and
any dataset name wraps that dataset’s class, along with Fitter’s
dataset-combination warnings.
tests/data/golden_chi2.json is the bar every later phase has to
clear. It holds the chi2 of every built-in model on every dataset
that needs no Boltzmann code: 21 x 17 values at one point, written by
tools/make_golden_chi2.py straight from the original likelihood
classes, so it depends on neither Fitter nor the new core. The core
reproduces it to 1e-10 and matches Fitter to 1e-12 on five
configurations that cover compute_rd, dataset versions and a free
nuisance parameter.
New dependency: pyyaml.
DESI defaults to Data Release 2¶
"desi" loaded DR1 (2024) unless asked otherwise, while the app has
defaulted to DR2 (2025) for some time. The same dataset name
therefore meant different data in the library and in the app. The
library default is now DR2: 13 measurements, including the DR2
Lyman-alpha and the QSO tracer as anisotropic D_M/D_H. DR1
remains available as dataset_kwargs={"desi": {"version": "desi2024"}}. Fits that relied on the default change.
One AIC/BIC, and a resumed chain that says which version wrote it¶
AIC and BIC were written out three times. The copies were in
stats.model_comparison, stats.results.BestFitResult and
likelihoods.joint.JointLikelihood. All three agreed today, and
nothing kept them agreeing. The other two now call the first.
Resuming a chain written by another version now warns. A chain’s signature (model, datasets, free parameters, priors, fixed values) says which posterior it belongs to, but not which code computed that posterior. A release that changes what a likelihood computes leaves the signature unchanged. This release does that several times: a sample cut, a covariance, a default prior version. Continuing such a chain merges samples from two posteriors. The chain records the version that wrote it, and resuming under a different one now warns. It does not refuse, because most releases change no likelihood and a chain is expensive.
Three closed forms, checked against the equations they solve¶
IDE at its resonance. At w0 + xi = 0 the closed form’s
C = -xi Omega_de0 / (w0 + xi) diverges. The code returned C = 0
there, with a comment saying this kept E(z) continuous. It did the
opposite: the limit is -3 xi Omega_de0 (1+z)^3 ln(1+z), energy fed
into matter linearly in ln(1+z), so E jumped at the resonance.
Next to it, C was finite but enormous, and multiplying it by a
nearly vanishing bracket lost digits. The transfer term is now
written through expm1(x)/x, which is exact and stable everywhere,
resonance included.
Running vacuum with curvature. With Lambda = c0 + 3 nu H^2,
H^2 obeys d(H^2)/dN + 3(1-nu) H^2 = 3 c0 - k/a^2. Its curvature
solution is Omega_k (1+z)^2 / (1 - 3 nu), not Omega_k (1+z)^2,
because the vacuum responds to the curvature term in H^2 like
everything else in it. Matter also gains a curvature-fed piece,
2 nu K (1+z)^2. E, dEdz, Omega_matter, Omega_de and the new
matter_exchange now carry both. The error was of order
nu Omega_k: small, but the closed form claimed to be exact.
DGP with Omega_m + Omega_k >= 1. Omega_rc comes from squaring
sqrt(Omega_rc) = (1 - Omega_k - Omega_m) / (2 sqrt(1 - Omega_k)).
Squaring dropped the sign, so a closed matter budget produced
E(0) != 1 with no error. It now raises: the self-accelerating
branch does not exist there.
The tests integrate each model’s own continuity equations with
solve_ivp and require agreement to 1e-9. That includes the
resonance itself and points 1e-7 on either side of it. Eight of the
18 failed before.
Distances past z = 5, and docstrings that said the wrong thing¶
The distance table stopped at z = 5. Past it, chi(z) came back
NaN, because the spline does not extrapolate. A likelihood turned
that into chi2 = NaN, and a sampler then rejected every point without
a word. No bundled dataset goes beyond z = 2.33, so nothing ever
showed it, but a quasar or GRB Hubble diagram would. The table now
extends itself to cover a request and keeps that range on later
rebuilds. The spline still refuses to extrapolate: the extension is
computed, not guessed.
Corrected statements:
f(R) Hu-Sawicki’s
mu(a,k)was called “chameleon-screened” in its docstrings and the README. It is the linear result, set by the scalaron’s Compton wavelength. Chameleon screening is non-linear and is not included. The README section on it already said so.BA’s
w(z)tends tow0 + waat high z, notw0 - wa.define_model’s finite-differencedEdzwas described as feeding onlyq(z). It also feeds the distance quadrature and the growth friction.The integrator’s module docstring described a trapezoid rule and PCHIP interpolation. The code uses a fourth-order corrected trapezoid and a cubic Hermite spline.
Fitter’s docstring listed 8 of its datasets. It now points to the registry and toCONFLICTING_DATASETS.sh0es2024is the SMC-anchor result (Breuval et al. 2024), not a JWST recalibration.constants.N_effwas 3.046 while every calculation uses 3.044. Nothing read it. It is now 3.044.
Growth in IDE and running vacuum: the exchange dilutes the contrast¶
In interacting dark energy and running vacuum, matter gains (or
loses) energy from the dark sector. The growth solver saw this only
through Omega_m(a) in its source term, which is exactly how the
background had already been corrected. Energy deposited
homogeneously into matter does two more things, both missing:
It dilutes the density contrast. Perturbing
rho_m' + 3 rho_m = Q/H, withQhomogeneous, givesdelta' + psi delta + theta/(aH) = 0, wherepsi = Q/(H rho_m).It changes the friction. Eliminating
thetawith geodesic matter and Poisson’s equation givesdelta'' + (2 + dlnH/dN + psi) delta' - [3/2 Omega_m mu - psi (2 + dlnH/dN) - psi'] delta = 0
This is the equation of Gomez-Valent, Sola & Basilakos (2015), written in
N = ln a.
Models now report psi through a Cosmology.matter_exchange(z)
hook:
RunningVacuum:3 nuIDE:3 xi rho_de / rho_m
The solver carries both terms. The test integrates the uncombined
continuity and Euler system with solve_ivp, so it checks the
algebra rather than repeating it. The two agree to 1e-8.
Effect on f D at z = 0:
Model |
Before |
After |
|---|---|---|
RVM, |
0.512 |
0.510 |
RVM, |
0.508 |
0.486 |
IDE, |
0.504 |
0.380 |
The IDE numbers rest on stated assumptions: the dark energy does not
cluster on sub-horizon scales, and the energy it hands to matter
carries no momentum in matter’s frame. As noted before, this model
couples all of Omega_m, baryons included.
A_planck carries its calibration prior when freed¶
Planck releases plik_lite with one nuisance constraint, the absolute
calibration A_planck = 1.0000 +/- 0.0025. Freeing A_planck here
gave it only the uniform 0.9 to 1.1 box, forty times wider than that
constraint. A_planck divides every bandpower squared, so it then
traded freely against ln1e10As and broadened the amplitude and
everything derived from it.
The prior is now part of planck_lite’s chi2, which is where public
plik_lite implementations attach it. The added term is zero at the
default A_planck = 1, so a fit that leaves it fixed is unchanged.
A free rd next to the CMB, one more overlap, and one list of overlaps¶
A free rd beside the CMB or BBN now warns. With rd free, BAO
measures H0 rd and nothing more. On its own that is a legitimate
choice, and an early-universe-agnostic one. Next to planck,
planck_lite or omega_b, the same fit constrains the densities
rd is computed from, yet lets the BAO ruler float free of them.
That drops the link those data are there to supply. Fitter now
says so and points to compute_rd=True. The message allows that it
may be deliberate, and the README’s quick example is one such case.
fsigma8 + bao_lowz is a conflict. Gold-2018’s z = 0.15 point
(0.490 +/- 0.145) is the SDSS DR7 Main Galaxy Sample’s growth rate
(Howlett et al. 2015), measured from the same galaxies as
bao_lowz’s MGS BAO.
The app had its own copy of the conflict list. By the time
anyone compared the two, the copy held 7 of the library’s 17 pairs.
It is now derived from CONFLICTING_DATASETS, so the two cannot
drift apart again.
Two candidate conflicts from the same audit were not added.
desi + bao_lowz is unverified, and the README states the low-z
pair is independent. planck + planck_lowe does not double-count
on any shared parameter, because the distance priors do not depend
on tau.
f(Q) and f(T) no longer pair a flat expansion with curved distances¶
FQExponential and FTPowerLaw solve Friedmann equations built on
Q = 6H^2 and T = 6H^2, which are identities of flat FLRW. Both
docstrings said “flat only – Omega_k is ignored”. The model’s
E(z) did ignore it, but the distance calculator did not, and
applied the sinh/sin of curvature to a flat expansion history.
The closure test passed for both at Omega_k = +/-0.05 because
E(0) = 1 holds whether or not curvature is in it. They now refuse
Omega_k != 0, as HDE, ADE and RDE already did.
Refusing inside the model is not enough when Omega_k is free. The
start point (Omega_k = 0) passes, and then every curved proposal is
rejected one at a time, so the posterior pins Omega_k = 0 and
nothing says why. A new Cosmology.FLAT_ONLY flag, set on all five
flat-only models, makes Fitter refuse Omega_k as a free
parameter up front.
Fits used outside their calibration say so, and so does a blind N_eff¶
The z_star and z_drag fits extrapolated silently. Both are
calibrated against CAMB over recorded ranges, and nothing checked
those ranges:
Fit |
Calibrated on |
Default prior reaches |
|---|---|---|
|
|
0.0125-0.405 |
|
|
up to 8 |
They now warn, once per process, when evaluated outside their calibration. Out-of-range points are not rejected, because rejection would truncate the prior with no trace in the output. The user is told instead that those tails rest on an extrapolated polynomial.
A free N_eff with only the compressed CMB. The compressed Planck
priors fix the radiation density and z_star at the standard N_eff
by construction, exactly as they ignore the neutrino mass. A free
N_eff was therefore constrained by a computed r_d alone, with
nothing in the CMB to push back. Fitter already warned about this
for m_nu, and now warns about N_eff too.
Tension statistics: honest bounds and real degrees of freedom¶
suspiciousness counted parameters, not constraints. Its degrees
of freedom were the number of free parameters. That is right only
when both datasets constrain every parameter. A parameter only one of
them constrains (rd in a supernova-only run) raised d and so
lowered the quoted tension. Nested runs now carry their Bayesian
model dimensionality, 2 Var_P[ln L] (Handley & Lemos 2019), and
d = d_A + d_B - d_AB uses it. d_source says when it fell back to
the count. The end-to-end test against two Gaussians now measures
d rather than assuming it, and gets the parameter count back.
sample_tension reported 37 sigma it could not see. When two
posteriors are further apart than any random pair of samples reaches,
the difference distribution has no samples at zero. The code read
that as p = 0, clipped it to 1e-300 and turned it into about
37 sigma. It now reports the bound it actually has, p < 1/n_pairs,
as a lower bound on the tension, flagged lower_bound=True.
run_nested also seeds resample_equal, so its equal-weight
posterior samples are reproducible along with ln Z.
The Fisher matrix says when it cannot be trusted¶
fisher() inverted whatever matrix the finite differences gave it,
which failed silently in three ways:
A step into an undefined region. If a step took a parameter somewhere the likelihood is undefined (a negative
Omega_m, say), the infinite chi2 became a NaN entry in the matrix. It now raises, naming the step.A flat direction. If a free parameter is one no dataset reads (
rdwithout BAO), its row is zero andnp.linalg.invraised, or numerical noise turned it into a huge error. If the matrix is not positive definite, it is now reported along its worst direction.covariancebecomes the pseudo-inverse, any error without a positive variance is NaN, andpositive_definitesays which case applies.Step dependence. The steps default to 1e-3 of each prior width, with nothing checking that the answer did not depend on them.
check_steps=True(the default) recomputes the diagonal at half the steps and warns if any entry moves by more than 10%.
best_fit()’s Nelder-Mead rescue had a flat budget of 2000
evaluations, which ran out on 8-10 parameter fits. It now scales
with dimension, and if the rescue stops without converging it says
so. It used to return an unconverged point as the best fit.
LsCDM’s growth rate now jumps where its expansion rate does¶
E(z) in LsCDM jumps at z_dagger, where the cosmological constant
changes sign. The growth solver stepped over the jump on its fixed
grid, and so kept dD/dN continuous across it. Continuity of
dD/dN is the wrong matching condition. Rewriting the growth
equation as
(1/H) d(H D')/dN + 2 D' - (3/2) Omega_m mu D = 0
shows that the continuous quantity is H dD/dN, the velocity
d(delta)/dt, which a finite jump in H cannot change in zero
time. D carries across unchanged. dD/dN, and with it f, is
multiplied by H_before / H_after, which is 0.90 at
z_dagger = 1.8.
Models can now declare discontinuities in E(z) through a new
Cosmology.background_jumps() hook. LsCDM declares z_dagger. The
growth solver integrates up to each jump, applies the matching,
continues, and joins the pieces into one piecewise polynomial whose
breakpoint is a real discontinuity in f. Smooth models report no
jumps and take the unchanged path, numba included.
The old result overestimated f D below the transition: by 1.3% at
z = 0, 2.1% at 0.5, 3.4% at 1.0 and 5.3% at 1.5. That is the size
of an RSD error bar, on the very growth data lscdm_mcmc.ipynb
uses. The new solution agrees with an independent solve_ivp
integration to 1e-8. Five tests, all of which failed before.
Multi-core runs on Windows, and solver failures counted in workers¶
Windows could not use more than one core. The worker pool
requested the fork start method explicitly and refused when it was
missing, so on Windows n_processes=2 raised. Asking for
n_processes > 1 explicitly now falls back to spawn there.
"auto" still uses fork only, and so stays single-process on
Windows. spawn re-imports the main module in every worker, so a
script needs an if __name__ == "__main__": guard around the run (a
notebook does not). Only someone who asked for workers should meet
that requirement. The test that a built-in model runs in a pool had
been failing on every Windows machine for this reason. It passes
now; CI never saw it because CI runs only on Linux.
Solver failures in workers were never reported. Each pool worker
builds its own Fitter and counts Boltzmann-solver failures in its
own LogPosterior, which the parent never sees. A pooled run (which
means exactly the long CAMB runs where solver failures happen)
therefore always reported none. Workers now add their failures to a
shared counter, and the parent folds that into solver_failures and
the end-of-run report.
A summary now says when the chain behind it has not converged¶
summary() reported the 16/50/84 percentiles of whatever chain it
was given. The burn-in was a fixed 1000 steps unless set by hand.
convergence() existed, but nothing called it: its own docstring
said so, and asked the reader to remember. Numbers from a chain far
shorter than its autocorrelation time look exactly like numbers from
a converged one.
summary()checks. It warns when the chain has not converged.check=Falsesilences it.convergence()reports more. It now gives the mean acceptance fraction and a split Gelman-RubinR - 1between groups of walkers.convergedneeds bothn >= 50 tauandR - 1 < 0.05. Groups of one ensemble’s walkers are not independent chains, so this is a weaker test thanR - 1across independent runs, whichstats.diagnostics.split_rhatcomputes. It still catches walkers that never mixed.run_mcmc(burnin="auto")measures the burn-in after the run: three autocorrelation times of the slowest parameter, at most half the chain.summary(interval="hpd")gives the shortest 68% interval. Every entry now also carries itslow/highends.fit.limit(name, 0.95)gives a one-sided limit: the way to quote a parameter the data bound from one side only.Contours.
plots.corner()drewcorner’s default contours, which enclose 39%, 68% and 86% of the 2D probability. Its innermost contour looked like the 68% one inw0_wa_planeand in every paper while enclosing 39%. It now draws 68% and 95%.
Fourteen tests. They cover split R-hat against a drifting chain and against walkers stuck in another mode, HPD against an exponential posterior, and the warning on a 120-step chain.
Pantheon+ was not the published sample, and its Cepheids did nothing¶
Two problems with one loader. Both were found in an audit of the likelihoods against their releases, not from a symptom: each one produced a perfectly ordinary posterior.
The Hubble-flow cut was missing. Every published Pantheon+ fit
(Brout et al. 2022) uses the 1590 light curves with z_HD > 0.01.
Below that, peculiar velocities make up a large part of the
redshift. The loader kept all 1624 non-calibrators instead,
including 44 with 0.001 < z_HD < 0.01. It also dropped the 10
calibrator light curves above the cut. Those are ordinary
Hubble-flow supernovae in an SN-only fit. The app’s dataset note
already said “1590 SNe”, so the number that was documented was the
right one. It was just not what got loaded.
include_cepheid=True put the calibrators on the Hubble
diagram. Their predicted magnitudes came from the cosmological
mu(z) at redshifts of a few thousandths, rather than from their
Cepheid distances (CEPH_DIST). They were also combined with the
default analytic M_B marginalization, which removes the absolute
scale that calibrators exist to fix. The one configuration meant to
measure H0 could not. Now:
with Cepheids, calibrators are predicted as
CEPH_DIST + M_B(Brout et al. 2022, Eq. 14);the sample is the 1657-row Pantheon+SH0ES selection;
marginalize_MBdefaults toFalse, and asking forTrueraises.
Profiling (H0, M_B) at Omega_m = 0.334 now gives
H0 = 73.52 ± 1.0 from this one dataset. Brout et al. quote
73.5 ± 1.1.
Combining that configuration with a SH0ES h0 prior counts the
Cepheid hosts twice, and nothing caught it. Fitter now warns. It
does not warn for TDCOSMO’s H0, which is independent of the
ladder. This could not be one more name pair in
CONFLICTING_DATASETS, because whether "pantheon" and "h0"
overlap depends on their options.
Nine tests, eight of which fail on the previous loader. One of them is the H0 measurement above.
Notebook outputs that include Pantheon+ predate this and were made with the 1624-row sample.
CAMB was handed models whose physics it does not solve¶
supports_cmb_spectra decided by class name, plus whether a model
defines w(z). Anything with a w that nobody had put on the list
went through. An audit found three ways that happened. Each one
returned a normal-looking spectrum.
IDE. Matter and dark energy exchange energy, and the model has aw. CAMB was given wCDM with an uncoupled CDM fluid. The C_l and sigma8 came out independent of the couplingxi, whilexistill moved the background and was in CAMB’s cache key. So CAMB re-ran on every change ofxiand returned the same thing.Action models with
growth="quasi_static". These aref(T),f(Q),f(R)and scalar-tensorF(phi) Rbuilt throughtheory.Action. Each one carries an action-derivedwand its ownmu(a, k). CAMB solved GR’s perturbations for them.define_modelwith bothwandmu. Same outcome.
The gate now asks what a model does rather than what it is called:
Overriding
mumeans the model changes the perturbation equations, and it is refused.Overriding
Omega_mattermeans matter is not conserved, and it is refused.
The name list stays for the cases those two checks cannot see, such
as the holographic family. It is now matched along the MRO, so a
subclass of a refused model is refused for the same reason. The LCDM
check is now issubclass, which replaces
issubclass(cls, tuple()) if False else name == "LCDM". That line
reduced to a name comparison by accident.
One case is deliberately left open. An action model with the default
growth="gr" declares that its extra degrees of freedom do not reach
the clustering, which is the effective-dark-energy assumption. CAMB’s
PPF fluid is that same assumption, so such a model is still accepted.
Eleven tests. Five failed against the old gate, and the action-model case had not been noticed at all.
HDE and ADE’s z_t and q0 were evaluated on one background¶
derived.q_of_z writes each posterior sample into the cosmology and
reads q(z) back. It skipped refresh(), on the grounds that E(z)
and dE/dz are analytic in the parameters. For HDE and ADE they are
not. Both solve an ODE for the dark-energy density and interpolate
the solution, and only refresh() re-solves it.
So every sample was evaluated on the background of whichever point
had been refreshed last. c_hde enters only through that ODE, so
every sample produced the same q(z) from c_hde’s side. The
z_t and q0 posteriors carried only the spread of the analytic
part of E(z), along with the GUI’s derived-quantity panel built on
them.
q_of_z now refreshes at every sample. That costs a distance-table
rebuild of a few milliseconds per sample, and derived caps samples
at 5000.
Three tests. One builds HDE at three values of c_hde and requires
each sample to match a freshly built model; it failed before.
A likelihood-ratio test reported less significance than a tension¶
The library had two definitions of “n sigma”:
stats.tensionandstats.cpl_diagnosticsused the two-tailed Gaussian equivalent. That is the convention papers quote, and for one degree of freedom it is exactlysqrt(delta_chi2).likelihood_ratio_testused the one-tailednorm.isf(p). Sodelta_chi2 = 4for one extra parameter came out as 1.69 sigma instead of 2.0, and 9 came out as 2.78 instead of 3.
The same evidence therefore read weaker in a model comparison than in a tension. That matters for exactly the question this library is used to ask: how strongly do the data prefer CPL over LCDM.
There is now one conversion, stats.significance.p_to_sigma, and
every caller uses it. The test that pinned the old value (2.0486 for
delta_chi2 = 5.39) now pins sqrt(5.39) = 2.32. LRT significances
shown in notebook outputs and the GUI’s comparison tab predate this.
The tau prior recommended for plik_lite already contained plik¶
The "tau" dataset shipped one number, 0.0544 +- 0.0073. It was
described as Planck’s low-l polarization constraint and recommended
as "planck_lite"’s companion, in its docstring, in plik_lite’s, in
the README and in the GUI’s “Full CMB” preset.
That number is not the low-l constraint. It is the TT,TE,EE+lowE
posterior, so it already carries the high-l spectra that
plik_lite adds again. The repository had the proof in its own test
suite: test_planck_lowe profiles plik_lite together with the low-l
EE table and reproduces exactly 0.0544 +- 0.0073. The pairing
counted those spectra twice, on the parameter where plik_lite on its
own is weakest.
New default: the
"tau"dataset now defaults to"planck2018_lowe", which is0.0506 +- 0.0086from low-l EE alone.Old number kept: the familiar value stays available as
"planck2018", for fits with no CMB spectra in them.Warning:
Fitterwarns when that version meets"planck_lite". It is a version-dependent rule, so it is not a pair inCONFLICTING_DATASETS.
Fits that used "tau" with "planck_lite" change. That change is
the point of the fix: tau and everything degenerate with it
(ln1e10As, sigma8) were constrained too tightly before.
DES-SN5YR is the Dovekie recalibration, and is now cited as one¶
The bundled DES-SN5YR_HD.csv is byte-identical to
4_DISTANCES_COVMAT/DES-Dovekie_HD.csv in des-science/DES-SN5YR.
That is the reanalysis of Popovic et al. (2026, arXiv:2511.07517):
the same supernovae, recalibrated with the Dovekie photometric
calibration, giving 1820 supernovae after selection.
Every citation in the library named the release this one replaced:
the loader’s reference, the likelihood docstring and
REFERENCES.md cited Vincenzi et al. / DES Collaboration (2024),
which has 1829 supernovae. The app’s dataset note said “1829 SNe”.
Only the likelihood docstring’s mention of
DES-Dovekie-SN_Likelihood.py gave it away.
The data, covariance and likelihood were already right. The fix is to what a fit using them should be quoted against: the two releases give different distances to the same objects, and so different w0-wa results.
The original 2024 release is still published (tag 1.3 of the data repository). Shipping it as a second version is left for later: it adds about 6 MB and uses a different covariance format.
f(R,T)’s H0 was not the Hubble rate today¶
FRTLinear sampled Omega_L as a parameter independent of
Omega_m. Its docstring said this was how the f(R,T) literature
fits the model, and the closure test excluded it by name. That left
E(0)^2 = Omega_k + (1 + 3 beta) Omega_m + (1 + 4 beta) Omega_L
as whatever the sampler made it, so the parameter called H0 was not
H(z=0). Every comparison that reads H0 as the Hubble constant then
compared against the wrong number:
an
"h0"prior;the SN absolute magnitude;
the CMB shift parameter
R = sqrt(Omega_m) H0 D_M / c;the
E_cmbrenormalization.
Fits that report independent Omega_m and Omega_L posteriors are
fitting a spatial curvature they do not name. This model has an
explicit Omega_k, so it can be named.
Omega_L is now derived from E(0) = 1:
(1 - Omega_k - (1 + 3 beta) Omega_m) / (1 + 4 beta). It is declared
in DERIVED_PARAMS, so freeing it warns like ADE’s Omega_m. The
closure exclusion is gone from the tests, and a new test checks
E(0) = 1 and H(0) = H0 at beta = -0.1, 0.05, 0.15, flat and
curved. The default beta = 0 is GR, where the old code happened to
close.
Breaking: Omega_L is no longer a parameter of FRTLinear.
Drop it from free_params and initial. To recover the old freedom,
free Omega_k.
A computed r_d now sees each model’s own early universe¶
compute_rd=True integrated r_d = int c_s/H dz with H built from
photons, neutrinos and Omega_m alone. The docstring stated why:
dark energy is negligible before recombination. That holds for most
models in the library. It does not hold for several others, which
each got LCDM’s r_d with no warning:
Running vacuum dilutes matter as
(1+z)^{3(1-nu)}, so atnu = 1e-3the early matter density is 2-3% low.Interacting dark energy swaps part of the matter budget into the dark-energy scaling.
Ricci dark energy carries a matter-like term 29% the size of matter at
gamma = 0.45.A Chaplygin gas behaves as dust early on, but that dust is not in
Omega_m.f(R,T) multiplies matter by
1 + 3 beta.CPL and its relatives with
w(z -> inf) >= 0are early dark energies.
The fix takes the departure from each model’s own E(z):
delta(z) = E(z)^2 / [Omega_m (1+z)^3 + Omega_k (1+z)^2] - 1
That departure is added to H inside the integral. It is evaluated
up to z = 1e5 and held constant above that, where radiation
dominates and an ODE-based model’s E(z) may not be defined. The
z_drag fit is evaluated at the cold-matter density that gives the
model’s own expansion rate there. This is exact for an extra
component that dilutes like matter, and an approximation otherwise:
it carries the expansion, not any change to recombination itself.
It only switches on where |delta| > 1e-7 at the probe redshifts.
LCDM’s own cosmological constant is about 2e-9 there, so LCDM, every
w(z) model in the usual range, and the CAMB validation of the
standard integral are untouched. Because the departure depends on
parameters the old cache key deliberately left out, it is now part
of the key. Previously, changing nu returned the cached r_d.
At default parameters: RDE 147.06 -> 137.34 Mpc, the Chaplygin gas
-> 127.81, IDE at xi = 0.05 -> 151.61, running vacuum at
nu = 1e-3 +0.57%, HDE -0.002% (its early dark energy is real but
small).
The test is a model that is LCDM plus a component that dilutes
exactly like matter. Its r_d must equal LCDM’s at
Omega_m (1 + epsilon). It does, to 1e-6, and it failed before at
any epsilon.
The compressed Planck priors’ z_* fitting formula still assumes a
standard early universe. That is a separate issue.
The CC covariance had its systematic correlations shrunk¶
The cosmic chronometer covariance was built as
R * outer(sigma, sigma), where R is the correlation matrix
distributed with the Favale et al. (2023) compilation and sigma
are its tabulated errors.
That correlation was built from diag(sigma^2) + sum_k s_k s_k^T.
Here s_k = H(z) f_k(z) are the IMF and stellar-population entries
of the Moresco et al. (2020) systematic budget. The budget reproduces
its off-diagonal to 1e-9. Rescaling it by sigma alone shrank every
off-diagonal term below sum_k s_k s_k^T, by the factor
sigma_i sigma_j / sqrt((sigma_i^2 + s_i^2)(sigma_j^2 + s_j^2)).
Which reading this adopts. An earlier attempt (b1adbbf, reverted
in 8104328) also added s^2 to the diagonal. That treats the
tabulated errors as statistical only, which is how Moresco’s own
notebook treats his table. For the Moresco points in this
compilation, though, the tabulated errors are already close to
statistical and SPS errors in quadrature (6.2 against 6.1 at
z = 0.1791), and Favale et al. do not release a covariance that could
settle it. So the diagonal stays at the published errors, and only
the correlation, which is wrong under either reading, is restored:
C_ii = sigma_i^2, C_ij = sum_k s_k(z_i) s_k(z_j)
The budget, data_MM20.dat, is copied unmodified from
gitlab.com/mmoresco/CCcovariance. The distributed correlation file
stays as the test of it.
A flat-LCDM fit to CC alone goes from H0 = 71.1 +/- 5.27 to 71.3 +/- 5.61 (Fisher). For comparison, the reverted diagonal reading gives +/- 6.13.
A configuration is a thing you can keep, and so is the chain¶
Two more gaps between what the app produced and what you could leave with.
The configuration was lost on reload. Fourteen dataset ticks, several models with their own parameters, a set of MCMC settings – closing the tab threw all of it away, and there was no way to hand a setup to a collaborator. It saves and restores as JSON now.
Saved by prefix rather than by an exhaustive list, so a widget added later to an existing family is carried without anyone remembering to come back. What is deliberately not saved is as important: the download-button state, which model each results tab is looking at, and whether an expensive extra has been asked for. Those describe a view rather than a run, and restoring them would silently point someone’s results at a different model.
Restoring writes into session state above the widgets that read it, so the values land on the same pass – the ordering the dataset presets already use – and it applies once per file, because Streamlit re-runs the whole script on every interaction and the uploader keeps handing back the same file. Without that, no edit made after a restore would ever stick.
The chain could not be taken away. The summary is a handful of numbers derived from the expensive thing; the chain is the expensive thing. Post-burn-in flattened samples now download as CSV, one row per sample and one column per free parameter, so a two-hour fit can be re-marginalised or combined with something else without running it again.
Three tests. The round trip is driven through the app rather than the helper, because the ordering is the part that can break – and it restores over a different preset first, so a successful restore cannot be confused with nothing having changed.
The fit, as Python you can take away¶
The app is a way into a Python library, not a replacement for it,
and the step from “explored it here” to “ran it in a notebook” was
a transcription job: read the widgets back, reconstruct the
Fitter call, hope nothing was missed.
The results now end with the same fit in Python – the imports,
the Action rebuilt from its Lagrangian where the model came from
one, the Fitter call, and run_mcmc with the settings this run
used. Copyable, and downloadable as a .py.
It is built from the values the run itself used rather than from a template, so a snippet that disagrees with the run is a bug here rather than a difference the reader has to spot. The action spec is recorded from the same tuple the model is built from, for the same reason: a second reader of the widgets could drift from the first.
Defaults are left out rather than repeated back – growth='gr' and
background='backward' say nothing – and anything that differs
from a default survives.
Two tests. One runs a real fit and requires the snippet to parse as Python and to name the datasets that run actually used, rather than matching it against a template. The other drives the generator directly for the action case, because reaching that route end to end needs a Lagrangian that converges and what is under test is the snippet’s contract, not whether a particular action fits.
v1.1.1¶
Two fixes cut after v1.1.0-dev had already been published: the
documentation build no longer fails when a network fetch times out,
and the interface reaches the two capabilities the library gained in
v1.1.0 and it could not use.
The GUI caught up with the library¶
Two things added to the library after the app was written were not reachable from it.
A general f(R) could only be integrated backwards. The
direction is now a control next to Growth, which means the whole
“disappearing cosmological constant” family – Hu-Sawicki,
Starobinsky 2007, Tsujikawa, the arctan models – can be built from
the interface at all. backward stays the default, because it is
what every model already in the library uses and forward
additionally requires a closure parameter.
Nothing showed whether a fitted model was a theory worth having
fitted. The best-fit panel now ends with the gate: viability()
on the left, screening() on the right, and the caption saying why
they are two questions rather than one. It appears only for models
that answer – most of the library is dark energy on top of General
Relativity, where neither question arises, and an empty panel there
would be noise.
Four GUI tests, and one of them earned its keep immediately: the first version looked for the direction control on a freshly booted app, where the action widgets are not rendered because a built-in model is selected. A control that is displayed but not wired is worse than no control, so the second test sets it and requires the app to run clean afterwards rather than raise a mismatch between the widget tuple and the builder signature.
The failing CI job was the documentation, not the tests¶
Four builds on dev reported failure and the notification read as
failing tests. Every test job passed on all three Python versions in
every one of them; the job that failed was api reference, and its
only warning was that docs.scipy.org had timed out while fetching
an inventory. The build runs with -W, so a network hiccup was an
error.
suppress_warnings cannot fix it: intersphinx emits that warning
with no subtype, checked in Sphinx 9.1’s source after an attempt
to suppress it changed nothing. So docs/conf.py now probes each
objects.inv with a five-second timeout and passes Sphinx only the
projects that answer. Cross-links survive when the network is up,
degrade to plain text when it is not, and -W keeps its meaning for
everything the sources actually control.
v1.1.0¶
Twenty-four commits on from v1.0.0. The theme is that a model now has
to earn the right to be fitted: every mu in the library refuses the
values that mean nothing, every sector answers whether a theory is
consistent and whether it is allowed, and the whole f(R) family that
could not be integrated at all now can be.
FTPowerLaw, and the sibling that was missing¶
The modified-gravity family had f(Q), f(R) and f(R,T) but no f(T) –
the one theory in that group whose action the theory module’s own
documented example already derives. FTPowerLaw adds it: the
Bengochea-Ferraro power law f(T) = T + alpha T^n, with T = 6H^2
and general relativity at f(T) = T.
alpha is not a free parameter. The E(0) = 1 closure fixes it from
Omega_m, so n is the only quantity beyond flat LCDM’s H0 and
Omega_m – the same shape as FQExponential, whose lambda is
fixed the same way.
Two limits are exact rather than approximate, and both are asserted
as equalities rather than tolerances: n = 0 is flat LCDM
identically, since f = T + alpha is teleparallel GR plus a
cosmological constant; n = 1 is Einstein-de Sitter whatever
Omega_m is, since f = (1 + alpha) T is a rescaled TEGR whose
rescaling cancels out of the Friedmann equation. The second is the
one that earns its keep – it catches a closure constant that
wrongly survives into a case where it must vanish, which closure
alone would not.
Growth: mu(a) = 1/f_T, scale-independent, exactly 1 at both GR
limits (n = 0, and Omega_m = 1 at any n).
The pole that was not where it looked¶
The first implementation carried the closure amplitude
A = (Omega_m - 1)/(2n - 1) through the Friedmann solve, and
refused to build at all near n = 1/2 where that diverges. That was
wrong in an instructive way: the background does not depend on A,
it depends on (2n - 1) A, and the closure makes that combination
exactly Omega_m - 1 – finite everywhere. Written that way the
Friedmann relation is
E^2 + (Omega_m - 1) * E^(2n) = Omega_m * (1+z)^3
with no free amplitude in it and no pole. E(z) and dEdz now work
at any n, n = 1/2 included, and only mu refuses there, because
only mu genuinely diverges: holding the background fixed at
n = 1/2 forces alpha to infinity, which switches gravity off.
A model that had refused to answer a question it could answer
perfectly well.
Checked against two independent implementations¶
The Friedmann equation, the closure and mu were derived by a
minisuperspace tetrad variation done symbolically in the
wljs-gr-toolkit
GR-06 notebook, and checked there against the TEGR, LCDM and
Einstein-de Sitter limits before any Python was written.
The Python then agrees to ~1e-16 with two separate routes: that
Wolfram derivation, and this library’s own theory.Action reduction
of T + A0*(-T)**b. The second is the interesting one. theory
uses the opposite sign convention for the torsion scalar,
T = -6H^2, so the two started out looking like different models;
working through both showed the closure absorbs the difference
exactly, and the agreement now covers the convention as well as the
algebra. tests/test_ft.py keeps both comparisons, the second
skipped when sympy is absent.
The README’s action-versus-hand table gains the row this makes
possible: T + A0*(-T)**b had only ever been checkable at b = 0,
against LCDM, because there was no hand-written f(T) to compare the
general case with.
FTPowerLaw returned plausible numbers where it has no solution¶
Found by a RuntimeWarning in the test log, not by a failing test,
which is the point.
On the n > 1/2 branch the Friedmann relation’s left-hand side
E^2 + (Omega_m - 1) E^(2n) is not monotonic: with Omega_m < 1 it
turns over, so past some redshift there is no real E at all. At
n = 2, Omega_m = 0.315 that happens above z = 0.05. Newton does
not report this. It wandered off and returned whatever it landed on,
and what it landed on looked entirely reasonable – finite, and
positive: E(0.5) = 4.53, E(2.0) = 1.70. Smaller at higher
redshift. An expansion history running backwards, handed back without
a word.
The solve now stays on the physical branch and, more to the point,
verifies its own answer: the returned root has to satisfy the
equation it was solving, with a residual scaled by the right-hand
side. Where it does not, the answer is NaN, which is what the rest of
the library returns for a model evaluated where it has none. Nothing
in the supported range changes – the E(z) and mu values still
match the Wolfram derivation to 4e-16, and the LCDM limit is still
exact to the last bit.
Three tests were added around it: that the no-solution regime is NaN
rather than a number, that E(z) increases with z wherever it is
finite (the invariant the unconverged result violated), and that the
solver emits no warning, since a warning is how this hid in the first
place.
Two CAMB tolerances that were a coin toss¶
test_planck_lensing.py and test_act_lensing.py each bump the
primordial amplitude, restore it, and assert the spectrum comes back
to rtol=1e-12. CAMB is not bit-reproducible between calls: its
OpenMP reductions depend on how many threads the runtime actually
uses, which varies with machine load. Measured on the lensing
bandpowers, one thread against eight differ by up to 1e-10
relative – a hundred times the tolerance being asserted. A captured
failure missed by 1.45e-12.
Both are now 1e-6: four orders above that noise floor and five
below the effect being checked, which is a ~40% change at the
smallest bandpower. The discriminating power is intact – a restore
that is wrong by one part in 10^4 still moves the spectrum by
3.7e-5, thirty-seven times the new tolerance.
What this does not fix, and what turned out to be behind most of it, is in the next entry.
A NaN spectrum could be rejected as if it were bad physics¶
CAMB can return an all-NaN power spectrum without raising. Nothing
checked, so the NaN became a NaN chi2, and a non-finite chi2 is
turned into -inf by LogPosterior, which is a rejection. A
sampler therefore dropped points quietly, and the output said
nothing about it.
That conflates two things that are not the same. Rejecting a point
because the parameters are unphysical is the model doing its job.
Rejecting one because the solver failed at parameters that are
perfectly reasonable is a hole in the sampled volume, and it biases
the result in a direction nothing else records. The failures seen
here were at Planck’s own best fit – H0 = 67.36,
Omega_m = 0.3153, ln1e10As = 3.044, tau = 0.0544 – so “bad
parameters” was never the explanation.
Two changes, on the two sides of the problem.
CAMBBackend no longer hands out a result it cannot vouch for.
Every returned quantity is checked, sigma8 included, and a
non-finite one raises BoltzmannError naming both the fields that
went bad and the point in parameter space, so the report is
something you can act on.
LogPosterior separates a solver failure from an unphysical
point. The point is still rejected – a chain cannot stop because
CAMB had a bad moment – but it is counted in solver_failures,
the first one is kept in first_solver_failure, a warning goes out
the first time it happens, and run_mcmc reports the total when it
finishes. A handful in a long run is noise; a large fraction means
the result should not be trusted until the cause is found. Either
way the number is now readable instead of invisible.
There is no retry. One was written, and then measured: an identical repeated call returns NaN again, so the failure is a property of the process rather than a transient glitch, and retrying only doubled the cost of the most expensive call in the library.
What is still unknown, and what it means for CI. Why CAMB
returns NaN is not established, and the evidence points outside this
library. Ruled out by direct test: the parameter points (every
reported one computes cleanly in a fresh process), a single
triggering module (each CAMB-using module paired with the first
failing one passes), memory (the failure happened at 907 MB with
neighbouring tests passing at the same figure), transience (see
above), and any special role for small lmax (a sweep from 2 to
100 is clean). What is left is CAMB’s own state after many calls in
one process.
The consequence is worth stating plainly: this makes the test suite fail when it happens, where before it sometimes passed with a silent NaN. Runs of 0, 1, 10 and 13 failures have all been seen. That is the intended trade – a suite that goes red on a real problem is better than one that stays green by not looking – but it does mean a red run here may be this, and not the change in front of you.
One failing CMB test was manufacturing a dozen more¶
The Planck modules had looked unstable for a while: consecutive
full-suite runs produced 0, 1, 11 and 14 failures across
test_planck_lensing, test_planck_lite and test_planck_lowe,
while each of those modules passed when run alone. That reads like
flakiness. Most of it was a cascade.
test_planck_lensing, test_planck_lowe and test_act_lensing
each build their likelihood in a module-scoped fixture, so every
test in the file shares one mutable cosmology, and several of them
move a parameter, check what it does, and move it back. That works
until one of them fails – at which point the restore line never
runs, and the cosmology stays moved for everything after it.
Caught in one run with full tracebacks. CAMB returned NaN for the
baseline spectrum; the first two tests failed on that; the third
failed before undoing its ln1e10As += 0.10; the fourth failed
before undoing its own += 0.20 and n_s -= 0.03; and by the
sigma8 check the shared cosmology was 0.30 high in ln1e10As and
reported sigma8 = 0.932 against an expected 0.811. Thirteen
failures, one cause. How far the cascade travelled decided the
count, which is exactly why it looked random.
A new tests/conftest.py restores the shared cosmology after every
test that uses one, pass or fail, and is a no-op for the rest of the
suite. Verified against a deliberately failing test: without it the
next test fails too, with it the next test sees the cosmology it
expects.
Still open. This does not explain why CAMB returned NaN for a standard LCDM in the first place, and that remains unfixed – it did not reappear in the runs after this change, but three clean runs are not proof for something intermittent. What the fix does buy is that the next time it happens the suite reports the one real failure instead of thirteen invented ones, which is the difference between a diagnosable bug and noise. Both the NaN and the module-scoped fixtures predate the f(T) work; the cascade reproduces on the commit before it.
Since traced, and it is not ours – see the next entry.
The NaN spectrum, traced out of this library entirely¶
The intermittent all-NaN spectrum above is reproducible in about six seconds by a script that imports nothing from CosmoFit. Three ingredients, all of them required, any two harmless:
import scipy.stats
font.set_text("Redshift z") # one FreeType layout
camb.get_results(pars) # lensing potential is NaN
tools/camb_nan_repro.py is that script; it reports 7 runs in 10 on
this machine. Each run needs a fresh process, because the outcome is
decided once per process – repeating the call inside one process
gives the same answer every time, which is why the guard’s message
says so.
What breaks is narrow. In the same call, the background, the
transfer functions, P(k), sigma8 and the unlensed CMB spectra
are all correct. Only lens_potential is NaN, and lensed_scalar
and total inherit it. NonLinear = none makes the same call
finite, so it is confined to the non-linear lensing branch.
How it was narrowed, by rates over >= 8 fresh processes each, never single runs – the failure is a coin flip and bisecting it one run at a time produces noise:
in the process |
NaN |
|---|---|
a fitter, and a 1200-evaluation chain |
0/8 |
a figure built, with labels, legend and styling |
0/10 |
|
9/10 |
the same, with no CosmoFit imported |
0/8 |
the same bbox measurement, no text on the figure |
0/8 |
|
4/8 |
|
0/8 each |
Four explanations were tested and are wrong. It is not the
floating-point mode: MXCSR and the CRT control word are identical
(0x1fa0) in 8 of 8 failing runs. It is not memory pressure:
substituting small numpy churn, large numpy churn or Python object
churn for the text layout is 0/8 in all three. It is not scipy’s
bundled OpenBLAS: scipy.linalg is what loads that, and alone it
is clean. And it is not HMcode’s physics: its feedback output is
byte-identical between failing and clean runs, and the non-linear
P(k) it produces is finite.
Two earlier conclusions in this file are superseded by that. The
trigger was recorded as CosmoFit’s plotting path; the plotting path
was simply the first thing in the suite that laid out text. And the
failure was recorded as not reportable upstream, on the grounds that
replaying the captured CAMBparams outside the test process came
back clean – true of the inputs, but too wide a conclusion, since
the reproduction turns out to need no cosmology code at all.
Nothing here is a correctness risk for a fit. Everything upstream of the lensing step is bit-correct in a failing run, the guard refuses to hand a NaN spectrum to a likelihood, and the posterior counts these separately from ordinary rejections. The remaining work was a report to the upstream projects, not a change here; that is filed as cmbant/CAMB#210.
One more measurement went into that report and is worth keeping here:
the failure is order-dependent. Importing scipy.stats after
the glyph layout rather than before it is clean, 0/8, so what decides
it is which initialises first and not merely that both are loaded.
No thread setting is involved either – with OMP_NUM_THREADS unset
the reproduction is 8/8 on 16 cores.
f(R) growth, which was the gap¶
theory.Action could solve a general f(R) background from the
action but refused growth="quasi_static", because f(R)’s coupling
is scale-dependent – the scalaron has a Compton wavelength – and
the scale-free mu = 1/f' the teleparallel sectors use would have
been wrong rather than approximate. That refusal was correct and is
now unnecessary:
mu = (1/f_R) (1 + 4m)/(1 + 3m), m = (k/a)^2 f_RR/f_R
with R read off the model’s own integrated background, in
theory.curvature.quasi_static_mu. A brand-new f(R) can now be
fitted to growth data, not only to distances – which matters more
than it sounds, because the background of a modified-gravity model
can usually be tuned to imitate LCDM while its growth cannot.
The algebra was not the risk. The GR-05 notebook in the
companion toolkit derives G_eff from the perturbed field
equations; subtracting its result from the expression above gives
identically zero in Wolfram. That was checked against the
derivation, not against the notebook’s summary of it.
The units were the risk. m is dimensionless only if (k/a)^2
and f_RR are in matching units, and misplacing a factor of c,
of 100, or of a moves the Compton wavelength without making
anything raise – growth would come out smooth, plausible and
wrong. So the load-bearing test holds the new formula against
FRHuSawicki, which performs the same conversion through
independently written code, and gets agreement to 2e-16 across
three scale factors and three wavenumbers. The remaining tests
pin the two physical limits (1/f_R far outside the Compton
wavelength, 4/(3 f_R) far inside), monotonicity in k between
them, and that the departure from GR falls linearly in the
coupling.
That last one is stated as a rate rather than a tolerance, and
the first attempt at it was wrong for an instructive reason:
“mu is 1 when alpha is small” is not well posed, since for any
alpha there is a k where m is not small. What is true at fixed
k is that the departure falls linearly in alpha, and the test now
asserts that.
Two things this is not. It is the linear result – chameleon
screening is non-linear and absent, so where screening matters this
overstates the departure from GR. And a metric action linear in R
still declines quasi_static, which is right: that theory is
general relativity, where mu = 1 exactly and growth="gr" is the
answer rather than an approximation. The error message used to
misdescribe both cases and now says so.
The canonical f(R) can now answer the question its parameter names¶
FRHuSawicki is the f(R) most likely to be fitted against growth
data, and it had neither viability() nor screening() – while
the compiled models had both. It is also the model where the
question is easiest to ask, since f_R0 is f_R(0) - 1, the
quantity the Solar System bound is written on.
Both are there now. screening() compares |f_R0| with the ~1e-6
bound; viability() checks f_R0 < 0 (the scalaron mass squared
goes as -1/f_R0, so positive is tachyonic) and 1 + f_R0 > 0.
The class bounds already forbid positive f_R0, so this is a second
line rather than the first – a fit cannot reach it, a directly
constructed model can.
SOLAR_SYSTEM_BOUND and screening_margin moved from
theory.curvature to cosmology.core.utils, since the Solar System
bound is a fact about gravity rather than about that module’s
reduction, and cosmology cannot import theory. Both names are
re-exported from where they were.
One instructive bug, at exactly the boundary. The first version
computed the deviation as screening_margin(1 + f_R0), which
reconstructs f_R and subtracts 1 again. In exact arithmetic that
is |f_R0|; in floating point it returns 1.0000000000287e-06 for
f_R0 = -1e-6 – three parts in 1e17 above the bound. The bound is
exactly where this class’s default sits, so the round trip failed
the default model, and gave f_R0 = +1e-6 the opposite verdict to
-1e-6 for the same magnitude. Taken from f_R0 directly it is
exact, and the test pins both the default and the sign symmetry.
The gate reports as well as refuses, in both sectors¶
Guarding mu left an asymmetry. A metric model could be asked
viability() and screening() and would answer; a teleparallel one
had neither, so the only way to learn a parameter choice was
unphysical was to call mu and catch the exception. That is fine
for a fitter, which wants the point rejected, and poor for a person
deciding whether a model is worth fitting at all.
coupling(z) now returns f' – the counterpart of the metric
sector’s scalaron(z) – and viability(z) returns the same
{"ok", "failed", "reasons"} verdict, on the single condition
f' > 0. Both hand-written models have them and so does
theory.Action’s compiled teleparallel path, and the two agree
about which models are admissible.
Refusing and reporting are deliberately different operations.
coupling_from_derivative raises, because a caller asking for mu
wants a number and must not be handed a meaningless one.
teleparallel_failures returns a verdict, because a caller asking
whether the model is viable wants an answer, and wants it for a
whole redshift range at once rather than one exception at a time.
Caught by the repository’s own rule that the public surface is
completely annotated: the first version of viability had no type
annotations and test_the_public_surface_is_completely_annotated
said so.
The third mu, guarded – and honestly labelled¶
Having done the metric and teleparallel sectors, the scalar-tensor
mu was the one left:
mu = (2F + 4s) / (F (2F + 3s)), s = sum (dF/dphi)^2
s is a sum of squares, so F > 0 is both necessary and sufficient
for that to be positive and finite – the pole is at 2F + 3s = 0,
which needs F = -3s/2 <= 0. F is what rescales Newton’s
constant, so F <= 0 is a ghost.
Evaluated directly the expression does reach those values:
F = 0, s = 0.5 gives inf, F = -0.5, s = 0 gives -2, and
F = -0.3, s = 0.2 sits on the pole at -6e15.
What could not be produced is a model that gets there. Scanning
xi from +0.05 down to -0.4 with two field starts, every case
that builds returns a positive finite mu; the ones that would not
are refused earlier, by the closure solve leaving its valid region.
So the protection that exists today is incidental – it comes from
an unrelated mechanism – and this guard is defence in depth rather
than a fix for a reachable bug. That is said in the test and in the
code, because a guard whose motivation is not written down looks
like evidence of a bug that was never there.
It costs nothing where nothing was wrong: the healthy branch returns the same numbers to five decimals.
All three mu paths in the library now refuse the values that mean
nothing rather than returning them, and by the same rule in each
case – the coefficient that rescales gravity has to be positive.
The notebook comparison now covers perturbations, not just E(z)¶
The agreement test compared expansion histories. That left the half of the physics a modified-gravity model is actually distinguished by unchecked – and it is the half where a sign can hide, because the two codebases disagree about the sign of the torsion scalar on purpose.
GR-06 puts it at T = +6H^2, with TEGR at f = -T, and derives
G_eff/G = -1/f_T. The theory package puts it at T = -6H^2 and
uses +1/f_T. Two conventions and two minus signs, which cancel, so
the same physical model has to give the same number – and would not
if either were ever flipped alone. The same theory reads
CosmoFit f(T_CF) = T_CF + A0 (-T_CF)^n, T_CF = -6E^2
GR-06 f(T) = -T + A0 T^n, T = +6E^2
and the second is TEGR at A0 = 0, which checks the translation
before any number is compared.
They agree to 1.4e-16 at n = -0.5 and n = 0.2 across
z = 0, 0.5, 1, 2. References regenerated by
cosmofit-mu-reference.wls in the toolkit, which drives GR-06’s own
geffSubHorizon rather than re-deriving anything.
They also agree about where the model is sick, which is the
better half of the result. At n = 0.7 GR-06’s expression gives
mu = -4.44, +25.6, +4.70, +2.38 – negative, then through the
pole. That is exactly what the new coupling guard refuses, so the
guard is declining something an independent derivation calls
unphysical rather than an artefact of this library’s arithmetic.
The same guard for the other half of the library¶
The metric sector got its pole guarded and its viability checked;
the teleparallel and symmetric-teleparallel sectors had neither, and
they hold more models. mu = 1/f' there, and it fails the same two
ways – except that nothing said so.
Measured, FTPowerLaw at n = 0.6 across z = 0, 0.5, 1, 2:
mu = -0.91, -1.79, -4.88, +5.08
Negative is repulsive gravity. The sign change between the last
two is the denominator passing through zero, so the fourth number
was computed on the far side of a singularity. All four are finite,
the growth equation integrates them without complaint, and
fsigma8 comes out perfectly smooth. n = 1.5 gave mu finite at
z = 0 and NaN everywhere else. A fit sampling that region would
have been fitting nonsense with no way to notice.
cosmology.core.utils.coupling_from_derivative now takes f' and
returns 1/f', refusing f' <= 0 and non-finite f'. Both routes
into that expression use it – the hand-written FTPowerLaw and
FQExponential, and theory.Action’s compiled teleparallel path,
which now lambdifies f' rather than 1/f' precisely so the check
has something to look at. f' > 0 is the standard viability
condition for these theories, the counterpart of f_R > 0 in the
metric sector.
Raising rather than returning nan is deliberate: a fitter treats
the exception as a rejected point, which is what an unphysical
region of parameter space deserves.
The two faults are reported separately, which took a second
attempt. Quoting the smallest finite value when the real problem
is a NaN names a perfectly good number as the culprit – the first
version told the user f' = 0.475 was the trouble when 0.475 was
fine and three of four points were NaN.
Nothing healthy changed: n = -1, 0.25, 0.45, 0.8 return exactly
the numbers they did before, and the compiled path still agrees
with the hand-written model to 1e-10.
The arctan f(R), actually fitted¶
With evaluations at 10 s a maximum-likelihood point is reachable, so the comparison stops being “chi2 at LCDM’s parameters” and becomes a fit. Against CC + DESI BAO + Pantheon + fsigma8, 1690 points:
LCDM |
arctan f(R) |
|
|---|---|---|
chi2 |
1498.650 |
1497.807 |
free parameters |
4 |
5 |
|
68.87 |
68.89 |
|
0.307 |
0.294 |
|
0.762 |
0.677 |
|
– |
1.158 |
dchi2 = -0.84 for one extra parameter, so dAIC = +1.16: the
cosmological data cannot tell them apart, with LCDM marginally
preferred for being simpler.
Why it survives the data. At the best fit mu(a=1, k=0.1) = 1.39,
a 39% enhancement of the growth of structure – and sigma8 comes out
11% lower than LCDM’s. fsigma8 is proportional to sigma8, so the
amplitude of the enhancement is absorbed and only its shape in
redshift is constrained. This is why the earlier fixed-parameter
comparison overstated the penalty at dchi2 = +22.5: that was an
upper bound with the amplitude not allowed to adjust, and it is now
measured properly.
What kills it is not the data here. The same best fit gives
|f_R0 - 1| = 0.041, forty-one thousand times the Solar System bound.
The model is theoretically consistent, fits the background and growth
as well as LCDM, and is excluded by local tests by four orders of
magnitude – which is the whole reason screening() is separate from
viability(), and would have been missed by either alone.
An optimizer note, since it nearly cost a wrong number. The first run
had MB among the free parameters, and the Pantheon likelihood
marginalises MB analytically – so it does not enter chi2 at all and
the fit had a completely flat direction, which Nelder-Mead has no way
to handle sensibly. The symptom was visible: the arctan fit pushed
MB to its prior bound while the LCDM fit left it exactly at its
initial value. Rerun without it, the answer was unchanged to five
digits, so nothing was actually corrupted – but a flat direction in a
derivative-free optimizer is not something to leave in on the grounds
that it happened to be harmless.
Making a forward f(R) fit feasible: 4.8x, and one idea that did not work¶
A likelihood evaluation cost 48 s, which put an MCMC at 66 hours. It now costs 10 s, which puts it at 14 – an overnight run rather than an impossible one. Every step was decided by measurement, and the one that sounded most convincing was the one that failed.
Profiling first, which contradicted the guess. The growth ODE
looked like the expensive part. It is not: _solved_closure was 89%
of the call, with 26 background integrations per evaluation.
Separate tolerances for the shot and the answer (2.4x). The
closure is being solved so that E(0) = 1; the integration only has
to be accurate enough to find that root. Going from rtol = 1e-11 to
1e-7 moves E(0) by 6e-8 and costs six times less, because the
equation is stiff and the step count falls with the tolerance. Taking
the loose root as final does not work – the build’s own
E(0) = 1 check is at 1e-8 and rejected it, correctly – so the
search is followed by a secant refinement at full tolerance. Measured
afterwards: the closure is stable to twelve digits along a chain, with
|E(0) - 1| under 4e-12.
A secant warm start rather than a bracket (2x). The first version tried fixed-width brackets around the previous answer. Profiling showed they essentially never bracketed, so every call fell through to the seventeen-point scan and the “optimisation” bought nothing. A secant from the previous root needs no bracket and converges in about three evaluations.
An analytic Jacobian, added and then removed. The equation is
stiff, LSODA was computing ~1950 numerical Jacobians per integration,
and the expressions are already symbolic – so this looked certain.
The Jacobian was correct, agreeing with finite differences to 6e-8,
and it did cut right-hand side calls from 52000 to 48800. Wall time
got 9% worse, at both tight and loose tolerances. The reason is
that this system has two components: a numerical Jacobian costs two
extra right-hand side calls, which is cheaper than evaluating the
symbolic one. Analytic Jacobians pay for large systems and this is not
one. Reverted, with the measurement left in theory/curvature.py so
the experiment is not repeated.
Still on the table, not done: caching the closure across a chain by
interpolating Lam*(Omega_m, Rw), which would remove the shooting
entirely at the cost of an approximation to justify.
Whether a model is consistent and whether it is allowed¶
viability() answers the first: no ghost graviton, no tachyonic
scalaron. It has nothing to say about the second, and the arctan
model showed why that gap matters – the literature reports it as
violating fifth-force constraints (arXiv:1601.07928), while our gate
passed it without comment.
model.screening() closes that. It reports |f_R(0) - 1|, the
fractional departure of the gravitational coupling today, against the
Solar System bound of ~1e-6 from the thin-shell condition (Hu &
Sawicki 2007). Deliberately a separate method rather than another
entry in viability(): one asks whether the theory is sick and the
other whether it is already ruled out, and a single boolean would
have had to pick one and mislead about the other. screening(bound=)
takes the looser galaxy and cluster numbers.
Two things it does not claim. It is the linear estimate – chameleon screening is non-linear, so failing means “excluded unless screening rescues it”, which is how the same quantity is treated in the literature. And passing it is not a recommendation: a model small enough to pass is, for the same reason, hard to tell from General Relativity.
What it says about the arctan model, at parameters held identical to LCDM’s rather than refitted – so the chi^2 gap is an upper bound on the penalty and not a fit result:
|
dchi2 vs LCDM |
|
|
over the bound |
|---|---|---|---|---|
0.3 |
+4.4 |
1.35 |
0.0101 |
1e4 |
1.0 |
+22.5 |
1.38 |
0.0358 |
3.6e4 |
3.0 |
+78.2 |
1.50 |
0.112 |
1.1e5 |
The two right-hand columns are the point. What makes the model
interesting is what excludes it: the same Rw that lifts mu to
1.35 lifts the scalaron four orders above what local tests allow, and
shrinking Rw until screening is satisfied leaves a model
indistinguishable from LCDM to a part in a million. That is the
standard f(R) tension, rederived here from the action rather than
quoted.
A full posterior was not run. At 20-27 s per likelihood evaluation – dominated by shooting the closure, and only slightly improved by warm-starting it from the previous step – an MCMC is ~30 hours, and it would not change a verdict already settled by five orders of magnitude. Making it feasible means relaxing the integration tolerance during the closure search, which is a separate job.
A bug the gate caught in the integrator. Checking viability over
the full redshift range reported f_RR < 0, which this model cannot
produce analytically. The cause was in the forward path’s analytic
continuation: it took the node values from the matter-dominated
solution but the node derivatives from the model’s right-hand side,
and since dR/dN carries 1/f''(R) – minute at high curvature –
asking for the slope at a point not exactly on the model’s own
solution returned an enormous number. The Hermite interpolant then
swung to +-1e12 between correct nodes, and R came out negative at
z ~ 50. H was unaffected, its slope following from
R = 6(2H^2 + H H'), which is why E(z) looked perfectly healthy
throughout. The continuation now carries its own analytic
derivatives.
The other direction, so the interesting f(R) models can be fitted¶
The previous entry ends by saying the fix is forward integration or a designer construction. This is the first of those.
Action(..., background="forward", closure="Lam") integrates from
deep in matter domination towards today instead of away from it.
Backwards, the scalaron’s oscillating mode grows and the problem is
ill conditioned; forwards it decays, so a slightly wrong initial
condition relaxes onto the attractor rather than leaving it. The
arctan model that could not be integrated at all now runs cleanly:
E(0) = 1 to 1e-12, R rising monotonically, f_R -> 1 and
f_RR -> 0 at high curvature, fsigma8 computed.
Two things swap over. R_0 stops being a parameter and becomes
derived – on the attractor the curvature today is not free, and
offering it as an input was always slightly dishonest. And a
closure becomes required, because E(0) = 1 is now the condition
to satisfy rather than the point one starts from. That restores the
same arrangement second-order actions have.
The direction is not universally better and is not chosen
automatically. R + alpha R^2 wants the backward path, where its
mode decays; the disappearing-cosmological-constant family wants
this one. Which a theory needs is a property of the theory, so it is
an argument rather than a guess.
The assumption, and how it is checked. Forward integration
cannot start deep enough for the growth ODE, which wants E(z) to
z ~ 10^4: the scalaron oscillates too fast there to follow. So
below the starting redshift the history is continued analytically,
on the grounds that this family has returned to General Relativity
and matter dominates by five orders of magnitude. That assumption
does real work, so it gets two tests. Moving the junction from
z = 20 to z = 60 must not move Lam*, E(z) or fsigma8 –
and does not. And a model still 1.8% away from matter domination
where it starts is refused rather than spliced.
Three mistakes made writing those tests are worth recording, since each would have left a green test proving nothing:
z_init=_Z_INITas a default argument freezes the starting redshift at import, so the test that moves it moved nothing and reported a difference of exactly zero – a run compared against itself. Read at call time now.The first transient measure compared
Rto a straight line.Rgoes as(1+z)^3, so that number is dominated by ordinary curvature and came out larger near today. It now measures the largest relative drop, which is identically zero for a monotone curve and therefore measures the oscillation and nothing else.With that fixed the transient is 4.3% near the start, not the “part in a thousand” a coarser sampling had suggested and which had already been written into a docstring. Both the bound and the claim were corrected; loosening the bound alone would have left the prose lying.
The transient is tolerable because it is confined: exactly zero
drop over z < 10, which is the range anything is fitted against.
This module is the slowest in the suite at around twenty minutes, since every model has its closure shot for. Noted rather than fixed.
Two gaps in the action namespace, and one in what it can integrate¶
Trying to compile a published arctan f(R) found three things, in
increasing order of interest.
atan was not in the namespace, nor any inverse trigonometric or
inverse hyperbolic function. The arctan f(R) models
(arXiv:1601.07928, arXiv:1310.6915) and the arcsin one
(arXiv:1507.04927) cannot be written without them, so the library was
declining a published family with a name error rather than with a
physics answer. Added.
pi was not either. A bounded correction has to be normalised by
its own saturation value, and an arctan saturates at pi/2; writing
3.14159... instead would be uglier and inexact. Added as the only
constant – sympy’s other one-letter ones, E and S especially,
read as parameter names and would shadow them silently, which is the
failure the namespace check exists to prevent.
And then it still would not integrate, which is the finding worth
keeping. theory.curvature integrates backwards from today, and that
is well conditioned only while the scalaron’s oscillating mode does
not grow into the past – true when f_RR is roughly constant, as in
R + alpha R^2, and false for the whole “disappearing cosmological
constant” family where f_RR falls steeply with R.
Measured, on R - (4 Lam/pi) atan(R/Rw) with Lam tuned so dR/dN
at N = 0 matches an LCDM background exactly: usable only to
z ~ 1.2, after which R turns over. Not the solver’s fault – RK45,
DOP853, Radau and BDF all fail at the same point, and LSODA reports
success while returning a non-monotonic R and NaNs past z ~ 2.4,
which is worse than failing. Not a slightly wrong initial condition
either: scanning R_0 from 8 to 15 never reaches beyond z ~ 1.2,
since the precision needed in R_0 grows exponentially with the
redshift wanted.
The fix is not a better integrator. It is to integrate forwards from
deep in matter domination, where the attractor attracts, or to impose
the background and solve for the f that produces it – the designer
construction FRHuSawicki uses, which is why that model is written by
hand rather than compiled from an action. Documented in
theory/curvature.py so the next person meets the limit as a stated
scope rather than as a mysterious failure.
An admissibility gate for f(R)¶
Inventing an f(R) that fits data is easy and, on its own, worth
little: a free function can be tuned to almost anything. What
separates a proposal from a curve is whether its perturbations
behave, so a compiled f(R) now checks the two standard conditions
along its own background – model.viability(), with
model.scalaron(z) returning (f_R, f_RR) directly.
f_R > 0 keeps the graviton from being a ghost. f_RR >= 0 keeps
the scalaron from being tachyonic – the Dolgov-Kawasaki
instability, whose growth time is short enough that such a
background would not survive to be observed. Zero is deliberately
not flagged: that is the general-relativity limit, where the
scalaron is absent rather than sick, and flagging it would reject
the one limit every f(R) has to pass through.
The reason to automate this is that a non-viable f(R) does not
announce itself. Flipping the sign of alpha in R - 2L + alpha R^2
leaves E(z) smooth, finite and entirely plausible; nothing raises
and the model fits happily. What actually breaks is mu: with
f_RR < 0 the denominator 1 + 3m passes through zero, and past
that pole the formula still returns a finite number. It no longer
does – quasi_static_mu refuses and names the condition, on the
same principle as the Boltzmann guard.
Not in this: the mode count. Confirming from the Hamiltonian
analysis that a general f(R) propagates exactly one extra scalar is
a job for the GR-09 notebook, and it carries a specific trap worth
recording before anyone attempts it. In ADM variables
R = K_ij K^ij - K^2 + (3)R plus total derivatives. Those are
droppable in general relativity, where they multiply a constant;
in f(R) they multiply f'(chi) and are not. Dropping them yields a
plausible and wrong count – the same trap theory.curvature
already documents for the ordinary reduction, which is why that
module uses a Lagrange multiplier instead.
Checked against the notebooks that derive the same physics¶
The wljs-gr-toolkit notebooks reduce a gravitational action on FLRW
in Wolfram Language; :mod:theory.minisuperspace does the same
reduction in sympy. The two share a convention list and nothing else
– different languages, different symbolic engines, different root
finders, written years apart – and they had never been compared.
They agree to 3 parts in 1e16, machine precision, for LCDM and for
the f(T) power law at n = -0.5, 0.2, 0.7, through both routes this
library offers: the hand-written classes in cosmology.models and the
action compiler in theory.Action. tests/test_notebook_agreement.py
pins the references, which the toolkit’s cosmofit-reference.wls
regenerates by loading its GR-02 notebook and driving that notebook’s
own functions rather than re-deriving anything.
The algebra matching is the stronger half. GR-02’s f(T) constraint is
E^2 - 6^(n-1) alpha (2n-1) H0^(2n-2) E^(2n) = Omega_m (1+z)^3
and imposing E(0) = 1 sends the coefficient to 1 - Omega_m, which
is exactly the relation FTPowerLaw was written to solve. Two
implementations arriving at the same equation before they arrive at
the same numbers is what makes the numerical agreement mean something
rather than being two solvers agreeing about one shared mistake.
tests/test_ft.py already pinned Wolfram values for one model from the
GR-06 notebook, so this extends an existing practice rather than
inventing one; what is new is covering GR-02’s engine and the action
compiler.
One incidental result worth recording: GR-02 gives the f(T) and f(Q)
power laws the identical background constraint, because both
scalars are -6H^2 on flat FLRW. That is correct rather than a bug,
and it means background data alone cannot separate those two families
– which is an argument for the growth observables, not against the
models.
v1.0.0¶
The first stable release, and the point where main catches up with
sixty-four commits of dev.
What makes it 1.0.0 is not a feature. It is that the three things the Roadmap had listed since the beginning – a stable public API, complete documentation, test coverage across the whole library – are done and held there by tests rather than by intention. The rest of this entry is the last of that work.
The GUI catches up with three releases¶
CosmoFit.theory landed in v0.36.0 and the GUI had no route to it.
It offered one way to a model the library does not ship – Custom,
which takes an E(z), the result of a derivation somebody did by
hand. Now it takes the input instead: a gravitational Lagrangian,
from which the library derives the Friedmann equation and shows it.
Six worked examples to start from, covering every route the module
has – algebraic, root-solved, Lagrange-multiplier and integrated –
and each a real model rather than a syntax demonstration.
A new Inference tab, for four things the library grew in
v0.36.0 and the app had none of: profile likelihoods (reported as
the Δχ² = 1 crossings, which is what a profile actually measures),
Fisher matrices with the ratio to the chain (the comparison is the
point – it is a Gaussian approximation, and that is the only way to
know whether it holds here), Bayesian evidence by nested sampling,
and tension by two definitions that disagree exactly when the
Gaussian one has stopped meaning anything.
Two smaller gaps: the eboss_surface figure, which is what shows why
a Gaussian summary of the two tabulated eBOSS datasets would be
wrong; and best_fit(restarts=), which exists because ΛsCDM once fit
worse than the ΛCDM it contains.
And two ways the GUI could hand somebody a result that looks like a
measurement and is not. ADE derives Omega_m and the parameter
table ticked it anyway, so choosing that model put the app straight
into the state Fitter warns about – a posterior that is the prior
back again. And the warning itself was invisible: Fitter warns
through warnings, which in a Streamlit app means stderr, which
means nowhere. The run loop catches them and renders them now, so
every guard the library grows later shows up without the GUI
restating any of it.
The package is typed¶
py.typed ships. That tells every downstream type checker to trust
these annotations, so it had to be true first: 201 of 201 public
callables fully annotated, from 58. CosmoFit.typing spells out the
two aliases the whole surface is written in – E(0.5) returns
np.float64 and E([0.1, 0.2]) returns an ndarray, so annotating
the return as np.ndarray alone would have been wrong for the
commonest call in the library.
Two tests hold it there: every annotation resolves, including the
ones deferred to TYPE_CHECKING (which is what a checker sees and
get_type_hints cannot – matplotlib.figure costs 0.65 s to
import, more than the rest of the library, and nobody who never draws
a figure should pay it), and nothing on the public surface is left
unannotated.
Running mypy over it for the first time found four annotations that
were simply false: load_chain declared MCMCResult and returns
a StoredSampler, theory.fields.build_system declared one object
and returns two, BaseLikelihood.model declared an array where
S8Likelihood returns a number, and EnsembleSampler.run narrows
its base class’s **kwargs into named parameters. All corrected –
which is exactly the class of thing py.typed would otherwise have
started telling other people’s type checkers.
mypy reports 81 remaining errors, all inside function bodies rather
than in the signatures py.typed is a promise about. It is in the
dev extra so a contributor can run it; CI does not gate on it yet.
Two workflows that nothing can trigger by accident¶
publish builds, runs twine check --strict, and uploads to PyPI
through Trusted Publishing – GitHub mints a short-lived OIDC token
and PyPI accepts it in place of an API token, so no long-lived secret
lives in this repository at all. pages deploys the API reference to
GitHub Pages.
Both are workflow_dispatch only, and that is the design rather
than a placeholder. A version number, once published, can never be
reused; a site, once deployed, is on the public internet under this
repository’s name. Neither should be able to happen as a side effect
of pushing a tag. Someone has to open the workflow and run it, and
each needs one thing set up on the other side first – a Trusted
Publisher on PyPI, Pages enabled here.
The release, rehearsed¶
Everything that could be checked before a first publish was, rather than being left to find out on the day:
cosmofitis free on PyPI and TestPyPI.Both artifacts pass
twine check --strict. The wheel is 22 MB against PyPI’s 100 MB per-file limit – 10 MB of it the Pantheon+ covariance – so no limit increase is needed.The sdist was incomplete, and nothing would have said so. The bundled data travels because it lives inside the package, but
CITATION.cff,REFERENCES.md,CHANGELOG.mdandCONTRIBUTING.mdsit at the repository root and were simply absent. AMANIFEST.inadds them and prunesdocs/,examples/,app/andtools/, which have no business in a source distribution. The citation file is the one that matters: this library bundles other people’s data and implements other people’s methods, and the file saying how to credit them should travel with the source rather than living only on a web page.The wheel was installed into a clean virtual environment, outside this source tree, and fitted ΛCDM to CC+DESI+Pantheon++Planck – 1671 points, H₀ = 67.5, Ω_m = 0.313 – with
py.typedpresent and thetheoryextra correctly absent and correctly explaining itself.
That last check earned its place immediately: it found a fifth
false annotation, and then a sixth. Fitter.best_fit was declared
-> BestFitResult and returns scipy’s OptimizeResult;
Fitter.run_mcmc was declared -> MCMCResult and returns emcee’s
EnsembleSampler. Both were wrong the same way – named after the
object that lands on fitter.result rather than the one handed back
– and neither mypy nor the resolution test could see it, because
scipy and emcee both return Any.
So there is now a test that calls ten of Fitter’s methods and
compares what comes back against what was declared. With py.typed
shipped, a wrong annotation is no longer a private mistake; it is
something other people’s type checkers repeat with confidence.
GitHub Pages is enabled with Actions as the source, which fixes the
documentation URL, so [project.urls] gains a Documentation entry.
Run pages before publish and it is live rather than a 404.
main catches up¶
Sixty-four commits, and the reason this is 1.0.0 rather than 0.41.
main had been deliberately frozen at v0.22.0 since August while
everything went out as -dev pre-releases; it now carries all of it.
That freeze had one consequence nobody had noticed: a
workflow_dispatch workflow is only visible to GitHub if the file
exists on the default branch. tests worked because it is
push-triggered, but publish and pages returned 404 to every
attempt to run them. They could not have been run at all until
main moved – so the last item on the Roadmap turned out to be a
prerequisite for the two before it rather than a tidy-up after them.
The Changelog and Examples entries in [project.urls] move from
dev to main with it, as does the “edit this page” link in the
API reference. Development Status goes from 4 - Beta to
5 - Production/Stable.
And with main moved, both workflows could finally run:
the API reference is live at https://salihyesil59.github.io/CosmoFit/;
pip install cosmofitworks – https://pypi.org/project/cosmofit/.
Published to TestPyPI first, installed back down from that index into a clean environment, and checked to give the same fit to the digit before the real one was touched.
And the notebooks stop cloning the repository¶
All seventeen used to open in Colab by cloning dev and installing
it editable, which then needed a sys.path fix: pip runs in a
subprocess, so the already-running kernel never saw the .pth file
it wrote, and import CosmoFit could resolve to the bare checkout
directory instead – same name, and /content is on sys.path. Six
slightly different versions of that workaround had accumulated.
A published package removes all of it: pip install cosmofit, into
site-packages, which the kernel is already looking at. Each notebook
asks for the extras it actually needs, and the Colab badges point at
main rather than dev.
Two things fell out of checking rather than assuming. lscdm_mcmc
looked like it needed CAMB because it mentions planck_lite – in a
section explaining why the full spectra cannot be used there.
And dataset_zoo genuinely does need it, which the extras table in
examples/README.md had not said. dark_energy_evidence_audit was
the one notebook with no Colab badge at all, while that README
claimed every one of them was Colab-ready; it has one now.
v0.40.0¶
Everything the Roadmap listed for v1.0.0, worked through against what the repository actually measured rather than against recollection. No new physics: this is the release where the library stops being a research codebase that happens to be public.
The release history had stopped at v0.25.0¶
Fourteen releases – the whole of CosmoFit.theory, Bayesian
evidence, profile likelihoods, Fisher matrices, tension statistics,
the three holographic models, the performance pass, the examples
reorganization – existed only as GitHub release notes. The
repository did not record its own last month of work.
That history is now this file, and the README lost half its bytes (98 KB to 49 KB) getting it out of the way: the old “Project Status” section had grown to nearly a thousand lines, so a reader looking for “what is this library” scrolled through a year of release notes to reach the Roadmap.
A public API that is pinned¶
Nothing was holding it. Every other test imports what it happens to
need, so a name could be renamed, moved between subpackages or
dropped from __all__ and the suite would go on passing as long as
some path to the object still existed.
tests/test_public_api.py types the surface out by hand – 64 names
– and asserts the set in both directions. An extra name is a promise
nobody meant to make; taking it back out is then itself a break.
Writing it down found two names that had already drifted out of
CosmoFit.cosmology while every one of their siblings was
re-exported: ModelConfigurationError, which is the one
exception a user is asked to tell apart from an ordinary failure and
was reachable only as
from CosmoFit.cosmology.core.errors import ..., and
GrowthCalculator. Both are importable from the top level now, and
a further test asserts that each layer of cosmology is re-exported
whole, so the class of drift is closed rather than the two
instances of it.
Two more things the file checks. Importing the library must not
import an optional backend – which caught numba: kernels.py
imported it at module scope, so import CosmoFit paid 115 ms, 17% of
the whole import, for a kernel only a growth fit ever calls. It is a
spec lookup now and the compilation happens on first call; 0.66 s to
0.56 s, with the two stepping paths still agreeing to 1e-13. And
every annotation on the public surface resolves – these modules
use from __future__ import annotations, so a wrong annotation is
invisible until something calls get_type_hints, which nothing did.
Test coverage, where the measurement said to look¶
93% overall, from 79%, across 681 tests. The Roadmap’s
phrasing – “across the whole library, not only the newest parts” –
turned out to be exactly right: theory, the newest subpackage, was
at 86-94% while the oldest code was not.
was |
now |
|
|---|---|---|
|
16% |
94% |
|
37% |
100% |
|
58% |
97% |
|
64% |
92% |
|
67% |
96% |
|
70% |
100% |
|
73% |
100% |
|
78% |
93% |
|
85% |
100% |
The plotter is the largest module in the library and 16% of it had
ever run. Nothing anywhere called a single plotting method. It is
also the worst place for a gap, because a plot fails quietly – an
empty axis, a curve at the wrong scale, a band that collapsed to a
line. Nothing raises; the figure is saved and somebody looks at it.
So the assertions are about content: axis labels set, artists
carrying points, one trace panel per free parameter with one line per
walker, six axes for the three CMB spectra and their residual panels.
Three of them are not about drawing at all – that a missing dataset
names itself instead of raising AttributeError on a None, that
the band appears only once there is a chain, and that drawing a band
leaves the cosmology where it found it (a band evaluates hundreds
of posterior draws; without the restore, every number read off the
fitter after a plot would come from whichever draw happened to be
last).
Elsewhere the untested half was consistently the rejection paths
and the code that leaves the process: the chain-signature machinery
that decides whether a saved chain may be resumed (the quietest
scientific error the library can make – one array of samples drawn
from two different posteriors, with nothing to say so); the JSON
encoder standing between numpy and a json module that rejects
everything numpy produces; both guards on LogPosterior.chi2, which
exist because L-BFGS-B once evaluated the objective at
[nan, nan, nan]; and both paths through DenseCovariance.solve,
including the fallback that discards a precomputed inverse which
fails its own accuracy check.
The sharpest test of the batch is in cpl_diagnostics: the
Mahalanobis distance is not the significance. D^2 follows
chi-square with two degrees of freedom, so D = 2.20 is 1.70 sigma,
not 2.2 – and reporting distance as sigma overstates the tension
with LCDM, which is the direction a dark-energy paper would like it
to be wrong in.
An API reference, and ten docstrings that rendered wrong¶
pyproject.toml had declared a docs extra – sphinx and furo –
for a repository with no docs/ directory. There is one now:
autosummary over the public API, napoleon for the numpydoc
docstrings, intersphinx, and myst-parser so README.md and
CHANGELOG.md are included verbatim rather than copied into a
second, drifting version.
Building it produced 237 warnings, and they were not cosmetic:
indented equations are reStructuredText block quotes, and a continuation line beginning with
*,+or-becomes a bullet list inside one – so GEDE’sOmega_de(z), IDE’s continuity equations, GCG’s density evolution and the curvature branches ofDistanceCalculator.DMwere all being parsed as prose with lists in the middle;GCG,FRTLinearandFRHuSawickieach had a numpydocParameterssection whose body is a paragraph, which napoleon renders as a field list that the prose then breaks;|beta|,|p/rho|and|a - b|– absolute-value bars in prose – are reStructuredText substitution references, and each was an error for an undefined name.
The build is clean under -W now, and CI keeps it there.
Packaging, and three CI jobs¶
[project.urls] did not exist, so a PyPI page would have been a
rendered README and a dead end. Four classifiers that matter for a
scientific package were missing, including both
Scientific/Engineering topics – which is how somebody browsing for
a cosmology library would ever find this one – and 3.12/3.13, which
CI had been testing for months. The license moved to an SPDX
expression.
black came out of the dev extra. It was listed and nothing used
it, which is worse than it sounds: a contributor taking the extra at
its word would reformat 101 of the 117 files under src/ and
tests/, and this code is hand-wrapped. ruff is configured as a
linter only, with three rules ignored and a reason for each.
New CI jobs: lint (which found seven dead or shadowed names the
test suite could not see, and later refused a blind
pytest.raises(Exception) of mine), python -m build +
twine check – the half of a release that cannot be found by
running the code – and docs, building with warnings as errors.
Also¶
CONTRIBUTING.md and CITATION.cff. The first explains why there is
no formatter and what a finished change looks like here; the second
is what GitHub’s “Cite this repository” button reads, and it says
plainly that the papers behind whatever you used matter more than the
software entry.
v0.39.2¶
No new capability. This closes a gap in what was tested, corrects two error messages that had fallen behind, and walks back a Roadmap claim the previous release had itself added. 501 tests.
The two things a runtime-built model cannot do¶
Three routes now produce a Cosmology subclass that exists only in
the session that made it – define_model (an E(z) function),
model_from_expression (an E(z) string) and
CosmoFit.theory.Action.build (an action). Everything about fitting
works on them, with two exceptions, and both follow from one fact:
such a class cannot be pickled by reference, because there is no
importable name to pickle it to. run_mcmc(n_processes>1) sends the
model to worker processes; Fitter.from_chain re-imports the model
the chain was sampled with.
Both were already guarded, and guarded well – run_mcmc refuses up
front rather than letting the pickling failure surface from inside a
worker, where the traceback says nothing about the model. But neither
had a test, and neither message mentioned Action.build(), so
somebody who had written an action was told about two functions they
had never called.
Messages corrected, and tests added across all three routes. The one
worth more than the error text: the from_chain workaround actually
works – rebuilt the same way, the chain reloads and gives back the
same posterior to 1e-12. A message promising a fix that does not work
is worse than no message.
A Roadmap entry walked back¶
v0.39.1 rewrote the Roadmap, and one of the entries it added
claimed more than it should have. It said the models’ own E(z)
counts massive neutrinos inside Omega_m as pure matter – true as
far as it goes. But the three places E(z) is consumed above z ~ 100
are each already handled: the sound horizon integrates the exact
Fermi-Dirac density (and validates against CAMB’s rdrag to 5e-5);
the compressed Planck priors deliberately keep massive neutrinos
inside Omega_m at every redshift, because a prediction has to share
the compression’s definitions rather than improve on them; and the
growth ODE solves a matter-plus-dark-energy equation, so starting it
deep in that matter era is self-consistent. The item is real but far
narrower than it read, and doing it carelessly would break agreements
the library has already validated. The Roadmap now says so, rather
than leaving a trap for whoever picks it up.
v0.39.1¶
Neither commit adds a model: the first closes two ways a theory-built model could give a wrong answer with nothing said, and the second catches the documentation up with four releases of work. 491 tests.
Growth of structure, where a theory model was silent¶
Two failures of the same kind – the answer is wrong, and nothing says so.
A configuration error was being swallowed into infinity.
LogPosterior.chi2 turns exceptions into an infinite chi-squared,
which is right for a parameter the model cannot represent: a sampler
that merely proposed it should not crash, and treating the point as
the worst possible fit is exactly what excluding it means. It is wrong
for a configuration it cannot represent. The growth ODE starts at
z = 9999 and a field action’s history starts at z_init, default
3000 – so Fitter(model=<any field model>, datasets=["fsigma8"])
built fine and then returned chi2 = inf at every parameter value,
a fit that ran to completion having learned nothing and saying
nothing. The message existed and already named the fix; it just never
reached anyone. New ModelConfigurationError, deriving from
Exception rather than from any of the three caught there, so it
propagates – and it now names the growth ODE’s starting redshift,
which is the number needed to pick z_init.
Scalar-tensor gravity reported mu = 1. In that theory the field
sets the strength of gravity, so G_eff/G_N is neither 1 nor
constant. Fitting fsigma8 with mu = 1 gives General Relativity’s
growth attached to a modified background: finite chi-squared,
plausible posterior, no way to tell. growth="quasi_static" now
covers it, with the sub-horizon result of Boisseau,
Esposito-Farese, Polarski & Starobinsky (2000),
mu = (2F + 4 F_phi^2) / (F (2F + 3 F_phi^2)), evaluated on the
field’s own solution; F = 1 with a constant coupling gives exactly
1, which is the normalization it has to satisfy. And Fitter now
warns when a non-minimally coupled model meets growth data with mu
still 1 – the one case where the answer is silently wrong.
Checked along the way and found correct: compute_rd=True works for
every kind of theory model, since it uses the early-universe densities
rather than the late-time E(z) and so never reaches past z_init.
The documentation had fallen four releases behind¶
An audit rather than a feature, and it found things a reader would
have been misled by. The Roadmap listed five delivered features as
still missing – eBOSS DR16 ELG and Lyman-alpha, Planck lensing, ACT
DR6, Planck low-l, holographic dark energy – and was rewritten to
what is actually left. The Features list did not mention
CosmoFit.theory at all, the largest addition of four releases,
invisible where a reader starts. REFERENCES.md had nothing for the
theory module although the code cites three papers; added, with every
arXiv identifier verified against arXiv’s API rather than written from
recall, and saying which is validation the module is checked against
and which is a perturbation result it imports.
v0.38.1¶
Housekeeping over two capabilities the notes recorded as “should work, but untested” – and one of them turned out not to work at all. 484 tests.
Scalar-tensor gravity, which four places already said worked¶
The minisuperspace docstring, reduce_order’s error message,
fields.py and the README all stated that a non-minimally coupled
F(phi) R reduces correctly. The reduction does handle it. It could
not be written: the namespace the gravity expression is parsed
in carried the cosmological parameters and the geometry scalar and no
field names, so Action("(1 + xi*phi**2)*R", fields={"phi": ...})
failed with Unknown name(s): ['phi']. An entire model class –
Brans-Dicke, induced gravity, anything where the field sets the
strength of gravity rather than sitting on top of it – was
unreachable while being advertised.
Two things had to follow. The field has to become a function of time
in the gravitational sector too: arriving as a plain symbol,
F(phi) differentiates to zero, the 3 H dF/dt term vanishes, and
what is left is a rescaled General Relativity with a Friedmann
constraint that still looks entirely reasonable. That is the same
failure the field Lagrangian had two releases earlier, which is why
the test does not only check the ratio to the textbook form – it
asserts the constraint genuinely depends on the field’s velocity,
which a coupling that had quietly disappeared could not produce. And
a field cannot be named R, T or Q: it would be the same
symbol as the geometry scalar in every expression, and the scalar
would silently win.
Validated three ways: the derived constraint equals
3 F H^2 + 3 H dF/dt = rho symbolically, ratio exactly 1; xi = 0
returns LCDM to 1e-9 with the closure recovering 3 Omega_de0; and
xi of either sign moves E(z) by more than 1e-4 with constraint
drift below 1e-10.
Multi-field actions¶
The other untested claim, and this one already worked. Two exponential
quintessence fields reduce to two equations of motion, integrate
together, and leave the closure condition one parameter to solve for
– E(0) = 1 to 1e-9, drift 2.6e-13. Now tested rather than assumed.
v0.38.0¶
Removes the theory module’s most conspicuous refusal – the one you
were most likely to hit first, since "R + alpha*R**2" is the obvious
thing to type. A general f(R) builds, with nothing to ask for:
Action detects that the action is nonlinear in R and routes there
itself. 478 tests.
Why it needed a different reduction¶
The ordinary reduction removes the addot in the Einstein-Hilbert
term by integrating by parts, which is legitimate only while the
Lagrangian is linear in it. For a general f(R) it is not – the
term that would be dropped is not a total derivative, and dropping it
returns a different theory with nothing to show for it.
The way round is to stop treating R as shorthand for a combination
of a and its derivatives and make it an independent variable,
held to that combination by a Lagrange multiplier. Varying the
multiplier returns R = R_geom; varying R gives
lambda = N a^3 f'(R); substituting back leaves
L = (1/2) N a^3 [f(R) - f'(R) R + f'(R) R_geom], which is linear in
addot again. So reduce_order applies unchanged, at the cost of one
extra dynamical variable – and that variable is the theory’s fourth
order. It surfaces as a parameter, R_0, the Ricci scalar today:
an initial condition General Relativity does not have.
What comes out is smaller than “fourth-order” suggests. Varying R
returns R = 6(2H^2 + H H'), an explicit H'; the constraint is
linear in R', giving an explicit R'. Two first-order equations in
(H, R).
It integrates backwards, and theory.fields does not¶
Not an inconsistency, and measured rather than asserted. A scalar
field integrated backwards runs away – the Hubble friction that damps
it forwards becomes anti-friction. The f(R) scalaron does not: a
1e-8 relative kick to R_0 moves E(z) by less than that out to
z = 1100, amplification below 1. So there is no shooting here and no
early initial condition, and closure= is refused rather than
accepted and quietly ignored – there is nothing left for it to fix.
The accuracy measure had to change too¶
In theory.fields the Friedmann constraint is a first integral that
the integration never imposes after the initial conditions, so its
drift is an independent measure of the error. Here the constraint is
one of the two equations that define the right-hand side: its
residual is zero by construction and measures nothing at all. A drift
check copied across would have looked reassuring and said nothing.
What is left over is the third equation of motion, from varying a,
which follows from the other two by the Bianchi identity – it holds
on an exact solution and drifts on an approximate one. It comes out at
1e-15.
Validated three ways: the derived constraint equals the textbook
3 f_R H^2 = (f_R R - f)/2 - 3 H d(f_R)/dt + rho symbolically;
alpha -> 0 approaches LCDM smoothly rather than merely resembling
it at one point (6.4e-01, 1.8e-01, 4.2e-03 for alpha = 1e-1, 1e-3,
1e-5 over z <= 5); and the routing boundary holds – R - 2*Lam stays
on the ordinary path, any nonlinearity in R does not, and
f(T)/f(Q) are never diverted.
dR/dN carries 1/f''(R), which vanishes in the GR limit, so a model
very close to GR is stiff – slow rather than wrong: alpha = 1e-3
integrates in about 3 ms, alpha = 1e-9 in about 650 ms. Measured, and
said in the error message when the integrator gives up.
growth="quasi_static" is refused for f(R), whose mu is
scale-dependent – a Compton wavelength enters – so the scale-free
1/f' of the teleparallel sectors would be wrong rather than
approximate. FRHuSawicki already carries the standard form.
v0.37.3¶
One notebook, closing the gap the previous release ended by naming. Seventeen notebooks; 463 tests.
planck_lite – the CMB computed from scratch rather than compressed
to three numbers – had exactly one example, and it was a research
analysis. Fine if that is your question; no use at all for finding out
what the dataset is and whether you want it.
01-getting-started/cmb_from_scratch.ipynb is the ten-minute version,
and makes four points, each with a measurement.
What it costs: 0.2 ms per evaluation for the three compressed
numbers against 766 ms for the 613 computed bandpowers – three
orders of magnitude, measured live and on new parameter points so
the CAMB cache does not flatter the result. Which is why the notebook
uses best_fit and a Fisher matrix and says plainly that an MCMC here
takes days.
What it refuses: CAMB solves the perturbation equations of General Relativity, so a modified-gravity model is turned away rather than quietly handed GR perturbations.
Why it cannot pin tau on its own: plik_lite starts at l = 30
and reionization’s signature lives below it, so the high-l spectrum
measures A_s e^{-2tau} and not its two factors. Doubling tau alone
costs +4562 in chi-squared; doubling it with the amplitude
following costs -5 – nothing, and if anything preferred. That
contrast is the entire argument for adding "tau" or
"planck_lowe", and it is two lines of output.
That it works: six parameters from TT/TE/EE and a Gaussian tau
prior alone land inside 0.7 sigma of Planck 2018 on every one, and a
Fisher matrix reproduces Planck’s published uncertainty budget in 60
seconds – ratios 0.93 to 1.01 across all five – where a chain would
have taken days.
v0.37.2¶
About the examples. The library had gained models from an action,
scalar fields, evidence, profile likelihoods, tension statistics and
three holographic models, and examples/ showed none of it: nine
notebooks in a flat directory, covering the analyses the library had
grown out of rather than what it can do. Sixteen notebooks now, in
five sections with an index. 463 tests.
Seven new notebooks¶
Every one executed end to end against real data, with short chains (~3000 steps, except where the model itself is expensive and the notebook says why).
custom_models– the three routes to your ownE(z): an expression string, a function, aCosmologysubclass. Plusmu(a,k)for modified gravity, and what a singular Fisher matrix tells you.models_from_an_action– rederives LCDM to 2e-16 andFQExponential’s transcendental constraint to 1e-15, fits a power-lawf(T)the library does not provide, and demonstrates all three refusals with their actual messages.scalar_field_models– both Copeland-Liddle-Wands attractors reproduced to five decimals, the wall where a steep potential cannot reach the requestedOmega_m, and why the field’s initial conditions go early rather than today.holographic_family– HDE, ADE and RDE against arXiv:2607.09732, including that paper’s split between probes, which falls out without being used as an input: supernovae pullcto 1.183, BAO to 0.930, combined 0.999.evidence_and_model_selection– and the three tools disagreed, which was not staged. wCDM against LCDM on CC+DESI+Pantheon+:Delta chi2 = 3.46for one parameter (p = 0.063), AIC favours wCDM, BIC favours LCDM, andln B = -0.83prefers LCDM – the opposite direction fromDelta chi2. Nothing is wrong with any of them; they answer different questions.profile_likelihood_and_fisher– Fisher against MCMC (ratios 0.87 to 1.07 here), profile against marginal, and why a parameter on a boundary breaks the usualDelta chi2reading.tension_statistics– four definitions of “they disagree at 4 sigma”: Gaussian, sample-based, N-dimensional, and suspiciousness, which divides the prior dependence out.
dataset_zoo covers all 21 datasets¶
It covered eight, and said “all eight” – written when that was true
and left behind by thirteen additions since. Three things the old one
could not say, because everything it knew about was Gaussian: that
three datasets ship a likelihood surface rather than a mean and a
covariance; that a tabulated likelihood’s chi-squared has an
arbitrary zero point (planck_lowe reads 396 for 28 points and is
not a bad fit – only differences in it mean anything); and that there
are sixteen conflicting pairs, against the two the old notebook
named by hand, read from CONFLICTING_DATASETS itself so the notebook
cannot drift from the code.
Three bugs the examples turned up¶
All of the kind that only surface when someone follows the documentation rather than the tests.
A direct Cosmology subclass could not be constructed. The
distance integrator interpolates 1/E(z) with a Hermite spline built
from dEdz – that is what makes it exact to fourth order instead of
second – so it needs the derivative from __init__ onwards.
define_model installs a central-difference fallback; subclassing
deliberately does not, so a model can supply the exact derivative.
That choice was made when dEdz only fed the deceleration plot, and
the switch to Hermite splines silently promoted it to a hard
requirement. Nothing said so: the abstract methods raised a bare
NotImplementedError from inside __init__, nowhere near the missing
method – and the subclassing example in the README did not run.
Fixed in both directions.
An invalid value in an expression model arrived as
KeyError: '__import__'. model_from_expression evaluates with
builtins removed, and an optimizer restart landing where the
expression goes invalid is fine – E(z) comes back non-finite,
chi-squared does too, the point is rejected. What it could not survive
was numpy reporting it: emitting a RuntimeWarning runs
warnings.warn, which needs the builtins that were removed. Fixed by
evaluating inside np.errstate(all="ignore"); adding __import__
back would have opened the door the empty builtins dict exists to
close.
The eBOSS Lyman-alpha likelihood could not be evaluated at all under
SciPy >= 1.18. That release changed RectBivariateSpline.ev to
return a length-1 array where a scalar query previously gave a 0-d
one, so float(log_likelihood()) raised TypeError. The 3-D branch
already restored the caller’s shape; the 2-D one now does too.
v0.37.0¶
Finishes the piece the previous release deliberately left out.
CosmoFit.theory could already reduce an action with scalar fields,
but build() refused, because the coupled system has to be integrated
rather than solved redshift by redshift. It no longer refuses. Each
field adds two parameters: its value and dphi/dN at z_init. 458
tests.
Where the equations come from¶
Nothing further was typed in. The Friedmann constraint is a first
integral of the equations of motion, so rather than separately
varying a to get the acceleration equation, this differentiates the
constraint along the solution and solves that together with the field
equations. The two routes are equivalent by the Bianchi identity, and
this one reuses expressions already in hand – and leaves the
constraint as an independent check, imposed only in the initial
conditions and never again, so its drift measures the integration
error rather than restating the tolerance. It stays at 1.7e-13.
A design decision, got wrong first¶
The field’s state is set at z_init (default 3000), not today. The
first version set it at a = 1, which is far more convenient: H = 1
holds there by the definition of H0, so E(0) = 1 is algebraic and
no shooting is needed at all.
It is also wrong. Integrating backwards from a field at rest
today, the Hubble friction that damps the field forwards in time
becomes anti-friction, the generic past solution is kinetic-dominated,
and rho_phi grows as a^-6. For exponential quintessence normalized
to today’s dark-energy density that gives E(2.5) = 9.3, where LCDM
gives 3.7. Nothing about the calculation fails. It is the correct
past of those initial conditions, and those initial conditions are not
a universe. This is the same trap ADE was rewritten out of one
release earlier – worth recording as a pattern rather than an
incident.
So the state is given where a quintessence model actually gives it,
early and typically frozen, the system is integrated forwards, and
E(0) = 1 becomes a shooting condition on closure=. Forwards is
also the numerically stable direction.
Validation: Copeland, Liddle & Wands (1998)¶
Their late-time attractors for an exponential potential are exact
functions of the slope alone, so there is nothing to tune. On a matter
background there are two, selected by lambda^2 against 3, and
both are reproduced to five decimals, from the action: the
field-dominated attractor w = -1 + lambda^2/3 at lambda = 0.5, 1.0,
1.5, and the scaling attractor at 1.9 and 2.0, where the field
tracks the matter it scales against at a fixed fraction
3/lambda^2. That fraction is the sharp test – a pure prediction of
the slope with no freedom left anywhere in the model.
It is also why a steep potential cannot accelerate: ask such a model
for more dark energy than its attractor allows and H(0) saturates
below 1 with no potential scale reaching it. That now arrives as a
statement about the model rather than as a stalled integrator. A
constant potential pins the other end – a cosmological constant
written as a field, reproducing LCDM to 1e-10.
Two things measured rather than assumed¶
Comparing integrators at equal tolerance is the wrong comparison. Radau looked clearly best that way. At equal accuracy, which is the comparison that decides anything, DOP853 reaches a tighter drift in 3.4 ms against 79.
w and Omega_de must be read off the field’s Lagrangian, not
off the background. The generic route – subtract the fluids from
E(z)^2 – cancels catastrophically once matter dominates: at
z = 2000 it is 2.4e9 minus 2.4e9 to get 0.7, nine digits gone. It was
returning w = -5.8 where the answer is -1. With p = L and
rho = 2 X L_X - L there is nothing to cancel. The fieldless models
keep the background route, since a modified gravitational sector has
no Lagrangian pressure to read, and its docstring now says where it
stops being trustworthy instead of leaving that to be discovered.
About 37 ms per parameter set, down from 58 by warm-starting the
shooting from its previous solution and carrying the secant slope with
it. Grid density turned out to be free – DOP853’s dense output is
cheap and the cost is all in the steps – so it is set for accuracy,
5e-12 relative in E(z).
v0.36.0¶
Where the last release was entirely about speed, this one is about
what the library can say: a way to build a model from its action
rather than from an E(z), the inference tools needed to judge
whether a model’s improvement is real, and two new dark-energy models.
20 models (was 18), 439 tests (was 354).
Models from an action¶
Every model in cosmology.models encodes the result of somebody’s
derivation: an E(z) transcribed from a paper. define_model lowered
the barrier to adding one but not the work – it still wants E(z).
CosmoFit.theory takes the input instead. What comes back is an
ordinary Cosmology subclass, so every dataset, likelihood, sampler
and plot works on it unchanged; Action.constraint() returns the
derived Friedmann equation symbolically, if the derivation was the
point rather than the fit.
The lapse is the whole trick. FLRW is written with an explicit
N(t), the action is reduced to a point-like Lagrangian in a,
adot and N, and varying the lapse gives the Friedmann
constraint – N is a non-dynamical gauge degree of freedom, which
is precisely why its variation produces a constraint rather than an
evolution equation. Setting N = 1 afterwards recovers the familiar
form; writing N = 1 from the start loses the equation altogether.
It rederives what the library already had by hand: R - 2*Lam gives
LCDM including curvature to 1e-16 in E(z) and dE/dz;
Q*exp(lam*Q0/Q) gives FQExponential with an identical constraint
and agreement to 1e-13; and T + A0*(-T)**b at b = 0 gives
LCDM again through the torsion sector. Lam = 3 Omega_de0 is
written nowhere – it falls out of the closure condition. The f(Q)
row is the demanding one: its constraint is transcendental and the
hand-written model inverts a Lambert W, so the derivation has to
produce the transcendental form from the action alone and the solver
has to land on the branch W_0 picks rather than on any root of it.
It refuses rather than approximates. A general f(R) is refused
(fourth-order; the integration by parts that removes addot would be
discarding something that is not a total derivative). An action that
does not satisfy E(0) = 1 is refused – it would predict every
distance wrong by a constant factor while looking entirely healthy.
And growth="quasi_static" is opt-in, because mu = 1/f' is a
statement about perturbations, which a background action does not by
itself determine. The three geometry scalars carry different signs
across the literature, and choosing wrongly inverts every modification
built on one with nothing downstream to complain, so they are fixed by
requiring an undeformed f to reproduce General Relativity exactly –
asserted in all three sectors rather than trusted.
Two bugs the tests caught, both a correct-looking Friedmann equation
hiding wrong physics: the scalar field arrived as a plain sympy
Symbol rather than phi(t), so V(phi) differentiated to zero and
the Klein-Gordon equation was wrong while the constraint looked
perfectly reasonable; and a root-finder turned loose on the whole
redshift range can switch branches of a transcendental constraint and
return a discontinuous E(z) without failing, hence solving by
continuation out from z = 0 where the closure condition guarantees the
root.
sympy is a new optional extra; CI asserts the minimal install still
works without it.
Bayesian evidence, profile likelihoods and Fisher matrices¶
Three things the notebooks kept building by hand or disclaiming:
stats.nested (dynesty), Fitter.profile() and Fitter.fisher().
And the first thing they did was change a verdict. LsCDM’s
Delta chi2 = 5.40 over LCDM had looked like real evidence; its Bayes
factor is ln B = +0.494 +/- 0.319 – inconclusive. The reason is
visible in the posterior: the transition redshift’s 95% interval runs
to [2.5, 96.6], so most of its prior volume does nothing and the
Occam factor eats the improvement. That prior sensitivity is now
measured rather than asserted – narrowing the range gives
ln B = +1.158. stats.tension adds Gaussian 1-D/N-D, sample-based
and suspiciousness statistics.
Four bugs these surfaced, three of them found by an answer that was impossible rather than merely surprising:
best_fit()settled in local minima – found by LsCDM fitting worse than the LCDM it contains, byDelta chi2 = -0.63.best_fit(restarts=n)added.best_fit()left the cosmology at the last evaluation, not at the best fit – which made a whole chi-squared breakdown table read +0.00 everywhere.A NaN parameter vector crashed from inside
refresh(). L-BFGS-B started where chi-squared was inf, computedinf - inffor the gradient, and evaluated at[nan, nan, nan];_apply()sat outsidechi2’s try block.likelihood_ratio_testreported an impossible result as “no evidence” –chi2.sf(negative) = 1.0andnorm.isf(1.0) = -inf, indistinguishable at a glance from a genuine null. It now warns, with a tolerance so nested-sampling convergence noise does not trip it.
A free neutrino mass now warns when nothing can see it¶
The compressed Planck distance priors are blind to m_nu by
construction: their z_star is a fitting formula calibrated at the
fiducial 0.06 eV and returns the same z_star, Omega_r and
r_s(z*) anywhere from 0 to 0.8 eV. A free m_nu there runs to 0.82
eV meaninglessly, and a posterior for it is not a neutrino-mass
measurement. The fit now says so and names what to add instead. Run
properly with the full CMB, the 95% profile bound is 0.039 eV against
a published Bayesian 0.064 eV, and the three candidate reasons for the
gap are flagged as unresolved rather than papered over.
Agegraphic and Ricci dark energy¶
Two more holographic models, validated against the same paper as
HDE. RDE’s gamma = 0.538 and Omega_m0 = 0.2173 sit inside both
published intervals; ADE’s n is ~2% high, and the honest reading is
that the model is right and the fit differs – n = 2.80 predicts
Omega_m = 0.280, exactly the pair that paper reports.
ADE has one fewer free parameter than LCDM. Its early-time
condition fixes the background from n alone, so Omega_m is not fit
but derived. New generic DERIVED_PARAMS declaration: a model
lists what it computes, and the fitter warns if one is set free, since
sampling it would just be reintroducing the prior.
Both were first written wrong in ways the tests caught. ADE was
integrated backwards from today with Omega_m free, which is
silently a different model. And RDE’s Omega_de(z) returned the
second term of E^2, but the Ricci density carries a matter-like
piece, so reading the two terms as “matter” and “dark energy”
misplaces about a third of the matter density. A third, from the
previous release: HDE.Omega_de(z) returned the density parameter
rho_DE/rho_crit(z) where the rest of the library means
rho_DE/rho_crit(0) – the two agree at z = 0, which is why it
passed, and are off by 0.59x at z = 0.5.
v0.32.0¶
Five commits, all of them performance. No new datasets, no new models, no API changes – the same 21 datasets, 18 models and 354 tests, running between 1.8x and 29x faster depending on the fit. Two of the changes also made the code more accurate, which is the part worth reading.
The profile was not where I expected¶
A growth fit with 68 data points cost 27 times what one with 1869 did. That was the whole finding, and everything else followed from measuring rather than guessing.
The growth ODE. solve_ivp at rtol=1e-8 called back into Python
for every stage of every step: 19,040 scalar right-hand-side
evaluations per solve, each evaluating E(z) three times. But the
equation is linear, smooth and non-stiff, so adaptivity was only ever
rediscovering a step size that never needed to change. It is now a
fixed 300-step RK4 with the coefficients evaluated for the whole grid
in four array calls.
The stepping itself, twice. RK4 is sequential, so there was no
array operation left to hand NumPy – 493 microseconds of pure loop.
Two ways round it, both implemented. Without numba, the equation
being linear means one RK4 step is a fixed 2x2 matrix and
composing them is a prefix product, which pairwise doubling does in
log2(n) rounds of batched work: 493 to 215 microseconds. With numba,
the same loop compiled: 5.2 microseconds. The speed extra was
already declared and nothing used it; it does now. The two agree to
8.9e-16, which a test asserts by running both, and CI covers both
paths.
x ** 3 goes through pow. NumPy special-cases x ** 2 and
x ** 0.5; it does not special-case cubes. On a 505-element array
that is 5.02 microseconds against 0.79 for x * x * x – and
Omega_m * (1 + z) ** 3 is the most-evaluated expression in the
library. Applied across seventeen files; E(z) dropped from 9.37 to
5.23 microseconds, and the rewrite differs from pow by one unit in
the last place.
Building the Hermite splines directly.
CubicHermiteSpline.__init__ validates, sorts and normalizes axes
before assembling coefficients that are four lines of algebra: 43
microseconds against 13. cosmology.refresh() – paid by every fit on
every step – went from 99 to 48.
Two places this got more accurate, not less¶
The distance integral. chi(z) was a plain trapezoid fitted with
a shape-preserving Pchip, which spent most of its build time deriving
slopes that are known exactly, since chi'(z) = 1/E(z), and got them
slightly wrong. It is now a corrected (Euler-Maclaurin) trapezoid and
a Hermite spline built from that same derivative. Against quad, the
maximum relative error on the default grid falls from 4.5e-06 to
1.3e-10.
The recombination and sound-horizon integrals.
scipy.integrate.simpson accepts an arbitrary x and pays for that
generality whether or not the grid is uniform – 36 microseconds of a
75 microsecond integral. Every grid here comes from linspace or
logspace, so uniformity is known at the call site. r_s(z*) goes
from 2.5e-07 to 2.8e-09 and r_d from 3.2e-09 to 3.4e-11,
both roughly twice as fast. A uniform-grid rule on the substituted
integrand is simply a better rule than a general one on the original.
What was measured and rejected¶
jax – its win comes from JIT-ing and fusing a whole computation,
which would mean rewriting every model’s E(z) in jax primitives.
That is a rewrite, not an optimization. A Cholesky factorization of
the supernova precision matrix – exact and worth 1.23x, but it
costs 106 ms at dataset load against 131 ms for the entire load, so it
makes short runs and the test suite slower to make long chains faster.
float32 for that matrix – 1.3x, for 1.8e-05 absolute in
chi-squared; not for sale in a library whose recent conclusions turned
on a Delta chi2 of 0.30. A symmetric dsymv – 15x slower,
because SciPy copies the C-contiguous matrix to Fortran order first.
A wrong turn, recorded¶
cProfile attributed ~150 microseconds per evaluation to simpson. I
wrote a direct replacement, found it worth 1.4, concluded the profiler
had been inflated by its own per-call overhead, and reverted. That
conclusion was wrong, and the check was the reason: I had timed
simpson(y, dx=h), a different and three times faster code path than
the simpson(y, x=grid) the callers actually used. The profiler had
been right, and finding that later produced the last commit in this
release – the one that also improved the accuracy.
v0.31.0¶
Mostly about BAO that is not a Gaussian, one new model, and four bugs – three of which were found by an answer that was impossible rather than merely surprising. 21 datasets, 18 models, 317 tests.
BAO released as a likelihood surface¶
Every BAO dataset here used to be a mean and a covariance. Three of eBOSS DR16’s are not, and in each case the collaboration released a grid because a Gaussian would misrepresent the measurement.
eboss_elg – D_V/r_d at z = 0.845, a 399-point curve. The BAO
feature is a 1.4 sigma detection, so the likelihood is asymmetric
and still rising at the low edge of the released table. About a
tenth of its probability sits below D_V/r_d = 16.5, where a Gaussian
at 18.33 +/- 0.6 puts a thousandth – two orders of magnitude, in
exactly the tail that decides whether a low expansion rate at
z ~ 0.85 is excluded.
eboss_lya – (D_M/r_d, D_H/r_d) at z = 2.334, a 50x50 surface;
the highest-redshift BAO in the library outside the CMB, and
independent of DESI’s.
eboss_elg_fs – the ELG sample again, full shape: a
100x100x100 grid. Three dimensions is the point rather than an
inconvenience, since fsigma8 is degenerate with the Alcock-Paczynski
distortion and a grid carries that exactly where two error bars could
not.
All validated against the papers with the published numbers used
nowhere as inputs. The ELG pair is more than a central-value check: it
identifies which interval the paper quotes – that table’s standard
deviation is 1.05 and its 68% credible interval is +0.52/-1.21, and
only the Delta chi2 = 1 crossings give the published asymmetric
pair. The Lyman-alpha check does double duty: eBOSS quote the combined
constraint from a joint fit but ship the auto- and cross-correlation
surfaces separately, so this multiplies them, which assumes
independence – recovering the published errors from the product is
what makes that defensible, since a neglected correlation would have
come out as errors too tight.
Two things this cost, both now tests. Interpolating the released probability directly does not work – thirty orders of magnitude, so a cubic is dominated by the peak and undershoots to negative values that have no logarithm. And for the 3-D grid, splining the log also fails: it ships floored 200 below its peak and a cubic rings at the step where that plateau starts, reaching a log of +146 against a node maximum of 0. That path is linear, and a test asserts the no-overshoot property directly.
sdss_fsbao, and a gap it uncovered¶
The SDSS BAO + full-shape consensus: D_M/r_d, D_H/r_d and
fsigma8 at four redshifts, with the covariance between them.
Reproduces Alam et al. (2021) Table 3 exactly, and the errors come
from a covariance file that is a separate product, so that also checks
the two were paired correctly. The real content is the correlation:
within a bin, D_M/r_d and fsigma8 are correlated at +0.388,
+0.389, +0.185, +0.636.
Which is the gap. sdss_bao + fsigma8 had been reachable without
a warning all along – the growth compilation includes BOSS z = 0.38
and the eBOSS quasars, so the pair covers the same galaxies twice
while treating geometry and growth as independent. Now registered as
conflicting, with the joint product named as the alternative.
DESI full-shape is not here and cannot be on this library’s terms: DESI publish MCMC chains and the full modelling-pipeline inputs but no compressed Gaussian summary, so using it properly means implementing an EFT model with its nuisance parameters. That is well outside a background-plus-linear-growth library and could not be validated cheaply.
Holographic dark energy¶
The holographic principle bounds the energy in a region by its
boundary area, giving rho_DE = 3c^2 M_p^2 / L^2; Li (2004) showed
L has to be the future event horizon for the universe to
accelerate at all. Architecturally it is the first background model
here without a closed-form E(z) – Omega_DE obeys an ODE,
solved and splined on every refresh().
Validated against the model’s definition rather than its equation of
motion, which is the point: for an ODE model there is nothing to check
by inspection, and a wrong equation gives a smooth, plausible,
entirely wrong history. The test computes the future event horizon by
quadrature from the solved E(z), with the ODE appearing nowhere in
it, and checks H(a) L(a) = c / sqrt(Omega_DE(a)). It holds to 2e-3
for c from 0.6 to 1.3, and a second test deliberately corrupts the
ODE and asserts the check notices, so it cannot quietly become
vacuous. That check is also why the solution runs forward to
a = 1e5: nothing asks for z < 0, but a model defined by the future
should be answerable about it. Flat only, and it says so – curvature
changes the causal structure the holographic bound is applied to.
Four bugs¶
best_fit() settling in local minima; a NaN parameter vector crashing
from inside refresh(); likelihood_ratio_test reporting an
impossible result as “no evidence”; and that warning then firing on
convergence noise – a 32-fit scan returned five negatives between
-8e-06 and -5e-04, every one at the nested limit where the two
optimizers reach the same minimum by different routes, and warning
about those trains the reader to ignore the warning.
Two notebooks¶
The LsCDM result in lscdm_mcmc.ipynb is a DESI result. Section 8
had found the transition-redshift constraint as a cliff –
chi-squared falling by 28 between adjacent grid points, straddling
DESI’s Lyman-alpha bin at z = 2.33 – and could not say whether that
was a property of the universe or of one measurement. With eBOSS’s
independent Lyman-alpha at essentially the same redshift, it can: the
Delta chi2 = 5.40 preference becomes 0.30, and the 28.04-unit
step becomes a 1.6-unit ripple. The mechanism turned out not to be
what the cliff suggested – broken down by dataset, DESI’s 5.40 is
2.72 in Planck and 2.12 in DESI’s own BAO, and with eBOSS the Planck
term is already 0.10: no tension, nothing to relieve.
dark_energy_evidence_audit.ipynb – the same question asked
twenty ways. LsCDM’s improvement is a compound of all three choices:
0.00 with no BAO, 0.30 with eBOSS, and – the sharpest – 5.40 drops
to 0.81 when r_d is fitted rather than computed. The evidence lives
in a CMB-calibrated BAO ruler disagreeing with the CMB’s own distance
priors; remove the calibration and there is nothing to relieve. CPL’s
is smaller and does not depend on any single choice, and all twenty
cells land on w0 > -1 and wa < 0.
v0.28.0¶
v0.25.0 computed the CMB; this finishes it, adds a second
telescope, and closes the loophole that let the whole thing be tested
against nothing. 230 tests (from 188), 17 datasets.
The Planck 2018 likelihood, all four parts¶
v0.25.0 shipped one component of four. The CMB is now complete –
652 data points, each part validated against a published number
rather than against itself: Commander low-l TT (data shipped, never
read), plik_lite high-l TT/TE/EE (already there), SimAll low-l EE
(stood in for by a Gaussian tau prior) and lensing (missing
entirely).
Low-l EE is the real thing, not the tau = 0.0544 +/- 0.0073
shorthand. Below l = 30 there are only 2l+1 modes on the sky, the
distribution is strongly skewed, and that regime carries essentially
all of the CMB’s information about tau – so Planck ships a
probability table and the likelihood is a lookup, with no mean and no
covariance. It is the library’s first non-Gaussian likelihood, and
BaseLikelihood no longer assumes every dataset has a covariance to
invert. Validated by reconstruction: profiling tau against that
table plus the high-l bandpowers returns 0.0541 +/- 0.0072 against
Planck’s published 0.0544 +/- 0.0073, with that number used nowhere as
an input, and the fitted amplitude tracks tau with slope 2.00, which
is the A_s e^{-2tau} degeneracy exactly.
Lensing is the CMB’s own growth measurement – every other CMB dataset here constrains recombination and reaches the present only through a distance. It is also the part that is easy to drop: the reconstruction is normalized against an assumed cosmology, so the linear correction propagating that dependence vanishes at the fiducial by construction. A chi-squared check at Planck’s best fit therefore passes whether or not it was implemented, and the tests check it away from there.
ACT DR6 lensing¶
A second, independent reconstruction – different telescope, different
sky, different pipeline, and tighter than Planck’s: 2.3% on the
lensing amplitude. Fitting a single scaling of the theory at
Planck’s best-fit LCDM returns A_lens = 1.017 +/- 0.026 against
ACT’s published 1.013 +/- 0.023, with Planck’s own lensing at the same
cosmology giving 0.998 +/- 0.025.
That test earns its place because the failure mode is a smooth
rescaling. ACT’s products are built on the lensing convergence,
Planck’s on the potential, and the two differ by 2 pi / 4. Get it
wrong and nothing crashes: the theory comes out uniformly off, a fit
absorbs it into the amplitude, and the posterior looks ordinary and
sits somewhere else – A_lens would read 0.65 or 1.6 instead of 1.
sigma8 derived instead of fitted¶
sigma8 was defined twice – the free parameter the growth
machinery normalizes with, and CAMB’s derived value from the primordial
amplitude – and nothing made them agree.
Fitter(derive_sigma8=True) takes the amplitude from the Boltzmann
code and drops it from the sampled parameters. This is what makes the
CMB predict growth rather than accommodate it: with sigma8 free,
an S8 measurement is reproduced to chi-squared ~ 0 and nothing has
been tested, because a free parameter slid onto it.
best_fit() was returning its starting point¶
The most serious bug in this release, and it was silent. L-BFGS-B
estimates gradients by finite differences with a step of ~1.5e-8. For
a likelihood whose own numerical noise exceeds the chi-squared change
such a step produces – which is every CAMB-based likelihood – the
estimated gradient is zero and the optimizer returns the starting
point while reporting success=True. The caller gets their initial
guess back labelled “best fit”, every downstream number is computed
from it, and nothing anywhere says so. On the full Planck CMB it cost
366 in chi-squared.
best_fit() now detects the stall – movement below 1e-6 of each
parameter’s own prior width, which is the only scale-free way to ask,
given that H0 and Omega_b differ by three orders of magnitude –
and retries with Nelder-Mead on a prior-scaled simplex, keeping the
better of the two. Passing eps= or method= suppresses the rescue:
the caller has taken control. Deliberately narrow, because a larger
step is not uniformly better – on CPL with CC+DESI+Planck it finds
a minimum 2.75 worse.
Infrastructure¶
Continuous integration, finally. Two jobs: the full suite with
every extra across Python 3.11-3.13, and a minimal job installing
core dependencies only – the configuration a plain
pip install cosmofit produces. The second is not redundant: CAMB and
Streamlit are optional and every test needing them skips, so it is the
only thing checking that the skip paths skip rather than erroring.
It asserts both extras really are absent first, so a leaked transitive
dependency cannot turn it into a duplicate of the first.
The neutrino-mass path is now pinned. It worked when it was built
and no test touched it. It has two consumers that never talk to each
other – in the sound horizon the neutrinos are relativistic at the
drag epoch and raise r_d; in CAMB they free-stream out of the
small-scale power and lower sigma8 – and both read the same
Omega_m. Getting the subtraction right in one place and wrong in the
other would not raise; it would move the two in opposite directions by
a fraction of a percent, comfortably inside the range where a fit
still looks plausible and is wrong.
examples/s8_tension_cmb.ipynb¶
The S8 tension posed from the CMB side, using everything above: all
652 Planck points, sigma8 derived, and neither weak-lensing
measurement in the fit. The fit reproduces Planck’s published
parameters to within 0.16 sigma, which is the bar it had to clear
before anything else means anything, and predicts
S8 = 0.8307 +/- 0.0104 – 2.9 sigma from KiDS-1000, 2.7 sigma from
DES Y3. ACT DR6’s lensing never entered the fit, so its A_lens is
an out-of-sample check on the whole chain, Boltzmann code included.
The sigma8-free version of the same question returns chi-squared =
0.000000 and the tension is invisible. And the growth compilation
gives chi-squared/N = 0.79 against the same prediction – the
disagreement is in weak lensing, not in redshift-space distortions.
v0.25.0¶
A pass over the graphical interface, on the principle that a tool which cannot be used wrongly is worth more than one with more knobs.
The app had grown to fourteen datasets and seventeen models presented as a flat checkbox list and a flat dropdown, with no indication of what any of them was. That is a problem specific to this domain: a wrong combination does not error, it returns a perfectly ordinary-looking posterior with error bars that are too small, or a “measurement” of a parameter nothing in the fit constrains.
Everything now explains itself. Each dataset says what it
measures, over what redshift range, how many points, what it actually
constrains, and where it comes from – grouped by probe, since a fit
is built by taking one from each family rather than by ticking
everything. Each model says what it is, what its extra parameters
mean, and which parameter values reduce it to ΛCDM, plus capability
badges for w(z), growth, and whether the full CMB spectra can be
computed for it.
Six presets configure a whole analysis at once. “DESI DR2 + BBN → H₀ without the CMB” sets the datasets, selects DR2, and turns on the computed sound horizon – none of the three is useful without the other two, and expecting someone to discover that from three separate widgets was optimistic.
The parameter table now shows only the parameters this fit uses.
The shared container carries every parameter any model needs; for an
LCDM fit against CC and BAO, all but four are inert. Relevance is a
property of the fit rather than the model, so it follows the datasets
too: rd appears only with BAO, sigma8 only with growth data,
n_s/τ only with the CMB spectra. The hidden ones still reach the
Fitter – they are inert, not absent.
Warnings fire before the run, not after. One is a hard error the
app can see coming (a modified-gravity model with the full CMB
spectra). The rest are quieter and matter more, because the fit will
run, converge, and produce something that looks like a result: a
modified-gravity model with no growth data ticked, a model whose own
parameters are left fixed, a free sigma8 nothing constrains, a
computed r_d with Ω_b held fixed.
Three things the GUI simply could not show before:
χ² per dataset, which turns a total χ² into a statement about which dataset is in tension. On the Hubble-tension preset it reads DESI 19.6/13, Planck 7.2/3, local H₀ 24.5/1 – the whole argument, in one table.
Derived quantities (
q₀,z_t,r_dfrom the densities) with real error bars. These have existed inCosmoFit.stats.derivedsince v0.13.0 and were reachable only from Python.Dataset versions. DESI DR2, the SH0ES 2024 and TDCOSMO H₀ measurements, DES Y3’s
S₈and the Cooke BBN prior all shipped in v0.23.0 and were unreachable from the GUI, which never passeddataset_kwargs. Each dataset with more than one version now has a picker.
A 📖 Guide panel lists every dataset and model in one browsable table with the conflict rules and a short walkthrough. Both tables are generated from the same dictionaries the widgets use, so a dataset added to the library without a note shows up as a blank row rather than silently.
Two bugs, and the first GUI tests¶
st.checkbox was being given both a value= and a session-state
entry under the same key – the one thing Streamlit explicitly warns
about, since the two disagree about which is authoritative. And the
preset button called st.rerun(), which is the obvious idiom and was
never needed here (the button sits above the widgets it writes to, so
the values land on the same pass); it was also an infinite loop
anywhere a button’s click state outlives the rerun.
That second one was found by the new tests/test_gui.py, which is the
point of it. Streamlit’s AppTest runs the app in-process and exposes
the rendered element tree, so 12 tests now drive the flows a user
actually takes: a preset writes the configuration it claims to, a fit
runs end to end and renders its tables, the parameter table follows
the datasets, and each warning appears when it should. 159 tests
total, ~18 s.
Two small public additions came out of this, both because the GUI
needed them and reaching into private state to get them would have
been worse: dataset_reference(dataset, version) returns a dataset’s
citation without loading its files, and
cosmology.boltzmann.supports_cmb_spectra(model) answers “can CAMB do
this model?” as a value rather than by raising – the backend now
routes its own check through it, so the two cannot drift apart.
v0.24.0¶
Computes the BAO sound horizon r_d from the
physical densities instead of fitting it.
What was wrong with fitting it¶
Nothing, exactly – treating r_d as a free nuisance parameter is a
defensible and common choice, and it makes BAO immune to any
assumption about the early universe. It is also the reason this
library could not measure H0 from BAO. H0 and r_d enter every
BAO observable only through the product H0 * r_d, so with r_d
free they are perfectly degenerate and BAO constrains neither.
SoundHorizon was, until now, a nine-line stub that returned the
free parameter. It now does the integral:
r_d = int_{z_d}^inf c_s(z)/H(z) dz,
c_s = c / sqrt(3(1 + R_b)), R_b = 3 omega_b/(4 omega_gamma) a
What is actually computed, and what is not¶
Computed from first principles: photon density from T_CMB;
massless neutrinos; the baryon loading; the integral itself.
Massive neutrinos, exactly. They are relativistic at the drag
epoch (y = m/kT ~ 0.34 for 0.06 eV) and matter-like today, and the
transition matters. The usual [1 + (Ay)^p]^(1/p) approximation
costs 0.05% in r_d at Sum m_nu = 0.06 eV and 0.15% at 0.6 eV –
the latter half of DESI’s best BAO precision, spent on an
approximation with no reason to be there. Instead the Fermi-Dirac
energy density integral is tabulated once at import (a few ms) and
splined afterwards. The familiar Sum m_nu / omega_nu h^2 = 93.14 eV
shorthand is then not an input at all: the calculation derives
93.0378 eV, where CAMB gets 93.04.
Fitted, and honestly labelled: z_drag alone. The drag epoch is
defined by a Thomson-drag optical depth over a full recombination
history, which is a Boltzmann code’s job. This takes the same route
the library already took for z_star in v0.19.0 – a fit calibrated
directly against CAMB, over a 5850-point grid spanning omega_b in
[0.018, 0.026], omega_cb in [0.09, 0.20], N_eff in [2.0, 5.0] and
Sum m_nu in [0, 0.6] eV.
Eisenstein & Hu (1998)’s
z_dragformula is bundled for comparison and is not usable here: it gives 1020.7 where CAMB gives 1059.9, 3.7% low, which putsr_d2.5% high – ten times DESI DR2’s best BAO error bar. That is not a flaw in EH98; theirz_dwas calibrated jointly with their own closed-formr_s, and the two halves are not separately meaningful. Splicing one of them onto a modern integral is exactly the convention error v0.19.0 documents at length, in a different place.
Accuracy¶
Against CAMB’s rdrag over that whole grid:
max error |
|
|---|---|
the integral alone, given CAMB’s |
2.2e-6 |
the |
6.7e-5 |
end to end |
5.0e-5 |
end to end, within realistic priors |
1.4e-5 |
DESI DR2’s single best BAO bin is a 0.24% measurement, so the worst case is ~50 times smaller than the best data’s error bar and the typical case ~500 times. A trimmed copy of that grid ships as a test fixture, so the comparison runs without CAMB installed.
r_d depends on less than you might expect¶
The integral runs entirely through the radiation- and
matter-dominated eras, so r_d does not depend on H0, on
curvature, or on the dark-energy model at all – only on
omega_b, omega_cb, N_eff and m_nu. Verified against CAMB,
which returns the same rdrag to 1e-7 across H0 from 60 to 75.
Two consequences. It works identically for every model in the
library, including the modified-gravity ones a Boltzmann code will
not accept. And the cache keys on the densities alone, so an MCMC
step that moves only w0 reuses it – which matters, because the BAO
likelihoods ask for r_d once per data point.
Using it¶
Off by default: switching a fitted nuisance parameter into a derived quantity changes every BAO prediction, and that is a choice about the analysis.
fit = Fitter(
model=LCDM,
datasets=["desi", "omega_b"], # BAO + the BBN prior
dataset_kwargs={"desi": {"version": "desi2025"}},
free_params=["H0", "Omega_m", "Omega_b"],
initial={"H0": 68.0, "Omega_m": 0.30, "Omega_b": 0.0493},
compute_rd=True,
)
rd must not be in free_params alongside it – the likelihood
would ignore the sampled value and its “posterior” would be its
prior – and the constructor says so rather than letting it happen.
Omega_b becomes the parameter BAO now needs and cannot pin down on
its own, which is what the "omega_b" BBN dataset is for. Running
that fit (DESI DR2 + BBN, flat ΛCDM) gives
H0 = 68.55 +0.59 -0.60
Omega_m = 0.2977 +0.0087 -0.0085
Omega_b = 0.0472 +0.0008 -0.0008
r_d = 148.10 +1.59 -1.63 Mpc (derived)
which lands where published DESI BAO+BBN constraints do – an end-to-end check of the whole chain that no single unit test provides.
r_d is a derived quantity now, so it leaves summary() and joins
z_t and q0 in CosmoFit.stats.derived:
from CosmoFit.stats import derived
derived.summarize(derived.sound_horizon(fit))
That works whether or not the fit used it. With compute_rd=False
it returns what the early-universe physics would have predicted for
the same densities, and comparing it against the fitted r_d is a
real consistency test: a mismatch is the standard signature of new
physics before recombination, and one of the main ways the Hubble
tension is diagnosed.
compute_rd is part of the chain signature, so a chain sampled one
way cannot be resumed the other. The GUI gets a checkbox, which
un-ticks rd for you and points at the BBN dataset if it is missing.
One bug this surfaced¶
Nothing in the growth machinery: Fitter now also refuses
compute_rd=True together with a free rd, which was previously
expressible and would have produced a rd “posterior” identical to
its prior with no indication anything was wrong.
Seventeen new tests (147 total) cover the neutrino thermodynamics, the CAMB comparison, the independence claims above, quadrature convergence, and the wiring.
v0.23.0¶
The largest single addition since the library began: six new datasets, eight new cosmological models, the first test suite, and – the headline – the CMB computed from scratch rather than compressed.
Planck, uncompressed¶
Until now “Planck” meant three numbers: the distance priors (R, l_A, omega_b h^2). That is fast, dependency-free and works for every model, and v0.19.0 documents at length how badly it can go wrong, because a compression carries the conventions of whoever produced it and the theory prediction has to share them exactly.
"planck_lite" is the other end of that trade. It uses the actual
measured spectra: 613 binned TT/TE/EE bandpowers over
l = 30-2508 with their full 613x613 covariance, compared against a
C_l spectrum computed by a Boltzmann code. No compression, no
summary statistic, no borrowed convention – the theory prediction
is the same object the data is.
CosmoFit does not implement a Boltzmann hierarchy, and should not:
that is thousands of coupled ODEs per wavenumber through
recombination, and a pure-Python version would be far too slow for
an MCMC. A new cosmology/boltzmann.py translates a Cosmology
into CAMB’s parameter conventions and calls it, as an optional
dependency (pip install "cosmofit[cmb]"). Nothing else in the
library imports it.
Three details that decide whether this is right or subtly wrong:
Which models can go through it. LCDM maps onto CAMB directly. Any model exposing a
w(z)(wCDM, CPL, JBP, BA, GCG, and the new PEDE/GEDE/IDE) is passed through CAMB’s PPF dark-energy module as a tabulatedw(a)– exact at background level, and stable across thew = -1crossing that CPL posteriors routinely visit. The modified-gravity models are refused outright.FRHuSawickiwould have run happily – its background is LCDM’s by construction – and returned LCDM’s C_l withf_R0doing nothing, which is worse than an error.Massive neutrinos. CosmoFit counts them inside
Omega_m; CAMB counts them separately. Soomch2isOmega_m h^2 - Omega_b h^2 - Omega_nu h^2– subtracting. Adding instead shiftsOmega_c h^2by ~0.0006, about half a sigma of Planck’s constraint on it, with no other symptom.A CAMB API trap, found by testing.
pars.DarkEnergy = objcopies the object into CAMB’s Fortran state, so setting thew(a)table on the Python instance afterwards is silently discarded – and CAMB then returns a perfectly valid cosmological-constant spectrum for a w0-wa model. Caught by asserting that CPL atw0 = -1, wa = 0reproduces LCDM and that CPL atw0 = -0.9, wa = -0.4does not.
Validation. Against the reference spectrum shipped with
planck-lite-py, this implementation reproduces its published
log-likelihood exactly: -291.33481235418026 for TTTEEE
(bit-identical) and -101.58123068722571 vs -101.58123068722583 for
TT (1e-13, matrix-inversion roundoff – Cobaya’s own plik_lite
differs from planck-lite-py by 2e-13 on the same number).
Independently, at Planck’s best-fit LCDM it returns chi2 = 585 for
613 bandpowers.
What it costs. One CAMB call is ~0.7 s against ~1 ms for the
whole rest of a joint likelihood, so a chain including this is
roughly three orders of magnitude slower per step. That is not an
implementation flaw – it is why full CMB chains run on clusters and
why compressed priors exist. Budget hours, use n_processes, and
save the chain. A CMB spectrum also needs ln1e10As, n_s and
tau_reio, which no background fit ever did; and because
plik_lite starts at l = 30, tau is degenerate with the amplitude
unless the new "tau" dataset is included.
The primordial amplitude is
ln1e10As, notA_s– that name was already taken in this library by the Generalized Chaplygin Gas parameter, an unrelated quantity that shares the symbol in its own literature. Renaming the GCG one would invalidate every saved chain that names it.
Six new datasets¶
DESI DR2 BAO (2025),
dataset_kwargs={"desi": {"version": "desi2025"}}– three years of observations, >14 million galaxies and quasars, twice the DR1 sample, and the measurement the strengthened evolving-dark-energy claim rests on. Identical file format to DR1, so it drops into the same loader. Not to be stacked with DR1: DR2 contains every DR1 galaxy.Union3 (
"union3") – 2087 supernovae fit with the UNITY1.5 hierarchical model and released as 22 binned distance moduli. The third of the three compilations the DESI dark-energy results are argued with, and they disagree about how far the data sits from a cosmological constant: DES-SN5YR pulls hardest, Pantheon+ least, Union3 in between. A library that can only fit one of them cannot reproduce that comparison, which is the actual state of the evidence.Low-z BAO (
"bao_lowz") – 6dFGS (z = 0.106) and SDSS DR7 MGS (z = 0.15), the only BAO leverage below z = 0.2 (DESI starts at 0.295, BOSS at 0.38). Independent of both, so unlike DESI-vs-SDSS these can be added to either. Two details are handled rather than papered over: 6dFGS reportsr_s/D_V, kept as its own observable rather than inverted (inverting a Gaussian gives something that is neither Gaussian nor centred on 1/mean), and its measurement is calibrated against an Eisenstein-Hu fitting-formula sound horizon, so the theoryr_dis rescaled by 153.9/149.8 – 2.7% on a 4.5%-precision point, the same class of definitional mismatch v0.19.0 documents."h0","omega_b","tau"– external single-number measurements: SH0ES 2022/2024 and TDCOSMO 2025 time-delay lensing for H0, BBN (Schoeneberg 2024 or Cooke 2018) for omega_b h^2, and Planck lowE for tau.These are datasets, not priors, and the distinction is deliberate. The posterior is identical either way, but a prior is a statement of belief before seeing data, while “SH0ES measured 73.04 +- 1.04” is data from a telescope with a systematic error budget. As a dataset it shows up in the per-dataset chi2 breakdown, in the degrees-of-freedom count AIC/BIC use, in figure legends, and in the chain metadata that decides whether a saved chain may be resumed. A fit that quietly assumed the local distance ladder should not look, from the outside, like a fit that did not. It also makes the H0 tension askable in the form it is argued: run the same model with and without
"h0"and compare what each dataset contributes.
The BBN prior is more than an extra data point: BAO measures
D/r_d, and with a BBN constraint on omega_b a BAO-only fit gains
a CMB-independent route to H0 – exactly how the DESI “BAO + BBN”
constraints are produced.
Overlapping datasets now warn¶
Every “don’t combine X and Y” rule in this README was, until now,
written down only in a docstring, where it protected nobody who did
not go looking. Fitter now checks and warns, naming the reason.
The failure it guards against is silent: overlapping data treated as
independent gives a perfectly ordinary-looking posterior with error
bars that are too small. It stays a warning, not an error –
quantifying how much an overlap matters is a legitimate thing to
want to do.
Two new rules join the existing ones: Union3 must not be combined
with Pantheon+ or DES-SN5YR, and "planck" must not be combined with
"planck_lite" (the distance priors are a compression of exactly
those bandpowers – that is the entire CMB dataset twice).
Eight new models¶
Grouped by what they actually change, since that decides what can be done with them:
Model |
Extra parameters |
Reduces to |
|---|---|---|
LogarithmicDE |
none (reuses |
wCDM at |
PEDE |
none at all |
— |
GEDE |
|
LCDM at |
LsCDM |
|
LCDM below the transition |
IDE |
|
wCDM at |
RunningVacuum |
|
LCDM at |
Cardassian |
|
LCDM at |
DGP |
none at all |
— |
Every reduction in that last column is a test
(tests/test_models.py), asserted to machine precision, not a
docstring claim. That matters more than it sounds: the Friedmann
closure E(0) = 1 pins some normalizations but not all of them, and
a limit check pins the rest.
Two of these – PEDE and DGP – have exactly LCDM’s parameter count and a completely different expansion history. That makes the model comparison unusually clean: identical AIC/BIC penalties, so a chi2 difference is a difference in fit and nothing else.
DGP also overrides mu(a, k) with the standard Koyama-Maartens
result, so it joins the three modified-gravity models in predicting a
growth history that differs from GR’s at fixed background. On the
self-accelerating branch gravity is weaker (mu = 0.72 today,
matching the literature), and the bundled fsigma8 data feels it
directly.
A matter-scaling bug this surfaced¶
RunningVacuum and IDE both change how matter dilutes – that is
their whole content. But GrowthCalculator read Omega_m(a) off
Omega_m (1+z)^3 for every model, so those two would have been given
LCDM’s growth source term alongside their own E(z): internally
inconsistent, and silent. Cosmology now has an overridable
Omega_matter(z) hook that both models implement and every other
model inherits unchanged. No existing result changes; the two new
models get a growth history that is actually theirs.
The first test suite¶
The library had no tests. It now has 130, running in ~10 s, and they are aimed at the failure mode this project actually has: physics that is plausibly wrong rather than broken.
test_models.py– Friedmann closure for every model (flat and curved),dEdzagainst a central finite difference (two independent hand transcriptions of the same algebra), every known limit, published signatures (PEDE’sw(0) = -1.145, DGP’sOmega_rcand growth suppression), and a bound on the error the distance integrator’s grid makes on LsCDM’s genuine discontinuity.test_datasets.py– every dataset loads, every likelihood’s covariance matches its data vector, and every chi2 per data point is O(1) at a concordance cosmology. The two deliberate exceptions are the tensions themselves:"h0"disagrees at ~5 sigma and"s8"at ~2.9 sigma, and the bounds are set just above those so a further error would still be caught rather than hidden.test_planck_lite.py– the binning and covariance algebra against published log-likelihoods, and the CAMB translation separately against physics, so a failure says which half is wrong.
Run them with pip install -e ".[dev]" && pytest.
v0.22.0¶
A figure-typography pass: everything a plot says is now written the way a paper would write it, and a few labels that were quietly wrong are fixed.
Parameter names reach every axis and corner-plot title as LaTeX
($\Omega_b$, $r_d$, $\sigma_8$) instead of as Python
identifiers – the labels were always declared on the parameter
container, the plots just weren’t reading them. Model names do the
same: legends show ΛCDM, wCDM, f(R) Hu-Sawicki via a new
Cosmology.MODEL_LABEL / plot_label() (with plain_name() for
tables, dropdowns and JSON, where raw LaTeX would be shown
literally), and define_model(..., label=...) lets a custom model
supply its own. Corner-plot titles now size their precision per
parameter, so Omega_b reads 0.0491 +0.0011 -0.0011 instead of
corner’s default 0.05 +0.00 -0.00.
Three labels were misleading rather than merely plain:
The Pantheon+ Hubble diagram’s y axis claimed to be
mu = m_B - M_B. It is neither: the plotted data is the corrected apparent magnitudem_b_corr(~11-27 mag, not a distance modulus’s ~33-46), andM_Bis analytically marginalized out by default, so it cannot appear in an axis label. Now$m_B$ [mag], with the model curve’s legend entry saying where its normalization came from (Model ($M_B$ marginalized)).The BAO panels were titled
D_V/r_swhile their y axis saidr_d. Both denote the sound horizon at the drag epoch; thersspelling comes from DESI’s own data file, whichMODEL_MAP’s keys keep, but the code divides byrdand now so do the titles.The Planck pull plot’s ticks were the raw dataset identifiers (
lA,omega_b_h2); they now render as$\ell_A$,$\omega_b h^2$.
Dataset combinations in legends also spell themselves out
(CC + DESI + Pantheon+, via stats.dataset_label) rather than
joining registry keys. No numerical result changes from any of this –
it is labels, titles and legends only.
The DES-SN5YR Hubble diagram is also legible for the first time. That
release ships 81 supernovae (of 1820) with mu_err between 5 and 468
mag – entries the survey de-weights rather than removes – and
matplotlib autoscales an errorbar plot to contain every whisker, so
the panel spanned +/-500 mag with the actual 35-45 mag Hubble diagram
compressed into a sliver of it. Panels are now scaled by the
measurements rather than by their largest error bars, and a bar may
only stretch the view if it is extreme both as a fraction of the
data’s spread and as a multiple of that dataset’s median error –
which caps nothing at all on CC, DESI, Pantheon+ or fsigma8, where
matplotlib’s own limits are reproduced exactly. The 81 points stay on
the plot, drawn without the whiskers that no longer fit and counted
in the legend.
One functional bug surfaced while testing the above and is fixed here
too: FitResult.save_json() (and the GUI’s JSON download) failed
for any fit that used run_mcmc(save=...), with
TypeError: Object of type int64 is not JSON serializable. Reading a
chain’s step count back through an HDF5 attribute yields np.int64
rather than int, and json rejects it (np.float64 slips through
only because it subclasses float). The counters are now cast at the
source, and both JSON writers coerce numpy scalars rather than
failing on a save.
v0.21.0¶
Adds the w0-wa dark-energy plane
(fitter.plots.w0_wa_plane(), and compare_w0_wa_plane() for
several posteriors at once): 2D credible contours over the
phantom/quintessence/quintom-A/quintom-B regions with ΛCDM marked at
(-1, 0) – the figure the DESI evolving-dark-energy results are
argued in. The classification behind it is available on its own as
cpl_diagnostics.classify_region() /
cpl_diagnostics.region_fractions(), which turns “the contours sit
in the quintom-B region” into a posterior probability. Contour levels
are stated as 2D credible probabilities (68%/95% of the samples), not
sigmas, since in two dimensions the familiar 1D contours enclose only
39%/86.5%.
The same release fixes a reporting bug in the GUI’s w0-wa
diagnostics: it printed the Mahalanobis distance D of the ΛCDM point
with a σ suffix. In 2D, D is not a number of sigma (D² follows χ² with
2 degrees of freedom), so this overstated the tension – D = 2.20
reads as “2.2σ” but is really 1.70σ. The library was corrected in
v0.19.0; the GUI now reports sigma (with the confidence level, and D
labelled as what it is) too.
v0.20.0¶
Makes MCMC chains persistent. Until now a chain
lived only in memory: closing the notebook, or adding one more plot
to the bottom of a script, meant sampling the whole posterior again
from step zero – hours, to recompute samples that hadn’t changed.
run_mcmc(save="chains/fit.h5") now writes the chain to HDF5 as it
is sampled (emcee’s own HDFBackend, plus a cosmofit metadata
group), and picks it back up on the next call instead of re-running
it. nsteps counts the total length to reach, so re-running an
unchanged script samples nothing, raising nsteps samples only the
difference, and an interrupted run keeps every step it had already
taken. Fitter.from_chain("chains/fit.h5") reopens a finished run in
a later session – model, datasets, free parameters, priors and fixed
values all come back out of the file – and
CosmoFit.stats.chains.open_chain() reads the posterior with no
dataset loaded at all.
Resuming is exact, not approximate: emcee’s proposal RNG state is
stored with the walkers, so a chain sampled in four sittings is
bit-identical to the same chain sampled in one (verified directly, as
is multi-core n_processes writing through the same file). The
metadata is also what keeps it safe – a resume whose model,
datasets, free parameters, prior bounds or fixed parameter values
differ from the stored chain’s is refused, naming exactly what
differs, rather than silently welding samples from two different
posteriors into one array. The GUI uses the same machinery
(fitter.chain_id(), a stable hash of the posterior, as the
filename), so adding a model to a comparison no longer re-runs the
models already fitted. h5py is now a dependency.
v0.19.0¶
Fixes a real scientific error in the Planck
distance-prior likelihood. Evaluated at Planck’s own best-fit
LCDM – where a correct implementation must return chi2 ~ 0 – the
old code returned chi2 ~ 100 for 3 data points, with l_A off by
-8.9 sigma (R and omega_b_h2 were fine at 0.13 and 0.07
sigma, which is what localized it to the sound horizon r_s(z*)).
The cause was not a bug in the physics but a definitional
mismatch, which is the classic trap with compressed likelihoods.
These priors are not a measurement of the sky; they are a summary of
Planck’s own fit, computed by Chen, Huang & Wang (2019) under a
specific set of conventions. CosmoFit was computing a more detailed
prediction than the compression assumed – radiation as photons plus
3.046 massless neutrinos (omega_r = 4.18e-5) where CHW19 define
Omega_r = Omega_m/(1+z_eq) (0.8% lower, massive neutrinos left in
Omega_m), and z* from the Hu & Sugiyama (1996) fitting formula
where CHW19 take it from the Planck chains, i.e. from CAMB. HS96 was
calibrated against 1990s recombination physics and runs 0.22% high
for Planck-like parameters (1091.9 vs CAMB’s 1089.9); l_A is
sensitive enough to z* that this alone is a ~4 sigma shift. Being
more physical than the data’s own definitions is still wrong when
the data is a compression.
RecombinationCalculator now follows CHW19 Eqs. (1)-(6) exactly, and
z* comes from a fit calibrated directly against CAMB 2.0.1 over
a 12x14 grid in (omega_b, omega_cb) covering far more than the
Planck posterior, accurate to 0.0018% in z* (~0.04 sigma of the
l_A prior). The radiation term is also renormalized properly:
adding Omega_r(1+z)^4 on top of a model’s E(z) left
E(0)^2 = 1 + Omega_r, over-closing the universe; the correction
reuses each model’s own dark-energy evolution, so it stays exact for
curved and non-LCDM models alike. The same fiducial check now gives
l_A +0.54 sigma, R -0.03 sigma, chi2 = 0.39.
Cross-checked two independent ways: against CAMB (z* to 0.002%),
and against a separate scipy.quad implementation of the CHW19
recipe across flat/open/closed LCDM and CPL (R and l_A to
<0.01 sigma). Per-evaluation cost is unchanged.
This changes results. The bias did not show up as a bad fit – the sampler absorbed it by shifting parameters, which is exactly what makes this kind of error dangerous. Refitting CC+DESI+Pantheon+ + Planck:
old |
new |
|
|---|---|---|
LCDM |
68.04 |
67.39 |
LCDM |
0.3118 |
0.3149 |
CPL |
-0.973 |
-0.881 |
CPL |
-0.000 |
-0.298 |
The CPL case is the headline: the old code put wa at essentially
zero – perfectly consistent with a cosmological constant – while
the corrected code prefers evolving dark energy, in the same
direction and of comparable size to DESI’s own published w0waCDM
result. Any CPL/JBP/BA conclusion drawn from a Planck-including fit
made with v0.18.0 or earlier should be regenerated.
Note: don’t combine two different
"s8"versions in the same fit (default is"kids1000"; passdataset_kwargs={"s8": {"version": "des_y3"}}for the other) – they’re independent survey constraints, not a joint one. See theS8Likelihooddocstring.
v0.17.0¶
Adds growth-of-structure: the “phase 2” v0.16.0
explicitly deferred, since a background-only treatment left
FRHuSawicki’s f_R0/n genuinely untestable (its background is
LCDM’s by construction). A new cosmology.calculators.growth.GrowthCalculator
solves the standard sub-horizon, quasi-static linear growth ODE
(D'' + (2+dlnH/dN)D' - (3/2)Omega_m(a) mu(a,k) D = 0) for every
model via a Cosmology.mu(a,k) hook (default 1, i.e. standard GR
growth – LCDM/wCDM/CPL/JBP/BA/GCG all get this for free), exposing
fitter.cosmology.background.{growth_rate,sigma8,fsigma8}(z), plus
a new sigma8 cosmological parameter and two new datasets/likelihoods:
"fsigma8" (the “Gold-2018” RSD growth-rate compilation, Sagredo,
Nesseris & Sapone 2018, arXiv:1806.10822, 22 points, with the same
Alcock-Paczynski correction the reference likelihood applies) and
"s8" (a single Gaussian S8 = sigma8*sqrt(Omega_m/0.3) constraint,
KiDS-1000 or DES Y3). Each of the three modified-gravity models now
has a real, cited mu(a,k): FQExponential uses the settled
sub-horizon result G_eff/G_N = 1/f_Q (Barros, Barreiro, Koivisto &
Nunes 2020, arXiv:2004.07867); FRTLinear uses
mu = 1 + 3*beta (the same coupling already in its own E(z)^2,
stated explicitly as a simplification rather than a full covariant
perturbation derivation – see the class docstring); and
FRHuSawicki gets the standard chameleon-screened, scale- and
time-dependent mu(a,k) (Pogosian & Silvestri 2008, arXiv:0709.0296),
derived here directly from this model’s own background rather than
transcribed, held at a fixed fiducial pivot k=0.1 h/Mpc since
CosmoFit’s fsigma8 data are single per-z points, not a P(k) shape –
f_R0/n are still inert for background-only fits, but now
genuinely shape "fsigma8"/"s8" predictions (verified end-to-end:
a short FRHuSawicki fit against "fsigma8" alone now gives f_R0 a
real, visibly informative posterior, not a flat one spanning the
whole prior). fitter.plots.growth()/compare_growth() add the
fsigma8(z) diagram alongside the existing figures, and the GUI picks
up both new datasets/the new plot automatically through the same
DATASET_REGISTRY/EXTRA_PARAMS mechanism every other dataset/model
already goes through.
v0.16.0¶
Adds modified-gravity models – FQExponential
(f(Q) gravity), FRTLinear (f(R,T) gravity), and FRHuSawicki
(f(R) gravity) – a real category beyond dark-energy-on-top-of-GR
reparametrizations (LCDM/wCDM/CPL/JBP/BA) or a unified fluid (GCG):
these modify the gravitational field equations themselves.
Background (E(z)) level only – CosmoFit’s datasets (CC, BAO,
SNe, Planck distance priors) are all background/expansion-history
probes, and growth-of-structure data (fσ8/RSD) plus the perturbation
machinery to use it isn’t implemented, so that’s the honest ceiling
of what’s testable here for now. Both FQExponential’s and FRTLinear’s
Friedmann equations were derived and verified directly against
primary sources (Anagnostopoulos, Basilakos & Saridakis 2021,
arXiv:2104.15123, for f(Q); Harko, Lobo, Nojiri & Odintsov 2011,
arXiv:1104.2669, for f(R,T)) rather than taken from a single
secondary source – one transcription (a sign error in FQExponential’s
Lambert-W closure formula) was caught this way, by a numerical
closure self-check (E(z=0) should equal 1 by construction; it
didn’t, until the sign was fixed) that’s now a permanent part of the
model’s test coverage. FRHuSawicki’s background is identical to
LCDM’s by construction – the standard “designer f(R)” approach
builds f(R) to reproduce an assumed target background, so f_R0/n
are present as parameters but don’t affect E(z) at all; fitting
them against these datasets won’t meaningfully constrain them. This
is stated explicitly in the class docstring and shown as a visible
warning next to the model picker in the GUI – included for
completeness rather than silently omitted, but not silently
overstated either. All three plug into the existing EXTRA_PARAMS
mechanism (built for define_model()/custom models), so Fitter,
every plot including compare_*, and the GUI’s multi-model
comparison all work with them with no further changes.
v0.15.0¶
Fixes n_processes on Python 3.14: it changed
multiprocessing’s default start method on Linux from fork to
forkserver (matching what macOS/Windows already used), which made
run_mcmc(n_processes=...) crash outright as a plain script
(forkserver/spawn require every process-spawning call to sit
behind an if __name__ == "__main__": guard) and, even inside a
Jupyter kernel where that particular crash doesn’t surface, measured
no speedup at all. Fitter._mcmc_pool now explicitly requests the
fork context rather than relying on whatever the interpreter’s
default happens to be – still available on Linux/macOS even where
it’s no longer automatic, and with neither of the above problems.
This version also brings the graphical interface up to parity with
cpl_mcmc_tfd42.ipynb: the GUI can now configure and fit multiple
models at once (built-in or custom) sharing one set of datasets, with
a statistical comparison tab (AIC/BIC, and a likelihood-ratio test
when exactly two models are properly nested), every compare_*
figure alongside the existing single-model ones, and the CPL-family
w(z)=-1-crossing/LCDM-distance posterior diagnostics – so everything
that notebook does is now also reachable with zero code. Every figure
also gets a download button (SVG/PNG/PDF, picked once per session and
applied to all of them) – the browser’s own save dialog is what lets
you choose where it goes.
Resolved (v0.18.0): the “no speedup inside Jupyter” limitation was
a misdiagnosis. Multiprocessing was never the problem, and it was
never specific to notebooks. The real cause was the per-evaluation
cost: every likelihood evaluation solved the Pantheon+ covariance
with a Cholesky triangular solve (cho_solve), and a triangular
solve is an inherently sequential recurrence – each element depends
on the one before it – so BLAS cannot thread it and worker processes
contend on memory bandwidth instead of scaling. The whole MCMC was
therefore pinned near one core’s throughput in every environment;
a plain script only looked better because that is where
n_processes was actually being passed.
The covariance is constant, so DenseCovariance now precomputes an
explicit inverse once (validated against the original matrix, and
falling back to the Cholesky path if it fails that check) and
solve() is a symmetric mat-vec, which BLAS does thread. Measured
on the bundled 1624x1624 Pantheon+ covariance:
per solve |
8-process scaling |
|
|---|---|---|
|
1.70 ms |
4.8x |
mat-vec, 1 BLAS thread |
0.80 ms |
7.5x |
mat-vec, threaded BLAS |
0.18 ms |
– |
End result for a 3-dataset CPL chain on an 8-core machine: the
single-process path went from 1.1x to 7.9x core utilization and
roughly halved in wall time, without any multiprocessing at all –
and it measures the same in a plain script, in Jupyter Lab, in VS
Code, and under nbconvert. chi2 is unchanged to ~1e-11 relative.
n_processes now also defaults to "auto", so notebooks get
multi-core behaviour without passing anything: it uses every core the
process is allowed to run on (os.sched_getaffinity, not
os.cpu_count() – the two differ inside a container, cgroup, or
SLURM allocation, and oversizing the pool there makes things slower),
but only when the run is long enough to earn back worker startup, and
it silently stays single-process for a define_model() model rather
than raising. The chain is unaffected: the proposal RNG lives in the
main process, so a given seed gives bit-identical results at any
n_processes (verified).
v0.14.0¶
Adds multi-core MCMC: fitter.run_mcmc(n_processes=...)
evaluates the ensemble’s walkers across multiple CPU cores. This
isn’t emcee’s own naive recipe (pairing a raw multiprocessing.Pool
with Fitter’s already-built, data-carrying log-posterior) – for a
dataset like Pantheon+ (a ~1600x1600 dense covariance matrix),
re-pickling and sending that to a worker on every single step
measured slower than one process (~19ms to pickle vs. ~2ms to
evaluate). Instead, each worker process builds its own Fitter once
(from a small, cheap-to-pickle recipe), via the pool’s initializer,
and only a length-ndim float vector crosses the process boundary
per evaluation after that; workers also pin their own BLAS thread
pool to 1 (via the new threadpoolctl dependency) to avoid
oversubscribing the machine. Net effect, measured on an 8-core
machine for a 4-dataset CPL fit: ~2.4x, well under n_processesx
(per-step IPC has its own cost) but a real, significant speedup. Only
works for models picklable by reference (every built-in model; not a
dynamically-built define_model()/model_from_expression() model,
which raises a clear error rather than an obscure pickling failure
if n_processes is given), and is most reliable on Linux/macOS
(multiprocessing from a Windows notebook is fragile for reasons
outside this library’s control). The new examples/05-case-studies/cpl_mcmc_tfd42.ipynb
notebook is a publication-scale variant of cpl_mcmc_analysis.ipynb
using this: all four datasets (including Planck, which makes rd
and Omega_b constrainable and lets them join the free parameters
too) and a much longer chain (nwalkers=64, nsteps=12000), run in
parallel across every available core.
v0.13.0¶
Adds model comparison plots: every
fitter.plots.* figure (hz, deceleration, w_of_z,
hubble_diagram, des_hubble_diagram, bao_distances,
sdss_bao_distances) gets a compare_* counterpart that overlays
this fit’s curve with one or more other models’ curves on the same
data/axes – the “model A vs model B” figures cosmology papers use,
rather than only being able to look at models one at a time or
compare them statistically (AIC/BIC/LRT) without a picture of what
the difference actually looks like. other_fits=None (the default)
auto-compares against a quick best-fit-only LCDM reference built
from the same datasets; passing an already-fit Fitter, or a list
of them, compares against exactly those instead, for an arbitrary
N-model figure. Every compare_* method reuses the corresponding
single-model method’s existing evaluation logic (posterior-predictive
bands, per-fit analytic SN offsets, …) rather than duplicating it,
and works for any model – built-in or
custom – since it only depends on
Fitter/Cosmology’s existing generic interface. The
cpl_mcmc_analysis notebook’s CPL vs. LCDM section now includes
these alongside its existing AIC/BIC/likelihood-ratio comparison.
v0.12.0¶
Adds a graphical interface: a
Streamlit app (app/streamlit_app.py,
optional pip install -e ".[gui]") for ticking datasets, picking a
built-in model or writing a custom E(z) as an expression, editing
free parameters/bounds in a table, and running the fit + rendering
plots with one click – a pure UI layer over the existing
Fitter/FitPlotter API, with no changes to either. The one new
library addition is model_from_expression()
(cosmology.custom), a thin wrapper around define_model() that
takes E/w/dEdz as expression strings (e.g.
"sqrt(Omega_m*(1+z)**3 + ...)") instead of Python callables –
evaluated with builtins stripped and only whitelisted numpy math
plus the model’s own parameters reachable, appropriate for a
locally-run tool.
v0.11.0¶
Adds support for custom, not-in-the-literature
models: define_model() (cosmology.custom) builds a usable
Cosmology subclass from a single E(z) function – which alone is
enough to fit against every built-in dataset/likelihood and produce
every plot except w_of_z()/deceleration() (an optional dEdz=
enables the latter, else a numerical finite-difference fallback is
used) – plus any new parameters the model needs beyond the standard
set (H0, Omega_m, Omega_k, w0, wa, …), each declared
inline with a default and prior bounds. The same mechanism is also
available by subclassing Cosmology directly and declaring an
EXTRA_PARAMS class attribute (Cosmology.__init_subclass__ builds
a matching parameter dataclass and property automatically); this is
what define_model does under the hood. Required generalizing
Fitter to read the parameter container off the model
(model.PARAMS_CLASS) instead of hardcoding
CosmologyParameters – every built-in model is unaffected (none
declare EXTRA_PARAMS), verified against all six.
v0.10.0¶
Splits MCMC sampling out of Fitter and into a
dedicated stats.sampler module: EnsembleSampler (the existing
emcee-backed walker initialization/run logic, unchanged in
behavior) now implements a small BaseSampler interface, so the
sampling backend is a swappable component rather than logic
inlined in Fitter.run_mcmc, and run_mcmc() gains a moves=
argument to pass through custom emcee proposals (e.g.
emcee.moves.DEMove() for strongly correlated posteriors). It also
adds a consolidated result interface (stats.results): fitter.result
returns a FitResult bundling the best-fit point (BestFitResult)
and MCMC posterior summary/convergence (MCMCResult) that were
previously only available piecemeal via best_fit_params,
best_fit_chi2, summary(), convergence(), with a single
readable repr and FitResult.save_json() / .load_json() for
keeping a fit’s headline numbers without pickling the emcee
sampler. Both are purely additive – every existing Fitter
method/attribute (run_mcmc(), best_fit(), summary(),
convergence(), samples_dict(), .sampler, .best_fit_result,
…) is unchanged.
v0.9.0¶
A performance pass, driven by profiling rather
than guesswork. The big one: evaluating PlanckLikelihood was
dominated by two integrals (the sound horizon and the comoving
distance to z*) computed with scipy.integrate.quad – an adaptive
scalar quadrature that calls the (already fully vectorized)
integrand at one z at a time, several hundred times per call. Both
are now evaluated on a fixed, vectorized grid (scipy.integrate.simpson)
instead – one array-valued call instead of hundreds of scalar ones.
Getting this right took two tries: the first grid (linear in the
substituted variable) looked fine across randomized-parameter testing
but turned out to badly under-resolve the sound-horizon integral’s
approach to its asymptote at realistic (Planck-fiducial-like)
parameter values specifically – caught by comparing end-to-end
PlanckLikelihood output at a fixed point against the original
quad-based result, not just spot-checking the integral in
isolation. A log-spaced grid fixes it, verified to <1e-6 relative
error against quad across dozens of randomized and
literature-realistic parameter sets, across all 6 models. Net
effect: ~8x faster evaluation of a joint CC+DESI+Pantheon+Planck
likelihood. The distance-integrator’s interpolation grid
(cosmology.numerics.integrals) was also shrunk several-fold
(verified: still >100x more accurate than any dataset’s measurement
precision needs) for a smaller additional gain that applies to every
fit, Planck or not.
v0.8.0¶
Adds the SDSS BAO likelihood: BOSS DR12
(z=0.38, 0.51) + eBOSS DR16 LRG (z=0.698) + eBOSS DR16 QSO (z=1.48),
combined into one dataset with a block-diagonal covariance (each
component is an independent, non-overlapping-redshift measurement).
Data downloaded directly from
CobayaSampler/bao_data
(the same repository that is, independently, also the exact source of
this project’s existing DESI 2024 files – confirmed byte-for-byte)
and cross-checked against the published eBOSS DR16 LRG/QSO
uncertainties. BOSS DR12’s usual third bin (z=0.61) is deliberately
omitted, since it overlaps the eBOSS DR16 LRG redshift range. The
DESI/SDSS-BAO model()/residuals()/chi2() logic (previously
duplicated in DESILikelihood) was factored into a shared
BAODistanceLikelihood base class.
Note: don’t combine
"desi"and"sdss_bao"in the same fit – DESI targets much of the same sky BOSS/eBOSS did, so treating them as independent double-counts structure. See theSDSSBAOLikelihooddocstring.
v0.7.0¶
Adds the DES-SN5YR (Dark Energy Survey 5-year)
supernova likelihood, downloaded directly from the official
des-science/DES-SN5YR
data release and cross-validated against its own reference likelihood
implementation (the distance-modulus formula and analytic
marginalization both match it exactly). Its covariance is shipped as a
precision (inverse covariance) matrix rather than a covariance matrix,
so this version also adds PrecisionCovariance, used directly (no
inversion round-trip) instead of forcing it through the existing
Cholesky-based DenseCovariance. The Pantheon+ and DES-SN5YR
marginalization logic (previously duplicated) was factored into a
shared AnalyticOffsetMixin.
Note: don’t combine
"pantheon"and"des_sn5yr"in the same fit – DES-SN5YR’s low-z anchor sample (~11% of it) is also compiled into Pantheon+, so fitting both double-counts those supernovae. See theDESSN5YRLikelihooddocstring.
v0.6.0¶
Adds three dark-energy models: JBP and BA
(alternative w0-wa parametrizations to CPL, reusing the same w0/wa
parameters) and GCG (Generalized Chaplygin Gas, a genuinely
different unified dark-matter/dark-energy fluid, adding two new shared
parameters A_s/alpha). All three have closed-form E(z)/dE/dz
(no per-step numerical integration), verified against finite-difference
derivatives and independent numerical integration of the continuity
equation, so they’re exactly as fast in an MCMC as LCDM/wCDM/CPL.
v0.5.0¶
Moves the package to a standard src layout
(src/CosmoFit/...) with a unified public API, so from CosmoFit import CPL, Fitter, ... now works instead of importing the five
subpackages (cosmology, data, likelihoods, stats, plots)
separately from the repository root.
v0.4.0¶
Fixes a Pantheon+ distance-modulus bug (the
zHD/zHEL redshift distinction from Brout et al. 2022 was not
applied) and a circular import between data.loader and
likelihoods, roughly halves the per-step cost of an MCMC run
that includes Pantheon+, adds the wCDM model, adds an
autocorrelation-time MCMC convergence check
(Fitter.convergence()), and moves all plotting into a dedicated
plots.FitPlotter (fitter.plots), with new Hubble diagram, H(z),
BAO distance, Planck pull, w(z) evolution and deceleration-parameter
figures alongside the existing chain/corner plots.
v0.3.0¶
Fixes a curvature bug in the LCDM/CPL Friedmann equations and in the transverse comoving distance (Omega_k was previously a fittable-but-inert parameter), and adds a Planck 2018 CMB distance-prior likelihood (shift parameter R, acoustic scale l_A, omega_b_h2), backed by a radiation-aware sound-horizon calculation and the Hu & Sugiyama (1996) z_star fitting formula.