Skip to content

Commit 7644a30

Browse files
committed
Include metadata in numpy/polars cache fingerprints to prevent collisions
hash_numpy_array now includes shape and dtype in the hash, preventing collisions between arrays with identical raw bytes but different semantics (e.g., shape=(6,) vs shape=(2,3)). hash_polars_dataframe now includes column names and dtypes (schema) in the hash, preventing collisions between DataFrames with identical cell values but different column schemas. Existing caches will simply miss (different hash = recomputation), not produce incorrect results. Reported-by: Dem0
1 parent 78ec446 commit 7644a30

2 files changed

Lines changed: 46 additions & 11 deletions

File tree

hamilton/caching/fingerprinting.py

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -261,11 +261,15 @@ def hash_pandas_obj(obj, *args, depth: int = 0, **kwargs) -> str:
261261

262262
@hash_value.register(h_databackends.AbstractPolarsDataFrame)
263263
def hash_polars_dataframe(obj, *args, depth: int = 0, **kwargs) -> str:
264-
"""Convert a polars dataframe, series, or index to
265-
a list of hashes then hash it.
264+
"""Convert a polars dataframe to a hash that includes column names
265+
and dtypes (schema) alongside row hashes. This prevents collisions
266+
between DataFrames with identical cell values but different schemas.
266267
"""
267-
hash_per_row = obj.hash_rows()
268-
return hash_sequence(hash_per_row.to_list(), depth=depth + 1)
268+
schema_str = ",".join(f"{name}:{dtype}" for name, dtype in obj.schema.items())
269+
schema_hash = hash_bytes(schema_str.encode())
270+
row_hash = hash_sequence(obj.hash_rows().to_list(), depth=depth + 1)
271+
combined = hashlib.md5(schema_hash.encode() + row_hash.encode())
272+
return _compact_hash(combined.digest())
269273

270274

271275
@hash_value.register(h_databackends.AbstractPolarsColumn)
@@ -277,11 +281,11 @@ def hash_polars_column(obj, *args, depth: int = 0, **kwargs) -> str:
277281

278282
@hash_value.register(h_databackends.AbstractNumpyArray)
279283
def hash_numpy_array(obj, *args, depth: int = 0, **kwargs) -> str:
280-
"""Get the bytes representation of the array raw data and hash it.
284+
"""Hash a numpy array including shape and dtype metadata.
281285
282-
Might not be ideal because different higher-level numpy objects could have
283-
the same underlying array representation (e.g., masked arrays).
284-
Unsure, but it's an area to investigate.
286+
Without metadata, arrays with the same raw bytes but different shapes
287+
or dtypes (e.g., shape=(6,) vs shape=(2,3), or float32 vs int32 with
288+
identical bit patterns) would produce identical hashes.
285289
"""
286-
# use the same depth because we're simply dispatching to another implementation
287-
return hash_bytes(obj.tobytes(), depth=depth)
290+
metadata = f"{obj.shape}:{obj.dtype}".encode()
291+
return hash_bytes(metadata + obj.tobytes(), depth=depth)

tests/caching/test_fingerprinting.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,37 @@ def test_hash_pandas():
210210

211211
def test_hash_numpy():
212212
array = np.array([[0, 1], [2, 3]])
213-
expected_hash = "ZwjDgY0zQOxO9KPHlYecog=="
213+
expected_hash = "tVIm5kJ7G0GZaaifSEtrOQ=="
214214
fingerprint = fingerprinting.hash_value(array)
215215
assert fingerprint == expected_hash
216+
217+
218+
def test_hash_numpy_different_shapes_differ():
219+
"""Arrays with the same raw bytes but different shapes must hash differently."""
220+
a = np.array([1, 2, 3, 4, 5, 6])
221+
b = np.array([[1, 2, 3], [4, 5, 6]])
222+
assert fingerprinting.hash_value(a) != fingerprinting.hash_value(b)
223+
224+
225+
def test_hash_numpy_different_dtypes_differ():
226+
"""Arrays with the same bit pattern but different dtypes must hash differently."""
227+
a = np.array([1.0], dtype=np.float32)
228+
b = np.array([1065353216], dtype=np.int32) # same 4 bytes as float32(1.0)
229+
assert a.tobytes() == b.tobytes() # confirm same raw bytes
230+
assert fingerprinting.hash_value(a) != fingerprinting.hash_value(b)
231+
232+
233+
def test_hash_polars_different_columns_differ():
234+
"""DataFrames with identical values but different column names must hash differently."""
235+
polars = pytest.importorskip("polars")
236+
a = polars.DataFrame({"region": ["East", "West"], "revenue": [100, 200]})
237+
b = polars.DataFrame({"student": ["East", "West"], "height_cm": [100, 200]})
238+
assert fingerprinting.hash_value(a) != fingerprinting.hash_value(b)
239+
240+
241+
def test_hash_polars_same_schema_same_data_matches():
242+
"""Identical DataFrames must produce the same hash."""
243+
polars = pytest.importorskip("polars")
244+
a = polars.DataFrame({"x": [1, 2], "y": [3, 4]})
245+
b = polars.DataFrame({"x": [1, 2], "y": [3, 4]})
246+
assert fingerprinting.hash_value(a) == fingerprinting.hash_value(b)

0 commit comments

Comments
 (0)