Skip to content

Model output frames have one column order, whatever the hash seed - #63

Merged
HughRunyan merged 3 commits into
mainfrom
waste-type-column-order-is-deterministic
Sep 9, 2026
Merged

Model output frames have one column order, whatever the hash seed#63
HughRunyan merged 3 commits into
mainfrom
waste-type-column-order-is-deterministic

Conversation

@HughRunyan

@HughRunyan HughRunyan commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Model output DataFrames came out with their waste-type columns in a different
order in every process. Same code, same inputs, different column order — because
a set of str iterates in hash order, and Python randomizes that per process
(PEP 456).

Found while verifying #62, which needed a fixed PYTHONHASHSEED to get a clean
before/after diff, and flagged there as out of scope.

Reproduction

Environment: Python 3.12, pinned requirements.txt (pandas 3.0.5),
pip install --no-deps -e . — the CI job exactly.

from SWEET_python.city_params import City, DiversionFractions
city = City("x")
city.dst_baseline_blank("Algeria", 2_594_000, 716.81, 18.38)
city.implement_dst_changes_simple_v1_5(
    DiversionFractions(compost=0.20, anaerobic=0.10, combustion=0.10, recycling=0.20),
    0, 0, 0.0, 0.0, 2026, 1, 0.10)
print(list(city.baseline_parameters.landfills[0].ch4.columns))
before after
PYTHONHASHSEED=0 ['food', 'paper_cardboard', 'textiles', 'green', 'wood'] ['food', 'green', 'wood', 'paper_cardboard', 'textiles']
PYTHONHASHSEED=1 ['paper_cardboard', 'food', 'green', 'wood', 'textiles'] ['food', 'green', 'wood', 'paper_cardboard', 'textiles']
PYTHONHASHSEED=2 ['green', 'food', 'paper_cardboard', 'textiles', 'wood'] ['food', 'green', 'wood', 'paper_cardboard', 'textiles']
PYTHONHASHSEED=3 ['wood', 'paper_cardboard', 'textiles', 'green', 'food'] ['food', 'green', 'wood', 'paper_cardboard', 'textiles']

Expected: the same column order on every run.
Actual (before): 356 of 749 dumped model outputs changed order between two
seeds. It matters for anything downstream that indexes positionally, writes
CSV/Parquet, caches, or diffs serialized model output — a re-run diffs dirty
against itself.

Root cause

City.components and each value of City.div_components were plain sets.
A set is the right shape — eligibility ("can this be composted?") is
membership, not sequence — but those same collections are what the engines
iterate to build DataFrame columns:

components = list(self.city_instance_attrs["components"])   # model_v2.py:228
...
ch4_df = pd.DataFrame(ch4_df, columns=components, index=index)

The fix: one definition of the order, in the type

WASTE_TYPES in constants.py is now the single canonical sequence, and
WasteTypeSet is a frozenset that iterates in it.

The order is not arbitrary, and not alphabetical. It is the field order of
WasteFractions / WasteMasses, which pydantic already imposes on every frame
built from a model_dump() — the scenario divs_df frames, for instance. There
were two orders in the package before this; adopting the pydantic one leaves
one. A test pins them together so they cannot drift apart.

Why the type and not sorted() at the call sites. There are 35 live
list(<component collection>) conversions and 45 iterations over one. Fixing it
in the type makes all 80 deterministic with nothing to remember — including the
ones outside this repo, since the Climate TRACE pipeline reaches into the same
attributes (mitigation_strategies.py:181, :201). A sorted() someone
forgets to add is silent, and it would order columns alphabetically — a third
order.

Set semantics are untouched: in, == against a plain set, &/|/-/^
(which re-wrap, so a derived set stays ordered), deepcopy — which the DST
relies on to build a scenario from the baseline. Nothing in this repo, the TRACE
pipeline or the WasteMAP backend mutates these collections, so frozenset is
safe; that was checked rather than assumed.

advanced_dst_city.py had a lone sorted(...) # deterministic column order.
It was deterministic, but alphabetical — the second order. Now the canonical one.

No output-value change — so not a model-output-change

Five city-DST runs (four Algeria scenarios, one Kenya) dumped at full float
precision via repr(): 749 model outputs, 297,806 numbers, compared
column-aligned so ordering and values are judged separately.

Cross-seed, after: the dumps under PYTHONHASHSEED 0, 1 and 2 are
byte-identical — sha256 ab37f4d0b2b979d7cca7d1c4cb47b280b2b8a5c16af82625509069f4d349938a.
0 column-order differences, 0 value differences. Before, the same comparison
gave 356 order differences and 12 outputs whose values differed.

