Skip to content

Replace a simulation's population when the builder writes a situation into it - #611

Draft
MaxGhenis wants to merge 2 commits into
masterfrom
builder-replaces-population
Draft

MaxGhenis wants to merge 2 commits into
masterfrom
builder-replaces-population

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

WIP: Sol re-review, full suite and CI pending.

Problem

SimulationBuilder.build_from_dict(system, situation, simulation=existing) writes a situation into an existing simulation's populations as they are. So do build_from_entities, build_from_variables and build_default_simulation. Reproduced on master 8fdf59e with the country template (10 people in 5 households, then one person with a salary of 3,000), a simulation built over a dataset and then given a situation:

  • keeps is_over_dataset=True and its dataset. So check_macro_cache still allows macro-cache reads, and country models that read the flag treat the situation as data. policyengine-us reads it in county and medicaid_slcsp_state_denominator. policyengine-uk reads it once Impute UC deductions in constituency and local-authority runs policyengine-uk#1905 lands, whose review found the UC deduction draw at 0.88331 instead of 1.0 and private school attendance imputed for the situation's child (reproduced below);
  • answers from _fast_cache with values calculated for the dataset: income_tax returned the ten people's [150, …] instead of [450];
  • keeps the dataset's arrays for every variable the situation does not set (age, household_weight);
  • keeps the dataset's input record, so to_input_dataframe() raised Length of values (10) does not match length of index (1);
  • keeps its branches on the dataset: a reform simulation's baseline still had 10 people and returned their tax;
  • keeps prerequisites requested for the old population (requires_computation_after), and a FullTracer's trees with the old values.

Change

  • Simulation._replace_population() gives the simulation new populations from its system and clears what described the old ones: dataset, is_over_dataset, situation_input, has_axes, input_variables, _store_history, _user_input_keys, _requested_variables, invalidated_caches, _fast_cache and a FullTracer's trees. It also advances _input_epoch, so a calculation elsewhere that read the simulation does not keep what it calculated from the old population. It keeps the tax-benefit system and settings. It refuses a branch (which shares its parent's population and baseline) and a simulation with a calculation running. It does not call _drop_computed(), because that reaches _note_unobserved_activity(), which flags restricted frames in every simulation in the process, and Simulation(situation=...) now passes through this path.
  • The builder replaces the population once the situation has parsed: in build_from_entities just before writing, and in build_from_variables after checking variable names and periods. A situation that fails to parse therefore leaves the simulation as it was. It then calls _finish_replacing, also when writing fails partway, so the records and branches describe what was written. That records axes and input variables, then calls Simulation._rebuild_branches(): other branches are dropped, and the baseline is rebuilt on the situation under the baseline system. That code moved verbatim out of subsample, which rebuilt branches the same way. build_from_dict skips its own input-variable recount when replacing, so Simulation(situation=...) makes no extra pass over the variables.
  • subsample raises ValueError("Only a simulation with a dataset can be subsampled…") when dataset is None. Before, a simulation built from a situation raised AttributeError reading the default seed, and one written over data failed on the leftover inputs' length.

Only the path where a simulation is passed in changes. Simulation(situation=...) and policyengine-uk's build_from_situation already pass fresh populations, so for them the replacement just re-instantiates entities.

Invariants (property-tested)

The property test draws any dataset (1–4 households of 1–2 people, optional ages), any reform (none, a rate change, a neutralized variable, a changed default, an added variable), and any prior calculations, branches or earlier situations. It then writes any situation (with or without entities, with or without axes) using build_from_dict. Afterwards:

  1. is_over_dataset is false and dataset is None.
  2. Differential: the simulation calculates exactly what a simulation the builder makes from the situation alone, under the same system, calculates.
  3. It records the same _user_input_keys, input_variables and has_axes as Simulation(situation=..., reform=...), and exports the same to_input_dataframe(). Its baseline calculates and records what that simulation's baseline does.
  4. The only branch left is "baseline" (none without a reform), and it is the simulation's baseline.

The baseline is compared with Simulation(situation=..., reform=...)'s baseline, not with a build under the baseline system. When a reform changes the default of a variable the situation gives for some people only, the situation builder stores the reform's default for the others as an input. That happens in both constructions, is a known limitation (test_reform_baseline_system.py), and is out of scope here.

