Skip to content

Commit 23fd355

Browse files
committed
feat(interop): D-376 — Murmur3/hashUnencodedChars, real UTF-16 JVM bit-parity
Smell-audited: 1: the row's either/or (UTF-8 shortcut accepting AD-009 divergence vs a real UTF-16 path) resolved to the REAL path — ~30 lines, removes the divergence entirely; unlike the AD-009 collection hashes the input is pure code units, so JVM bit-parity is achievable and locked. hash.zig hashUnencodedChars: streaming UTF-8 -> UTF-16 code units (surrogate pairs for astral codepoints), 2 units per 32-bit block, fmix over 2*unit-count — the exact JVM Murmur3.hashUnencodedChars. Registered on the existing cljw.clojure.lang.Murmur3 surface (string arg; a CharSequence deftype raises until a consumer needs it). Unit test pins 6 clj-oracle values incl. surrogate pairs; corpus clojure_lang_util +5 clj-byte-matched goldens. data.xml's full-lib run stays with D-528.
1 parent c79a7f7 commit 23fd355

4 files changed

Lines changed: 78 additions & 9 deletions

File tree

‎.dev/debt.yaml‎

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -649,15 +649,6 @@ active:
649649
allow/deny set consulted at the single var-resolution site. Lower priority
650650
than D-351/D-352.
651651
last_reviewed: "2026-06-09"
652-
- id: "D-376"
653-
status: "open (2026-06-10, ADR-0108 am1 out-of-scope). `clojure.lang.Murmur3/hashUnencodedChars` (2 sites, data.xml only) hashes a CharSequence's UTF-16 code units. cljw's hashString deliberately hashes UTF-8 bytes (hash.zig — a WASM/edge-native choice, F-011 internals are impl-specific), so there is no UTF-16-codeunit hasher to delegate to. Deferred from the D-375 closed set. Revisit if data.xml is promoted to a verified lib: add a hashUnencodedChars hashing UTF-8 (accept the AD-009 value divergence) or build a real UTF-16 path then."
654-
category: "interop"
655-
barrier: |-
656-
`Murmur3/hashUnencodedChars` hashes UTF-16 code units; cljw hashes UTF-8
657-
(no UTF-16 hasher exists). Low value (data.xml only, 2 sites). Revisit when a
658-
verified lib needs it; then hash UTF-8 (AD-009) or add a UTF-16 path. Cross-ref
659-
ADR-0108 am1, AD-009.
660-
last_reviewed: "2026-06-10"
661652
- id: "D-033"
662653
status: "DUE — trigger fired (corrected 2026-06-17 audit). primitive/ files long past the ~500 trigger: core.zig 1837 / math.zig 1444 / collection.zig 1190; core + math also breach the ROADMAP A6 ≤1000-line cap. Split never done. Next: confirm A6 still applies, then split (primitive/core|sequence|…) or amend the cap with an ADR."
663654
category: "structure"
@@ -6537,6 +6528,12 @@ discharged:
65376528
barrier: "DISCHARGED (was: print-control dynamic vars)."
65386529
last_reviewed: "2026-07-06"
65396530

