Skip to content

Fix: PMap buckets group entries by hash(key) % len(buckets)... - #330

Open
M001N wants to merge 1 commit into
tobgu:masterfrom
M001N:oss-engine/e3228716-87e43918
Open

Fix: PMap buckets group entries by hash(key) % len(buckets)...#330
M001N wants to merge 1 commit into
tobgu:masterfrom
M001N:oss-engine/e3228716-87e43918

Conversation

@M001N

@M001N M001N commented Aug 16, 2026

Copy link
Copy Markdown

Summary

In pyrsistent/_pmap.py, updated all key-comparison sites (PMap._getitem, PMap._contains, the evolver's set() including its bucket-rebuild comprehension, and the evolver's remove()) to gate every k == key call behind k is key or (hash(k) == hash(key) and k == key), mirroring CPython dict's own lookup guarantee: a stored key only matches if it's literally the same object, or if it has the exact same hash AND compares equal. This prevents a pathological eq from ever being consulted across genuine hash collisions caused only by the coarser bucket-modulo grouping.

Problem

tobgu/pyrsistent issue reference: #212

Root Cause

PMap buckets group entries by hash(key) % len(buckets) (pyrsistent/_pmap.py _get_bucket), which is coarser than exact hash equality. _getitem, _contains, the evolver's getitem/set, and the bucket-removal logic all compared candidate keys purely via k == key, so any two objects landing in the same modulo bucket would have eq invoked on them regardless of whether their real hashes matched. A pathological eq (e.g. always True) then causes the wrong entry to be matched, returned, overwritten, or removed. A pure k is key or k == key identity-shortcut fix (as initially attempted) is INSUFFICIENT: it still calls the buggy eq against every other entry in the bucket, so if a non-matching entry happens to be visited before the true identity match, it wins first. CPython's real dict avoids this because it only ever calls eq on a candidate slot when that slot's exact stored hash equals the lookup key's hash (identity is also checked first as a fast path) -- it never merely relies on a coarser modulo-bucket grouping.

Testing

PASS: tests/map_test.py 53/53 passed; full suite 640 passed, 1 skipped with deterministic ordering (-p no:randomly). Note: with pytest-randomly's default random ordering, the full suite shows unrelated RecursionError failures in record/checked_vector/class tests; verified these are pre-existing on the unmodified base commit too (order-dependent, unrelated to this fix) and disappear entirely with deterministic ordering.

Related Issue

#212

PMap._getitem, _contains, the evolver's set/getitem, and the bucket
removal logic compared keys purely via k == key. Buckets group entries
by hash(key) % len(buckets), which is coarser than exact hash equality,
so a custom __eq__ that claims equality between unrelated objects (e.g.
always returning True) could match the wrong bucket entry and return/
overwrite/remove the wrong key's value.

Mirror CPython dict's own lookup guarantee: a stored key only matches
if it is literally the same object, or if it has the same hash AND
compares equal via __eq__. This gates calls to a possibly-buggy __eq__
behind an identity check and an exact hash comparison, exactly as the
builtin dict does internally, fixing tobgu#212.

Adds regression tests in tests/map_test.py covering __getitem__,
__contains__, and .set() on an existing key using a pathological
always-equal key type.
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