Repository navigation
Conversation
anth-volk
added this pull request to stack #755
October 14, 2026 21:40
anth-volk
force-pushed
the
microdata-weight-rules
branch
3 times, most recently
from
October 15, 2026 14:06
6b8c63b to
cd7ad69
Compare
anth-volk
force-pushed
the
microdata-weight-rules
branch
from
October 15, 2026 15:09
cd7ad69 to
f59f487
Compare
The output-builder tests built household frames as plain pd.DataFrames, which carry no weights, so they did not weight the way production's MicroDataFrames do. That is how #767 escaped. fixtures/weighted_frames.py now builds every builder test's entity frames as MicroDataFrames, and rejects a weight column that is the same on every row. The golden macro result keeps its budget: its households now weigh 0.5 and 1.5, with values chosen so that only a correctly weighted build reproduces it. The materialized-columns budget test now expects weighted totals, derived in the test from an explicit weighted formula; it expected unweighted sums before, which production never returned. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The executor's entity frames are MicroDataFrames weighted by <entity>_weight, so any column's sum is already weighted. #767 fixed a weight column aggregated through one, which returned the sum of squared weights. Four rules now cover that class of mistake: - sim.microdata.weights-not-values (doc-only): weights, values, labels and counts each allow different operations. - sim.microdata.weights-not-aggregated-as-values: a weight is never the variable of a weighted aggregator or frame[<weight>] followed by a weighted reduction. Enforced, with no findings. - sim.microdata.weight-stripping-confined: raw values are read from a weighted frame only in simulation_output_common.py. Ratcheted: the UK geography builders' plain DataFrame handoff (_output_household) is baselined for a decision. - sim.microdata.builder-tests-use-weighted-frames: the output-builder tests build entity frames only through the shared weighted factory. Enforced, with no findings. The AST guards live in the executor's tests, so the executor leg of the Test job runs them. Each rule's proof uses the real pre-#767 budget line or the mistake it mirrors. The rationale is in docs/engineering/skills/microdata-weights.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
_change_output_variable sums reform minus baseline through the entity frames. MicroSeries arithmetic keeps the left operand's weights, so that change is weighted by the reform's weights alone, and a mismatch between the two sides would pass silently. It now logs a warning unless both frames carry the same weights, allowing only a float32 round trip (relative tolerance 1e-6). Frames without weights are not compared. The total is still published. Making the mismatch an error waits until logs show that no legitimate run produces one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
budget.households is a plain sum of the household weights. #767's bug published the sum of squared weights instead, about 10^4 times too high (135.6 billion for New York). build_budgetary_impact now raises ImplausibleEntityCountError for a count that is negative, not finite, or above its country's bound: 200 million US households (about 131 million, US Census Bureau Current Population Survey 2023) or 60 million UK households (about 28 million, ONS Families and households 2023). Other countries only need a finite, non-negative count. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The UK local-authority breakdown hands policyengine's compute_longwise_uk_geography_impacts plain household frames, so that it can apply this service's boundary metadata. That function weights each area by the baseline's household_weight itself, so the strip is deliberate, but it is still a strip to remove. The constituency breakdown already hands policyengine the simulations, as the US congressional district breakdown does, and only needed the plain frame to check for its area-code column, which the weighted frame answers just as well. - The handoff moves into simulation_output_common.py as _unweighted_household_frame, with a docstring saying why it exists and what removes it. The constituency check reads the weighted frame. - sim.microdata.weight-stripping-confined now checks the designated module too: counting entities is always allowed there, and every other read is a recorded exception in the rule's baseline, which may only shrink. Its one entry is _unweighted_household_frame. A new test pins that sorting. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk
force-pushed
the
microdata-weight-rules
branch
from
October 15, 2026 16:06
f59f487 to
635f7f0
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #806
Summary
Phase 1 step 1.9: weighted-microdata rules. Stacked on #804.
The executor's entity frames are
MicroDataFrames weighted by<entity>_weight, so any column's.sum()is already weighted. #767 fixedbudget.households, which summedhousehold_weightthrough the household frame and published the sum of squared weights, about 10^4 times the count. It escaped because the builder test used plainpd.DataFrames.This PR:
Five commits, each passing the guards:
fixtures/weighted_frames.weighted_entity_framebuilds every output-builder test's entity frames asMicroDataFrames. It rejects a weight column that is the same on every row.Rules
sim.microdata.weights-not-values: weights, values, labels and counts each allow different operationssim.microdata.weights-not-aggregated-as-values: a weight is never the variable of_sum_output_variable,_change_output_variable,AggregateorChangeAggregate, norframe[<weight>]followed bysum,mean,median,quantile,std,count,groupbyorcumsumsim.microdata.weight-stripping-confined: raw values (np.asarray,np.array,.values,.to_numpy(),.tolist(),pd.DataFrame,pd.Series) are read from a weighted frame only insimulation_output_common.py. There, counting entities is always allowed, and every other read is a recorded exceptionsim.microdata.builder-tests-use-weighted-frames:tests/test_simulation_output_*.pybuild entity frames only through the shared factoryThe guards live in
projects/policyengine-simulation-executor/tests/test_microdata_weight_boundaries.py, so the executor leg of theTestjob runs them.How the guards recognise weights and weighted frames:
_weightor_weights(includingf"{entity}_weight"), a name assigned from one, or a.weightsattribute.<simulation>.output_dataset.data.<entity>;getattr(<simulation>.output_dataset.data, ...);_entity_data(...);MicroDataFrameorMicroSeriesbuilt withweights=;output_dataset.data.entity_dataare outside them:simulation_microdata, the Stage 12 simulation worker and the precompute determinism check.Proof
Each rule has a bad and a good fixture under
rules/fixtures/<rule-id>/, and atest_rule_proof_*test asserting exactly which expressions are flagged.households=_sum_output_variable(baseline, "household_weight", entity="household")._count_output_entities(baseline, entity="household", weight="household_weight").np.asarray(household["household_net_income"]).sum(), an unweighted sample total and the mirror image of Count households once: sum the weight column unweighted #767, against the weighted frame's own.sum().pd.DataFramehouseholds the builder test used when Count households once: sum the weight column unweighted #767 escaped, and a hand-builtMicroDataFrame, against the shared factory.Reverting #767's line on this branch now fails the aggregation guard and three builder tests. Before commit 1, only #767's own test could catch it: the other builder tests used plain frames, or weights of 1 and 1, which give the same count either way.
The recorded stripping exception
The UK local-authority breakdown calls policyengine's
compute_longwise_uk_geography_impactswith plain household frames, so it can apply this service's own boundary metadata.Why the strip is deliberate. That function reads income and the baseline's
household_weightas arrays, and weights each area by hand. Handing it the weighted frame would weight twice if it ever reduced a column directly.What commit 5 does:
simulation_output_common.pyas_unweighted_household_frame. Its docstring says why the helper exists and what would remove it.rules/baselines/sim-microdata-weight-stripping.txt, and that list may only shrink._unweighted_household_frame.test_weight_stripping_review_ratchets_the_designated_modulepins how findings are sorted.What removes the exception. policyengine's local-authority output needs a way to skip its own area lookup. The breakdown can then hand over the simulations, like the constituency breakdown.
Builder test changes
_macro_baseline_reform:CURRENT_SINGLE_YEAR_MACRO_RESULTis unchanged.test_builder_budgetary_impact_uses_materialized_columns_and_uk_state_tax_zero:weight * (reform - baseline)sums.Runtime checks
_change_output_variable):reform - baselinekeeps the reform's weights, so a change total over differently weighted sides would pass silently.ImplausibleEntityCountError(build_budgetary_impact):GET /jobs/{job_id}returns 500 withstatus: failedand the redactedSimulation failed (correlation_id=...), and a Stage 12 report is marked failed with the exception's name as its error summary.Testing
uv run pytest tests/: 1278 passed, 22 skipped, 3 deselected;python scripts/run_quality_guards.py: rules-registry and ci-scripts passed;python -m scripts.rules guard --base-ref origin/ruff-complexity: passed;python -m scripts.rules renderleaves no diff.python -m unittest discover -s scripts/rules/tests -t .passes.ruff check .andruff format --check(ruff 0.15.22, as CI pins it) are clean, with no newnoqa.changelog_entry.yamlparses with PyYAML, and every entry is a plain string.simulation_output_common.py, the stripping guard fails.🤖 Generated with Claude Code