Skip to content

v0.2.4

Choose a tag to compare

@github-actions github-actions released this 04 Aug 01:11
d9127cc

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
    imports Ratio imports what + means on one. std/ratio binds all three,
    and 1/2 + 1/3 is now written the way arithmetic is written rather than as
    added(half, third). Ordering is deliberately left out: < on a user type
    meets sort, which is the trait question rather than a question about
    notation.
  • Int.max and Int.min are numbers. Int is a signed 64-bit integer and a
    program had no way to say so: a where clause keeping a sum inside the type
    had to carry nineteen digits, and the smallest Int had to be written
    0 - 9223372036854775807 - 1 because 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 a List<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 with is charged to
    whoever wrote it there. The state is the one way into a handler that does
    not go through an operation, so with 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 when OverLimit was 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 Int says what to write instead. It is the
    number somebody reaches for when they want the smallest one, and the answer
    is Int.min rather than digits. DEED4031, which used to answer Int.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 where clause gets DEED2023 and the condition under
    it is still read. where ok => n + n <= 10 is what somebody writes after
    reading the ensures beside it, and it used to end the contract at the =>,
    so the answer was a block that was expected and, separately, that ok is 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 largest Int for a number too big, and what
    was read before the line ended for a string with no closing quote.
  • a and b and a or b are read as && and ||, with DEED2020 naming the
    operator and deed fix writing it. The words are ordinary names here, so a
    where clause 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 -> Int used 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.max and Int.min get DEED4031, which names the number and writes it.
    The answer used to be that Int is 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 a where clause that had to keep a sum inside the
    type.

Standard library

  • std/task ships: a cooperative scheduler with Task.fork, Task.more,
    Task.step, and run and run_up_to over 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.deed uses it and
    examples/scheduler.deed is 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, and std/map was 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.rs runs every one now, and
    examples/tree.deed joined 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> and keys<Str, Int> were one entry and the second call
    reached the first call's body.
  • A use that asks for a function gets the types in its signature too.
    use std/table.{set} is the whole of what a program writes, and set hands
    back a Table<K, V> over an Entry<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 a Table<K, V> has no example of V, and Empty
    carries none of either half of a Map<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 for step,
    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 a with naming 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 DefId is
    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 fix checks 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 build compiles 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. What deed run interprets and what
    deed build produces 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, so Queue.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 need Entry, which nobody writes down
    on the borrowing side and the backend had no way to find.
  • A for whose accumulator only the surrounding type says the shape of is
    checked against that shape. with seen = Nothing says nothing about what
    the choice holds, and the while above 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 if or a match written 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 a match arm or a branch whose value was
    a branch.
  • deed build compiles a call into another module. The lowering was handed one
    file and the interpreter was handed all of them, so anything with a use in
    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)
    with step a 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.
  • perform compiles 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 build compiles ?. 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 with Result.
  • A refinement the checker could not prove is checked in the compiled program
    as well as the interpreted one. deed check said "so it becomes a runtime
    check" and deed build emitted 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 where clause an assert refuses is 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_string and to_int
    used to exist only in the interpreter, so a program that called one ran under
    deed run and refused under deed 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 build used 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 check no 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, and deed fix builds the region a uses clause 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. for is the only loop and a for
    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 is push's first argument or a
    branch handing it on, and the value of every path is the accumulator or one
    push onto 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/table is untouched, because its cost is across calls to set
    rather than inside a walk.
  • Most of what a compiled program wastes is one shape. for is the only loop
    and a for is 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.md proposes 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, since DEED4030 refuses 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.md was measured on the
    interpreter and predicted that a compiled backend would move the crossover
    toward smaller N without reversing it. Both halves held: std/map is ahead
    of std/table from 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.