Phoenix

Phoenix  /  Changelog

Changelog

What has shipped, newest first, for somebody who is not reading the source.

The other records are internal and answer different questions: journal.md is the day-by-day narrative and what each stage got wrong first, postmortem.md scores the predictions, COMPLETED.md is the standing inventory of what exists, and ROADMAP.md is what does not. This page is the one that says when.

Phoenix versions by stage, tagged stage-0 … stage-7. Work after stage 7 was not planned as stages; it is dated instead. There is no release cadence and no compatibility promise yet — the notation is still being decided, and every entry below that changes it says so.


2026-10-11: a default reading a field where an attribute was handed down

phx warns when an otherwise clause reads $x in a pass that hands x down, and some node it runs at has a field x. A field is read before an attribute, so at that node the default reads the field, and what was handed down never arrives. A clause in a rule for one node type is not warned about, since there the field is usually the one meant. No description in the repository is warned about.

Tests: 752 → 753.


2026-10-11: make rebuilds after an edit in the same second

make no longer skips a rebuild because a source and bin/phx share a timestamp. The make macOS ships compares seconds, so an edit saved in the second the binary was linked left the old binary in place. The Makefile now compares a checksum of the sources with the one the binaries were built from, and rebuilds when they differ. A make with nothing changed still does nothing.

Tests: 752 → 752, unchanged.


2026-10-10: units initialised in fpc’s order, and const kept

languages/units/ runs initialisation sections in fpc’s order, a depth-first walk from the program through each unit’s interface uses and then its implementation’s, each unit entered once. It ran them in the order they are written. Its divergent/ directory is empty: both of the things it got wrong are answered.

phx builds with -Wcast-qual, and so does every compiler it writes. The names and texts the runtime only reads are declared const, and the casts that hid the difference are gone.

Tests: 752 → 752. The units harness holds thirteen checks, and the written-out compilers are compiled with -Werror=cast-qual.


2026-10-10: a circular unit reference at any depth

languages/units/ refuses A -> B -> C -> A, a cycle through interface uses of any length, where it caught only two units using each other. A reach pass, run until what each unit reaches settles, does the closing; the message names the unit and the unit it uses in fpc’s order, circular unit reference between ub3 and ua3. A description over 63 units is refused, since each unit is one bit of what another reaches.

A failed check is reported once when ... meets it. [...x] of a failure said ... wants a list, a second line naming the description under a correct refusal of the program.

Tests: 752 → 752. The units harness holds ten checks where it held eight, and is reported as one.


2026-10-10: make test in half the time

The suite’s harnesses run at the same time, each beside the rest of the suite, rather than one after another. On a twelve-core machine a run took 450 seconds before and 236 after, with the same 752 checks printing the same lines in the same order. PHX_SERIAL=1 make test runs them one at a time, as before.

Tests: 752 → 752, unchanged.


2026-10-10: a written-out compiler is C11

phx -o writes a compiler that is C11 to the letter. A table the description left empty, its rewrites or embeds, say, was written as = {}, which is C23’s, and an array of no elements, which is an extension. Plain cc took both without a word, so every compiler written out compiled, and none of them was C11: each failed cc -std=c11 -pedantic-errors. An empty table now gets one zeroed row, which nothing reads.

Tests: 750 → 752, compiling two written-out compilers under -std=c11 -pedantic-errors.


2026-10-10: find in the library

find(text, part) answers where part first begins in text, counting from one, or 0 when it is not there, so find(t, w) > 0 asks whether t contains w. The descriptions here asked that by splitting and counting, 58 times, and now ask it this way.

Tests: 750 → 750, with four claims and one refusal added to the library’s checks.


2026-10-10: calls checked when a description is read

A call to a function that does not exist, or with the wrong number of arguments, is refused when the description is read, where it used to be found only when a node reached it. A typo in a clause no test input reached could sit in a description that read cleanly.

Tests: 749 → 750.


2026-10-10: %define, named computations

A description can name a computation and call it: %define twice(n) = $n * 2 . and then twice($x) in any clause. The body sees only its parameters, so it means the same thing wherever it is called; a define may call another but not itself. This adds to the notation, and %define is a new directive.

A template’s {} count is now checked when the description is read, as the specification always said, and not only when a node reached it.

The C description uses six defines and is about a sixth smaller, 480 KB to 402 KB; every C program in the oracle compiles to the same bytes.

Tests: 743 → 749. The new checks cover what a define is, in phx and in a compiler it wrote, every refusal, a read inside a define seen by the driver check, the library’s names, and the template count.


2026-10-10: repeat in the library

repeat(x, n) makes text, or a list’s elements, n times over. It replaces a thirteen-row table the C description had pasted 65 times, about a tenth of the file.

C pointers of thirteen or more levels now compile. The table stopped at twelve, and a thirteenth level was refused with a wrong message. Every other C program in the oracle compiles to the same bytes as before.

Tests: 743 → 743. Four claims and one refusal join the library’s checks, and one program joins the C oracle: 355 agree with cc.


2026-10-10: if in the notation

A clause can choose with if c then a else b, which works out only the arm it takes, so if $n = 0 then 0 else 100 div $n never divides by zero. The condition must be a boolean, and the else is required. This changes the notation: if, then and else now begin and end arms of an expression, though they stay usable as field names.

Every lookup([[true, a]], c, b) in the descriptions here, 1,072 of them, 876 in the C description alone, is now an if, and every C program the oracle runs compiles to the same bytes as before.

Tests: 743 → 743, with four claims and one refusal added to the specification’s own checks.


2026-10-06: the C compiler on hold

languages/c/ gains nothing more for now. The workspace’s C compiler is to be written by hand in C in DevTools, and the one Phoenix generates stops where it stands: what it compiles, it still compiles, and its oracle stays in make test, but no construct it refuses today will be lifted, and the ELF target for Ouroboros will not be built. Phoenix itself is meant to run on Ouroboros one day instead, with no date. ROADMAP section 6 says what the hold keeps.

Tests: 743 → 743, unchanged.


2026-10-05: C functions declared in a block have the block’s scope

A function declared in a block is known only to the block’s end, C11 6.2.1p4, where it was known to the end of the file: a call of it or its name after the block is refused, as cc refuses it, until it is declared again at file scope. And it hides a local of its name to the block’s end, by a declarator, int f(void);, or by a typedef of its type, binop add;, where both were refused by name.

Tests: 740 → 743. Two refusals go and five join. One program joins the C oracle: 354 agree with cc, none diverges. Refusals: 501.


2026-10-05: C a name in its own initialiser, with a * in parentheses

int (*f)(void) = sizeof f == 8 ? g : 0; is compiled, as C11 6.2.1p7 puts f in scope from the end of its declarator, where it was refused by name: in a local, a static local and a global, a pointer to a function or to an array. An array whose initialiser is in braces is the one form still refused.

Tests: 741 → 740. One refusal goes. One program joins the C oracle: 353 agree with cc, none diverges. Refusals: 498.


2026-10-05: C * in parentheses inside another’s

int (*(*p)) declares p as int (**p) does, C11 6.7.6, where it was a syntax error: in a global, a local, a static, a member and a typedef, around a pointer to a function and to an array, any number deep. A ) too many or too few after one is refused with a reason saying which. One with brackets or a function inside the inner parentheses, int (*(*p)[3]), is still a syntax error, and so is either kind in a parameter, which does not take a name in parentheses either.

Tests: 738 → 741. Three refusals. One program joins the C oracle: 352 agree with cc, none diverges. Refusals: 499.


2026-10-04: C variadic functions defined

A function ending in ... can read its arguments, through the four builtins 's macros expand to in `cc`'s headers: `__builtin_va_list` is a `char *`, `__builtin_va_start` points it at the first argument after the `...`, `__builtin_va_arg` reads one of an `int`, a `long`, an unsigned type or a pointer and moves past it, `__builtin_va_end` does nothing, and `__builtin_va_copy` copies one. A `va_list` can be handed to `vsnprintf` and the rest of libc's `v` functions. `va_start` outside a function with `...`, or naming another parameter than the last, is refused, and so is `va_arg` of a type that promotion changes, of a struct, or of `void`. Proem's `lib/expr.c` is accepted whole: nine of its ten files.

Tests: 729 → 738. Nine refusals. One program joins the C oracle: 351 agree with cc, none diverges. Refusals: 496.


2026-10-04: C ?: between two pointers is const where either is

*(c ? p : q) = 1 with q a pointer to a const int is refused, as C11 6.5.15p6 has it and cc refuses it, where it was accepted when q was the second arm: the answer of ?: between two pointers to one type is const at each level either arm is, where it had been the first arm’s. A write under the const level is still compiled.

Tests: 727 → 729. Two refusals. One program joins the C oracle: 350 agree with cc, none diverges. Refusals: 487.


2026-10-04: C functions declared in a block, and parameters of a function’s type

int f(void); in a block declares f, and so do int x, f(void); and binop add; with binop a typedef of a function’s type, which were a syntax error and a refusal. Each is checked against the function’s other declarations, as one at file scope is, and the locals around it keep their places. static, auto or register on one is refused, as C11 6.7.1p7 has it. A parameter of a function’s type, int apply(binop f), is a pointer to one, as 6.7.6.3p8 has it, where it was refused. A function declared in a block where a local of its name is in scope is refused by name, and one declared in a block is still known after it, which cc refuses. A member of a function’s type was refused in words saying C allows it; they now say it does not.

Tests: 713 → 727. Thirteen refusals, one more check, and one refusal gone, since the program compiles. One program joins the C oracle: 349 agree with cc, none diverges. Refusals: 485.


2026-10-04: C () in a pointer to a function is no prototype

int (*f)() points at a function of unstated parameters, as C11 6.7.6.3p14 has it, where it was read as a pointer to one taking none: a call through it is compiled with its arguments in registers, and it may be given any function returning the same type whose parameters are not char, short or _Bool and which has no ..., as 6.7.6.3p15 allows and cc takes; given anything else it is refused, as cc refuses it. The same holds of () in a typedef of a function’s type, a cast and a parameter. A function declared through such a typedef, F add;, may then be defined with parameters, and a prototype written before it still says what is taken.