Before vs after, column-aligned:

seed cells differing / 297,806 where size
0 307 all in the derived total column exactly 1 ULP (max relative 2.2e-16)
1 0
2 0

Every per-waste-type number — ch4, captured, emissions,
waste_mass_after_degredation, every divs frame — is bit-identical at all
three seeds.

The 307 cells are the point rather than a caveat: total is
q_df.sum(axis=1), a sum across those columns, and floating-point addition
is not associative. Its last bit followed the column order, so it followed the
hash seed too — the same run gave 674.3032214448853 in one process and
674.3032214448854 in another. There was no stable value to change away from;
there is one now. Nothing here is a modeling change, so no
model-output-change label.

advanced_dst_city, moving from alphabetical to canonical, is the same
arithmetic in a different order: 958 of 3,528 cells move, all within 5 ULP
(max relative 5.8e-16), because denom = sub.sum(axis=1) sums the reordered
columns. Its outputs are also now identical across seeds.

Deliberately not changed

DivsDF.sum() builds its columns with Index.union, which sorts — so it,
and the waste_mass_df derived from it, come out alphabetical. That is a second
order, but it is deterministic, so it was never part of this bug; those exact
lines are being edited by #62; and it affects nothing downstream, because every
consumer of waste_mass_df slices it by name. Pinned by a test with that
reasoning attached, so the remaining inconsistency is visible rather than
folklore. Worth a follow-up once #62 lands.

Tests

tests/test_waste_type_order.py, 27 tests, hermetic (no DB, no network), ~1.5s.
7 of them fail on the pre-fix code — verified by reverting city_params.py
and advanced_dst_city.py to main while keeping the new module and tests.

  • test_column_order_does_not_depend_on_the_hash_seed — the property itself.
    Two subprocesses at PYTHONHASHSEED 0 and 1 (it is read once at interpreter
    start, so it cannot be varied in-process), comparing the components, a
    landfill's ch4 and emissions columns, two divs frames' columns, and the
    total value that used to move by 1 ULP.
  • test_landfill_emissions_frames_are_pinned — a landfill's ch4 / captured /
    emissions / waste_mass_after_degredation columns pinned literally.
  • test_divs_frames_are_pinned — the eligibility-built baseline divs_df
    frames, per stream.
  • test_both_divs_construction_paths_agree_on_the_order — the model_dump()
    path and the eligibility path now produce the same order.
  • test_canonical_order_matches_the_pydantic_models — stops WASTE_TYPES and
    WasteFractions drifting apart, which would reintroduce two orders.
  • TestWasteTypeSet — iteration order independent of construction order, set
    semantics preserved, operators re-wrap, unknown members sort last, survives
    deepcopy. Includes the asymmetry Python imposes: plain & ordered resolves
    the left operand's __and__ first and returns a plain set.

Acceptance Criteria

  • Model output column order is identical across processes — demonstrated on
    749 outputs at three hash seeds, byte-identical dumps.
  • One definition of the order, in one place, tied to the pydantic models
    that already imposed it; no sorted() added at any call site, and the one
    that existed removed.
  • Set semantics of components / div_components unchanged.
  • No model-output-value change, shown column-aligned at full precision
    rather than asserted — hence no model-output-change label.
  • Column order pinned by test for a landfill emissions frame and a divs
    frame; the regression test fails on the pre-fix code.
  • Changelog entry in changelog/2026-09.md per CLAUDE.md.

Definition of Done

  • Acceptance criteria met
  • Tests & checks pass — 160 passed (133 existing + 27 new) under Python
    3.12 with the pinned requirements.txt, at PYTHONHASHSEED 0 and 1
  • Docs updated where relevant — changelog entry + changelog/README.md
    highlights; constants.py documents the order and the type
  • Reviewed & merged

Notes for the reviewer

🤖 Generated with Claude Code

@HughRunyan HughRunyan added bug Something isn't working python Pull requests that update python code test Adds or modifies tests labels Sep 8, 2026
HughRunyan added a commit that referenced this pull request Sep 8, 2026
@HughRunyan
HughRunyan requested a lite review from Copilot September 8, 2026 22:25

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The changelog currently states “No output-value change” / “The only cells that move…” while also describing an advanced_dst_city reorder that can change cross-column sums by a few ULP and should be documented accurately.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR makes waste-type column ordering in model-output DataFrames deterministic across processes by replacing hash-iteration set usage with an ordered, set-semantics type and a single canonical waste-type sequence.

