Skip to content

Commit 6589652

Browse files
kurodo3[bot]claude
andcommitted
feat: add timestamp and duration support in leaf data hashing (ITL-438)
- Add is_timestamp and is_duration checks to _element_size_for_type (8 bytes each) - Add test_timestamp_types_in_schema and test_duration_types_in_schema - Add TestTimestampDurationHashing golden parity tests (15 cases) matching Rust starfix byte-for-byte Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 0e41acb commit 6589652

3 files changed

Lines changed: 130 additions & 0 deletions

File tree

src/starfix/arrow_digester.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -642,6 +642,10 @@ def _element_size_for_type(dt: pa.DataType) -> int | None:
642642
return 4
643643
if pa.types.is_time64(dt):
644644
return 8
645+
if pa.types.is_timestamp(dt):
646+
return 8 # int64 physical storage; unit/tz are schema metadata
647+
if pa.types.is_duration(dt):
648+
return 8 # int64 physical storage; unit is schema metadata
645649
if pa.types.is_decimal(dt):
646650
return dt.bit_width // 8
647651
if pa.types.is_fixed_size_binary(dt):

tests/test_arrow_digester.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,34 @@ def test_struct_fields_sorted_in_schema(self):
6767
# a_field should appear before z_field in the Struct array
6868
assert s.index('"a_field"') < s.index('"z_field"')
6969

70+
def test_timestamp_types_in_schema(self):
71+
schema = pa.schema([
72+
pa.field("ts_utc_s", pa.timestamp("s", tz="UTC"), nullable=False),
73+
pa.field("ts_utc_ms", pa.timestamp("ms", tz="UTC"), nullable=False),
74+
pa.field("ts_utc_us", pa.timestamp("us", tz="UTC"), nullable=False),
75+
pa.field("ts_utc_ns", pa.timestamp("ns", tz="UTC"), nullable=False),
76+
pa.field("ts_naive_us", pa.timestamp("us"), nullable=False),
77+
])
78+
s = _serialized_schema(schema)
79+
assert '{"Timestamp":["Second","UTC"]}' in s
80+
assert '{"Timestamp":["Millisecond","UTC"]}' in s
81+
assert '{"Timestamp":["Microsecond","UTC"]}' in s
82+
assert '{"Timestamp":["Nanosecond","UTC"]}' in s
83+
assert '{"Timestamp":["Microsecond",null]}' in s
84+
85+
def test_duration_types_in_schema(self):
86+
schema = pa.schema([
87+
pa.field("dur_s", pa.duration("s"), nullable=False),
88+
pa.field("dur_ms", pa.duration("ms"), nullable=False),
89+
pa.field("dur_us", pa.duration("us"), nullable=False),
90+
pa.field("dur_ns", pa.duration("ns"), nullable=False),
91+
])
92+
s = _serialized_schema(schema)
93+
assert '{"Duration":"Second"}' in s
94+
assert '{"Duration":"Millisecond"}' in s
95+
assert '{"Duration":"Microsecond"}' in s
96+
assert '{"Duration":"Nanosecond"}' in s
97+
7098

7199
# ── Schema hashing (golden values from Rust) ──────────────────────────
72100

tests/test_golden_parity.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -607,3 +607,101 @@ def test_batch_split_independence(self):
607607
hash_split = digester.finalize()
608608