Tests: 706 → 713. Seven refusals. One program joins the C oracle: 348 agree with cc, none diverges. Refusals: 472.


2026-10-04: C names in parentheses in a declarator

int (x) = 3; declares x, as C11 6.7.6p6 has it, where it was a syntax error: a name in any number of parentheses is the name, in a global, a static, a local, a member and a typedef, and inside a pointer’s parentheses, int (*(p))[2]. A name declared so is in scope in its own initialiser, as any other is. A name in its own initialiser where the declarator has a * in parentheses, int (*f)(void) = ...f..., is still refused by name.

Tests: 706 → 706; one refusal reads a different parse error. One program joins the C oracle: 347 agree with cc, none diverges. Refusals: 465.


2026-10-04: C integers cast to pointers in a global’s initialiser

int *p = (void *)0; compiles as a global or a static, where it was refused as not worked out before the program runs; a plain 0 had been taken. Any integer constant cast to a pointer is now an address constant, as C11 6.6p9 has it, so char *c = (char *)0; and int *t[2] = { (void *)0, (int *)16 }; are written into the object file as the numbers they are, and a pointer to a function may be initialised with a 0 cast to void * there too. A pointer cast to an integer, or given to !, &&, || or as the condition of ?:, is still refused where a constant is wanted; cc takes those as an extension.

Tests: 701 → 706. Five refusals. One program joins the C oracle: 346 agree with cc, none diverges. Refusals: 465.


2026-10-04: C () in a declaration is no prototype

int f(); says nothing of f’s parameters, as C11 6.7.6.3p14 has it, where it had been read as int f(void): a later prototype or definition with parameters is no longer refused as a second declaration that disagrees, and a call with arguments is compiled, each in a register, as cc passes them. int puts(); puts("line"); works. A prototype written before a () still says what the function takes. In a definition, () is still no parameters, and in a pointer to a function it is still read as no parameters.

Tests: 696 → 701. Five refusals. One program joins the C oracle: 345 agree with cc, none diverges. Refusals: 460.


2026-10-04: C functions declared through a typedef of their type

typedef int binop(int, int); binop add, sub; declares two functions at file scope, as C11 6.7.6.3 has it, which can be called before they are defined and are checked against their other declarations: a different number of parameters, a different return type, or a global of the same name is refused. It had been refused by name. In a block, as a parameter and as a struct member it still is.

Tests: 686 → 696. Nine refusals. One program joins the C oracle: 344 agree with cc, none diverges. Refusals: 455.


2026-10-04: C functions declared beside other names

A function may be declared in a list with other names at file scope, int count, printf(const char *format, ...), total = 5;, in any place in the list, and is a prototype like any other: it can be called before its definition, and its declarations are checked against each other and against the globals. It had been a syntax error. In a block it still is.

Tests: 682 → 686. Four refusals. One program joins the C oracle: 343 agree with cc, none diverges. Refusals: 446.


2026-10-04: C names in their own initialisers, in braces, static and global

A name may be used in its own initialiser in braces, in a static local and in a global, as it already could in a plain local, C11 6.2.1p7: struct node x = { &x, sizeof x }, static void *p = &p;, void *g = &g;. Each had been refused by name. Still refused by name, which cc compiles: an array in braces, whose size its initialiser may decide, and a declarator in parentheses.

Tests: 685 → 682. Three refusals gone to the oracle, which one program joins: 342 agree with cc, none diverges. Refusals: 442.


2026-10-04: C enumerators from comparisons, logic and ?:, and a wrong value refused

An enumerator may be given a comparison, !, &&, || or ?: of constants: enum { LT = 1 < 2, P = LT ? 10 : 20 }. They had been refused by name. 1 ? 0 : 0 is now a null pointer constant, as C has it.

A fix for wrong code. An enumerator’s value with unsigned arithmetic in it was worked out as if every number were signed, so -1 / 2u was 0, where cc makes it 2147483647, and -2 % 3u was -2 where it is 2. Each place where the unsigned answer differs from the exact one, a negative operand or answer beside an unsigned number, or one past 2^32, is now refused by name. Where they agree, 1u << 4 and A | B, nothing changes.

Tests: 677 → 685. Eight refusals. One program joins the C oracle: 341 agree with cc, none diverges. Refusals: 445.


2026-10-04: C pointers to functions checked at a call and a return

A pointer to a function is passed and returned only as its own type, as it was already assigned and initialised: an argument or a return of another function’s type, of a pointer to an object where a pointer to a function goes, or of a function where a pointer to an object or a number goes, is refused. These had been accepted, and a call through the wrong type passed and read what that type says. cc refuses or warns on each.

(void *)0 goes into a pointer to a function, as C11 6.3.2.3p3 has it, at an assignment, a local’s initialiser, braces, an argument and a return. It had been refused at the first three as a pointer to something that is not a function. It is still refused as a global’s or a static’s initialiser, for any pointer, which is now on the roadmap.

Tests: 669 → 677. Eight refusals. One program joins the C oracle: 340 agree with cc, none diverges. Refusals: 437.


2026-10-04: C escapes past their second digit, each refused

Nothing a program can do changed. Every digit is now shown refused as the third of a hex escape, and as the third of an octal escape that starts with a 4 to 7, one to a line, in one program cc refuses line for line. Until now each of those two refusals was shown by a single example, so a digit missed would have let a string through a byte too long.

Tests: 668 → 669. One refusal: 429.


2026-10-04: C enumerators from character constants

An enumerator may be given a character constant’s value, and any constant expression made with one: enum { A = 'a', NL = '\n', SUM = 'z' - 'a' + 1 }. It had been refused by name. A character past 127, '\xff', is negative here as in cc, and makes its enumeration an int.

Tests: 669 → 668. One refusal gone to the oracle, which a program joins: 339 agree with cc, none diverges. Refusals: 428.


2026-10-03: C ?: with a void * beside another pointer

A void * and a pointer to an object may be the two arms of ?:, either way round, and the answer is a void *, as C11 6.5.15p6 has it: len <= sizeof small ? small : malloc(len). It had been refused as two kinds of pointer. It was the next stop in Proem’s lexer.c, which is now accepted whole: eight of its ten files are.

Refused, which cc only warns about: a void * beside a function or a pointer to one, and a void **, which is no void *, beside another pointer. A program reading through such an answer, *(c ? p : v), is refused because a void * has nothing to read, as cc refuses it.

Tests: 663 → 669. Six refusals. One program joins the C oracle: 338 agree with cc, none diverges. Refusals: 429.


2026-10-03: C functions returning a char

A function may return a char, signed char or unsigned char, narrowed at return as a short is, so char f(int x) { return x; } gives back the low eight bits, sign- or zero-extended as Apple’s arm64 has a callee do. It had been refused by name since functions came in. It was the first stop in Proem’s lexer.c, which now gets 364 lines further and stops at a ?: with a void * on one side.

Tests: 664 → 663. One refusal gone to the oracle. Two programs join the C oracle: 337 agree with cc, none diverges. Refusals: 423. The ABI test returns a char and an unsigned char across cc both ways.


2026-10-03: C ?: with a null pointer constant

A pointer and a null pointer constant may be the two arms of ?:, either way round, and the answer is the pointer’s type, as C11 6.5.15p6 has it: c ? "2" : NULL and has_id ? &id : NULL, with NULL written as Apple’s headers write it, ((void *)0), or as a plain 0, 1 - 1 or an enumerator worth 0. It had been refused since ?: came in, and was the first stop in two of Proem’s files, which are now accepted whole: seven of its ten are.

Refused, which cc only warns about: a number beside a pointer that is no null pointer constant, 1 or an int variable holding 0. A 0 cast to any pointer type but an unqualified void * is a pointer and not a null pointer constant, so beside a pointer to another type it is refused as before.

Tests: 659 → 664. Six refusals, one of the old ones gone to the oracle. Two programs join the C oracle: 335 agree with cc, none diverges. Refusals: 424.


2026-10-02: C names in their own initialisers

A local’s name may be used in its own initialiser, as C11 6.2.1p7 has it: struct node *n = malloc(sizeof *n);, int x = sizeof(x);, and the address of the name itself. It had been refused as undeclared since the subset began, and it is how Proem writes every allocation. Found by running Proem’s source through Phoenix, which no earlier change had done.

Refused by name, though cc compiles them: the same in an initialiser in braces, in a static local or a global, and where the declarator has parentheses.

Tests: 656 → 659. Five refusals, two of the old ones gone to the oracle. Three programs join the C oracle: 333 agree with cc, none diverges. Refusals: 419.


2026-10-02: C octal and hex escapes, and escapes closed

Octal and hex escapes work, the third and last part of ROADMAP 6.16, which closes it: in a character constant, worth the char they name, so '\377' is -1 as char is signed here, and in a string, where a \0 may now stand anywhere.

Refused, as cc refuses them: an octal or a hex escape past 255, \x with no digit, and an escape C does not have. Refused by name, though cc compiles it: a hex escape of more than two digits whose others are zeros, '\x041'.

Tests: 649 → 656. Nine refusals, two of the old ones gone to the oracle. Four programs join the C oracle: 330 agree with cc, none diverges. Refusals: 416.


2026-10-02: C string literals side by side

Two string literals or more, side by side, are one, the second part of ROADMAP 6.16, as C11 6.4.5p5 has it: anywhere a string is, in an initialiser, under sizeof, and as a _Static_assert’s message. Proem writes most of its messages that way.

Tests: 649 → 649. Four programs join the C oracle, one of them a refusal until now: 326 agree with cc, none diverges. Refusals: 409, one in and one out.


2026-10-02: C escapes \a \b \f \r \v \?

The six simple escapes C has and the subset did not, the first part of ROADMAP 6.16: each is a character constant worth its code, and each may be written in a string, whose length counts it as one byte. Proem’s lexer tests for '\v' and '\f', and its reader for '\r'.

