Skip to content

fix(core): evolve recomputes defaults computed from the cosmology - #444

Merged
steven-murray merged 5 commits into
mainfrom
fix/core-evolve-computed-defaults
Nov 8, 2026
Merged

steven-murray merged 5 commits into
mainfrom
fix/core-evolve-computed-defaults

Conversation

@steven-murray

@steven-murray steven-murray commented Nov 8, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

_with_computed_defaults ran only in from_flat/build, never in evolve, so:

a = MassFunction.build(cosmology=Planck15, **{"transfer.model": "EH"})
b = MassFunction.build(**{"transfer.model": "EH"}).evolve(cosmology=Planck15)
# before: a has sigma_8 0.8159 / n_s 0.9667, b kept Planck18's 0.8102 / 0.9665
# now:    a == b, with bit-identical dndm

A cosmology scan done with evolve gave hybrid models. Also, evolve to a cosmology with no meta kept σ8 without a word, while build raised.

The rule

When evolve changes a parameter that a stage's defaults are computed from, every parameter that computed_defaults gives a default for takes the new default, unless it is given in the same call. Any other change recomputes nothing.

mf = MassFunction.build(sigma_8=0.8, n_s=0.95)
mf.evolve(cosmology=Planck15)                # sigma_8 = 0.8159, n_s = 0.9667
mf.evolve(cosmology=Planck15, sigma_8=0.8)   # sigma_8 = 0.8,    n_s = 0.9667
mf.evolve(sigma_8=0.85)                      # n_s stays 0.95

So mf.evolve(cosmology=c) equals build with c and all of mf's other parameters except σ8 and n_s. To keep σ8 fixed through a scan, pass it with each cosmology.

The result depends only on the stage's value and the changes: there is no hidden state. A stage read back from a pickle, a copy or a configuration evolves exactly as the original does.

If the new cosmology can't supply a parameter that isn't passed, evolve raises the same ValueError as build. The message names the parameter and says "set it explicitly". It also names the cosmology as cosmology.name or type(cosmology).__name__, where it used to print 'None'.

Design: declared inputs

  • Declaration: Stage.computed_defaults_inputs is a class attribute listing the dotted paths below the stage that its defaults are computed from. MassFunction declares ("linear_power.transfer.cosmology",). When the routing table is first built, the router checks that each entry is a field of the tree.
  • The declaration is required: a stage that defines computed_defaults without declaring any inputs raises a TypeError when its routing table is first built. Without the declaration, evolve would never recompute that stage's defaults, which is exactly this bug. No extension stages exist yet, so enforcing it now breaks nothing; MassFunction is the only stage in src that defines the hook.
  • When the hook re-runs: evolve re-runs the hook only when a change overlaps a declared input. Replacing a stage that holds the input counts too, e.g. evolve(**{"linear_power.transfer": T}). The hook is called with the changes from the call and the new input values.
  • What gets updated: each default the hook returns for a parameter not given in the call, and not below a stage given whole, is added to the changes, but only if it differs from the current value. So stage sharing and caches survive.
  • Why declared rather than tracked lookups: the MassFunction hook also looks up sigma_8 and n_s, to check whether they were given. A tracker would count those lookups as inputs, so evolve(sigma_8=0.8) would re-run the hook and reset an n_s the user had set earlier.
  • Checking the declaration: undeclared_inputs also runs the hook with no parameters and reports any lookup that is neither a declared input nor one of the hook's own outputs. It reports "*" if the hook iterates its parameters. GivenParameters records its lookups for this purpose.

Tests

tests/core/test_core_computed_defaults.py:

  • Parity with build: for Planck13, Planck15, WMAP7 and WMAP9, build().evolve(cosmology=X) == build(cosmology=X) == from_flat(...), with equal stage trees and bit-identical dndm. σ8 and n_s equal X.meta. This holds from any starting cosmology, and evolving back to Planck18 recovers the default.
  • Explicit values are reset: the new cosmology wins however σ8 and n_s were set before: in build, in an earlier evolve, or after another cosmology. Values passed in the same call are kept.
  • Other changes recompute nothing: an n_s set earlier survives evolve(sigma_8=...), and the transfer stage is shared. evolve(fit=...) shares the linear power.
  • Stages given whole: a transfer stage given whole takes σ8 from its own cosmology and keeps its own n_s. A linear-power stage given whole is kept unchanged.
  • Missing source: a meta-less cosmology raises, naming n_s or σ8 and the cosmology, unless both are passed.
  • No hidden state: after a pickle, deepcopy or to_config/from_config round trip, evolve(cosmology=...) and evolve(sigma_8=...) give the same results as on the original.
  • Extension stages: a stage that holds a MassFunction recomputes its defaults. A mass function given whole is kept as is.
  • Toy trees:
    • a shared computed default;
    • an optional stage that is absent;
    • undeclared_inputs on a hook with an undeclared input, and on one that iterates its parameters;
    • a declared input that isn't a field raises TypeError;
    • a stage that defines computed_defaults with no declared inputs raises TypeError from both from_flat and evolve.

