3. Hashing
How a definition becomes a 512-bit identity, computed by hand and checked against the kernel.
Goal: exact compatibility with Unison’s hashing, so UCM can act as an oracle. The facts below were
each verified against UCM 1.4.0; docs/hashing-notes.md has the full list.
Step 0: Core to a kernel term
The kernel wants a small term language with names as strings. add becomes (curried):
{ "name": "add",
"type": { "k": "arrow", "from": {"k":"builtin","name":"Int"},
"to": { "k": "arrow", "from": {"k":"builtin","name":"Int"}, "to": {"k":"builtin","name":"Int"} } },
"term": { "k": "lam", "v": "a", "paramType": {"k":"builtin","name":"Int"},
"body": { "k": "lam", "v": "b", "paramType": {"k":"builtin","name":"Int"},
"body": { "k": "app",
"f": { "k": "app", "f": {"k":"builtin","name":"Int.+"}, "a": {"k":"var","name":"a"} },
"a": {"k":"var","name":"b"} } } } }
Multi-argument functions and calls are curried here, matching Unison. twice refers to add with
{"k":"ref","name":"add"}, which the kernel resolves to add’s hash.
Step 1: tokens and accumulate
Everything hashed is a byte sequence built from a few token kinds:
const tag = (n) => Buffer.from([n]); // 1 byte
const nat = (n) => u64be(n); // 8 bytes big-endian
const int = (n) => i64be(n); // 8 bytes big-endian
const text = (s) => cat(nat(utf8len(s)), utf8(s)); // length-prefixed
// a Hashed token is the raw 64-byte digest of something already hashed
const acc = (...tokens) => sha3_512(cat(tag(2), ...tokens)); // "accumulate"
The hash is SHA3-512 (not Blake2b, an early wrong guess), Merkle-style: a node’s hash is built from its children’s hashes, so a change anywhere changes everything above it.
Step 2: by hand, for let x = 1 (type Int)
const termInt = acc(tag(1), tag(65), acc(int(1n))); // the literal +1
const tyInt = acc(tag(0), tag(0), acc(tag(0), text("Int")));
const def = acc(tag(1), tag(4), termInt, tyInt); // TermAnn: term AND its type
const named = acc(tag(1), def, acc(nat(0n))); // component of one member, slot 0
const overall = acc(named);
const id = base32hex_lower_unpadded(overall);
Running that and the kernel on the same definition prints the identical string, byte for byte:
by hand: 13j0nj3f1fpejjkfbqfj0h7tr9ps4cfaadnb0tiv7gdqud2bhkq1fo20cel7e5go24jepmpc4k2fcbt718hc4ke404pfabkeq094kg8
kernel : 13j0nj3f1fpejjkfbqfj0h7tr9ps4cfaadnb0tiv7gdqud2bhkq1fo20cel7e5go24jepmpc4k2fcbt718hc4ke404pfabkeq094kg8
Two traps the first attempt fell into:
- Even a single definition goes through the component step (
named,overall). Skipping it gives a wrong hash. - A definition is hashed together with its type (
TermAnn). Sosame = addhas a different hash fromadd. Only a reference toaddinside other code is just add’s hash.
Variables, and recursion
Variables are de Bruijn indices, so lam a. lam b. a + b and any renaming of it hash the same: names never
reach a hash. Mutually recursive definitions form one component (found with Tarjan’s SCC), hashed together;
members are ordered by their hash and referenced as #hash, #hash.1, … Two structurally identical members
cannot be ordered, an error in both Unison and the kernel.
Results for the running example
add = cu9eebq1q7omesk7klune6m5kmsc721st2rqmie2oojikesim6nl4um7earv2ogd93rqiliqn6vb9p4ljv4chqofqg4829qd047qlug
twice = uroib53bchrqo3c8t0h4bt6qntkloklp5mtameqvccb2qnsc3sgkjevqnlup6hteug7v9qfot042jqa43jr4g1b1kuv2ui1phu0j2n8
twice’s hash contains add’s hash, not the name “add”. Rename add to plus and twice keeps its identity.
Implementations: packages/kernel (TypeScript, now a CI-only differential check) and packages/kernel-rs
(Rust, the real one, also compiled to wasm). Both replay the same fixtures recorded from UCM.
Next: Typechecking.