README.md

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 left calls core.fee:(n * 10) with n in 0..9, the compiler records that left relies on fee for inputs 0..90, plus the hash of what fee does 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 fee for 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), records f:{w: 1}, lists, bags (unordered) and sets are all just argument shapes.
  • No if and no loops. Branching happens through clause patterns, iteration through recursion or map/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 main returns.

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).