6531+
- id: "D-376"
6532+
status: "DISCHARGED 2026-07-06 (easiest-first drain). `clojure.lang.Murmur3/hashUnencodedChars` landed as a REAL UTF-16 code-unit Murmur3 (hash.zig hashUnencodedChars: UTF-8 -> UTF-16 units streaming, surrogate pairs for astral codepoints, 2 units/block, fmix over 2*unit-count — the exact JVM algorithm), registered on the existing cljw.clojure.lang.Murmur3 surface. UNLIKE the AD-009 collection hashes this is JVM BIT-PARITY (input is pure code units, no cljw-native value hash) — unit test pins 6 clj-oracle values incl. a surrogate-pair case (𠮷野家 -> -383720716); corpus clojure_lang_util +5 clj-byte-matched goldens. The 2026-06-10 either/or (hash UTF-8 accepting AD-009 divergence, OR build a real UTF-16 path) resolved to the REAL path — it costs ~30 lines and removes the divergence entirely. String-arg only (a CharSequence deftype arg raises type_arg_invalid; extend on a real consumer). data.xml (the 2 call sites) has no local clone — take its full-lib run up under D-528 when cloned."
6533+
category: "interop"
6534+
barrier: "DISCHARGED (was: no UTF-16 hasher)."
6535+
last_reviewed: "2026-07-06"
6536+
65406537
conventions: |-
65416538
- Structure: two top-level lists — `active:` and `discharged:`. Active entries carry id / status / category / barrier (+ optional quality_floor / last_reviewed); discharged entries carry id / discharged_at / resolution. Edit as YAML (no Markdown table to align).
65426539
- New debt: grep `.dev/debt.yaml` for class-overlap before adding (per `.claude/rules/debt_dedup.md`).

‎src/runtime/clojure/lang/Murmur3.zig‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const Env = @import("../../env.zig").Env;
2222
const SourceLocation = @import("../../error/info.zig").SourceLocation;
2323
const error_catalog = @import("../../error/catalog.zig");
2424
const hash = @import("../../hash.zig");
25+
const string_mod = @import("../../collection/string.zig");
2526