Tests: 649 → 649. One program joins the C oracle: 323 agree with cc, none diverges. Refusals: 409.


2026-10-01: C enum, and the integer types closed

Enumerations work, the fourth and last part of ROADMAP 6.15, which closes it: enum with a tag, without one, and through a typedef, in a block or at file scope; constants counted on from 0 or from a value written, which may be worked out from numbers in any base, earlier constants, parentheses and the arithmetic and bitwise operators, so Proem’s 1u << 4 flags are; and the constants used as case labels, in initialisers and as values. An enumeration’s type is the one this cc gives it: an unsigned int when none of its constants is negative, and an int when one is.

Refused, as cc refuses them: an enumerator declared twice or named as something else already is, one past an int’s range, an enumeration defined twice or named before its list, an assignment, ++ or & of a constant, and a value that is no constant. Refused by name, though cc compiles it: a value from a character constant, A = 'a'.

Tests: 639 → 649. Eleven refusals. Two programs join the C oracle: 322 agree with cc, none diverges. Refusals: 409.


2026-10-01: C _Bool

_Bool works, the third part of ROADMAP 6.15, and so does bool from <stdbool.h> once cc -E has made it _Bool: one byte, and always 0 or 1, whatever it is given, a long of 2^32 or an int of 256 among them, in an assignment, +=, --, an initialiser, a global, a cast, a return and an argument. A function may return one, and cc’s code and this one’s agree on how one is passed and returned.

Refused, as cc refuses it: unsigned _Bool. One program refused by name until now compiles.

Tests: 639 → 639, one refusal replaced by another. Three programs join the C oracle, one a refusal until now: 320 agree with cc, none diverges. Refusals: 399.


2026-10-01: C short

short works, the second part of ROADMAP 6.15: short, short int and unsigned short, two bytes, wrapping at sixteen bits, in arrays, structs, globals and statics, as parameters and return values, and through .... A function returning one hands it back as cc expects.

Refused, as cc -pedantic-errors refuses them: short long and short short. One program refused by name until now compiles.

Tests: 638 → 639. Two refusals. Three programs join the C oracle, one a refusal until now: 317 agree with cc, none diverges. Refusals: 399.


2026-10-01: C long long

long long works, the first part of ROADMAP 6.15: long long, long long int and unsigned long long, eight bytes as long is on this machine, and the suffixes LL, ll, ULL and LLU in a constant, in hex too. signed char already worked, as char, which is signed here.

Refused, as cc refuses it: 1lL, whose letters are in two cases. Two programs refused until now compile.

Tests: 639 → 638, two refusals by name replaced by one. Four programs join the C oracle, two of them refusals until now: 314 agree with cc, none diverges. Refusals: 398.


2026-10-01: C pointers to functions closed: typedefs, returns, and types checked

The types around pointers to functions work, the fourth and last part of ROADMAP 6.14, which closes it: a typedef of a function’s type, typedef int binop(int, int); and binop *f, a function returning a pointer to a function, written out, int (*pick2(int))(int, int), or through a typedef, and called at once, meta(1)(2)(9, 4); printf called through a pointer; and casts between two such types. ?: and the comma give a function as a pointer to it.

A pointer to a function is given only its own type, as cc insists: one of another type, a pointer to an object, a void * or a number is refused, either way, at an assignment and an initialiser.

Two old messages are corrected: a typedef of a function’s type is no longer refused, and a function’s definition declared typedef says that C forbids it. A function declared before its definition, returning a pointer to a function, was refused as declared twice, and is not.

Refused by name, though cc compiles it: a function declared through a typedef of its type, binop add;.

Tests: 628 → 639. Eleven refusals. Three programs join the C oracle, one of them a refusal until now: 310 agree with cc, none diverges. Refusals: 399.


2026-10-01: C pointers to functions in structs and in data

A function’s address is data, the third part of ROADMAP 6.14: a global, a static and a table of them initialised with functions’ names, in braces, by position and by designator, as Proem’s {expr_report, pp} and .skip = already_included are, and libc’s functions as much as this file’s. A struct member that points at a function is called as d->report(...), and tested against 0 first, which the earlier parts had already made work.

A false refusal is fixed: int (*g)(int) = *twice;, where * of a function is the function, was refused as not worked out before the program runs.

Refused, as cc refuses them: a global initialised from another global’s pointer to a function, alone or in braces.

Tests: 626 → 628. Two refusals. Two programs join the C oracle: 307 agree with cc, none diverges. Refusals: 388.


2026-10-01: C calls through pointers to functions

A call may be through a pointer, the second part of ROADMAP 6.14: op(5, 3), (*op)(5, 3), table[i](7, 2), (**pp)(5, 6), and a parameter called inside the function it was passed to. Arguments are converted, structs passed and returned, small and large, and longs widened, as in a call by name.

Compiling is faster: a call nested in another call’s arguments was read again for every way the grammar tried to go on after it, so four levels deep took nine seconds. It takes a few hundredths.

Two false refusals are fixed: a pointer to a function returning void was refused as an object declared void, and calling through one with * was refused as * of a void *.

Refused, as cc refuses them: a call of something that is neither a function nor a pointer to one, the wrong number of arguments through a pointer, and a number given where a pointer’s parameter is a struct. An undeclared name inside a call’s arguments is refused as a name, not as a call.

Tests: 619 → 626. Six refusals. Two programs join the C oracle: 305 agree with cc, none diverges. Refusals: 386.


2026-10-01: C pointers to functions, as values

A function is a value, the first part of ROADMAP 6.14: int (*op)(int, int) = add;, &add, *op, arrays of them, static ones, typedefs of them, casts and sizeof with int (*)(int) written out, and ==, != and a test against 0 between them. One can be passed to a function that calls it, so qsort and bsearch from libc sort and search with comparators written in C. Calling through one yourself is the next part.

Refused, as cc -pedantic-errors refuses them: arithmetic, ++ or an order on pointers to functions, sizeof of a function, and an assignment to a function or through * of a pointer to one. Two programs refused by name until now compile.

A false refusal is fixed: a parameter named in a pointer to a function’s parentheses, int (*f)(int T);, hid a typedef T until the end of the block, so a declaration of T x; after it was a syntax error.

Tests: 614 → 619. Seven refusals. Five programs join the C oracle, two of them refusals until now: 303 agree with cc, none diverges. Refusals: 380.


2026-10-01: C compound literals, and initialisers closed

Compound literals work, the fourth and last part of ROADMAP 6.13, which closes it: (struct pair){3, 4}, (int []){1, 2, 3}, (int){5}, with designators and strings as any initialiser has them. In a block one is filled each time it is reached; at file scope it is data, so int *p = &(int){42}; works. One is used as a name is: &, =, a subscript, a member, sizeof, passed whole, or decaying to a pointer.

Two messages are corrected: a struct global or static initialised from another struct’s value says, as cc does, that its value is not worked out before the program runs. It said braces were not here yet.

Refused: a compound literal given more values than it has room for, {}, or a string longer than it; one of void, of a struct not complete, or of variable length; a block’s compound literal in a static’s initialiser; and a struct’s compound literal initialising a global, which cc takes as an extension and C11 does not. Three refusals 6.13 listed are now tested too: an array or a struct from a scalar, and an array with neither a size nor an initialiser.

Tests: 601 → 614. Thirteen refusals. Two programs join the C oracle: 298 agree with cc, none diverges. Refusals: 375.


2026-09-30: C designated initialisers, and braces left out

Designated initialisers work, the third part of ROADMAP 6.13: .member = and [index] = , chains of them, {.t.kind = 5, .n[2] = 7}, in any order, with values after one going on from where it left off. The last value given for a place is the one kept. An array of no stated size is sized by its furthest designator.

Braces may be left out wherever C allows, inside structs too, which the previous part refused: {'e', 7, 8, 9, 10, 11} fills a struct whose second member is an array and whose third a struct.

A compile failure is fixed: a struct member more than 4095 bytes in produced assembly the assembler refused.

Refused: a designator past an array’s end, naming no member, or of the wrong kind; an index that is an expression and not a number, for now; a struct over 32767 bytes or 86 members, and an object nested more than eight levels deep, in an initialiser.

Tests: 593 → 601. Nine refusals. Three programs join the C oracle, one of them a refusal until now: 296 agree with cc, none diverges. Refusals: 362.


2026-09-30: C structs initialised in braces

Structs can be initialised in braces, the second part of ROADMAP 6.13: member by member, struct pair p = {1, 2};, at file scope, in a function and static, with members that are arrays or structs in their own braces, a member of char from a string, a struct member or an array element from a struct’s value, {0}, and arrays of structs, CPP’s own {{"+", 1}, {"-", 2}} among them. Too many values, and a struct of the wrong kind, are refused.

One C form is refused for now: leaving out the braces of a member that is itself an array or a struct, {'e', 7, 8, 9} for a struct whose second member is int[3]. C allows it; nothing in CPP writes it.

Tests: 588 → 593. Five refusals. Two programs join the C oracle: 293 agree with cc, none diverges. Refusals: 354.


2026-09-30: C initialisers in braces for arrays, and large frames

Arrays and scalars can be initialised in braces, the first part of ROADMAP 6.13: int primes[5] = {2, 3, 5, 7, 11}; at file scope, in a function and static, with inner braces or without, int x = {5};, and an array of no stated size taking its size from its values, int sized[] = {10, 20, 30};. A string fills an array of char, char word[] = "hello";, and so does a list of them, months[][4] = {"Jan", "Feb", "Mar"}. What is not given is zero. Too many values, a string too long, {} and a value at file scope that is not a constant are refused.

A compile failure is fixed. A function whose local variables took more than 255 bytes of its frame produced assembly the assembler refused, for any variable past that point: int big[80]; int i = 0; did not assemble. Such variables are now reached through a register.

Tests: 577 → 588. Eleven refusals. Three programs join the C oracle: 291 agree with cc, none diverges. Refusals: 349.


2026-09-30: C arrays of arrays and pointers to arrays; declarations done