609609
assert hash_combined == hash_split
610+
611+
612+
# ---------------------------------------------------------------------------
613+
# Timestamp and Duration hashing — golden values match Rust (ITL-438)
614+
# ---------------------------------------------------------------------------
615+
616+
617+
class TestTimestampDurationHashing:
618+
"""Golden-hash parity for timestamp and duration types.
619+
620+
All expected values were generated by the Rust starfix test suite
621+
(timestamp_array_hashing / duration_array_hashing in tests/arrow_digester.rs).
622+
Array contents: [0, None, 1_000] as raw i64 values.
623+
"""
624+
625+
# -- Timestamp (tz-aware UTC) ------------------------------------------
626+
627+
def test_timestamp_second_utc(self):
628+
arr = pa.array([0, None, 1_000], type=pa.timestamp("s", tz="UTC"))
629+
assert ArrowDigester.hash_array(arr).hex() == "0000017cef6166a14741f94906bd3146ffa197d1b5756d594eb46304b678b21e44c40b"
630+
631+
def test_timestamp_millisecond_utc(self):
632+
arr = pa.array([0, None, 1_000], type=pa.timestamp("ms", tz="UTC"))
633+
assert ArrowDigester.hash_array(arr).hex() == "000001fd253a835218d5605ce8bb348699f973b93e29fbe21fe6e532468f988ece4ce5"
634+
635+
def test_timestamp_microsecond_utc(self):
636+
arr = pa.array([0, None, 1_000], type=pa.timestamp("us", tz="UTC"))
637+
assert ArrowDigester.hash_array(arr).hex() == "0000013af741ca0fe5bd48d0bd39e1993c60e7c032f23c4cf4be6c11cc4b50ce56013e"
638+
639+
def test_timestamp_nanosecond_utc(self):
640+
arr = pa.array([0, None, 1_000], type=pa.timestamp("ns", tz="UTC"))
641+
assert ArrowDigester.hash_array(arr).hex() == "000001f5b86904b4b7f2c6a18c7c4febda142bf622e4d01752f42b8f832c0a68a4d9c0"
642+
643+
# -- Timestamp (tz-naive) ----------------------------------------------
644+
645+
def test_timestamp_second_naive(self):
646+
arr = pa.array([0, None, 1_000], type=pa.timestamp("s"))
647+
assert ArrowDigester.hash_array(arr).hex() == "0000016ee5249d352c03cc820af39e98b7043993d467207cb6ca34c8d13cfacf141e30"
648+
649+
def test_timestamp_millisecond_naive(self):
650+
arr = pa.array([0, None, 1_000], type=pa.timestamp("ms"))
651+
assert ArrowDigester.hash_array(arr).hex() == "000001ef46619d2be8259335ed0943f42ea3ced19ff6d75471a64b2f03aea89afa4842"
652+
653+
def test_timestamp_microsecond_naive(self):
654+
arr = pa.array([0, None, 1_000], type=pa.timestamp("us"))
655+
assert ArrowDigester.hash_array(arr).hex() == "0000017e624eb2847f79f940d7123980b076aea1c1ecd3adaa34bdae24fa00f05afda3"
656+
657+
def test_timestamp_nanosecond_naive(self):
658+
arr = pa.array([0, None, 1_000], type=pa.timestamp("ns"))
659+
assert ArrowDigester.hash_array(arr).hex() == "000001f677ef0e42e9e40ea092471f82283212833ab6b4ff9bb45e86e88590f8d15796"
660+
661+
# -- Timestamp: unit and tz produce different hashes -------------------
662+
663+
def test_timestamp_units_differ(self):
664+
values = [1_000, 2_000]
665+
hashes = [
666+
ArrowDigester.hash_array(pa.array(values, type=pa.timestamp("s"))).hex(),
667+
ArrowDigester.hash_array(pa.array(values, type=pa.timestamp("ms"))).hex(),
668+
ArrowDigester.hash_array(pa.array(values, type=pa.timestamp("us"))).hex(),
669+
ArrowDigester.hash_array(pa.array(values, type=pa.timestamp("ns"))).hex(),
670+
]
671+
assert len(set(hashes)) == 4, "all 4 timestamp units must produce distinct hashes"
672+
673+
def test_timestamp_tz_differs(self):
674+
values = [1_000, 2_000]
675+
naive = ArrowDigester.hash_array(pa.array(values, type=pa.timestamp("us"))).hex()
676+
utc = ArrowDigester.hash_array(
677+
pa.array(values, type=pa.timestamp("us", tz="UTC"))
678+
).hex()
679+
assert naive != utc, "tz-naive and tz=UTC must produce different hashes"
680+
681+
# -- Duration ----------------------------------------------------------
682+
683+
def test_duration_second(self):
684+
arr = pa.array([0, None, 1_000], type=pa.duration("s"))
685+
assert ArrowDigester.hash_array(arr).hex() == "0000013540d23a4abf1dfbc939a9e5514be69e364cec466ef900fa35050dc3cf2994fa"
686+
687+
def test_duration_millisecond(self):
688+
arr = pa.array([0, None, 1_000], type=pa.duration("ms"))
689+
assert ArrowDigester.hash_array(arr).hex() == "000001564a87e1e07898af07a4a212e3db83f911ac287134f28c772bf5fb4da683402c"
690+
691+
def test_duration_microsecond(self):
692+
arr = pa.array([0, None, 1_000], type=pa.duration("us"))
693+
assert ArrowDigester.hash_array(arr).hex() == "0000017904faf3043cf870f0c139cba0282cdaf11c590aeb80f2a0d6b103893ba5da2a"
694+
695+
def test_duration_nanosecond(self):
696+
arr = pa.array([0, None, 1_000], type=pa.duration("ns"))
697+
assert ArrowDigester.hash_array(arr).hex() == "00000189a38c5a7adf4b19b3e0d467f042ab2389fed8ae5f1d2af8555dd6131478a49b"
698+
699+
def test_duration_units_differ(self):
700+
values = [1_000, 2_000]
701+
hashes = [
702+
ArrowDigester.hash_array(pa.array(values, type=pa.duration("s"))).hex(),
703+
ArrowDigester.hash_array(pa.array(values, type=pa.duration("ms"))).hex(),
704+
ArrowDigester.hash_array(pa.array(values, type=pa.duration("us"))).hex(),
705+
ArrowDigester.hash_array(pa.array(values, type=pa.duration("ns"))).hex(),
706+
]
707+
assert len(set(hashes)) == 4, "all 4 duration units must produce distinct hashes"

0 commit comments

Comments
 (0)