2627
/// Resolve `clojure.core/<name>` and call it through the backend vtable.
2728
fn callCore(rt: *Runtime, env: *Env, name: []const u8, args: []const Value, loc: SourceLocation) !Value {
@@ -57,12 +58,27 @@ fn mixCollHash(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation
5758
return Value.initInteger(@as(i32, @bitCast(mixed)));
5859
}
5960

61+
/// `(clojure.lang.Murmur3/hashUnencodedChars s)` — Murmur3 over the string's
62+
/// UTF-16 code units. Unlike the AD-009 collection hashes this is JVM
63+
/// BIT-PARITY (the input is pure code units — no cljw-native value hash is
64+
/// involved), so clj and cljw agree on the value (D-376; data.xml's 2 sites).
65+
fn hashUnencodedChars(rt: *Runtime, env: *Env, args: []const Value, loc: SourceLocation) anyerror!Value {
66+
_ = rt;
67+
_ = env;
68+
try error_catalog.checkArity("clojure.lang.Murmur3/hashUnencodedChars", args, 1, loc);
69+
if (args[0].tag() != .string)
70+
return error_catalog.raise(.type_arg_invalid, loc, .{ .fn_name = "clojure.lang.Murmur3/hashUnencodedChars", .expected = "string", .actual = @tagName(args[0].tag()) });
71+
const h = hash.hashUnencodedChars(string_mod.asString(args[0]));
72+
return Value.initInteger(@as(i32, @bitCast(h)));
73+
}
74+
6075
fn initMurmur3(td: *type_descriptor.TypeDescriptor, gpa: std.mem.Allocator) anyerror!void {
6176
if (td.method_table.len != 0) return; // idempotent re-run
6277
const specs = .{
6378
.{ "hashOrdered", &hashOrdered },
6479
.{ "hashUnordered", &hashUnordered },
6580
.{ "mixCollHash", &mixCollHash },
81+
.{ "hashUnencodedChars", &hashUnencodedChars },
6682
};
6783
const entries = try gpa.alloc(type_descriptor.TypeDescriptor.MethodEntry, specs.len);
6884
inline for (specs, 0..) |spec, i| {

‎src/runtime/hash.zig‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,40 @@ pub fn hashString(input: []const u8) u32 {
9090
return fmix(h1, @truncate(input.len));
9191
}
9292

93+
/// JVM `clojure.lang.Murmur3.hashUnencodedChars` bit-parity: Murmur3 over
94+
/// the string's UTF-16 CODE UNITS (surrogate pairs for astral codepoints),
95+
/// two units per 32-bit block, `fmix` over `2 * unit-count` bytes. Distinct
96+
/// from `hashString` (cljw-native UTF-8 bytes, AD-009) — this serves the
97+
/// `Murmur3/hashUnencodedChars` static (data.xml), where JVM value-parity is
98+
/// achievable because the input is pure code units, not value identity (D-376).
99+
pub fn hashUnencodedChars(utf8: []const u8) u32 {
100+
var h1: u32 = SEED;
101+
var unit_count: u32 = 0;
102+
var pending: ?u32 = null;
103+
var it = std.unicode.Utf8View.initUnchecked(utf8).iterator();
104+
while (it.nextCodepoint()) |cp| {
105+
var units: [2]u32 = .{ @as(u32, cp), 0 };
106+
var n: usize = 1;
107+
if (cp >= 0x10000) {
108+
const v: u32 = @as(u32, cp) - 0x10000;
109+
units[0] = 0xD800 + (v >> 10);
110+
units[1] = 0xDC00 + (v & 0x3FF);
111+
n = 2;
112+
}
113+
for (units[0..n]) |u| {
114+
unit_count += 1;
115+
if (pending) |lo| {
116+
h1 = mixH1(h1, mixK1(lo | (u << 16)));
117+
pending = null;
118+
} else {
119+
pending = u;
120+
}
121+
}
122+
}
123+
if (pending) |lo| h1 ^= mixK1(lo);
124+
return fmix(h1, 2 *% unit_count);
125+
}
126+
93127
/// Mix a precomputed collection hash with its element count.
94128
pub fn mixCollHash(hash_val: u32, count: u32) u32 {
95129
var h1 = SEED;
@@ -177,3 +211,15 @@ test "hashUnordered is order-independent" {
177211
const b = [_]u32{ hashInt(3), hashInt(1), hashInt(2) };
178212
try testing.expectEqual(hashUnordered(&a), hashUnordered(&b));
179213
}
214+
215+
test "hashUnencodedChars matches JVM Murmur3 (clj oracle 2026-07-06)" {
216+
// clj: (clojure.lang.Murmur3/hashUnencodedChars s) — UTF-16 code-unit
217+
// hash, JVM bit-parity (D-376). Values incl. a surrogate-pair case (𠮷 =
218+
// U+20BB7 → 2 UTF-16 units).
219+
try testing.expectEqual(@as(i32, 1118836419), @as(i32, @bitCast(hashUnencodedChars("abc"))));
220+
try testing.expectEqual(@as(i32, 1867108634), @as(i32, @bitCast(hashUnencodedChars("a"))));
221+
try testing.expectEqual(@as(i32, 0), @as(i32, @bitCast(hashUnencodedChars(""))));
222+
try testing.expectEqual(@as(i32, 1689409188), @as(i32, @bitCast(hashUnencodedChars("hello world"))));
223+
try testing.expectEqual(@as(i32, 1524218000), @as(i32, @bitCast(hashUnencodedChars("あいう"))));
224+
try testing.expectEqual(@as(i32, -383720716), @as(i32, @bitCast(hashUnencodedChars("𠮷野家"))));
225+
}

‎test/diff/clj_corpus/clojure_lang_util.txt‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,13 @@
2222
;;=> true
2323
(clojure.lang.Util/hashCombine 7 13)
2424
;;=> -1640531072
25+
(clojure.lang.Murmur3/hashUnencodedChars "abc")
26+
;;=> 1118836419
27+
(clojure.lang.Murmur3/hashUnencodedChars "")
28+
;;=> 0
29+
(clojure.lang.Murmur3/hashUnencodedChars "hello world")
30+
;;=> 1689409188
31+
(clojure.lang.Murmur3/hashUnencodedChars "あいう")
32+
;;=> 1524218000
33+
(clojure.lang.Murmur3/hashUnencodedChars "𠮷野家")
34+
;;=> -383720716

0 commit comments

Comments
 (0)