Identity
A definition's hash covers its canonical form: bound names erased, and every reference replaced by the hash of what it points at. So the hash pins one exact implementation together with everything it transitively depends on.
Rename the function or its parameter and the hash is unchanged, because the names you bind are a metadata layer. Change what it computes and the hash moves. Try both:
fn damage(hp, hit) {
return hp - hit;
}
Hashes above are real output from rigid hash.
What that gives you
Four consequences of hashing structure instead of trusting names. Every terminal block below is copied from a run.
Renames are repaired mechanically
When a definition is renamed, the compiler still recognises it by hash and finds it under the new name. The diagnostic carries a typed fix, so repair is one command — including call sites in modules you never opened.
$ rigid check
error[E0201] app.rg:1:10: unresolved import 'shout' from
"words.rg" — the pinned definition (sha256:f2aeacb386b8…)
now exists under the name 'yell'
$ rigid fix --apply
applied: 3 occurrences of 'shout' -> 'yell' in app.rg
converged: check is green
A name cannot silently change meaning
If a name is reused for a different definition, every existing reference to it would quietly point somewhere new. rigid refuses, and names both hashes. Accepting the new meaning is a separate, explicit command.
Run against strict TypeScript on the same breakage, tsc compiles it clean — correctly,
by its own rules, because it has no record of what the name meant before.
See the benchmark.
$ rigid check
error[E0202] app.rg:1:10: 'shout' from "words.rg" no longer
means what it meant: the pinned definition
(sha256:f2aeacb386b8…) is now named 'yell', and 'shout'
now names a different definition (sha256:a9dd7e003ea9…).
Refusing to silently rebind. Run `rigid rebind
app.rg#shout` to accept the new meaning.
Renaming is free; changing behaviour is not
A test's hash covers its whole dependency cone, and bound names are erased from it. So renaming
and reformatting cannot invalidate a cache, and changing what code computes always does. Below is a
real run on examples/press — four modules, 54 definitions, 22 tests.
$ # renamed every local and parameter in split_lines,
$ # reflowed it, added comments. Then:
$ rigid test
0 executed, 22 skipped via content-address cache, 0 failed
$ # now change what split_lines actually does:
$ rigid test
FAIL strings.rg#test_split
FAIL md.rg#test_h1 … and 3 more
8 executed, 14 skipped via content-address cache, 5 failed
Those 8 are exactly the tests whose dependency cone contains
split_lines — checked against the graph, with nothing wrongly skipped and no rerun
wasted. Every other build cache keys on file text, so the first case would have rerun all 22.
Dependencies stay exact through string-keyed dispatch
A table is itself a definition in the graph, so a handler reached only by a runtime
string key is still a normal edge — queryable in both directions, at the CLI. A type checker
follows these too when the handlers are named by identifier; what it cannot follow is a target
named by a computed string, or a registry assembled where it cannot see. Measured, with the
cases each one misses, in the benchmark.
$ rigid rdeps "route.rg#on_created"
rdeps of route.rg#on_created (1 result):
route.rg#handlers sha256:d010715b9f7c…
$ rigid rdeps "route.rg#handlers"
rdeps of route.rg#handlers (1 result):
route.rg#route sha256:759fa57bf790…
The language
Small on purpose: integers, floats, strings, lists, records, functions,
while, for (x in xs), if as a statement and as an
expression, and fn(x) { … } lambdas. No type system. A program is a folder of
.rg files with no manifest and no build step.
fn sum_to(n) {
let total = 0;
let i = 1;
while (i <= n) {
total = total + i;
i = i + 1;
}
return total;
}
fn checked_div(a, b) {
return if (b == 0)
fail({ code: 400, msg: "division by zero" })
else a / b;
}
fn safe_div(a, b) {
let r = try(checked_div(a, b));
return if (r.ok) r.value else 0 - 1;
}
fn greet(name) !io {
return print("hello, " + name);
}
Three commands do everything
$ rigid check # parse, resolve, hash, reconcile
$ rigid run flow.rg#sum_to 100
5050
$ rigid test # every test_* function
Twenty-four commands in all. deps / rdeps /
why query the graph; diff grades a text diff by identity;
caps lists what reaches the outside world; merge is a three-way merge
over identities; attest and trust bind claims to a hash;
dead and dup find what nothing reaches and what is written twice.
Plus fmt, repl, an LSP, and build --target js to run
programs on Node or in a browser.
The graph is readable by machines too
rigid describe emits the whole workspace as one JSON document: every definition with
its identity, arity, effects, exact dependencies and reverse dependencies, table topology, and test
coverage. None of it is reconstructed from the text — it is the compiler's own resolution, written
down.
Here is why that matters, in one program written twice. Both versions dispatch on a runtime string key. Only one of them lets you find out what that key can select.
const R = {};
function reg(k, f) { R[k] = f; }
reg("created", audit);
reg("updated", notify);
const h = R; // aliased
h["closed"] = close_out;
if (true) { reg("archived", archive); }
reg("breach", escalate);
function handle(kind, sev) {
const f = R[kind]; // which functions can f be?
if (!f) return "drop:" + kind;
return f({ kind: kind, sev: sev });
}
The registry is built at runtime by five separate statements — one through an
alias, one inside a conditional. Nothing in the text lists what R[kind] can be, so
"which handlers perform IO?" has no answer without running it.
{
"name": "handlers",
"kind": "table",
"hash": "sha256:e17952f86a72…",
"entries": {
"created": "pipeline.rg#audit",
"updated": "pipeline.rg#notify",
"closed": "pipeline.rg#close_out",
"archived": "pipeline.rg#archive",
"breach": "pipeline.rg#escalate"
},
"rdeps": ["pipeline.rg#handle"]
}
The same five targets, named — plus the hash that pins the set and the
dispatcher that reaches it. Two of those handlers are !io, so the effect reaching
handle is traceable hop by hop.
A model was asked five dependency questions about each
Answers scored against the compiler's own graph, by a fresh model over the API — not the one that built any of this. Five runs.
What C got right every run and the others did not was the transitive effect surface — including the dispatch table on the path.
This is a pilot, not a benchmark: one program, five questions, five runs, one model. It is also the one result on this site that was not re-run while the site was built, because it needs a model over an API rather than a terminal.
Does declaring dispatch targets cost anything?
The fair objection to writing your value sets down is that real registries might be enormous. The corpus and method were pre-registered before anything was downloaded: five widely-used Python projects with plugin and registry architectures, 983 source files, 430 dispatch tables pooled.
The tail is real: the largest is pygments' 602-entry lexer registry, and a catalogue genuinely has that many entries. Separately, 2.05% of non-method call sites resolve their target from a runtime string. One run was discarded for a methodological error, which is recorded in the pre-registration. Method and per-project distribution: the study.
Install
Requires a Rust toolchain from rustup.rs. Nothing else.
git clone https://github.com/DavidRDudas/rigidlang
cd rigid-lang
cargo build --release
export PATH="$PWD/target/release:$PATH"
Then check everything the site claims:
$ ./demo.sh # 48 verified steps
$ rigid test --root examples/triage
$ rigid rdeps "score.rg#severity" --root examples/triage
The tutorial walks the language from the first program. The specification is the grammar and canonical form. The paper is the design and its evaluation.
Status and limits
rigid is a working research prototype. Everything on this page is a green step in
demo.sh. The gaps are listed here so you find them now rather than later.
Unison got here first. Identifying definitions by the hash of their structure, non-breaking renames, no builds — that is Unison's idea, and Scrapscript explored the same ground. If you want the mature version of content-addressed code, go and read theirs.
What differs here is the price. Unison stores code in a database and you work through its codebase
manager; text files are a staging area. rigid keeps ordinary files, ordinary diffs, ordinary git, and
recomputes identity from them — which is why it needs machinery Unison does not: a per-module
lockfile pinning what each imported name resolved to, and a hard refusal (E0202) when a
name starts meaning something else.
The one thing that is ours: declared dispatch sets. A table is a definition in
the graph, so a handler reached only by a runtime string key is still an exact edge, and effects
propagate through it. In Unison — and in every other language — a map of functions is just data, and
the call graph stops there. That is what
the benchmark and
the pilot are actually about.
Unison is also far ahead on everything else: types, algebraic effects, distributed execution, and users.
The language is named after Kripke's rigid designator: a reference that picks out the same thing in every possible world. A hash-pinned definition does exactly that, which is why the name fits.