test_core_routing.py:

  • The cosmology cases of test_evolve_every_parameter and test_cosmology_change_is_consistent now compare against from_flat of the stage's parameters minus σ8 and n_s.
  • The toy Holder stage now declares base as its input and computes its default from base's value. As a result, evolve(base=4) equals from_flat({"base": 4}).

Gate numbers (local)

benchmarks/test_gates.py: 25 passed. Median time per call on a warm EH tree:

evolve(...) main this PR
sigma_8=0.85 13.5 µs 16.8 µs
fit="ST" 8.9 µs 11.6 µs
cosmology=Planck15 60 µs 84 µs

The routed-evolve gate (≤ 250 µs) and the σ8/z/fit no-recompute gates pass. Only a cosmology change calls the hook.

Checks run

  • tests/core + tests/regression: 2342 passed, 11 skipped. routing.py, stage.py and mass_function.py have 100% coverage.
  • benchmarks/test_gates.py: 25 passed.
  • uv run mypy (strict): clean.
  • prek on the changed files: clean.
  • Sphinx -W: clean.
  • Doctests of the changed modules: pass.

The PR touches routing.py, stage.py, mass_function.py, docs/core.rst and tests. It no longer touches config.py or pickling.

Type of change

  • Bug fix

Checklist

I have

  • Added or updated tests covering this change.
  • Updated docstrings and/or docs where relevant.

🤖 Generated with Claude Code

https://claude.ai/code/session_019U8u44xFH87Am2mNHVrimP

`build` takes sigma_8 and n_s from the cosmology's meta when they are not
given, but `evolve(cosmology=...)` kept the old values, so a cosmology scan
done with `evolve` silently gave hybrid models (e.g. Planck15 with Planck18's
sigma_8), and evolving to a cosmology without meta silently kept sigma_8
where `build` raises.

Each stage now records which parameters below it took a default from its
`computed_defaults`, and which paths of the given parameters the hook looked
up (`ComputedRecord`, in an init=False, eq=False, physics=False field, so it
is not part of the value, the hash or the content hash). `evolve` makes every
parameter it sets explicit, and when it changes a path the hook read, calls
the hook again on the new tree for the parameters still computed. So
`build(**a).evolve(**b) == build(**a, **b)`. A pickle or copy keeps the
record; a configuration does not, so every value a configuration gives is
explicit when read. `Stage.computed_parameters()` lists the computed ones.

The missing-meta error now names the cosmology's class when it has no name,
and asks to set the parameter explicitly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019U8u44xFH87Am2mNHVrimP
@steven-murray steven-murray added type: bug Something isn't working changes results Changes the results of a default calculation, e.g. a changed default labels Nov 8, 2026 — with Claude
@sourcery-ai

sourcery-ai Bot commented Nov 8, 2026

Copy link
Copy Markdown

Sorry @steven-murray, you've used your own review budget of 250,000 diff characters for the last 7 days.

You can request another review in 4 days and 1 hour by commenting @sourcery-ai review. Upgrade to get a review now.

@sourcery-ai

sourcery-ai Bot commented Nov 8, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

Data structures

The router records stages’ declared computed-default inputs and validates that each path exists in the stage tree, while GivenParameters records lookups for declaration checks.

  • src/hmf/core/routing.py L161-164, L172-181, L382-407 (+3 small edits)

API

The Stage contract declares computed-default inputs and documents when evolve reruns defaults, and MassFunction declares that its defaults depend on the transfer cosmology.

  • src/hmf/core/mass_function.py L406-411 (+3 small edits)
  • src/hmf/core/stage.py L213-219, L233-240, L347-356 (+1 small edits)

Implementation

Evolving now reruns computed defaults when declared inputs change, adds only defaults not overridden by the call, and checks hook lookups for undeclared inputs.

  • src/hmf/core/routing.py L771-823
  • src/hmf/core/stage.py L567-579, L636-656 (+2 small edits)

The missing-cosmology-parameter error now identifies unnamed cosmologies by class and asks callers to set the parameter explicitly.

  • src/hmf/core/mass_function.py L211-215

Documentation

The core documentation page is updated to explain the computed-default behavior and its use during evolution.

  • docs/core.rst L446-447, L452-472, L516-527

Tests

The new tests cover recomputation, overrides, missing cosmology metadata, stage composition, and declared-input validation.

  • tests/core/test_core_computed_defaults.py L1-331

Routing tests adapt cosmology comparisons to omit defaults that are recomputed, verify lookup tracking, and declare inputs for the toy computed-default hook.

  • tests/core/test_core_routing.py L203-214, L565-571, L876-879 (+4 small edits)

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@github-actions github-actions Bot added the type: maint: documentation Improvements or additions to documentation label Nov 8, 2026
@codecov

codecov Bot commented Nov 8, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.70%. Comparing base (251eb41) to head (5e155f1).

Additional details and impacted files
@@           Coverage Diff           @@
##             main     #444   +/-   ##
=======================================
  Coverage   99.70%   99.70%           
=======================================
  Files          69       69           
  Lines        9468     9537   +69     
=======================================
+ Hits         9440     9509   +69     
  Misses         28       28           

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

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

from_flat on a tree with an optional stage that stays None (only shared
names reach it) tried to set that stage's record on None. Skip it. Cover the
remaining branches of the re-derivation: a shared computed default, a stage
given whole inside an extension, and the evolving tree's parameter view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019U8u44xFH87Am2mNHVrimP

steven-murray commented Nov 8, 2026 •

Copy link
Copy Markdown
Collaborator Author

docs/readthedocs.org:hmf failed on b7e2655, but the failure is not caused by this PR. The build stopped in RTD's pre_create_environment, before any project code ran:

asdf install uv latest
No compatible versions available (uv [0-9])
asdf global uv latest
version 0.13.7 is not installed for uv

asdf couldn't fetch uv. On the previous head, b744d22, the RTD build of the same docs passed. The new commit only touches routing.py and the tests, and a local sphinx-build -W passes. There's no fix to port, because this happens inside RTD's environment setup. I can't re-run RTD builds from here, so the build needs a rebuild from the RTD dashboard. It will also re-run on the next push.


Generated by Claude Code

…ted-defaults

# Conflicts:
#	docs/core.rst
#	src/hmf/core/config.py
Replace the provenance design with a rule that depends only on the stage's
value: when evolve changes a parameter a stage's defaults are computed from,
every parameter computed_defaults gives a default for takes the new default,
unless it is given in the same call. So mf.evolve(cosmology=c) takes sigma_8
and n_s from c, and mf.evolve(cosmology=c, sigma_8=0.8) keeps 0.8; any other
change computes no default again.

A stage declares what its defaults are computed from in
computed_defaults_inputs (MassFunction: the transfer stage's cosmology); the
router checks each is a field. Tracking what the hook looks up would also
count its "is it given?" checks, so evolve(sigma_8=...) would reset an n_s
set earlier. undeclared_inputs now also checks the hook against its declared
inputs, through the lookups GivenParameters records.

Removed: the init=False provenance field on Stage, ComputedRecord,
computed_parameters(), and the computed-defaults element of the pickle state.
A stage read from a pickle or a configuration evolves as the original does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019U8u44xFH87Am2mNHVrimP
@steven-murray steven-murray changed the title fix(core): evolve re-derives defaults computed from the cosmology fix(core): evolve recomputes defaults computed from the cosmology Nov 8, 2026
…_defaults

A stage that defines computed_defaults without declaring what its defaults are
computed from would never have them recomputed by evolve, which is the bug
this PR fixes. Building its routing table now raises a TypeError that asks for
the inputs. No stage outside the tests defines the hook except MassFunction,
which declares its cosmology.

The toy Holder stage in the routing tests declares base, with a hook computed
from base's value, so evolve(base=4) now equals from_flat({"base": 4}).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019U8u44xFH87Am2mNHVrimP
@steven-murray
steven-murray merged commit 132f513 into main Nov 8, 2026
27 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changes results Changes the results of a default calculation, e.g. a changed default type: bug Something isn't working type: maint: documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants