- TypeScript 94%
- JavaScript 6%
behavior-lang #
Proof of concept. This is an experiment in how code could be identified, shared and run. It is not a production language: there's no compiled backend, no persistent store, and no global registry yet. See Status.
behavior-lang is a small, total, purely functional language where a function's identity is a hash of what it does, not of its name, its source text, or a version number.
fee:n < 0 = 0
fee:n in 0..99 = 5
fee:n = n / 20
$ bhv cells fee.bhv
fee id 31de27be02
n ∈ [-∞..-1] → 0
n ∈ [0..99] → 5
n ∈ [100..∞] → n / 20
That table is the function: every region of input it accepts, and the
normalized expression it returns there. The id is the root of a Merkle Search
Tree over those rows. Write fee a different way (reorder clauses, rename
variables, call a helper, write n + n instead of 2 * n) and if it does the
same thing, it gets the same id.
Inspiration: Unison #
Unison showed what happens when code is content-addressed. A definition is identified by a hash of its syntax tree, and names are just metadata pointing at hashes. That gets you:
- no builds: a definition is compiled once, keyed by its hash, and never again;
- no dependency conflicts between definitions: two versions of a function are simply two different hashes, and both can exist side by side;
- renames that are free and never break anything;
- code you can ship to another machine by hash, letting it fetch exactly the definitions it's missing.
behavior-lang asks the next question: what if the hash were of the behavior, not the syntax?
Unison's hash says "this exact code". Two definitions that compute the same thing but are written differently get different hashes, and a function whose dependency changed, even somewhere it never uses, gets a new hash too. That makes "can I use the newer version?" a question for humans and changelogs.
Here the hash says "this exact behavior, on this exact set of inputs":
- Refactors are invisible. Rewriting a function without changing what it does keeps its id.
- Dependencies are recorded as behavior, not versions. When
leftcallscore.fee:(n * 10)withnin 0..9, the compiler records thatleftrelies onfeefor inputs 0..90, plus the hash of whatfeedoes there. Not "core 1.x". - Change only matters where someone can see it. A caller's id depends only
on the part of a callee it can actually reach. Fix a bug in
feefor large inputs, and every caller that only uses small inputs keeps its id.
Why it matters: run code by behavior #
Picture a global registry where every function is stored under its behavioral id, the way Unison stores definitions by syntax hash.
Running code by behavior. You don't ask for core@2.3.1. You ask for "the
function with behavior 31de27be02", and any implementation with that id is,
by construction, interchangeable. The registry can hand you whichever copy is
nearest, fastest, or already cached. A runtime that already holds a function
with that id doesn't need to fetch anything.
Asking for exactly what you need. A caller doesn't even need the whole
function. It needs the function restricted to the inputs it uses, which is
itself a content-addressed function with its own id. "Give me anything that
behaves like fee on 0..90" is a lookup, not a negotiation.
Diamond dependencies, solved by checking rather than guessing. When left
was built against core@1 and right against core@2, the resolver doesn't
look at version numbers. It checks which implementations behave identically
on every input each of them can actually send:
$ bhv resolve examples/synth/app@1.0.0.bhv
core@2.0.0: no
✗ left@1.0.0 needs core.fee on input ∈ [0..90]: 1.0.0 gives 5, 2.0.0 gives 4
core@1.0.0: no
✗ right@1.0.0 needs core.fee on input ∈ [100..∞]: 2.0.0 gives input / 10, 1.0.0 gives input / 20
No published version fits; synthesizing core@1.0.0+2.0.0:
fee input ∈ [0..90] ← core@1.0.0 (for left)
input ∈ [100..∞] ← core@2.0.0 (for right)
Verification (recompile each dependent against it; ids must not change):
✓ left@1.0.0 ✓ right@1.0.0
- When a published version fits, it's chosen, and the choice is proven: the dependents are recompiled against it and their ids don't move.
- When none fits, a version is stitched together from the regions each dependent relies on. In a registry keyed by behavior, those pieces already exist.
- When two dependents truly need different answers for the same input, that's reported as a conflict, with the exact inputs. It's a real incompatibility, not a version-range accident.
Upgrades become safe to automate. "Is v2 compatible with my code?" stops being a judgment call. It is a comparison of two hashes over the region you use.
Caching and memoization across everyone. A function's id fully determines
its output for every input, and there are no side effects (effects are
returned as values), so (id, input) → output can be cached globally, by
anyone, forever.
Extending without breaking. A package can add cases to someone else's
function, e.g. teach map about its Promise type. Extensions may only add
inputs the function didn't handle before, so they can't change any existing
caller's behavior or id.
The language, briefly #
area:(Circle:r) = 3 * r * r -- f:x calls f with x
area:(Rect:{w, h}) = w * h -- clauses split by input shape
fact:0 = 1
fact:n > 0 = n * fact:(n - 1) -- recursion must provably terminate
map:f = ([] => []) -- map:f returns a function (map:f:xs)
| ([x, ...rest] => [f:x, ...map:f:rest])
| ({| x, ...rest |} => {| f:x, ...map:f:rest |})
main:_ = [Print:(show:(map:(n => n * 2):[1, 2, 3]))]
- One input, one output. Tuples
f:(a, b), recordsf:{w: 1}, lists, bags (unordered) and sets are all just argument shapes. - No
ifand no loops. Branching happens through clause patterns, iteration through recursion ormap/filter/fold. - No types and no signatures. The cell table is the signature. A function's domain is inferred from its patterns and from what its body needs, and calling outside it is a compile error.
- Total and pure. Recursion must provably terminate, and effects are
values that
mainreturns.
DESIGN.md covers the language and how the hashing works in detail.
Try it #
Requires Node 24+ (it runs the TypeScript sources directly).
npm install
node src/cli.ts cells examples/basics/basics.bhv fee # a function's cells (its signature)
node src/cli.ts check examples/basics/basics.bhv # every function's id and cells
node src/cli.ts diff examples/refactor/v1.bhv examples/refactor/v2.bhv # rewrites, same ids
node src/cli.ts run examples/basics/basics.bhv
node src/cli.ts run examples/extend/app@1.0.0.bhv # map extended to promises
node src/cli.ts resolve examples/diamond/app@1.0.0.bhv # a newer version fits
node src/cli.ts resolve examples/synth/app@1.0.0.bhv # one is synthesized
node src/cli.ts resolve examples/conflict/app@1.0.0.bhv # a real conflict
npm test
npm run typecheck
Status #
This is a proof of concept. What exists:
- a compiler from source to canonical cells, decision diagrams and MSTs;
- an interpreter that runs functions straight from that form, by id;
- diamond resolution (choose, synthesize, or report a conflict, then verify);
- function extensions;
- a prelude written in the language.
What doesn't exist yet:
- A global registry. Packages live in local directories; the "run code by behavior" story above is the goal this POC is testing the foundations for.
- A persistent object store. Everything is recompiled from source on each run.
- A compiled backend (JS or WASM per function id).
- Mutual recursion, and richer termination proofs.
Fundamental limits:
- Equivalence detection is partial. Deciding whether two programs behave the same is undecidable in general. Equivalence is detected up to a normal form (canonical arithmetic, inlining, partition merging). A missed equivalence makes resolution stricter, never wrong.
- Some checks happen at runtime. Without types, some whole-structure requirements (e.g. "every element is an Int") are checked when the program runs rather than at compile time.
Open issues are tracked with beads
(bd list).