C types can be built by declarators, the fourth and last part of ROADMAP 6.12, which closes it: int a[2][3], int (*p)[3], int (*q[2])[3], sizeof(int *[3]), casts to a pointer to an array, a parameter declared int b[][3], and typedef int row[3];. A pointer to an array steps by the array’s size, and a row of an array of arrays decays as an array does. A pointer to a function, int (*f)(int), is refused by name for now.

Declarations as C11 has them are done: the words of a type in any order, several names in one declaration, extern, tentative definitions, _Static_assert, struct tags in any scope, and now types built by declarators.

Tests: 572 → 577. Six refusals. Three programs join the C oracle, one of them a refusal until now: 288 agree with cc, none diverges. Refusals: 338.


2026-09-30: C struct tags, and a miscompile fixed

A wrong answer is fixed. Pointer arithmetic through a struct member that points at its own struct, p->next + 1 in a struct node, counted in elements of no size, so the program ran and read the wrong element. It had been so since structs arrived on 2026-09-23. The width of a struct is now found where a pointer to it is used, not where the pointer was declared.

Structs can be declared where C allows, the third part of ROADMAP 6.12: inside a function with the function’s or the block’s scope, with no tag, inside another struct, and before they are defined, struct later;. A pointer to a struct that is not complete yet is allowed, and an object of one, its *, sizeof and arithmetic are refused until it is, as cc refuses them. A typedef may be declared inside a function.

Tests: 568 → 572. Ten refusals. Ten programs join the C oracle, six of them refusals until now: 285 agree with cc, none diverges. Refusals: 333.


2026-09-29: C declarations of several names, extern, and _Static_assert

A C declaration may declare several names, the second part of ROADMAP 6.12: int a = 1, b, *p; at file scope, in a function and in a struct, and typedef int I, *IP;. A for may declare its own variables, which end with the loop. A prototype may leave its parameters unnamed, int add(int, int);.

extern works, at file scope and in a block, and a global may be declared more than once, as C11 allows: int x; int x = 5; compiled to a refusal until now. Its storage is written once, and a second declaration with another type, or a second initialiser, is refused. A static name declared again with extern stays its file’s own.

_Static_assert works, at file scope and in a function, and a failing one prints its message, as cc does. A definition with an identifier list, int old(a, b) int a; long b; { ... }, compiles when its declarations come one name each and in the list’s order, and is refused by name otherwise.

Tests: 552 → 568. Twenty refusals. Seven programs join the C oracle, four of them refusals until now: 275 agree with cc, none diverges. Refusals: 329.


2026-09-29: C’s specifiers in any order, and every keyword reserved

The words of a C type may come in any order, the first part of ROADMAP 6.12, the first entry of the C11 arc: int long, long unsigned int and char signed are the types C11 says they are, and a list of words that is no type, long char or int int, is refused. A storage class may stand anywhere among them, int static f(void) and int typedef count;, and only one is allowed. auto and register arrive for locals, and a register parameter; taking a register local’s address is refused, and so is either word at file scope.

All 44 of C11’s keywords are reserved. Until now 21 of them could be used as names, so int float = 1; compiled where cc refuses it. Those whose feature is not in the subset yet are refused by name wherever they are used: float, double, short, _Bool, enum, union, extern, volatile, restrict, inline, _Noreturn, _Alignas, _Alignof, _Generic, _Static_assert and _Thread_local, and long long. _Complex, _Imaginary and _Atomic are optional in C11, and the refusal says so.

Tests: 500 → 552. Fifty-four refusals. Three programs join the C oracle, two of them refusals until now: 268 agree with cc, none diverges. Refusals: 313.


2026-09-29: const in C

The C subset has const, the third part of ROADMAP 6.10, which closes it. It may be written before or after a type’s base, after any *, and twice. A write to something const is refused, as cc refuses it: by =, += and the other compound assignments, ++ and --, whether the thing is a variable, a parameter, a member, an element, or what a pointer to const points at. So is assigning a struct whole when a member of it is const. const and static are reserved words now. A pointer to const given to a pointer without it is not checked, which cc only warns about.

Tests: 468 → 500. Thirty-two refusals. One program joins the C oracle: 265 agree with cc, none diverges. Refusals: 261.


2026-09-29: static locals in C

A local in the C subset can be static, the second part of ROADMAP 6.10: it keeps its value from one call to the next, and is initialised once, before the program starts. Its initialiser has to be worked out before the program runs, as a global’s does, and is refused with the reason when it is not.

A refusal that named the wrong reason is corrected. A pointer initialised with an address that is no constant in C, the address of a local or a global pointer’s value, was refused as an address this compiler cannot write yet, which is what &table[1] gets. It now says the value cannot be worked out, as cc does.

Tests: 452 → 468. Sixteen refusals. One program joins the C oracle: 264 agree with cc, none diverges. Refusals: 229.


2026-09-29: static functions and globals in C

A function or a global in the C subset can be static, the first part of ROADMAP 6.10: it is its own file’s, and another file can have one of the same name. A declaration without static after one with it is its file’s too, as C has it; the other order is refused, as cc refuses it. static is a reserved word now, so int static = 1;, which compiled until today, is refused. It has to be the first word of a declaration here: int static f(void) is C, and a syntax error. static inside a function, and const, are the next two parts.

Tests: 444 → 452. Seven refusals, and a test that links two files, each with static names of the other’s, compiled by Phoenix and by cc in every pairing. One program joins the C oracle: 263 agree with cc, none diverges. Refusals: 213.


2026-09-29: a refused constant says why

When the C subset refuses a case label or a global’s initialiser, it now says why. Until now every one said the value cannot be worked out before the program runs, which was true of a variable or 1 / 0 and not of the rest. Now there are four answers:

Where two parts of an expression disagree, the stronger reason is given.

Tests: 423 → 444. Twenty refusals, three of them written for sizeof on 2026-09-27 and asserted by nothing until now, and one check that every program in the C refusals is asserted by name. Refusals: 206.


2026-09-27: hex, octal and suffixes in C

An integer constant in the C subset can be hex, 0x1F, or octal, 017, and can carry u, l or both, in either order and either case: the third part of ROADMAP 6.9, which closes it. Each has the type C gives it, which depends on its size, its suffix and whether it is decimal: 0xffffffff is an unsigned int and 4294967295 a long, so -1 < 0xffffffff is 0. 1L and 9223372036854775808 compile; both were refused until now.

Constant expressions wrap as unsigned int. A case label or a global’s initialiser can cast to an unsigned type, use ?: with an unsigned answer, or wrap, 4294967295u + 2 being 1. In a switch on an unsigned int a label is converted to one, so case -1 matches

  1. An unsigned long expression that passes 2^63 is still refused where a constant is wanted, except a comparison.

Refused, as cc refuses them: an octal constant with an 8 or a 9, 0x with no digits, a suffix twice, and a constant too big for any type. 1ll is refused too, long long not being in the subset.

Tests: 416 → 423, for eleven refusals, less four checks whose programs now compile. Seven programs join the C oracle, five of them moved from the refusals: 262 agree with cc, none diverges. Refusals: 189.


2026-09-27: sizeof is unsigned

sizeof is an unsigned long in the C subset, as C’s size_t is on this machine, the second part of ROADMAP 6.9. It had been a long, and a program that subtracts past a size got every answer wrong: sizeof(int) - 5 < 0 was 1 where C says 0. The difference of two pointers stays a long.

A constant with unsigned arithmetic in it folds only where signed and unsigned agree: operands and answer from 0 to 2^63. So case sizeof(long) - 4: compiles, and case sizeof(int) - 5:, -sizeof(int), ~sizeof(int) and -1 < sizeof(int) are refused where a constant is wanted, which cc folds. Constants that wrap are the third part.

Tests: 416 → 416. None added: the C oracle and its refusals are one check each. Two programs join the oracle: 255 agree with cc, none diverges. Four join the refusals, 183.


2026-09-26: the website builds again

The website at the project’s GitHub Pages address is current again. From 2026-09-05 no build of it succeeded, fifty-five in a row, and the site went on showing the pages as they were that day: none of the C work, and none of the documents written since. The site’s description had gained a colon that YAML reads as the start of a key, so the build stopped before it read a page. The description is quoted, and the first build after it deployed.

Tests: 416 → 416. None added: nothing in make test builds the site.


2026-09-26: unsigned types in C

The C subset has unsigned char, unsigned int and unsigned long, the first part of ROADMAP 6.9, and signed before char, int or long. Their arithmetic wraps, and /, %, >> and the comparisons are the unsigned ones, with C’s usual conversions deciding which applies: -1 < (unsigned int)1 is 0, because the -1 becomes 4294967295 first, while a long beside an unsigned int stays signed. An unsigned char reads as 0 to 255. The words of a type come in one order here, and long unsigned is declined by name, as is, until constants learn to wrap, a case with a cast to an unsigned type or a negative one in a switch on an unsigned int. Suffixes such as 4294967295u and hex constants are the next part.

Tests: 410 → 416, for six refusals. Two programs join the C oracle: 253 agree with cc, 179 are refused, none diverges among them. One program Phoenix compiles today still does, sizeof being a long here; that is the second part.


2026-09-26: int refuses a number too big for 64 bits

The library’s int refuses text that does not fit in sixty-four bits. It read "9223372036854775808" as 9223372036854775807, the nearest integer that fits, and said nothing, where every other integer that does not fit stops with an error. A description that reads numbers out of a source with int now hears about one too big, and written-out compilers carry the fix.

In C, a constant one past the largest long is refused by name. C makes it an unsigned long, which is ROADMAP 6.9’s. A case of one had been folded to the largest long.

Tests: 408 → 410, for the refusal and a check that it is said once. One program joins the C oracle, the largest and the most negative long: 251 agree with cc, 173 are refused, none diverges.


2026-09-26: a global initialised with an address

A C global can be initialised with an address, which closes 6.8: char *greeting = "hello";, int *where = &answer;, int *row = table;, each written into the program as the address it names, as cc writes it. Declined by name, although cc compiles them: an address with a number added, &table[1], table + 2 or "hi" + 1. A global pointer initialised from another global pointer is refused, as cc refuses it.

Tests: 403 → 408, for five refusals. One program joins the C oracle: 250 agree with cc, 172 are refused, none diverges.


2026-09-26: global variables in C

A C program can declare a variable outside every function, the second part of ROADMAP 6.8: int count;, long big = 4294967296 * 2;, char table[16];. One with no initialiser starts at zero, and one with an initialiser is given it when the program is compiled, converted to its type as an assignment would be, so char c = 200; is -56. A local or a parameter of the same name hides a global inside its function, and a function above a global cannot see it. A global defined twice, one named like a function or a typedef, a void one, and one initialised with a call or with another global are refused, as cc refuses them. Declined by name, although cc compiles them: a global declared twice where one has no initialiser, which C allows, and an array of no elements. An initialiser that is an address, char *s = "hi";, is the next part.

Tests: 390 → 403, for thirteen refusals. Two programs join the C oracle: 249 agree with cc, 167 are refused, none diverges.


2026-09-26: long constants in C

A constant long expression is worked out when the program is compiled, the first part of ROADMAP 6.8. Until now only arithmetic on numbers that fit in 32 bits was, which was enough for a case in an int switch and not for one in a long switch: case 4294967296 * 2: was refused, and is now 8589934592, as cc has it. An answer that does not fit in 64 bits is an overflow, which C leaves undefined; cc wraps it, and a case of one is refused here by name, as is an int shifted by 32 or more.

Tests: 386 → 390, for four refusals. One program joins the C oracle: 247 agree with cc, 154 are refused, none diverges.


2026-09-26: casts in C, and a failure passed through and and or

The C subset has casts, which closes 6.7: (long)a * a multiplies in 64 bits, (char)200 is -56, (char *)&x reads an int’s bytes, and (void)x throws a value away. A cast in a case label is worked out when the program is compiled, so case (char)300: is case 44:, and two labels equal through a cast are refused, as cc refuses them. A struct cast to a number, a void value cast to anything but void, and an assignment to a cast are refused; declined by name, although cc compiles it, a cast to a struct.

A description’s checks say one thing once, again. When the right side of and or or in a description was an attribute whose check had already failed, Phoenix reported a second error, naming a line of the description, after the right one about the source. The left side has always been passed over quietly, and now the right is too. Written-out compilers carry the fix.

Tests: 379 → 386, for seven refusals. Four programs join the C oracle: 246 agree with cc, 150 are refused, none diverges.


2026-09-26: void in C

A C function can return void, the first part of 6.7. return; leaves one, and falling off its end does too. f(void) declares a function of no parameters, as int main(void) does. void * converts to and from any other pointer without a cast, so void *malloc(long n); is how malloc is declared now. A void value used as a number, return; in a function that returns one, a value returned from a void function, and a variable, parameter or member declared void are refused, as cc refuses them. Declined by name, although cc compiles them: sizeof(void), arithmetic on a void *, main returning void, and a typedef of void itself.

Tests: 366 → 379, for thirteen refusals. Two programs join the C oracle: 242 agree with cc, 143 are refused, none diverges.


2026-09-26: printf in C

A C program can call printf, and any function declared with ..., with as many arguments past the named ones as it likes. That closes 6.6. The extra arguments go where Apple’s arm64 puts them, on the stack in slots of eight bytes, so printf from the system library reads them: %d, %ld, %c and %s all work, with ten arguments or none. A call with fewer than the named arguments is refused, as cc refuses it. Declined by name, although cc compiles it: a struct passed through ....

Tests: 364 → 366, for the two refusals. Three programs join the C oracle: 240 agree with cc, 130 are refused, none diverges.


2026-09-26: a C function declared with ...

A C function can be declared or defined with ... after its parameters, the first part of 6.6. So int printf(char *format, ...); is now a declaration the subset reads, and printf("hello\n") works. A call still passes exactly the named arguments: passing more is the second part. ... with no parameter before it, ... before another parameter, and a function declared with ... in one place and without it in another are refused, as cc refuses them.

Tests: 361 → 364, for the three refusals. One program joins the C oracle: 237 agree with cc, 128 are refused, none diverges.


2026-09-25: switch, and constants worked out before the program runs

The C subset has switch, case and default, which finishes the statements. A case can sit anywhere inside its switch, inside a loop inside it included, as Duff’s device has it; falling from one case into the next, default anywhere, and break leaving the switch all work as in C, and continue in a switch goes on with the loop around it.

A case label is worked out when the program is compiled, as C requires: 'a' + 1, 1 << 3, -7 / 2, sizeof(int), ~0 & 7, 1 ? 40 : 1 / 0 and the like, with C’s arithmetic, a truncating / and 32-bit ints. Constant expressions are a pass of their own, which array sizes, enum and globals will use too. A label that is not constant, two cases with one value and two defaults are refused, as cc refuses them. Declined by name, although cc compiles them: a comma in a label, which C11 forbids and clang allows, and a label too wide for an int switch.

Tests: 350 → 361, for eleven refusals. Six programs join the C oracle: 236 agree with cc, 125 are refused, none diverges.


2026-09-25: bitand, bitor and bitxor in the library

The notation changed. Three functions join the library, next to quotient and remainder:

bitand(12, 10) = 8      bitor(12, 10) = 14      bitxor(12, 10) = 6

Each takes two integers and works on their sixty-four bits, a negative integer being its two’s complement, so bitxor(x, -1) is -x - 1. None can overflow. They are functions and not operators because and, or and not already belong to the booleans. A description that works out another language’s constants before its program runs needs them, and could not write them before in + - * div mod; C’s case A | B: is the first, ROADMAP 6.5. A compiler written out with -o has them too. semantics.md specifies them, and the VS Code extension colours them.

Tests: 350 → 350. None added: the notation’s own claim files, run through phx and through a compiler it writes, check eleven more claims, and the library’s refusals one more, a float given to bitand.


2026-09-25: goto and labels in C

The C subset has goto and labels, the second part of ROADMAP 6.4. A label belongs to the whole function, so a goto can jump forwards or backwards, out of loops and into blocks, and two functions can each have a label of the same name. Labels are a name space of their own, as C says, so a label and a variable may share a name. A goto to a label its function does not have, and a label defined twice, are refused, as cc refuses them.

Tests: 347 → 350, for the three refusals. Two programs join the C oracle: 230 agree with cc, 114 are refused, none diverges.


2026-09-25: break, continue, do and the empty statement in C

The C subset has break, continue, do … while and the empty statement ;, the first part of ROADMAP 6.4. break leaves the innermost loop and continue goes on with it: in a for that runs the step, and in a do the test at the bottom. A do runs its body once before it tests. break or continue outside every loop is refused, as cc refuses it. do, break and continue are now reserved words; before this, break; was read as a variable nobody had declared.

Tests: 344 → 347, for the three refusals. Five programs join the C oracle: 228 agree with cc, 111 are refused, none diverges.


2026-09-25: the bitwise operators, the shifts, ?: and the comma in C

The C subset has every expression operator C has except the cast. The last part of the operators brings &, |, ^, ~, << and >>, their compound assignments &=, |=, ^=, <<= and >>=, the conditional ?:, the comma operator and unary +. They take C’s precedence, so a & b == c is a & (b == c) as it is in C. >> of a negative number shifts in its sign, as cc does here, and a shift is the type of its left side. ?: runs one arm, and takes two numbers, two pointers to one type or two of the same struct. A comma is two arguments inside a call, as C says, and an operator anywhere else.

Two things cc compiles are refused: ?: with a pointer on one side and 0 on the other, and ?: with pointers to two different types.

Tests: 333 → 344, for eleven refusals, nine of them ones cc makes too. Six programs join the C oracle: 223 agree with cc, 108 are refused, none diverges.


2026-09-25: ++, -- and the compound assignments in C

The C subset has ++ and --, before and after, and +=, -=, *=, /= and %=, the third part of ROADMAP 6.3. The place is worked out once, so x[k++] += 10 moves k once and a call in a subscript runs once. A pointer steps by what it points at, twelve bytes for a struct of three ints. The arithmetic is done at the wider of the two sides, so an int divided in place by a long is divided by all of it, and a char is narrowed on the way back, as an assignment narrows it. They chain as C’s postfix operators do: q++->c and *p++ are what they are in C.

Tests: 325 → 333, for eight refusals, each one cc makes too. Six programs join the C oracle: 217 agree with cc, 97 are refused, none diverges.


2026-09-25: an int from a function cc compiled is read correctly

Fixed: a C program compiled by Phoenix could misread an int returned by code another compiler built. The calling convention leaves the upper half of the register unspecified when a function returns an int, and Phoenix tested the whole register in every if, loop condition, !, && and ||. A library function that returned a narrowed long, as int f(long x) { return x; } does, could make a condition true that was false. Phoenix now clears the upper half after every call that returns an int. Functions Phoenix compiled were never affected, which is why the C oracle, which compiles each program with one compiler, could not see it; tests/abi/, which links the two, now does.

Tests: 325 → 325. None added: the calling-convention check asks more of the same pairing.


2026-09-25: !, && and || in C

The C subset has C’s logical operators, the second part of ROADMAP 6.3. && and || evaluate their right side only when the left has not already decided the answer, so p && *p is safe and a call on the right of a decided || is never made. All three are worth 0 or 1, an int, whatever they were given: 0 || 3 is

  1. They take numbers and pointers, a long tested on all its 64 bits, and refuse a struct as cc does.

Tests: 322 → 325, for the three refusals. Five programs join the C oracle: 211 agree with cc, 89 are refused, none diverges.


2026-09-25: % in C

The C subset has %, the remainder, on int, char and long operands, the first of the operators ROADMAP 6.3 lists. It truncates toward zero as / does, so -7 % 3 is -1 and 7 % -3 is 1, and an int meeting a long is widened first. A pointer or a struct on either side is refused, as cc refuses it.

Tests: 320 → 322, for the two refusals. Four programs join the C oracle: 206 agree with cc, 86 are refused, none diverges.


2026-09-25: .phx in colour, in VS Code

editors/vscode/ is an extension for .phx descriptions: comments, literals and the {} holes in them, directives, the rule a production defines, the attribute a pass clause defines, references, nodes, labels and the library are each coloured, brackets match, and Cmd+/ writes (* … *). It is installed by linking the directory into ~/.vscode/extensions, which its README shows.

It is checked against the notation’s own description of itself: languages/phx/phoenix.phx lexes every .phx file in the repository, and the colouring has to agree with it about every comment and every literal. python3 editors/vscode/test.py runs that; it is not part of make test, which needs nothing outside the repository.


2026-09-25: a C function can return a pointer

int *f() compiles. A function in the C subset returned an int, a long or a struct; it can now return a pointer, to any of them, written with stars before its name or through a typedef. That is what lets a program declare malloc, with a char * prototype, and use what it gives back. A function returning a pointer to a struct was the care the change needed: it has a struct’s tag and is not a struct, and three places in the compiler took one for the other until each was asked for no stars as well.

A long index into a pointer, built on 2026-09-23 as a 64-bit multiply and add, had no program that could tell it from the 32-bit one it replaced, because only an object over 2 GB could. One now does: it mallocs three billion bytes and indexes near the end. void is still not in the subset.

Tests: 321 → 320. The check that refused a function returning a pointer is gone, and its program is one of six new ones the C oracle holds against cc: 202 agree, 84 are refused, none diverges.


2026-09-24: a test that never finishes fails instead of hanging

make test can no longer hang. Every program the suite runs, phx itself, everything phx or cc has just made, and awk and solvm running what phx wrote, runs under a limit of 20 seconds, in every one of the fifteen harnesses as well as in tests/run.sh, and each harness runs under a limit of 600. A program that loops forever is stopped and its check fails with did not finish in the reason. So is one that prints forever, which is the likelier regression and which time alone did not catch: it wrote so much that the shell capturing it ran out of memory and took the suite down with it. A program is stopped after 16 MB of output as well. The limit is tests/limit.c, built by make as bin/limit, because macOS has no timeout. PHX_LIMIT, PHX_HARNESS_LIMIT and PHX_LIMIT_MB change the three numbers. The suite takes as long as it did.

A stop fails the run even where the check around it would not notice. Every stop is logged, and the last check fails, naming each program and its arguments, if anything was stopped anywhere. A check that expects a failure and discards what the program said would take a stop for the failure it wanted, and an oracle whose two programs are both stopped sees them agree. The first run with the log found one of those: the Solveig bytecode oracle compares solvm --trace output, and sola.sol’s is 49 MB, so both sides were cut at 16 MB and matched. That oracle now allows its traces 256 MB, and its compiles 60 seconds, since sola.sol takes 8 to compile. It used to run solvm under a perl alarm, which reported nothing when it fired.

Tests: 317 → 321. Three for the limit itself, which everything else now runs through: a program that finishes keeps its exit status, one that never finishes is stopped and says so, and so is one that never stops printing. And one for the whole run: every program the suite ran finished.


2026-09-23: typedef, a struct copied and returned whole, and the first thing a parse remembers

The notation changed. %names is a new directive: a table of names the parse keeps while it matches, and one rule that may ask it.

%names typedef-names
    declare Typedef.name
    hide    Param.name Local.name LocalInit.name LocalArray.name
    scope   block function
    guard   typedef-name .

typedef-name = name .

A node the grammar builds declares a name or hides one, at the moment the name is read; a scope rule ends what was bound inside it; and a guard rule matches a name only when the table says it is declared. A failed alternative takes back what it bound, so ordered choice behaves as it did before. It is written into compilers made with -o as well. Five mistakes in a %names are refused when the description is read, with the line.

The C subset has typedef, which is what the directive is for. typedef int number;, typedef int *intp;, a typedef of a typedef, and typedef struct node *list; all compile, and a typedef can be a parameter’s type, a member’s and a sizeof’s. x * y; is a declaration when x is a typedef and a multiplication when it is not, and a variable declared with a typedef’s name hides it until its block or function ends, as C says. A typedef declared twice for two different types is refused. Not yet: a typedef inside a function and a typedef of an array. sizeof and the difference of two pointers are still ints, because what C makes them is a long, which the subset does not have. Thirteen more oracle programs, 156 in all, and 58 refusals.

A struct is copied whole: a = b, struct t u = s;, and a struct passed to a function, all of which were refused. Passing follows the machine’s calling convention, so a struct goes between code Phoenix compiled and code cc compiled, in either direction, and a new test links the two to make sure. A struct of one kind copied into another is refused, as cc refuses it. So is a struct used where C wants a number: in arithmetic, a comparison, a condition, a return from a function that returns an int, or put into an int or a pointer. Those compiled before today, into the struct’s address used as a number, and cc refuses every one. Nothing in Phoenix itself changed for any of this. Nineteen more oracle programs, 175 in all, and 73 refusals.

A function can return a struct: struct point make(int x, int y), and a typedef of a struct or of int as the return type. It follows the machine’s calling convention, so a struct comes back from cc’s code to Phoenix’s and the other way, and the same test that links the two for arguments now does it for returns. A call that returns a struct can be copied, passed on, or have a member read, make(1, 2).y, but not have one assigned or its address taken, which cc refuses too. So is returning one kind of struct from a function declared to return another, or declaring a function twice with two return types. A function returning a char or a pointer is refused by name, although cc compiles both, and so is a main that returns a struct, which cc only warns about. Ten more oracle programs, 185 in all, and 82 refusals.

The C subset has long: signed, eight bytes, as a variable, a parameter, a member, a return type and behind a typedef. int and long mix as C says, the int converted first, so -1 stays -1 when it is widened. A decimal constant too big for an int is a long. sizeof and the difference of two pointers are longs now, as they are under cc, so the two programs that gave 4 where cc gives 8 agree, and nothing in the C subset diverges. A long passes to and from cc’s code in either direction. Not yet: unsigned, and a suffix such as 1L. Nine more oracle programs and the two former divergences, 196 in all, and 85 refusals.

The notation changed again: a driver can run a pass until something settles.

%driver code = layout, relax until labels, reach, code -> out .

relax until labels runs relax again and again until the root’s labels is what the round before left, and then goes on. A stage that never settles stops after 256 rounds with a message naming it. until is not a reserved word. The Z80 assembler uses it: br now always picks the shortest encoding, where it used to leave some programs a byte or two long, and two-rounds.z80, which was pinned as a divergence at 130 bytes, is 129. Compilers written out with -o do the same.

phx refuses a name in a pass that nothing could answer, when the description is read. $sig with no binding, field, attribute or embed of that name was accepted and then reported once for every node that reached it, or not at all if no program did. So is a thread or an inherited attribute read by a pass other than its own, which no later pass can see. Every description in this repository reads as it did.

Phoenix has a license: MIT, in LICENSE. There was none before.

Tests: 264 → 317. Eight for %names, on a small grammar made to have C’s problem and nothing else: what it parses, the same through a compiler written out with -o, and five refusals and a warning about the directive itself. Nine for typedef: four refusals of what C refuses too, three of what this subset leaves out, and two of a local named in its own initialiser, which were refused before today and are written down now. Then sixteen for the copy: the calling convention against cc’s, and eighteen new refusals, nine of them of programs that compiled into an address, less the three refusals that became oracle programs. Then nine refusals for a struct returned, six that cc makes too and three this subset adds. Then three for a bare name: the two ways of getting one wrong, and a grammar holding every kind that is right, so the check stays as generous as the lookup it guards. Then seven for until: four refusals, the program that needs a fourth walk and what one walk made of it, and a compiler written out with -o running the loop. Then three refusals for long, less the two checks that pinned the divergences it closed.


2026-09-22: & and *, and a left side that is a place

The C subset’s seventh construct: the address of a thing, and the thing a pointer points at. int *p = &x; *p = 7; compiles, and so do int **q = &p, a pointer parameter a function writes through, and a swap written the way C programs write one.

What is on the left of an = is a place and no longer a name. A place is a rule with two alternatives, a name or a * on anything, and unary & goes through the same rule. That is C11 6.5.3.2’s the operand shall be an lvalue, said once in the grammar. &1 and 1 = 2 are refused by the parser with a position, rather than by a pass asking what kind of node it was handed.

A place is a second attribute rather than a second node. A Variable and a Deref each answer both the value and the address of the value, and the parent asks for the one it meant, so &*p emits exactly what p emits and C11’s identity for it is never written down. The declarators int *p and int **q are read and their stars counted; a name in the symbol table now means a slot and a star count, as one pair in the one table that already had a scope rule.

A third pass, types, which exists to refuse what would otherwise mis-compile. Every value here is eight bytes, so a pointer taken for an int is harmless, except that C11 6.5.6 counts p + 1 in what p points at, and nothing in this subset has a size yet. So pointer arithmetic is refused by name until arrays and sizeof bring a size, * on an int is refused as 6.5.3.2 requires, and - and * on a pointer with it. Whether an assignment or an argument has the type it was given is not checked: the one program that would show the eight-byte int, a pointer put in an int and taken out again, is one cc refuses to compile.

Sixteen more oracle programs and seven more refusals, 58 and 18 in all. Every one of the sixteen agreed with cc on the first run.

Later the same day: int is thirty-two bits. It had been sixty-four, which is a conforming implementation and was left open on 09-21 for the oracle to settle. The oracle had never been asked: int a = 2000000000; int b = a + a; return b / 1000000; exits 218 under cc and had exited 160 here, in a program the subset could compile since its second construct. sizeof(int) is 4 and a pointer stays 64 bits wide. On arm64 this costs one letter per instruction, w or x chosen from the star count the types pass already had, because w0 is the low half of x0 and writing it zeroes the half above; the stack machine, the calling convention and the frame are all untouched. Three more oracle programs, 61 in all, two of them written to catch a half-finished narrowing and checked by half-finishing one.

Later again: sizeof. Both shapes, sizeof(int) and sizeof(int **) and sizeof x and sizeof *p, and the operand is not evaluated, which C11 6.5.3.4 requires and which two oracle programs check by putting an assignment and a side-effecting call inside one. It wanted nothing from the passes: while int is the only base type a star count already is a size, so this reads the table the register letter comes from for its other column.

One number disagrees with cc, and is written down rather than refused. C makes sizeof worth a size_t, which is a typedef, and this subset stops before typedef, so sizeof is worth an int and sizeof(sizeof(int)) is 8 under cc and 4 here. languages/c/tests/divergent/ holds the program and the suite pins both answers, so closing the gap fails with the old numbers in it.

Later still: an array, and a frame measured in bytes. int a[10] and int *a[10], sizeof a, and the decay C11 6.3.2.1 requires: a name that is an array is a pointer to its first element everywhere except under sizeof, so *a is element zero and int *p = a; is the same address. A local now lives at a byte offset rather than in a numbered slot, because forty bytes do not fit in a slot, which is the first time a declaration’s type reaches the frame. Everything is still given a multiple of eight, because the distance between two separate objects is not something a C program can observe. Assigning to an array is refused, as cc refuses it; a zero-length array is refused where cc takes it as an extension, which is what keeps a count of zero free to mean not an array; and indexing, a[i] and a + 1, is the rest of the roadmap item and is refused by name until it arrives. Eight more oracle programs, 79 in all.

And last: indexing, which closes the item. a[i], a + 1, p - 1, 2[a], m[1][1] = 4 and &a[2]. A subscript is C11 6.5.2.1’s *((E1)+(E2)), built as exactly that by the grammar, so it needs no node of its own and anything the passes already said about * and + holds for it. Pointer + and - now count in elements, as C11 6.5.6 says, with one arm64 add that sign-extends a 32-bit index, scales it by the element’s width and adds it to the pointer. Adding two pointers and taking a pointer from a number are refused, as cc refuses them. The difference of two pointers is an int, the count of elements between them. C makes it a ptrdiff_t, which is a typedef, so it takes the decision sizeof took and is pinned the same way: sizeof(&a[1] - a) is 8 under cc and 4 here, and both answers are asserted. Subtracting pointers to different types is refused, as C requires. Twenty more oracle programs, 99 in all, 25 refusals and two divergences.

Then char, as a type. Locals, parameters, arrays and pointers of char, sizeof(char), and the conversions C11 6.3.1 asks for. A plain char is signed on Apple’s arm64, where Apple departs from AAPCS64, so it is loaded with ldrsb; a store keeps the low byte, so char c = 300; holds 44; and an assignment to a char is worth what the char holds afterwards. Anything that is not a place or a pointer is promoted to int, so sizeof(c + 1) is 4. A type is now two numbers, the stars and the width of what is under them, and two pointers are the same type only when both agree. Eleven more oracle programs, 110 in all, and 26 refusals.

Then character constants. 'a', and six escapes, \n \t \\ \' \" \0. Each is an int, as C11 6.4.4.4 says, so sizeof 'a' is 4. Printable ASCII only: a hex or octal escape, two characters between the quotes and a tab typed between them are C, and are refused by the lexer as outside the subset. One oracle program checks all ninety-five printable characters. Five more oracle programs, 115 in all, and 29 refusals.

And string literals, which close the item. "hello, world" is an array of char in static storage, one longer than its characters, decaying to a char *: sizeof "abc" is 4 and "abc"[1] is 'b'. Programs can print now, through a puts or putchar declared in the program, and the oracle compares what they print as well as how they exit. The escapes are five of the six character constants have: \0 is refused in a string, because C reads octal digits after it. Two literals side by side, which C joins, are refused too. printf is not reachable yet: it is variadic, and Apple’s arm64 passes variadic arguments on the stack. Eight more oracle programs, 123 in all, and 31 refusals.

Last: struct, which completes step one. struct point { int x; int y; }; at file scope, then p.x, q->y, arrays of structs, structs inside structs, member arrays, sizeof(struct point), and a struct holding a pointer to its own kind, so a linked list compiles and walks. Members are laid out as cc lays them out, each at the next offset its alignment allows and the whole rounded to its widest member, so sizeof agrees for every struct the oracle tried. A step through a pointer to a struct counts in structs of any size. A struct is never copied whole here: assigning one, initialising one from another and passing one to a function are refused, and a pointer to one is the way to hand it around. A struct defined inside a function, one with no tag, one with no members and a pointer to one nobody defined are refused too; cc compiles all four. Twenty more oracle programs, 143 in all, and 49 refusals. Nothing in Phoenix itself changed through the whole subset, which is what the C arc predicted when it began.

Tests: 224 → 264, forty-two in and two retired. The first thirteen were all but one a refusal: the address of something that is not a place and an assignment to one, both from the grammar; a * on an int and on a name nothing declared; a - and a * on a pointer; and pointer arithmetic, which after the ninth parameter is the second thing in the C subset refused for being outside the subset rather than outside the language. Then a sizeof of a name nothing declared and a * on what a sizeof is worth, and the pin on sizeof(sizeof(int)), which asserts both answers so that closing the gap fails with the old numbers in it. Last, the two refusals of pointer arithmetic were retired by indexing, and four came in: two pointers added, a pointer taken from a number, a subscript on an int, and two pointers to different types subtracted. And a second pin beside the first, on the size of a pointer difference, and a refusal of a char * less an int *, three of character constants outside the subset, and two of string literals. Then eighteen for struct: two from the grammar, seven about a definition, four about a member asked of the wrong thing, one about pointers to two different structs, and four about copying a struct whole.


2026-09-21: a C subset begins, with cc as its oracle

A C compiler has its first six constructs: languages/c/.

phx --driver arm64 languages/c/c-arm64.phx prog.c > prog.s
cc prog.s -o prog && ./prog

int main(){return 42;}, then + - * / with parentheses, then unary minus and the six comparisons, then a local int, then if, while, for and blocks, then functions with parameters and calls: functions returning int with up to eight int parameters, prototypes, a body of declarations, assignments, expression statements, control flow and returns, expressions over constants, locals and calls, and the two comment shapes.

The calling convention is AAPCS64 as Apple applies it: the first eight arguments in x0 to x7, the result in x0. A caller pushes each argument as it is evaluated, then loads the registers from the stack and drops it; a callee stores each register into its parameter’s slot in the prologue, after which a parameter is a local. Recursion, mutual recursion through a prototype, and a call nested in an argument are in the oracle. A ninth parameter is refused by name rather than mis-compiled.

C99 6.5.2.2 wants a declaration above every call, and cc enforces it, so the gathered table awk uses for a call above its function is not what C needs. The declared functions are a thread; a definition binds itself on the way in so its body can recurse. A gather pass remains for the two questions no order can answer, a function defined twice and one declared with two arities. Six of C’s fifteen expression levels; a comparison is an int worth 0 or 1 that chains to the left the way C11 says it does, and assignment is an expression worth what it assigned, grouping to the right.

The first pass that can say no. locals is a symbol pass in c.phx: a thread of what has been declared, set afresh at each function and saved and restored around each block, and a slot counter that only grows. A name nothing declared, one declared twice in a scope, one read after its block has closed, a parameter declared again in the body, a call before its declaration or with the wrong number of arguments, a function defined twice or declared with two arities, and a ninth parameter, are refused with a position, and tests/refused/ holds the eleven programs that show it. A second thread holds only the current block’s names, because C11 6.2.1 lets an inner block shadow an outer one and a single table cannot tell shadowing from redeclaring. The emit pass turns a slot into an offset below the frame pointer and lowers sp past the locals in the prologue, rounded to sixteen. c.phx is the grammar and the tree and has no opinion about a machine; c-arm64.phx imports it and adds one emit pass, so the split is the one every other language here makes. The target is arm64 assembly in the syntax cc assembles on this machine, and cc assembles and links it.

The emit pass is a stack machine. Every expression leaves its value in x0; a binary operator pushes the left operand, computes the right, and pops the left into x1, sixteen bytes a push because sp must stay aligned. One rule and a table of four instructions, because a general Binary rule above Binary(op: "+") takes everything and phx refuses it at read time. The prologue and epilogue are the ones the later constructs will fill in rather than change. Falling off the end of a function returns 0, which C11 5.1.2.2.3 requires of main and permits of the rest.

The oracle is cc itself, the way fpc is Pascal’s and /usr/bin/awk is awk’s. tests/oracle/run.sh compiles each program twice and compares what the two exit with, which until a function can be called is all a program can say. Forty-two programs, among them the groupings the folds have to get right, a quotient that truncates toward zero, a signed comparison, five locals whose slots must not overlap, a dangling else, a for (;;) that only a return leaves, and two nested loops whose labels must not collide. Every branch target is a label the program never wrote, from a thread counted on leaving, the way languages/z80/ numbers nothing and languages/solvm/ numbers its chunks. One of them, 3 > 2 > 1, is C11 as written and an error to this cc by default, so the oracle downgrades that one warning by name rather than lose the witness. Nothing in the directory has a hand-written expected result, which is the rule ROADMAP 6 set for the arc.

On the roadmap. 6.1 was written on 2026-09-06 and this is its first step taken; the entry itself is unchanged but for a marker at the construct that exists. Its three predictions are not yet scoreable: the first is about reaching struct with no change to the tool, and the tool has not been asked for anything.

Tests: 211 → 224. Thirteen new ones: that the description reads, one that runs the oracle programs and reports how many agree with cc, and eleven refusals from the functions and locals passes. The first run of the suite said 212, because the check that the records’ counts match the tree was failing on a missing row and is itself one of the 213.


2026-09-05 — a second calc backend, and a conformance rule that reaches a loop

calc compiles to awk: languages/calc/calc-awk.phx.

phx --run emit-awk languages/calc/calc-awk.phx prog.calc > prog.awk
awk -f prog.awk

