Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions python/cudf_polars/cudf_polars/dsl/expressions/literal.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# SPDX-FileCopyrightText: Copyright (c) 2024-2026, NVIDIA CORPORATION & AFFILIATES.
# SPDX-FileCopyrightText: Copyright (c) 2024-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
# TODO: remove need for this
# ruff: noqa: D101
Expand All @@ -23,6 +23,20 @@
__all__ = ["Literal", "LiteralColumn"]


def _freeze_for_hash(value: Any) -> Hashable:
"""
Convert ``value`` into a process-independent hashable form.

Nested ``list`` / ``dict`` values (e.g. list/struct literals) are frozen
into tuples so they can appear in :meth:`Node.get_hashable` results.
"""
if isinstance(value, dict):
return tuple(sorted((k, _freeze_for_hash(v)) for k, v in value.items()))
Comment on lines +33 to +34

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Handle heterogeneous dictionary keys without ordering them directly.

A dictionary can contain keys such as 1 and "1". sorted then compares these keys and raises TypeError. This prevents Literal.get_stable_id() from completing.

Use a type-aware canonical key encoding before sorting, or reject unsupported dictionary key types during construction.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@python/cudf_polars/cudf_polars/dsl/expressions/literal.py` around lines 33 -
34, Update the dict-handling branch in _freeze_for_hash to canonicalize keys
with a type-aware, comparable encoding before sorting, so heterogeneous keys
such as integers and strings cannot trigger TypeError while preserving distinct
key identities for Literal.get_stable_id().

if isinstance(value, list):
return tuple(_freeze_for_hash(v) for v in value)
return value


class Literal(Expr):
__slots__ = ("value",)
_non_child = ("dtype", "value")
Expand Down Expand Up @@ -57,9 +71,8 @@ def agg_request(self) -> NoReturn: # noqa: D102
"Not expecting to require agg request of literal"
) # pragma: no cover

def get_hashable(self) -> Hashable:
"""Get the hash of the literal."""
return (type(self), self.dtype.plc_type, id(self.value))
def get_hashable(self) -> Hashable: # noqa: D102
return (type(self), self.dtype.plc_type, _freeze_for_hash(self.value))

def astype(self, dtype: DataType) -> Literal:
"""Cast self to dtype."""
Expand Down
53 changes: 38 additions & 15 deletions python/cudf_polars/cudf_polars/dsl/nodebase.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# SPDX-FileCopyrightText: Copyright (c) 2024-2026, NVIDIA CORPORATION & AFFILIATES.
# SPDX-FileCopyrightText: Copyright (c) 2024-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

"""Base class for IR nodes, and utilities."""
Expand All @@ -21,13 +21,26 @@
T = TypeVar("T", bound="Node[Any]")


def _expand_hashable(obj: Any) -> Any:
"""Expand nested Node instances to their hashable form."""
def _update_stable_hasher(hasher: Any, obj: Hashable) -> None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Perhaps a nicer way of doing this is something like the following.

The pre-order traversal of an expression uniquely defines it. A non-leaf object is therefore uniquely defined by the structure induced by:

[(type(n), n._non_child) for n in traversal([root])]

I think?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Ohh I like the idea of using its position in the traversal. Let me see how that works.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I wasn't immediately able to get this to work :/

"""
Feed ``hasher`` a bottom-up structural digest of ``obj``.

Nested :class:`Node` instances contribute their full cached digest
rather than a truncated id or a full subtree expansion, so hashing an
entire DAG is linear in total local payload size while distinct child
subtrees remain distinguishable when composing parent digests.
"""
if isinstance(obj, Node):
return _expand_hashable(obj.get_hashable())
hasher.update(b"N")
hasher.update(obj._get_stable_digest())
elif isinstance(obj, tuple):
return tuple(_expand_hashable(x) for x in obj)
return obj
hasher.update(b"T")
hasher.update(len(obj).to_bytes(4, "big"))
for x in obj:
_update_stable_hasher(hasher, x)
else:
hasher.update(b"L")
hasher.update(repr(obj).encode("utf-8"))
Comment thread
coderabbitai[bot] marked this conversation as resolved.


class Node(Generic[T]):
Expand All @@ -49,11 +62,13 @@ class Node(Generic[T]):
__slots__ = (
"_hash_value",
"_repr_value",
"_stable_digest",
"_stable_hash_value",
"_stable_plan_id",
"children",
)
_hash_value: int
_stable_digest: bytes
_stable_hash_value: int
_stable_plan_id: uuid.UUID
_repr_value: str
Expand Down Expand Up @@ -102,17 +117,23 @@ def get_hashable(self) -> Hashable:
"""
return (type(self), self._ctor_arguments(self.children))

def _get_stable_digest(self) -> bytes:
"""Return the full MD5 digest for this node, computing it if needed."""
try:
return self._stable_digest
except AttributeError:
h = hashlib.md5(usedforsecurity=False)
_update_stable_hasher(h, self.get_hashable())
self._stable_digest = h.digest()
return self._stable_digest

def get_stable_id(self) -> int:
"""
Compute a stable identifier for Node.

Uses MD5 hash of the node's hashable representation for determinism
across process boundaries (Python's hash() uses PYTHONHASHSEED).

Parameters
----------
ir_node
The IR node.
Digests :meth:`get_hashable` bottom-up with MD5 so the result is
deterministic across process boundaries (Python's ``hash()`` uses
``PYTHONHASHSEED``).

Returns
-------
Expand All @@ -122,8 +143,10 @@ def get_stable_id(self) -> int:
try:
return self._stable_hash_value
except AttributeError:
content = repr(_expand_hashable(self)).encode("utf-8")
self._stable_hash_value = int(hashlib.md5(content).hexdigest()[:8], 16)
# First 4 digest bytes == int(hexdigest()[:8], 16).
self._stable_hash_value = int.from_bytes(
self._get_stable_digest()[:4], "big"
)
return self._stable_hash_value

def get_stable_plan_id(self) -> uuid.UUID:
Expand Down
8 changes: 6 additions & 2 deletions python/cudf_polars/tests/dsl/test_nodebase.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES.
# SPDX-FileCopyrightText: Copyright (c) 2025-2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

from __future__ import annotations
Expand Down Expand Up @@ -93,9 +93,12 @@ def test_get_stable_id(node: Node):
assert isinstance(node_id, int)
# Second call should return the cached value
assert node.get_stable_id() == node_id
# External id is a truncation of the full digest used for composition.
assert node_id == int.from_bytes(node._get_stable_digest()[:4], "big")
assert len(node._get_stable_digest()) == 16


def test_get_stable_plan_id():
def test_get_stable_plan_id() -> None:
node = expr.BinOp(
DataType(pl.Int64()),
plc.binaryop.BinaryOperator.ADD,
Expand All @@ -115,6 +118,7 @@ def test_get_stable_plan_id():
expr.Literal(DataType(pl.Int64()), 1),
)
assert node2.get_stable_plan_id() == plan_id
assert node.children[0].get_stable_id() == node2.children[0].get_stable_id()

# And uniqueness
node3 = node.children[0]
Expand Down
13 changes: 13 additions & 0 deletions python/cudf_polars/tests/expressions/test_literal.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,19 @@ def test_literal_hash(dtype, val):
assert isinstance(hash(Literal(DataType(dtype), val)), int)


def test_literal_stable_id_is_content_based():
a = Literal(DataType(pl.Int64()), 42)
b = Literal(DataType(pl.Int64()), 42)
c = Literal(DataType(pl.Int64()), 43)
assert a.get_stable_id() == b.get_stable_id()
assert a.get_stable_id() != c.get_stable_id()
# Distinct Python types with the same dtype stay distinct via value.
assert (
Literal(DataType(pl.String()), "42").get_stable_id()
!= Literal(DataType(pl.Int64()), 42).get_stable_id()
)
Comment on lines +181 to +185

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Those have different dtypes, so at the very least this comment is wrong.



def test_struct_literal_not_supported(engine: pl.GPUEngine):
df = pl.LazyFrame({"a": [1, 2, 3]})
q = df.select(pl.lit({"x": 1, "y": "foo"}))
Expand Down
Loading