Model output frames have one column order, whatever the hash seed - #63
Merged
Conversation
There was a problem hiding this comment.
🟡 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_TYPESas the canonical waste-type sequence andWasteTypeSet(an ordered-iterationfrozensetsubclass) inconstants.py. - Updates
Citywaste-type collections (components,div_components, and related attributes) to useWasteTypeSet, and removes an alphabeticalsorted()call inadvanced_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.
HughRunyan
force-pushed
the
waste-type-column-order-is-deterministic
branch
from
September 8, 2026 23:44
4eaa150 to
0f56ffe
Compare
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
force-pushed
the
waste-type-column-order-is-deterministic
branch
from
September 9, 2026 01:11
0f56ffe to
359eae0
Compare
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>
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.
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
setofstriterates in hash order, and Python randomizes that per process(PEP 456).
Found while verifying #62, which needed a fixed
PYTHONHASHSEEDto get a cleanbefore/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.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.componentsand each value ofCity.div_componentswere plainsets.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:
The fix: one definition of the order, in the type
WASTE_TYPESinconstants.pyis now the single canonical sequence, andWasteTypeSetis afrozensetthat 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 framebuilt from a
model_dump()— the scenariodivs_dfframes, for instance. Therewere 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 livelist(<component collection>)conversions and 45 iterations over one. Fixing itin 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). Asorted()someoneforgets 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 DSTrelies on to build a scenario from the baseline. Nothing in this repo, the TRACE
pipeline or the WasteMAP backend mutates these collections, so
frozensetissafe; that was checked rather than assumed.
advanced_dst_city.pyhad a lonesorted(...) # deterministic column order.It was deterministic, but alphabetical — the second order. Now the canonical one.
No output-value change — so not a
model-output-changeFive city-DST runs (four Algeria scenarios, one Kenya) dumped at full float
precision via
repr(): 749 model outputs, 297,806 numbers, comparedcolumn-aligned so ordering and values are judged separately.
Cross-seed, after: the dumps under
PYTHONHASHSEED0, 1 and 2 arebyte-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:
totalcolumn2.2e-16)Every per-waste-type number —
ch4,captured,emissions,waste_mass_after_degredation, everydivsframe — is bit-identical at allthree seeds.
The 307 cells are the point rather than a caveat:
totalisq_df.sum(axis=1), a sum across those columns, and floating-point additionis not associative. Its last bit followed the column order, so it followed the
hash seed too — the same run gave
674.3032214448853in one process and674.3032214448854in another. There was no stable value to change away from;there is one now. Nothing here is a modeling change, so no
model-output-changelabel.advanced_dst_city, moving from alphabetical to canonical, is the samearithmetic in a different order: 958 of 3,528 cells move, all within 5 ULP
(max relative
5.8e-16), becausedenom = sub.sum(axis=1)sums the reorderedcolumns. Its outputs are also now identical across seeds.
Deliberately not changed
DivsDF.sum()builds its columns withIndex.union, which sorts — so it,and the
waste_mass_dfderived from it, come out alphabetical. That is a secondorder, 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_dfslices it by name. Pinned by a test with thatreasoning 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.pyand
advanced_dst_city.pytomainwhile keeping the new module and tests.test_column_order_does_not_depend_on_the_hash_seed— the property itself.Two subprocesses at
PYTHONHASHSEED0 and 1 (it is read once at interpreterstart, so it cannot be varied in-process), comparing the components, a
landfill's
ch4andemissionscolumns, twodivsframes' columns, and thetotalvalue that used to move by 1 ULP.test_landfill_emissions_frames_are_pinned— a landfill'sch4/captured/emissions/waste_mass_after_degredationcolumns pinned literally.test_divs_frames_are_pinned— the eligibility-built baselinedivs_dfframes, per stream.
test_both_divs_construction_paths_agree_on_the_order— themodel_dump()path and the eligibility path now produce the same order.
test_canonical_order_matches_the_pydantic_models— stopsWASTE_TYPESandWasteFractionsdrifting apart, which would reintroduce two orders.TestWasteTypeSet— iteration order independent of construction order, setsemantics preserved, operators re-wrap, unknown members sort last, survives
deepcopy. Includes the asymmetry Python imposes:plain & orderedresolvesthe left operand's
__and__first and returns a plain set.Acceptance Criteria
749 outputs at three hash seeds, byte-identical dumps.
that already imposed it; no
sorted()added at any call site, and the onethat existed removed.
components/div_componentsunchanged.rather than asserted — hence no
model-output-changelabel.frame; the regression test fails on the pre-fix code.
changelog/2026-09.mdper CLAUDE.md.Definition of Done
3.12 with the pinned
requirements.txt, atPYTHONHASHSEED0 and 1changelog/README.mdhighlights;
constants.pydocuments the order and the typeNotes for the reviewer
.github/workflows/tests.ymlis not on
mainyet. The 160-passed result above is a local run reproducingthat job exactly: Python 3.12,
pip install -r requirements.txt,pip install --no-deps -e ..copykeyword #62: this touchesconstants.py,city_params.py,advanced_dst_city.pyand a new test file; The diversion sum keeps working when pandas removes thecopykeyword #62 touchesclass_defs.py.copykeyword #62's, not new here.regression test covers the same property at a size that belongs in CI.
🤖 Generated with Claude Code