Tests and evidence

  • tests/core/test_builder_replaces_population.py: 20 examples covering each symptom above, every builder entry point, parse and write failures, branches, a formula that overwrites its own simulation, requires_computation_after, the trace, the reform baseline, the macro cache, and subsample without a dataset. Run against master, every one fails except the parse-error example, which guards behaviour that should not change.

  • tests/core/test_builder_replaces_population_property.py: the invariants above, with 200 Hypothesis examples.

  • Mutation check: each reset or guard is removed in turn. So far every removal fails a new test, apart from the defensive resets of situation_input, input_variables and _store_history (nothing reads the first after a successful write, _finish_replacing recomputes the second, and a stale store history only widens drops). Full results to follow.

  • End to end on policyengine-uk#1905's branch (a git archive of origin/built-from-dataset-imputation-gates), using its synthetic tables(n=40, weight_scale=SMALL_NATIONAL_SCALE) and SITUATION. A core Simulation over that data is overwritten with the situation (with the review's fresh-holder control on master):

    built_from_data uc_deduction_random_draw attends_private_school
    master True 0.8833108 [False, True]
    this PR False 1.0 [False, False]
    fresh situation False 1.0 [False, False]
  • Simulation(situation=...) construction on the UK system, medians: core 3.3–4.2 ms (this PR) vs 3.5 ms (master); UK Simulation about 1.4–1.5 s either way. No measurable change.

  • Existing builder, simulation, entity, axes, subsample, reform-baseline, clone and macro-cache tests: 350 passed on the first commit. Full suite on this head: pending.

  • Review: GPT-6.1 Sol review 1 (static; its sandbox could not run Python) requested changes on six points. All are addressed in f6673ae. Re-review pending.

A follow-up in policyengine-uk will raise its policyengine-core floor to the release with this fix and drop the caveat in built_from_data's docstring.

axiom: n/a: engine change in policyengine-core, no policy encoded.

🤖 Generated with Claude Code

MaxGhenis and others added 2 commits October 12, 2026 14:20
… into it

SimulationBuilder.build_from_dict (and build_from_entities,
build_from_variables and build_default_simulation) can write a situation
into an existing simulation. It wrote the situation into that
simulation's populations as they were. A simulation built over a dataset
and then given a situation:

- kept is_over_dataset and dataset, so the macro cache and country
  models that read is_over_dataset treated the situation as data;
- answered from its fast cache with values calculated for the dataset;
- kept the dataset's arrays for every variable the situation did not set;
- kept the dataset's input record, so to_input_dataframe failed;
- kept its branches, the baseline among them, on the dataset.

Once the situation parses, the builder now gives the simulation new
populations (Simulation._replace_population), with no dataset, cache,
input record or store history from the old ones. After writing it
records the input variables and axes and rebuilds the baseline branch on
the situation (Simulation._rebuild_branches, moved out of subsample,
which rebuilt it the same way). subsample now says it needs a dataset
instead of failing on the missing one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of the first commit (GPT-6.1 Sol, static):

- A branch shares its parent's population and baseline, so writing a
  situation into one replaced its population but not its baseline.
  _replace_population now refuses a branch.
- A formula could replace the population of the simulation calculating
  it. _replace_population now refuses while a calculation runs, and
  advances _input_epoch so a calculation elsewhere that read the
  simulation does not keep what it calculated from the old population.
- requires_computation_after accepted a prerequisite requested for the
  old population, and a FullTracer kept the old population's values.
  Both are reset.
- build_from_variables resized the populations before checking the
  situation. It now checks variable names and periods first, so a
  situation that fails to parse leaves the simulation as it was, as
  build_from_entities did. A situation that fails while it is written
  leaves the simulation replaced with what was written; the builder now
  records it and rebuilds branches in that case too.
- The property test compared the baseline with one built under the
  baseline system, which differs whenever a reform changes the default
  of a variable the situation gives for some people only (a known
  limitation of the situation builder). It now compares with
  Simulation(situation=..., reform=...)'s baseline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Overlap note from #613 (fixes #612, the reform default leaking into a situation's baseline, which this PR's body lists as a known limitation).

#613 makes Simulation.__init__ build a reform simulation's baseline branch from the situation under the baseline system, through a new Simulation._build_from_situation(situation) (new populations from the branch's own system, the situation built in with SimulationBuilder.skip_rejected_inputs = True, records reset), instead of _bind_to_tax_benefit_system(). Without a situation (datasets, subsample) the branch is still bound.

Whichever of the two merges second should:

  • in _rebuild_branches() (or _finish_replacing), call baseline._build_from_situation(situation) when the population was replaced by a situation, so the builder-overwrite path gets the same baseline as Simulation(situation=..., reform=...);
  • resolve the conflict in __init__'s reform block (both PRs edit it);
  • in this PR's property test, compare the baseline with a build under the baseline system instead of Simulation(situation=..., reform=...)'s baseline, dropping the limitation note.

I'll do the rebase on #613's side if #611 lands first.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant