Skip to main content

hara_native/lang/hash/
mod.rs

1//! Java-parity hashing stack for the hara Rust runtime.
2//!
3//! This module is the Rust analogue of `hara.lang.base.G` plus the
4//! collection hash composition in `hara.lang.data.types.IOrderedType` /
5//! `IUnOrderedType` / `IStringType` and `hara.lang.data.Trie`.
6//!
7//! All hash values are Java `long`/`int` semantics: wrapping two's-complement
8//! arithmetic. Functions return `i64` (Java `long`); value-level hashes are
9//! Java `int` results sign-extended to `i64`, exactly like `G.hashValue`
10//! returning `long`. Collection composition accumulates in full 64 bits.
11//!
12//! DEFAULT_HASH is RAPID (mirrors `G.DEFAULT_HASH`).
13//!
14//! Documented deviations from the Java runtime (all cases where the Java
15//! behaviour is identity-hash based and therefore non-deterministic across
16//! JVM runs):
17//!
18//! - **Keyword**: Java's `IStringType.hashCalc` uses `toString()`, and
19//!   `Keyword` does not override `toString()`, so Java hashes
20//!   `"::KEYWORD|hara.lang.data.Keyword@<identity>"` — non-deterministic.
21//!   This port standardises on the display form:
22//!   `"::KEYWORD|:ns/name"`. (Verified empirically; see
23//!   `target/hashdump/HashDump.java` and the normative corpus
24//!   `hara-specs-registry/01-lang/020-data-structures/draft/conformance/hash-parity.edn`.)
25//! - **Symbol**: Java inherits `ObjPersistent.toString()` which is
26//!   `class-name + "<" + display() + ">"` — deterministic but
27//!   class-qualified. This port mirrors Java exactly:
28//!   `"::SYMBOL|hara.lang.data.Symbol<ns/name>"`.
29//! - **Pointer**: Java's `Pointer.hashCalc` is `System.identityHashCode`.
30//!   This port uses the deterministic string-type hash of
31//!   `"::POINTER|" + as_str()`.
32//! - **SYSTEM hash of collections**: Java's `G.hashFn(SYSTEM)` degrades to
33//!   `Object.hashCode()` (identity) for collection objects. This port uses
34//!   the same structural composition as the other hash types so SYSTEM
35//!   stays deterministic.
36//! - **SIP**: Java never actually routes SipHash through `G` —
37//!   `G.hashSip` degrades to `hashValue` (identity for collections) and
38//!   `IStringType.hashCalc(SIP)` returns `-1`. This port returns `-1` for
39//!   string types (exact mirror) and uses structural composition for
40//!   collections (deterministic fallback).
41//! - **Regex / arrays / host objects**: Java hashes `java.util.regex.Pattern`
42//!   and Java arrays by identity. This port hashes the pattern string and
43//!   uses `java.util.Arrays.hashCode(byte[])` for byte vectors (that one IS
44//!   deterministic in Java and mirrored exactly).
45//! - **f64 formatting**: `G.hashValue(Double)` goes through
46//!   `BigDecimal.valueOf` which is defined by `Double.toString`. This port
47//!   uses Rust's `{}` formatting; both produce shortest round-trip digits
48//!   and canonicalisation (trailing-zero stripping) normalises notation
49//!   differences. One KNOWN divergence: the bottom subnormal family, e.g.
50//!   `Double.MIN_VALUE` — Java emits `"4.9E-324"` (hash 1844) while Rust's
51//!   formatter picks the 1-digit `"5e-324"` form (hash 479). Both strings
52//!   round-trip to the same double; the runtimes' digit-selection rules
53//!   simply disagree there. That case is excluded from the parity fixture.
54
55pub mod murmur3;
56pub mod rapid;
57pub mod siphash;
58
59use crate::lang::protocol::HashType;
60
61/// Mirrors `G.DEFAULT_HASH`.
62pub const DEFAULT_HASH: HashType = HashType::Rapid;
63
64/// Rust analogue of `G.hashFn(t).apply(o)`: the Java `long` hash of a value
65/// under the given hash type. Implemented for `Value` in `core.rs` and for
66/// the plain Java-like primitives here. Collection element types in
67/// `lang::data` hash through this trait so the `HashType` dispatches all the
68/// way down nested structures.
69pub trait JavaHash {
70    fn java_hash(&self, hash_type: HashType) -> i64;
71}
72
73impl JavaHash for bool {
74    fn java_hash(&self, _: HashType) -> i64 {
75        hash_bool(*self) as i64
76    }
77}
78
79impl JavaHash for char {
80    fn java_hash(&self, _: HashType) -> i64 {
81        hash_char(*self) as i64
82    }
83}
84
85impl JavaHash for i64 {
86    fn java_hash(&self, _: HashType) -> i64 {
87        hash_long(*self) as i64
88    }
89}
90
91impl JavaHash for i32 {
92    fn java_hash(&self, _: HashType) -> i64 {
93        hash_long(*self as i64) as i64
94    }
95}
96
97impl JavaHash for usize {
98    fn java_hash(&self, _: HashType) -> i64 {
99        hash_long(*self as i64) as i64
100    }
101}
102
103impl JavaHash for u64 {
104    fn java_hash(&self, _: HashType) -> i64 {
105        hash_long(*self as i64) as i64
106    }
107}
108
109impl JavaHash for f64 {
110    fn java_hash(&self, _: HashType) -> i64 {
111        hash_double(*self) as i64
112    }
113}
114
115/// Plain strings hash identically under every hash type: `G.hashFn(t)`
116/// only dispatches on `IHash` objects, everything else takes `hashValue`,
117/// which for a Java `String` is `String.hashCode`.
118impl JavaHash for String {
119    fn java_hash(&self, _: HashType) -> i64 {
120        java_string_hash(self) as i64
121    }
122}
123
124impl JavaHash for &str {
125    fn java_hash(&self, _: HashType) -> i64 {
126        java_string_hash(self) as i64
127    }
128}
129
130// ---------------------------------------------------------------------------
131// plain values (G.hashValue)
132// ---------------------------------------------------------------------------
133
134/// Java `String.hashCode`: 31-polynomial over UTF-16 code units, wrapping i32.
135pub fn java_string_hash(s: &str) -> i32 {
136    let mut h = 0i32;
137    for unit in s.encode_utf16() {
138        h = h.wrapping_mul(31).wrapping_add(unit as i32);
139    }
140    h
141}
142
143/// Java `String.hashCode` of the `IObjType` hash seed `"::" + obj_name`.
144pub fn hash_seed(obj_name: &str) -> i32 {
145    java_string_hash(&format!("::{obj_name}"))
146}
147
148/// Java `Boolean.hashCode`.
149pub fn hash_bool(b: bool) -> i32 {
150    if b {
151        1231
152    } else {
153        1237
154    }
155}
156
157/// Java `Character.hashCode` — the UTF-16 unit as int. BMP chars match
158/// exactly; supplementary Rust `char`s hash as their full code point
159/// (no single Java `char` equivalent exists).
160pub fn hash_char(c: char) -> i32 {
161    c as i32
162}
163
164/// `java.util.Arrays.hashCode(byte[])` — bytes are sign-extended to int.
165pub fn hash_bytes(bytes: &[u8]) -> i32 {
166    let mut h = 1i32;
167    for b in bytes {
168        h = h.wrapping_mul(31).wrapping_add(*b as i8 as i32);
169    }
170    h
171}
172
173/// System/value hash for a long uses the canonical numeric domain. Decimal
174/// trailing zeroes are not significant: `100`, `100.0`, and `1E2` compare and
175/// hash alike.
176pub fn hash_long(n: i64) -> i32 {
177    canonical_decimal_str_hash(&n.to_string())
178}
179
180/// CHAMP placement hash for an integral value. Java's node layout retains the
181/// scale-zero representation here, including trailing zeroes, even though
182/// value/protocol hashing uses the canonical numeric domain above.
183pub fn hash_long_placement(n: i64) -> i32 {
184    let text = n.to_string();
185    let Some((signum, digits, scale)) = parse_decimal(&text) else {
186        return java_string_hash(&text);
187    };
188    bigdecimal_hash(&digits_to_words_be(&digits), signum, scale as i32)
189}
190
191/// `G.hashValue(Double)`:
192/// - `0.0` (and `-0.0`) → 0
193/// - finite → `canonicalDecimal(BigDecimal.valueOf(d)).hashCode()`
194pub fn hash_double(d: f64) -> i32 {
195    assert!(d.is_finite(), "non-finite number");
196    if d == 0.0 {
197        return 0;
198    }
199    // BigDecimal.valueOf(d) is defined via Double.toString; Rust's `{}` also
200    // produces shortest round-trip digits (see module deviation notes).
201    canonical_decimal_str_hash(&format!("{d}"))
202}
203
204/// `canonicalDecimal(new BigDecimal(string)).hashCode()` — parse a Java
205/// BigDecimal/BigInteger grammar string, strip trailing zeros, hash.
206/// Malformed input falls back to `java_string_hash` (should not happen for
207/// runtime-produced numeric strings).
208pub fn canonical_decimal_str_hash(s: &str) -> i32 {
209    match parse_decimal(s) {
210        Some((signum, digits, scale)) => canonical_decimal_hash(signum, digits, scale),
211        None => java_string_hash(s),
212    }
213}
214
215// ---------------------------------------------------------------------------
216// string types (IStringType.hashCalc: hashSeed() + "|" + toString())
217// ---------------------------------------------------------------------------
218
219/// Per-type hash of an already-composed string-type hash input
220/// (`hashSeed + "|" + display`, or the Java-mirrored Symbol form).
221/// Mirrors `IStringType.hashCalc`, including the SIP → -1 case.
222pub fn hash_string_type(hash_type: HashType, hashed: &str) -> i64 {
223    match hash_type {
224        HashType::System => java_string_hash(hashed) as i64,
225        HashType::Rapid => rapid::hash(hashed.as_bytes()) as i64,
226        HashType::Murmur3 => murmur3::hash_chars(hashed) as i64,
227        HashType::Sip => -1,
228    }
229}
230
231// ---------------------------------------------------------------------------
232// collection composition (IOrderedType / IUnOrderedType / Trie)
233// ---------------------------------------------------------------------------
234
235/// `IOrderedType.hashCalc`: acc starts at `hashSeed().hashCode()` (widened to
236/// long), then `acc = acc * 31 + hash(item)` per item, wrapping i64.
237pub fn compose_ordered(obj_name: &str, items: impl IntoIterator<Item = i64>) -> i64 {
238    let mut acc = hash_seed(obj_name) as i64;
239    for h in items {
240        acc = acc.wrapping_mul(31).wrapping_add(h);
241    }
242    acc
243}
244
245/// `IUnOrderedType.hashCalc`: acc starts at the seed, then `acc += hash(item)`
246/// per item (order-insensitive sum), wrapping i64.
247pub fn compose_unordered(obj_name: &str, items: impl IntoIterator<Item = i64>) -> i64 {
248    let mut acc = hash_seed(obj_name) as i64;
249    for h in items {
250        acc = acc.wrapping_add(h);
251    }
252    acc
253}
254
255/// Hash of a map entry. Map iterators yield `MapEntry` values, which use
256/// ordered composition with the `"::SEQUENTIAL"` seed:
257/// `(seed * 31 + hk) * 31 + hv`.
258pub fn compose_entry(key_hash: i64, value_hash: i64) -> i64 {
259    compose_ordered("SEQUENTIAL", [key_hash, value_hash])
260}
261
262// ---------------------------------------------------------------------------
263// canonical number hashing (BigDecimal.hashCode / BigInteger.hashCode)
264// ---------------------------------------------------------------------------
265
266/// `BigInteger.hashCode`: 31-fold over big-endian sign-magnitude u32 words
267/// (wrapping i32), times signum.
268fn biginteger_hash(words_be: &[u32], signum: i32) -> i32 {
269    let mut h = 0i32;
270    for w in words_be {
271        h = h.wrapping_mul(31).wrapping_add(*w as i32);
272    }
273    h.wrapping_mul(signum)
274}
275
276/// `BigDecimal.hashCode` given the unscaled value as big-endian magnitude
277/// words, its signum, and the scale. Reproduces both the compact path
278/// (unscaled fits in a Java long) and the inflated path:
279///
280/// ```java
281/// if (intCompact != INFLATED) {
282///     long val2 = (intCompact < 0)? -intCompact : intCompact;
283///     int temp = (int)( ((int)(val2 >>> 32)) * 31  + (val2 & LONG_MASK));
284///     return 31*((intCompact < 0) ?-temp:temp) + scale;
285/// } else
286///     return 31*intVal.hashCode() + scale;
287/// ```
288fn bigdecimal_hash(words_be: &[u32], signum: i32, scale: i32) -> i32 {
289    if signum == 0 {
290        // intCompact == 0: val2 = 0, temp = 0 → 31 * 0 + scale
291        return scale;
292    }
293    let mag: u128 = words_be
294        .iter()
295        .fold(0u128, |acc, w| (acc << 32) | (*w as u128));
296    // intCompact == INFLATED (Long.MIN_VALUE sentinel) exactly when the
297    // magnitude does not fit in a non-negative long.
298    if mag < (1u128 << 63) {
299        let val2 = mag as u64;
300        let temp = ((((val2 >> 32) as u32) as i32).wrapping_mul(31) as i64
301            + (val2 & 0xffff_ffff) as i64) as i32;
302        let signed = if signum < 0 {
303            temp.wrapping_neg()
304        } else {
305            temp
306        };
307        31i32.wrapping_mul(signed).wrapping_add(scale)
308    } else {
309        31i32
310            .wrapping_mul(biginteger_hash(words_be, signum))
311            .wrapping_add(scale)
312    }
313}
314
315/// Parse a Java BigDecimal-grammar string into (signum, digits without
316/// leading zeros, scale). Handles optional sign, decimal point and exponent.
317fn parse_decimal(s: &str) -> Option<(i32, Vec<u8>, i64)> {
318    let b = s.as_bytes();
319    let mut i = 0usize;
320    let mut neg = false;
321    if i < b.len() && (b[i] == b'-' || b[i] == b'+') {
322        neg = b[i] == b'-';
323        i += 1;
324    }
325    let mut digits: Vec<u8> = Vec::new();
326    let mut seen_dot = false;
327    let mut scale: i64 = 0;
328    let mut any_digit = false;
329    while i < b.len() {
330        match b[i] {
331            c @ b'0'..=b'9' => {
332                digits.push(c - b'0');
333                if seen_dot {
334                    scale += 1;
335                }
336                any_digit = true;
337            }
338            b'.' if !seen_dot => seen_dot = true,
339            b'e' | b'E' => {
340                i += 1;
341                let mut eneg = false;
342                if i < b.len() && (b[i] == b'-' || b[i] == b'+') {
343                    eneg = b[i] == b'-';
344                    i += 1;
345                }
346                let mut exp: i64 = 0;
347                let mut any_exp = false;
348                while i < b.len() && b[i].is_ascii_digit() {
349                    exp = exp.saturating_mul(10).saturating_add((b[i] - b'0') as i64);
350                    any_exp = true;
351                    i += 1;
352                }
353                if !any_exp {
354                    return None;
355                }
356                scale -= if eneg { -exp } else { exp };
357                break;
358            }
359            _ => return None,
360        }
361        i += 1;
362    }
363    if !any_digit {
364        return None;
365    }
366    match digits.iter().position(|d| *d != 0) {
367        None => Some((0, vec![0], 0)),
368        Some(first) => Some((if neg { -1 } else { 1 }, digits[first..].to_vec(), scale)),
369    }
370}
371
372/// `NumUtils.normalizeDecimal` + hash: zero → `BigDecimal.ZERO` (hash 0);
373/// otherwise strip trailing zeros (each stripped zero decrements the scale)
374/// and take `BigDecimal.hashCode`.
375fn canonical_decimal_hash(signum: i32, mut digits: Vec<u8>, mut scale: i64) -> i32 {
376    if signum == 0 {
377        return 0;
378    }
379    while digits.last() == Some(&0) {
380        digits.pop();
381        scale -= 1;
382    }
383    let words = digits_to_words_be(&digits);
384    bigdecimal_hash(&words, signum, scale as i32)
385}
386
387/// Convert a decimal digit sequence (most significant first) to big-endian
388/// u32 magnitude words (no leading zero words).
389fn digits_to_words_be(digits: &[u8]) -> Vec<u32> {
390    let mut le: Vec<u32> = vec![0];
391    for d in digits {
392        let mut carry = *d as u64;
393        for w in le.iter_mut() {
394            let v = (*w as u64) * 10 + carry;
395            *w = v as u32;
396            carry = v >> 32;
397        }
398        while carry > 0 {
399            le.push(carry as u32);
400            carry >>= 32;
401        }
402    }
403    while le.len() > 1 && le.last() == Some(&0) {
404        le.pop();
405    }
406    le.iter().rev().copied().collect()
407}
408
409// ---------------------------------------------------------------------------
410// tests
411// ---------------------------------------------------------------------------
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416    use crate::core::Value;
417    use crate::kernel::parser::parse_forms;
418    use crate::kernel::Form;
419    use crate::lang::data::{
420        Keyword, Map as PMap, MapEntry as PMapEntry, Set as PSet, Symbol, Tuple as PTuple,
421    };
422    use crate::lang::protocol::{IDisplay, IHash, IObjType};
423
424    /// Locates a repo-relative file from the crate manifest dir (mirrors the
425    /// corpus runners in kernel::parser_tests and vm::conformance_tests).
426    fn corpus_path(relative: &str) -> Option<std::path::PathBuf> {
427        crate::spec_registry::resolve(relative).filter(|candidate| candidate.is_file())
428    }
429
430    fn field<'a>(case: &'a Form, key: &str) -> &'a Form {
431        match case {
432            Form::Map(entries) => entries
433                .iter()
434                .find(|(k, _)| matches!(k, Form::Keyword(kw) if kw == key))
435                .map(|(_, v)| v)
436                .unwrap_or_else(|| panic!("case missing :{key}: {case}")),
437            other => panic!("case is not a map: {other}"),
438        }
439    }
440
441    fn kw_of(form: &Form) -> &str {
442        match form {
443            Form::Keyword(s) => s,
444            other => panic!("expected keyword, got {other}"),
445        }
446    }
447
448    fn num_of(form: &Form) -> i64 {
449        match form {
450            Form::Number(n) => *n,
451            other => panic!("expected number, got {other}"),
452        }
453    }
454
455    fn str_of(form: &Form) -> &str {
456        match form {
457            Form::String(s) => s,
458            other => panic!("expected string, got {other}"),
459        }
460    }
461
462    fn hash_type(id: &str) -> HashType {
463        match id {
464            "system" => HashType::System,
465            "rapid" => HashType::Rapid,
466            "murmur3" => HashType::Murmur3,
467            "sip" => HashType::Sip,
468            other => panic!("unknown hash type: {other}"),
469        }
470    }
471
472    /// Converts a corpus EDN element form to a runtime `Value` (collection
473    /// elements only: scalars and nested vector/map/set/list).
474    fn element_value(form: &Form) -> Value {
475        match form {
476            Form::Nil => Value::Nil,
477            Form::Bool(b) => Value::Bool(*b),
478            Form::Number(n) => Value::Number(*n),
479            Form::Float(f) => Value::Float(*f),
480            Form::String(s) => Value::String(s.clone().into()),
481            Form::Vector(items) => Value::Vector(items.iter().map(element_value).collect()),
482            Form::List(items) => Value::List(items.iter().map(element_value).collect()),
483            Form::Map(pairs) => Value::Map(
484                pairs
485                    .iter()
486                    .map(|(k, v)| (element_value(k), element_value(v)))
487                    .collect::<PMap<Value, Value>>(),
488            ),
489            Form::Set(items) => {
490                Value::Set(items.iter().map(element_value).collect::<PSet<Value>>())
491            }
492            other => panic!("unsupported collection element: {other}"),
493        }
494    }
495
496    /// Builds the corpus collection for a `:kind :collection` case, using
497    /// `:structure` to interpret the input form (queue, compact-vector2, and
498    /// map-entry inputs are vector-encoded).
499    fn collection_value(structure: &str, input: &Form) -> Value {
500        match structure {
501            "vector" | "list" | "map" | "set" => element_value(input),
502            "queue" => match input {
503                Form::Vector(items) => {
504                    Value::Queue(Box::new(items.iter().map(element_value).collect()))
505                }
506                other => panic!("queue input must be a vector: {other}"),
507            },
508            "compact-vector2" => match input {
509                Form::Vector(items) if items.len() == 2 => Value::Tuple(Box::new(
510                    PTuple::from_values(items.iter().map(element_value).collect()).unwrap(),
511                )),
512                other => panic!("compact-vector2 input must be a 2-vector: {other}"),
513            },
514            "map-entry" => match input {
515                Form::Vector(items) if items.len() == 2 => Value::MapEntry(Box::new(
516                    PMapEntry::new(element_value(&items[0]), element_value(&items[1])),
517                )),
518                other => panic!("map-entry input must be a 2-vector: {other}"),
519            },
520            other => panic!("unknown collection structure: {other}"),
521        }
522    }
523
524    fn eval_case(case: &Form) -> i64 {
525        let hash = kw_of(field(case, "hash"));
526        let kind = kw_of(field(case, "kind"));
527        let input = field(case, "input");
528        match kind {
529            "string" => {
530                let s = str_of(input);
531                match hash {
532                    "rapid" => rapid::hash(s.as_bytes()) as i64,
533                    "murmur3" => murmur3::hash_chars(s) as i64,
534                    "sip" => siphash::hash(&siphash::HARA, s.as_bytes()) as i64,
535                    "system" => java_string_hash(s) as i64,
536                    other => panic!("unknown string hash type: {other}"),
537                }
538            }
539            "int" => murmur3::hash_int(num_of(input) as i32) as i64,
540            "long" => match hash {
541                "murmur3" => murmur3::hash_long(num_of(input)) as i64,
542                "system" => hash_long(num_of(input)) as i64,
543                other => panic!("unknown long hash type: {other}"),
544            },
545            "double" => match input {
546                Form::Float(f) => hash_double(*f) as i64,
547                other => panic!("double input must be a float: {other}"),
548            },
549            "bigint" => canonical_decimal_str_hash(str_of(input)) as i64,
550            "bool" => match input {
551                Form::Bool(b) => hash_bool(*b) as i64,
552                other => panic!("bool input must be a boolean: {other}"),
553            },
554            "char" => match input {
555                Form::Character(c) => hash_char(*c) as i64,
556                other => panic!("char input must be a character: {other}"),
557            },
558            "bytes" => match input {
559                Form::Vector(items) => {
560                    let bytes: Vec<u8> = items.iter().map(|f| num_of(f) as i8 as u8).collect();
561                    hash_bytes(&bytes) as i64
562                }
563                other => panic!("bytes input must be a vector: {other}"),
564            },
565            "nil" => 0,
566            "seed" => hash_seed(str_of(input)) as i64,
567            "keyword" => match input {
568                Form::Keyword(s) => Keyword::parse(s).unwrap().hash_calc(hash_type(hash)) as i64,
569                other => panic!("keyword input must be a keyword: {other}"),
570            },
571            "symbol" => match input {
572                Form::Symbol(s) => Symbol::parse(s).hash_calc(hash_type(hash)) as i64,
573                other => panic!("symbol input must be a symbol: {other}"),
574            },
575            "collection" => {
576                let structure = kw_of(field(case, "structure"));
577                let value = collection_value(structure, input);
578                match hash {
579                    "rapid" => value.stable_hash() as i64,
580                    "murmur3" => value.java_hash(HashType::Murmur3),
581                    other => panic!("unknown collection hash type: {other}"),
582                }
583            }
584            other => panic!("unknown case kind: {other}"),
585        }
586    }
587
588    /// Byte-exact parity against the Java runtime's hash values, from the
589    /// normative corpus hara-specs-registry/01-lang/020-data-structures/draft/conformance/
590    /// hash-parity.edn (generated by target/hashdump/HashDump.java).
591    #[test]
592    fn java_parity_fixture() {
593        let Some(path) =
594            corpus_path("01-lang/020-data-structures/draft/conformance/hash-parity.edn")
595        else {
596            eprintln!(
597                "skipping hash-parity corpus: specs checkout not found from {}",
598                env!("CARGO_MANIFEST_DIR")
599            );
600            return;
601        };
602        let source = std::fs::read_to_string(&path)
603            .unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()));
604        let forms = parse_forms(&source).expect("hash-parity corpus must parse");
605        assert_eq!(forms.len(), 1, "corpus must be a single map form");
606        let Form::Vector(cases) = field(&forms[0], "cases") else {
607            panic!("corpus :cases must be a vector");
608        };
609        let mut failures: Vec<String> = Vec::new();
610        for case in cases {
611            if kw_of(field(case, "kind")) == "decimal" {
612                continue;
613            }
614            let id = kw_of(field(case, "id")).to_string();
615            let expected = num_of(field(case, "expect"));
616            let actual = eval_case(case);
617            if actual != expected {
618                failures.push(format!(":{id}: expected {expected}, got {actual}"));
619            }
620        }
621        assert!(
622            cases.len() >= 270,
623            "only {} hash-parity cases found",
624            cases.len()
625        );
626        if !failures.is_empty() {
627            panic!(
628                "{} of {} hash-parity cases failed:\n{}",
629                failures.len(),
630                cases.len(),
631                failures.join("\n")
632            );
633        }
634    }
635
636    #[test]
637    fn cross_type_numeric_equality() {
638        // Canonical numeric representations share one equality/hash domain;
639        // `hash_long` is the separate scale-zero system/CHAMP layout hash.
640        assert_eq!(hash_double(1.0), canonical_decimal_str_hash("1"));
641        assert_eq!(hash_double(1.0), canonical_decimal_str_hash("1.0"));
642        assert_eq!(hash_double(2.5), canonical_decimal_str_hash("2.50"));
643        assert_eq!(hash_double(100.0), canonical_decimal_str_hash("100"));
644        assert_eq!(hash_double(100.0), canonical_decimal_str_hash("100.0"));
645    }
646
647    #[test]
648    fn subnormal_double_known_divergence() {
649        // Pinned deviation (see module docs): Java hashes Double.MIN_VALUE
650        // via "4.9E-324" → 1844; this port formats it as Rust does ("5e-324"
651        // digits) → 479. Excluded from the parity corpus on purpose.
652        assert_eq!(hash_double(f64::from_bits(1)), 479);
653        assert_eq!(hash_double(5e-324), 479);
654    }
655
656    #[test]
657    fn keyword_display_form_deviation() {
658        // The corpus keyword/symbol cases already route through hash_calc;
659        // pin the composed strings explicitly.
660        let kw = Keyword::create(None, "a").unwrap();
661        assert_eq!(
662            kw.hash_calc(HashType::Rapid) as i64,
663            rapid::hash("::KEYWORD|:a".as_bytes()) as i64
664        );
665        let sym = Symbol::create(None, "a");
666        assert_eq!(
667            sym.hash_calc(HashType::Rapid) as i64,
668            rapid::hash("::SYMBOL|hara.lang.data.Symbol<a>".as_bytes()) as i64
669        );
670        // IStringType.hashCalc(SIP) == -1 in Java.
671        assert_eq!(kw.hash_calc(HashType::Sip) as i64, -1);
672        // display forms used in the composition
673        assert_eq!(kw.display(), ":a");
674        assert_eq!(sym.display(), "a");
675        assert_eq!(kw.hash_seed(), "::KEYWORD");
676        assert_eq!(sym.hash_seed(), "::SYMBOL");
677    }
678}