Changes:

  • Introduces WASTE_TYPES as the canonical waste-type sequence and WasteTypeSet (an ordered-iteration frozenset subclass) in constants.py.
  • Updates City waste-type collections (components, div_components, and related attributes) to use WasteTypeSet, and removes an alphabetical sorted() call in advanced_dst_city.
  • Adds a focused regression test suite to pin ordering across hash seeds and to lock the canonical order to the pydantic model field order; updates changelog highlights.
File summaries
File Description
SWEET_python/constants.py Adds canonical WASTE_TYPES and WasteTypeSet to enforce deterministic iteration order.
SWEET_python/city_params.py Converts waste-type eligibility collections from set to WasteTypeSet and aligns waste_types with WASTE_TYPES.
SWEET_python/advanced_dst_city.py Removes alphabetical sorted() in favor of canonical iteration order from WasteTypeSet.
tests/test_waste_type_order.py Adds regression and contract tests to pin ordering and hash-seed independence.
changelog/README.md Updates the monthly highlights summary to include the ordering fix.
changelog/2026-09.md Documents the ordering fix and related determinism implications.
Review details
  • Files reviewed: 6/6 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread changelog/2026-09.md Outdated
HughRunyan added a commit that referenced this pull request Sep 8, 2026
@HughRunyan
HughRunyan force-pushed the waste-type-column-order-is-deterministic branch from 4eaa150 to 0f56ffe Compare September 8, 2026 23:44
The waste-type eligibility collections on City -- `components` and each value
of `div_components` -- were plain `set`s. They answer a membership question, so
a set is the right shape; but they are also what the engines iterate to build
DataFrame columns, and a `set` of `str` iterates in hash order, which Python
randomizes per process (PEP 456). The same code on the same inputs therefore
produced a different column order in every process:

    PYTHONHASHSEED=0  ['food', 'paper_cardboard', 'textiles', 'green', 'wood']
    PYTHONHASHSEED=1  ['paper_cardboard', 'food', 'green', 'wood', 'textiles']

`WASTE_TYPES` in constants.py is now the single definition of that order -- the
field order of WasteFractions/WasteMasses, so the frames built from a pydantic
`model_dump()` and the frames built from an eligibility set finally agree --
and `WasteTypeSet` is a frozenset that iterates in it. Fixing this in the type
rather than at the ~30 call sites means every `list(city.components)` and
`for waste in div_components[div]` becomes deterministic with nothing to
remember, here and in the Climate TRACE pipeline, which reaches into the same
attributes.

advanced_dst_city switches from `sorted()` (deterministic, but alphabetical and
so a second order) to the same canonical one.

Verified on 749 dumped model outputs / 297,806 numbers across five city-DST
runs: the three hash seeds now produce a byte-identical dump, sha256 ab37f4d0.
Column-aligned against the pre-change code the numbers are unchanged, except
307 cells of the derived `total` column at one seed, each off by exactly 1 ULP
-- `total` is a sum ACROSS the columns and floating-point addition is not
associative, so its last bit used to follow the hash seed too.
@HughRunyan
HughRunyan force-pushed the waste-type-column-order-is-deterministic branch from 0f56ffe to 359eae0 Compare September 9, 2026 01:11
Copilot review on #63: the entry claimed "No output-value change" and "the only
cells that move are 307 in the derived `total` column", while the same paragraph
described `advanced_dst_city` moving off its alphabetical `sorted()`. Those two
statements cannot both hold -- `_diverted_masses` does `denom = sub.sum(axis=1)`
across exactly those reordered columns, and floating-point addition is not
associative.

Verified rather than argued. Summing a Dirichlet-random composition in canonical
versus alphabetical order differs for 24-52% of draws per pathway (max 5.5e-16
relative). End to end on a 137,414 t/yr city diverting to compost, recycling and
combustion: per-component diverted mass moves by up to 1.5e-16 relative
(9.1e-13 t) and modelled emissions by up to 8.9e-16. So per-waste-type numbers
do move, not just `total`.

The entry now separates the two claims: byte-identical across hash seeds (the
point of the PR), and a 1-ULP change against the previous code wherever a sum
runs across the columns, with both sites named. Highlights and the changelog
README summary say the same. Labelled model-output-change accordingly.

206 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@HughRunyan HughRunyan added the model-output-change Changes model OUTPUT values (expected progress, not breaking); results differ from prior runs label Sep 9, 2026
@HughRunyan
HughRunyan merged commit 76bac88 into main Sep 9, 2026
2 checks passed
@HughRunyan
HughRunyan deleted the waste-type-column-order-is-deterministic branch September 9, 2026 01:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working model-output-change Changes model OUTPUT values (expected progress, not breaking); results differ from prior runs python Pull requests that update python code test Adds or modifies tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants