Skip to content

Register weighted-microdata rules, weight the builder tests, and check change weights and household counts - #807

Draft
anth-volk wants to merge 5 commits into
ruff-complexityfrom
microdata-weight-rules
Draft

anth-volk wants to merge 5 commits into
ruff-complexityfrom
microdata-weight-rules

Conversation

@anth-volk

@anth-volk anth-volk commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

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 fixed budget.households, which summed household_weight through the household frame and published the sum of squared weights, about 10^4 times the count. It escaped because the builder test used plain pd.DataFrames.

This PR:

  • registers the rule that weights aren't values;
  • guards the shapes a source check can recognise;
  • makes the builder tests weight as production does;
  • adds two runtime checks.

Five commits, each passing the guards:

  1. Builder tests through one weighted factory (test-only). fixtures/weighted_frames.weighted_entity_frame builds every output-builder test's entity frames as MicroDataFrames. It rejects a weight column that is the same on every row.
  2. The rules, guards and proofs.
  3. Runtime check: warn when change totals are weighted differently on each side.
  4. Runtime check: refuse an implausible published household count.
  5. The UK local-authority handoff becomes a recorded, ratcheted exception in the module allowed to strip weights.

Rules

Rule Status Findings
sim.microdata.weights-not-values: weights, values, labels and counts each allow different operations doc-only —
sim.microdata.weights-not-aggregated-as-values: a weight is never the variable of _sum_output_variable, _change_output_variable, Aggregate or ChangeAggregate, nor frame[<weight>] followed by sum, mean, median, quantile, std, count, groupby or cumsum enforced 0
sim.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 in simulation_output_common.py. There, counting entities is always allowed, and every other read is a recorded exception ratcheted 1 recorded exception
sim.microdata.builder-tests-use-weighted-frames: tests/test_simulation_output_*.py build entity frames only through the shared factory enforced 0

The guards live in projects/policyengine-simulation-executor/tests/test_microdata_weight_boundaries.py, so the executor leg of the Test job runs them.

How the guards recognise weights and weighted frames:

  • A weight is a string or f-string ending in _weight or _weights (including f"{entity}_weight"), a name assigned from one, or a .weights attribute.
  • A weighted frame is any of:
    • <simulation>.output_dataset.data.<entity>;
    • getattr(<simulation>.output_dataset.data, ...);
    • _entity_data(...);
    • a MicroDataFrame or MicroSeries built with weights=;
    • anything assigned, indexed or computed from those.
  • What they can't see: they don't follow data through function calls or containers. So the whole-frame readers that iterate output_dataset.data.entity_data are 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 a test_rule_proof_* test asserting exactly which expressions are flagged.

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_impacts with 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_weight as 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:

  • The helper. The handoff moves into simulation_output_common.py as _unweighted_household_frame. Its docstring says why the helper exists and what would remove it.
  • The constituency check. The constituency breakdown already hands policyengine the simulations, as the US congressional district breakdown does. It only used the plain frame to check for its area-code column, and now reads the weighted frame for that.
  • The ratchet. The stripping rule now checks the designated module as well. Counting entities is always allowed there. Every other read must be listed in rules/baselines/sim-microdata-weight-stripping.txt, and that list may only shrink.
    • Its one entry is _unweighted_household_frame.
    • test_weight_stripping_review_ratchets_the_designated_module pins how findings are sorted.
    • Adding a new read to the module fails the guard.

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:
    • Its two households now weigh 0.5 and 1.5. The values are chosen so the weighted totals still equal the golden macro result's budget, so CURRENT_SINGLE_YEAR_MACRO_RESULT is unchanged.
    • Unweighted sums would give 120, 40, 24 and 1200 instead of 100, 30, 20 and 1000.
    • Summing the weights through the weighted frame (Count households once: sum the weight column unweighted #767) would give 2.5 households instead of 2.
  • test_builder_budgetary_impact_uses_materialized_columns_and_uk_state_tax_zero:
    • It now expects weighted totals, written out in the test as weight * (reform - baseline) sums.
    • Tax 25 (was 15), state tax 8 (was 5), benefits -5 (was -3), budget 30 (was 18), baseline net income 500 (was 300). Households stay at 3.
    • The old values were unweighted sums of plain frames, which production never returned.

Runtime checks

  • Weight mismatch (_change_output_variable):
    • reform - baseline keeps the reform's weights, so a change total over differently weighted sides would pass silently.
    • It now logs a warning when the two frames' weights differ in length, or by more than a float32 round trip (relative tolerance 1e-6). The total is still published.
    • Making this an error waits until logs show that no legitimate run produces a mismatch.
  • ImplausibleEntityCountError (build_budgetary_impact):
    • The household count must be finite, non-negative, and at most:
      • US: 200 million (about 131 million households, US Census Bureau Current Population Survey 2023);
      • UK: 60 million (about 28 million, ONS Families and households 2023).
    • Otherwise the run fails. GET /jobs/{job_id} returns 500 with status: failed and the redacted Simulation failed (correlation_id=...), and a Stage 12 report is marked failed with the exception's name as its error summary.
    • Country-level bounds can't catch an inflated count in a small region.

Testing

  • Suites:
    • executor uv run pytest tests/: 1278 passed, 22 skipped, 3 deselected;
    • entry: 528 passed;
    • contract: 207 passed.
  • Guards:
    • 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 render leaves no diff.
  • Rules tool: python -m unittest discover -s scripts/rules/tests -t . passes.
  • Ruff: ruff check . and ruff format --check (ruff 0.15.22, as CI pins it) are clean, with no new noqa.
  • Changelog: changelog_entry.yaml parses with PyYAML, and every entry is a plain string.
  • Negative checks:
  • No published schema or OpenAPI document changes.

🤖 Generated with Claude Code

@anth-volk
anth-volk added this pull request to stack #755 October 14, 2026 21:40
@anth-volk anth-volk changed the title microdata weight rules Register weighted-microdata rules, weight the builder tests, and check change weights and household counts Oct 14, 2026
@anth-volk
anth-volk force-pushed the microdata-weight-rules branch 3 times, most recently from 6b8c63b to cd7ad69 Compare October 15, 2026 14:06
@anth-volk
anth-volk force-pushed the microdata-weight-rules branch from cd7ad69 to f59f487 Compare October 15, 2026 15:09
anth-volk and others added 5 commits October 15, 2026 19:57
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
anth-volk force-pushed the microdata-weight-rules branch from f59f487 to 635f7f0 Compare October 15, 2026 16:06

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.

Register weighted-microdata rules: weights aren't values

1 participant