An %import line, an emit pass and three drivers — no grammar, no typechecker and no interpreter, because those are in calc.phx and have no opinion about any target. It is the same twenty-four clause lines as the C backend, and seventeen of them are identical to it character for character.

What it is for. Phoenix’s rule is that one description, run two independent ways, gives one answer. One of those ways was --run eval, which cannot interpret a branch or a loop — an attribute is computed once per node in one walk. So until now every calc program with control flow in it was checked by a single backend against an expected string somebody had typed into the suite. There are two backends under those programs now, and the suite compares their output with each other rather than with an expectation.

Why awk and not the parked Solveig backend. awk needs nothing this repository does not already need — the Makefile itself builds phoenix/runtime.h by piping through awk — and, more to the point, awk’s numbers are doubles. A second backend that shared C’s arithmetic could only catch a mistake in its own clauses; this one had to write calc’s truncating division out as int(a / b) in a host whose / is floating, which is the divergence semantics.md exists to prevent, met a second time in a second host.

Nothing about the tool changed. This is a description, and the language a compiler emits was never Phoenix’s business.

Syntax errors point at the mistake. A parse that matched its goal and left input over used to be reported at the first leftover token, with the expected list from wherever the match had really got stuck — two places in one message, and the token named was usually legal where it stood. Since a start rule that is a repetition never fails outright, that was every syntax error in every language described here.

print a +;
^ expected -, (, integer or name, and found "print"     (before)
         ^ expected -, (, integer or name, and found ";" (after)

parse.c already tracked the furthest point the match reached; one path threw it away. The fallback is unchanged for a parse that never got past the leftover.

A Z80 subset, and an assembler for it: languages/z80/z80.phx.

phx --driver code --raw languages/z80/z80.phx prog.z80 > prog.bin
z80asm -o want.bin prog.z80 && cmp want.bin prog.bin

326 lines: immediate loads, inc, dec, the immediate arithmetic, the absolute jumps and calls, the relative ones, labels, and a listing backend beside the bytes.

And br, which is the point. It assembles to a two-byte relative jump when the target is in reach and a three-byte absolute one when it is not — a size that depends on a distance that depends on sizes. The description walks the tree twice, and the second walk is the first one written out a second time, because the notation has no way to say again: walk one has met no forward label and assumes three bytes, walk two decides against walk one’s addresses, which over-state and therefore only shrink.

That reaches the minimum on a program one round deep — tests/oracle/chain.z80 goes 131 to 129 — and misses by a byte on divergent/two-rounds.z80, where the second br shrinking is what brings the first into range on a third walk nobody makes. No fixed number of walks is the answer, which is ROADMAP 2.5 — now with the customer, the witness, and a working prototype of the mechanism in front of it.

The oracle is byte for byte, which none of the others are. z80asm assembles the same four programs and the two binaries are compared whole — where Pascal and Solveig agree about what a program prints, and a consistently wrong translation can survive that.

A thread declared below the rules that update it is refused. It used to be silent, and silence there is a wrong answer rather than a missing one: clauses are classified as they are read, so the rule above the declaration got an ordinary attribute and the rule below it got the thread, and the thread reached every node with the value it started at. The message names the fix, and otherwise is covered as well as the rules.

Tests: 189 → 211. Twenty-two new ones: six for the calc backend; one for <> and or, whose clauses no calc program had ever reached; one holding the records’ own counts against the tree; and two for a claim reference.md had made since %rewrite shipped and nothing had run — a node built by a rewrite keeps the position of the node it replaced, so a diagnostic from a later pass points at the program rather than at the rule. And seven for the Z80 subset: one that it reads, four refusals — an undefined label, one name at two addresses, an immediate that does not fit its byte, a hand-written jr that does not reach — five pinning the two walks and the one that needs a third, and the oracle. And one for the defect below. The calc backend’s tests need awk to run what it emits, in the same role cc already plays for the C backend; the Makefile requires both to build phx at all. The Z80 oracle needs z80asm, and skips without it.

2026-09-03 — an assembler, and documentation for a stranger

A fourth description, and a target rather than a language: languages/solvm/. An assembly language for SolVM and an assembler that emits .sob bytecode — 21 mnemonics, labels, and blocks whose chunks nest. Phoenix can emit .sob and cannot read it: a length-prefixed format needs the match to depend on a count it has just read, and the notation has no computed repetition. solvm --dump stays the reading half, and comparing what it prints for two producers of one program is the oracle.

Slots may be addressed by name. Where a frame is declared as its slots’ names — slots self, n — an instruction may write local n and outer 1, n rather than local 1. The name resolves against that frame’s own declaration and the same byte is written, so the two spellings are one program; programs/adder.sasm and adder-named.sasm are exactly that, and the suite compares their bytes.

Two checks that used to be the loader’s. outer 0 and a depth past the outermost frame are now refused at assembly time, at both ends, matching serialize.c’s d < 1 || d > ancestor_count. So is a slot past the frame it addresses, outer included. Every rule in SolVM’s verify_chunk is now either guaranteed by construction or checked here, except the stack-height dataflow, which a walk over a tree cannot do.

Notation: | became an expression operator, for awk’s cmd | getline (1.6).

Tool: a field that shadows a thread or a synthesised attribute is now an error rather than a silent win; a failed check complains once rather than twice.

Settled against: scope graphs, from Statix (2.3). Turbo Pascal’s units were described to test it and resolution stayed an association list.

Documentation. A manual, reference and cheatsheet for Phoenix and for the assembler, three tutorials, and a website at hansolovkarlsson.github.io/Phoenix assembled from docs/ on every push rather than committed. The tutorials are executable: make test runs each one and holds the page to what actually happens. Doing that the first time found eight defects in them.

This page, and two corrections it caused. Written from the stage-* tags, it found COMPLETED.md’s stage table disagreeing with them at rows 4 to 6 — and then found that the README holds a second stage table which is the original plan rather than the delivery. Both now say which they are. This page was itself wrong within the hour, and that correction is recorded in journal.md rather than hidden.

Measured, a fourth time. ROADMAP 1.2 — whether the tables should be compiled to code — came out the same way again, now with the control it had been missing. The assembler’s grammar has no expression ladder, so it is what the matcher costs when a grammar asks nothing of it: 11–25 match-steps per token, against Pascal’s 12–31 and awk’s 221–2,638. Three grammars, 240× apart in constant and identical in curve, over nine shapes. performance.md has every number and the command that reproduces it.

ROADMAP 1.2 is closed, settled against building. The fourth measurement is what settled it: three flat curves with no control could not say why they were flat, and the assembler supplied one. Generating code buys a constant factor on a matcher already within 2× of a grammar that asks nothing, and costs a second implementation of ordered choice, floored division and pattern matching. The roadmap now has no open entries at all — sections 1 and 2 are empty, and what remains is what the project has decided not to have.

Fixed in the benchmark: bench/run.sh did not check whether phx had succeeded, so a failed run was parsed out of the error message and printed as a measurement — two numbers on that page came from it. It checks now. bench/generate-awk.awk and bench/generate-solvm.awk are new; before them nothing in the repository could reproduce the awk figures at all.

Tests: 176 → 189, of which 186 need nothing outside the repository.

2026-09-02 — awk, and five notation entries

A third language: languages/awk/. POSIX awk — grammar, a call check, and a compiler to C. It is the first grammar here that was not vendored from a specification, and the oracle carries the whole weight: programs that e2fsprogs, ncurses and vim ship compile and print what /usr/bin/awk prints.

Notation, all five argued for by a language that needed them:

   
%include a target language’s own includes, spliced by the reader (1.0)
%embed a file’s bytes under a name, frozen at read time (1.5)
$pos where a node came from (1.1)
$pos as a span Position(line, column, file, endline, endcolumn) — a node is a stretch of source, not a point (1.4)
%rewrite topdown, bottomup, innermost — replacing a node rather than decorating it (2.2)

Settled by something already built: 1.3, a way for a description to share a computation, closed with no new notation — otherwise had shipped at stage 2, and languages/pascal/pascal.phx was already using it twenty-one times with a comment saying why. The entry had framed the problem as a map over a list; it was an attribute every node has.

Fixed: a literal holding a NUL was frozen with strlen, so -o wrote a compiler that disagreed with phx for any description with a NUL in a literal — which every binary backend has. The generated main also had no --raw.

Settled against: reference attributes, from JastAdd (2.1). awk needed a forward reference and two passes gave it.

semantics.md was made executable — the arithmetic specification is now checked rather than asserted.

2026-09-01 — stages 0 to 7, the tool itself

Eight tags in one day. Each is the end of a stage rather than a release.

tag what it delivered
stage-0 read a grammar, scan and match a file, print the tree
stage-1 -> actions: what a production builds, with no host-language splices
stage-2 %pass, clauses keyed on the vocabulary the actions build, and otherwise. docs/semantics.md specifies the meta-language’s arithmetic in its own terms
stage-3 %driver, passes that read each other’s work, %import, %require, and lib/expression.phx
stage-4 actions on Wirth’s Pascal, taken from the published grammar unmodified
stage-5 -o: a description written out as a C program that is its own compiler. Tables, not code — one implementation of the notation rather than two
stage-6 Pascal that typechecks and compiles to C; fpc as an oracle, which found six bugs on its first run and four more on the next; one directory per language; the notation described in itself; a Solveig front end
stage-7 a binary target — Solveig to .sob bytecode, held against solas byte for byte

The oracle is the method, and it is worth stating once. Every language here is checked against an existing implementation of it — fpc for Pascal, /usr/bin/awk for awk, solas and solvm for Solveig — comparing what the two produce rather than only what they print. The fpc oracle found ten bugs in its first two runs, none of which reading the code had found.


Compatibility

None promised. The notation is still being decided; entries leave ROADMAP.md with a verdict, and some of those verdicts change how a description is written. Two changes so far would break an existing description:

The .sob format version is an equality rather than a floor: a SolVM build reads exactly its own and refuses every other in both directions. Phoenix writes 14, and tests/run.sh checks that against SOL_SOB_VERSION whenever a Solveig checkout is to hand.