Content-addressed code that still lives in files

A definition is identified by the SHA-256 of its structure rather than by its name, so renaming and reformatting cannot invalidate anything downstream. Unison established that idea by replacing the medium — code in a database, edited through a codebase manager. rigid derives the same identity from ordinary text files, and adds one thing Unison has no need for: dispatch sets you declare, so the call graph stays exact even where a runtime string picks the target.

Research prototype. Rust toolchain, no users yet, 48 verified demo steps.

triage.rg
fn severity(impact, urgency) {
  return impact * urgency;
}

fn route_net(rec) {
  return "net:" + str(severity(rec.i, rec.u));
}

fn route_db(rec) {
  return "db:" + str(severity(rec.i, rec.u));
}

// keyed by a string; the target set is declared
table routes {
  "network"  -> route_net,
  "database" -> route_db,
}

fn handle(kind, i, u) {
  return routes[kind]({ i: i, u: u });
}

fn test_severity() {
  return severity(3, 3) == 9;
}

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;
}
rename it
change what it computes
as first written
sha256:51e00ffda76426bb01d99f0fe5d5bb3ebedab8711a3be637ea54326def588b7f
now

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.

flow.rg
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.

legacy.js — ordinary platform-script style
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.

rigid describe — the same registry, enumerated
{
  "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.

A — convoluted JavaScript~2/5
B — rigid source4/5
C — rigid source + spec5/5

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.

1
median targets per site
5
90th percentile
88.8%
have four or fewer
3.0%
exceed ten
≤ 4 targets 88.8% 5–10 8.2% > 10 3.0%

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.

shell
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:

shell
$ ./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.

  • No users. One author wrote it, with AI assistance. Nobody else has built anything in it, and no reviewer has tried to break the design.
  • No type system. Argument counts are checked at every call form, and a literal argument is checked against the parameter's inferred value domain (E0212) — enough to reject severity("high", 5), nowhere near what a type checker does. Everything else is deferred while the identity model is being validated.
  • Calling a closure is quarantined. fn(x) { … } exists and captures — each lambda is lifted to a top-level definition with its own hash, which rdeps reaches — but the call site still has to name its possible targets with dyn. A generic higher-order function cannot yet live in a shared library for that reason.
  • Foreign functions are declared one at a time. extern fn puts a host function in the graph with checked arity and propagated effects. There is no npm-scale binding story, no async, no promises.
  • The study covers five projects in one language. Pre-registered and independently replicated, but not a large-corpus result.
prior art

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.