v0.2.4
Most of this release is one question asked properly: what does a compiled
program do with memory. The answer was that building a list of 256 allocated
129 times what the answer was worth, and that a list of 1024 could not be
built at all, because every turn of a walk copied the accumulator to append
one item. Nothing about that was visible from a clock, which is why it went
unnoticed for four releases: what makes it visible is counting instructions
and bytes rather than timing them, and a count is the same on every machine
and can therefore be held to a ratchet.
Two things came out of the counting. A handler's state was allocated where a
handler was installed and never given back, so a program that installs one in
a loop grew without bound; it goes on the frame stack now and installing one
is free. And a walk whose accumulator is only ever pushed onto builds one
list, which is not reuse analysis and does not stand in for it: it asks
nothing about whether a value is unshared, it arranges for there to be nothing
to share. That works because for is the only loop and a for is a fold, so
the intermediate lists exist only as values of one name.
The rest is notation. 1/2 + 1/3 is written the way arithmetic is written,
Int.max is a number rather than nineteen digits, and an effect takes row
variables, which is what a scheduler needed before it could be shipped.
Programs that used to compile and no longer do
- None yet.
Language
- An operator can be bound to a function a module declares:
operator + = added
says that+, written between two values of the type that function takes,
means it.+,-and*, and nothing else: an operator answers with the
type it was handed, and/is partial, which this language spells with a
Result. A binding rather than a definition, so the function keeps its name
and can still be passed. The binding travels with the type, so a module that
importsRatioimports what+means on one.std/ratiobinds all three,
and1/2 + 1/3is now written the way arithmetic is written rather than as
added(half, third). Ordering is deliberately left out:<on a user type
meetssort, which is the trait question rather than a question about
notation. Int.maxandInt.minare numbers.Intis a signed 64-bit integer and a
program had no way to say so: awhereclause keeping a sum inside the type
had to carry nineteen digits, and the smallestInthad to be written
0 - 9223372036854775807 - 1because negation is an operator rather than
part of a literal. Both are folded where they are written, so a clause
bounded by one is settled at check time rather than guarded.- An effect takes row variables:
effect Task<uses r>. They are in scope in
its operation signatures and in the state of any handler implementing it, so
a handler can hold aList<Fn() uses r -> ()>, and each call to an operation
fills the variable in from the value it was passed. A program that forks a
task which logs is charged with logging. Before this a scheduler's queue had
to name every effect its tasks might perform, which only the program knows,
so a scheduler could be written and not shipped. - A function value written into a handler's state at a
withis charged to
whoever wrote it there. The state is the one way into a handler that does
not go through an operation, sowith RoundRobin { queue: [noisy] }used to
put a task in that nobody answered for. - A record pattern binds what it names.
err(OverLimit { limit })bound
nothing whenOverLimitwas a record rather than a variant of a choice, and
the name it did not bind was reported missing where it was used rather than
where it was written. The same pattern on a variant has always worked, and
the two are one shape.
Diagnostics
- A digit run one past the largest
Intsays what to write instead. It is the
number somebody reaches for when they want the smallest one, and the answer
isInt.minrather than digits. DEED4031, which used to answerInt.max
with the digits and ask for them to be written out, is gone: the name is a
value now. - A refinement that fails at runtime says which refinement in the sentence and
which value in the label under the source line, rather than putting the value
in the sentence. Both engines stop on one now and only one of them can write
an arbitrary value into a sentence, so the value in it would have been a
second dialect of the same failure. - An outcome written on a
whereclause gets DEED2023 and the condition under
it is still read.where ok => n + n <= 10is what somebody writes after
reading theensuresbeside it, and it used to end the contract at the=>,
so the answer was a block that was expected and, separately, thatokis a
builtin rather than a value. - A number or a string the lexer cannot read is one message rather than two.
It used to hand the parser an invalid token, which then said an expression
was expected in the same column the lexer had just written in. What was
written stands in for it instead, the way a decimal point already did: the
digits before the bad one, the largestIntfor a number too big, and what
was read before the line ended for a string with no closing quote. a and banda or bare read as&&and||, with DEED2020 naming the
operator anddeed fixwriting it. The words are ordinary names here, so a
whereclause holding one used to stop at the word and answer with the block
that did not follow it. The resolver has had the answer since #213 and never
got to give it: across eighteen recorded model runs the word reached the
parser seventeen times and the resolver none.- A function body written without braces gets DEED2021, which says a body is a
block and offers the braces, and the body is read to the next declaration so
the function is still a function. It used to be a message about a brace, then
a message about the first line of the body not being a declaration, then a
return type that matched nothing. Thirty-two of the six hundred programs in
the recorded model runs were written this way. - A contract written before the return type gets DEED2022, and the return type
is read where it was written.fn f(n: Int) where n > 0 -> Intused to put
the arrow where the body should have begun, so the message was about a brace
and the function went on to have no return type and a body that did not match
the one it was missing. Int.maxandInt.minget DEED4031, which names the number and writes it.
The answer used to be thatIntis a type and not a value, which is true and
is not the question. Ten of the recorded model turns reached for one of the
two, all of them writing awhereclause that had to keep a sum inside the
type.
Standard library
std/taskships: a cooperative scheduler withTask.fork,Task.more,
Task.step, andrunandrun_up_toover them. Tasks are function values
and run to completion in the order they were forked; a task that wants to
leave room for another forks the rest of itself. There are no resumptions,
so nothing suspends in the middle.examples/tasks.deeduses it and
examples/scheduler.deedis the same scheduler written by hand, kept for
the comparison.
Tools
- The compiled backend runs the whole shipped library. It ran 59 of the 91
tests those modules carry, andstd/mapwas twenty of which two ran. A
module's generic functions are lowered once per set of type arguments, so a
module full of them builds cleanly with no generic body ever put through the
backend at all, and "the backend compiles the corpus" said nothing about
them.crates/deed-driver/tests/shipped.rsruns every one now, and
examples/tree.deedjoined the corpus files whose test blocks run compiled. - Two copies of a generic function that differ only in the order of their type
arguments are two copies. The name a copy was cached under sorted them, so
keys<Int, Str>andkeys<Str, Int>were one entry and the second call
reached the first call's body. - A
usethat asks for a function gets the types in its signature too.
use std/table.{set}is the whole of what a program writes, andsethands
back aTable<K, V>over anEntry<K, V>; neither name appears on the
importing side, so the backend refused the program over a type it had never
been told about. - A function from another module can be named as a value. The keyed libraries
take a comparator and the one a program passes is one of theirs, so
insert(m, k, v, cmp_string)was refused. - A type parameter no argument says anything about, and a variant of a generic
choice written where nothing says which one, stand in as numbers.
holds([], key)over aTable<K, V>has no example ofV, andEmpty
carries none of either half of aMap<K, V>; both still need a layout for a
value that holds nothing. - A closure whose body lifts anything points at itself. The compiled backend
reserved the closure's place before lowering its body, and a body that
lifted a function of its own, another closure, a copy of a generic function,
a wrapper for a function named as a value, took that place first. So
|| Task.fork(step)compiled to a value pointing at the wrapper forstep,
and calling it ran the wrong code with no diagnostic anywhere. - A handler declared in another module is lowered against that module's
tables. Installing one only needs the declaration, so awithnaming an
imported handler got that far and then read its operation bodies with the
wrong resolutions, where every name resolved to nothing. - A call inside an imported module reaches the function it names. A
DefIdis
an index into one module's table, and the table of functions in the module
being compiled was consulted for definitions from anywhere, so a recursive
function in a library reached whatever happened to have its number in the
program that imported it. deed fixchecks a file with the modules it imports beside it. On its own,
a call into another module has no row, so a function performing an effect
only through such a call looked like one declaring an effect it never
performs, and the fix on offer was to delete the row.deed buildcompiles every program in the corpus. It compiled seven of the
thirty-five when #877 was opened; the rest were refused for a dozen separate
reasons and each one is closed. Whatdeed runinterprets and what
deed buildproduces are the same language now, which is the difference
between a program you can write and a program you can hand to somebody.- Two values that live in memory compare by what they hold. Equality is
structural in this language and two addresses being equal is not two records
being equal, so the backend refused rather than answering the wrong
question. It writes a comparison per shape now: a record knows its fields, a
choice knows its variants, a list knows what it holds, and a shape holding
another calls that shape's comparison. - A handler whose first operation lifts a function of its own dispatches the
rest of its operations to the right bodies. Each operation was told where it
would be before any of them was lowered, and a body that added a function
after itself moved every operation after it, soQueue.more()reached
whatever the operation before it had lifted. - A field with no representation is not read.
ok(nothing)on a call that
answers with()bound a name to a word that was never a value, and left it
behind on the stack. - An empty list's element stands in as a number rather than as
(). A()
takes no room, so a walk over a list of them counted elements of nothing and
the function disagreed with itself about how much of the stack it had. - A pattern that reaches one level in compiles.
err(OverLimit { limit: reached })names a field of the record the failure
holds, and what it reads is the same read twice over. - A generic function whose type parameter appears only inside a type somebody
declared, or only inside an alias for one, compiles.Option<Int>and
Option<String>become two layouts and neither says what it holds, so what
the parameter stood for cannot be read off the value the way it can off a
List. What the checker recorded says it, and an alias is followed to
whatever it is written over. - A type a module borrowed brings whatever it is written over with it.
use std/table.{Table}is enough to needEntry, which nobody writes down
on the borrowing side and the backend had no way to find. - A
forwhose accumulator only the surrounding type says the shape of is
checked against that shape.with seen = Nothingsays nothing about what
the choice holds, and thewhileabove the body reads the accumulator
before anything has settled it, so every read of it was a value the compiler
knew nothing about and the backend could not compile. - A record, a choice, an alias, an effect or a handler declared in one module
and used in another compiles. A type crosses a boundary the same way a
function does, and what it comes out as is what the module that declared it
built, so a value of one record fits everywhere it is named rather than
fitting one layout in one file and another elsewhere. - An
ifor amatchwritten where a type is expected of it compiles.
The checker works out what one comes to and did not write it down, because
the pass that records a type per expression is the one that infers rather
than the one that checks against something. Five more corpus programs
compile, and every one of them was amatcharm or a branch whose value was
a branch. deed buildcompiles a call into another module. The lowering was handed one
file and the interpreter was handed all of them, so anything with ausein
it was refused, which is most programs that do real work. What is lowered is
what is reached: a module that ships thirty functions and is imported for one
contributes one, along with whatever that one calls, and a contract on the
other side is checked here because the call that could break it was answered
for here.- A declared function written where a value belongs compiles.
map(step, xs)
withstepa function rather than a closure used to be refused, because a
call through a value passes an environment and a call by name does not, so
the value points at a wrapper that takes the environment and calls the real
one. The contract stays on the function it belongs to. performcompiles in a module that installs no handler for the effect. The
shape of the call was interned from whatever bodies happened to be nearby,
and a handler installed only inside a test block is not one of them, so a
program that performed an effect it did not also handle was refused.deed buildcompiles?. A function that propagates a failure used to be
refused with "this expression is not lowered yet", which is four of the
thirty-four programs in the corpus and most of the ones that do any real
work withResult.- A refinement the checker could not prove is checked in the compiled program
as well as the interpreted one.deed checksaid "so it becomes a runtime
check" anddeed buildemitted nothing, so a value that violated its own
type went through. Both engines stop on the same code, in the same place,
with the same sentence. - A
whereclause anassert refusesis aiming at keeps its runtime check in
the compiled program. The check is dropped when every recorded call proved
the clause, and a call written to break it was recorded nowhere, so the one
caller that needed the check was the one caller nothing knew about. - The compiled backend has all thirteen prelude functions.
at,push,
repeat,split,join,trim,upper,lower,to_stringandto_int
used to exist only in the interpreter, so a program that called one ran under
deed runand refused underdeed build. Each is now written in
WebAssembly directly and emitted only when reached. Thirteen of the
thirty-four programs in the corpus compile, up from seven, and every shape
that started compiling is answered the same way by both engines first. - The compiled backend joins two strings, compares them and orders them.
deed buildused to refuse any program that put two pieces of text together
or asked which came first, which is most programs that touch text at all.
These are functions the backend writes into the module and calls, numbered
after everything the program declares, and only the ones a program reaches
are emitted. deed checkno longer panics on a function whose parameter list never
closes. A declaration used to end at the token sitting where its closing
token should have been, so a signature could reach past the start of its own
body, anddeed fixbuilds the region ausesclause goes in by subtracting
one from the other. Every construct now ends where the parser actually read
to. Found by the nightly fuzzer.- The scheduled fuzz run reports what it finds as an issue rather than as a
pull request. Opening a pull request from a workflow needs a repository
setting that also lets workflows approve pull requests, and the first run
that found anything got told so and said nothing to anybody. The branch is
pushed either way, so the issue names it.
Measurements
- A walk that only pushes builds one list.
foris the only loop and afor
is a fold, so the lists a walk builds on the way exist only as values of its
accumulator: when every mention of that name ispush's first argument or a
branch handing it on, and the value of every path is the accumulator or one
pushonto it, nothing can reach an intermediate list and the walk builds a
single one at the length of the list it walks. Building a list of 256 used to
allocate 129 times what the answer is worth and now allocates the answer, and
at 1024 the answer was always eight kilobytes while building it exhausted a
megabyte, so what changed there is not that it got cheaper but that it runs.
This is not reuse analysis and does not stand in for it: it asks nothing
about whether a value is unshared, it arranges for there to be nothing to
share.std/tableis untouched, because its cost is across calls toset
rather than inside a walk. - Most of what a compiled program wastes is one shape.
foris the only loop
and aforis a fold, so the lists a walk builds on the way exist only as
values of its accumulator, and whether any of them can be observed is a
question about what the body does with that one name. Counted over the
shipped library and the corpus: 44 walks mention their accumulator only as
push's first argument or as the value of a branch handing it on, against
34 of every other shape. Those 44 have nothing holding an intermediate list,
so there is no reason for them to be separate lists.
design/decisions/2026-08-04-a-walk-that-only-pushes.mdproposes what to do
about it; nothing is written yet. - Installing a handler is free. A handler's state is reserved from the frame
stack now rather than the value heap, so it is given back when the block
ends the way the frame already was, and a walk that installs one every turn
allocates exactly what the same walk without one does. What made it safe is
the rule that made the frame safe: nothing in a program can hold the state
itself, sinceDEED4030refuses a closure over it and an operation hands
back the value in a field rather than the block holding it. - A walk over numbers allocates a word a turn on its own, and nothing in it is
a value that lives in memory. Found while measuring the line above, which
had been credited to the handler. - What a compiled program allocates is what its memory reached, because
nothing gives any of it back except a handler frame. So the number worth
having is not the total but how much of the total is still worth anything,
and building a list by folding allocates the whole answer once per element:
129 times over at 256, and at 1024 the answer is eight kilobytes and
building it exhausts a megabyte. Every one of those copies dies the moment
the next is made and nothing else points at it, which is the case reuse
analysis answers and a collector would only clean up afterwards. It also
answers the first open question of the reclamation decision, which asked
what workload would make the limit unacceptable: a keyed structure of a few
hundred entries. - The tree-versus-table crossover, compiled. The decision in
design/decisions/2026-07-31-tree-vs-table-decision.mdwas measured on the
interpreter and predicted that a compiled backend would move the crossover
toward smaller N without reversing it. Both halves held:std/mapis ahead
ofstd/tablefrom sixteen keys for lookup and sixty-four for insert, where
interpreted it took a few hundred, and neither growth rate changed. The
compiled half is counted in instructions rather than seconds, because what
runs a module here is an interpreter over the instructions the compiler
emits: its clock is a fact about that runner, its instruction count is a
fact about the compiled program, and the second one is the same on every
machine. - A compiled program cannot build a thousand-key structure. It gets one
megabyte and a handler frame is the only thing it gives back, so the list
runs out of memory copying itself and the tree runs out two hundred inserts
later. Above a few hundred keys the module a program picks is not what stops
it; value reclamation is.