This is an automated email from the ASF dual-hosted git repository.

junrushao pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tvm-ffi.git


The following commit(s) were added to refs/heads/main by this push:
     new 5796ff4b test(python): add comprehensive RecursiveHash test suite 
(#485)
5796ff4b is described below

commit 5796ff4b6b1a765a9181addaeb56ba9b253cfa8b
Author: Junru Shao <[email protected]>
AuthorDate: Fri Feb 27 17:11:28 2026 -0800

    test(python): add comprehensive RecursiveHash test suite (#485)
    
    ## Summary
    
    - Expose `RecursiveHash` to the Python FFI API (`_ffi_api.py` stub +
    `__all__`)
    - Add `TestHash` and `TestCustomHash` reflected test fixture classes to
    `tvm_ffi.testing`
    - Add comprehensive `test_dataclass_hash.py` covering the full
    `RecursiveHash` contract
    
    ## Architecture
    
    - Two new reflected test fixture classes registered via C++ reflection:
    - **`TestHash`** (`testing.TestHash`): exercises `Hash(false)` field
    exclusion on `hash_ignored`
    - **`TestCustomHash`** (`testing.TestCustomHash`): exercises
    `__ffi_hash__` custom hook (hashes only `key`, ignores `label`)
    
    ## Test Coverage
    
    | Category | What's tested |
    |---|---|
    | Primitives | int, float, bool, str, bytes, None, DataType, Device |
    | NaN handling | All NaN payloads hash equal; canonicalization in nested
    containers |
    | Signed zero | `+0.0` and `-0.0` hash identically |
    | Containers | Array, List, Shape, Map, Dict —
    equal/different/empty/nested |
    | Reflected objects | TestIntPair, inherited fields (3-level), objects
    with container fields |
    | Field exclusion | `Hash(false)` via TestHash; `Compare(false)` implies
    hash-off |
    | Custom hooks | `__ffi_hash__` via TestCustomHash and TestCustomCompare
    |
    | Cycle detection | Self-referential List/Dict hashing succeeds
    gracefully |
    | Consistency law | `RecursiveEq(a, b) ⟹ RecursiveHash(a) ==
    RecursiveHash(b)` — primitives, containers, reflected objects, custom
    hooks |
    | Aliasing invariants | Shared vs duplicated references produce
    identical hashes |
    | Recursion depth | 127 and 1000 levels of nesting (iterative heap-based
    stack) |
    | DAG scaling | Shared binary DAG hashing is linear, not exponential
    (warm-up + averaged) |
    | Guard | `__ffi_eq__` without `__ffi_hash__` raises ValueError |
    
    ## Test Plan
    
    - [x] `uv run pytest -vvs tests/python/test_dataclass_hash.py`
---
 python/tvm_ffi/_ffi_api.py          |   2 +
 python/tvm_ffi/testing/__init__.py  |   2 +
 python/tvm_ffi/testing/testing.py   |  37 ++
 tests/python/test_dataclass_hash.py | 971 ++++++++++++++++++++++++++++++++++++
 4 files changed, 1012 insertions(+)

diff --git a/python/tvm_ffi/_ffi_api.py b/python/tvm_ffi/_ffi_api.py
index 8fc050ab..1da4c69f 100644
--- a/python/tvm_ffi/_ffi_api.py
+++ b/python/tvm_ffi/_ffi_api.py
@@ -97,6 +97,7 @@ if TYPE_CHECKING:
     def RecursiveEq(_0: Any, _1: Any, /) -> bool: ...
     def RecursiveGe(_0: Any, _1: Any, /) -> bool: ...
     def RecursiveGt(_0: Any, _1: Any, /) -> bool: ...
+    def RecursiveHash(_0: Any, /) -> int: ...
     def RecursiveLe(_0: Any, _1: Any, /) -> bool: ...
     def RecursiveLt(_0: Any, _1: Any, /) -> bool: ...
     def ReprPrint(_0: Any, /) -> str: ...
@@ -177,6 +178,7 @@ __all__ = [
     "RecursiveEq",
     "RecursiveGe",
     "RecursiveGt",
+    "RecursiveHash",
     "RecursiveLe",
     "RecursiveLt",
     "ReprPrint",
diff --git a/python/tvm_ffi/testing/__init__.py 
b/python/tvm_ffi/testing/__init__.py
index f828c019..cff1ce90 100644
--- a/python/tvm_ffi/testing/__init__.py
+++ b/python/tvm_ffi/testing/__init__.py
@@ -20,7 +20,9 @@ from ._ffi_api import *  # noqa: F403
 from .testing import (
     TestCompare,
     TestCustomCompare,
+    TestCustomHash,
     TestEqWithoutHash,
+    TestHash,
     TestIntPair,
     TestNonCopyable,
     TestObjectBase,
diff --git a/python/tvm_ffi/testing/testing.py 
b/python/tvm_ffi/testing/testing.py
index 13f04c79..057301bf 100644
--- a/python/tvm_ffi/testing/testing.py
+++ b/python/tvm_ffi/testing/testing.py
@@ -147,6 +147,43 @@ class TestEqWithoutHash(Object):
     # tvm-ffi-stubgen(end)
 
 
+@register_object("testing.TestHash")
+class TestHash(Object):
+    """Test object with Hash(false) on hash_ignored."""
+
+    __test__ = False
+
+    # tvm-ffi-stubgen(begin): object/testing.TestHash
+    # fmt: off
+    key: int
+    name: str
+    hash_ignored: int
+    if TYPE_CHECKING:
+        def __ffi_shallow_copy__(self, /) -> Object: ...
+        @staticmethod
+        def __c_ffi_init__(_0: int, _1: str, _2: int, /) -> Object: ...
+    # fmt: on
+    # tvm-ffi-stubgen(end)
+
+
+@register_object("testing.TestCustomHash")
+class TestCustomHash(Object):
+    """Test object with custom __ffi_hash__ hook (hashes only key)."""
+
+    __test__ = False
+
+    # tvm-ffi-stubgen(begin): object/testing.TestCustomHash
+    # fmt: off
+    key: int
+    label: str
+    if TYPE_CHECKING:
+        def __ffi_shallow_copy__(self, /) -> Object: ...
+        @staticmethod
+        def __c_ffi_init__(_0: int, _1: str, /) -> Object: ...
+    # fmt: on
+    # tvm-ffi-stubgen(end)
+
+
 @register_object("testing.SchemaAllTypes")
 class _SchemaAllTypes:
     # tvm-ffi-stubgen(ty-map): testing.SchemaAllTypes -> 
testing._SchemaAllTypes
diff --git a/tests/python/test_dataclass_hash.py 
b/tests/python/test_dataclass_hash.py
new file mode 100644
index 00000000..f64558cc
--- /dev/null
+++ b/tests/python/test_dataclass_hash.py
@@ -0,0 +1,971 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Tests for ffi.RecursiveHash."""
+
+from __future__ import annotations
+
+import math
+import struct
+import time
+from collections.abc import Callable
+
+import numpy as np
+import pytest
+import tvm_ffi
+import tvm_ffi.testing
+from tvm_ffi._ffi_api import RecursiveEq, RecursiveHash
+from tvm_ffi.testing import (
+    TestCompare,
+    TestCustomCompare,
+    TestCustomHash,
+    TestEqWithoutHash,
+    TestHash,
+    TestIntPair,
+    _TestCxxClassDerived,
+    _TestCxxClassDerivedDerived,
+    create_object,
+)
+
+
+def _make_nan_from_payload(payload: int) -> float:
+    """Create a quiet NaN with a deterministic payload."""
+    bits = 0x7FF8000000000000 | (payload & 0x0007FFFFFFFFFFFF)
+    return struct.unpack(">d", struct.pack(">Q", bits))[0]
+
+
+# ---------------------------------------------------------------------------
+# Primitives: int
+# ---------------------------------------------------------------------------
+
+
+def test_int_hash_equal_values() -> None:
+    assert RecursiveHash(42) == RecursiveHash(42)
+
+
+def test_int_hash_different_values() -> None:
+    assert RecursiveHash(1) != RecursiveHash(2)
+
+
+def test_int64_extremes_hash() -> None:
+    i64_min = -(2**63)
+    i64_max = 2**63 - 1
+    assert RecursiveHash(i64_min) == RecursiveHash(i64_min)
+    assert RecursiveHash(i64_max) == RecursiveHash(i64_max)
+    assert RecursiveHash(i64_min) != RecursiveHash(i64_max)
+
+
+# ---------------------------------------------------------------------------
+# Primitives: float
+# ---------------------------------------------------------------------------
+
+
+def test_float_hash_equal_values() -> None:
+    assert RecursiveHash(3.14) == RecursiveHash(3.14)
+
+
+def test_float_hash_different_values() -> None:
+    assert RecursiveHash(1.0) != RecursiveHash(2.0)
+
+
+def test_float_signed_zero_hash() -> None:
+    """Both +0.0 and -0.0 hash the same (consistent with RecursiveEq)."""
+    assert RecursiveHash(-0.0) == RecursiveHash(0.0)
+
+
+def test_float_infinity_hash() -> None:
+    assert RecursiveHash(math.inf) == RecursiveHash(math.inf)
+    assert RecursiveHash(-math.inf) == RecursiveHash(-math.inf)
+    assert RecursiveHash(math.inf) != RecursiveHash(-math.inf)
+
+
+# ---------------------------------------------------------------------------
+# NaN handling
+# ---------------------------------------------------------------------------
+
+
+def test_nan_hash() -> None:
+    """All NaN values hash the same (consistent with RecursiveEq)."""
+    assert RecursiveHash(math.nan) == RecursiveHash(math.nan)
+
+
+def test_nan_payloads_hash_equal() -> None:
+    nan1 = _make_nan_from_payload(0x1)
+    nan2 = _make_nan_from_payload(0x2)
+    assert math.isnan(nan1) and math.isnan(nan2)
+    assert RecursiveHash(nan1) == RecursiveHash(nan2)
+
+
+def test_nan_payloads_in_nested_array_hash() -> None:
+    nan1 = _make_nan_from_payload(0xA5)
+    nan2 = _make_nan_from_payload(0x5A)
+    a = tvm_ffi.Array([1.0, nan1, 2.0])
+    b = tvm_ffi.Array([1.0, nan2, 2.0])
+    assert RecursiveHash(a) == RecursiveHash(b)
+
+
+# ---------------------------------------------------------------------------
+# Primitives: bool
+# ---------------------------------------------------------------------------
+
+
+def test_bool_hash_equal() -> None:
+    assert RecursiveHash(True) == RecursiveHash(True)
+    assert RecursiveHash(False) == RecursiveHash(False)
+
+
+def test_bool_hash_different() -> None:
+    assert RecursiveHash(True) != RecursiveHash(False)
+
+
+# ---------------------------------------------------------------------------
+# Primitives: string
+# ---------------------------------------------------------------------------
+
+
+def test_string_hash_equal() -> None:
+    assert RecursiveHash("hello") == RecursiveHash("hello")
+
+
+def test_string_hash_different() -> None:
+    assert RecursiveHash("hello") != RecursiveHash("world")
+
+
+def test_string_small_boundary_hash() -> None:
+    small = "1234567"  # SmallStr
+    large = "12345678"  # heap-backed Str
+    assert RecursiveHash(small) == RecursiveHash("1234567")
+    assert RecursiveHash(small) != RecursiveHash(large)
+
+
+def test_string_embedded_nul_hash() -> None:
+    assert RecursiveHash("a\x00b") == RecursiveHash("a\x00b")
+    assert RecursiveHash("a\x00b") != RecursiveHash("a\x00c")
+
+
+# ---------------------------------------------------------------------------
+# Primitives: bytes
+# ---------------------------------------------------------------------------
+
+
+def test_bytes_hash_equal() -> None:
+    assert RecursiveHash(b"hello") == RecursiveHash(b"hello")
+
+
+def test_bytes_hash_different() -> None:
+    assert RecursiveHash(b"hello") != RecursiveHash(b"world")
+
+
+def test_bytes_small_boundary_hash() -> None:
+    small = b"1234567"  # SmallBytes
+    large = b"12345678"  # heap-backed Bytes
+    assert RecursiveHash(small) == RecursiveHash(b"1234567")
+    assert RecursiveHash(small) != RecursiveHash(large)
+
+
+# ---------------------------------------------------------------------------
+# None
+# ---------------------------------------------------------------------------
+
+
+def test_none_hash() -> None:
+    assert RecursiveHash(None) == RecursiveHash(None)
+
+
+def test_none_vs_other_hash() -> None:
+    assert RecursiveHash(None) != RecursiveHash(0)
+
+
+# ---------------------------------------------------------------------------
+# DataType
+# ---------------------------------------------------------------------------
+
+
+def test_dtype_hash_equal() -> None:
+    assert RecursiveHash(tvm_ffi.dtype("float32")) == 
RecursiveHash(tvm_ffi.dtype("float32"))
+
+
+def test_dtype_hash_different() -> None:
+    assert RecursiveHash(tvm_ffi.dtype("float32")) != 
RecursiveHash(tvm_ffi.dtype("float16"))
+
+
+# ---------------------------------------------------------------------------
+# Device
+# ---------------------------------------------------------------------------
+
+
+def test_device_hash_equal() -> None:
+    assert RecursiveHash(tvm_ffi.Device("cpu", 0)) == 
RecursiveHash(tvm_ffi.Device("cpu", 0))
+
+
+def test_device_hash_different() -> None:
+    assert RecursiveHash(tvm_ffi.Device("cpu", 0)) != 
RecursiveHash(tvm_ffi.Device("cpu", 1))
+
+
+# ---------------------------------------------------------------------------
+# Containers: Array
+# ---------------------------------------------------------------------------
+
+
+def test_array_hash_equal() -> None:
+    a = tvm_ffi.Array([1, 2, 3])
+    b = tvm_ffi.Array([1, 2, 3])
+    assert RecursiveHash(a) == RecursiveHash(b)
+
+
+def test_array_hash_different() -> None:
+    a = tvm_ffi.Array([1, 2, 3])
+    c = tvm_ffi.Array([1, 2, 4])
+    assert RecursiveHash(a) != RecursiveHash(c)
+
+
+def test_array_empty_hash() -> None:
+    empty1 = tvm_ffi.Array([])
+    empty2 = tvm_ffi.Array([])
+    assert RecursiveHash(empty1) == RecursiveHash(empty2)
+
+
+def test_array_different_length_hash() -> None:
+    a = tvm_ffi.Array([1, 2])
+    b = tvm_ffi.Array([1, 2, 3])
+    assert RecursiveHash(a) != RecursiveHash(b)
+
+
+# ---------------------------------------------------------------------------
+# Containers: List
+# ---------------------------------------------------------------------------
+
+
+def test_list_hash_equal() -> None:
+    a = tvm_ffi.List([1, 2, 3])
+    b = tvm_ffi.List([1, 2, 3])
+    assert RecursiveHash(a) == RecursiveHash(b)
+
+
+def test_list_hash_different() -> None:
+    a = tvm_ffi.List([1, 2, 3])
+    c = tvm_ffi.List([1, 2, 4])
+    assert RecursiveHash(a) != RecursiveHash(c)
+
+
+# ---------------------------------------------------------------------------
+# Shape
+# ---------------------------------------------------------------------------
+
+
+def test_shape_hash_equal() -> None:
+    a = tvm_ffi.Shape((2, 3, 4))
+    b = tvm_ffi.Shape((2, 3, 4))
+    assert RecursiveHash(a) == RecursiveHash(b)
+
+
+def test_shape_hash_different() -> None:
+    a = tvm_ffi.Shape((2, 3, 4))
+    c = tvm_ffi.Shape((2, 3, 5))
+    assert RecursiveHash(a) != RecursiveHash(c)
+
+
+# ---------------------------------------------------------------------------
+# Map/Dict
+# ---------------------------------------------------------------------------
+
+
+def test_map_hash_equal() -> None:
+    a = tvm_ffi.Map({"x": 1, "y": 2})
+    b = tvm_ffi.Map({"x": 1, "y": 2})
+    assert RecursiveHash(a) == RecursiveHash(b)
+
+
+def test_map_hash_different_values() -> None:
+    a = tvm_ffi.Map({"x": 1, "y": 2})
+    c = tvm_ffi.Map({"x": 1, "y": 3})
+    assert RecursiveHash(a) != RecursiveHash(c)
+
+
+def test_map_hash_different_size() -> None:
+    a = tvm_ffi.Map({"x": 1})
+    b = tvm_ffi.Map({"x": 1, "y": 2})
+    assert RecursiveHash(a) != RecursiveHash(b)
+
+
+def test_map_hash_order_independent() -> None:
+    """Map hash should be the same regardless of insertion order."""
+    a = tvm_ffi.Map({"x": 1, "y": 2, "z": 3})
+    b = tvm_ffi.Map({"z": 3, "x": 1, "y": 2})
+    assert RecursiveHash(a) == RecursiveHash(b)
+
+
+def test_dict_hash_equal() -> None:
+    a = tvm_ffi.Dict({"x": 1, "y": 2})
+    b = tvm_ffi.Dict({"x": 1, "y": 2})
+    assert RecursiveHash(a) == RecursiveHash(b)
+
+
+def test_dict_hash_different() -> None:
+    a = tvm_ffi.Dict({"x": 1})
+    b = tvm_ffi.Dict({"x": 2})
+    assert RecursiveHash(a) != RecursiveHash(b)
+
+
+# ---------------------------------------------------------------------------
+# Reflected objects: TestIntPair
+# ---------------------------------------------------------------------------
+
+
+def test_reflected_obj_hash_equal() -> None:
+    a = TestIntPair(1, 2)  # ty: ignore[too-many-positional-arguments]
+    b = TestIntPair(1, 2)  # ty: ignore[too-many-positional-arguments]
+    assert RecursiveHash(a) == RecursiveHash(b)
+
+
+def test_reflected_obj_hash_different() -> None:
+    a = TestIntPair(1, 2)  # ty: ignore[too-many-positional-arguments]
+    c = TestIntPair(1, 3)  # ty: ignore[too-many-positional-arguments]
+    assert RecursiveHash(a) != RecursiveHash(c)
+
+
+# ---------------------------------------------------------------------------
+# HashOff flag: TestHash
+# ---------------------------------------------------------------------------
+
+
+def test_hash_off_ignored_field() -> None:
+    """hash_ignored is excluded from hashing via Hash(false)."""
+    a = TestHash(1, "x", 100)  # ty: ignore[too-many-positional-arguments]
+    b = TestHash(1, "x", 999)  # ty: ignore[too-many-positional-arguments]
+    assert RecursiveHash(a) == RecursiveHash(b)
+
+
+def test_hash_off_key_differs() -> None:
+    a = TestHash(1, "x", 100)  # ty: ignore[too-many-positional-arguments]
+    b = TestHash(2, "x", 100)  # ty: ignore[too-many-positional-arguments]
+    assert RecursiveHash(a) != RecursiveHash(b)
+
+
+def test_hash_off_name_differs() -> None:
+    a = TestHash(1, "a", 100)  # ty: ignore[too-many-positional-arguments]
+    b = TestHash(1, "b", 100)  # ty: ignore[too-many-positional-arguments]
+    assert RecursiveHash(a) != RecursiveHash(b)
+
+
+# ---------------------------------------------------------------------------
+# CompareOff implies hash-off: TestCompare
+# ---------------------------------------------------------------------------
+
+
+def test_compare_off_implies_hash_off() -> None:
+    """Fields with Compare(false) are also excluded from hashing."""
+    a = TestCompare(1, "x", 100)  # ty: ignore[too-many-positional-arguments]
+    b = TestCompare(1, "x", 999)  # ty: ignore[too-many-positional-arguments]
+    assert RecursiveHash(a) == RecursiveHash(b)
+
+
+# ---------------------------------------------------------------------------
+# Same pointer fast path
+# ---------------------------------------------------------------------------
+
+
+def test_same_pointer_hash() -> None:
+    x = TestIntPair(42, 99)  # ty: ignore[too-many-positional-arguments]
+    assert RecursiveHash(x) == RecursiveHash(x)
+
+
+# ---------------------------------------------------------------------------
+# Different types produce different hashes
+# ---------------------------------------------------------------------------
+
+
+def test_different_types_hash() -> None:
+    """Different type indices should generally produce different hashes."""
+    assert RecursiveHash(1) != RecursiveHash(1.0)
+    assert RecursiveHash(1) != RecursiveHash(True)
+
+
+# ---------------------------------------------------------------------------
+# Nested containers
+# ---------------------------------------------------------------------------
+
+
+def test_array_of_arrays_hash() -> None:
+    a = tvm_ffi.Array([tvm_ffi.Array([1, 2]), tvm_ffi.Array([3, 4])])
+    b = tvm_ffi.Array([tvm_ffi.Array([1, 2]), tvm_ffi.Array([3, 4])])
+    c = tvm_ffi.Array([tvm_ffi.Array([1, 2]), tvm_ffi.Array([3, 5])])
+    assert RecursiveHash(a) == RecursiveHash(b)
+    assert RecursiveHash(a) != RecursiveHash(c)
+
+
+def test_list_of_lists_hash() -> None:
+    a = tvm_ffi.List([tvm_ffi.List([1, 2]), tvm_ffi.List([3])])
+    b = tvm_ffi.List([tvm_ffi.List([1, 2]), tvm_ffi.List([3])])
+    c = tvm_ffi.List([tvm_ffi.List([1, 2]), tvm_ffi.List([4])])
+    assert RecursiveHash(a) == RecursiveHash(b)
+    assert RecursiveHash(a) != RecursiveHash(c)
+
+
+def test_three_level_nested_hash() -> None:
+    a = tvm_ffi.Array([tvm_ffi.Array([tvm_ffi.Array([1])])])
+    b = tvm_ffi.Array([tvm_ffi.Array([tvm_ffi.Array([1])])])
+    c = tvm_ffi.Array([tvm_ffi.Array([tvm_ffi.Array([2])])])
+    assert RecursiveHash(a) == RecursiveHash(b)
+    assert RecursiveHash(a) != RecursiveHash(c)
+
+
+def test_map_with_array_values_hash() -> None:
+    a = tvm_ffi.Map({"k": tvm_ffi.Array([1, 2])})
+    b = tvm_ffi.Map({"k": tvm_ffi.Array([1, 2])})
+    c = tvm_ffi.Map({"k": tvm_ffi.Array([1, 3])})
+    assert RecursiveHash(a) == RecursiveHash(b)
+    assert RecursiveHash(a) != RecursiveHash(c)
+
+
+# ---------------------------------------------------------------------------
+# Inherited field hashing
+# ---------------------------------------------------------------------------
+
+
+def test_inherited_fields_hash_equal() -> None:
+    a = _TestCxxClassDerived(10, 20, 1.5, 2.5)  # ty: 
ignore[too-many-positional-arguments]
+    b = _TestCxxClassDerived(10, 20, 1.5, 2.5)  # ty: 
ignore[too-many-positional-arguments]
+    assert RecursiveHash(a) == RecursiveHash(b)
+
+
+def test_inherited_fields_differ_in_base_hash() -> None:
+    a = _TestCxxClassDerived(10, 20, 1.5, 2.5)  # ty: 
ignore[too-many-positional-arguments]
+    b = _TestCxxClassDerived(99, 20, 1.5, 2.5)  # ty: 
ignore[too-many-positional-arguments]
+    assert RecursiveHash(a) != RecursiveHash(b)
+
+
+def test_three_level_inheritance_hash() -> None:
+    # Positional order: required (v_i64, v_i32, v_f64, v_bool), then optional 
(v_f32, v_str)
+    a = _TestCxxClassDerivedDerived(1, 2, 3.0, True, 4.0, "hi")  # ty: 
ignore[too-many-positional-arguments]
+    b = _TestCxxClassDerivedDerived(1, 2, 3.0, True, 4.0, "hi")  # ty: 
ignore[too-many-positional-arguments]
+    assert RecursiveHash(a) == RecursiveHash(b)
+    c = _TestCxxClassDerivedDerived(1, 2, 3.0, False, 4.0, "hi")  # ty: 
ignore[too-many-positional-arguments]
+    assert RecursiveHash(a) != RecursiveHash(c)
+
+
+# ---------------------------------------------------------------------------
+# Objects with container fields
+# ---------------------------------------------------------------------------
+
+
+def test_object_with_array_field_hash() -> None:
+    a = create_object(
+        "testing.TestObjectDerived",
+        v_i64=1,
+        v_f64=2.0,
+        v_str="s",
+        v_map=tvm_ffi.Map({}),
+        v_array=tvm_ffi.Array([10, 20, 30]),
+    )
+    b = create_object(
+        "testing.TestObjectDerived",
+        v_i64=1,
+        v_f64=2.0,
+        v_str="s",
+        v_map=tvm_ffi.Map({}),
+        v_array=tvm_ffi.Array([10, 20, 30]),
+    )
+    assert RecursiveHash(a) == RecursiveHash(b)
+
+
+def test_object_with_array_field_differ_hash() -> None:
+    a = create_object(
+        "testing.TestObjectDerived",
+        v_i64=1,
+        v_f64=2.0,
+        v_str="s",
+        v_map=tvm_ffi.Map({}),
+        v_array=tvm_ffi.Array([10, 20]),
+    )
+    b = create_object(
+        "testing.TestObjectDerived",
+        v_i64=1,
+        v_f64=2.0,
+        v_str="s",
+        v_map=tvm_ffi.Map({}),
+        v_array=tvm_ffi.Array([10, 21]),
+    )
+    assert RecursiveHash(a) != RecursiveHash(b)
+
+
+# ---------------------------------------------------------------------------
+# Array/List type mismatch: same content, different types
+# ---------------------------------------------------------------------------
+
+
+def test_array_vs_list_different_hash() -> None:
+    arr = tvm_ffi.Array([1, 2])
+    lst = tvm_ffi.List([1, 2])
+    assert RecursiveHash(arr) != RecursiveHash(lst)
+
+
+def test_map_vs_dict_different_hash() -> None:
+    m = tvm_ffi.Map({"k": 1})
+    d = tvm_ffi.Dict({"k": 1})
+    assert RecursiveHash(m) != RecursiveHash(d)
+
+
+# ---------------------------------------------------------------------------
+# None in nested contexts
+# ---------------------------------------------------------------------------
+
+
+def test_array_with_none_elements_hash() -> None:
+    a = tvm_ffi.Array([None, 1, None])
+    b = tvm_ffi.Array([None, 1, None])
+    c = tvm_ffi.Array([None, 2, None])
+    assert RecursiveHash(a) == RecursiveHash(b)
+    assert RecursiveHash(a) != RecursiveHash(c)
+
+
+# ---------------------------------------------------------------------------
+# Cycle safety: run in subprocess
+# ---------------------------------------------------------------------------
+
+
+def test_cyclic_list_same_pointer_hash() -> None:
+    """Same cyclic list hashed with itself succeeds (pointer identity 
short-circuit)."""
+    lst = tvm_ffi.List()
+    lst.append(lst)
+    # Should not raise; same pointer returns a deterministic hash
+    h = RecursiveHash(lst)
+    assert h == RecursiveHash(lst)
+
+
+def test_cyclic_list_handled() -> None:
+    """Distinct cyclic lists are handled gracefully via on-stack cycle 
detection."""
+    a = tvm_ffi.List()
+    a.append(a)
+    b = tvm_ffi.List()
+    b.append(b)
+    h_a = RecursiveHash(a)
+    h_b = RecursiveHash(b)
+    assert h_a == h_b
+
+
+def test_cyclic_dict_handled() -> None:
+    """Cyclic dict is handled gracefully via on-stack cycle detection."""
+    d = tvm_ffi.Dict()
+    d["self"] = d
+    h = RecursiveHash(d)
+    assert h == RecursiveHash(d)
+
+
+# ---------------------------------------------------------------------------
+# Consistency law: RecursiveEq(a, b) => RecursiveHash(a) == RecursiveHash(b)
+# ---------------------------------------------------------------------------
+
+
+def test_consistency_primitives() -> None:
+    """Verify hash consistency for various primitive pairs."""
+    pairs = [
+        (42, 42),
+        (3.14, 3.14),
+        (True, True),
+        (False, False),
+        ("hello", "hello"),
+        (b"hello", b"hello"),
+        (None, None),
+    ]
+    for a, b in pairs:
+        assert RecursiveEq(a, b), f"Expected RecursiveEq({a!r}, {b!r})"
+        assert RecursiveHash(a) == RecursiveHash(b), (
+            f"Hash mismatch for equal values: {a!r} and {b!r}"
+        )
+
+
+def test_consistency_nan() -> None:
+    nan1 = _make_nan_from_payload(0x1)
+    nan2 = _make_nan_from_payload(0x2)
+    assert RecursiveEq(nan1, nan2)
+    assert RecursiveHash(nan1) == RecursiveHash(nan2)
+
+
+def test_consistency_signed_zero() -> None:
+    assert RecursiveEq(-0.0, 0.0)
+    assert RecursiveHash(-0.0) == RecursiveHash(0.0)
+
+
+def test_consistency_containers() -> None:
+    """Verify hash consistency for containers."""
+    pairs = [
+        (tvm_ffi.Array([1, 2, 3]), tvm_ffi.Array([1, 2, 3])),
+        (tvm_ffi.List([1, 2, 3]), tvm_ffi.List([1, 2, 3])),
+        (tvm_ffi.Shape((2, 3, 4)), tvm_ffi.Shape((2, 3, 4))),
+        (tvm_ffi.Map({"x": 1, "y": 2}), tvm_ffi.Map({"x": 1, "y": 2})),
+        (tvm_ffi.Dict({"x": 1, "y": 2}), tvm_ffi.Dict({"x": 1, "y": 2})),
+    ]
+    for a, b in pairs:
+        assert RecursiveEq(a, b), f"Expected RecursiveEq for {a!r} and {b!r}"
+        assert RecursiveHash(a) == RecursiveHash(b), "Hash mismatch for equal 
containers"
+
+
+def test_consistency_reflected_objects() -> None:
+    """Verify hash consistency for reflected objects."""
+    a = TestIntPair(1, 2)  # ty: ignore[too-many-positional-arguments]
+    b = TestIntPair(1, 2)  # ty: ignore[too-many-positional-arguments]
+    assert RecursiveEq(a, b)
+    assert RecursiveHash(a) == RecursiveHash(b)
+
+
+def test_consistency_compare_off() -> None:
+    """Fields excluded from comparison are also excluded from hash."""
+    a = TestCompare(1, "x", 100)  # ty: ignore[too-many-positional-arguments]
+    b = TestCompare(1, "x", 999)  # ty: ignore[too-many-positional-arguments]
+    assert RecursiveEq(a, b)
+    assert RecursiveHash(a) == RecursiveHash(b)
+
+
+def test_consistency_hash_off() -> None:
+    """Fields excluded from hashing produce same hash when they differ."""
+    a = TestHash(1, "x", 100)  # ty: ignore[too-many-positional-arguments]
+    b = TestHash(1, "x", 999)  # ty: ignore[too-many-positional-arguments]
+    assert RecursiveHash(a) == RecursiveHash(b)
+
+
+def test_consistency_law_on_int_pairs() -> None:
+    """Verify: RecursiveEq(a, b) => RecursiveHash(a) == RecursiveHash(b)."""
+    values = [
+        TestIntPair(0, 0),  # ty: ignore[too-many-positional-arguments]
+        TestIntPair(0, 1),  # ty: ignore[too-many-positional-arguments]
+        TestIntPair(1, 0),  # ty: ignore[too-many-positional-arguments]
+        TestIntPair(1, 1),  # ty: ignore[too-many-positional-arguments]
+    ]
+    for a in values:
+        for b in values:
+            if RecursiveEq(a, b):
+                assert RecursiveHash(a) == RecursiveHash(b), (
+                    f"Hash consistency violated: RecursiveEq({a},{b}) but 
hashes differ"
+                )
+
+
+def _make_nested_singleton_array(depth: int) -> object:
+    value: object = 0
+    for _ in range(depth):
+        value = tvm_ffi.Array([value])
+    return value
+
+
+# ---------------------------------------------------------------------------
+# Shared-reference aliasing invariants
+# ---------------------------------------------------------------------------
+
+
+def test_aliasing_consistency_array_of_reflected_objects() -> None:
+    shared = TestIntPair(11, 22)  # ty: ignore[too-many-positional-arguments]
+    aliased = tvm_ffi.Array([shared, shared])
+    duplicated = tvm_ffi.Array(
+        [
+            TestIntPair(11, 22),  # ty: ignore[too-many-positional-arguments]
+            TestIntPair(11, 22),  # ty: ignore[too-many-positional-arguments]
+        ]
+    )
+    assert RecursiveEq(aliased, duplicated)
+    assert RecursiveHash(aliased) == RecursiveHash(duplicated)
+
+
+def test_aliasing_consistency_list_of_reflected_objects() -> None:
+    shared = TestIntPair(13, 26)  # ty: ignore[too-many-positional-arguments]
+    aliased = tvm_ffi.List([shared, shared])
+    duplicated = tvm_ffi.List(
+        [
+            TestIntPair(13, 26),  # ty: ignore[too-many-positional-arguments]
+            TestIntPair(13, 26),  # ty: ignore[too-many-positional-arguments]
+        ]
+    )
+    assert RecursiveEq(aliased, duplicated)
+    assert RecursiveHash(aliased) == RecursiveHash(duplicated)
+
+
+def test_aliasing_consistency_array_of_arrays() -> None:
+    shared = tvm_ffi.Array([1, 2, 3])
+    aliased = tvm_ffi.Array([shared, shared])
+    duplicated = tvm_ffi.Array([tvm_ffi.Array([1, 2, 3]), tvm_ffi.Array([1, 2, 
3])])
+    assert RecursiveEq(aliased, duplicated)
+    assert RecursiveHash(aliased) == RecursiveHash(duplicated)
+
+
+def test_aliasing_consistency_list_of_lists() -> None:
+    shared = tvm_ffi.List([1, 2, 3])
+    aliased = tvm_ffi.List([shared, shared])
+    duplicated = tvm_ffi.List([tvm_ffi.List([1, 2, 3]), tvm_ffi.List([1, 2, 
3])])
+    assert RecursiveEq(aliased, duplicated)
+    assert RecursiveHash(aliased) == RecursiveHash(duplicated)
+
+
+def test_aliasing_consistency_shape_objects() -> None:
+    shared = tvm_ffi.Shape((3, 4))
+    aliased = tvm_ffi.Array([shared, shared])
+    duplicated = tvm_ffi.Array([tvm_ffi.Shape((3, 4)), tvm_ffi.Shape((3, 4))])
+    assert RecursiveEq(aliased, duplicated)
+    assert RecursiveHash(aliased) == RecursiveHash(duplicated)
+
+
+def test_aliasing_consistency_map_shared_values() -> None:
+    shared = TestIntPair(31, 41)  # ty: ignore[too-many-positional-arguments]
+    aliased = tvm_ffi.Map({"x": shared, "y": shared})
+    duplicated = tvm_ffi.Map(
+        {
+            "x": TestIntPair(31, 41),  # ty: 
ignore[too-many-positional-arguments]
+            "y": TestIntPair(31, 41),  # ty: 
ignore[too-many-positional-arguments]
+        }
+    )
+    assert RecursiveEq(aliased, duplicated)
+    assert RecursiveHash(aliased) == RecursiveHash(duplicated)
+
+
+def test_aliasing_consistency_dict_shared_values() -> None:
+    shared = tvm_ffi.Array([7, 8, 9])
+    aliased = tvm_ffi.Dict({"x": shared, "y": shared})
+    duplicated = tvm_ffi.Dict({"x": tvm_ffi.Array([7, 8, 9]), "y": 
tvm_ffi.Array([7, 8, 9])})
+    assert RecursiveEq(aliased, duplicated)
+    assert RecursiveHash(aliased) == RecursiveHash(duplicated)
+
+
+def test_aliasing_consistency_reflected_object_fields() -> None:
+    shared = TestIntPair(5, 6)  # ty: ignore[too-many-positional-arguments]
+    aliased = create_object(
+        "testing.TestObjectDerived",
+        v_i64=1,
+        v_f64=2.0,
+        v_str="shared",
+        v_map=tvm_ffi.Map({"k": shared}),
+        v_array=tvm_ffi.Array([shared]),
+    )
+    duplicated = create_object(
+        "testing.TestObjectDerived",
+        v_i64=1,
+        v_f64=2.0,
+        v_str="shared",
+        v_map=tvm_ffi.Map({"k": TestIntPair(5, 6)}),  # ty: 
ignore[too-many-positional-arguments]
+        v_array=tvm_ffi.Array([TestIntPair(5, 6)]),  # ty: 
ignore[too-many-positional-arguments]
+    )
+    assert RecursiveEq(aliased, duplicated)
+    assert RecursiveHash(aliased) == RecursiveHash(duplicated)
+
+
+# ---------------------------------------------------------------------------
+# Map/Dict order-independence with shared references
+# ---------------------------------------------------------------------------
+
+
+def test_map_hash_order_independent_with_shared_values() -> None:
+    shared = TestIntPair(1, 2)  # ty: ignore[too-many-positional-arguments]
+    a = tvm_ffi.Map({"a": shared, "b": shared, "c": shared})
+    b = tvm_ffi.Map({"b": shared, "a": shared, "c": shared})
+    assert RecursiveEq(a, b)
+    assert RecursiveHash(a) == RecursiveHash(b)
+
+
+def test_dict_hash_order_independent_with_shared_values() -> None:
+    shared = tvm_ffi.Array([1, 2])
+    a = tvm_ffi.Dict({"a": shared, "b": shared, "c": shared})
+    b = tvm_ffi.Dict({"b": shared, "a": shared, "c": shared})
+    assert RecursiveEq(a, b)
+    assert RecursiveHash(a) == RecursiveHash(b)
+
+
+# ---------------------------------------------------------------------------
+# Recursion-depth boundary
+# ---------------------------------------------------------------------------
+
+
+def test_depth_127_nested_arrays_allowed() -> None:
+    RecursiveHash(_make_nested_singleton_array(127))
+
+
+def test_depth_1000_nested_arrays_works() -> None:
+    """Deep graphs now succeed thanks to iterative (heap-based) stack."""
+    RecursiveHash(_make_nested_singleton_array(1000))
+
+
+# ---------------------------------------------------------------------------
+# Additional adversarial quality checks
+# ---------------------------------------------------------------------------
+
+
+def test_map_hash_collision_swap_values() -> None:
+    """Swapping values across two keys should not trivially collide."""
+    a = tvm_ffi.Map({"a": 0, "b": 1})
+    b = tvm_ffi.Map({"a": 1, "b": 0})
+    assert not RecursiveEq(a, b)
+    assert RecursiveHash(a) != RecursiveHash(b)
+
+
+def test_array_hash_collision_small_int_pairs() -> None:
+    """Distinct short arrays should not have obvious low-domain collisions."""
+    a = tvm_ffi.Array([1, 2])
+    b = tvm_ffi.Array([2, 61])
+    assert not RecursiveEq(a, b)
+    assert RecursiveHash(a) != RecursiveHash(b)
+
+
+def test_function_hash_consistent_with_eq() -> None:
+    """Functions have no reflected fields, so RecursiveEq treats all as equal.
+
+    Hash must be consistent: RecursiveEq(a, b) => RecursiveHash(a) == 
RecursiveHash(b).
+    """
+    f_add_one = tvm_ffi.get_global_func("testing.add_one")
+    f_nop = tvm_ffi.get_global_func("testing.nop")
+    assert RecursiveEq(f_add_one, f_nop)
+    assert RecursiveHash(f_add_one) == RecursiveHash(f_nop)
+
+
+def test_tensor_hash_consistent_with_eq() -> None:
+    """Tensors have no reflected fields, so RecursiveEq treats all as equal.
+
+    Hash must be consistent: RecursiveEq(a, b) => RecursiveHash(a) == 
RecursiveHash(b).
+    """
+    t1 = tvm_ffi.from_dlpack(np.array([1, 2], dtype="int32"))
+    t2 = tvm_ffi.from_dlpack(np.array([[9, 8, 7]], dtype="int64"))
+    assert RecursiveEq(t1, t2)
+    assert RecursiveHash(t1) == RecursiveHash(t2)
+
+
+def _make_shared_binary_dag(depth: int) -> object:
+    node: object = 1
+    for _ in range(depth):
+        # Two edges point to the same child object (DAG with heavy sharing).
+        node = tvm_ffi.Array([node, node])
+    return node
+
+
+def test_shared_dag_hash_scaling_not_exponential() -> None:
+    """Hashing shared DAGs should scale roughly linearly in depth."""
+    d18 = _make_shared_binary_dag(18)
+    d19 = _make_shared_binary_dag(19)
+
+    # Warm-up run to mitigate one-time setup costs
+    RecursiveHash(_make_shared_binary_dag(10))
+
+    repeats = 10
+    t0 = time.perf_counter()
+    for _ in range(repeats):
+        RecursiveHash(d18)
+    t18 = (time.perf_counter() - t0) / repeats
+
+    t0 = time.perf_counter()
+    for _ in range(repeats):
+        RecursiveHash(d19)
+    t19 = (time.perf_counter() - t0) / repeats
+
+    # With memoization this ratio should stay close to 1x; 1.6x leaves buffer 
for noise.
+    assert t19 <= t18 * 1.6, f"Unexpected super-linear scaling: d18={t18:.6f}s 
d19={t19:.6f}s"
+
+
+# ---------------------------------------------------------------------------
+# Custom __ffi_hash__ hook: TestCustomHash
+# ---------------------------------------------------------------------------
+
+
+def test_custom_hash_ignores_label() -> None:
+    """TestCustomHash hashes only `key`, ignoring `label`."""
+    a = TestCustomHash(42, "alpha")  # ty: 
ignore[too-many-positional-arguments]
+    b = TestCustomHash(42, "beta")  # ty: ignore[too-many-positional-arguments]
+    assert RecursiveHash(a) == RecursiveHash(b)
+
+
+def test_custom_hash_different_key() -> None:
+    a = TestCustomHash(1, "same")  # ty: ignore[too-many-positional-arguments]
+    b = TestCustomHash(2, "same")  # ty: ignore[too-many-positional-arguments]
+    assert RecursiveHash(a) != RecursiveHash(b)
+
+
+def test_custom_hash_in_container() -> None:
+    """Custom-hooked objects inside an Array."""
+    a = tvm_ffi.Array(
+        [
+            TestCustomHash(1, "x"),  # ty: 
ignore[too-many-positional-arguments]
+            TestCustomHash(2, "y"),  # ty: 
ignore[too-many-positional-arguments]
+        ]
+    )
+    b = tvm_ffi.Array(
+        [
+            TestCustomHash(1, "different"),  # ty: 
ignore[too-many-positional-arguments]
+            TestCustomHash(2, "labels"),  # ty: 
ignore[too-many-positional-arguments]
+        ]
+    )
+    assert RecursiveHash(a) == RecursiveHash(b)
+
+
+def test_custom_hash_consistency_with_eq() -> None:
+    """RecursiveEq(a,b) => RecursiveHash(a)==RecursiveHash(b) for 
TestCustomCompare."""
+    a = TestCustomCompare(42, "alpha")  # ty: 
ignore[too-many-positional-arguments]
+    b = TestCustomCompare(42, "beta")  # ty: 
ignore[too-many-positional-arguments]
+    assert RecursiveEq(a, b)
+    assert RecursiveHash(a) == RecursiveHash(b)
+
+
+# ---------------------------------------------------------------------------
+# Failing regression tests for Eq=>Hash invariant
+# ---------------------------------------------------------------------------
+
+
[email protected]("key", [-7, -1, 0, 1, 7, 1024])
[email protected]("lhs_label,rhs_label", [("alpha", "beta"), ("x", 
"y"), ("foo", "bar")])
+def test_custom_compare_eq_implies_hash_same_direct(
+    key: int, lhs_label: str, rhs_label: str
+) -> None:
+    lhs = TestCustomCompare(key, lhs_label)  # ty: 
ignore[too-many-positional-arguments]
+    rhs = TestCustomCompare(key, rhs_label)  # ty: 
ignore[too-many-positional-arguments]
+    assert RecursiveEq(lhs, rhs)
+    assert RecursiveHash(lhs) == RecursiveHash(rhs)
+
+
[email protected]("key", [-3, -1, 0, 2, 11])
[email protected](
+    "wrap",
+    [
+        lambda obj: tvm_ffi.Array([obj]),
+        lambda obj: tvm_ffi.List([obj]),
+        lambda obj: tvm_ffi.Array([0, obj, 1]),
+        lambda obj: tvm_ffi.List([0, obj, 1]),
+        lambda obj: tvm_ffi.Map({"k": obj}),
+        lambda obj: tvm_ffi.Dict({"k": obj}),
+        lambda obj: tvm_ffi.Array([tvm_ffi.Array([obj])]),
+        lambda obj: tvm_ffi.List([tvm_ffi.List([obj])]),
+    ],
+)
+def test_custom_compare_eq_implies_hash_same_in_wrappers(
+    key: int, wrap: Callable[[object], object]
+) -> None:
+    lhs_obj = TestCustomCompare(key, "left")  # ty: 
ignore[too-many-positional-arguments]
+    rhs_obj = TestCustomCompare(key, "right")  # ty: 
ignore[too-many-positional-arguments]
+    lhs = wrap(lhs_obj)
+    rhs = wrap(rhs_obj)
+    assert RecursiveEq(lhs, rhs)
+    assert RecursiveHash(lhs) == RecursiveHash(rhs)
+
+
+# ---------------------------------------------------------------------------
+# Guard: __ffi_eq__ without __ffi_hash__ must raise in RecursiveHash
+# ---------------------------------------------------------------------------
+
+
+def test_eq_without_hash_raises() -> None:
+    """RecursiveHash rejects types that define __ffi_eq__ but not 
__ffi_hash__."""
+    obj = TestEqWithoutHash(1, "hello")  # ty: 
ignore[too-many-positional-arguments]
+    with pytest.raises(ValueError, match="__ffi_eq__ or __ffi_compare__ but 
not __ffi_hash__"):
+        RecursiveHash(obj)
+
+
+def test_eq_without_hash_inside_container_raises() -> None:
+    """The guard also triggers when the object is nested inside a container."""
+    obj = TestEqWithoutHash(1, "hello")  # ty: 
ignore[too-many-positional-arguments]
+    arr = tvm_ffi.Array([obj])
+    with pytest.raises(ValueError, match="__ffi_eq__ or __ffi_compare__ but 
not __ffi_hash__"):
+        RecursiveHash(arr)


Reply via email to