Phoenix

Phoenix  /  Journal

Journal

Why each decision was made, in the order it was made, so that undoing one is informed rather than archaeological. The staging exists to be retreated through; this is the map for a retreat.


2026-09-01 — what Phoenix is

The first framing was wrong and worth recording as wrong. Phoenix looked like “lex/yacc that emits Solveig”, and that reading survived about one exchange.

The actual complaint is not about lex.c + parser.c + tokens.h. It is that lex and yacc solve the front of the problem and abandon you at the AST: after the parser you still hand-write forward-declaration collection, type checking, optimisation and code generation, four large modules that are largely traversal boilerplate, written again for every language.

So the claim Phoenix is making is:

A compiler is a grammar plus a sequence of tree walks, and Phoenix is a notation for both plus a driver that runs them in order and reports.

The grammar half is the part everyone already builds. The pass half is the part nobody does well, and is the reason for the project.

2026-09-01 — the meta-language will be attribute-shaped

Three shapes were on the table for what an action is:

  1. Solveig, spliced verbatim — yacc’s bargain with C. Nothing to design, and the actions are string concatenation in a language with no operators.
  2. A notation Phoenix owns, with two backends: interpret it, or compile it to Solveig. One description, two ways to run it.
  3. An evaluator shipped inside the generated compiler, interpreting an action table at run time.

(2), because it is the only one where the phrase “built-in interpreted evaluator language” does any work, and because the interpreted backend is what makes a language cheap to iterate on before any code is generated.

The shape is Knuth’s attribute grammars — -> names the node a production builds, %pass holds clauses keyed by node type, = defines a synthesised attribute, down an inherited one, ! raises a diagnostic, %driver orders the passes. JastAdd and Silver are the living implementations, so the attribute-scheduling problem is lifted rather than invented.

The known weakness, recorded before it bites. Attribute grammars suit type checking and code generation and are awkward for exactly the two passes named first: forward declarations want to mutate a table across the whole tree, and optimisation wants to rewrite the tree rather than decorate it. Those probably want a second mechanism — rewrite rules with a declared strategy. Two mechanisms is a cost, and it is better to discover it is needed than to design it up front, so it is deferred until a real pass demands it.

2026-09-01 — phx is C11

Weighed against writing phx in Solveig, which would have had one genuine advantage: the interpreted backend and the generated backend would share one semantics, and in this design they must agree.

C won on the other three counts — standalone binary, no bootstrap, no recursion ceiling on a tree-walker, better debugging. The cost is accepted knowingly and written down here so that the first divergence bug between the C interpreter and the generated Solveig is recognised as this decision arriving rather than as a surprise.

2026-09-01 — stage 0

The grammar half. Wirth’s notation plus the older angle-bracket dialect, the lexical/syntactic seam, longest-match scanning, a PEG over tokens, a tree.

Three things were built that were not planned, each because a test asked.

The scanner recovers. It stopped at the first character no rule matched. Solveig’s lexical.pas — two stray characters on two lines — carries a comment arguing that both should be reported, because a scan that stops at the first tells you least about the file you know least about. It now takes the character and carries on, and fails at the end.

A literal nothing spells is a grammar error. A test grammar named "," in the syntactic half with no token rule producing a comma. That rule can never match, and the way it failed was a syntax error on a correct file, at the comma, saying it expected a comma. Every literal in the syntactic half is now checked against the scanner when the grammar is read.

The unused-rule warning had to learn about token rules. It fired on symbol = ";" | ..., which nothing names — because a token rule is reached by its spelling, not by its name. Warning about those is noise.

And one thing was got right by borrowing. %fragment came straight from check_syntax.sol, which learned it the hard way: the first Pascal file it read came back as a stream of letter and digit, because longest-match ties break by declaration order. The trap was known before it was walked into, which is the whole value of the prior art being in-house.

2026-09-01 — what self-hosting would and would not prove

Raised as a question and worth writing down carefully, because the loose answer (“Phoenix could describe itself”) conflates three different things.

Level 1 — the notation describes its own grammar. Real, and easy. A .phx file’s format is expressible in .phx, which is Wirth’s own argument for the notation. It would replace the hand-written reader in grammar.c and nothing else.

Level 2 — Phoenix generates its own front end. Real, once stage 5 exists. phoenix.phx generates a program that reads .phx files, with resolution and the checks written as %pass blocks; phx the C binary would then be needed only to bootstrap, and a generated phoenix.sol would be kept in the repository the way every self-hosting compiler keeps a bootstrap artifact.

Level 3 — Phoenix generates all of itself. Not real, and not a failure. The PEG matcher and the longest-match scanner are the engine that runs a grammar rather than anything derived from one, so they would ship as a runtime beside the generated code. Every compiler generator does this; a self-hosting C compiler still links a runtime.

Self-hosting is a test, not a feature. A Solveig Phoenix would be slower and bound by SolVM’s limits — strictly worse as a tool. What it buys is a completeness argument: a notation that can describe its own compiler demonstrably has enough in it.

And the place it should be expected to fail is the interesting part. Left-recursion detection is a Warshall closure over a rule graph, not a walk over a tree. If %pass can only express tree traversals it cannot express Phoenix’s own checks — and that limit is not peculiar to Phoenix. Dataflow analysis, call graphs, register interference and alias analysis are all graph algorithms wearing a tree costume. Attempting level 2 would say early whether %pass needs an escape hatch for passes that are not tree walks.

It would also be the sharpest available probe of the divergence risk taken on knowingly when phx was made C rather than Solveig: the two implementations of the meta-language must agree, and self-hosting is where disagreement surfaces.

2026-09-01 — stage 1

-> says what an alternative builds. Three decisions, one of which was not planned and is the best thing in the stage.

Positional and named references, both. $n is terse and familiar and is yacc’s worst ergonomic flaw — $3 drifts silently when a factor is inserted before it. Labels (e:expression, then $e) survive editing. Rather than choose, both are read, and both are checked when the grammar is read rather than when a file is parsed, which turns yacc’s silent wrong tree into a message with a caret under the $5.

$$ folds. The flat repetition every grammar writes for a binary operator — term { ( "+" | "-" ) term } — has to become a left-leaning tree, and that is the single most common shape in any grammar. If the notation could not say it cleanly the notation was wrong. The rule that works: an action mentioning $$ replaces the value before it rather than following it. Precedence then comes from the grammar and associativity from the fold, and neither had to be declared.

The unplanned one: interior nodes are never built. The original plan was that -> builds a node and the default is the concrete tree. Making a rule’s answer depend on how many values its body produced — one passes through, any other number gets wrapped — turned out to collapse expression → term → factor → number down to the number for free. That is the CST-to-AST cleanup every hand-written tree-builder exists to perform, and it fell out of a rule chosen for a different reason. It is one sentence in parse.c and it deleted a feature.

What was not needed. No %node declarations, no separate AST schema. The vocabulary is what the actions build, gathered while checking them, printed by --nodes. Stage 2’s passes will be keyed against exactly that, and a type built with two different field lists is warned about now because a pass would have to handle both.

Unchanged, deliberately. pascal.bnf has no actions and behaves exactly as it did at stage 0 — all 22 of that stage’s tests still pass untouched. A grammar that only wants to be checked never has to learn any of this.

2026-09-01 — stage 2, and one thing the sketch got wrong

%pass works. The calculator has an eval pass that interprets, and emit-sol and emit-c passes that compile, and all three answer 97.

The sketch proposed demand-driven evaluation with memoisation, JastAdd’s approach. That was dropped while building it, and the reason is worth keeping.

A threaded attribute needs a defined traversal order and demand-driven evaluation has none. Once a walk has to exist for thread to be threaded along, computing everything else during that same walk is simpler than being lazy: there is no scheduling problem, no dependency analysis, and no cycle to detect, because a node’s attributes are computed strictly after its children’s. What it costs is that an attribute cannot refer forward to a node the walk has not reached — which is what several passes and a %driver are for, and how a hand-written compiler handles forward references anyway.

So the model is one walk, post-order, two phases at each node: down clauses entering, children, everything else leaving.

Three things had to be fixed that only appeared once it ran.

A built node’s position was in the wrong file. Value.pos came from the action’s position in the .phx, so a diagnostic from a pass pointed at the compiler description rather than at the program being compiled. It is the source position now, which is the only one worth pointing at.

Checks are guards, not clauses that print. Variable’s undefined-name check fired, left val as nil, and then the addition above it complained about the nil, and then every node above that complained in turn — one mistake, four messages. A check now blocks its node’s attributes: if one fires, they are set to a failure rather than computed, and a failure passes through every operator and every library function silently. One mistake, one message.

$body.out had to mean something. $body is a list, and there was no way to say “that attribute of each element” without a map, a lambda, and a reason. Reading an attribute of a list now reads it from every element, which is one rule and removes the need for all three.

The . is whitespace-sensitive, and this is the one place in the notation where a space changes a meaning. $left.val reads an attribute; $left . is a reference followed by the terminator that ends a clause. No lookahead can separate them — . Binary(op: "+") beginning the next clause looks exactly like a field access — so adjacency decides, and the README says so.

What the two emit passes proved. They were written together, and neither knows what language Phoenix itself emits. The conformance rule from docs/semantics.md is now a test rather than a hope: the same .phx, interpreted and through both backends, gives the same answer, and a change that breaks one of the three fails the build.

2026-09-01 — Solveig parked, and C is the target

Phoenix began as a generator of Solveig compilers. It is not one now.

The reasons given were maintenance ones and they are sound. Solveig is still changing, so every release is a chance to break emit-sol and the round trip, and fixing that buys nothing while the meta-language is itself unstable. And once Phoenix had its own value semantics, Solveig had stopped supplying anything: it was a second target, not a foundation.

The reason worth recording is a different one. The argument for building two backends was that two implementations which must agree will catch host assumptions leaking into the notation. Dropping to one does not remove that risk — it mirrors it, and C assumptions can leak in now instead. What makes this safe is that the replacement was built first, in the right order:

So the discipline moved from two backends disagree to the spec says, and the interpreter checks. Losing the third leg is a real loss and a small one.

The dependency that actually mattered was not the backend. make test read pascal.bnf and its fixtures out of a sibling checkout, which is the kind of dependency that breaks a test suite for reasons having nothing to do with the project being tested — reorganise Solveig, and Phoenix fails. Those are fixtures, not a library, so they are vendored into languages/pascal/tests/grammar/ and the suite now passes with Solveig absent from disk, which was checked rather than assumed.

Going back is writing an emit pass, not changing Phoenix. The language a compiler emits was never Phoenix’s business; it lives in the .phx. languages/calc/calc-solveig.phx still holds those clauses and is still read by the suite, so the notation cannot drift out from under it, and PHX_TEST_SOLVEIG=1 make test runs the round trip. The only place “C only” is a genuine narrowing is stage 5’s own backend — what a generated compiler is written in — and that is Phoenix’s code rather than anybody’s .phx.

2026-09-01 — exhausting stage 2 on the calculator

The calculator grew comparisons, if, else, while, blocks and assignment, plus a typecheck pass — which is the first time stage 2 has been asked to do one of the four jobs this project exists for. Five things broke, and every one of them was worth the trip.

Two clauses for one pattern silently did nothing. Block appeared twice in emit-c — once for down indent, once for out — and first-match-wins meant the second was never reached. The symptom appeared elsewhere, as a node missing an attribute that a perfectly good clause defines. It is the same hazard as "<" | "<=" in the lexical half, one level up, and it now gets the same treatment: a subsumption check over each pass’s clauses, refusing a general pattern written above a specific one. An error rather than a warning, because there is no reading under which it was meant.

A repetition matched once was not a list. { statement } gave a list for zero or two statements and a bare node for exactly one, so $body.out was text in a one-statement block and join failed on it. The .phx author cannot know how many statements a block will hold — but the grammar knows the count is not fixed, so a repetition and an option now yield a list always, of whatever length they turned out to be. Decidable from the grammar, which is the property that matters.

The interpreter has a boundary, and it is not a bug. An attribute is computed once per node in one walk. A while needs its body evaluated a number of times that depends on the program, and a branch not taken must leave the variables alone. Neither is expressible. So eval works on straight-line programs and says so where a program runs into it, rather than failing obscurely. Interpreting is for checking a language while it is being designed; compiling is what Phoenix is for.

That costs something and it should be said plainly. When Solveig was parked, the claim was that --run eval against --run emit-c keeps the conformance rule honest. It does — for straight-line programs. Anything with a loop is now checked by one backend only. %import is what makes a second one cheap again, which is a better argument for it than the tidiness one.

And the divergence docs/semantics.md was written to prevent happened anyway, in the example. 0 - 7 / 2 gave -4 interpreted and -3 compiled: eval used Phoenix’s div, which is floored, and C’s / truncates. The bug was in calc.phx rather than in Phoenix — the calc language had never said what / meant, so each pass borrowed its host’s answer, which is precisely the page’s headline.

Fixing it needed something the library did not have. Truncation cannot be written in terms of flooring in a notation with no conditional, so quotient and remainder were added — and they earn their place by the rule at the top of library.c: target languages disagree with each other about negative division, and a pass that models one of them has to be able to say which. There is a test with a negative number in it now, so this cannot come back quietly.

2026-09-01 — %import, and what to call a .phx

The naming, settled because the docs say it on every page. A .phx file is a description — it describes a language, and Phoenix turns a description into a compiler. It is not a program, since nothing runs it, and it stopped being a grammar when it grew passes. Spoken of as a unit — importable, one of several, in a directory — it is a module. A module with a %start stands on its own and one without is only ever imported, which is the whole distinction; there is deliberately no word for the partial kind, because fragment is spoken for by %fragment and a second meaning would be a trap.

The split %import makes possible is not grammar-versus-passes, which is where the idea started. It is the language against the target: the grammar, the tree, the typechecker and the interpreter all belong to the source language and have no opinion about C, so only the emit pass is per-target. calc-solveig.phx went from ninety lines of duplicated grammar — quietly wrong the first time calc.phx changed — to an emit pass and a line naming the language.

Two things had to change underneath, and one was not foreseen. A position was an offset into one file; with imports it has to name which file. The files are held end to end in one buffer with a map saying which stretch came from where, so an offset stays a single number and everything downstream keeps working unchanged while getting the right filename and the file’s own line numbers.

The unforeseen one: grammar_check refused a description with no syntactic rules. That was right when a description was always a whole thing and is wrong now — lib/lexical.phx is exactly that and is meant to be imported. The check moved from reading a description to using one, which is where it belongs: having nothing to parse with is only a problem when something asks you to parse.

What earns a place in lib/, and the rule that decides. A pass is only reusable together with the grammar that produces the nodes it keys on — a pass naming Binary needs something to build a Binary. So the natural module is a grammar fragment and the passes over it, never either half alone. That is why lexical.phx is all grammar and works: it is upstream of any node at all.

2026-09-01 — lib/expression.phx, and what building it taught

The expression module was proposed as the test of a rule stated the day it was written: a pass is only reusable together with the grammar that produces the nodes it keys on. Building it half-confirmed the rule and falsified what was expected to follow from it.

The rule held. show — rendering an expression back to something close to what was read, for putting inside a diagnostic — depends on the shape of these nodes and on nothing else, and it belongs with the grammar that makes them.

What did not hold is the expectation that there would be several such passes. There is one. A typechecker over these nodes needs the importing language’s type system; an emit pass needs its target. Both are the caller’s, so both stay the caller’s. A lib/ module is therefore mostly grammar, occasionally with a pass attached — and lexical.phx being pure grammar is not the exception it looked like, it is upstream of any node at all.

And the module needed something Phoenix did not have. An expression grammar cannot know what a language’s atoms are, which is the one thing every language differs about. So %require primary declares a hole: the description reads fine with it open, because that is what a module is, and is refused by name the moment something asks it to parse a file. A module system needs an interface in both directions, and the half saying what a module needs is the one easy to forget.

Two checks had to learn about incompleteness. A rule named by a directive and never defined is a mistake unless it was required. And a literal nothing spells cannot be judged while a description has holes — expression.phx writes "or" and the language importing it is what makes an or token — so that check waits for the assembled description, which is where it belonged anyway.

One bug only a three-deep import could show. A file with no %start used to assign the fallback goal rule, so calc-c.phx — which says nothing about a goal — silently unsaid the %start program that calc.phx had settled. A file that says nothing must not unsay something. The fallback now applies only when nothing has chosen yet.

What is still missing, and it is stage 3’s. show exists so that a typechecker can write “cannot add {} and {}” of $left.show, $right.show — and that needs show to have run before typecheck does. There is no way to sequence two passes yet, so the module’s one pass is written, tested and not yet usable for the thing it is for. That is the clearest argument for %driver so far, and a better one than ordering passes for tidiness.

2026-09-01 — stage 3

%driver names passes in order and which attribute of the root is the answer. The sketch survived, which is the first time that has happened; what it did not anticipate were three things that only appeared once passes could actually see each other’s work.

A threaded attribute is not a node attribute, and the collision check had to learn it. The first driver written produced a warning that typecheck and eval both define env and the later would win. They do not: thread is state belonging to the walk, declared per pass with its own starting value, never written where another pass could read it. Two passes threading an env each have their own. The warning was wrong and the distinction is worth having a name for.

$x.attr had to read fields as well as attributes. The check for a misordered driver flagged $left.text as a cross-pass read of something nothing defines — and it was right that nothing defines it, because text is a field. Reading a child’s field is an ordinary thing to want and was not possible: . read attributes only. It now reads a field first and an attribute second, which is the same order $name uses on the node being visited, so there is one rule rather than two. The driver check skips names that are fields in the vocabulary, since reading one is not a claim about order.

And a pass could not read what an earlier pass left on the node it is standing on. $name resolved through bindings, fields, threads and inherited values, and not through the node’s own attributes — so reading a previous pass’s work meant going through a child, which is a strange thing to have to do to read your own attribute. Added at position three, after fields.

What the stage was for, working: typecheck’s messages now render the offending expression with show, a pass that arrived with lib/expression.phx and knows nothing about calc —

print wants an int, and (n < (2 + n)) is bool

— which is exactly the thing that was written, tested and unreachable when the expression module landed.

One limit of the order check, stated because it is a real one. It sees $x.name and not a bare $name. A bare one may be a field, a binding, a threaded attribute or an inherited one, and deciding which would mean knowing what shapes reach the clause. So it catches a pass reading another node’s work, which is the ordinary case and the one that goes wrong, and stays quiet about a pass reading its own.

2026-09-01 — actions on Wirth’s Pascal

The point of doing this to Pascal rather than to another toy is that the grammar was written by somebody else, for another purpose, years before Phoenix existed. languages/pascal/pascal.phx is languages/pascal/tests/grammar/pascal.bnf with -> clauses added — the fixture is left unmodified, because three tests depend on it being a real published grammar that Phoenix reads without being met halfway.

It works: 51 node types, both good programs building an abstract tree with no punctuation in it, all four bad ones still refused, and an outline pass that reads packed arrays, pointer types, records, sets, enumerations and var parameters back out. Five things had to change, and three of them are lessons about the notation rather than bugs in it.

$$ needs its fold and its base in one list

simple-expression = [ "+" | "-" ] term { op term -> Binary(op: $1, left: $$, right: $2) }
                  -> Signed(sign: $1, value: $2) .

That was the natural way to write Wirth’s leading sign and it cannot work. $$ is what the enclosing sequence built last, and when that sequence carries an action of its own every factor is gathered separately — so that $1 can mean the first factor — and the term before the repetition is in a different slot from the repetition. $$ looked in an empty one.

It was a message from inside the matcher, at parse time, on a file that was fine. It is a message about the grammar now, with the fix in it: give the fold a rule of its own. Which is also the better decomposition, and is what the grammar wanted all along.

An option is not a choice, and the difference is a node per program

[ integer-number ":" ] statement -> Statement(label: $1, do: $2) builds a Statement around every statement in the program, nearly all of them holding an empty label. Written as two alternatives, a Labelled node exists only where there is a label and means something wherever a pass finds one.

The same mistake was in four places — Signed around every expression, Packed around every structured type, Constant around every constant, Statement around every statement. A node that is nearly always empty is not worth having, and the notation makes the better version no harder to write.

A separator has to be dropped where it is matched

{ "," identifier } answers everything it matched. The commas were in the tree. A repetition that means “a list of these” carries an action — { "," n:identifier -> $n } — and there is no way around that, because the repetition is the only place that knows which of its factors was the payload.

Patterns could not match a boolean

Param(byref: true) read true as a lower-case name, which is a binder, which matches anything. The outline printed falseu, v for a parameter list. true, false and nil are values in an expression and are values in a pattern now — reading them as binders is the sort of mistake that matches everything and looks like it worked.

And the absence of if was right

function-declaration has an optional result type. A pass reading $returns.show would have to ask whether there is one — except there is no if to ask with. The answer the notation forces is to give absence a name:

returns = ":" t:identifier -> NamedType(name: $t)
        | -> NoType .

and NoType : show = "". That is better than the conditional would have been, and it is the clearest evidence so far for 3.5 being the right call. The other half of the same story is Param(byref: true) and Param(byref: false) as two clauses: shape matching is the conditional, and it reads better than one.

What the shared expression module could not do

lib/expression.phx was not used. Pascal’s operators are div mod and or not in, its factor includes a set constructor and a function call, and its leading sign attaches to the whole simple-expression. None of that fits the module, and bending the module to fit would mean reserving div, mod and in in every language that imports it.

So the module is less reusable than it looked, and the reason is worth keeping: an expression grammar is not one thing. What is shared between calc and Pascal is the shape — precedence climbing, a fold per level, a hole at the bottom — and a shape is not a module. That is a real limit on what lib/ can hold, and it was found by the second language rather than argued about.

2026-09-01 — stage 5, and the last decision was the same as the first

phx desc.phx -o desc.c writes a description out as a C program that is its compiler. One file, cc desc.c -o prog, no flags and no headers.

Tables, not code, and it is the same argument as everything else here. A generator can emit a recursive-descent function per rule and a switch per pass, which is faster and is what the yacc family does. Or it can emit the description as data and ship the machine that already runs it. The first means a second matcher with the same ordered-choice rules, a second evaluator with the same floored division, a second pattern matcher — two implementations of one notation, which have to agree. Avoiding that is why actions are not host-language splices, why docs/semantics.md exists, and why phx --run eval and --run emit-c are tested against each other.

So a generated compiler is the runtime — support.c, eval.c, library.c, lex.c, parse.c, run.c — written into the file verbatim, plus the grammar, passes and drivers frozen as static tables, plus a main. It cannot disagree with phx about what a description means, and the test asserts the strong form: byte for byte identical output.

That also drew a line through the codebase that had not been drawn before. The runtime runs a description; the front — grammar.c, check.c, expr.c, pass.c, emit.c — reads one, and a generated compiler has no use for it because it will never see a .phx file. The Makefile names both.

Three things went wrong, and all three were the runtime becoming one translation unit.

A static initialiser in C may not name a variable. Emitting static Expr *cv = &x5; and then using cv in an aggregate does not compile. The ids are kept and &x5 written into the aggregate directly.

Four static functions collided. fail in eval.c and library.c, and lower, match and same in both lex.c and parse.c. Renamed — which is better hygiene than it was before, since the two matches do genuinely different things.

And the emitted tables shared a namespace with the runtime. reserved is a table here and a function in parse.c. Everything emitted is phx_-prefixed now.

One bug was worth more than the three. emit_gnode wrote NULL for a node’s action. The generated compiler parsed every program correctly and answered nothing at all: the grammar was entirely there and every -> had vanished. It is the failure a table emitter is most likely to have — a field that is only sometimes present, forgotten in the one place it is written — and the symptom points nowhere near the cause.

2026-09-01 — Pascal that checks programs

languages/pascal/pascal.phx now has a symbols pass and a typecheck pass, and --driver check reports undeclared names, bad assignments and non-boolean conditions on real Pascal. Both fixtures check clean; a program wrong in four ways has all four found, each at the right column.

The two-pass shape was forced rather than chosen, and it is the clearest justification %driver has had. A Pascal block declares things and then uses them, and a nested block sees the enclosing one’s names as well as its own — which is an inherited attribute, handed down. But an inherited clause runs on the way in, before this node’s children have been looked at, and gathering a block’s declarations is bottom-up. One walk cannot do both.

Two passes can: symbols computes Block.scope on the way up, and typecheck reads it on the way in, because a previous pass’s work is on the node before this pass arrives. That is collect-then-use, which is what a hand-written compiler does, and it is what %driver exists to write down.

I reached for two library functions and needed neither. assignable and condition are what a checker wants, and both are ordinary expressions:

! not ($target.type = $value.type
       or $target.type = "unknown" or $value.type = "unknown"
       or ($target.type = "real" and $value.type = "integer"))

Putting that in library.c would have made Pascal’s assignment rule Phoenix’s opinion about Pascal, which is exactly what a description is for saying. The library did grow by two, and both meet the bar in library.c’s header: flatten, because [...$vars.entries] opens one level and a declaration list is a list of lists and there is no fold in the notation; and a third argument to lookup, which is the same operation with an answer for absence.

Type aliases needed one idea. var i : Small where type Small = 1..Limit binds i to "Small", and the checker compares "Small" with "integer". Resolving that is a second lookup — and the trick that makes a second lookup always safe is binding every base type to itself, so integer answers integer and Small answers integer, and nothing has to ask which it was looking at.

Two places it says nothing, and one of them is the roadmap’s first real argument

with origin do writeln(x, y) opens a record’s fields into scope. Knowing which fields those are means knowing the structure of Point and not just that origin is one, and this table maps a name to a type’s name. So names are not checked inside a with.

function Area; after function Area(r : real) : real; forward; repeats the heading, and its parameters belong to the declaration it repeats. Finding that declaration is a reference to another node, and an attribute computed in one walk cannot reach sideways. So names are not checked in there either.

That second one is worth more than the checking it costs: it is the first concrete argument for reference attributes, which until now was a thing the literature offered rather than a thing this project needed. The rule has been do not build it speculatively, and it is no longer speculative.

And composing descriptions found a wart

pascal-outline.phx imports pascal.phx, and an import is read first — so “the first driver declared” handed the default to the imported description, and running the outline description ran the checker instead. A file that says nothing must not unsay something was the rule the last import bug taught; this is its other half: a file that says something must outrank the file it imported. The default driver is now the first one declared in the file that was named.

2026-09-01 — the reference attributes that were already there

The two places the Pascal checker said nothing — with origin do ... x ... and function Area; repeating a forward heading — were filed as needing reference attributes, a JastAdd feature that would have brought demand-driven evaluation back with it and undone a decision stage 2 made deliberately.

Both are now solved, and nothing was added.

The realisation is that a Phoenix value can be a node. bind(env, name, node) stores one and $x.field reads one wherever it came from, so an environment that binds a name to the thing it was declared as, rather than to the name of its type, is an ordinary list of pairs and was always expressible. Following a with is then four hops:

origin   -> NamedType(name: "Point")     the variable's declared type
"Point"  -> RecordType(fields: [...])    what that type is
fields   -> what each FieldDecl declares

They work because every hop points backwards — at a node the one post-order walk has already visited and finished with, whose attributes are therefore computed and sitting on it. That is the whole of what made them look hard and the whole of why they were not.

The forward case is the same shape: a routine binds its name to its parameter list, the repeated heading looks that name up, and lookup answers the first match — which is the earlier declaration, because the routines are in document order.

What this does to the roadmap

§2.1 shrinks to one sentence: what is missing is a reference that points forward, to a node the walk has not reached. Pascal barely has one — forward exists precisely so that it does not — and a language that did would be one with mutually recursive types and no forward declaration, or one where a method may be used above where it is defined.

Two things are worth taking from that. The discipline paid. The rule has been do not build a roadmap item speculatively, and the item that finally looked justified turned out to be four-fifths unnecessary; building it when it was first written down would have added demand-driven evaluation to solve a problem that a list of pairs solves.

And §2.3 should be read more sceptically now. Scope graphs were filed against with blocks specifically, and with turned out to need nothing. A mechanism named in advance against a difficulty nobody has met yet is a prediction, and this project has now had one prediction fail in the useful direction.

One thing Phoenix caught that I had wrong

symbols computed Block.shapes on the way up and typecheck handed down something different under the same name. The driver’s collision check said so — both ‘symbols’ and ‘typecheck’ define ‘shapes’, the later one wins — which is a check written for a hazard that had not yet happened, catching the first time it did.

2026-09-01 — Pascal to C

languages/pascal/pascal-c.phx is %import "pascal.phx" and an emit pass. languages/pascal/programs/primes.pas compiles to C that cc -Wall -Werror accepts, and the program is right — ten primes below thirty, the last of them 29, and Gcd(1071, 462) is 21. The whole chain has nothing in it but cc.

The subset is written at the top of the file rather than discovered: in are scalars, subranges, one-dimensional arrays, records, value and var parameters, the control structures and write; out are sets, files, pointers, goto, case, with and nested routines. Phoenix names the missing clause the first time a program reaches for one, which is the right way round.

What C made awkward, and what the notation had to say about it

A C type is written around its name — long v[30], not long[30] v — so a type answers two pieces, pre and post, and every declaration is {pre} {name}{post}. Writing C’s declarator rule out once is cheaper than special-casing arrays in the four places a type is declared.

An array parameter is by reference in C whether it says so or not, because an array decays to a pointer. So Pascal’s var f : Flags is a plain Flags f with no dereference, while var r : Result is Result *r and every use is (*r). Telling them apart needs the parameter’s type resolved, which only the typechecker can do — so the typechecker computes it and emit-c reads it off the node. That is what a sequence of passes is for.

A call has to know which of its arguments to take the address of. The routine was declared earlier and its name is bound to its parameter list, so this is another backward hop — the same mechanism as the with statement, used for something that looks nothing like it.

The conditional the notation does not have, four times

There is no if, and four times something needed one. Every time the answer was a table:

lookup([["array", ""]], $shapeof, "*")              a pointer, unless it is an array
lookup([["integer", "%ld"], ["real", "%g"], ...], $value.type, "%ld")
lookup($spelled, $name, $name)                      a name C spells differently
lookup([["array", "{}"]], $shapeof, "(*{})")        which template to apply

lookup with a default is the conditional, and reading these back they are better than the ifs would have been: each one is a small explicit table of what differs, with everything else falling through to a default that is right. 3.5 has now survived a real compiler.

Three findings, and cc -Wall found the worst one

A field is read before an attribute, and a field can shadow. Block has a field called routines and I computed an attribute of the same name; the field won, silently, and the message was about a list where text was wanted. The rule is right — one rule for $name and $x.name both — and the hazard is real.

A node built inside a clause has fields but no attributes. A pass puts attributes on the nodes it walks, and NamedType(name: "?") as a lookup default was never walked, so asking it for .tname fails. Obvious in hindsight, and not before.

And an inherited attribute is not on the node either. The driver’s collision check said typecheck and emit-c both define known and the later would win. They do not: a down attribute lives in a scope pushed on the way into a node and popped on the way out. That is the second time this check has been wrong in the same way — thread was the first — and both are the same fact said twice: only a synthesised attribute is on a node. It is one condition in check.c now and the comment says why.

The worst bug was a writeln inside an if. writeln(x) is two C statements, a printf and the newline, so if (c) writeln(x); put the newline outside the if and printed it every time. cc -Wall said misleading indentation and it was right — which is a good argument for a test that compiles what the compiler emits rather than only reading it.

2026-09-01 — gcd.pas compiles

The Pascal subset now covers languages/pascal/tests/grammar/gcd.pas, which is the file that has been in this repository since the first commit and was written for another tool years before Phoenix existed. It compiles to C that cc -Wall -Werror accepts, and every line of its output is right — the record through a with, the enumeration through a case, the set membership, the field widths.

with cost four lines, and that is the finding. C has nothing like it, so the fields have to be spelled origin.x inside the body — which is a table of names spelled differently, handed down. That table already existed, for var parameters. A construct C does not have turned out to need no mechanism, because the mechanism it needs was built for something that looks nothing like it.

An enumeration became a C enum rather than a long and a run of #defines, which means nothing has to count: C numbers enum members from nought exactly as Pascal does. That is the second time the answer has been emit the C construct that already agrees rather than emit the arithmetic, the first being array parameters, which C passes by reference whether you ask or not.

A set is a bit per member in a long, and the empty set is why SetOf emits (0 | ...): the leading nought makes [] come out as (0) instead of (), without a second clause for a case the arithmetic already covers.

Two things the library did not have, and one it did not need

slice and split. A Pascal string arrives with its quotes on and its doubled quotes undoubled, and turning 'it''s' into "it's" cannot be written without them. They are ordinary operations on bytes and their absence was a real gap rather than a Pascal-shaped one.

What it did not need was a way to number a list. That was wanted for enumeration members, and choosing C’s enum removed the want — which is worth noticing, because the first instinct was to add indexed() to the library and the better answer was to emit different C.

The lesson that keeps arriving

Argument(width: nil) could not be told from Argument(width: something), because a pattern matches a node type and cannot ask whether a field is nil. So Argument, Padded and Rounded are three node types.

That is the fourth time: Labelled, Packed, Signed, Accessed, and now these. An option is not a choice, and every time the grammar has been asked to make a distinction it already knows, the passes have got simpler and a node that was usually empty has stopped existing.

2026-09-01 — the notation, described in itself

languages/phx/phoenix.phx is the .phx notation written in .phx. It parses itself, and it parses every other description in this repository.

This is level one of the three the earlier entry set out, and the only one that is free: it describes the shape of a .phx file and replaces nothing. The reader in grammar.c stays, and the PEG machine and the scanner are not derived from any grammar and never could be.

Four things came out of it. Three were improvements to Phoenix and one is a gap that stays.

An attribute should be its own token

grammar.c tells $left.val from $left . by whether a space precedes the dot — a special case in the scanner, and the one place in the notation where whitespace changes a meaning. It has been in the README’s list of warts since stage 2.

Described in the notation it is a lexical rule and there is no special case: an attribute is a dot with a lower-case letter immediately after it, so .val is a token and . cannot be one. Longest match does the rest.

And it is better than the rule it describes, because it covers something the reader’s rule was never about. at($vars, 1).name is an attribute of a call, and there is no $ for a space to be adjacent to; the reader handles it by accident of where read_postfix sits, and the description handles it by saying what an attribute is.

A directive could not end at a line, and no longer has to

%fragment letter digit ends where the line does, and a grammar matched over tokens cannot see a line — the scanner threw it away. Phoenix now accepts a . after a directive, which is how every other construct in the notation already ended, and every description here was updated to write one.

Reserved words are automatic, and this is the file guaranteed to mind

Every word-shaped literal in a syntactic rule becomes a reserved word, worked out from the grammar rather than declared. It is a good rule. It is also why of, and, or, not, div, mod, thread and down — all operators in this notation — became unusable as field names, and pascal.phx writes of: $t and means a field.

Phoenix does not reserve them: its reader knows an operator from a field name by where it is looking. The notation cannot say that, so the description has a rule word listing the words a plain name will also accept. It is the wart the README already called a grammar module imposes reserved words, met in the one description certain to meet it.

The optional production terminator stays undescribable

grammar.c lets a production end without its ., by looking two tokens ahead for a name followed by a definition symbol. That is syntactic negative lookahead, and ! is lexical only — refused in a syntactic rule on purpose, because there it would be asking about characters where there are only tokens.

Nothing is lost by requiring the terminator: every .phx file here writes one. But it is a feature of the notation that the notation cannot state, and it is the answer to the question this exercise was for.

And two mistakes worth keeping

! "" never matches. The first attempt at a string escape was "\\" ! "" — a backslash and then anything — and there is no anything in this notation: ! x is one character provided x does not match, an empty literal always matches, so ! "" always fails. The escapes are enumerated instead, which is more accurate anyway.

A grammar can describe a call and forget that a call can be reached into. Name(field: value) was described as a function call with positional arguments, so Description(items: $1) failed on the colon; and postfix was missing entirely, so at($vars, 1).name failed on the dot. Both were places where the reader does something so ordinary that writing the grammar down was the first time anybody had to notice it was a rule.

2026-09-01 — and the reader took the description’s advice

.val is a token now. The scanner makes one when a dot is immediately followed by a lower-case letter, and the expression reader has nothing left to decide.

This is the change languages/phx/phoenix.phx argued for by being written: the notation could only describe its own attribute access by making it lexical, and having done so it was plainly the better rule. The tight flag is gone from every token, along with the stamping that maintained it, and the wart that has been in the README since stage 2 with it.

Three things it fixes rather than moves:

at($vars, 1).name was working by accident. The old rule asked whether a space preceded the dot, which is a question about a reference — and an attribute of a call has no reference before it. It worked because read_postfix happened to sit after read_primary. Now it works because an attribute is an attribute.

The scanner no longer tracks what preceded a token. Maintaining tight meant stamping every push site with where the previous token ended, in eleven places, and getting one wrong would have been a silent misparse.

The rule is now written where a rule belongs. It was a condition in expr.c, one call deep, in a loop about something else. It is a scanner rule about what a token is, which is what it always was.

2026-09-01 — measured, and it found a segfault

bench/ and performance.md. The question was never how fast; it was whether the work grows in proportion to the input, because a PEG with no memoisation need not and Phoenix has none. A constant factor is a decision and a bad complexity is a defect.

It is linear, in all four shapes tried — statements in sequence, one long expression, nested parentheses, nested blocks — at about 24 match-steps per token, flat across sixteen times the input. There is no bad case in Pascal’s grammar, which is a thing nobody had checked. Steps are deterministic, so that is the curve exactly rather than a measurement of it.

And measuring found a crash nobody suspected. Matching is recursive descent, so the C stack is proportional to how deeply the input nests, and about 3,400 nested parentheses exhausted it: a SIGSEGV, which tells a person nothing, cannot be caught, and arrives on input a compiler does not get to trust. There is a depth limit now and a message with a position. Real programs reach 62 to 99; the limit is 10,000.

That is the whole argument for measuring. The linearity result confirmed what was hoped, which is worth having written down and is not why it was worth doing. The crash would otherwise have been found by somebody else’s malformed file.

One number is worth keeping for what it says about stage 5. The generated compiler is 18% faster than phx on the same input, and all of the saving is fixed cost — it neither reads a description nor checks one. On a small program that is most of the run and on a large one it is noise. The generated compiler’s value is that it stands alone, not that it is quick, and it is better to know that than to have assumed either way.

And fpc fails on the 20,000-line file — “Procedure too complex, it requires too many registers” — where Phoenix does not, because register allocation was never Phoenix’s problem. That is not a point in Phoenix’s favour so much as a description of what emitting C means.

2026-09-01 — the oracle, and the six bugs it found on the first run

languages/pascal/tests/oracle/ compiles the same Pascal with fpc -Miso and with Phoenix, runs both, and compares byte for byte. fpc is the oracle: where they differ, Phoenix is wrong until somebody shows otherwise, because fpc has been read by more people than this repository has. It is the method Solveig’s PASCAL.md uses, for the same reason.

Eight programs. The first run had one of them agreeing.

| | | | — | — | | mod was C’s % | ISO Pascal’s i mod j is the non-negative remainder and wants j > 0: -7 mod 2 is 1, not −1, and 7 mod -2 is a runtime error. There is a phx_mod helper now | | an integer had no default width | Pascal right-justifies in eleven, so writeln(15) writes nine spaces first. Every unwidthed integer in every program was wrong | | a boolean printed as 1 | it is ` true and false, in a field of five | | **a real printed as 1.5** | it is 1.5000000000000000e+000 — scientific, sixteen fraction digits, and a **three**-digit exponent where C's %e gives two. There is a phx_real helper now | | **a recursive function would not compile** | the result variable took the function's own name, so Fact := n * Fact(n-1) became Fact = n * Fact(n-1) with Fact a long. The result is phx_result now, and *reading* the function's name is spelled that way while *calling* it is not, because a call is a different node | | **Signed was two things** | a signed *constant* and a signed *expression* shared a node, and symbols asked a Variable` what type of constant it was |

Every one is a place C’s obvious answer is not Pascal’s, and not one would have been found by reading the output and thinking it looked right. Three of them — the widths — had been in front of me the whole time, in primes.pas’s own expected output, which I wrote down from what Phoenix produced.

What it cost to fix, and what that says

Nothing was added to Phoenix. All six are changes to languages/pascal/pascal-c.phx, and the two that needed C that Pascal has and C does not — phx_mod and phx_real — are emitted by the description into every program it compiles. A description that needs a runtime writes one.

Two notation things came out of it. of does not chain, so a template built by lookup is filled once and not twice. And adjacent string literals do not run together, so a multi-line C helper is join([...], "\n") — which turns out to be better anyway, because the braces in C code are then values rather than template syntax and none of them has to be doubled.

And one thing the oracle cannot check

fpc -Miso has 32-bit integers and Phoenix has 64-bit, so maxint differs and so does where arithmetic overflows. The oracle would catch it only on a program that overflows, and such a program has no agreed answer to compare. It is a divergence in the sense Solveig’s PASCAL.md uses the word: written down, not fixed.

2026-09-01 — six more oracle programs, four more bugs, two of them in Phoenix

The standard functions, chars, enumerations, sets, case with several labels, and a with inside a with. Fourteen programs now agree with fpc -Miso byte for byte.

Two of the four were in Phoenix, and both were silent.

each ran to the length of its first list, so everything the second had beyond it was dropped without a word. abs(i) came out as abs(): the first list is what a call puts before each argument, and a call to something the description does not declare puts nothing before anything — so the first list was empty, and so was the answer. It runs to the longer of the two now.

lookup compared only text, so an integer key never matched and never said so. lookup([[1, "char"]], size(t), "string") quietly answered "string" for every length, which is exactly the shape of bug a table-instead-of-a-conditional design invites. It compares the way = compares now, which is what it should always have done: one notion of equality, not two.

Both are the same kind of fault — a silent wrong answer from a library function, where an error would have been found in a minute. Neither would have surfaced without an oracle, because both produce output that looks plausible.

The other two were Pascal facts. None of abs, sqr, odd, ord, chr, succ, pred, round, trunc is a C function, and two of them depend on their argument’s type — which the typechecker knows and leaves on the node. And a one-character Pascal literal is a char, not a string: c := 'a' was a type error and ord('A') took the address of a C string literal.

And a nested with needed one word changed. The prefix a with puts in front of its fields has to be the spelled form of its own variable, not its name — inside with o do with p do ..., p is already o.p, so a becomes o.p.a. Looking the prefix up in the very table the clause is extending is the whole of it.

2026-09-01 — four more oracle programs, and the one an oracle cannot catch

Array bounds, set operators, for over chars and enumerations, and two-dimensional arrays. Eighteen programs agree with fpc -Miso byte for byte.

One of them agreed while the compiler was corrupting memory, and that is the thing worth writing down. array [5 .. 9] had one subtracted from every index instead of five, so it wrote elements 4 to 8 of a five-element array, and array [-3 .. 3] wrote element −4. The oracle passed it. It passed because the write and the read used the same wrong offset: the answers matched and the memory did not.

An oracle proves agreement, not correctness, and a program that only reads back what it wrote is exactly the shape that hides this. languages/pascal/languages/pascal/tests/oracle/run.sh says so at the top now. What found it was reading the emitted C, which is the other half of the method and the half that does not scale.

Fixing it properly took the longest chain in the description so far — a variable to its type, a named type to what it stands for, and that to where its index range starts — and two things had to change underneath.

A lookup’s default has to be constructible, and a node built in a clause has fields but no attributes. So shape binds a name to a Shape(iname:, startsat:, node:) wrapper, and every hop reads fields rather than attributes.

And a field shadows an attribute of the same name. Subrange has a field called low, so a computed low was invisible from outside — the second time this has happened, after Block.routines. Renamed to startsat. The rule is right and worth restating: $x.name is a field first and an attribute second, and a computed attribute must not take a field’s name.

Two smaller things. A pass builds nodes too, and the vocabulary was gathered only from productions — so the driver’s check reported a field of one as an attribute nothing defines. And a down clause does not overwrite a node attribute, which is the third time the collision check has been wrong the same way; it now counts only synthesised clauses, which is the one condition that was always meant.

Member is the fifth time an option should have been a choice. A set constructor could not ask whether an element was one bit or a run of them, so it shifted a range that was already a mask.

2026-09-01 — the other half of correct

An oracle says the subset is right. It says nothing about what is outside it, and that is the half where a mistake looks like success.

set of 0 .. 200 compiled quietly and answered no where Pascal answers yes. A set is a bit per member in a long; the two-hundredth bit is not there. Nothing said so until a program was written that asked, and the oracle would never have asked, because a program using a big set is not a program anybody writes by accident.

languages/pascal/tests/refused/ is that half now: programs that must fail, each with a message naming the feature, at a position in the Pascal. Four of them — a nested routine, a set too large, pointers, files — and the test insists on both halves of the message, so a refusal that says ‘Pointer’ has no attribute ‘pre’ in pass ‘emit-c’ counts as a failure. That is a true sentence about the description and no use at all to somebody holding a Pascal program.

Three things came out of writing them.

goto turned out to be in rather than out. C has labels and goto, so it is two clauses — and it is the one place emitting C is strictly better than emitting Solveig would have been, which Solveig/docs/PASCAL.md predicted from the other side: a translator into source has no control-flow syntax to translate a goto into.

A node cannot ask a question about itself that its own down clause answers. Procedure : down inroutine = true with a check reading $inroutine sees its own value: the scope is pushed on the way in and is still there when the checks run on the way out. The block inside it is what can ask, and the program’s block gets the program’s answer. Obvious afterwards, and the false positive was on every top-level routine in the suite.

And a check is introduced by ! alone, not by : !. I wrote the latter twice. The message — expected the name of an attribute — is accurate and points at the !, which is not where a person would look.

2026-09-01 — six more oracle programs, and a second thing that agreed while wrong

Strings, mixed arithmetic, partial lines, a dangling else, records inside arrays inside records, and enumerations ordered against each other. Twenty-four programs agree with fpc -Miso.

Two of the six were wrong in ways the oracle had already been passing.

'abc' < 'abd' compiled to ("abc" < "abd"), which compares two addresses and is undefined — and it agreed with fpc, because the compiler had pooled the identical literals and laid the others out in order. That is the second time an accident has looked like a pass, after the array bounds; both were found by reading the emitted C rather than by the oracle. strcmp now, and each comparison has a clause of its own because the two templates want their operands in different places.

b.name is a char and was printed as 66. The accessors were a flat list, and the comment written at the time said turning that list into a nest was “a decision a pass can make with more context than this rule has”. It was not: a pass has to know what b.corners[2] is before it can say what .x is, and a flat list has nowhere to hang that. They nest now — Indexed(of:), FieldOf(of:), Deref(of:) — and each step asks the step below it.

The chain works in type names, not type nodes, and that was the second attempt. Nodes needed a default wherever a lookup might miss, and a node built in a clause has fields but no attributes — so $tnode.iname failed on every name the scope did not hold, which is every enumeration constant and every built-in. Text has no such trouble: named is the type as written and type is that resolved, both plain strings, and a default is just another string.

That also deleted the down lowbound hack. An array’s lower bound belongs to the Indexed node that uses it, and with a nest there is finally an Indexed node to put it on.

And a check cannot read an attribute its own rule defines. A check is a guard and runs before them. FieldOf’s check asks the child instead, which is where the answer was anyway.

2026-09-01 — a directory per language

Pascal had spread: three descriptions in examples/, its programs beside calc’s, its grammar fixtures in tests/pascal/, its oracle in tests/oracle/ and its refusals in tests/refused/. None of that was wrong while there was one serious language, and all of it would have been in the way of a second.

languages/
  pascal/   the descriptions, its programs, and its own tests
  calc/     the same, smaller
  phx/      the notation described in itself
tests/      tests of Phoenix rather than of any language

The distinction that matters is the last line. tests/ was holding two kinds of thing: whether Phoenix reads a description correctly, and whether the Pascal description describes Pascal correctly. The first belongs to the tool and the second to the language, and only one of them multiplies when a language is added.

The move was mechanical and the tests caught every path it broke, which is the argument for having had them. Nothing in phoenix/ changed, and no description needed editing beyond the paths in its own header comment — %import resolves beside the file that names it, so the descriptions moved together and kept working without being told.

2026-09-01 — an array passed by value was not copied

Six more oracle programs — by-value parameters, a for limit, case on a char, records inside arrays inside records, expressions as loop bounds, whole record assignment. Thirty programs agree with fpc -Miso.

Two more silent wrong answers.

Pascal copies an array passed by value and C does not, because a C array decays to a pointer — so TouchArray(r) wrote the caller’s r. An array is wrapped in a struct now: struct { long v[5]; }, indexed through .v. A struct copies, so the C does what the Pascal says.

And that deleted a special case rather than adding one. var array parameters had needed different treatment from every other var parameter — no pointer and no dereference — for exactly the reason that arrays decayed. With a struct nothing decays, every var parameter is a pointer, every use is (*name), and the table that chose between the two spellings is gone along with the shapeof attribute that fed it.

A for limit is evaluated once in Pascal, before the loop, so a body that changes what the limit was computed from does not change the loop. C re-evaluates it. The limit gets a variable in a block of its own, which also means a nested for shadows it rather than clashing with it.

Neither would have been found by reading the emitted C, which is how the last two were found. a[1] := 99 inside a procedure is right-looking C, and for (i = 1; i <= n; i++) is what anyone would write. It took a program that asked, which is what an oracle is.

2026-09-01 — mutual recursion, and two orderings said again

Five more oracle programs — evaluation order of and and or, recursion to depth 500, mutual recursion through forward, stray semicolons and empty statements, and the boundaries of enumerations and char. Thirty-five programs agree with fpc -Miso.

Four of the five passed first time, which is the first time that has happened and is worth noting for what it says: the bugs are getting harder to find, and the ones left are in the corners rather than in the middle.

forward was the one. A forward declaration is a C prototype and the heading repeated after it is the definition, whose parameters and result type belong to the declaration it repeats. The routine’s name is already bound to a shape; the shape carries the result type as a name now, so finding it is one lookup — a backward reference, like every other hard thing in this description.

That the shape had to carry a name rather than the type node is the same fact as always: a lookup needs a default, a default has to be constructed, and a constructed node has fields but no attributes. Text needs no default worth worrying about.

And two orderings had to be said again. An inherited clause runs on the way in and a synthesised one on the way out, so down spelled cannot read a forward attribute computed in the same rule — the lookup is written out twice. And Phoenix’s own warning caught a Shape built with four fields in one place and three in another, which is a check written for a hazard that had not happened, catching the second time it did.

The evaluation-order test is the interesting pass. ISO Pascal does not promise short-circuit evaluation and C promises the opposite, so Note('a', false) and Note('b', true) could have printed ab under fpc and a under Phoenix. It prints a under both: fpc short-circuits too. A divergence that was expected and is not there is worth a test, because the next compiler might not.

2026-09-01 — three checks, from three mistakes made twice

Writing languages/pascal/ I hit the same three foot-guns repeatedly. All three are decidable from the description alone, which makes them Phoenix’s business: a description is read once and then run over every program ever compiled with it, so a fault found while reading it is found before anybody else sees it.

All three are about when a clause runs. A node is visited in two phases: down clauses on the way in, then the children, then the checks and the synthesised attributes on the way out.

   
an attribute with a field’s name a field is read before an attribute, so Block.routines and Subrange.low were invisible from outside their pass. A warning, since it is legal and merely almost never meant
a down clause reading its own rule’s attribute inherited runs on the way in, synthesised on the way out. An error
a check reading the attributes it guards a check runs before them. An error

Each was tried against the mistake that motivated it, by putting the mistake back: Subrange : low = ... warns, and the down spelled = ... $forward ... that cost me twenty minutes is now a message naming the rule and the reason.

And the check found one I had not noticed. Param : type = "void" — Param already has a field called type, so the attribute was never visible from outside the pass, and nothing read it. It had been there since the typechecker was written, doing nothing, because a clause was added for uniformity that the uniformity did not need. Deleted.

That is the argument for this kind of check in one line: it is not that I would have made the mistakes more slowly, it is that one of them was in the tree, harmless, and would have stayed.

2026-09-01 — Solveig, and a conformance suite before a compiler

The next language is Solveig, and the first thing built for it is not a description — it is a conformance suite, so that the target exists before anything aims at it.

The grammar half is already free. Solveig ships programs/check_syntax/solum.bnf, its published grammar in Wirth’s notation, held character for character against docs/GRAMMAR.md. Phoenix reads it unmodified and parses all 63 .sol files in that repository — every example, every program, every library file. The same result pascal.bnf gave, from a grammar written for another tool.

Why a conformance suite is a different thing from Solveig’s own tests. tests/*.c there check the internals of solas and solvm, which is the right job for an implementation to do to itself. A conformance suite checks the language: programs and the output each must produce, which can be handed to anything claiming to compile Solveig. There was not one, and there is now — nine programs asserting what CHEATSHEET.md and REFERENCE.md say the language is.

The expectations are read against the documentation rather than taken on trust, which is the difference between a suite and a set of fixtures. Where the two disagree that is a finding. Nothing disagreed: floored division, byte-counted strings, trapping overflow, infinity for float division by zero, join strict about strings, and checking for a block when it is sent — all as written.

One thing that looked like a disagreement was not. display writes the value and a newline, which CHEATSHEET.md says explicitly of print and not of display — and REFERENCE.md shows #3:repeat({ "tick":display }) producing tick tick tick on one line. Measured, it produces three. The inline comments in those docs render output space-separated, which is a convention rather than a claim; the three-line reading is right.

And two of the programs were wrong before the compiler was. join over integers is an error, not a conversion, and I had written it expecting a conversion. That is what a suite written against documentation is for: it caught me rather than solas.

2026-09-01 — the Solveig front end, and what a second language proved

languages/solveig/solveig.phx is the published grammar with -> clauses added. Fifteen node types where Pascal needed fifty-one, because Solveig has one thing that happens and Pascal has many.

Every .sol file there is — 75 of them, including a 2,800-line compiler written in Solveig — parses, renders back out, and parses to an identical tree. The twelve conformance programs get the stronger form: what comes out is compiled by solas, run, and prints what the original printed.

What the second language proved about the notation

It needed nothing new. No library function, no check, no mechanism. That is the answer to the question the whole exercise was for: a notation that suited fifty-one node types suits eighteen, and a language with statements, types and infix did not shape it against a language with none of those.

And the lessons transferred exactly. The two mistakes I made writing it were both ones Pascal had already taught:

The second is worth stating as a rule, since it is now three for three: a fold has to be where the accumulator is. Pascal’s accessors, Pascal’s signed expression, and now Solveig’s send chain.

The one real finding

solum.bnf lets the operator ladder take a leading minus, so -1.5:truncated reads as negated(1.5:truncated) — the sign applied to the whole chain. solas reads it as (-1.5):truncated, because outside an @expr region its scanner makes no minus: ’-‘ must be followed by digits. The two differ, #-1 against #1.

The grammar file names this itself — “that is the looseness this ladder costs” — and a grammar checked against files rather than against a compiler can afford it. This description follows solas, which is the one place it departs from the published grammar and the only place it should.

It was found by the round trip, on the two conformance programs that write a negative float, and by nothing else: the tree round-tripped fine, and only running the rendering through solas showed the difference. That is the same division of labour the Pascal oracle taught — reading the output catches what is wrong on its face, and running it catches what is wrong only against the language.

And the operators are not in the tree

@expr(a + b * c) produces Send(a, add, [Send(b, mul, [c])]). The language says the operators are a second spelling and never a second semantics, so the tree says so too — and & and | wrap their right side in a block, because and and or short-circuit. That is the one place the lowering is more than renaming, and it is the sort of thing a description is for saying.


Stage 7 — a binary target, and what it took

The .sob backend (languages/solveig/solveig-sob.phx) compiles Solveig to SolVM bytecode, which solvm runs. Every emit pass before it produced text, where the output followed the shape of the tree and a parent wrapped its children. A bytecode file does not work that way, and the roadmap had claimed since stage 2 that a flat target is where threaded state earns its keep without ever testing the claim.

The claim held, and it was not enough. Three things had to be added, and each one is a place where the notation was short rather than a place where the target was strange.

bytes, and int with a base

A description that emits a binary format cannot do without a number as bytes, and it cannot be written in the notation. bytes(n, width) is little-endian and one to eight wide; bytes(f, 8) of a float is its IEEE 754 bits, which is the only reading of “this number as eight bytes” a binary format ever wants.

int(text, base) is the same generalisation of something that was already there. Solveig writes #45, $ff and %1010 and they are one node, so the marker says the base and the base has to be sayable.

positions, and slots

A block’s locals are slots numbered from zero, and a name’s slot is where it is in the list of the frame’s slot names. Nothing could reach that: at wants an index and nothing produced one. positions(list) answers the table saying where each thing is — a table rather than a list of indices, so that it composes with lookup, which is how every other question in the notation is asked.

With it, scoping is three lines and no machinery: a name is a slot if the frame has one by that name, an OP_OUTER if a frame further out does, and a global otherwise.

down on a threaded attribute — the real one

A thread runs in one chain along the whole walk, which is right for anything the program has one of and wrong for anything a scope has its own of. A .sob method carries its own name and constant tables, so entering one has to start them empty and leaving one has to put the enclosing tables back. That is a save and a restore. That is a stack. A single chain has no stack in it.

The change is that a down clause naming a threaded attribute now sets the thread for the subtree instead of binding a name over it. Everything else follows from what was already there: the save is an ordinary down attribute, the restore is the node’s own leaving clause — which works because a node’s scope is torn down after its leaving clauses run, not before.

Block : down heldnames = $names        (* save    *)
      : down names     = [...]         (* reset   *)
      ...
      : names          = $heldnames .  (* restore *)

It nests because a stack nests: a block inside a block saves the middle chunk’s tables and puts them back, and its method lands in the middle chunk’s method table where its OP_BLOCK looks for it. Nothing in run.c knows what a chunk is.

This is the first thing the design could not express, and section 3.5 of the roadmap asked to have it recognised as this decision arriving rather than as a puzzle. It is not the conditional that gave way — tables-as-conditionals held up fine over an entire backend. It is that inherited and threaded were two mechanisms where the target wanted one thing that was both: accumulating left to right and scoped.

Spreading a non-list is now an error

... of something that is not a list used to spread to the thing itself. That is quiet and wrong, and it hid a real bug in two files for as long as it was allowed:

group = "(" t:temporaries e:expression { "." f:expression -> $f } [ "." ] ")"
          -> Group(temps: $t, body: [$e, ...$3]) .

$3 is the third item — e:expression — not the repetition, which is $4. So [$e, ...$3] built [e, e]: the first statement twice, every later statement dropped. ("ran":display. { nil }) parsed as ("ran":display. "ran":display).

The round trip could not see it, and this is the sharpest example yet of why an oracle is not optional. The parse was wrong, the tree was wrong, and what show wrote back out was wrong in the same way — so it parsed to an identical tree and the round trip passed. Running it did not: ifTrue was handed a string.

Refusing the spread found the same mistake twice more, in languages/phx/phoenix.phx itself — Apply(values: [$a, ...$3]) and Shape(fields: [$f, ...$3]), both wanting $4. Phoenix’s own self-description had been miscounting items since it was written, and every test passed because the miscount was consistent.

What the oracle found

solas and solvm are the oracle, the way fpc is for Pascal, and the test is stronger: not “does the output read correctly” but “does the compiled program print the same thing”. Two bugs, both in the front end and neither findable by rendering:

Where it stands

50 of the 51 Solveig files in the repository that do not use @include compile to bytecode that prints exactly what solas’s does, run by the same solvm. Four differ only in the locations in a traceback, because this backend emits one line run per chunk and no file table — missing debug information, not a miscompile. One reads the clock.

@include is the one construct refused, and refusing it is right: splicing another file in before compiling is something a reader does, and a pass is a walk over one tree that has already been read. It is the first real gap that is not about expressions at all, and any language with a module system will want the same thing.

2026-09-02 — @include, and the failure that was not one

The first gap that was about the shape of the tool rather than about expressions, and ROADMAP 1.0 had named it as the one to decide next. @include "library.sol" splices another Solveig file in before compiling; 24 of the files in that repository use it, and the bytecode backend refused all 24 by name.

Refusing it was right, and that is the whole argument for the mechanism. A pass is a walk over one tree that has already been read. An include is a second file, which has to be read before there is a tree to walk. No clause can reach it, however it is written — so this is a directive the reader acts on, and the only one that is about the target language’s files rather than about the description’s.

include = "@include" p:string -> Include(path: slice($p, 2, size($p) - 1)) .
%include Include path .

Two names: which node an include is built as, and which of its fields holds the file. What happens then is fixed and is deliberately not a description’s to vary — the file is read with the same grammar, and the items its root holds take the include node’s place in the list that held it.

The quotes come off in the action

The first real decision, and it went the other way from the obvious one. A token arrives spelled the way the file spelled it, quotes and escapes and all, so Include(path: $p) holds "library.sol" with the quotes on. The reader could strip them. It must not: how a language writes a string is the one thing only that language’s description knows, and a reader that guesses is a reader that is wrong about the first language that disagrees.

So %include takes the field’s text exactly as it stands, and the description says what a path is with the notation it already has. show then puts the quotes back, which is what keeps the round trip honest.

A cycle turned out not to be a failure

The roadmap entry warned about three new ways to fail — a cycle, a missing file, a path relative to which of two files — and predicted each would need a message with a position. Two of them did, and they are one message: cannot read the included file, and here is where I looked. The rule is C’s, and for C’s reason: beside the file the include is written in, so a program survives being moved, then the search path, -I in the order given.

The third needed nothing. A file is read once however many ways it is reached, which is what %import already does one level up and for the same reason — and once that is true, a file that comes round to itself finds itself already read and contributes nothing. There is no cycle to detect. The check that would have detected one would have been a second mechanism doing what the first already did.

What the entry did not anticipate is that the splice needs two refusals of its own, and both are the same sentence: there is no answer to what this would mean.

Identity is spelling, and that is a decision

A file reached beside its includer and again from the search path is the one file, so the two spellings have to be recognised as one. realpath is the answer and realpath is POSIX; a generated compiler is meant to build with cc file.c and nothing else, and this code is part of one. So the paths are folded textually — a/./b, a//b and a/x/../b are a/b — and a symbolic link is read twice.

Worth writing down because it is a trade rather than an oversight: being wrong here costs a file compiled twice, which runs twice and is visible, rather than something quiet.

The joined text, and the pointer that stays valid

Positions had to keep working across files, and the machinery for that already existed one level up: every file’s text end to end in one buffer, with a map saying which stretch came from where, so an offset stays a single number and a diagnostic still names the file and its own line.

The buffer is copied when it grows, which looked like a problem — a token scanned from an earlier copy points into memory that has been superseded. It is not one, and the reason is the arena: nothing is freed, text is only ever appended, so an old copy holds the same bytes at the same offsets that the new one does. A pointer into it is stale and correct. That removed the tree walk that would otherwise have had to shift every position, and with it the aliasing bug that walk would have had if a node ever appeared twice.

--no-includes, and what a round trip is a question about

Expanding is what a compilation does, so phx does it before anything walks the tree, --tree shows the expanded tree, and a generated compiler does the same and takes -I for the same reason.

The round-trip test wanted the other thing. It parses every .sol file in the Solveig repository, writes it back out, and parses that; with expansion it would have been checking the library over and over and never once checking that an @include is written back as one. So --no-includes exists, and the flag is not a convenience: what does this file’s own text mean and what does this compilation mean are two questions, and only the second follows a file it names.

What it moved, and what it uncovered

The Solveig oracle went from 50 programs to 72. The 22 it added found nothing wrong with the backend, which is the useful negative result: some 22,000 lines of Solveig that had never been compiled by this description before, and not one new disagreement.

Two of them did cross a line the other seventy do not, and the cause is one thing seen twice. solas inlines the block of an ifTrue:, a whileTrue:, an and: and an or: into the enclosing chunk, behind a jump; solveig-sob.phx compiles every block as a block. The bytes differ and the output does not — except where a block that is really a block is visible:

Neither is a miscompile and neither was caused by this work; @include is what made the two files reachable. They are counted apart in the test, with the cause named, and ROADMAP 2.4 is the entry for the missing optimisation.

The oracle was worth more here for what it made reachable than for what it disagreed with, which is the opposite of every previous time it earned its place — and it only worked because the count is reported by category rather than as a single number. “72 agree, 2 nest a block solas inlines” says something; “74 tried” would have said nothing, and hiding the two would have been a lie with a green tick on it.

2026-09-02 — $pos, and the sentence on the roadmap that was wrong

ROADMAP 1.1 asked one question and answered another. The question was what a position is to a description — a line, a line and a column, or an opaque value only the diagnostics understand. The answer it did not offer is the one that turned out to be right:

Position(line, column, file)

A node. Which sounds like a detail and is the whole design. A number would have been smaller and would have meant nothing: every use a description has for a position is a line or the name of a file, and a byte offset into a buffer nobody wrote is neither. A node makes reading part of one an ordinary field read — $pos.line, $pos.file — so the notation needs no new syntax and no library function to get at it, and a fourth thing later is a field rather than a second reserved name.

And because . over a list already means “that of each”, $body.pos.line is a column of line numbers without anybody deciding it should be. That is the shape a table in a binary format wants, and it arrived free.

One word reserved, said out loud

$pos resolves before bindings, fields and attributes. It has to: a name that means the position on one node type and a field on another is the shape of quiet wrongness this project keeps checks for, and a description reading $body.pos over a mixed list would get both.

So pos is a word a grammar may not call a field, and a description that does is refused when it is read. That is a real cost and it is written in the warts rather than left to be discovered — the same bargain expression.phx makes when it reserves and and or.

“Everything needed is already there” was wrong

The entry said the work was small because a node already carries its position. Reading one is small: about thirty lines. Using one is not.

A .sob line table is a run per statement — its bytes at its line — and writing one needs, for every element of a list, a value computed from it. The notation cannot say that. each applies a template to a list, and a template can only write an element out; it cannot ask for size(x) of one, or for x as four little-endian bytes.

There were two ways forward and only one of them is small:

The second, and the reason is the argument roadmap 3.4 makes about the library being a visible thing: sizes(list) is the companion to positions(list) — that one answers where each thing is, this one how big it is, and neither can be asked any other way — and bytes taking a column of numbers is the same function taking a list, the way bind already takes names pairwise and each already takes two lists.

With those, the whole line table is one clause:

bodyruns = join(each(bytes(sizes($body.code), 4),
                     bytes($body.pos.line, 4),
                     "{}{}\x01\x00\x00\x00{}"), "")

— each statement’s bytes at its line, then the one byte after it, the POP, at the same line. A block drops eight bytes off the end of that table for the same reason it drops one byte off the end of its code: its last statement has no POP. The run count is measured off the table rather than counted from the body, because the loader adds the runs up and refuses a file whose lines do not account for every byte.

The case that stayed open, and what it is evidence for

The file table did not fall out. A chunk holding code from two files — which is what @include made possible last night — needs a run per statement naming a row of a table of the distinct files, and that is a lookup per element: the third case of the same missing thing, and the one two library entries did not cover.

So the file table is written when a chunk is about one file, which every block is and every program that does not include is, and otherwise it is not written at all. That is the format’s own answer for the case — bytes belonging to no file print a bare line — and it is the right one, because a line number naming a file nobody said reads as a line of the file you were looking at and is worse than saying nothing.

Three cases of one missing mechanism in a single stage is what ROADMAP 1.3 was waiting for, and it is not the shape it expected. 1.3 was written about repetition: the same computation spelled out once per node type. This is narrower and worse — a computation the notation cannot express at all, patched twice by adding to the library and left open the third time. A map with an expression would have covered all three, and it is the mechanism that page keeps warning about.

What it bought, measured

The Solveig oracle stopped normalising locations away. It used to replace every [...] in a program’s output before comparing, because every message from bytecode written here said [line 1]; now the file and the line are compared like any other byte.

A test that stopped normalising also found a flaky one. programs/diff.sol prints a file’s timestamp to the second, and the check for that ran the oracle twice back to back — which nearly always agrees — before running this backend a moment later, on the other side of a second boundary. The two oracle runs now bracket the one under test, so a clock that ticks anywhere in the window is reported as a program that does not repeat itself, which is what it is.

2026-09-02 — the block that should not be there

ROADMAP 2.4 said inlining was “the first thing the .sob backend cannot express rather than has not got round to”, and left open whether that was a gap in the notation or in that description. It was neither, quite. It was a gap in what a pass is.

solas compiles the block of an ifTrue:, an ifFalse:, an ifElse:, an and:, an or:, a whileTrue: and a doUntil: into the enclosing chunk, behind a jump, whenever the block is written right there with no parameters and no temporaries. This backend compiled every block as a block, and three things followed: an extra frame in every traceback, programs/pascal.sol nesting 19 deep where the format allows 16, and programs/basic.sol running out of call depth one test early.

The thirty lookups that were not written

The obvious way in was a clause: match the send, and emit jumps instead. Two things were in the way.

A pattern could not ask the question. Send(args: [Block(params: [])]) is exactly the shape, and a pattern could not be a list — it covered nodes, text, integers, booleans, nil and anything, which is every kind a value can be except the one that holds several things. That is not a special case for an optimisation, it is a hole, and it is filled: [ a, b ] and [] match a list of exactly that many, each element matching.

And the block would still have been there. A Block node opens a chunk, starts fresh name and constant tables, and pushes a frame’s worth of slots. An inlined one must do none of that: its body belongs to the enclosing frame. Conditioning it on who the parent is meant threading a flag down and turning about thirty clauses into lookup([[true, ...]], $inlined, ...) — in the rule the journal already calls the thread that had to nest.

What a pass cannot do

So the answer was 2.2, deferred on day one and waiting for a customer better than constant folding:

Send(to: c, message: "ifTrue", args: [Block(params: [], temps: [], body: b)])
  => IfTrue(cond: $c, body: $b) .

A clause answers about a node. Some things are answered by there being a different node. The block is not something to compile differently; it is something that should not be there. Take it out and what is left is a list of statements sitting where the send was — and it compiles in the enclosing frame because that is the frame it is in. OP_OUTER depths come out right with nothing adjusting them, for the reason solas gives for the same thing.

That is the same argument %include made a day earlier from the other end. A pass is a walk over one tree; there are things to be done to a tree that are not walks over it, and each one has turned out to want its own mechanism rather than a longer clause.

%rewrite cost about a hundred lines in run.c and no second implementation of anything: the same match_pattern, the same eval_expr, with a traversal that puts the answer back. A rewrite and a pass cannot disagree about what a pattern means, because there is one of each.

The strategy is a word rather than a default, which the roadmap’s sketch already had right and which the fold test is there to show: 2 + 3 * 4 + 1 folds to 15 bottom-up and stops at ((2 + 12) + 1) top-down, because top-down asks about the outside of an expression before its inside.

The arithmetic that was not a fixup

The one thing a jump needs is an offset, and this is where the entry’s worry about “a jump over code in the middle of the chunk being built” turned out to be about the wrong tool. solas writes 0xffff, keeps the slot, and patches it when it knows. A clause does not have a slot to patch — and does not need one: the code being jumped over is a value the clause is holding, so its size is a question rather than a fixup.

: code = join([$cond.code,
               bytes(14, 1), bytes(size($bodycode) + 3, 2), bytes($idx, 2),
               $bodycode,
               bytes(13, 1), bytes(1, 2),
               bytes(1, 1)], "") .

Every offset in all seven forms is that: the number of bytes between the end of the jump and where it lands, which is a size of something already built. A backwards OP_LOOP is the same arithmetic with the terms rearranged.

What it fixed, and what it did not

   
programs/pascal.sol nested 19 deep and the loader refused what this wrote. It compiles and agrees now
programs/basic.sol stopped one recursion test early. It agrees now
every traceback’s frames an inlined block is a frame that is not there, and there are no longer any extra ones

Sixty-eight programs print exactly what solas’s bytecode prints, up from sixty-six, and the two categories the test kept for this gap are gone.

Seven still differ, in a traceback line and nothing else, and the useful part is that the cause is no longer this entry. Six lose the file name, because their top-level chunk holds code from more than one file and the file table for that is a lookup per element of a list. The seventh names the enclosing statement’s line where solas names the inlined statement’s, because a chunk’s line table is a run per statement and splitting one is a run per element of a list.

Both are 1.3, which three stages in a row have now ended at, and which is the only thing left between this backend and an oracle it agrees with on every byte. That is a much better sentence than the one this stage started with, and it was worth two mechanisms to be able to write it.

And one more flaky test

Comparing tracebacks exactly means comparing everything exactly, and programs/system.sol prints how long a loop took. The oracle’s two runs happened to agree while this backend’s, a moment later, did not. Both are now asked again — but only when they differ, which is two extra runs on the few that get there rather than on all seventy-five.

2026-09-02 — the clause that was already the answer

ROADMAP 1.3 had been the most dangerous entry on the page for three stages, and it said so itself: the obvious fixes were all a second mechanism — a function, a macro, a rule other rules call — and “each one would be the first thing in the notation that is not a clause about a node, and the reason this tool is small is that there is only ever one of those.”

It also said what to do about it: wait for a second example in a different language. Three stages had ended at this entry and all three examples were in Solveig, so the first thing worth doing was to look, rather than to design.

It was already written. languages/pascal/pascal.phx has type = "void" twenty-one times, with a comment saying exactly why:

Everything else a walk reaches, so that a node above it can read a type without asking which kind of statement it was.

And the shape it settled

That comment is the whole answer, and it says the entry had been framing the problem the wrong way round. The Solveig cases looked like a map over a list: a lookup per element for the file table, a run per element for the line table. Pascal’s is not a list at all. What both are is an attribute every node has — and a list of nodes then has a column of them for free, because . over a list already means “that of each”.

So the mechanism is not a map. It is the general clause about a node, which is why the warning this entry carried for three stages turned out not to apply:

otherwise type = "void"

What a node answers with when its own rule works nothing out. It runs after that rule, so it can read what the rule worked out, and only for the attributes the rule left alone. A node with a field of the name reads the field, since .name reads a field before an attribute — which is the node saying so itself, and is exactly what this is “otherwise” to.

Twenty-one clauses in Pascal became one, and the 35 programs fpc checks are what says it still means the same thing.

Both open cases, closed

The file table. otherwise fileidx interns every node’s file into the chunk’s table the way a name is interned, and a chunk’s file runs are then $body.fileidx. The table is saved and restored per chunk exactly as the name and constant tables are — and a Block restores and interns its own file into the enclosing table in one clause, because a block is a statement of the chunk around it and its OP_BLOCK has to name a file like any other.

The line table. otherwise runs gives every node its own code at its own line, and the seven nodes an inlined block became compose one from their parts: the condition’s runs, the jump at this node’s line, the body’s runs, the tail.

Sixty-eight programs agreeing with solas byte for byte became seventy-one, and every message the backend produces now names the right file.

What two library entries cost

sizes(list) and bytes over a list were added a stage earlier, and otherwise would have covered both. That is worth writing down rather than tidying away: 3.4 says a library entry has to be something a real pass needed and could not be written in the notation, and both met that rule at the time. What the rule does not catch is answering a question one case at a time before seeing the shape of it — and the cost of that is two entries in a library the same page says to watch the size of.

The four that are left, and what they are

Not 1.3, and not inlining either. All four fail inside a send whose arguments run over several lines, and they name the line the statement starts on where solas names the line it ends on:

path := system:arguments:size:greaterThan(#0):ifElse(
    { system:arguments:at(#1) },
    { | fallback |
      ...
      fallback }).

solas writes the OP_SEND after compiling the arguments, so the line it records is the one the ) is on. A node here carries one position and that is its first token.

So the last gap is a second number, and the question to answer before adding one is whether a position is a point or a span — $pos.line and $pos.column are the start of one either way, and nothing until now has wanted the end. It is 1.4, it is four traceback lines in seventy-five programs, and it is the first entry on that page that came from a measurement rather than from a design.

2026-09-02 — the one thing the two of them could disagree about

The README’s second claim, after the notation itself, is that phx and the compiler it writes out cannot disagree: a generated compiler is the same lex.c, parse.c, eval.c and run.c over frozen tables, so there is only one implementation and nothing for two of them to argue about.

It was not true, and finding out took asking a question nobody had asked: what happens if the description that emits bytes is written out as a compiler?

$ phx --raw --driver sob solveig-sob.phx arrays.sol   →  2552 bytes of .sob
$ ./sobc      --driver sob arrays.sol                 →  3242 bytes of errors
solveig-sob.phx:644:24: error: this template has 1 {} and 2 values were given

A literal may hold a NUL, and the length beside it is what says so

emit.c froze every string with emit_string, which measures with strlen. A template like "{}\x01\x00\x00\x00{}" — a line-table row, four bytes of little-endian one between two holes — arrived in the generated compiler as "{}\001", with the len field beside it still saying eleven. So the generated compiler read six bytes past the end of a string phx never had, and found one {} where the description has two.

Three kinds of thing can be one: a grammar literal (phx.h has said “a literal may hold a NUL” beside GNode.len since stage 0), a pattern, and a template. All three were frozen the same wrong way. The fix is that each is written by its length instead, which is three lines and a helper.

It had been broken since stage 7. solveig-sob.phx has seeded its name table with "\x05\x00array\x02\x00of\x0a\x00dictionary" since the day the backend was written. Nothing noticed, because nothing had ever built that description as a standalone compiler.

And the flag it needed as well

The generated main had no --raw. phx grew one when the .sob backend arrived — a trailing newline is a kindness to a terminal and a corruption of a binary file — and the generated one kept appending. So even with the strings right, a compiler written out from a description that emits bytes wrote a file phx would not. Same flag now, same comment, same reason.

What the test was, and what it is

ok    sum.calc: identical to phx, byte for byte
ok    a Pascal compiler, and it agrees with phx
ok    12 .sob files, byte for byte, from phx and from a compiler it wrote

The first two lines had been there for stages. Both compare text a person could read, and neither was ever going to notice a NUL, because a description without one cannot have the bug.

That is the lesson worth keeping, and it is not about strings:

“There is only one implementation” is a claim about the code. That it holds is a claim about the tests, and it is only as strong as the widest thing they compare.

The suite now writes languages/solveig/solveig-sob.phx out as a compiler and requires its .sob to be identical to phx’s over the twelve conformance programs — a binary format, where a single wrong byte has nowhere to hide. There is a smaller one beside it, tests/grammars/nul-literal.phx, which puts a NUL in a grammar literal, in a pattern and in a template at once, so the next person to touch emit.c finds out from a test rather than from a .sob.

Both fail against the previous commit. That was checked rather than assumed.

2026-09-02 — a node is a stretch, not a point

ROADMAP 1.4 was the only entry on that page that came from a measurement rather than from a design, and it posed one question: is a position a point or a span?

It is a span, and four programs are what said so. All four failed inside a send whose arguments run over several lines, and named the line the statement starts on where solas names the line it ends on:

path := system:arguments:size:greaterThan(#0):ifElse(
    { system:arguments:at(#1) },
    { | fallback |
      fallback := "build/example-access.log".
      system:writeFile(fallback, sample).
      fallback }).

solas records the line of the token it has just read. By the time it writes the OP_SEND for ifElse, that is the ) on the last line — because a send’s own bytes go in after its arguments. A node here carried one position, its first token, and had nothing to say about that.

Position(line, column, file, endline, endcolumn)

Every value now carries where it ends as well as where it starts: a token ends at its last byte, and a node ends at the last byte of the last token its sequence consumed. That is one field on Value, one on Eval, and about fifteen lines between parse.c and run.c.

The clause that was wrong by one word

Send : runs = join([$to.runs, join($args.runs, ""),
                    bytes(4, 4), bytes($pos.endline, 4)], "") .

$pos.line to $pos.endline, and 71 programs became 72. Bind and SetSlot store after their value, so the same change took it to 76 — every .sol file in the Solveig repository. Array, Dictionary, Pair and Group compose theirs too, which changed nothing measurable and is right for the same reason: a composite node’s line runs are its children’s, in the order it writes their bytes, which is the order it already wrote them in. The bytes and the lines are the same walk.

What the test says now

76 programs print exactly what solas compiled prints, 0 do not
   (1 do not repeat themselves)

Nothing is normalised and nothing is counted apart. The sed that replaced every [...] before comparing is gone; so is the rule that let a difference count as “only a traceback”; so are the categories for the format’s nesting limit and for the call depth. A traceback names a file and a line and both are compared like any other byte.

The one thing still set aside is a program that does not print the same thing twice under solas either — programs/system.sol prints how long a loop took. That is not a disagreement about compiling and it never was.

The field nothing asked for

endcolumn came along with endline and nothing uses it. It is there because a position is a line and a column at both ends or at neither, and a shape that is right is cheaper to keep than a shape that is minimal. That is the second field on Position in that position — column was the first — and both were free because $pos answers a node.

Which is the small vindication of the decision two stages ago not to make a position a number. Every time this has needed to say more, it has been a field.

Where that leaves the page

Section 1 has one entry left, and it is the one that page has twice said is not worth doing: compiling the tables to code, measured, weak, and the only thing on it that would create a second implementation of the notation. Of the four borrowed entries, two are done and two are the two it said to be sceptical about.

The Solveig oracle has nothing left to say. That is worth noticing: it has been the sharpest instrument in this repository for five stages, and it has run out of disagreements. The next real evidence has to come from a language nobody here has described yet.

2026-09-02 — the specification, made to run

Two commits ago a bug turned up by asking a question nobody had asked: what happens if the description that emits bytes is written out as a compiler? The answer was that phx and the compiler it wrote disagreed, silently, and the lesson written down at the time was that a claim about the code is only as strong as the tests that compare it.

So: what else is claimed and not compared?

docs/semantics.md is the specification. It says what the meta-language’s arithmetic, comparison, text and formatting are, in Phoenix’s own terms, “so that a second backend has something exact to agree with”, and eval.c’s header says the two are changed together or not at all.

Nothing checked that. Every test in this repository asks about a language being described — Pascal against fpc, Solveig against solas, calc through two backends. None of them asks about the notation doing the describing. The page and eval.c could have drifted a claim at a time and the suite would have stayed green the whole way.

The page as a description

Case ! not ( 7 div  2 =  3) : "7 div 2"
     ! not (-7 div  2 = -4) : "-7 div 2"
     ...
     ! not ((-7 div -2) * -2 + (-7 mod -2) = -7) : "the identity, -7 and -2"

Forty-four checks, in the order the page makes its claims, and a check that fires names the sentence it came from. There is nothing new in the notation here — a check is what a description already uses to refuse a program, pointed at the tool instead.

Two of them are asserted by not failing, which is the only way to observe a short circuit from inside:

! (false and (1 div 0 = 0))   : "and short-circuits"

The right-hand side divides by zero. If and ever stopped short-circuiting, this would not report “and short-circuits” — it would report “division by zero”, which is a better message than the one written for it.

And the half a specification usually leaves out

A page that says only what works is half a page, and the half it leaves out is the one two backends drift apart in: an implicit conversion one of them makes and the other does not is exactly the silent disagreement the page exists to prevent. So semantics-refused.phx is thirteen clauses that must not work — 1 + 1.0, "a" + "b", int(1.5), overflow, div by zero, negating the most negative integer, nil + 1, 1 < "1", not 1, formatting a nil or a list, ... of a non-list.

A clause whose value cannot be worked out reports and leaves a failure behind, and the clauses after it still run — so one description asks for all thirteen at once, and the test checks that every message the page promises is among them.

The conformance rule, applied to the page it is about

Both halves run through phx and through a compiler phx wrote. The assertions have to hold in both. The refusals have to produce the same complaints, byte for byte — which they do, and which is the strongest form the rule takes anywhere in this repository, because the thing being compared is the tool’s own account of what it means.

ok    44 claims from docs/semantics.md hold
ok    and every refusal it names
ok    and hold in a compiler phx wrote
ok    with the same complaints, byte for byte

Checked, rather than assumed

Deleting one line from div_floored — the correction that makes division floored rather than truncating — makes four claims fire and two tests fail, each naming the sentence on the page that stopped being true:

error: -7 div 2
error: 7 div -2
error: the identity, -7 and 2
error: the identity, 7 and -2

That was run rather than reasoned about, which is the same discipline the NUL tests got and for the same reason: a regression test nobody has seen fail is a regression test nobody has tested.

A specification nothing runs is a document about a program, and it drifts from it one sentence at a time. This one runs.

2026-09-02 — awk, and the two bugs a round trip could not see

The Solveig oracle had run out of disagreements, and the note at the end of that stage said the next evidence had to come from a language nobody here had described. This machine has /usr/bin/awk, so: awk.

The first grammar that is not vendored

Pascal came with Wirth’s report and Solveig with that project’s solum.bnf, both held character for character in tests/ so that “reads the published grammar unmodified” is a checkable claim. There is no awk grammar on this machine. What languages/awk/awk.phx holds is the POSIX definition transcribed into Wirth’s notation, and the honest thing is to say so at the top of the file rather than to imply otherwise.

What carries the weight instead is the oracle, and it carries more of it than in either previous language: /usr/bin/awk decides what awk means, and tests/corpus/ is six programs that e2fsprogs, ncurses and vim ship — 800 lines of awk written by people who had never heard of this.

All six parsed on the first run, which was not expected and is the single most useful thing the stage says about the notation.

What awk is here to test

Pascal has statements, expressions and a type system, and 51 node types. Solveig has one thing that happens and 12. awk has neither shape and 46: a program is a list of pattern-action rules with no main; nothing is declared; and concatenation has no operator, which is why its grammar is not LL(1) and why concat here is a repetition of the rung below it.

concat = additive { a:additive -> Concat(left: $$, right: $a) } .

That is juxtaposition written down, and it is one line.

The one place the description guesses

/ is division and /re/ is a regular expression, and which one it is depends on the parser: a real awk lexer asks whether the previous token could end an expression. Phoenix’s scanner is longest match over the token rules and has no such feedback, deliberately — ROADMAP 3.3 says a tool that guesses the lexical/syntactic seam reports a correct file as broken.

So the description guesses instead, which is its business and not the tool’s: a regexp may not start with a space, a tab or an =, and must close on the same line. That reads every regexp in the corpus and every division in it, because nobody writes a/b/c without spaces.

And a/b/c is checked in, in tests/divergent/, together with the second divergence — f (1) with a space, which awk reads as a concatenation and this reads as a call, because saying otherwise means putting the ( inside the token and then if(, while( and print( become function names. Both tests assert the wrong answer on purpose, so that a change to either shows up with the old answer in it. A guess with a witness is a different thing from a guess.

The two bugs, and why the round trip could not see them

Fourteen programs parsed, rendered and re-parsed to identical trees. Then the oracle ran them:

awk: syntax error at source line 1
	do { printf "%d", i; i++ >>>  }; <<<

A ; after a block is not the same as a ; after a statement. awk wants a terminated statement before an else or a while; a block terminates itself, so } followed by ; ends the whole if and orphans the else. Written the other way — if (c) x = 1 else y = 2 — awk wants the ;.

So the rendering depends on the shape of the branch, which is what a pattern is for:

If(otherwise: nil)  : show = "if ({}) {}" of ...
If(then: Block)     : show = "if ({}) {} else {}" of ...
If                  : show = "if ({}) {}; else {}" of ...

and the same three lines again for do. Both bugs re-parsed to the same tree, which is exactly the failure a round trip cannot see, and it is the third time this journal has written that sentence.

The rung awk takes out

print a > b writes to a file. So a print’s arguments cannot contain a relation — and not just >: awk rejects print 1 == 2 and print 1 < 2 as well, while allowing &&, ?: and assignment. That is awk’s own grammar splitting unary_expr from non_unary_expr, and here it is the expression ladder with one rung removed, duplicated because nothing in the notation parameterises a rule.

Six rules copied, which is the largest piece of duplication in any description here. It is the same shape 1.3 was about and it is not the same problem: otherwise answers “every node does this”, and this needs “these seven rules, but one of them differently”. Worth remembering if a fourth language wants it too; not worth a mechanism on one example.

What it left on the roadmap

2.1 has been waiting for a language where a name is used above where it is defined. awk is that language — tests/conformance/functions.awk calls greet from BEGIN before its body appears, and awk resolves it — so the entry is no longer waiting for a language. It is waiting for a pass over the one it has, because what is described so far is a front end and nothing has yet had to resolve that call.

Which is the same order Solveig was built in, and for the same reason: a backend for a language whose parse is wrong is a backend written twice.

2026-09-02 — the entry that was waiting for a language, and lost

ROADMAP 2.1 — reference attributes, from JastAdd — had been narrowed twice already. What was left of it was one sentence:

A reference that points forward — to a node the walk has not reached — which is the only case a backward lookup cannot serve.

and one condition: until a language that needs one is being described, this stays unbuilt. Pascal has forward precisely so that it does not need one. Solveig sends messages and resolves nothing until it runs. awk was picked, in part, because it would need one: a function may be called above where it is defined, and awk resolves the call by name over the whole program.

BEGIN { print greet("world") }
function greet(who,   prefix) { prefix = "hello, "; return prefix who }

awk prints hello, world. So checking that a call names something has to see a node the walk has not reached.

It is two passes

%pass functions
  thread known = []
  Function : known = [...$known, [$name, size($params)]] .
  Program  : table = $known .

%pass calls
  Program : down declared = $table .
  Apply ! not defined($declared, $name)
          : "'{}' is not a function in this program" of $name .

One walk collects the functions and leaves the table on the root. A leaving clause on the root runs after the whole subtree, so every function is in that table wherever in the file it was written — the forward reference is answered by the shape of the walk rather than by a mechanism. The next pass hands the table back down, and attributes stay on nodes between passes, which is the whole reason a %driver names a sequence.

Twenty lines, and it finds while reading a program what awk finds when the call runs — on somebody else’s input, on somebody else’s machine. It flags outside/hello.awk for calling bindtextdomain, a gawk builtin POSIX awk has not got, which is a true thing about that file.

The arity check came free beside it, and with it the notation doing plurals the way it does every other conditional — a table:

lookup([[1, ""]], lookup($declared, $name, 0), "s")

So the entry loses

The cost of two passes is that the walk happens twice. The cost of reference attributes is demand-driven evaluation and the cycle detection that walking once avoids, and what it would have bought here is one walk instead of two.

The case two passes cannot do is a dependency that does not stratify — two nodes each needing an attribute the other computes. Neither Pascal nor Solveig nor awk has one.

Three languages described without wanting this, and the one picked because it looked like it would want it did not. That is as close to an answer as this project’s roadmap gets, and it is worth more than the mechanism would have been: the entry can now be closed on evidence rather than left open on politeness.

What is worth noticing about the shape of the answer

Every time this roadmap has been wrong, it has been wrong in the same direction: it predicted a new mechanism where the answer was the mechanisms already there, used in an order nobody had tried.

Three for three. The next entry that asks for a mechanism deserves the same question first: what does the walk already know, and when does it know it?

2026-09-02 — awk compiled, and the first value that is not a machine word

languages/pascal/pascal-c.phx has no runtime worth the name. Its entire preamble is four #includes, because Pascal’s types are C’s types: an integer becomes a long and C does the work. languages/solveig/solveig-sob.phx emits opcodes for a machine that already knows what a value is.

awk has one type and it is not C’s. Every value is a string and a number at once; $1 == "10" and $1 == 10 differ by whether the field looks numeric; a variable never set is both "" and 0. None of that is optional — it is what awk programs are written against — so a compiled awk program carries a runtime, and awk-c.phx is mostly that runtime.

typedef struct { char *s; double n; int isnum, strnum; } Cell;

Two hundred lines of C, written and checked against awk before being embedded, which is the only reason the rest went quickly. The subtle pair — a field of ` 10 ` comparing equal to the number 10, and an unset variable comparing equal to both 0 and "" — was right in the second attempt and has been right since.

ok    6 awk programs compiled to C print what awk prints, 0 do not

That is the conformance rule with a third language under it.

What the backend taught the front end

Two bugs in awk.phx that reading 800 lines of other people’s awk had not found:

for (;;) would not parse. optnl — “any number of newlines or semicolons” — is right between statements and wrong inside a for header, where the semicolons belong to the header: the rule ate the second one and then the grammar wanted a third. POSIX writes newline_opt in exactly those places, and there is now an nl for exactly that. Fourteen awk programs had round-tripped without touching it, because none of them writes an empty for.

For shadowed its own fields. The emit pass named attributes init, cond and step, which are that node’s fields — a field is read before an attribute, so nothing outside the pass could ever have seen them. Phoenix’s own check said so the first time the description was read, which is the check earning its place a second time.

A backend is the test a front end cannot be given any other way. That is the argument for building one rather than admiring the tree.

The conditional that evaluates both answers

lookup is a function, so both answers are worked out before it chooses. That is fine until one of them cannot be:

lookup([[true, ""]], $init = nil, "{};" of $init.out)

An omitted for part was a nil, and .out of a nil is an error however the condition comes out. 3.5 says there is no if and that the first thing it cannot express should be recognised as a decision arriving. This is not that. The fix was in the grammar: an omitted part now builds a Nothing node that renders as nothing, so every part is emitted the same way whether it is there or not and the question stops being asked.

Third time the answer to “the notation cannot say this” has been “say something else, earlier”. 1.3 wanted a map and got an attribute; 2.4 wanted a special case and got a rewrite; this wanted a conditional and got a node.

And a second customer for otherwise

An expression is a statement too, and the tree cannot say which: x = 1 is an Assign whether it stands alone or sits inside something. So every node answers a stmt as well as an out, and the default is the expression with a semicolon after it:

otherwise stmt = "  {};\n" of $out

Nine statement kinds override it; the twenty-odd expression kinds say nothing. That is otherwise in a second language, doing the thing it was added for, and it is the strongest evidence yet that 1.3 landed on the right mechanism.

It also found a rough edge in it: a node whose check fired was still having its defaults worked out, so a refused node reported its refusal and then a consequence of it. run_defaults takes the block now, for the same reason run_clauses always has — one mistake, one message.

The two hundred lines, and where they live

The runtime is a list of one-line literals joined with newlines. It is exactly what pascal-c.phx does with its four #includes and it is fifty times the size, which is the first time the shape has been big enough to argue about. Nothing in the notation reads a file at emit time, so there is nowhere else to put it.

1.5 is the entry for that, and it is written down rather than built because there is one customer. What it would cost is on the page: a description that names a file it does not contain stops being one thing, and -o writing a compiler that needs a file beside it would be the end of one file, no headers, no library.

2026-09-02 — arrays, functions and regular expressions, and three questions otherwise answered

Stage two of the awk backend: the parts that need a hash table and a regular expression engine. <regex.h> is POSIX and is what one-true-awk’s EREs are close enough to; the hash table is a hundred lines. The runtime went from two hundred lines to three, and — as in stage one — it was written and checked against awk before being embedded, which is why the rest went quickly.

ok    10 awk programs compiled to C print what awk prints, 0 do not

Arrays with in, delete and for-in; functions with locals, recursion, calls above their definition and arrays by reference; patterns, ~, !~, match, sub, gsub and split.

Three questions the tree could not answer, and one mechanism that could

Every one of them is the same shape: what is this node, when it is used here? — which a clause cannot ask, because a clause is keyed on what a node is.

   
stmt x = 1 is an Assign whether it stands alone or sits inside something. A statement wants a ; after it and an expression does not
argout a variable handed to a function has its array made first, because an array is by reference and a scalar is by value and awk decides which by what the callee does. Anything that is not a variable is passed as it stands
reout a regexp on its own matches the record; handed to sub or split it is the pattern. Match can ask with a clause pattern, because the regexp is a field of it — an argument is in a list, and a clause cannot reach into one

otherwise answers all three, one line each, and the nodes that differ say so. Without it each would have been the same clause written once per node type — twenty-odd times, three times over.

That is the mechanism 1.3 landed on, in its third language, answering a question nobody had when it was built. It was added for “an attribute nearly every node answers the same way”; what it turns out to be good at is “an attribute about the position a node is in”, which is a larger thing.

Four bugs, and where each was actually wrong

(i, j) in a asked about a C comma expression. A parenthesised subscript list is one SUBSEP key, and saying so is a clause pattern — In(index: Group).

An array’s name was text. Index(array: "a", ...) meant the pass that collects a program’s variables never saw it, because that pass looks at Var nodes. An array name is a variable, so it builds one now. The fix was in the grammar, which is the fourth time this week the answer to a pass that could not ask something was a tree with a different shape.

substr("abc", 0, 2) was "a" and one-true-awk says "ab": the start is clamped to 1 and then the count is taken. POSIX can be read either way and the oracle cannot, which is the whole reason for having one.

/ +/ will not parse, and that is not a bug — it is the guess from stage one, met in the wild. A regexp may not begin with a space here, because a / b is division and nothing but a parser can tell them apart. It has a way out that costs nothing (" +" is a string used as a regexp, which awk allows) so it is a limit rather than a wall, and it is checked in beside the other two divergences. Found by writing a conformance program rather than by thinking about it, which is the useful kind of finding.

What the shape of this stage says

Stage one was the hard one and it was hard in the runtime. Stage two is twice the language and about the same amount of work, nearly all of it in C rather than in the notation — because once a value is a Cell and a variable can hold a map, an array is a hash table lookup and a function is a C function.

The description grew by about a hundred and twenty lines for arrays, functions, regular expressions and eighteen builtins. That ratio is the argument for the whole project, and it is the first time it has been visible on a language nobody here designed.

2026-09-02 — the corpus, compiled

Stage three of the awk backend: output redirection and pipes, close, system, fflush, range patterns, a multi-character FS, and the input awk actually takes — the files named on the command line, FILENAME, FNR, and name=value operands including -v.

The point of it was one line:

ok    13 awk programs and 6 other people wrote compile to C that prints what awk prints

The six are the corpus. et_c.awk is 269 lines of awk that e2fsprogs uses to generate C error tables. Compiled by awk-c.phx it becomes 1,149 lines of C and prints the same 56 lines that /usr/bin/awk does, given the same input and the same -v outfile=. Nobody who wrote it had heard of this project, and that is the whole claim being tested.

Two bugs found by compiling other people’s code

Neither could have been found any other way — both are in constructs the conformance programs written here did not happen to use.

sub(/,/, ", ") with no third argument. It works on the record, which means writing the record back and splitting it again — and the emitter had a placeholder for a $0 that was never defined, so the C did not compile. Two shapes told apart by how many arguments there are, which is what a list pattern is for.

printf("%s %s\n", a, b). A parenthesised list after print or printf is its arguments, not one argument that happens to be parenthesised — and that is how most awk is written. The tree cannot say which, because both are a Group in the one place a group may be. It compiled to a C comma expression, which is to say it printed the last argument and threw the rest away, silently.

A clause would have needed four of them — print and printf, each with and without a redirect — and each would have had to reach inside a list to find the group. Written as a rewrite it is two rules:

%rewrite unparen bottomup
  Print(args: [Group(items: g)], to: t)  => Print(args: $g, to: $t) .
  Printf(args: [Group(items: g)], to: t) => Printf(args: $g, to: $t) .

That is the third mechanism in a row doing something it was not built for. %rewrite was built to take an inlined block out of a tree; here it takes a parenthesis out. Both are “the answer is a different node”.

And a mis-parse that was worse than a refusal

getline was not described at all, on the grounds that nothing compiles it and the corpus does not use it. So it was not a keyword — and getline line read as two variables concatenated. Ordinary awk, read as something else, quietly.

That is the failure this project refuses to have, and refusing to describe a construct is not the same as refusing it. Four of getline’s six forms are described now, the node is refused by name with a position, and the other two — cmd | getline, which needs | to be an expression operator — do not parse, which is loud. Mentioning the word is what makes it reserved, and that is the whole reason the four forms are in the grammar.

The range pattern, and the position it is named after

A range is a rule with a memory, and the memory has to be a static with a name nothing else uses. $pos answers where the rule was written, so the static is named after it:

: id    = "{}_{}" of $pos.line, $pos.column
: ahead = "static int in_range_{};\n" of $id

Which is $pos doing something it was not built for either — it was added so that a .sob could say which line an instruction came from.

Where it stands

awk is described, checked, and compiled. Three descriptions, 458 lines for the language and 1,187 for the backend, of which about 700 are the C runtime held as literals — awk’s value model, its hash tables, its regular expressions and its input loop, none of which C brings.

The ratio is the argument for the whole project: the awk in the corpus is 800 lines, the description that compiles it is 1,600, and the description works on awk nobody has written yet.

2026-09-02 — measuring awk, and the seven hundred lines that could not be compiled

The roadmap was down to entries it says not to do, so the next thing was to measure rather than to build — which is what 1.2 rests on and what it had only ever been told about Pascal.

awk is the grammar that ought to have broken it

Pascal’s is a shallow ladder and an LL(1) shape. awk’s is neither: fourteen rungs from expr down to primary, concatenation with no operator, and a print whose arguments need six of those rungs duplicated because print a > b writes to a file.

   
Pascal 24 match-steps per token
awk, simple statements 370, flat from 705 tokens to 11,205
awk, the corpus 365 – 2,594

A hundred times the constant and the same curve. Sixteen times the input, the same work per token; the expression shape gets cheaper per token as it grows. And the constant does not matter: et_c.awk is 269 lines, 1,452 tokens and 2.9 million match-steps, and it reaches running C in 50 ms.

The figure that surprised me was the node count: 70,204 values built for 1,452 tokens, nearly all of them made and dropped inside alternatives that failed. That is what ordered choice costs and what memoisation would save, and neither is worth a table per position at 50 ms.

So 1.2 is refused a third time, and this time by the hardest grammar tried rather than by the easiest.

And what the measuring actually found

682 of awk-c.phx’s 1,187 lines were quoted C — 58% of the biggest description in the repository, held as one-line string literals.

1.5 was the entry for that, and it said to wait for a second customer. That was the wrong test. There is still only one customer. What made the case is a cost the entry had not noticed:

Seven hundred lines of C inside a .phx cannot be compiled.

The runtime was written as a standalone file and checked against awk before being embedded — twice, once for each stage — and both times the file was thrown away and only the transcription survived. The artefact that had been tested was not the artefact in the repository. Nothing would have caught a change to the literals; make test compiled the runtime only as part of a program generated from it, which is a much weaker thing to compile.

%embed runtime "awk-runtime.c" .

The file is read when the description is read and frozen into whatever -o writes, so one file, no headers, no library still holds. It is looked for beside the description and then in lib/, which is where %import looks; --imports names it; and there is a test that the bytes survive being written into a generated compiler, which is the lesson of the NUL bug applied in advance.

awk-c.phx went from 1,187 lines to 510, and the 682 lines that came out are byte-identical to the C that was tested — checked, rather than assumed, because that was the whole problem.

The rule this changes

3.4 says a library entry has to be something a real pass needed and could not be written in the notation. 1.5 said to wait for a second customer. Both are rules about how many — and the thing that decided this was what the cost was, which neither rule asks about.

The cost here was not repetition and not size. It was that a tested artefact and a shipped artefact had drifted apart with nothing able to notice. That is worth adding to the questions a mechanism has to answer: not only who else wants this, but what can no longer be checked without it.

2026-09-02 — where it stands

A stopping point rather than an entry: what is true now, so that the next session can start from the documents rather than from this one.

What exists

Three languages described and compiled, each against an oracle that is not this project’s:

   
Pascal 35 programs agree with fpc -Miso; 5 outside the subset are refused with a position
Solveig every .sol file in that repository prints what solas’s bytecode prints — byte for byte, tracebacks included, nothing normalised and nothing counted apart
awk 13 programs written here and 6 that e2fsprogs, ncurses and vim ship compile to C that prints what /usr/bin/awk prints

176 tests, 0 failing; 174 of them need nothing outside this repository.

COMPLETED.md is the record — the tool, the languages, the notation, every roadmap entry that has left with a verdict, and the defects found with what found each one. ROADMAP.md is now only what is not built: three entries, one of which is a measurement that keeps coming out the same way.

What this run of stages added to the notation

%include, %embed, %rewrite, otherwise, $pos, and patterns over lists. Six mechanisms, and the thing worth noticing is how few of them were the mechanism that was asked for:

Every time this project has predicted a new mechanism, the answer has been the mechanisms already there, used in an order nobody had tried.

Four for four. otherwise is the sharpest case: added for “an attribute nearly every node answers the same way”, and what it turned out to be good at is “an attribute about the position a node is in” — which answered three questions in the awk backend that nothing else could ask.

What the tests learned

Two claims on this project’s own pages turned out to be false, and both were found by asking what a test does not compare:

The rule both leave behind: a claim about the code is only as strong as the widest thing the tests compare.

Where to look first

README.md for what the tool is, COMPLETED.md for what exists, ROADMAP.md for what does not. The three things named as unfinished are getline’s two piped forms in awk, scope graphs, and compiling the tables to code — and the page says why none of them is urgent.


2026-09-03 — documentation, and what running it found

The tool was finished enough that the day’s work was mostly about the pages around it. Five documents for Phoenix — a manual, a reference, a cheatsheet and two tutorials — then a website that builds them, then the same four documents for a new language, then a week’s worth of finding out that a page nobody runs is a page that is wrong.

The website, assembled rather than committed

www/ holds a front page and a stylesheet; .github/workflows/pages.yml runs www/assemble.sh on every push that touches docs/ or www/. Nothing is committed built, so the site cannot fall behind the documentation and there is no generated HTML in the history.

The one thing that had to be worked around: Jekyll’s Liquid ate {{, which is how this notation writes a literal brace, so six of fourteen documents refused to build. assemble.sh wraps each body in {{**, which is how this notation writes a literal brace, so six of fourteen documents refused to build. assemble.sh wraps each body in {% raw %}. Front matter is added there too, rather than committed, because docs/*.md are read on GitHub as often as on the site and YAML renders there as a stray table.

An assembler for SolVM

languages/solvm/ — 21 mnemonics, labels, nested blocks. It is the thing this tool is most obviously for: every hard part of solveig-sob.phx is absent, because in assembly the programmer says which slot and which frame. What is left is the part that is not, and it is the two-pass shape %driver exists for.

Two mechanisms, because there are two questions. A side-table index is assigned where a name is first seen — a fold over the walk, so a thread. A label is used before it is defined, so it cannot be one, and is gathered instead. Both nest, because a block is its own chunk, and down on a threaded attribute finally has its second customer.

count.sasm is the loop-and-conditional listing printed in SolVM’s own docs/BYTECODE.md, typed back in by hand, and it assembles to that listing exactly — every offset, every opcode, every side-table index.

The oracle found the one thing reading did not

The CAPTURES flag was derived over nested chunks as well as this one, on the reasoning that a frame read from below has to survive too. solas does not, and says why in a comment above touches_home: a nested chunk is not consulted; its depths are counted from its own frame. Both spellings run the same program and print the same answer. Only comparing the instruction streams found it.

And then: run the documentation

Three tutorials were made executable this week, and the first two had been “verified” by their author while writing them. That verification is worth nothing, and it is worth understanding why: an author checks the commands against files that already exist, in a directory that already has state. Neither is what a reader has.

The solvm tutorial had four defects. Three were a solvm run on bytecode nothing had reassembled after the source changed — the reader sees an answer that has nothing to do with the file in front of them. The fourth was the closing comparison, the step the whole page builds to, diffing a program with a block against one without.

docs/tutorial-assembler.md had two: a command using a driver the page never told the reader to write, and a line number captured from a file that already had a later pass appended. docs/tutorial-picture.md had one, and it was a caret line two spaces short.

All three now run in the suite, and each asserts that what came back appears verbatim in the page. Confirmed to bite by drifting a field name in one and a line number in another.

Two roadmap entries left with verdicts

1.6 — | as an expression operator, for awk’s cmd | getline. Where the rung goes is not guessable from the ladder around it, so it was settled against /usr/bin/awk: looser than concatenation, tighter than a relation, a left fold, no newline after it. Two of those four are things a description could be consistently wrong about, which is why the round trip could not have settled them. The entry predicted printargs would need telling where to stop; it needed nothing, because awk keeps | as the redirect inside a print and says so loudly.

2.3 — scope graphs. The entry had scepticism and no evidence, so Turbo Pascal’s units were described to get some: languages/units/. Every rule settled against fpc -Mtp first, including one a reading of the others gets wrong. Resolution stayed a list, and a cycle between two implementations costs nothing — because visibility does not compose, resolving a uses is one lookup in a table the first pass built, not a walk. There is no traversal for a cycle to be a cycle in.

So the entry’s leading criterion, modules that import each other, is not sufficient. What would bite is a language where visibility composes, and the entry leaves saying so.

A gap in the checker, found by using a feature

Adding a slotnames field called names to a node whose clauses save and restore the interned names thread broke the assembler silently: a field is read before an attribute, so the save read the field, the thread never passed through, and the chunk came out with a name count of zero and the names still in it. Three bytes wrong, no diagnostic.

A synthesised attribute of a field’s name had always been warned about. A threaded one had not, and it is the sharper case: the synthesised one is merely invisible from outside, while the threaded one takes a value out of the fold and puts a different one back. It is an error now. An inherited one is a warning. And settling the third case turned up a documentation bug — docs/manual.md stated the resolution order backwards, claiming a field is read before a binding.

What the day’s last hour said about all of it

docs/reference.md § 11 was held by nothing: eighteen of the library’s twenty-two functions had no executable check. 66 claims later, all 66 held.

That is the useful result. Three tutorials and one reference section were made executable this week; the tutorials had eight defects between them and the reference had none. The difference is what the two kinds of page are about. A tutorial’s claims are about a sequence of commands in a directory, and go stale when anything around them moves. A reference’s claims are about the tool, and the tool has tests.

Run the documentation whose claims are not already held by something else. That is sharper than “run your documentation”, and it is what this week actually taught.

176 tests at the start of the day, 189 at the end.

A last pass over the known warts

Closing the day, the standup said ROADMAP.md § 5 was likely stale — labels, slots and threads had all moved this week, so surely two of the five entries described behaviour that no longer happens.

Checking found one, and it was a count. Three languages later this has still not cost anything is now seven grammars, and the claim is stronger than when it was written: languages/solvm/ puts Label above the mnemonics because n:word ":" fails on push and falls through, and languages/units/ tries slots 2 before slots self, n. Two descriptions now rely on ordered choice rather than merely surviving it.

One sentence had become true again by accident, which is worth recording as such: languages/awk/tests/divergent/ holds all three was wrong yesterday, when that directory held four, and closing 1.6 moved getline-pipe.awk out of it. The remaining three entries had not moved at all.

The real gap was omission. Three warts were missing, all found this week:

docs/reference.md mirrors the list and carried the same stale count; it is corrected and gains the three in brief.


2026-09-03 — a slot with a name, after the day was closed

The second entry for this day, and the closeout above it was already written when this began — which is the only reason it is worth saying so. The last thing the assembler was left owing. .slots self, total, i had put the names in the chunk since the day slot names went in — solvm --trace reads them to write value(n: #41) — but an instruction still said local 1, and the page said so in as many words: names are for reading, not for addressing.

What it cost, and what it did not

The resolving is two passes, and it is the same two the assembler already is. The script’s frame is declared by a .slots that is a sibling of the instructions using it and very often below them, so the names are gathered on the way up in one pass and handed back down in the next — which is exactly what layout and sob do for labels. A block needs neither pass, its frame being in its own header; the mechanism is written once for the case that needs it.

Three things made it smaller than expected:

The evidence is that nothing changed

REGOLD=1 rewrote the goldens and git reported one new file and no modifications: five programs, byte for byte, through a rewrite that moved every slot operand from a field to a child node. programs/adder-named.sasm is adder.sasm with outer 1, 1 and local 1 written outer 1, n and local m, and the suite puts both at one path — a chunk records the file it came from — and compares the bytes.

The tutorial’s step 6 now has a 6c that renames the block’s frame and re-assembles, and step 7 compares that build against solas: the named spelling produces the instruction stream the compiler produces.

The check that pays for it

local 0 in a frame of two is a valid instruction that pushes the receiver instead of the argument, and nothing catches it. local nn is not an instruction at all. That is the only thing a name buys that the number does not — the byte is identical — and it is worth the two passes.

Two refusals came with it and one came out of writing the message. A frame with two slots of one name is refused, for the reason two labels of one name are. And outer 3, x at the top level first reported no slot is called x here, which is true and is not the mistake: the lexical chain is as long as the nesting, so a chunk n deep has n frames outside it whenever it runs, and a depth past that is knowable from the text. outer 5, 1 had been silently accepted until then, numeric spelling included.

A diagnosis that is true and not the mistake is a bug in the diagnosis. The $fdepth >= 0 guard on the slot-name check exists only to let the depth complaint win, which is the one that names what is wrong.

189 tests, unchanged — the assembler’s own count went 49 → 58.

Two stale numbers, found on the way past

README.md quoted the assembler’s suite as 25 checks and said it was among the 177; the suite reports 58 and there are 186 that need nothing outside the repository. Both were quoted output, which is the kind that goes stale in silence — the same lesson the tutorials taught this week, one level up. COMPLETED.md’s table of languages had no solvm/ row at all.

And then the check found a claim the manual had wrong

Asked whether anything was still open, the honest way to answer was to exercise the three operand forms the day had not: setlocl by name, setoutr by name, and outer 0. The first two resolved to the same bytes as their numbers, which is what the rest of the feature already said they would.

outer 0, n assembled cleanly and SolVM refused to load it.

solum/src/serialize.c:817 is if (d < 1 || d > ancestor_count), and the chain is indexed ancestors[d - 1] — so a depth counts from one, and depth 0 names no frame at all. The manual said the opposite in as many words: Depth 0 is this frame, so outer 0, s is a roundabout local s. It never was. The cheatsheet’s operand table said it too.

The check written an hour earlier had the upper bound and not the lower, which is the same mistake in a smaller place: d > ancestor_count was transcribed from the behaviour — a chunk cannot reach past its own nesting — rather than from the line of C that states both halves. Reading the source would have given both at once.

A bound taken from reasoning has one end. A bound taken from the code that enforces it has two.

What makes this the third time this week: nothing about the feature required outer 0 to be considered. It came up because a slot written as a name has to ask which frame, and asking that question out loud is what made the depth worth looking at.

One left, and it is the sibling of the one just fixed

serialize.c checks two things about an outer, and the assembler now checks one of them. The other is slot >= ancestors[d - 1] — an outer 1, 99 into a frame of two — and it is still accepted here and refused at load:

$ phx --driver check ... w.sasm      # accepted
$ solvm w.sob
solvm: cannot load: an outer access names a slot that frame has not got

It is knowable statically for the same reason the depth is: every frame on the chain declared its size, and slotrefs already carries a table keyed by depth. It is left open rather than folded in because it would widen slot n is past this frame, which has m from local to outer, and that message has a test naming it.

The sibling check, and the bound it made unreachable

Closing the gap left open above turned out to be a boundary correction rather than an addition. serialize.c checks two things about an outer, and the assembler checked one; the reason the other was missing is that it lived in the wrong file.

! $slot.num > $nslots - 1 sat in the sob pass. Its bound comes from a slots in the source, not from the container — and solvm-sob.phx’s own header says the file is only the two passes that make bytes, with the checks about the program in solvm.phx. Moving it to slotrefs put it where the frames already are, so it covers outer and setoutr for free:

$ phx --driver check ... w.sasm
error: slot 99 is past this frame, which has 2
          outer   1, 99

One check, one message, all four instructions — instead of the two-places-one- job the other shape would have left.

And it made a bound unreachable, which is worth saying out loud. layout also refused a slot wider than a byte. A frame is at most 255 slots, so past the frame it was declared in is strictly tighter: there is no program the byte bound catches that the frame bound does not, and the frame bound says something useful when it fires. It only ever fired before because it ran first.

So it is gone, and tests/slot-too-big.sasm with it — its subject is not a reachable state any more. The depth keeps its byte bound, because nothing bounds a depth more tightly than the nesting does.

A check that only fires because it runs first is not a check, it is an ordering. Moving the tighter one earlier is what exposed which was which.

The goldens did not move. 189 tests; the assembler’s own count 59 -> 58, one refusal retired and its subject folded into a message that already existed.

And a count to correct, in the entry above rather than here. 1db93b8’s message says the depth-zero test took the assembler from 58 to 60. It took it from 58 to 59 — one test, one check. The number was written from memory instead of from grep -c '^ ok', which is the third counting error this week and the second in a commit message, where it cannot be edited afterwards. The run is cheap and the claim is not; there is no reason to have guessed.

Asked a third time whether anything was open, and this time nothing was

Twice today “anything still open?” found a defect, so the third asking got a method instead of a memory. Two sweeps.

Every attribute in both descriptions is defined and read. The refactor moved snames, sl, declared and ownnslots between passes, and a move is where a stranded definition hides. Counting definitions against reads found none stranded, and sl reads only through $items.sl, which is what it is for.

Every rule in verify_chunk is accounted for. solum/src/serialize.c is the list the assembler is really measured against, and it had never been walked end to end — the README’s what it checks, and what it cannot was written from what had been implemented rather than from what the loader refuses. Reading it against the code: eleven rules, of which four are guaranteed by construction, six are checked, and one is verify_stack_heights.

That last one was already named as out of reach, and the reason stands: two paths reaching one instruction at different depths is a dataflow analysis, not a walk over a tree. What changed is that it is now the only one, which is a much stronger sentence than the list it replaces — and the README says so as a table, so the next person can check it in a minute rather than an hour.

The nesting bound, generated rather than reasoned about

SOL_MAX_NESTING is 16 and this refuses $bdepth > 16, which looks like the same bound and is the kind of looking that was wrong twice today. So the programs got generated: sixteen nested blocks with outer 16, 0 at the bottom.

It assembles, loads and runs — OUTER 0 ^16 in the dump, sixteen chunks. Seventeen deep is refused here, and outer 17 from the sixteen-deep one is refused here. The edge is exact at both ends, and the child_count clamp in serialize.c that looked like an off-by-one is unreachable: it bites at an ancestor count of 16, whose children are 17 deep and already refused.

Two of today’s three bugs came from reading a bound instead of running one. Generating the program at the boundary costs a minute and settles it.

189 tests, main clean at 28f0045.

2026-09-03 — a changelog, and the two tables that were about different things

The fourth stretch of a day that had already been closed out three times.

The skill, twice rewritten and then removed

day-closeout had been left unrun since the day it was noticed that it would damage this repository. Asked to fix it, the diagnosis went past the three known collisions:

Two rewrites followed. The first replaced scraping with structured input and added a provenance marker; it still owned filenames, so roadmap.md still opened ROADMAP.md, and its guards ran per file — a refusal on the third document left the first two written. The second discovered the documents instead of naming them, which found ROADMAP.md and COMPLETED.md correctly, and planned every change before applying any.

Then it was removed anyway, and the reason is the one thing worth keeping. The plumbing was never the problem. A script can guarantee it writes the right file, atomically, reversibly — and it still cannot know whether the summary it was handed is the whole day. Asked three times whether it would for sure document everything, the answer was no three times, for the same reason each time. A tool that cannot fail loudly at the thing you actually want is worse than no tool, because it reports success.

Automating the placement of a judgement does not automate the judgement.

The gap it did surface

Running its discovery against this repository listed five roles and found four. There was no changelog — and looking for one is what showed the hole. Four records existed and none answered when: COMPLETED.md is the standing inventory, ROADMAP.md its complement, journal.md this narrative, postmortem.md the scoring. Somebody arriving from the website could learn what exists and why, and not when it arrived or what would break if they had written a description last week.

CHANGELOG.md is that page, organised by the thing this project actually versions by — the stage-0 … stage-7 tags — and ending with a compatibility section that names what a description would have to change.

Two tables, about two different things, and neither said which

Writing it from the tags found COMPLETED.md’s stage table disagreeing: -o at 4 and Pascal at 5, where the tags have Pascal at 4 and -o at 5. The journal settled it — there is no stage 4 entry at all, and the stage 5 entry is -o. %import was credited to 6; the commit adding it is inside 3.

Fixing that turned up why it had drifted. The README has a stage table too, and it is a different kind of table: the original plan, ticked off, where COMPLETED.md’s is the delivery keyed to the tags. They agree everywhere but stage 4 — the plan put an emit pass writing C there, and it arrived early, examples/calc-c.phx being present already at tag stage-3, so the stage-4 tag went onto the Pascal work instead.

Neither table was wrong about its own subject. Neither said what its subject was, which is what let one of them drift for two days with a second table sitting in the README contradicting it.

Both say now, and the delivery table names the tags, so the claim is checkable in one command.

The page was wrong an hour after it was published

Asked again whether anything was open, the answer was to run git log -S for each feature the changelog dates rather than re-read it. otherwise was listed as one of six notation entries added on the 2nd. It was not added that day — 067fff8 Stage 2: passes, on the 1st.

What happened on the 2nd was better than what the page claimed. ROADMAP 1.3, the entry that had carried a warning for three stages, closed with no new notation: the mechanism was already built and languages/pascal/pascal.phx was already using it twenty-one times with a comment saying why.

Five entries, then, not six — and the correction is worth more than the count, because an entry that closes because the answer was already written is a different and stronger result than one that closes because something was built.

Also softened: $pos becoming a span is listed as a breaking change, and it and $pos itself landed the same day hours apart, so only a description written between two commits is affected. Saying “breaking” without that is louder than the truth.

189 tests throughout. main at ccd8cb1.

The fourth measurement, and the harness that could not fail

ROADMAP 1.2 has come out the same way four times. What changed this time is what was measured: languages/solvm/ is the control the first three lacked. It has no expression grammar at all — an instruction is a mnemonic and its operands, and the first token settles which rule matches — so it is what this matcher costs when a grammar asks nothing of it.

11 to 25 match-steps per token, against Pascal’s 12 to 31 and awk’s 221 to 2,638. Three grammars, 240× apart in constant and identical in curve, over nine shapes. Two of the nine get cheaper per token as they grow.

The constant tracks how deep ordered choice must go before it commits. The curve tracks nothing.

That is a stronger case than three measurements could make, because the assembler is precisely where generating code would help least, and it is within a factor of two of Pascal.

The defect was in the instrument

bench/run.sh never checked whether phx had succeeded. On error --stats prints nothing and the diagnosis arrives on the same stream, so awk '{print $9}' picked a word out of the error text and the row looked like a measurement.

Two numbers in performance.md came from that — nest and blocks at n=1600, for shapes that cannot complete. A nested parenthesis costs 11 levels of grammar recursion, so the 10,000-level limit binds at about nine hundred parentheses; the page had never distinguished a level from a paren, and reading it could not have caught that.

A measurement harness that cannot report failure will report something else. The numbers it invented were plausible, which is why they survived three readings of the page they were on.

And the awk figures had no generator anywhere in the repository — nothing could reproduce them. bench/generate-awk.awk is that now, and its constant differs from the old claim because the grammar gained a rung today, | for getline. Reproducible numbers show that drift. Unreproducible ones hide it.

Timings re-taken while there: 20,000 lines of Pascal reach C in 189 ms, not the 238 the page claimed.

1.2 closed, and the roadmap emptied

The fourth measurement did not change the number and did change the decision, which is the distinction the entry had been missing.

Three flat curves could not say why they were flat, because all three grammars were expression languages. The assembler is the control: no expression grammar at all, and 11–25 match-steps per token against Pascal’s 12–31. So the constant tracks how deep ordered choice must go before it commits, the curve tracks nothing, and generating code buys a constant factor on a matcher already within 2× of the grammar that asks least.

Against that it costs a second implementation of ordered choice, of floored division, of pattern matching. Two implementations of one notation is what this project has refused everywhere else, and there is now a measurement saying what refusing it costs: nothing anybody has asked for.

Closed against, with the reopening order preserved — tables first, code checked against the interpreter that produced them.

The section that emptied, and one entry that was in the wrong half

Sections 1 and 2 of the roadmap are now both empty. Nothing on that page is open; what is left is what the project has decided not to have, what a description is checked for, and the warts it knows about.

Filing 1.2 under Settled, and not built turned up 2.3 sitting in the wrong half. Its own first line reads Settled against, and the way it was settled is the point — and it was filed under Settled, and built, where it had been since the day it closed. The audit that caught it compared each entry’s verdict sentence against its section, which is a thing worth doing once per section rather than once per entry.

A document with two halves will eventually have something in the wrong one, and the check is cheap: read what each entry says it is, not where it sits.

Three entries are settled against now — 1.2, 2.1, 2.3 — and the README had said the one.

2026-09-04 — the standup, moved out of the records it was never part of

docs/daily-standup.md was a working note filed among the records. It was written at the end of each day to be read at the start of the next, which is a different job from the five documents around it: they are written for a stranger a year from now, and it was written for one person tomorrow morning. It moved to scratch/, which is gitignored, and left version control.

The evidence that it was never a record was that nothing linked to it. The five documents in docs/ are defined as much by their cross-references as by their contents — ROADMAP.md and COMPLETED.md are two halves of one ledger and say so in every entry, postmortem.md scores what the other two predicted, and CHANGELOG.md answers the when the rest decline to. A grep for the standup’s path across the whole repository before the move returned nothing outside the file itself. Three documents mention the standup as an actor — what it estimated, what it recommended against — and not one of them had ever needed to link to it. A document that nothing points at, in a set whose discipline is pointing at each other, was already outside the set; the move only wrote that down.

What the move cost, and the wrong way to pay it back

It cost the handoff. The file the next session reads first is now a file in a directory that session is told not to open, and one that a fresh clone does not have at all. The first instinct was a stub in docs/ — a small tracked file whose whole content is the scratch path — and that instinct was wrong for a reason worth keeping.

A stub would have been a sixth document in a set of five. The property that makes those five work is that each answers a question no other one answers, and the italic note at the top of each is that claim in writing. The stub’s question would have been where is the other file, which is not a question about this project. It would have earned a heading, a cross-link from the others, and a line in whatever counts them, all to say something that is not about compiling anything.

What was written instead is CLAUDE.md at the root, which is already the file whose job is orientation and which no record cross-links by design. It names the standup’s path as the thing to read first and then does the part a stub could not: it says plainly that the path is absent on a fresh clone and on any day that was not closed out, and gives git log and the five documents as the way in for that case. A pointer that admits it often points at nothing is honest in a way a stub in docs/ would have had to pretend not to be.

A document that has to explain where another document is has the wrong question. Move the pointer to the file whose question is already where do I start.

What the audit found, which was nothing

The day’s second half was an audit, and the useful result is that it changed nothing. Sections 1 and 2 of the roadmap are still empty, make test is still 189 passing and 0 failing — the same count before the writing and after — and a sweep for TODO, FIXME and TBD across every tracked file returns empty. Nothing was owed to ROADMAP.md, so nothing was added to it; the temptation to record push the branch on a page whose header says it lists what is not built is the same category error as the stub, one document doing another’s job.

2026-09-05 — a second backend, and the string somebody had typed

The roadmap said nothing was open, and that was true of sections 1 and 2. This day’s work came out of section 3, which is the list of things deliberately not built — and the thing that changed there was not a decision but a cost.

§ 3.1 refuses an interpreter that can loop, and the refusal still stands: an attribute is computed once per node in one walk, a loop needs its body evaluated a number of times that depends on the program, and neither is a thing a value computed once can say. What the entry also did was admit what the refusal costs — “the conformance rule therefore covers straight-line programs only” — and then name the fix and leave it unbuilt. That sentence had been sitting there since 2026-09-01.

An entry in section 3 has two halves and only one of them is a decision. The decision was right and is unchanged. The cost was payable and had not been paid, and nothing about an empty section 1 said so.

What the hole actually was

The conformance rule is that one description, run two independent ways, gives one answer. sum.calc has always had both legs: the suite takes what --run eval says and holds the compiled binary against it, so nobody ever typed 97 anywhere — two implementations agreed on it and the test records the agreement.

programs/fizz.calc is the only calc program with a loop and a branch in it, and it had one leg. --run eval refuses it by design. So the test read:

if [ "$got" = "1 2 300 4 5 300 7 8 300 10 11 300 13 14 300 " ]; then

That string is right. It is also a person’s answer, checked by that person, sitting in a suite whose whole method everywhere else is to make two independent things agree. The gap was not that the expectation was wrong; it was that nothing could have said so.

Why awk, and why not the two easier answers

calc-solveig.phx was already a second backend and could not be this one. It needs solas and solvm, which are not in this repository, so the suite can only run it behind PHX_TEST_SOLVEIG=1 — a leg that is absent on most machines is not a leg. Its control-flow clauses are also missing on purpose, which is the same fact from the other end.

A second C backend — the same tree emitted through goto and labels — was the cheap answer and is the one worth arguing against explicitly. It would have caught a mistake in its own clauses. It could not have caught a host assumption leaking into the notation, because both legs would still divide the way C divides, and that is what the two-backend discipline was for (2026-09-01).

awk is wrong in a different direction, which is the whole qualification for the job. Its numbers are doubles. / is floating division where calc’s truncates, so the backend has to write the model out as int(a / b) — the same sentence semantics.md was written to force, met a second time in a second host. And it costs no dependency at all: the Makefile builds phoenix/runtime.h by piping through awk, so a machine that cannot run these tests cannot build phx either.

The mutation that proves it, and it is the interesting one

A test that has never failed is a claim about a test, so the backend was broken on purpose twice. Removing the int() from division — the one clause that models the host rather than spelling it — made awk print 300 fifteen times, because floating division makes n / 3 * 3 = n true for every n and the branch always takes the same arm.

That output is plausible. It is fifteen lines of a number the program really does print, from a backend that is consistently wrong about the one thing this project has a whole document to pin down. No interpreter leg could ever have caught it, because the interpreter refuses that program. It is exactly the failure the conformance rule exists to catch, and until today calc’s looping programs were outside the rule.

A test whose expectation was typed by the person who wrote the code being tested is a spell-check, not a check. The suite knew that everywhere except the three programs the interpreter could not reach.

What the estimate got wrong, which is in the postmortem

“About fifteen lines” against twenty-nine actual. The miss is scored in postmortem § 12 and is worth one line here because of where the missing half went: nine of the twenty-four clause lines answer for nodes calc’s own grammar never writes — Binary, Compare, Logical, Negate and Not all arrive with lib/expression.phx. The other fifteen are calc’s own, which is the estimate almost exactly.

calc.phx has a comment directly above those clauses saying what importing a grammar module costs. The estimate was made anyway.

A cost written down in a comment is not a cost that has been counted.

What the two files now say to each other

Both emit passes are twenty-four clause lines and seventeen of them are identical character for character. The seven that differ carry five differences, and each is the target forcing one: awk’s special variables are ordinary assignable names, so a variable gets a _ prefix, where C would have rejected long printf = 3; loudly and awk would have accepted NF = 3 and gone quietly wrong; / is floating; there are no declarations, so let and := are one shape; print x > y redirects to a file, so the argument list is parenthesised; and the unit of execution is a rule, so a program is a BEGIN block.

That is what %import was argued for, and it is the first time the argument has been checkable rather than asserted: a diff between calc-c.phx and calc-awk.phx is a list of the ways awk is not C, and contains nothing else, because there is nothing else in either file to differ about.

The sweep afterwards, and nine numbers that had gone quietly wrong

A status sweep the same afternoon held every checkable claim in the records against the tree. Nine numbers disagreed, and the shape of the disagreement is the part worth keeping: not one of them was ever wrong when it was written.

solveig 1,108, awk 968 and 50 node types, solvm 820 — all four entered COMPLETED.md on 2026-09-02 and 2026-09-03, and all four descriptions changed after. ee5c993, the commit that made | an expression operator, is almost certainly what turned awk’s fiftieth node type into its fifty-first. Nobody was careless; the numbers were true, and then the thing they counted moved.

A record’s prose is written to be read again. Its numbers are written once and then quietly describe a repository that has gone on without them.

Two of the nine were two hours old. 480 lines for calc went into the ledger this morning and a comment block in calc-awk.phx grew by two lines afterwards; 189 checks in the README was corrected in the CHANGELOG and not in the README. Same failure at a hundredth of the timescale, which is the useful part: it is not a discipline problem that better care would fix, it is that nothing runs these numbers. semantics.md has a whole entry about that — a specification nothing runs is a document about a program — and the counts in the ledger are in exactly the position semantics.md was in before tests/grammars/semantics.phx.

The README’s was the one that mattered, being the page a stranger reads first, and it was the wrong shape rather than merely stale: “186 of the 189, and the two that drive solas and solvm” counted skipped lines in one clause and tests in the other. Measured rather than reasoned about, with the tools removed: 195 passed with them, 191 without, four external tests reported through two skipped lines. The claim the paragraph exists to make survived — the suite does pass with nothing outside this repository.

And the check that could not fail, found in the thing built to prevent it

The sweep also read tests/run.sh and found that backends_agree, written this morning, checked neither program’s exit status — and captured stderr for awk while not capturing it for C. Two programs that both die print the same nothing, and the comparison would have called that agreement. COMPLETED.md already carries a row for this exact mistake reaching bench/run.sh, so it is the second time, in the same repository, in a harness written to be trusted.

Probing the fix turned up something better than the fix. A calc program dividing by zero:

   
awk fatal, exit 2, “division by zero”
C exit 0, prints 0 — undefined behaviour, and on this machine the quiet kind

calc has never said what dividing by zero means. Its eval clause refuses it, but that clause reads $right.val and so is the interpreter’s alone; a compiled program has whatever the target does, and the two targets disagree. That is the same hole quotient was written to close, one operator over: the description borrowed each host’s answer because it had not given one. It is written down in calc.phx beside the check that misled, and left unfixed, because emitting a guard is a language decision this calculator has not needed to make.

The pleasing part is the order. The divergence was found on the same day as the machinery that finds divergences, by using it. Two backends existed for six hours and had already caught something the interpreter could not see.

Two claims reframed rather than picked

Two of the sweep’s findings had no right answer and a better question. ~12,900 lines of C11 counts runtime.h, 4,217 generated lines that are the runtime re-emitted as a string literal — so the runtime is in the number twice. Choosing 8,698 or 12,915 would have made one of two true readings disappear; the ledger states both and says what separates them. Likewise languages/units/ was absent from a table of six languages in a tree with seven directories, which reads as an oversight and is a decision — so the table says so now, in a line, rather than leaving a reader to count.

When a number has two honest readings, the fix is not to pick one. It is that the page never said which it meant.

And one number that cannot be kept right, so it stopped being a number

The recount put 76 Solveig programs against solas back in the README having checked it. Two runs an hour later reported 77, and then 76 again. Nothing in this repository changed between them.

That leg counts .sol files in another repository, maintained by somebody else on their own schedule. The number was never Phoenix’s to know, and a page that quotes it is a page that goes wrong on a commit made somewhere else — the same dependency, one level up, that got pascal.bnf’s fixtures vendored into this tree on 2026-09-01. COMPLETED.md had already found the right form and used it — every .sol file in that repository — so the README now says that too, and lets the suite print whatever it counted.

A number in a document is a promise to keep it right. Do not make that promise about a repository you do not control.

And then the counts were made to run

The obvious answer to nine stale numbers is to be more careful. It is the wrong answer, and the evidence was in the same sweep: two of the nine were two hours old, written by somebody who had just measured them, in the session that measured them. Care is what produced them. What was missing is what semantics.md was missing before tests/grammars/semantics.phx — something that runs the claim.

tests/counts.sh holds every count in the records against wc -l, --nodes and the tree: eighteen checks, reported to the suite as one. Each of the five kinds was mutated to confirm it bites, including putting awk’s stale 968 and 50 back and watching them fail.

The interesting part was the count it cannot check. The suite’s own total is the most visible number in the records and the one a test cannot assert, because asserting it changes it. Making that a test would have meant a fixpoint — this check passes when the README says one more than the number of checks that are not this one — which is a puzzle wearing a check’s clothes.

So it is not a test. It runs in tests/run.sh after the summary line, where pass is final and nothing is still counting, and it fails the run without being in it. The first time it ran it did exactly that, and make came back with Error 1 over a stale number, which this repository had never done before.

That immediately found a second thing. The check fails on a machine without fpc or Solveig, where the suite legitimately reports a smaller number — a check about the records would have become a check about the machine. The fix needed the suite to know what it had not run, and it did not: skips were printed and never counted. They are counted now, and a guard declares how many checks are behind it rather than how many lines it prints, so three Solveig checks behind one guard count as three. An incomplete run says 193 passed, 0 failed, 4 skipped, the summary the status sweep had wanted that morning and could not have.

A check that cannot say what it skipped is a check that reports a number it has no right to.

The survey, and what a survey is actually for

The question was what do comparable tools have that we do not, and the useful part of the answer was how little of it was new. lineage.md already named JastAdd, Silver, Eli, UUAG, LISA, Kiama, Spoofax, Stratego, Statix, Rascal, TXL, Nanopass, Coco/R, Cocktail and TREE-META. Of the features those tools have and this one does not, most were already refused with evidence — reference attributes lost to awk, scope graphs lost to Pascal units, host-language actions and conditionals are design decisions with reasoning attached.

So the survey’s yield was three families the page had missed and two entries that could be opened, and it is worth saying which came first: the entries did. languages/solvm/ has emitted .sob and been unable to read it since the day it was written, and languages/units/divergent/three-cycle.pas has sat there refusing to be refused. Both were recorded as costs. What the survey supplied was not the problems but the prior art — Kaitai Struct’s repeat-expr for the first, JastAdd’s circular attributes for the second — and that is what moved them from a cost to somebody’s solved problem.

A survey of other tools does not tell you what you are missing. It tells you what the thing you already know you are missing is called.

The three families added are Kaitai and DFDL for binary layouts, Langium for what a workbench looks like now, and the PEG error-reporting line — Ford’s farthest failure, then labeled failures, then recovery — which is the literature for a limit this tool has and had never named.

Two findings that are not roadmap entries, and why

The parser reports one syntax error, at the wrong column. Measured rather than suspected: three bad statements give one message, where three bad names give three. print a +; is reported at column 1 as expected -, (, integer or name, and found “print”, because ordered choice discards why an alternative failed and what surfaces is the outer position that gave up. That is a wart — 5 — because no description is blocked by it. It is also the weakest surface this tool has, for a project whose argument is that a fault found while reading is found before anybody else sees it.

The round trip rests on a hand-written renderer. Three descriptions are checked by rendering the tree and parsing it again, and this repository states three separate times that a round trip can be green while the parse is consistently wrong, because what is written back out is wrong in the same way. Those renderers are 122 lines in awk, 51 in solvm, 46 in solveig, 16 in calc, and nothing checks that a show pass agrees with the grammar it renders — a person wrote both. SDF3 closes this by deriving the pretty-printer from the grammar. It went into 4, under things no check can reach, rather than onto the roadmap, because no description is blocked and that page’s bar is a language that cannot say something.

Those two placements are the part of this worth remembering. The roadmap’s opening note says an entry names why, and its header says the next one should arrive from a real language needing something — so a gap found by reading about other tools does not qualify, however real it is. It goes where the project already keeps things it knows and has not fixed.

And a tenth stale number, in the record the check did not cover

lineage.md opened its argument with 5,300 lines of C11 against the C standard library and nothing else. The tool is 8,698 lines. Written on 2026-09-01 it was exact — 5,333 against a claimed 5,300 — and four days later it understated by 63%.

It survived a sweep that was looking for exactly this, the morning after tests/counts.sh was built to make the class impossible. Two reasons, and both are instructive. The check covered COMPLETED.md and postmortem.md because those are where the counts were — a check written from a list of known cases rather than from the question which records quote numbers. And 5,300 is close to what the descriptions now come to, 5,395, so the number reads as plausibly about something. A stale number that looks wrong gets fixed; one that looks like a different true number does not.

tests/counts.sh covers lineage.md now, and the mutation was run.

A check written from the cases you found is a check against the cases you found. The question it should have been written from is what kind of thing can be wrong.

$pos does survive a rewrite, and the check for it found something else

The standup’s one untested item, answered: it survives. A %rewrite that folds 2 * 3 to 6 gives a node claiming 1:3..1:5, which is exactly the span the Binary it replaced had. run.c copies v->pos and v->endpos into the builder on purpose, with a comment saying why, and the behaviour matches the comment at an inner node as well as at the root.

Two corrections to what the standup said. It claimed nothing in the records discusses it. reference.md has discussed it since %rewrite shipped — a node built by a rewrite takes the position of the node it replaced, so a later diagnostic points at the program rather than at the rule. The claim was there and nothing ran it, which is the position semantics.md was in before it became a test. It is two checks in tests/grammars/fold.phx now, and breaking the copy in run.c was tried, and caught.

And the syntax-error wart was diagnosed wrong this morning

Written up a few hours earlier as ordered choice throws away why an alternative failed, so the error surfaces at the outer position that gave up. That is the textbook problem and it is not the one this tool has.

parse.c already implements Ford’s farthest-failure heuristic — note_want tracks the highest token index ever reached and the set of things wanted there. The reference documents it. It works. There are two failure paths and the second throws the answer away:

if (got < t->n) {
    /* The goal matched, and there is more file. */
    p.furthest = got;
    report_failure(&p);

furthest is overwritten with the first leftover token — deliberately, and defensibly, since that is the first thing the parse could not use — but p->wanted is not cleared, because clearing it is note_want’s job and note_want was bypassed. So the message pairs one position’s token with another position’s expectations:

print a +;
^ error: expected -, (, integer or name, and found "print"

print is legal at that column. The same file parses with the expression finished. The expectations belong to the ;, eight columns along.

And a start rule that is a repetition can never fail outright. program = { statement } matches zero statements and succeeds, so in every language described here every syntax error takes the second path. The wart is universal and was recorded as a guess.

The heuristic was not missing. A code path was discarding its answer, and the literature was the wrong place to look for that.

What this says about the survey. Reading about how other tools solve a problem told me what the problem is called and predisposed me to assume Phoenix had it in the textbook form. It did not. Naming a limit from the outside is worth doing and is not evidence about the inside — the fifteen minutes in parse.c were worth more than the afternoon of comparison, and would have been worth more still if they had come first.

And then it was fixed, which took four lines

The wart above lasted an afternoon. parse_run keeps the furthest point unless nothing got past the leftover token, and clears nwanted in that fallback because moving the position invalidates what was wanted at the old one:

if (p.furthest < got) {
    p.furthest = got;
    p.nwanted  = 0;
}

Every case improved and none regressed. let a := 1 with a missing semicolon was reported at 1:1 — the first character of the file — and is now reported at the print on line 2, which is where a person notices it too.

Two things the fix turned up that the diagnosis had not.

A test was pinning the defect in place. languages/awk/tests/divergent/spaced-regex.awk asserted the message contained and found "BEGIN", which was the parser blaming the first token of a file for a fault 34 columns into line 13. It read like a test of the divergence and was a test of the bug. The new expectation is the position, which is what that divergence is actually about — and the caret now lands on the / +/ the description cannot read.

A test written from observed output records the behaviour, not the intent. When the two differ, the test is what keeps them apart.

And tests/counts.sh caught its own author, four hours old. Editing parse.c grew the tool by fourteen lines and three records went stale at once — COMPLETED.md twice, lineage.md, and postmortem.md, which the check did not yet cover and does now. The suite refused the build before the commit existed. That is the first time a number in this repository has been wrong for less than a minute.

The sweep the standup asked for, and it found its own author twice

The carry-forward was an expectation nothing derives — a literal pasted from a run rather than worked out from the intent. Every prints, refuses and warns in the suite was read against that test. Three hits out of seventy-three, and the shape of the misses is the more useful half.

The fifty-three refuses are almost all right, and right in the same way. They match a distinctive phrase from the diagnostic’s intent — left-recursive, a check runs before the attributes it guards, is a pass as well as a rewrite. A phrase like that is a restatement of the rule being enforced, so it cannot be pasted from a run without saying what the run was for. That is a pattern worth naming, because it was arrived at fifty-three times without being written down anywhere.

One refuses was the opposite failure, and it is the more dangerous kind:

refuses "a reserved word as a name" "expected"

"expected" is in every parse error this tool emits. The test asserted that let print := 1; was refused and nothing whatever about why — a missing semicolon in an unrelated file satisfies it, which was checked. It has the name and the position now, and the missing semicolon was checked again and no longer passes.

A test can fail to derive its expectation in two directions. A pasted literal says too much about one run; a fragment this loose says nothing about any.

And the other two hits were three hours old and mine. The checks written that afternoon for $pos surviving a rewrite pasted a thirteen-column span tree —

((2@1:1..1:1 + (3@1:5..1:5 * 4@1:9..1:9)@1:7..1:9)@1:3..1:9 + 1@1:13..1:13)@1:11..1:13

— which is exactly the thing the standup had complained about, written by the person who wrote the complaint, in the same session. It is unreadable, and worse, it silently encodes a rule nobody had written down.

That rule is the fourth finding. A node’s span is the syntax that built it, not everything underneath it. An action on a whole alternative gets the whole alternative — Print covers print a; — but an action inside a repetition gets that turn only, and every binary operator here is built by a left fold where $$ came from an earlier turn. So Binary starts at its operator: in 1 + 2 it claims columns 3 to 5 while its left child is the 1 at column

  1. A parent’s span does not necessarily enclose its children’s.

That is probably the right behaviour — it is what a diagnostic about an operator wants — and it was undocumented, which is why the pasted literal could not be checked by eye. reference.md says it now, and the test is 2 * 3, three tokens at columns 1, 3 and 5, verifiable by counting.

The rewrite check derives its span now: whatever the root claims before the fold is what the folded node must claim after. Breaking the copy in run.c was tried again and caught — root claimed 1:11..1:13, folded said 15@1:1..1:1.

The discipline is not write better literals. It is that an expectation should be produced by something other than the code under test — the other backend, the other run, or arithmetic a reader can do.

The first sentence, which was true of yacc as well

The page opened with you write a language’s grammar in EBNF and describe what each construct means. That is true, and it is also true of the front half of yacc — the thing that separates this tool from yacc was sixty lines further down, in the section arguing about the name. It leads now: a compiler is a grammar plus a sequence of tree walks, and both halves can be written down instead of programmed, then what a .phx file therefore holds and what comes out of it.

Four copies carry it — this page’s README, CLAUDE.md, the landing page and the repository’s own description on GitHub — and only the last had a limit worth recording. A description there caps at 350 characters and the sentence is 357, so standalone came out of that copy and stayed in the others.

The meta tags took a shorter form rather than the same one truncated. A search result is cut near 160 characters and a link preview near 200; the full two sentences would have been severed mid-clause in front of exactly the readers who have not arrived yet. 179 characters, same claim.

The hero was the one worth looking at twice. Pasted in full it became eight lines of large type above the pipeline diagram, because .hero .tag is capped at 46 characters wide — and the page’s own three sections already say the detail at more length than a lede should. It stops at instead of programmed.

The name, asked properly and kept

The section arguing that compiler-compiler is the older and better word had never said who else holds the shorter one. Asked directly, the answer is that the word is taken everywhere except here: Elixir’s framework owns the search term, Arize’s owns the AI-observability one, Apache Phoenix is SQL over HBase, and none of the three is a compiler generator. Searching GitHub for one that is turns up coursework.

The namesake in the same field is dead. Microsoft Phoenix was a compiler and program-analysis framework out of Microsoft Research whose last SDK was the June 2008 CTP; what outlived it was some of the static analysis in Visual Studio 2010, and its Wikipedia article was deleted.

Then the question the other way round: what would a rename buy. Every myth-adjacent alternative collides nearer than the incumbents do. Pyre is Meta’s Python type checker and Cinder is Instagram’s CPython fork — both in this field. Bennu, the Egyptian bird the Greek phoenix descends from and so the clever version of the move, is already a JavaScript parser combinator library and a game programming language. That is trading a distant collision for a near one.

Brooker was the only candidate that survived the check — nothing above two stars holds it, and it means precisely what the name section argues, since the Compiler Compiler at Manchester had a notation for meaning and not only for syntax. It loses anyway: a surname says nothing to a reader who does not know the history, which is most readers, and it yields no better binary than phx.

The word is contested everywhere except in the field that could confuse it for something else.

What is actually contested is not Phoenix but phx, which is how Elixir spells its mix tasks. Homebrew has neither name, which is where it would matter; npm, crates.io and PyPI each have a dead phx.

Three paragraphs of this are in the README now, under the section that was already arguing about the word — including the precedent, which is the same word: Mozilla shipped a browser called Phoenix in September 2002, renamed it Firebird in April 2003 when Phoenix Technologies objected, and renamed it Firefox in February 2004 when the Firebird database project objected in turn. No star counts and no formula counts went in with it. A number that moves on its own does not belong in a record nothing re-runs.

The closeout said the tree was clean, and it was not

The day was closed out once already, and the standup’s table said clean, nothing uncommitted at eb4e650. Two records were sitting modified: COMPLETED.md’s defect row and postmortem.md § 14 — the very entries the same standup listed as the day’s durable half. They had been written and not committed, and the status table was filled in from what the closeout had done rather than from what git status said.

That is the day’s own carry-forward wearing different clothes. An expectation produced by the thing it describes is worth nothing, and it does not stop being worth nothing when the thing doing the describing is a closeout rather than a test. They are committed now, as 0d27886.

What the next language would have to fail at

More descriptions do not add mechanisms; they produce the evidence that earns one. The roadmap already says so in both open entries — one is a workaround; two is a mechanism for 1.7, and a customer a person would notice for 2.5. So the question is not which language would be pleasant to write but which one would fail at something.

Three candidates were considered and two rejected. BASIC teaches nothing: its one distinctive shape is unstructured control flow, which pascal.phx already parses and pascal-c.phx already emits as C goto, and there is no canonical dialect to be right against — no arbiter, no row in the table. Lua would most likely produce a negative result at full price: dynamic values, hash tables and coercion are what awk already demanded, and its one new thing, closures, is a set synthesised up the scope tree, which is what %pass does. Java is the right idea at the wrong size — inherited-member lookup is where reference attributes pay, and it is the only thing here that would seriously retest 2.1’s verdict, but the subset would be arbitrary and the runtime enormous.

What survived is two shapes. An assembler for an ISA with span-dependent instructions, which is 2.5’s cheapest possible customer and is written up in that entry. And lex and yacc as described languages, where a subset construction and a FIRST/FOLLOW closure are fixpoints by definition, flex and bison are the arbiters, and the tool whose name this one argues about becomes a description in it. The second has a question in front of it: a yacc file embeds arbitrary C in braces, so reading one means scanning nested braces, strings and comments, and whether the lexical half can express that is an experiment rather than a guess.

A Z80 assembler, and the half of it that was supposed to be boring

The customer 2.5 has been waiting for, started the same evening it was named. z80asm went on the machine and the first thing built was the comparison, not the description: four programs, assembled by the oracle, and no expectation typed anywhere. That is the day’s carry-forward applied before there was anything to be wrong about — every byte the suite checks came out of z80asm, so there is no literal for a later reading to have pasted.

The subset is immediate loads, inc, dec, the three immediate arithmetic instructions, absolute jumps and calls, ret, nop, halt, and labels. 190 lines. jr is not in it on purpose, and that is the whole design: every instruction here has one encoding and one length, so layout threading a pc and handing a label table back down — solvm’s two passes, unchanged — is enough.

All four programs agreed with z80asm on the first run. That is the control doing its job rather than luck: if a subset with no choice of encoding had disagreed, the fault would have been in this description and the jr experiment would have been run on a broken instrument. It agreed, so what happens when jr is added is about the notation and nothing else.

One thing the notation made pleasant. A register is written in the grammar as its encoding — reg = "b" -> "0" | "c" -> "1" and so on — because ld r, n is 0x06 + 8r, inc r is 0x04 + 8r and dec r is 0x05 + 8r. Three clauses would otherwise each carry a copy of the same seven-entry table. It is solveig.phx’s relation = "<=" -> "lessOrEqual" in a language whose vocabulary happens to be numeric.

And one small mystery closed. An empty a.bin had appeared in the repository root and nothing in the Makefile or the suite writes that name. z80asm writes it when -o is left off, the way cc writes a.out. The oracle runner passes -o into a temporary directory and says why in a comment.

Five checks, and the three records that went stale at once

199 to 204, and the counts caught it before the commit existed — README.md and CHANGELOG.md both still said 199, which is the check written this morning working exactly as its own postmortem entry described: a wrong number surviving for the gap between two commands rather than for days.

The third was COMPLETED.md’s languages table, and it went stale in two ways a number does not usually go stale: a new row was needed, and the sentence under it said seven directories, six rows. Both fixed — but the more useful part is that tests/counts.sh iterated pascal solveig awk solvm calc phx, a list with no z80 in it, so the new row’s two numbers would have been unchecked from the moment they were written.

That is postmortem 14 arriving four hours after it was written up. Its finding was that a check assembled from the cases a sweep found inherits the sweep’s blind spot, and the fix it named was to derive the check’s inputs from the question instead. The question is which languages have a row, so z80 is in the loop now and the row is held against the tree like the other six.

br, and the two bytes that are the entry

The step the standup called three. jr went into the grammar as an ordinary instruction — two bytes, a signed displacement from the byte after it — and that on its own changes nothing, because a jr the programmer wrote has a fixed size whether it reaches or not. What creates the problem is br: a pseudo-instruction that assembles to jr when the target is in reach and jp when it is not, so its size depends on a distance that depends on sizes.

What one walk can actually do. More than expected, and the shape of what it cannot do is sharper for it. layout threads seen, the labels already met, so a backward br is decided exactly. A forward one is not, and assuming short and being wrong emits a jump to somewhere else, so it is assumed long. Correct, and not minimal.

The interesting witness is chain.z80, because it rules out the cheap answer. One nop, a forward br, 123 more nops, a backward br, and halt. With the forward br taking three bytes the backward one sits at 127 and its displacement is -129, one out of range, so it takes three too: 131 bytes. With the forward one taking two, the backward one sits at 126, the displacement is exactly -128, and both fit: 129.

So a second walk is not the fix. The first shrink is what makes the second possible and the second is what makes the first worth having — recompute until nothing moves, which is the fixpoint ROADMAP 2.5 has been describing since it opened, now with a program that a person can count the waste in.

The notation can check a relative jump and cannot choose one. The check needs every address, which the second walk has; the choice needs a size the first walk is still deciding.

And the tool asked for a third pass. A rule cannot both compute disp and complain about it — a check runs before the attributes it guards — and phx refuses that with the fix in the message: write the expression out here, or compute it in an earlier pass. Writing it out meant one subtraction copied into six places, so reach is a pass of its own between layout and code, whose whole job is how far each jump goes.

A thread declared in the wrong place is not a thread, and nothing says so

br picked the long form for a backward target, which is the one case the walk knows the answer to. The label table was arriving empty at every node.

The cause is position. thread seen = empty had been written in the middle of the pass, under the rule that assigns it, and clauses are classified as they are read — pass.c asks is_thread at that moment. A rule above the declaration gets an ordinary synthesised attribute; a rule below it gets the thread. Two attributes, one name, no diagnostic. Moving one line fixed it, and the same file with the line in the other place is a different program.

Isolated, because a bug found through 326 lines of Z80 is not yet a bug:

%pass late                     %pass early
  Num  : seen = $seen + 1 .      thread seen = 0
  thread seen = 0                Num  : seen = $seen + 1 .
  Prog : total = $seen .         Prog : total = $seen .

Three digits in, 0 on the left and 3 on the right.

It is written into ROADMAP 5 and not pinned by a test, on purpose. A test asserting 0 would encode the defect as intended behaviour, which is precisely what spaced-regex.awk did this morning and what took four hours to notice. The shadowed thread is already an error in this tool; this belongs in the same family, and the fix is a sweep at the end of the pass rather than another expectation.

And the count read off a red run was already wrong

A small one, worth a line because it wasted a cycle. make test printed 206 passed, 1 failed — the failure being counts.sh, which had noticed the z80 row’s numbers move — so the records were updated to say 206. Fixing the count turned that failing check into a passing one and the total became 207.

The number of checks a red run reports is not the number a green one will. The check that holds the records to the total is the thing that caught it, one command later, which is the third time today it has paid for itself.

The wart, fixed in the other file

The sweep the standup asked for, and it went somewhere else than the standup said. The prediction was the end of read_one_pass in pass.c, which is where the classification happens and is the obvious place. It is wrong for one reason: a pass is only whole once every %import has been read. A rule in one module and the thread in another is the same defect, and a check running at the end of each file would not see it. So it is in check.c, next to the shadowed thread it is a family with, where the grammar is assembled.

A C_SYNTH clause whose name the pass declares as a thread can only have been read before the declaration — anything after it was classified C_THREAD on the way in — so the test is one comparison and needs no new bookkeeping. The otherwise clauses needed a second loop, because check_clause_order walks the rules and a default is not one.

error: 'seen' is declared a thread further down this pass, so this clause
       is an ordinary attribute and not an update -- the thread reaches
       every node with the value it started at
phx: move `thread seen = ...` above the rules that update it

Pinned now, which it deliberately was not four hours ago: tests/grammars/thread-declared-late.phx. The difference is the direction of the assertion. This morning the only test possible would have asserted 0, the wrong answer, which is what spaced-regex.awk did and what took four hours to notice. A refuses asserts the complaint, and a complaint cannot be satisfied by the defect coming back.

What actually found it, which was not a test

Worth its own paragraph because it is a kind of finder this repository has not recorded before, and it is the cheapest one in it.

The z80 description has two backends — the bytes and a listing — and the listing exists only because z80asm cannot be handed a br. Neither is a test. What found the defect is that one of them says out loud what the other encodes: jp top where jr top was expected, in a five-line program. The bytes were a correct jump. They would have stayed correct forever, three bytes at a time, and the oracle would have agreed with them every run — because the oracle is handed the listing, and the listing was wrong in exactly the same way.

A second backend is not redundancy. It is the same tree said twice, and the two ways of saying it fail differently.

calc/ has been making that argument since its awk backend shipped, about arithmetic. This is the same argument arriving from a direction nobody aimed at, which is the better kind of confirmation.

And the tool grew by 64 lines, so COMPLETED.md, lineage.md and postmortem.md all wanted the new number and counts.sh said so before the commit existed. Third time today.

The walk written out twice, which is the mechanism with the word missing

2.5 has had a customer since the afternoon and the obvious next move was to build the circular attribute. What was cheaper, and turned out to settle more, was to unroll one round by hand: relax is layout written a second time, with layout’s table in hand, deciding forward brs against an estimate.

Two things it proved that an argument would not have.

The direction matters, and it is the reason it terminates. Every address in layout’s table came from a walk in which nothing was shorter than it will finally be, so a distance measured against it can only over-state. A br that fits against an over-estimate still fits when everything settles — shrink only, never grow back. That is not a claim in a comment now: code refuses a br that took the short form and no longer reaches, so if the reasoning is wrong the suite says so rather than a program jumping somewhere else.

And one round is enough more often than expected. chain.z80 went 131 to 129, its minimum, and short-forward.z80 5 to 4. Both left divergent/ and are ordinary oracle fixtures now. The entry needed a witness one round deeper, so it got one.

two-rounds.z80 is two forward brs where the second one shrinking in walk two is what brings the first into range in walk three. 131, then 130, and 129 is reachable. Nobody makes the third walk. Three would do for that file and four for a longer one, which is the point: no fixed number is right, and the stopping rule is until nothing moves.

A workaround that is the mechanism run once by hand is worth more than a mechanism built to a specification nothing has run. The specification is now the diff.

What the prototype settled about the mechanism. The step is one pass, exactly as written. The bottom is what layout already produces. The test is whether any size changed. The guard is a bound with a diagnosis naming what was still moving. What is left is only where the repetition is written — on the pass or on the driver — and the driver reads better, because a driver is already the claim about what runs in what order, and a pass repeated with a termination test the tool owns is not 3.1’s interpreter that loops.

Two things the tool refused on the way, both correctly. Two Program rules in one pass — clauses for one pattern go together in a single rule — and a warning that layout and relax both defined short, with the later one winning. The second was a real hazard rather than pedantry: the first walk’s guess and the second walk’s decision are different values, and only one of them should reach the backends. layout’s is first now.

2026-09-21: the first C construct, and what starting it cost

Sixteen days since the last entry, and one commit in between that this journal did not see: on 2026-09-06 another session wrote ROADMAP 6, a C compiler as an arc with a first step and three predictions, and closed without a changelog line or a paragraph here. This entry is where the arc starts moving.

The step is the smallest one the entry allows. int main(){return 42;}: a grammar of five rules, a tree of five node types, one emit pass, one driver. languages/c/c.phx holds the language and c-arm64.phx imports it, so the split is the one languages/README.md prescribes and a second target would touch nothing above the emit pass. Four programs sit in tests/oracle/ and run.sh compiles each through cc and through Phoenix’s assembly through cc, and compares what they exit with. Four agree. Two checks in the suite, 213 in all.

Four decisions, each small, each written down because the next construct will lean on it.

The lexical half is written, not imported. lib/lexical.phx reads # as a line comment, which C does not, and every rule the module carries becomes a token kind whether the subset wants it or not. C has hex and octal constants, suffixes, character literals and strings with escapes, and each will arrive with the construct that needs it. The module’s own README says to import only what a language uses, and this is the first language where the answer was none of it.

Falling off the end returns 0. A mov x0, #0 sits between the body and the epilogue’s label, on the path that reaches the closing brace without a return. C11 5.1.2.2.3 requires that of main and says nothing about any other function, of which returning 0 is one legal outcome. fall-off.c is the oracle program for it, and cc agrees.

A constant is ldr x0, =n. The assembler’s pseudo-instruction takes any value: a mov when it fits, a literal pool when it does not, and the description does not have to know which. wide.c returns 1000000 and reaches the shell as 64, on both sides. The alternative, mov and movk computed in the emit pass, would have been Phoenix arithmetic modelling the target’s, which docs/semantics.md says to write out when it is needed and not before.

The value is 64 bits wide in a register C calls a 32-bit int. That is chibicc’s first shape too. It is left to the oracle: an operation whose answer differs between the widths is a program on which cc and this description disagree, and that program will be found by the construct that makes it possible, not by guessing now.

One slip the tool caught. Block(body: $1) where the block is "{" { statement } "}": $1 is the opening brace, and the repetition is $2. phx refused the description at read time with .out reads an attribute, and this is text, at the clause that reads $body.out. That is the miscounted factor reference.md already warns about under ..., caught one rule earlier by a different check, and the lesson is the same: a literal counts.

What the oracle can and cannot see yet. An exit status is eight bits, so two programs that return values congruent modulo 256 are the same program to it. That is the whole channel until a function can be called and printf declared, which is several constructs away, and it is enough for constants and arithmetic: a wrong answer is overwhelmingly a wrong low byte too. When it is not enough the entry’s own rule holds: no hand-written expectation, so the answer is a printf and not a number typed into the suite.

The predictions stand untested. Nothing here asked the tool for anything, so the first prediction, reaching struct with no change to it, has had no chance to fail. The third, that the calling convention costs the most, is several constructs away. What can be said is that the frame is already the shape the convention wants: x29 and x30 saved, sp restored from x29, so a call will find a function it can call.

Later the same day: the second construct. + - * / and parentheses, and the stack machine gets its push and pop. Two levels of C’s fifteen, written as lib/expression.phx writes them and not imported from it, because the module’s table is calc’s: and, or, not and <> are in it and C has none of them. Six more programs, ten in all, and cc agrees with every one.

The tool refused the obvious layout, correctly. Four rules, one per operator, under a general Binary rule computing the shared push-and-pop as an attribute: Binary(op: ...) can never match, Binary above it already takes everything it is for. Clauses for one pattern go together, and first match wins, both of which the reference says. What replaced it is one rule and a four-row table from operator to instruction, which is shorter and puts the whole encoding in one place, the way z80.phx keeps a register’s code in the grammar. The refusal was a better design review than the one the file got before it was run.

Sixteen bytes a push. sp on this machine must be sixteen-aligned at every instruction that uses it, so an eight-byte value takes a sixteen-byte slot. chibicc on x86 pushes eight; on arm64 there is no cheaper honest answer than this until the pass keeps a depth and pairs its pushes, and no construct has asked for that.

What the oracle saw. Two groupings the fold decides, 100 - 50 - 25 and 64 / 4 / 2, where the other associativity gives 75 and 32; a quotient (0 - 7) / 2 that must be -3 and not -4, made with subtraction because there is no unary minus yet; and a nest of parentheses whose pushes have to be popped in the right order. Overflow is not in the oracle. Wrapping at 32 bits is undefined in C, so a program that wraps is one on which cc’s answer is an accident rather than a standard, and the 64-bit register stays an open question the oracle cannot yet ask.

And the third: unary minus and comparison. Three more levels, eight in the tree now, sixteen programs, and cc agrees with every one. Negate is one instruction. Compare is the same push and pop as Binary, then cmp and cset with the signed condition, which signed-compare.c holds: -1 < 1 is 1, and the unsigned condition would say 0.

The oracle refused a valid program, and the fix names the refusal. A comparison chains in C: 3 > 2 > 1 is (3 > 2) > 1, which is 0, and C11 6.5.8 says so. This cc makes it an error, and -w does not reach it, because the diagnostic is one clang promotes to an error on its own. The program is the witness that the grammar groups to the left, so the oracle now passes -Wno-error=parentheses and says why in the line above it. That is the first place cc and C differ on a program in this directory, and the principle is that the oracle is C, and cc is how C is asked.

The width question was asked and not yet answered. A comparison is where 64 and 32 bits could first disagree in a defined way, and they do not: the operands are constants in int’s range, and a comparison of two values that fit is the same at either width. What would differ is a value that got there by overflowing, and overflow is undefined. So the register stays 64 bits wide for one more construct, and the entry that might change that is char, whose conversions are defined.

And the fourth: a local int. Declarations with and without an initialiser, assignment as an expression, expression statements, and a locals pass, the first in this language that can say no. Seven more programs agree with cc, twenty-three in all, and three are refused with a position. Nothing in the tool was touched, which is the first prediction still standing.

The pass is the reference’s own example, and it worked first time. A thread of declarations set afresh at each function by a down clause, saved into an ordinary down attribute at each block and restored by the block’s leaving clause, and a slot counter that is reset per function and never restored, because a slot is taken whether or not its name is still in sight. Every line of that is in reference.md under Threads, written for Pascal, and C wanted it unchanged. Function : frame = $slots on leaving reads the count the body left behind, before the next function’s down resets it.

Two nodes for a declaration, not one with an optional field. int x; is Local and int x = e; is LocalInit, and the emit pass says the second is the first followed by an assignment. A node whose field is sometimes there is a node every pass has to ask about, and the notation has no way to ask.

Assignment is an expression. a = b = 21 sets both, and the value stays in x0 after the store, which is all it costs. The left side is a name and nothing else, because there is nothing else it could be yet; *p = 1 and a[i] = 1 arrive with * and [] and will make the left side a place rather than a name, which is the construct that changes this rule.

What the oracle saw. Five locals in one frame, where a wrong slot width makes two of them share; an assignment whose right side pushes and pops between the store’s address being decided and the load, which is where a frame offset computed from sp instead of x29 would have gone wrong; and an expression statement whose value has to be thrown away and not stored anywhere.

And the fifth: if, while, for, and blocks as statements. Eleven more programs, thirty-four in all, and one more refusal. The emit pass invents its first labels, from a thread counted on leaving, so the number a node takes is one nothing before it in the walk took, its own children included; nested loops are the witness. A for carries its three clauses as lists of at most one, because an option is a list that is empty when it did not match, and the emit pass joins each the way it joins a block. The one place that is not enough is the missing condition, which must emit no test rather than a test of nothing, and a two-row table on the list’s size says so.

The dangling else cost nothing. The else alternative is tried first, so an inner if gets the chance to take an else before the outer one does, which is C11 6.8.4.1’s rule. Ordered choice did that; nobody wrote it.

The oracle caught a wrong refusal, and it was a wrong question. The first version refused int x = 1; { int x = 2; } as declared twice. C11 6.2.1 lets an inner block shadow an outer name; what it forbids is two declarations in one scope. The pass had one table, of every name in sight, and was asking it both questions. It has two threads now: env, which a read asks, and scope, which only a declaration asks, and the second is reset at every block. The reference’s example saves and restores one thread; this saves and restores two, with the same three clauses each.

A wrong refusal is found the same way a wrong translation is: by a program the oracle accepts. block-scope.c was written to test shadowing’s offsets, and it found the check instead.

What is not here, and why. &&, ||, ! and % are C, and chibicc has them early, but ROADMAP 6.1’s list does not name them and this arc walks that list. They are an afternoon each and none of them asks the tool for anything; the next construct, a function with parameters and a call, is the one the entry predicts will cost the most, and it is next.

And the sixth: functions with parameters, and calls. The construct the entry predicted would cost the most, and it did: three rounds against the tool before the oracle ran, where every construct before it took one or none. Eight more programs, forty-two in all, and seven more refusals, eleven. Postmortem 16 scores the prediction; this is what happened.

The convention itself was the cheap part. A parameter is a node, so that it can take a slot the way a local does, and parameter k is slot k, so its register is k - 1 and the prologue stores it there. An argument is a node, so that it can know its index from a thread the call resets and its count from an attribute the call hands down; every argument is pushed as it is evaluated, and the registers are loaded from the stack afterwards, so an argument that is itself a call finds the ones before it safe. Sixteen lines, and eight-params.c weights each register so that a swap shows.

C refused the forward call, and the awk answer was the wrong one. The first version gathered every function into a table on the way up and handed it down, which is what languages/awk/ does because awk lets a call come above its function. cc refused forward.c: C99 6.5.2.2 wants a declaration above every call. So the declared functions are a thread, a definition binds itself on the way in so that its body can recurse, and a prototype is in the grammar because that is what C offers instead. The gather pass stayed for the two questions no order can answer, a function defined twice and one declared with two arities, both asked at the top with the expressions written out, because a check cannot read what its own rule computes.

A rule with no action answers the one thing it matched. params with one parameter arrived as the Param itself and with two as a node called params, and the emit pass wanted a list either way. The reference says so under No action at all, and the collapse is the feature that keeps expression from wrapping a number in six nodes; a list rule has to say -> $1 to keep its list. Both list rules now do, with a comment, because this will catch the next person too.

Two refusals from the tool, both mine. Two Function rules in one pass, the same slip as 2026-09-05’s, and a check reading its own rule’s attribute, which z80.phx had already answered by writing the expression out. Neither cost more than the message took to read.

The day, read back. Six constructs, six commits, forty-two programs, and the oracle ran on every one before the records were written. Counting where each round’s cost landed: the tool refused five things at read time, all of them slips the reference already names, and each cost the time it took to read the message. The oracle refused three, and every one of those was C rather than the machine: a chained comparison this cc rejects by default, shadowing across blocks that one table could not tell from redeclaring, and a call above its function that C99 forbids without a prototype. The machine refused nothing. That is the shape postmortem 16 scores in the prediction about the calling convention, and it held for the whole day: the expensive half of a C compiler in this notation is the rules of C, and the oracle is where they are met. Nothing in phoenix/ was touched.

2026-09-22: & and *, and a place that turned out to be a rule

The seventh construct, and the one the roadmap’s list said would change the assignment rule. It did, on one line, and the line it changed was not the interesting part of the day.

A place is a rule, not a pass. C11 6.5.3.2 wants an lvalue after & and 6.5.16 wants one before =, and both are the same question: where can a value be put? The subset’s answer is two alternatives:

place = "*" u:unary -> Deref(value: $u)
      | n:name      -> Variable(name: $n) .

Both assignment and unary & go through it, so &1 and 1 = 2 are refused by the parser, with a position, and nothing downstream had to ask what kind of node it was handed. The shape that was nearly written instead was C’s own, assignment = unary "=" assignment with a pass rejecting the operands that are not lvalues; that needs a pass that can ask what a node is, which is a question about the tree rather than about the program, and answering it would have meant an attribute on every node kind saying whether it was a place. The grammar already distinguishes them for free, because it is the thing that built them.

The rule is narrower than C by one alternative, and the narrowing is written down here because nothing else records it: C lets a place be parenthesised, so (x) = 4 and &(x) are programs cc compiles and this subset refuses. They are refused with a position rather than mis-compiled, which is the line this arc holds, and the day something wants them the rule gains an alternative.

A place is a second attribute, not a second node. x on the right of an = is a load and x on the left is an address, and both are the same Variable in the tree. The emit pass gives that node two attributes: out, the value, and addr, where the value lives. The parent asks for the one it meant. Deref answers both too, and its addr is its child’s out, which is the whole of it: &*p emits exactly what p emits, and the identity C11 6.5.3.2 states falls out of the shape of the tree rather than being written down anywhere. Nothing else in the tree has an addr, and the place rule is what guarantees nothing else is ever asked for one.

One table, not two. A name now means two things: which slot it lives in, and how many stars stood in front of it. The second nearly arrived as a second thread beside env. It would have needed the same down at a function and the same save-and-restore at a block, and two copies of a scope rule are two chances to disagree about what a scope is. So env binds a pair, the name is looked up once and its halves are read off it with at, and the locals pass’s title gained a third clause: which local is which, where does it live, and what is it. That is one question about a name rather than two about the same name. The declarators are counted rather than kept: a type in this subset is int and a number of stars, and a number is what the node already carried.

A third pass, and the one thing it is for. Every value here is eight bytes, so almost nothing comes out wrong from a pointer being taken for an int: the same register holds both and the same ldr fetches both. C says otherwise in one place, 6.5.6: p + 1 is the next int and not the next byte. Nothing in this subset has a size yet, so the types pass counts stars and refuses that by name, along with *x on an int, which 6.5.3.2 forbids and which would have compiled into a load from whatever the int happened to be. What it deliberately does not do is check that what is assigned, or passed, has the type it is given. 6.5.16.1 has a table for that and cc warns from it, but the rule this pass was written to is narrower and load-bearing: refuse what would otherwise mis-compile, and nothing else. A check that only reproduces somebody else’s diagnostic is a second opinion rather than a compiler.

The program that would show the eight-byte int, and why the oracle cannot see it. That rule is only honest if nothing does mis-compile from a type being wrong, so the program that would break it was written: int q = 0; q = &x; int *p = q; return *p;. Phoenix keeps sixty-four bits and it works; cc puts the pointer in thirty-two and it does not. Except that cc will not compile it at all. This one makes -Wint-conversion an error by default and -w does not move it, the way -w did not move the chained comparison on 09-21. The difference is that the chained comparison was C11 as written and worth downgrading by name, and this is a constraint violation that is worth leaving refused. So the only programs on which the width could show are ones the oracle will not build, and the 09-21 note stands unchanged: char is still where the width gets decided.

Sixteen programs, seven refusals, and neither the oracle nor the tool refused anything. Fifty-eight against cc now, eighteen refused. All sixteen agreed on the first run and phx read the description without complaint, which is the first construct of which both are true. Yesterday’s lesson was that the expensive half of a C compiler here is C rather than the machine, and that the oracle is where C is met; today C was met in 6.5.3.2 and 6.5.6 before anything was written, and the oracle confirmed instead of correcting. That is either the lesson working or the construct being small, and the next one will say which: arrays and sizeof are where the size this pass is missing has to arrive, and a size is a change to what a type is here rather than another row in a table.

Prediction one is seven constructs in and still untouched. Nothing today asked the tool for anything: a rule, an attribute, a pair in a table and a pass, all of which the notation already had. Prediction two, that typedef is the first thing to want a change, has still not been asked.

The day read back, and one expectation moved. The 09-21 entry left the eight-byte int to the oracle and said char was where it would be decided. Today found the first program on which the two compilers really do disagree about it, and then found that cc refuses to compile that program, which is why the note survived the morning unchanged. What did not survive was the char part, and what moved it cost one command: asking cc about the next construct rather than this one. sizeof(int) is 4 and sizeof(int *) is 8, both are programs cc compiles without a word, and the description as it stands would answer 8 to the first. So the width is settled at sizeof, two constructs earlier than the last entry expected, by a program the oracle cannot help seeing; ROADMAP 6.1 states the choice and does not take it. The general form of that is the cheaper lesson: the construct after the one being written is a free place to look for what will be expensive, because its oracle programs can be run against cc before a line of it is described.

2026-09-22: the width, decided after the day was closed

The closeout wrote the sizeof question onto the roadmap and left it there. It was taken the same evening, because the argument turned out to be shorter than expected and one leg of it turned out to be false.

sizeof(int) is 4, and the value narrows to thirty-two bits. Both answers were available: the standard asks int to be at least sixteen bits, so a sixty-four bit one is a conforming implementation and this subset would have been a different implementation rather than a broken one. That is a real defence and it is why this needed deciding instead of fixing.

What settled it was not sizeof. Looking for the argument turned up a better thing: the subset already disagreed with cc, in the tree as committed, in a program the second construct made possible on 09-21.

int main() { int a = 2000000000; int b = a + a; return b / 1000000; }

218 against 160. Undefined behaviour on both sides, so neither compiler is wrong, but the arc’s rule is where they differ, Phoenix is wrong until somebody shows otherwise, and the reason this one had never come up is that nobody had written it. postmortem 17 is about that rather than about the width.

The machine made the choice cheap, which is the part worth writing down. On arm64 w0 is not a second register, it is the low half of x0, and every write to a w register zeroes the upper thirty-two bits. So x0 always holds the zero-extended w0, and:

   
the stack machine unchanged. str x0, [sp, #-16]! and ldr x0, [sp], #16 are still lossless, because there is nothing above the low half to lose
the calling convention unchanged. AAPCS64 passes a 32-bit int in w0 to w7, which are x0 to x7. The prologue’s store becomes str w0 and nothing else moves
the frame unchanged. A scalar keeps its eight-byte slot, written with str w0 and read with ldr w0, so sub x0, x29, #8n is still the address of it and the slot counter never hears about this
the operators one letter, chosen by the node’s type: add w0, w0, w1 on ints, add x0, x0, x1 on pointers. sdiv w0 still truncates toward zero and cmp w0 with cset still answers 0 or 1

So what looked like a rewrite of the emit pass is a second column on the table it already has, and the types pass grows from how many stars to how many stars and how wide the base is, which arrays were going to force anyway. One instruction is genuinely new, sxtw where a 32-bit index meets a 64-bit pointer, and it arrives with a[i]. Nothing here asks the tool for anything, so prediction one is not threatened by the decision either.

One argument was made, tested, and dropped. The expectation was that a sixty-four bit int would break printf, which ROADMAP 6.1 reaches through the system libc with a declaration rather than an include, and which is how the oracle gets a channel wider than eight bits at char and string literals. It does not. Apple’s arm64 variadic convention gives every argument an eight-byte stack slot and %d reads the low four of it, little-endian, so passing sixty-four bit values to printf("%d %d %d\n", ...) prints them correctly. The argument was written out before it was run and would have been the strongest one in this entry. It is recorded here because a reason that sounds decisive and is false is worth more to a later reader than the three that held.

2026-09-22: the width, built, and two tests made to fail on purpose

Decided in the evening and built in the same one, because the estimate in the entry above turned out to be the whole of it.

Nine clauses and a table with one row. lookup([[0, "w"]], t, "x"): no stars is an int and lives in a w, a star is a pointer and lives in an x. The types pass did not change at all, because the star count it already computed is the width once there is a table to read it through, and the table is in c-arm64.phx where a register is allowed to be mentioned. What moved: a constant, a load, a dereference, three stores, the arithmetic, the negation and the comparison.

Everything the estimate said would not change, did not. The stack machine still pushes and pops x0, the calling convention still loads x0 to x7, and the frame still gives a scalar eight bytes. All three for the same reason, which is worth stating once as the fact it is: on this machine a write to a w register zeroes the upper half of its x, so x0 is always the zero-extended w0 and there is nothing above the low half to lose. Two places in the pass now say so in a comment rather than leave a reader wondering why they were not touched: the condition test, cmp x0, #0, which is right for an int and a pointer both, and the argument load, which puts the right thirty-two bits in w{i} by loading the whole of x{i}.

A store is str w0 into an eight-byte slot, so the upper half of the slot keeps whatever was there. Nothing reads it, and nothing may: the slot is one int and its address is the low end of it. That stops being free when a declaration takes more than one slot, which is arrays.

The estimate is not scored in the postmortem, on purpose. It was made two hours earlier and three of its instructions had been assembled by hand before it was written down. A prediction checked before it is published is a plan, and scoring a plan as though it had been a guess would make the file’s other sixteen entries worth less.

The three programs catch three different mistakes, which is the part that generalises. overflow.c, the witness recorded the day the decision was made, catches an int that does not wrap: 218 against 160. It is undefined behaviour, so cc is not right and this is not wrong, and what the test actually asserts is that the two implementations agree about a width, which is the thing the arc wants. The two added today are defined, and they catch something overflow.c cannot: a narrowing that is half done.

int main() { int x = 0; int *p = &x; *p = 0 - 5; return (*p < 0) * 10 + (*p + 15); }

ldr w0 zero-extends, so a negative int sits in x0 as 0x00000000FFFFFFFB and a comparison that asked x0 rather than w0 would call it positive. The other does the same through the calling convention, where AAPCS64 leaves the upper half of x0 unspecified for a 32-bit argument and so nothing downstream may read it.

Both were then made to fail, by forcing the comparison back to x and running them: 20 against 10, and 30 against 10. A test written for a change just made is the easiest kind to write so that it cannot fail, and the cost of finding out was two minutes.

Fifty-nine programs became sixty-one, and the suite is unchanged at 231 checks, because the oracle is one check however many programs it runs.

2026-09-22: sizeof, which wanted nothing, and the one number it got wrong

The roadmap’s next item is arrays and sizeof. They came apart on inspection: sizeof is testable on its own and arrays are not testable without it, so sizeof went first with the suite green, which is the rule this arc was set up under.

It wanted nothing. Two alternatives in unary, two node types, two emit clauses, and the types pass did not grow a line. The reason is worth having written down, because it will stop being true: while int is the only base type, a star count already is a size. No stars is four bytes and any star is eight, so lookup([[0, 4]], t, 8) is the whole of sizeof, read from the same table the register letter comes from and for its other column. When char arrives that table gets a second row and a size stops being derivable, and that is the day a type needs a node rather than a number.

The type form goes before the expression form, which ordered choice makes a decision rather than a preference. sizeof(x) would otherwise be taken by the expression form, whose primary is a parenthesised expression, and then sizeof(int) would fail there because int is reserved and no expression begins with it. Tried the other way round, both work.

The operand is not evaluated, C11 6.5.3.4, which is one absence in two clauses: neither mentions $value.out. sizeof(x = 2) does not assign and sizeof bump(&x) does not call, and both are oracle programs because an absence is the kind of thing that is right by accident until somebody checks. The earlier passes still walk the operand, so a name nothing declared and a call with the wrong arity are refused inside a sizeof exactly as outside one, which is also C.

One number is wrong, and it is the first thing in this arc to be written down as wrong rather than refused. C makes sizeof worth a size_t, which is eight bytes here; size_t is a typedef, and ROADMAP 6.1 stops the subset before typedef on purpose, so there is no way to name the type this answer ought to have. So sizeof is worth an int, and sizeof(sizeof(int)) is 8 under cc and 4 here.

Everything else agrees, because the answers themselves, 4 and 8, are cc’s. The gap goes in tests/divergent/, which is the shape languages/awk/tests/divergent/ already established for the lexical seam, and the suite asserts both numbers rather than the difference: closing it fails the check with the old answers in it, which is what will happen when typedef arrives and brings size_t with it. A refusal would have been wrong here. sizeof(sizeof(int)) is a legal program that this compiler compiles and gets a different answer to, and calling that a refusal would hide it behind a message.

Eight oracle programs, sixty-nine in all, and all eight agreed on the first run. Two more refusals, twenty. Three more checks, 234.

2026-09-22: the place rule gains the alternative it was short

The narrowing written down in the first entry of the day, closed. C lets a place be parenthesised and this rule did not, so (x) = 4 and &(x) were ordinary C and a syntax error here.

place = "*" u:unary     -> Deref(value: $u)
      | "(" p:place ")" -> $p
      | n:name          -> Variable(name: $n) .

One alternative, and its action answers the place inside the parentheses rather than building anything, which is what parentheses are: they group and they do not change what a thing is. It goes after the * and before the name for the reason every ordered choice in this file does, which is that the first token decides. Two oracle programs, seventy-one in all.

The refusal it was keeping company with moved, and that is the part worth recording. &1 was refused with expected * or name, and is now refused with expected *, ( or name. The suite asserted the old text, so widening the rule turned a green check red with the message in it, which is exactly what that check is for: the expectation is a statement about what a place is, and the rule changed what a place is. It was updated rather than loosened. A check written as it was refused somehow would have said nothing here.

2026-09-22: an array, and a frame that stopped counting slots

The first half of arrays and sizeof’s remaining half: a declaration, a size, and the decay that makes a name usable. Indexing is the other half and is refused by name until it arrives.

The frame was the work, and it was not the part with brackets in it. A local was a numbered slot and 8 * $slot was in four places; forty bytes do not fit in a slot, so a local now lives at a byte offset and a declaration’s type reaches the frame for the first time. All seventy-one existing programs went through that change and none of them noticed, which is what it was hoped they would do: everything is still given a multiple of eight and started on one, so a scalar sits exactly where it sat.

Packing each thing at its own width was considered and dropped. It was the recommendation when this step was sketched, on the grounds that struct will want real offsets. struct will want member offsets inside one object, which is a different mechanism from handing out frame space, and no C program can observe the distance between two separate objects: that distance is not defined, and the distance between two elements of one array is the element’s width whichever way the frame is handed out. So the change that no test can see was not made.

A node answers a type that decays and a size that does not, which is the whole of C11 6.3.2.1 in this description. A name that is an array is a pointer to its first element in an expression, so Variable.type has one more star than the declaration had; sizeof is one of the places the conversion does not happen, so Variable.size is the array’s forty. Two attributes answering two questions, and neither has to know that sizeof exists. *a is element zero and int *p = a; is the same address, both of them falling out.

An array is its own address, so the load a scalar needs is the thing an array does not do, and $count picks between a ldr and a sub.

The zero-length array is the reason there are two declaration nodes. int a[0] is a count of zero and int a; is no count at all, and one node spells both the same, which would have made count > 0 an unreliable way to ask is this an array. So the bracketed form is LocalArray and the count it binds is refused below one. C11 6.7.6.2 wants a size greater than zero and this cc takes int a[0] as an extension with a sizeof of 0, so without the refusal it is a program both compile and answer differently. The file already had the argument written down for Local and LocalInit: a field that is sometimes there is a field every pass has to ask about.

Two attribute names collided across passes, and the tool said so at read time. elem was defined by locals on LocalArray and by types on Variable, and index by locals on Param and by the emit pass on Arg. Different node kinds in each case, so nothing was actually wrong, and the warning is still right: a driver names the passes that run in order, and two passes writing one attribute name is a thing to have said out loud rather than discovered. Renamed to ebytes and preg.

Eight oracle programs, seventy-nine in all, every one agreeing on the first run; three more refusals, twenty-three. Two of the three are refused where cc compiles: the zero-length array, and indexing, which is the rest of the item.

2026-09-22: the day read back, and nothing cost what it was supposed to

Eight commits, five of them a construct or a decision, and the suite from 224 checks to 237. What is worth carrying out of the day is none of the constructs.

Nothing cost what it was supposed to. & and * were the construct ROADMAP 6.1 said would change the assignment rule, and they changed one line; the thinking went into deciding that a place is a rule rather than a pass. Narrowing int to thirty-two bits sounded like a rewrite of the emit pass and was one letter per instruction, because w0 is the low half of x0. sizeof wanted nothing at all. Arrays cost the frame, which is the part with no brackets in it. In every case the expensive half was deciding what a thing is: what a place is, how wide an int is, what counts as an array. Emitting it was cheap once that was settled.

That refines yesterday’s reading rather than replacing it. Yesterday: the expensive half of a C compiler here is C, not the machine. Today: the expensive half of C is not its constructs either. It is the few questions each construct forces about what things are, and those are the questions the standard has already answered. 6.5.3.2 says what a place is. 6.3.2.1 says what an array is in an expression. 6.7.6.2 says an array has at least one element. Reading them before writing anything is why thirty-seven oracle programs were added today and every one agreed with cc on its first run, except the one written to be red.

Everything written down in the morning was corrected by evidence in the afternoon, and the writing is what made correcting it cheap. Five times:

what was written what corrected it
char is where the eight-byte int gets decided sizeof, two constructs earlier, and by a program that had been possible for a day
the types pass needs no compatibility table, because nothing mis-compiles without one true, and only because the program that would show it is one cc refuses. The reason was found by writing that program
a sixty-four bit int would break printf, which is how the oracle gets a channel wider than eight bits it would not. Apple’s arm64 varargs give every argument eight bytes and %d reads the low four
the place rule is C11 6.5.3.2 written in the grammar it was 6.5.3.2 minus one alternative, and (x) = 4 is ordinary C
pack the frame at each thing’s real width, because struct will want it struct wants member offsets inside one object, a different mechanism, and no C program can observe frame layout

None of the five was expensive to correct, and all five were cheap for one reason: the claim had been written somewhere a program or a check could be pointed at. The cheapest of all, the printf argument, was the one that had been written out in full and not yet run, and it took a single file to kill. The most expensive was the one nobody had written a program for at all, which is postmortem 17 and cost a day of a wrong int.

What the day did not do. It did not ask the tool for anything, which leaves prediction one untouched at nine constructs of eleven. The tool spoke twice, both at read time, both about an attribute name defined by two passes in one driver. That is a warning about this description, not a need for a different tool.

2026-09-22: indexing, which is a * of a +

The rest of arrays and sizeof, after the day had been read back. a[i], a + 1, 2[a], p[-1], m[1][1] = 4 and &a[2] compile, and ROADMAP 6.1’s item is closed.

A subscript builds no node of its own. C11 6.5.2.1 says E1[E2] is identical to (*((E1)+(E2))), and the grammar action builds exactly that:

subscript = p:primary "[" e:expression "]"
              -> Deref(value: Binary(op: "+", left: $p, right: $e)) .

So every pass already knew what a subscript was, 2[a] is C because + commutes, and x[0] on an int is refused as the * on an int it is. What had made indexing wait was never the brackets. It was the +, which has to count in elements, and that was a clause in types and one instruction in the emit pass. The day read back said the expensive half is deciding what a thing is. Here the standard had decided it in one sentence, and the sentence went into the grammar as written.

The place rule’s new alternative goes first, and ordered choice is why. (a)[1] = 2 would otherwise match (a) as a parenthesised place, fail at [ where the = was wanted, and never be tried again, because a PEG does not revisit a choice that succeeded. Tried first, a place with no subscript fails at the token after its primary and the choice moves on. The fold is written twice, once in indexed for the place and once reached through postfix for an expression, because $$ needs its base in the list it folds.

A pointer and an int is one instruction, add x0, x{p}, w{i}, sxtw #s: sign-extend the int’s thirty-two bits, shift them by the element’s width, add. The types pass says which side is the pointer and what it points at; the emit pass reads both. Each of the three steps was then left out on purpose, one at a time, to see that a program notices:

left out what went red
the sign extension, uxtw in its place index-negative, where p[-2] lands four gigabytes away
the width, always a shift of 2 index-of-pointers, index-twice, pointer-to-pointer-arith
which side the pointer is on index-commutes, pointer-plus-int

That is yesterday’s habit applied before the code rather than after it: the fourteen programs were written and run red first, and all fourteen agreed with cc on the first green run.

&1 changed its message a second time, and the new one is true. It was expected *, ( or name and is now expected [, one token later. That looked like a worse message until the program was written: &1[a] is &(1[a]), which is C, and cc compiles it. A subscript is the one way a number becomes a place, so the refusal of &1 is only certain at the ;. The expectation was updated and address-of-a-subscripted-number.c went into the oracle as the program that makes the message right.

The two refusals indexing replaced did not move to the oracle. Both were undefined behaviour once they compiled: pointer-arithmetic.c read one int past a scalar, and index-an-array.c read an element nobody had written. A program whose answer the standard does not define cannot be an oracle program, so they were deleted. Four refusals replace them. Two are C’s own and cc refuses them too: two pointers added, and a pointer taken from a number. One is the subscript on an int. The fourth is the difference of two pointers, which is C and refused here as not built. It is worth a ptrdiff_t, and ptrdiff_t is a typedef exactly as size_t is, so building it means either taking the sizeof decision a second time or making a different one. That decision belongs to the person who made the first one.

Fifteen oracle programs, ninety-four in all; four refusals in and two out, twenty-five. Nothing in phoenix/ was touched, so prediction one stands at nine constructs of eleven with the ninth now whole, and the tool said nothing at read time.

2026-09-22: the difference of two pointers is an int

The one piece of C11 6.5.6 the indexing entry left refused, decided the way sizeof was decided that morning. The standard makes the difference a ptrdiff_t, which is a typedef, and the subset stops before typedef, so it is an int here: the count of elements between the two pointers. That is also what it was in K&R’s first edition, before ANSI C gave it a name, which is the reason given for taking the same answer twice rather than a new one. size_t and ptrdiff_t are to be revisited together when typedef arrives, and tests/divergent/ now pins one program for each, both 8 under cc and 4 here. The suite’s single-purpose check for the first became a function called twice.

The type is a difference of star counts, which is 0 for two pointers of one type and the pointer’s own for a pointer less an int. Two pointers of different types are refused, as 6.5.6 requires and cc does, and that refusal is also what keeps the subtraction from producing a type no value has. The emit pass subtracts the addresses, shifts right by the element’s width, and writes w0 to itself so that x0 holds the zero-extended int the file’s header promises.

Leaving each step out on purpose found two instructions with no witness, and neither can have one yet. The width was caught, by pointer-difference-of-pointers. mov w0, w0 was not, which was expected: it restores an upper half nothing reads. asr was not caught either, and that was not expected. A difference can be negative, so the shift has to keep the sign, and pointer-difference-negative was written to check that. It passes with lsr, because the two shifts differ only in the top two or three of sixty-four bits and an int keeps the bottom thirty-two. No program can tell them apart while the difference is an int. Both are kept, for what they will be when the answer is the whole of x0: the asr becomes the one that matters and the mov the one to delete, and the negative program becomes the witness it was written to be. That is recorded at the instruction, because a test that passes for the wrong reason looks exactly like one that passes for the right one.

Five oracle programs, ninety-nine in all, every one agreeing on the first run; the pointer-difference refusal became an oracle program, and one refusal for unlike pointers replaced it, twenty-five. Two divergences. 240 checks.

2026-09-22: char, which needed a number and not a node

The first half of char and string literals: char as a type. Character constants and string literals are the second half, and are separate because they are a question about the lexer and about static data, where this is a question about widths.

A type is two numbers now. The grammar had said, since the first pointer, that char would be the day a type needs a node of its own. It needed one more number: the width of what is at the bottom of the stars, 4 for an int and 1 for a char. The declaration keeps the keyword, locals turns it into basew and binds it as a fourth value beside offset, stars and count, and types carries it as width through the expressions that can have a pointer’s or a place’s type. Everything else is 4 by an otherwise, and that default is C11 6.3.1.1’s integer promotion rather than a convenience: a char in an expression is an int before any operator sees it, so only a thing that is still a place, or points at one, can be narrower. Every size question in both files became lookup([[0, width]], stars, 8). struct is where two numbers stop being enough, because a member has a type and a number cannot hold one, so the prediction was one construct early rather than wrong.

This machine’s char is signed. AAPCS64 makes a plain char unsigned; Apple’s arm64 ABI departs from it and makes it signed, and cc here agrees with Apple. So a load is ldrsb, which sign-extends into w0, and that one instruction is also the whole of the promotion: what arrives is the int. A store is strb. char-is-signed.c exits 101 under cc and is the program that knows which ABI this is.

An assignment is worth what its left side holds afterwards, C11 6.5.16, so (c = 300) is 44 when c is a char, and the emit pass narrows w0 with a sxtb after a one-byte store. The first program written for that passed without the sxtb, and was caught only because each step was left out on purpose again. It returned the value as its exit status, and the shell keeps eight bits of one, so 300 reached the comparison as 44 on both routes: the shell did the narrowing the program was checking the compiler for. It now divides by ten first. The breakage run itself had a second fault of the same kind: the sed that was to remove the sxtb matched nothing, and the green it reported was the unbroken file. Both were found by asking why a line that must matter did not.

One step has no witness and cannot have one: a char array sized as though its elements were ints only makes the frame larger, which no program can observe, for the reason the frame entry gave about packing. Too small would be caught, because two arrays would overlap.

Eleven oracle programs, 110 in all, every one agreeing on its first green run; one refusal, twenty-six, for a char * less an int *, which cc refuses too. Nothing in phoenix/ was touched.

2026-09-22: character constants, and a code measured rather than looked up

'a' is an int, C11 6.4.4.4, so the node answers the defaults the types pass already had: no stars, four bytes. sizeof 'a' is 4 here as in C, and would be 1 in C++. Everything this step added is the lexer’s and one clause.

The lexer says exactly what a constant may be, so no pass has to ask. A constant is one printable character or one of six escapes, and printable is three ranges, " " .. "&" | "(" .. "[" | "]" .. "~": ASCII from the space to the tilde, less the quote at 39 and the backslash at 92, which are what an escape is for. A hex escape, two characters between the quotes and a tab typed between them are all C, cc compiles all three, and all three are refused at the quote as outside the subset.

The notation has no function from a character to its code, and the step did not need one. The ninety-five printable characters are written out in order in the clause, and a character’s code is how much of that text comes before it, plus 32: size(at(split(table, c), 1)) + 32. An escape is looked up first, because its text is two characters and is not in the table. That is the same move the reference makes for Pascal’s doubled quote, a question about characters answered with split and size because they are what the library has, and it holds only because the lexer guarantees the character is in the table. A character that was not would measure as the whole table and come out as 127, silently; the three ranges are what make that impossible rather than unlikely.

Whether this wants a library function is not settled by one use. ord would say it in a word. The rule the roadmap keeps is that a second use makes a mechanism, and string literals, next, will not be a second use unless their bytes are computed here rather than handed to the assembler.

Every step left out on purpose was caught, with exact replacements this time that assert they applied: two characters swapped in the table, the table starting at 33, the entry for \n gone, and \" given the backslash’s code. The swap is caught only by char-constant-every-one.c, which is generated and checks that each of the ninety-five is one more than the one before it, with the space anchored at 32, so that cc judges every character and nothing in it is an expected value somebody typed.

One refusal was not what it said. char-constant-hex-escape.c was written with echo, and zsh’s echo turned \x41 into A on the way to the file, so the first run of it compiled a perfectly good 'A'. Read back with cat -v and rewritten with a quoted heredoc.

Five oracle programs, 115 in all; three refusals, twenty-nine. Nothing in phoenix/ was touched.

2026-09-22: string literals, and the bytes the notation could not write

The item closes. "hello, world" is an array of char in static storage, C11 6.4.5, one longer than its characters for the NUL, and it decays like any array: the node’s type is a char * and its size is the whole array. The emit pass gives each literal a label in __TEXT,__cstring, reaches it with adrp and add, and collects the literals in a thread that Program writes out once, the way Function reads its frame off bytes. Programs print now, through a puts or putchar the program declares, and the oracle compares their output as well as their exit status. That is the channel the char entry wanted: an exit status keeps eight bits and had already hidden one fault today.

The recommendation made before this step was wrong, and the notation is why. The plan was to compute each literal’s bytes in the description, so that the assembler would not be trusted with C’s escapes. It cannot be done: the notation has no iteration over data, which ROADMAP 5 already lists as a wart, and each fills a template rather than applying anything to an element, so nothing can visit a string’s characters one at a time. The one route left would have been a chain of ninety-five substitutions through control-character markers and ninety-five more back. So the assembler writes the bytes and the description writes only the length, and the question of an ord for character constants is still at one use, because this was not a second.

The assembler, asked, disagreed with C. Before relying on it, one line was assembled with all six escapes in it, and as refused \' as an unrecognised escape, which C allows. The emit pass rewrites it as a plain quote. That has to tell \' apart from the end of \\', an escaped backslash and a plain quote, so the escaped backslashes are set aside first, as tabs, which the lexer guarantees are not in the text, and put back after. The types pass counts the length the same way: with \\ set aside, every backslash left begins an escape, and each escape is one byte less than its spelling. string-length-agrees.c holds that count against the bytes the assembler wrote, walked one at a time, so the two routes to the length are compared on every run.

\0 is refused in a string. C reads up to three octal digits after a backslash, so "\01" is one byte, and a lexer that knew only \0 would count it as two. A character constant keeps '\0', where the closing quote ends the escape. Two literals side by side, which C joins, are a syntax error at the second. cc compiles both programs.

Every step left out on purpose was caught: no rewrite of \', the rewrite without setting \\ aside, the length without setting it aside, and a length that ignored escapes. The first two turn into assembler errors, which the oracle reports as such.

One warning was thrown away, by me. The emit attribute that holds the rewritten text was first called text, which is the node’s field, and a field is read before an attribute. phx warned, with the line and the reason, and the warning went to standard error, where a | tail -6 on the output cut it off; the .asciz came out with the raw token in it, quotes and all. The tool did what the reference says. Warnings are now read with standard error captured on its own.

Eight oracle programs, 123 in all, every one agreeing on its first green run; two refusals, thirty-one. Nothing in phoenix/ was touched, and ROADMAP 6.1 has one construct left before typedef: struct.

2026-09-22: the afternoon read back, and the day closed a third time

Six commits after the day was first read back at midday, and the suite from 237 checks to 246. ROADMAP 6.1 has one construct left before typedef: indexing closed arrays and sizeof, char, character constants and string literals closed the next item, and struct is what remains. Prediction one stands at ten constructs of eleven with nothing in phoenix/ touched.

Two decisions were taken for the same reason, and are to be revisited together. The difference of two pointers is an int, as sizeof is, because C names both types with a typedef and the subset stops before typedef; K&R’s first edition gave the difference an int before ANSI named ptrdiff_t. Each has a program in tests/divergent/ with both answers pinned, so the day typedef arrives, the two go red together and say which numbers they held.

The notation met a thing it cannot say, and a borrowed tool said it. A string literal’s bytes cannot be computed without walking its text, which nothing here can do, so the assembler writes them. Asked before being relied on, the assembler disagreed with C about \', and the description rewrites that one escape. It is the first construct whose meaning the description does not state in full, which postmortem 16 now records as the nearest prediction one has come to failing.

The code was right more often than the checks on it. The habit from the morning, leaving each step out on purpose, was applied to every construct in the afternoon, and its one failure was in the apparatus: a sed that matched nothing, and a witness whose answer the shell truncated before comparing. Two more faults of the same kind turned up beside it, an echo that rewrote an escape on the way to a file, and a tail that cut a phx warning off the output. Postmortem 18 scores the belief behind the habit and what a silent breakage turns out to mean.

2026-09-22: struct, and a type that stayed numbers

The last construct of step one. struct point { int x; int y; }; at file scope, s.x and p->x as places and as values, arrays of structs, structs in structs, member arrays, sizeof(struct point), and struct node *next inside struct node, so a list can be built and walked. Twenty oracle programs, every one agreeing with cc on its first run, and eighteen refusals. Nothing in phoenix/ was touched, which closes prediction one; postmortem 19 scores it.

The node went to the base, not to the type. The standup and the grammar both said a type would become a node here. It did not need to. An expression’s type stayed numbers, with a third one added: the tag of the struct at the bottom of the stars, “” for none. A member’s type is found by looking the tag up, so the expression never has to carry a member’s type, only which struct it is in. What did become a node is the base of a declaration. int and char had been a keyword that five declarations each turned into a width with their own copy of a table; a struct’s width is defined by the program, so the question moved to a Base node that asks one table of layouts. That table starts with int and char in it, as two structs with no members, so the keyword and the tag are the same question.

Member offsets are a thread, like frame offsets. Three threads reset by the definition: the members so far, the next free offset, and the widest alignment seen. Each member is placed at ((off + a - 1) div a) * a and the struct is rounded to its widest alignment, which is C11 6.7.2.1 with the ABI supplying the numbers. The frame keeps its multiple-of-eight rule, because the distance between two separate locals is invisible, and the members cannot use it, because the distance between two members of one struct is visible through sizeof and through &.

A struct points at itself through an inherited attribute, not a placeholder. The first plan bound the tag on the way in, the way a function binds itself so its body can call it. But then the check for a second definition, which runs on the way out, would always see the placeholder. So StructDef hands down defining, and Base does not refuse an undefined tag when it is the one being defined. It answers a width of 0, which no complete type has, and a member of that type with no stars is refused as incomplete. A pointer needs no width, so struct node *next goes through.

A later pass cannot read a thread. The types pass works out which struct an expression is, and needs the layouts to find a member, but the layouts are a locals thread. locals keeps the table at each Member as an ordinary attribute. The first attempt named that attribute layouts, after the thread, and it was still invisible: a clause named after a thread updates the thread. Renamed known, it worked. The tool named the line both times, and printed it once per member in the program, which is a wart of its own: see the closeout below.

-> builds (*p).x, as a subscript builds *((a)+(i)), so there is one Member node and it is always asked of a struct. A -> on a struct is then refused by the *, in its own words, which now names the struct: ’*’ wants a pointer, and this is a struct. A member is a node, where a subscript is not, because it carries a name that only a layout can turn into an offset. The grammar’s indexed and subscript became suffixed and suffix, one repetition for all three suffixes, since C mixes them freely.

A struct is never copied whole, and that is refused rather than built. C assigns, initialises and passes a struct by copying its bytes. The stack machine holds a struct as its address, so without the refusal u = s would store s’s address into u’s first eight bytes. A copy is a loop, or a call to memcpy, and it is its own step, as it is in chibicc. So Assign, LocalInit, Param and Arg refuse a struct. The Arg one guards programs cc refuses anyway, since no parameter here can be a struct; it is kept because the alternative is a silent address where a value was meant, and this pass exists to refuse exactly that.

A pointer step became a multiply. It had been add x0, x{p}, w{i}, sxtw #s, a shift by 0, 2 or 3, because every element was 1, 4 or 8 bytes. A struct of three ints is twelve. smaddl x0, w{i}, w2, x{p} sign-extends, multiplies by the width in w2 and adds, and smsubl subtracts, and one template for every width was kept over a shift for three widths and a multiply for the rest. The pointer difference went from asr to sdiv for the same reason. The first comment written for it was wrong: it said udiv for sdiv was unobservable, carrying over what had been true of lsr for asr. Worked through before committing, udiv of -24 by 12 leaves 0x55555553 in the low half where sdiv leaves -2, so the division has a witness now, struct-pointer-difference-negative.c, and the breakage below confirmed it.

The oracle programs print, and the first print helper printed a space after every digit, so 16 came out as 1 6 on both routes. It agreed with cc, which is exactly why it was not caught by the oracle. It was caught by reading the output before trusting a clean sweep. Rewritten as digits and print.

Fifteen breakages, each asserted to have applied: offsets ignored, alignment ignored, the struct not rounded, the alignment never growing, a pointer member aligned as its base, a struct local given eight bytes, the step and the difference unsigned, the step at a pointer’s width, a member’s width, stars and array decay, sizeof(struct) as an int. Thirteen went red. The two green were the load a struct value leaves out, at a name and at a *. Following postmortem 18, the observer was checked first: with both breakages in, the assembly for all 143 programs was identical, so no program asks. Only a discarded s; or *p; can, and the comment at the instruction says so.

The types pass header’s “every value here is eight bytes”, false since the morning, was corrected with this step. The &1 refusal’s message moved a third time, now expected [, . or ->, because a member is the second way a number can start a place; &1.x is refused by the types pass.

Twenty oracle programs, 143 in all; eighteen refusals, 49. 246 checks to

  1. Step one of ROADMAP 6 is complete, with the copy of a struct the thing a C program is most likely to want next, and typedef the construct the arc was built to reach.

2026-09-22: the day closed a fourth time, and one wart written down

One commit since the third closeout, 8ece027, which was struct and wrote its own records: the journal entry above, postmortem 19, the changelog, and step one of ROADMAP 6 marked done. The suite is 264 checks, all passing, from 246 at the third closeout.

One thing the session saw was in no record, and is now. While writing struct, a check that named a thread from another pass printed the same error six times over a program with six members. Reproduced on a copy of the description before being believed: a bare $name that resolves to nothing reads cleanly, and is reported once per node that reaches it when a program runs, while $child.nosuch is refused once, when the description is read. It is in ROADMAP 5 as a known wart, with what was measured and what was not.

2026-09-23: typedef, and the first thing a parse remembers

Step two of ROADMAP 6, and the first change to phoenix/ since the C arc began. The entry asked for the failure to be written down before the mechanism, so that came first.

The failure, measured on a copy. The most a description could do alone was let a declaration’s base be any name and trust ordered choice with the rest. A copy of c.phx did that, and all 143 programs were run through both it and the committed description. One moved: sizeof-parens.c, where sizeof(x) names a variable, was read as the type called x and refused. x * y; became a declaration of y. Both refusals, neither a wrong answer, and neither something a pass could repair, because by the time a pass runs the tree has already taken one shape. So the predicate was wanted, and at typedef, which is prediction two.

What the prediction called a predicate turned out to be a table. Asking is the small part. Something has to write the names in as declarations are read, end what a block wrote, and take back what an alternative wrote before it failed. Rats! calls this stateful parsing and parses C with it, which is the precedent, now in lineage.md.

Why a directive and not syntax inside productions. A binding written into a production would be a second place that says what a declaration is, and the actions already say it: a Typedef node is a typedef. So %names names node types and fields, declare Typedef.name, hide Local.name, and adds nothing to any rule. scope and guard name rules, the way %fragment and %skip do.

Why one stack and truncation. Every %names shares one stack of (table, name, declared-or-hidden) entries, the newest in force. parse_match notes the stack’s height on the way in and cuts it back if the match fails, which is the whole of the undo, in the one function every match passes through. A scope rule cuts it back on success as well. Rats! has transactions; a PEG that already backtracks by position only needs the position of the stack.

Why a guard is one token. What is asked is how a token was spelled. A guard over a rule that answered a node would need a way to say which field is the name, and no language here has wanted that, so typedef-name = name . is the shape and anything else is refused.

Why the four words are not literals in phoenix.phx. A literal reserves its word in every description the self-description reads, and scope is a thread in c.phx. They are names there, the same wart word already records.

The mistake, found by writing this entry. The first version bound a name when the node naming it was finished. Stating that in C’s terms, C11 6.2.1p7 starts a scope at the end of the declarator, and a LocalInit finishes after its initialiser. typedef char T; int main() { int T = sizeof(T); return T; } answers 4 under cc and compiled here to 1. No oracle program declared a name and used it before its own ;, so all 156 agreed. The binding moved to the factor: the checks find, in each action building a bound node, the factor its field is filled from, and the matcher binds right after matching it. A field the action computes, name: text($n), has no such moment and is refused. The program is now refused by the locals pass, which binds every local on the way out and has always refused int x = sizeof(x); for the same reason. Both are in tests/refused/ now, with what the difference is.

In the description. function became a rule so that it could be a scope: a parameter hides a typedef from the ( to the end of the body, or to the ; of a prototype. A typedef name builds Named beside Base, because it names a base and some stars and a Base has none, and every declaration reads ptrs = $stars + $base.ptrs where it read $stars. The checks could not read ptrs, since a check runs before its rule’s attributes, so they write the sum out, which is what the tool told them to do. C11 6.7p3 allows a typedef declared twice to the same type, and lists compare structurally, so only a second typedef for a different type is refused.

The observer, twice. The first run of the matcher’s breakages said restoring the source had not restored the behaviour. make had seen a binary built in the same second as the restored parse.c and not rebuilt. make -B throughout, after that. Then two oracle programs named for the hiding they tested stayed green with the hiding removed, because return T * 2 begins with return and nothing in them depended on how T was read. Both now ask sizeof(T), where the variable and the type differ, and both go red.

Eleven breakages, each asserted applied, all red: four in the matcher (no undo, no scope, no guard, binding at the node) and seven in the description (no hide, no function scope, no block scope, a typedef’s stars dropped, a typedef’s base’s stars dropped, sizeof ignoring a typedef’s stars, no twice check). The assembly for all 145 programs that were here before is byte for byte what the committed tool produced.

size_t and ptrdiff_t, revisited. Both are longs on this machine, and long is step four, so both stay ints and the divergent pins stay.

Thirteen oracle programs, 156 in all; nine refusals, 58. 264 checks to 281, all passing.

2026-09-23: a struct copied whole, and nine programs that compiled into an address

The standup’s likeliest next construct: =, an initialiser and an argument, all three refused since struct on the grounds that a copy is a loop and not a store. Nothing in phoenix/ changed for it.

The failure first, and it was a different one. Before writing the copy, every place a struct value can go was tried against the committed description: arithmetic, a comparison, -, a condition, a return, and a struct put into an int or a pointer. cc refuses all of them. Phoenix compiled all of them, into the struct’s address used as a number, because a struct is worth its address in the emit pass and nothing asked. The types pass’s header says it refuses what it would otherwise mis-compile, and that was not true of structs from the day they arrived. It went unseen because nothing moved a struct value anywhere; a copy is the first thing that does, so the holes were closed first. record is an otherwise in the types pass, a struct with no stars, and each place C wants a number refuses one.

The copy is a byte loop, counting down from the size, in x9 and w10. Not memcpy, which would need declaring in every program and make every copy a call with registers to save around it. Counting down makes s = s harmless. The registers are AAPCS64’s temporaries and carry no argument, so a copy can run in a prologue between two parameters. The loop is written once, as a down attribute on Program, because a pass has no constants. A node takes a label number only when it copies, so no scalar program’s labels moved, and the assembly of all 158 programs that were here was compared and did not move.

The argument is AAPCS64, not a convention of this subset’s own. A struct of up to eight bytes goes in one register, up to sixteen in two, and anything larger is copied by the caller and its address passed. The tempting shortcut was the callee copying from the caller’s own struct, which is invisible when both ends are compiled here. It is not what the machine’s convention says, and a cc-compiled callee writes straight into the memory it is handed. So registers are counted rather than parameters: argreg beside argn at the call, and pindex stepping by one or two at the parameter, which moves the ninth-parameter refusal to a ninth register. Every argument still pushes sixteen bytes, so the stack offsets did not change; a small struct is packed into its slot, zeroed first, and read out with ldr or ldp. A large one’s copy lives below the caller’s locals, where the emit pass’s temps thread says, and Function grows its frame by that much.

Why the signature went to the root. A call has to check that a struct goes to a parameter of the same struct, since a copy of the wrong size has no right answer. The table is bound on a function’s way out, once its parameters are worked out, which a down clause cannot wait for, so the types pass is handed the whole file’s table from Program. That is safe because a call above its callee’s declaration is already refused. A prototype and a definition that disagree about a struct parameter are refused where the second is read.

The oracle cannot see the convention. It compiles each program whole, one way or the other, so it asks whether Phoenix agrees with itself about how a struct is passed, and it always will. tests/abi/ is a caller and a callee in two files, linked cc with Phoenix and Phoenix with cc, against cc with cc. Of fifteen breakages, twelve went red on the oracle. Of the three green, one was the caller’s copy, and it goes red on tests/abi/: without it a cc callee zeroed the caller’s struct. The other two have no witness and cannot have one, and say so at the instruction: an assignment worth its source rather than its destination, which have the same bytes, and the zeroing of a small struct’s slot, whose padding no callee reads. A callee storing a register pair as one register was red on both.

A member of an assignment. Writing a program for which struct (a = b) is worth, (x = y).a = 5 was the first try, and cc refused it: C11 6.5.16 makes an assignment a value, so it has no members to put anything in. Phoenix compiled it into a write to x. The place rule has taken a member of a parenthesised expression all along, and nothing could reach it until an expression could be a struct. lvalue is another otherwise, true of a name and a * and inherited by a member, and an assignment to one or & of one is refused when it is false.

The bare-name wart, again. The Call check read $sig before anything defined it, and the description read cleanly. ROADMAP 5 has the second sighting.

Nineteen oracle programs, 175 in all. Eighteen new refusals, three old ones moved to the oracle, 73 in all. 281 checks to 297, all passing.

2026-09-23: a struct returned, and a register the callee has to keep

The standup’s nearest gap: a function returned an int and nothing else. Nothing in phoenix/ changed for it.

The return type became a base. The header said int as a keyword, and the one node that already answers what a struct is, its tag, width and alignment, is the base a declaration starts with. Taking that node whole lets char f() and a typedef of a pointer parse as well, and both are refused by name in the locals pass rather than left as syntax errors: what comes back in x0 is an int or a struct’s bytes, and nothing here narrows a char or keeps a pointer’s upper half on the way out. A typedef of int as a return type came free, which the morning’s changelog had listed as not yet.

The convention is AAPCS64 read backwards from the arguments. Up to sixteen bytes come back packed in x0 and x1; anything larger is written by the callee into memory the caller provides, whose address it passes in x8. The caller gives the struct a place in its frame either way, one of the temps a large argument’s copy already uses, so a call is worth the struct’s address, as a name, a * and an assignment of a struct already are. That is what makes f().x work with no change to Member: its addr is asked of the call, and a call’s addr is its out. The callee packs a small struct the way an argument is packed, copied into sixteen zeroed bytes and read out with one ldp, because an ldp straight from a three-byte struct reads past its end.

The first framing of the table was wrong. What each function returns was to be bound on the way in, with a down, as funcs is, so that a recursive function’s own calls would know it. phx refused it at read time: a down clause runs before the node’s children, and the return type is a child, so its tag does not exist yet. That is exactly the reason sigs is bound on the way out and handed to the types pass whole at the root, and the same answer works here for the same reason: a call above its callee’s declaration is refused anyway. The two tables now travel the same way.

x8 has to be kept. It is a scratch register to the callee, so a function returning a large struct that makes a call of its own before returning may have lost it. The prologue stores it in sixteen bytes held back at the start of the temps. The program written to see it, struct-return-large.c, did not: its inner call returned an int, and nothing touches x8 for those. Of five breakages made on purpose, x8 not kept was caught by one oracle program, struct-return-recursive.c, whose large struct is returned after a recursive call that returned one too. tests/abi/ was then extended so that a callee returning a large struct calls another first, and the same breakage printed garbage there. The other four each went red at once: the caller storing only x0, the callee loading only x0, a call taking no temporary, and x8 not set. The zeroing before the ldp is the fifth and has no witness, as the argument’s has none.

A wrong claim, caught before it was written down. The locals pass’s comment first said cc refuses a main that returns a struct. It compiles it, with a warning, and the program exits with whatever the struct left in w0. It is still refused here, now for that reason: there is no exit status for the oracle to compare.

The assembly of all 177 programs that were here before was compared with the committed description’s and did not move by a byte. Ten oracle programs, 185 in all; nine refusals, 82 in all. 297 checks to 306, all passing.

2026-09-23: the README’s other three numbers, measured and then held

The standup had carried it for two closeouts: the Building paragraph in the README said “291 of the 297 … the other five”, which is six, and that a run without fpc, Solveig and z80asm is “283 passed, 0 failed and 5 skipped”, which is 288 and not 297 less five. Each edit since had added to every number, which kept the paragraph growing and never made it true.

It was measured, not reasoned about. All three are found by the suite at run time, fpc and z80asm on PATH and Solveig through $SOLVEIG, so one run with the two directories left out of PATH and SOLVEIG pointed at nothing is the machine the paragraph describes, with nothing uninstalled. It printed 301 passed, 0 failed, 5 skipped. The five and the three skipped lines were right all along; both counts of what passes were wrong, by different amounts, and the paragraph’s own arithmetic, 301 and five making 306, is the thing that would have said so.

Then the suite was made to say so. tests/counts.sh exists because a number in the records rots by standing still, and the full-run count was already judged at the foot of tests/run.sh. These three were not, which is the whole of why they drifted. A full run now holds them against each other: the “of the” figure is the total, and the vendored count plus the skipped ones makes it. A run with exactly the three oracles missing, and nothing else skipped, holds its own passed count against the README’s, so the measurement above is one a machine without those tools repeats every time. Both were run against the stale paragraph first and both went red, the full run on the arithmetic and the bare run on 292 against 301; the first version of the extraction did not see a number at the start of a line, and the red message printed two blanks, which is how that was found.

2026-09-23: a bare name that nothing could answer

ROADMAP 5’s newest wart, with two sightings in two days: a bare $name in a pass was looked up only as the pass ran, so a name that meant nothing read cleanly and was then reported once per node that reached it, or never. The roadmap left one question open, whether what the reader already knows is enough to judge a bare name, and the answer turned out to depend on which question is asked.

The driver check’s own comment said it could not be done, and it was right about the question it was asking. check_drivers reads $x.attr and not $name, because a bare name may be a field, a binding, a thread or something handed down, and which of those answers depends on the shapes that reach the clause. That is undecidable at read time without knowing the programs. But the two sightings were not about which: they were names that no kind could ever answer, and whether any of six could is a question about the whole description, not about a program. So the check is written to be as generous as the lookup it guards. A name passes if it is pos, bound by the rule’s pattern, a field of any node type anywhere, anything this pass defines, anything any pass leaves on a node, or an embed. Only a thread and an inherited attribute are held to their own pass, because they live in one walk and nowhere else, which is the 09-22 sighting exactly. What it refuses is a mistake in every program and every driver order; a name that is wrong only for some shapes is still the runtime’s to report.

A module is not asked, for the reason check_spellable gives: lib/expression.phx reads $text of a Number that only the language importing it builds. The first run of the check over every description in the repository found exactly that and nothing else, which is also the evidence that the generosity is not too tight.

Both sightings were reproduced on copies of the C description: $sig in the Call check, and $rets, a locals thread, read from the types pass. Before the change the first read with exit 0; after it, each is refused with the line, and the thread gets a note naming the pass it lives in. Two grammars in tests/grammars/ hold the two refusals, and a third holds every kind of bare name that can answer, so a check made stricter later fails there rather than in somebody’s description.

One false alarm, and it was the observer. After a git stash round trip to measure the old behaviour, the thread case read cleanly, which would have meant the check did not work. The stash had given the source and the binary the same modification second, so make saw nothing to do and the run used the old phx. A forced rebuild gave the refusal. Postmortem 18 says to check the observer first, and that is what the second look did.

The tool is 9,312 lines, and the records that quote its size say so. 306 checks to 309, all passing, and 304 with the three optional oracles absent.

2026-09-23: a driver that runs a pass until it settles

ROADMAP 2.5 had been one decision away since 2026-09-06: relax, the Z80 layout written out a second time, was the fixpoint’s step, and what was left was where again is written. Hans chose the driver, as the roadmap and the 09-05 standup both leaned, and the reason held up on contact: a driver is already the claim about what runs in what order, and a pass that looped would be the interpreter 3.1 refuses.

The shape came out of relax before any code did. To be run again, the pass has to read its own previous answer, so it defines labels, the name layout uses, and hands down the table it is about to replace: layout’s on the first round, its own after that. The driver says relax until labels. That one decision made the rest mechanical, and it also showed what the roadmap’s own list had wrong. It said the test was did any size change; the step’s input is the label table, so the table is what has to stop moving, and a br after the last label can change a size without changing anything the next round reads.

The tool learned it in four places and one of them was not the tool. The parser takes until as the second of three names in a stage, not reserved anywhere. driver_run in run.c, which is in the runtime, holds the loop, so phx and a compiler written out with -o share one implementation; the generated compiler’s driver table gained a column. Two checks in check.c had been right about one walk and were wrong about several, a down clause reading its own rule’s work and two passes defining one attribute, and each now waives exactly the attribute a driver names after until. Four refusals say what until cannot mean. The fourth place was languages/phx/phoenix.phx, the notation in its own terms, and the suite found it: it parses every description here, and the new z80.phx did not parse. until is a name there too, for the reason the %names words are.

A message that was wrong about its own witness. The bound’s first message said a stage run until something settles “has to move it one way only”. The test written for the bound is a pass that adds one every round, which moves one way only and never settles. It says one way, towards somewhere it stops now, and the comment above it says why relax does.

The witnesses. two-rounds.z80, a divergence pinned at 130 bytes since 09-05, came out at 129 and moved into the oracle. A fixed three walks would also pass it, so a deeper one was needed. A random search over programs found one that needs four walks and was unreadable; the one kept, three-rounds.z80, was worked out by hand as two-rounds one level deeper, three nested brs, and simulated round by round against relax’s own rule before the tool ran it. 134, 133, 132, 131, 131, and the tool agrees at the end, from phx and from a compiler it wrote. z80asm agrees with all ten programs byte for byte.

What was not done. 2.5’s other customer, languages/units/, keeps its two divergences: a pass adding one step of reachability, run until the table settles, would refuse the three-unit cycle, but fpc already refuses that program and no one has asked for the diagnostic. ROADMAP 5’s wart about iteration over data is narrowed rather than closed: nothing inside a pass runs again on what it produced.

The tool is 9,466 lines. 309 checks to 316, and 311 with the three optional oracles absent, both measured.

2026-09-23: long, and 6.1 closed

The next construct by the workspace document’s order was step four, the type system in full, and within it long came first for a reason this repository had already written down: ROADMAP 6.1 stayed open only for two pinned divergences, sizeof and a pointer difference, which C makes longs and this subset had made ints. So signed long, and nothing else of step four: no unsigned, no suffix.

A long is a base like int and char, eight bytes in the table of layouts, and the types pass gained one attribute, wide: no stars, no tag, eight bytes. That was enough for every question C asks. C11 6.3.1.8’s usual arithmetic conversions make int op long a long, so an operator’s width is 8 when either side is wide. sizeof and a pointer difference are wide, and a decimal constant over 2147483647 is, which the notation’s 64-bit integers could say directly.

The emit pass had been choosing a register by star count, a w for no stars, and a long is the first scalar with no stars that lives in an x. The larger part of the work was where an int meets a long: x0 holds an int zero-extended, so -1 becomes 4294967295 unless it is sign-extended first. That happens at an operand, a comparison, a store, an initialiser, an argument and a return, and each is a sxtw placed where C says the conversion happens. The argument case needed the callee’s parameter types at the call, which travel the way the struct tags already did, bound on the way out and handed down at the root; a prototype and a definition disagreeing about one is refused, as cc refuses it. The other direction, a long into an int, keeps the low half, and where the value goes on being used a mov w0, w0 restores the zero extension.

The prediction in a comment held. The pointer difference had ended in a mov w0, w0 since 2026-09-22, with a comment saying it had no witness and was written for the day ptrdiff_t arrived with long, when it would be the one to delete. It was deleted today. Every other existing program’s assembly changed only where sizeof or a difference now widens the arithmetic around it, and all 185 still agree with cc.

The breakage run went wrong twice, both times in the harness. The first version of each conversion’s breakage flipped true to false in the lookup, which does not remove a conversion but moves it everywhere else, pointers included, and 53 programs went red for the wrong reason. Rerun with the conversion removed, each of the six goes red on one to three programs. Then the narrowing breakage never finished: the witness written for it had while (n = l) l = l + 1;, which loops forever when the assignment is not narrowed, and the harness had no timeout. It ran for two hours and forty minutes before Hans asked why the background processes were still going. The loop is gone; the if beside it catches the same breakage by printing 11 where cc prints 1, and a witness that hangs would have made make test hang rather than fail.

One step has no witness and says so. A long index is a madd over the whole register where an int index is a smaddl that sign-extends. They differ only past 2^31 elements, and the program that shows it, a 2 GB malloc indexed near its end, cannot be written here: no function may return a pointer, and cc refuses the long it would otherwise be. The comment at the instruction names that program for the day it can be.

6.1 is complete. Its closing condition, written this afternoon, was that long arrive and the two pinned programs agree. Both give 8 on both routes and are oracle programs, tests/divergent/ is gone, and the entry moved to COMPLETED with a paragraph saying so. 196 programs against cc, 85 refused, none diverging. 316 checks to 317: three refusals, less the two that pinned the divergences.

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

Yesterday’s breakage harness ran for two hours and forty minutes on a witness with a while in it, and nothing in the suite could have said anything else: tests/run.sh and the oracles ran what they compiled with no limit, so a regression that loops makes make test hang rather than fail. The standup set the shape of the fix as a per-program limit that reports “did not finish” as a failure. That shape held. It was half the fix, and the other half was found only by the first attempt breaking.

The limit is a program here, tests/limit.c, and not a command from elsewhere. macOS has no timeout(1), and nothing outside this repository may be needed to run its tests; the one harness that had a limit already, languages/solveig/tests/bytecode.sh, borrowed it from perl -e 'alarm 30; exec @ARGV', which said nothing when it fired. The program runs in a process group of its own and the group is stopped, SIGTERM and then SIGKILL, because the program may be a harness whose children are what loops, and a second limit inside it has to be able to pass the stop on. It exits 124 with a sentence beginning did not finish. The number is GNU’s; the sentence is the part that cannot collide, because the C oracle compares exit statuses as data and a program there may return 124 of its own accord.

The first breakage took the suite down instead of hanging it. With calc’s while made endless, the program under test was a loop around a print, and in twenty seconds it wrote so much that the shell capturing it in $(...) failed to allocate and the run died with no report at all, which is worse than a hang. Time alone does not catch the likelier regression, since most programs here print. So limit also counts what a program writes, through a pipe of its own, and stops it after 16 MB. When standard output and standard error lead to the same place, as under 2>&1, they share one pipe, so the order of lines between them is what it would have been.

Then the limit slowed the suite from 287 seconds to 418, and then 533, and the first reading of that was wrong. A wait of 50 ms, taken whenever a program’s pipes closed before it was reaped, looked like the cause and was replaced by a wake-up on SIGCHLD. The next run was slower still, and the conclusion drawn was a busy machine. A comparison of the old suite and the new, timestamping every line, said otherwise: gaps of exactly 20.01 and 600.03 seconds, each before a line reading ok. A program had finished, limit had reaped it, and in the same pass gone on to a poll whose wake-up byte had already been read, so it slept until the deadline and then reported the program as finished. How often depended on timing, which is why every run was slow by a different amount. Reaping before the test for being done fixed it, and the suite takes 299 seconds, as it did. The comment at the loop says why the order is the order.

Every harness then got the same treatment, and a log so that a stop cannot be quiet. tests/limit.sh holds bounded for all fifteen, and each runs phx, what it builds, and awk or solvm over what phx wrote through it; the oracle compilers stay outside, under the 600 seconds tests/run.sh gives each harness. A stop can still look like a pass: a check that expects a failure and discards standard error takes it for the failure it wanted, and an oracle whose two programs are both stopped sees them agree. So limit appends each stop to PHX_LIMIT_LOG, and the last check in run.sh fails, naming each, if anything was stopped anywhere.

Its first run found exactly the second kind. The bytecode oracle compares solvm --trace output, and sola.sol’s is 49 MB, so both sides had been cut at 16 MB and matched. It is legitimate, and all of it should be compared: PHX_LIMIT_MB now sets the cap, and that oracle’s traces get 256 MB. The same oracle compiles sola.sol in 8.3 seconds, which a 2-second run exposed and which leaves 20 too little room, so that compile gets 60. Everything else the suite runs finishes in under two.

A Pascal while broken on purpose now fails the suite in 190 seconds with control and semicolons named, and a run with fpc, Solveig and z80asm hidden is 316 passed and 5 skipped, measured, as the README says. 317 checks to 321: three for the limit itself and one for the whole run.

2026-09-25: the rest of C’s expressions and statements, and what the witnesses got wrong

The standup left one item, a function that returns a pointer, and the day became four roadmap entries in the C arc, one change to the notation, one defect older than any of them, and a sidetrack. Fourteen commits; the suite went from 321 checks to 361, and the C oracle from 196 programs to 236.

Writing 6.2 down found that the refusal was in three places, not one. The standup said a pointer return was refused by name, and it was, but only for a program that reached the locals pass through a typedef: int *f() was a syntax error, because the grammar wrote no stars between a function’s base and its name, and the types pass typed every call from a table of tag and width, so a call was an int whatever it returned. The entry predicted the emit pass would need nothing, and it needed three guards, all for one reason: a pointer to a struct has the struct’s tag and is not a struct, and three questions had read “has a tag” as “is a struct”. Postmortem 23 scores it. The witness the entry existed for worked: a three-gigabyte malloc indexed near its end is the one program that disagrees when smaddl stands in for madd.

The order of what came next was argued from dependence. The operators went first because the statements lean on them and every oracle program had been written around their absence; switch waited for constant expressions because a case label is one; constant expressions were made an entry of their own because five later constructs read the same answer. Writing the operators’ witnesses found that && already lexed, as two & tokens, so a binary & built first would have parsed a && b as a & (&b). That was a second reason for the order of the parts, and not a guess about one.

The defect was found by reading, which the defects table had never recorded. Building !, && and ||, which test all of x0, meant relying on the emit pass header’s promise that an int sits there zero-extended. AAPCS64 does not promise it for a value a call returns, and a callee cc compiled hands back what it likes. I said it would take an optimising cc to show it, and that was wrong: -O0 hands back the whole long too, and tests/abi/, which had linked Phoenix to cc since 2026-09-23, never looked at more than the low half. The first witness for it looped forever when the bug was present, and only the output limit from yesterday stopped it; it is bounded now. Postmortem 24 scores the promise.

Every part was broken on purpose before it was called done, and the breaks that passed were each the witness’s fault. An int meeting a long left unextended passed, because every long divisor in the witnesses left the low half alone, until -7 / (long)3 joined them. A char update left unnarrowed passed, because the only use of its value went out through an exit status of eight bits, where -99 and 156 agree. Two more were seen before running and added first: a negative int arm of a long ?:, and a label name two functions share. One passed and could not be otherwise, the two factors of an msub swapped, which is the same instruction. And one cannot be witnessed at all, the thirty-two bit wrap in the constants pass, because only undefined arithmetic reaches it; the pass says so where the wrap is written.

Three rounds of breaks tested nothing, and reported green. The replacements went through the shell, the quoting ate them, and the file under test was never changed; a break that does not apply looks exactly like a break that is caught by nothing. They were redone from a script that asserts its text occurs exactly once before it writes. Two witnesses also had undefined behaviour in them, an unsequenced read and write and an int overflow, and one used a cast the subset does not have; cc -Wall before a program joins the oracle catches all three kinds.

The sidetrack was colour for .phx in VS Code, asked for to make the descriptions easier to follow. The oracle it needed was already in the repository: languages/phx/phoenix.phx describes the notation’s own tokens, so phx lexes every .phx file with it and the colouring has to agree about every comment and literal. One of its fixture lines was wrong at first, and wrong the same way the break it was meant to catch was: it had a comment end at the second *) when phx ends it at the first.

The statements found that C’s keywords were not reserved, so break; had been an expression naming a variable. A break could not be handed its loop’s numbered labels, because a node takes its number on the way out and the break is compiled inside, before that; the labels are named from the loop’s line and column, which are known on the way in, as awk-c.phx names a rule. A goto’s label is gathered on the way out and handed down from the root as sigs is, and a switch gathers its cases through a thread each switch starts afresh and gives back, which is what lets Duff’s device work. The one thing that had to be taken on trust, that a leaving clause reads what the body left before the restore beside it runs, was checked by reversing them: six programs fail.

Constant expressions asked the notation for the first thing since %names. It has no bitwise operations, and &, | and ^ cannot be written in + - * div mod; ~, << and >> can. Hans chose three library functions over refusing them. I added them before reading ROADMAP 3.4, which is the rule for adding anything to the library and which I found through the reference page’s link afterwards. The rule was met, a real pass needed them and the notation could not spell them, and 3.4 now lists them and why the other three are not there. But it was met by luck of the case, not by reading first. The pass that uses them is written so that nothing traps, because a trap is the compiler stopping with an arithmetic error: operands are folded only within thirty-two bits, a zero divisor leaves the value unsettled, and a minus refuses only the most negative integer, after a first draft that held it to thirty-two bits refused case -4294967296:, which is C.

The records needed as much checking as the code. Twice a closing summary had counts written from memory and wrong, once “two years” for a harness two days old, and once an entry cited 1.7 as supporting the library change when 1.7 says the opposite, one customer being a workaround. Each was caught by counting or grepping before the commit and not after, which is the only reason none of them is in the history.

2026-09-26: printf, void, casts and globals, and two defects in the tool

The standup left one item, the C arc’s next entry, with an order argued the day before: variadic calls for printf, then void and casts, then globals, then the unsigned family. All four entries were written, three built and closed, and the fourth drafted, with the tool changed twice on the way. Twelve commits; the suite went from 361 checks to 410, the C oracle from 236 programs to 251, and the refusals from 125 to 173.

On this machine a variadic argument is not where an ordinary one is. Apple’s arm64 puts the named arguments in x0 to x7 and every argument after them on the stack in a slot of eight bytes, even with registers free, and packs a ninth named argument at its own size; cc -S was the source for both. 6.6 planned to move each extra into its slot inside the area the pushes had already taken, and said which way the copy ran was the part to get right. No way was right: with ten extras the first slot is where the fourth was pushed, and running the copy the other way, the seventh’s slot is where the first was. The slots went below the pushes instead, with sp raised after the bl, and postmortem 25 scores the plan. The witness is printf itself, compiled by cc, since nothing the subset compiles can read past its named parameters.

6.7 guessed that void was half built, and it was. int, char and long were already structs with no members in the table of layouts, and a void value has to be refused in exactly the places a struct is refused where C wants a number. As a fourth entry keeping its name as its tag, it is a record, and every one of those checks refuses it once. What it cost was about twenty-five messages that printed 'struct {}' and now print a kind, and three places that took a record for a struct in memory; postmortem 26 scores it. The implicit conversions cc refuses and the subset compiles, long n = p;, were put to Hans and held out by the rule of 2026-09-22: the types pass refuses what would otherwise mis-compile, and a cast is what makes each legal, not a reason to refuse the version without.

A refused cast found a defect in the tool. A cast to a struct was refused, and the assignment above it then asked the refused node whether it was a struct, on the right of an and. A failed check leaves failures behind, and every operator passes one through so that the source hears once; and and or passed one on the left and complained about one on the right, naming a line of c.phx. One line in eval.c, and tests/grammars/one-complaint.phx, which already held sizes, each and bytes to the same rule, holds the two operators too.

6.8 needed the constants pass to fold a long, and that was harder than it looked. The pass folded only when both operands fitted in thirty-two bits, and that one condition was what made the whole table safe, because the table works out every operator whichever is asked for: a + of two huge longs also computes their product, and the notation traps. So each operator that can overflow asks first, with and and or that stop before the arithmetic, and its entry works on operands that are real only when its own answer fits. The entry said case would gain nothing it could show, and a switch on a long showed eight labels it had refused. The globals themselves went as planned, except that an initialiser at file scope had an empty env and could not see the globals above it, and that cc reaches a global with no initialiser through the GOT, as a common symbol, which nothing here needs.

Three times today the instrument was wrong and not the code. The script that applies breaks looked for the word overflows to spot a trap, found it in its own witness’s file name, and reported all nine breaks caught; run honestly, three passed, and each needed a witness of its own, because one program with four bad labels stays refused when only one of the four questions breaks. Later the script restored a source with the time its copy was made, older than the binary it had just built, so make kept the broken bin/phx and the next break looked caught by a test it could not reach. And two breaks of alignment passed because arm64 does not fault on an unaligned load, until each witness printed an address modulo its type’s size after a char. The one that passed for a reason already met yesterday was a long cast to an int without its mov w0, w0: every witness gave the int to printf’s %d, which reads thirty-two bits, the eight-bit exit status again in another place.

Drafting 6.9 found that the subset already gets a program wrong. C makes sizeof an unsigned long; Phoenix makes it a long, under a comment in c.phx that says nothing can see the difference until unsigned arithmetic exists. sizeof(int) - 5 < 0 is that arithmetic, and Phoenix compiles it and answers differently in all five values of sizeof-is-unsigned.c. The oracle’s none diverges was true of what it held. Postmortem 27 scores the comment beside the case claim, since both said no program could see a thing.

Asking what a constant past the largest long folds to found the second defect in the tool. The library’s int called strtoll and never asked whether it had overflowed, so "9223372036854775808" was read as 9223372036854775807, without a word. It is refused now, with the claim in docs/reference.md § 11 where the library’s refusals are written, and in C such a constant is refused by name until 6.9 brings unsigned long. The first version of that refusal guarded int twice over, with attributes that kept the text from it, and a break showed they guarded nothing: a check that fails is itself the guard, since the attributes of its rule and every pass after are never worked out. They came out, and a check that the refusal is said once is its witness.

Closed out a second time, after two more pieces of work. The first was 6.9’s first part, the unsigned types, and the tool caught its worst mistake before a program ran: locals gave a base type an uns and types gave every node a default one, which would have hidden the base’s, so that a cast to unsigned read as signed; phx warned that both passes defined the name. Of twenty-four breaks, five found a witness missing, and every one was an unsigned int meeting a long or a value with its top bit set, which is where signed and unsigned differ and where the first witness had not looked. One cannot be seen until the third part, a switch on an unsigned int sign-extended, which differs only against a negative label.

The second was the website, which had not been built for three weeks. Hans asked why GitHub complained about pages.yml. Every run of it since 2026-09-05, fifty-five, had failed before Jekyll read a page, because the site’s description in www/_config.yml was rewritten that day to say Phoenix writes both down: EBNF, and in an unquoted YAML value a colon and a space begin a key. The journal of 2026-09-03 said the site, being built on every push, cannot fall behind the documentation; it fell behind by every document written since, and nothing in the repository ran the build or read its result. The value is quoted in the working tree, and parses, and every assembled page’s front matter parses too; whether Jekyll then builds is for the next run on GitHub to say, and that commit is not made yet. Postmortem 28 scores the claim.

2026-09-27: unsigned closed, and the site’s records

The standup left five items: the website’s fix put into the records, the workflow’s warnings, whether make test should parse the site, and 6.9’s second and third parts. Four were done, and the fourth closed 6.9. Four commits, all pushed, and the site built and deployed after each. The suite went from 416 checks to 423, the C oracle from 253 programs to 262, and the refusals from 179 to 189.

The site went into the records as a ninth kind of finder, the plainest: somebody looked. The check had run on every push and said so every time, to a page nothing in the repository reads. The workflow’s Node 20 warning was four actions a major version behind, each moved to its first release on Node 24; upload-pages-artifact leaves out dotfiles since v4, and the site has none. The Ubuntu 26 notice was left alone on purpose, because the build is a shell script and a Jekyll in its own container, and nothing in it asks which Ubuntu it is. Whether make test should parse www/ with Ruby’s YAML is still not decided.

Part 2, sizeof an unsigned long, was one attribute on two nodes and then a problem the plan had placed a day later. The constants pass had never settled an unsigned value, because part 1 left every cast to one unsettled, and sizeof made it settle one. That pass does C’s arithmetic in the notation’s signed integers, so case sizeof(int) - 5: would have folded to -1 and (sizeof(int) - 5) / 2 to 0. The roadmap’s rule for part 3, an unsigned long folds only where its operands and its answer lie from 0 to 2^63, was taken early. Of six breaks two passed: sizeof of an expression had no witness at run time, and nothing held the guard on ~, whose unguarded answer is the right sixty-four bits anyway.

Part 3 read a constant in halves, because the notation cannot read it whole. The library’s int refuses text past sixty-four bits, as it has since yesterday’s fix, and an unsigned long constant can be past 2^63. So each base splits its digits where both halves are small, and puts them together by a route with no step out of range: hex at thirty-two bits, octal at thirty-three, decimal at ten digits, where the value past 2^63 is worked out as itself less 2^64, the one form the notation can hold. Every entry of a lookup is worked out whichever is chosen, so each base reads its halves from its own text or from nothing; int of a hex digit in base 8 is an error even in an entry nobody asked for. The reading had to move a pass earlier as well: a check in types refusing an octal 8 reads the digits, and a check may not read what its own rule computes, since it runs before them, which is what makes it a guard.

The first break run was mostly green, and the instrument was not at fault. A control, one harmless edit, came back nothing caught it, so the harness could say so, and fourteen real breaks said the same. One was reproduced by hand before anything else was believed, as yesterday’s lesson said, and it passed by hand too. Most were one hole: the constants pass could hold an unsigned int as -1 or as 4294967295, and nothing asked which, because a global keeps thirty-two bits either way and the next unsigned operator converts its operands again. Only a value widened to a long tells the two apart, and no witness widened one. A product needed a witness past 2^63, and the first witness for a converted right operand, 4294967295u / -1, is 1 whether or not the -1 is converted. Every one has a program now but one, and that one was checked directly rather than argued: writing a constant’s text into the assembly passes because this assembler reads 10L and 017 as C does. A comment written an hour earlier said it read neither; the same check corrected it.

What is held, and why. An unsigned long past 2^63 is carried as its bits and not folded further, because the notation’s integers are signed: case sizeof(int) - 5: is refused, which cc folds, and a comparison is the one exception, since flipping both top bits makes it a signed one. Those refusals, and part 1’s, still say the label cannot be worked out, which is not true; a message that names the reason needs an attribute carried up through every node, and was not built. Postmortem 29 scores the witnesses the roadmap named.

2026-09-29: a refusal that says why, and const and static

Every refused constant said it cannot be worked out, and most of them can. case y: and case 1 / 0: cannot, in C. `case 9223372036854775807

The reason is one more attribute, and a number rather than words. The constants pass already carried settled and folded up every node, so why goes up beside them: 0 for a settled value, and otherwise 1 for an unsigned long past 2^63, 2 for a long shifted by 63, 3 for undefined and 4 for not a constant. It is a number so that two operands can be compared, and the larger wins, because each is a stronger claim than the one below it: an operand that is undefined makes the whole undefined whatever else is wrong, and one that is no constant makes the whole none. A node with no clause of its own, a variable or a call, answers 4 through otherwise, which runs after the node’s own clauses and can read its settled. The words are a second otherwise, because, read off one table, so the case and the global’s initialiser print the same sentence.

The shift by 63 is its own reason because it is neither of the others. The pass folds a long shift only by 0 to 62, since the notation cannot hold 2^63 to divide by. C defines 4L >> 63, so it is not undefined, and it is no unsigned long. Calling it either would have been the same mistake as cannot be, one level down.

Three refusals were asserted by nothing. Looking for where case-sizeof-below-zero.c was held, to change what it expected, found no line in tests/run.sh naming it, nor case-inverts-sizeof.c, nor global-negated-sizeof.c. All three were written for 2026-09-27’s second part, whose commit message counts them. Pascal’s refusals are run by a loop over the directory; C’s are named one by one, which gives each its own message and lets a file be forgotten. The suite now checks that every C refusal is named.

Yesterday’s lesson was followed, and it found two holes. The witnesses were written first from what the reasons are for, then twenty-eight breaks were made, one of them a control that nothing should catch and nothing did. Two others passed. && and || had been tried with the reason only on their right, so ignoring the left went unseen; a witness with the reason on the left sees it now. And an operator’s own 4 for a pointer operand cannot be seen: no value with a pointer’s type is settled, so the operand has always answered 4 before the operator is asked. That is said in the pass, as the thirty-two bit wrap is, and kept for the day an address with an offset folds. One hole was in the witnesses themselves before any break ran: a ?: whose condition was a variable, meant to show the condition’s reason being read, would have printed cannot be whether it was read or not, since 0 and 4 read alike. It was replaced with a condition whose reason is 1.

The next entry was chosen by a program, not by the list. The standup named five candidates for 6.10, the ones 6.9 had set aside: short, long long, _Bool, extern and an address with an offset. The workspace’s toolchain document had changed the same morning: the chain is now meant to end in hello.c compiled and run on Ouroboros, and its first step there is retargeting this back end to ELF and AAPCS64 as written. So there were two kinds of next, and the question was put to Hans with the evidence for each. The evidence for the language was CPP’s own source, preprocessed and run through the subset: every file stopped inside Apple’s headers within three lines, and CPP’s own code, counted apart from the headers, wants const 278 times and static 129, neither of which was on the list. bool came third, at 113. Hans chose const and static.

Writing the witnesses found the subset taking what cc refuses. Neither word was reserved, so int static = 1; int const = 2; compiled and exited 3. It is the same shape as 6.9’s first finding, a thing no program in the oracle could show, because the oracle holds only what cc compiles; the difference is that this one was a refusal missing and not a wrong answer.

static on a function or a global changed one thing in the emit pass, the .globl, and the risk was always in what it left out. Two files each with a static int count link and run whether the two are merged or not, if the zero global is written as a common symbol; the emit pass had never written one, which was checked in the source before the entry said so. The link test that holds it compiles each of two files by each compiler. The one break that got through was a static array, which neither file had.

A static local wanted a label, and the tables had no place for one. An entry in the locals pass was eight things, and a global was reached by its name, which was also its label. A static local’s name is not unique in a file, so an entry gained a ninth part: the label, the name for a global and f.n.K for a static local, where cc writes f.n and f.n.1. The counter is file-wide and the function in the label is only for a reader; one break showed the counter was needed and unwitnessed, since no function had two statics of one name until main was given two blocks with one each.

The morning’s lesson came back the same afternoon. A static local’s initialiser is a global’s, and so were its messages, and one of them was false for a case no global could reach: static int *p = &a, a a local, was refused as an address this cannot write, the words for &table[1], which is a limit of this compiler and not of C. cc says the address of a local is no constant at all. Telling the two apart needed the constants pass to know whether an address starts at a label, so two more attributes go up the tree, rooted of a value and sited of a place, and the notation’s rule of one rule per node in a pass folded them into the rules already there. int *q = p; at file scope had said the same untrue thing since 2026-09-26, and its assertion changed with it.

The break harness was wrong twice before the code was. It read only assertions quoted one way, so a break the suite would have caught came back uncaught; and its first fix could not read an assertion with a quote inside, which every one naming a variable has, so two breaks that were caught looked as if they were not. One of them was run by hand before anything was changed, and failed the program as it should; the harness was fixed and every break run again. Reproducing a surprise by hand before believing it, 2026-09-27’s rule, earned its place twice in an hour.

const was to be a number and is text. The entry planned a bit a level, halved by * and doubled by &. A declaration’s stars reach a pass as a list, and the notation has each and join to make text of a list and nothing to make a number of one, so the levels are a string of 0 and 1 with the value’s own last, a * drops a character and a & adds one. A value the pass knows nothing about is one level, so a gap loses a refusal and cannot make a false one; that was chosen, not found.

Part 3’s witnesses were written from how it could be wrong, before any break ran, and no break got through. Reading the plan for which nodes carry a pointer’s type to a place, eight had no witness: *&x, a * of an assignment, of a comma, of a ?:, of a call, of a sum with the pointer on the right, of a difference, and a const member inside a struct named by a typedef. All eight were written first. Thirty-one breaks were then all caught, where parts 1 and 2 let one and three through; the two that looked uncaught were faults in the breaks. The entry’s promise that const twice is accepted was the one thing the first grammar did not keep, and it was seen because the entry had written it down.

The C11 arc before the port. 6.11, the second target, was opened in the afternoon as the destination’s first step, and within the hour Hans put it after a different arc: c.phx conforming to C11 first, Ouroboros later. The toolchain document’s argument for the retarget going first, that every construct added before it is done twice, still holds and was weighed; the answer was that the language is the goal of this repository and the port is the workspace’s. 6.11 stays on the roadmap as written, parked, with its failing program and its hand-tried oracle, so the later arc starts from it and not from nothing.

What conforming means was put as two questions and settled on the recommended answers: the mandatory language and none of C11’s four optional features, each refused by name behind its __STDC_NO_*__ macro; and the compiler proper, phases 5 to 8, with cc -E and the system’s library kept. The choice that mattered is the first. Variable-length arrays, _Complex and _Atomic would each be an arc of their own, and leaving them out is what C11 itself allows.

Two dozen programs against the check driver changed the map. The list of what was left, written that morning from the refusals and CPP’s source, named types and qualifiers and missed that the declaration syntax is partial: int a, b;, long int, int a[2][3], int (*p)[3], int f(int a[]) and char s[] = "hi" are all syntax errors. The subset grew one declaration shape at a time, each as its entry needed, and nothing counted the shapes C has against the ones it takes. Most of the rest of the map is written in those shapes, so C11 6.7 in full is the recommended first entry of the arc.

6.12’s first part: the words of a type counted, and every keyword reserved. Two things the notation could not do shaped it. An action cannot read a child node’s field, so a declaration cannot copy static up from its specifiers, and a rewrite can only test a field for equality, so it cannot ask whether a text contains static. And %names has to see a Typedef while the parse is running. So the grammar decides the node, with sspecifiers and tdspecifiers, each a specifier rule that has one of the word in it somewhere, six alternatives apiece; everything else is decided from the words.

The words are counted by their spellings. split on " long " gives the occurrences plus one, which was tried on an empty text and on a single word before it was trusted, and a text with a space on each side of every word makes long long two and not one. Twelve counts in 6.7.2p2’s order make a key into a table of twenty-one lists, and a list not in it is no type. The counting is done in the functions pass and checked in locals, because a check cannot read what its own rule computes, which the file’s own comment on Program had already said.

The design was read for how it could be wrong before any break ran, as 2026-09-29’s lesson asked, and found five shapes the witness lacked: a static local through a typedef’s name, with static before and after it, a static local of a struct with the word after its tag, typedef after a typedef’s name and after a struct, and const among the words of a type. They were written before the breaks, and so was a guard: a local whose words hold static but which the grammar read as automatic would be a wrong answer with nothing to refuse it, so the locals pass calls that a fault in c.phx. Ten breaks were then all caught, each by the witness written for it, and none needed running by hand.

6.12’s second part: one node for a declaration, handing its specifiers down. int a = 1, b; is two declarators and one set of specifiers, and the first design gave each declarator its own copy. The notation could not build that: an action inside a repetition cannot read a label outside it, and nothing maps a list to nodes. So a declaration became a Decls node with the specifiers in spec, and every pass hands them down as base, the name the field had. The probe that settled it also found that a value handed down belongs to its pass alone, so the down is written in all five, and that a field and a handed-down attribute of one name draw a warning, which is why the field is spec.

Two collisions with names already in use. The functions pass gives every node a list of the functions it defines, now through otherwise, and called it fname; the emit pass hands the function’s name down as fname, and the first run compiled nothing. It is fdefs now. And %names must bind a parameter’s name from one factor, so a parameter with no name has a rule that matches nothing and answers “”, since the notation has no empty rule.

Tentative definitions are a question about the whole file: a global’s storage is written at its initialiser if the file has one anywhere, and otherwise at its first declaration that is not extern. The functions pass gathers the initialised names at the top and the emit pass reads them, with a thread of the names written so far.

The design was read for how it could be wrong first, again, and four shapes had no witness: a block extern of an array, static then extern for one name, a for’s name declared again after it, and a typedef’s name hidden by a for and back after it. Writing the breaks found two more. The linkage witness could not fail, because the extern declaration writes nothing, so a static name defined by an extern with an initialiser was added to both link files. And nothing held an initialiser carried past a tentative definition, so int x = 1; int x; int x = 2; joined the refusals. One break came back not caught, and it was right: an unnamed parameter’s binding under “” was guarded by a condition that protected nothing, since nothing can look “” up. The condition was taken out, and the break moved to the check that does matter, which the int add(int, int) prototype catches. Twelve breaks, all caught.

2026-09-30: declarations closed, initialisers in braces, and three old limits

6.12’s third part gave every struct definition a key. A struct could now be defined in a block, and a block’s struct s and the file’s are two types, so the layouts table could no longer be keyed by the tag. A definition at file scope is keyed by its tag, as before, and one in a function or with no tag by the tag and a number, node.3 or (anonymous).4; a scoped table, saved and restored at a block and a function body as the names are, maps a tag to the key it means there.

The key had to be known on the way in, so that a member pointing at its own struct finds it, and a probe showed why that was not free: a down value reads a counter as the node is entered, and an ordinary attribute reads it after the node’s children have moved it on. A node can read its own down value back, so the key is worked out once, on the way in, and read from there everywhere.

Writing the witnesses found a wrong answer that had been there since 2026-09-23. The plan said a pointer to a struct completed later would need its width looked up where it is used, and asking how that could already be wrong led to the struct that is always incomplete when a pointer to it is written: its own definition. struct node *next recorded a pointee width of 0, and (p->next + 1)->v read the element it started at. cc answered 2 3 2 and Phoenix 1 1 0. Nothing in the oracle did arithmetic through such a member; every list program followed ->next and nothing else. The width is now looked up again at every name, member and call, and where it is still 0 the struct is not complete there, and arithmetic, * and sizeof of it are refused, as cc refuses them.

Fourteen breaks, and four came back not caught. Each was run by hand before anything was believed, and each was a witness that could not see its break: a call’s width is taken from a table of the whole file, so only a function defined in another file can return an incomplete width, which went into the link test; nothing used a file tag after a function body redefined it; an inner struct’s members leaking into the outer one changes no size, only whether o.k is refused; and one break disabled one of two checks that overlap. Four witnesses were written and the four breaks were all caught.

Part 4’s representation was prototyped before it was chosen, at Hans’s asking, both ways, on the declarations arrays.c writes: a small description of C’s declarators answering sizeof, [i], * and the step of + 1 for each name, forty answers held against cc. A type as text and a type as a list, the second with slice taught to take one, both gave forty of forty. The list read better, 42 lines to 59, and text was settled on by ROADMAP 3.4’s rule: the prototype had shown the notation can say it, and the library grows only for what it cannot. The product the entry had feared was never needed: each level is made with its size in it, and a declarator’s brackets became a nested chain, so the size of each is an attribute of the one inside it. What the prototype found that the entry had not guessed was order: a value handed down is worked out as its node is entered, before its children, so the brackets of (*p)[3] reach the declarator inside the parentheses as nodes, read later, which works only because a node’s children are walked in the order its fields are written. That was checked by writing them the other way and watching it fail.

Most of the types pass did not change. It was built on two numbers, the pointers in front and the width they reach, and the emit pass steps a pointer by the second. A pointer to int[3] is those two numbers with the width twelve, so only the nodes where an array can appear, a name, a member, a *, a & and a cast, work them out of the text; everything else makes its text of them. multidim.c agreed with cc the first time it ran.

Two of part 4’s breaks showed code that did nothing. Decaying an array and then dropping its pointer level is the same text as dropping the array level, so *’s decay was taken out; ?:’s was kept, so that its text says what its type says, with a note that it cannot be witnessed yet. The third not caught was a break that changed a table row nothing reads.

6.12 is closed: its four programs agree with cc, all 44 keywords are reserved, and every refusal it named has its program. The map in ROADMAP 6 puts initialisers in braces and function pointers next, both of them what CPP’s own source needs.

The port waits for Hans’s judgement of the compiler. With 6.12 closed, the order the day before had set, the C11 arc and then the port, was made exact: nothing whose only purpose is Ouroboros starts until Hans is satisfied with the compiler itself, and the gate is his judgement and not the close of an entry. ROADMAP 6 and the workspace’s toolchain document were reworded to say so; step 0 of the latter, making a C program a command on Ouroboros, belongs to Ouroboros’s own roadmap and was left for him to decide. 6.13, initialisers, was opened next because CPP’s source writes thirty in braces and function pointers, the other candidate, need them too; its part 3 was written down with the question of order in it from the start.

6.13’s first part: an initialiser walked beside a type, and three things about handing a value down. A value in braces goes to the next scalar the type has, a list to the whole next element, and a string fills an array of char; the walk is a thread, a byte offset, and each list hands its items what they need. Getting that right taught three things the notation does that no description here had leaned on. down clauses are worked out in the order they are written: one that reads a name the node hands down later sees the parent’s value, and after it the node’s own, so a list that handed atop = false down before reading atop took itself for a nested one. A check reads the node’s own handed-down values, not its parent’s, so the parent’s are copied on entry into names of their own. A check reads a thread after the node’s children have moved it, so a list’s start is kept in its own lb and not read back from icur. Each was found by a check giving the wrong answer on init-arrays.c, and each was found by printing what the check saw, once, and not by guessing twice.

The witness for a large array found a limit from the first day. A local more than 255 bytes into its frame was loaded as [x29, #-offset], and arm64 has no such form past 256; int big[80]; int i = 0; never assembled. No program in the oracle had had a frame that large. It is reached through x16 since, the short form kept below 256 so that nothing else changed, and large-frame.c fails without the fix.

Twelve breaks, two not caught at first, both run by hand: no global had a gap in the middle of its data, only locals, which are zeroed anyway, so one global and one static with short rows were written; and a break of the space after the last value had changed a site nothing reads. Both were then caught.

6.13’s second part: structs member by member, and a limit taken on purpose. A struct’s level text now carries its key, the fifth field of its level, and its layout its members’ names in order, since fields is keyed by name and has no order to give; a list whose object is a struct counts members, imem, where an array’s counts bytes. Brace elision into a struct is where the byte cursor stops working: the next scalar after the last one written is found by walking down a struct’s members and carrying back up when one runs out, which is a loop. It was put to Hans, with the full walk in the notation to a fixed depth and a change to the tool beside it, and he took the limit for now: a member or an element that is an array or a struct must have its own braces, refused by name without, and the question goes to part 3, where designators out of order at file scope ask the same thing of the notation. CPP’s source leaves out no such braces, which was checked first.

Order within a clause again: sover, the check that a struct has a member left, read imem after the same clause had moved it on, and refused the last member of every struct. And a list inside a struct started its items where the struct’s cursor had got to, not at its member; it now hands its own start down as the cursor. Ten breaks, all caught, two of them by witnesses written because thinking about the breaks found them missing: a struct defined inside another’s members, and a typedef of a struct completed after it.

6.13’s third part: the iteration question, answered without iterating. Both needs that had been put off to here, a global’s designators going back and braces left out inside a struct, looked like loops: a sort, and a walk down and up a struct’s members. Each turned out to have an answer the notation can already write. Order is not needed if the data is not written in order at all: a global whose values go back is zeros, and a routine run from __mod_init_func before main stores the values in the order written, so the last one for a place wins, which is what C asks. It was tried by hand on Mach-O before anything was built. The walk is a lookup if each struct carries a map of its bytes, one letter a byte saying which member it belongs to, with padding counted as the next member’s: the scalar at or after any offset is then a fixed number of steps down, by division in an array and through the map in a struct. A run of one letter n long is a slice of a long run of dots, split at the dots and joined with the letter, and the run of dots is made once by doubling. The prototype gave twenty answers of twenty against cc’s offsets, and Hans settled it for the notation. ROADMAP 3.4’s line holds: the library did not grow, and nothing iterates.

The new walk replaced the old two, the byte cursor of part 1 and the member counter of part 2, and every witness of both stayed green through it. Getting it right took the usual ordering lessons and two of C’s: a value in braces never takes the braces’ own object, so a struct value stops one level down, as cc says; and a value past the end of a scalar is past the end of the object, which is how int x = {1, 2} is refused. iback, whether a value went back, was first written as an attribute and not a thread, and every global looked in order; the witness’s first backward global wrote a negative .space and said so.

A third old limit came out of the witnesses: a struct member more than 4095 bytes in was added with an immediate the assembler refuses, found by a twenty-thousand-byte struct written to test the run of dots. Twelve breaks, all caught, three of them by witnesses written because thinking about the breaks found them missing.

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

The Proem rename first, a note from the workspace: CPP is Proem since today, ~/Projects/CPP is gone, and the two links to it in ROADMAP 6 and COMPLETED were dead. Both point at ~/Projects/Proem now, and ROADMAP 6 says that every mention of CPP on that page means Proem. The journal and the changelog keep the old name, as history.

6.13’s fourth part: a compound literal is a name with its object inside it. C11 6.5.2.5 makes (struct pair){3, 4} an object with no name, and an lvalue: everything a name is, &, =, a subscript, a member, decay, sizeof, a struct passed whole. The question was how to get all of that without writing it again, and the answer was to build the literal as a Variable and give Variable a field, lit, empty for every name and holding the object, a Literal, for a literal. The Literal is worked out as a declaration’s initialiser in braces is, a LocalAgg’s in a function and a StaticAgg’s at file scope, and binds itself in env; the Variable looks it up there. Every clause a name had then applies, and the emit pass only had to put the Literal’s code in front of the Variable’s, so that a literal in a block is filled each time it is reached, which C11 6.5.2.5p5 asks.

The key was the one thing that did not come free. Two literals must not share an entry, and the obvious key is where each is written. A probe showed that a grammar action cannot read $pos: only a pass can. So the parse gives the Variable the name “”, the Literal works out lit.5.14 from its own position in the locals pass, and the Variable looks up its Literal’s key in place of its name. The key is also the label of a literal at file scope, _lit.5.14, since . is no C name’s and is an assembler’s. And one cursor had to be kept: the initialiser’s walk is a thread, and a down on a thread does not put it back, so a literal inside another initialiser, {(int []){1, 2}, 2}, left the outer walk at the inner one’s end. The Variable keeps the three and restores them on the way out. The second witness, written from reading the design for how it could be wrong, has that shape, and the break that took the restoring out was caught by it.

The witness agreed on its first run, and so did the second. What went wrong was elsewhere: &(long)x, refused as a syntax error since 6.7, was now refused one token later, at the x where a literal’s { was wanted, which is the same refusal said from further in. And trying the refusals against cc found two messages out of date since 6.13’s second part: a struct global or static initialised from another struct’s value said braces were “not here yet”. ROADMAP 6.13 had said they would come to say what cc says, and they do now: the value is not worked out before the program runs. A struct’s compound literal at file scope meets the same refusal, which cc takes as an extension.

6.13 closed with it, and three of its listed refusals turned out to have no program in refused/: an array from a scalar, a struct from a scalar, and an array with neither a size nor an initialiser, which cc takes at file scope as a tentative definition of one element and refuses in a block. All three are there now, and pending/ is empty. Thirteen breaks, all caught; one was first skipped by the script that made them, its text matching four clauses, and was made by hand on the Literal’s own. 601 → 614 checks.

6.14 opened, for pointers to functions, and its first part built. A call today is a name, its callee looked up in four tables keyed by the function’s name, and Proem’s three callback members are what the arc needs next. The witnesses came from Proem’s own shapes and the plain C around them, and each was checked clean under cc -pedantic -Wall; cc -pedantic-errors was asked what it refuses, and two of its answers were unexpected: a pointer to a function given one of another type is an error in this cc and not a warning, and so is void * from a function.

A function became a base, not a level of its own. The plan written into the ROADMAP had f: as a new kind of level. Reading the clauses that work a type’s stars out of its text showed every one of them finds the first a: or b: and divides what is before it by eight, so an f: would have been counted as a pointer everywhere. A function is b:1:0:1::KEY instead, a base whose sixth field is a key and whose fifth, a struct’s, is empty, and every one of those clauses was right about it unchanged. The key names the function’s parameters in the four tables a call reads, as a struct’s key names its layout: a function declared by name is its own key, and a declarator’s (int, int) is bound under fn.8.25, from where it is written. A probe found the second thing: a function’s address cannot be an adrp and an add when the function is in libc, and the linker says so; through the GOT it can, and a function in this file reached the GOT’s way is relaxed by the linker. So every function is reached that way.

The witness agreed on its first run once a function was its own address; before that, each comparator was loaded through and qsort called into its first instruction’s bits. Reading the design for how it could be wrong found two missing witnesses and one false refusal. Nothing took a libc function’s address, which is the reason for the GOT, so the second witness does, and the break that took the GOT out was then caught. A declarator’s parameter names must be in no scope after it, so a refusal holds that. And a parameter’s name hid a typedef of the same name to the end of the block, since %names ends a hiding at a rule it is told is a scope: int (*f)(int T); T x; was a syntax error that cc compiles. fn-suffix is a scope now. Thirteen breaks, twelve caught; the frame’s byte count not put back after a pointer to a function’s parameters makes a frame larger and nothing else, and is kept with a note. 614 → 619 checks.

6.14’s second part, and the question it carried, settled by building both answers. A call through a pointer could be a second node beside Call by name, or Call could become a call of any expression. Both were written in scratch copies of the description and run against the witness and the whole suite, and the assembly of all 303 oracle programs was compared with today’s: both left every byte where it was. What told them apart was not in the plan. The parser cannot tell add(1, 2) from op(5, 3), so two nodes meant a call by name that loads a pointer itself, and every call clause twice: 143 lines to 97. And the oracle compiled in thirty-four seconds with two nodes, twenty today, and nine with one, because a call by name had been a primary, which each of suffix’s alternatives parsed again before failing after it, so calls nested in calls’ arguments multiplied: struct-return-as-argument.c had been taking nine seconds alone, near the suite’s limit, and with a sixth alternative went past it. Hans chose one node.

Both needed one thing moved: a pointer’s key is the types pass’s to work out, after a call is entered, and an argument’s checks had read the callee’s parameters as the call was entered. They are the constants pass’s now, with the count. The second witness found two assumptions from before pointers to functions, both in one declaration, void (*v)(int *) = bump;: thirty-six checks counted the stars before the parentheses and refused it as an object of void, and the name kept its base’s tag, void, so (*v)(&n) read as * of a void *. Eleven breaks; two were missed at first, each for want of a witness, and caught once it was written. Making every call indirect changed no program’s answer, only its code, so a check now reads the assembly for a bl and a blr; and telling an argument it was being called changed only a refusal’s words, for a name not declared, so two refusals hold those. 619 → 626 checks.

6.14’s third part was one clause. Proem’s shape, a struct with a function pointer member set by position and by designator, tested against 0 and called through d->report(...), needed nothing the first two parts had not built but this: a function’s name counted as an address worked out before the program runs, so that it can be written into a global’s data as .quad _twice. The witness agreed once it was. Reading the clause for how it could be wrong found a false refusal beside it: *twice is the function, and was no address to the constants pass, so a global initialised with it was refused and cc compiles it. And of the clause’s two halves, rooted did nothing: it tells an address with an offset from none, and arithmetic on a function is refused before that is asked. It was taken out, since a break of it changed nothing a program could show. Four breaks, three caught, the fourth that half. 626 → 628 checks.

6.14’s fourth part closed the entry, and each thing it needed was a thing an earlier part had assumed. typedef int binop(int, int); had been read as a prototype with typedef among its specifiers, since function is tried first, so a typedef is tried first now. A call’s type was three numbers, so a call of a function returning a pointer to a function lost the key a call of that call needs; rets keeps the returned type’s text, and a Sig keeps its own, which the second witness’s meta(1)(2)(9, 4) asked for when the first had not. ?: and the comma decayed an array and not a function. And the redeclaration check worked out a function’s returned pointers from the stars before its name, which a return type in parentheses of its own does not have, so a prototype and its definition were refused as two return types.

The one type check this pass makes of a pointer is the part’s new thing: a pointer to a function is given only its own type, since a call through the wrong one passes and reads what the wrong type says. Two types are the same here when the four things a call reads of them are, which is as far as a call can tell them apart, and cc refuses every case the check does. It is made at an assignment and at an initialiser, alone or in braces, and not yet at an argument or a return, which is recorded as left. Nine breaks, all caught. 628 → 639 checks, and pending/ is empty again.

6.15 opened, for the integer types, and its first part was smaller than planned. Proem’s source was counted again, since the map’s count was CPP’s: bool 143 times, enum nine, long long four, and no union, short or floating point, so the entry is the integer types and not the whole row. Probing cc for the witnesses found one choice worth holding: an enum with no negative constant is an unsigned int here, so enum pos up = -1; up < 0 is 0, while its constants are ints. The witness asks it. Part 1 was long long and signed char, and signed char turned out to be done already, its words counting to char’s entry since 6.12. long long counts to long’s now, the same eight bytes, and its suffix is the old one with two letters. One slip of mine, caught by the count of refusals: a refusal program I wrote for long long long overwrote one of the same name from 6.12, and its assertion was there twice; the old program is back and the second assertion gone. Three breaks, all caught. 639 → 638 checks.

6.15’s second part, short, was a row in every table, and the breaks were what said which rows mattered. Every place the emit pass picks an instruction by a value’s width had rows for one, four and eight bytes, and took one for two. Eleven breaks, each taking a row out, found six caught by the two witnesses and five not, and each of the five was a different kind of answer. The value of an assignment, (t = big), had no witness, and has one. Which side of a call narrows cannot be seen in a program Phoenix compiled whole, since both sides are its own, so tests/abi/ took short in both directions: a callee here that did not narrow at return was caught by a caller cc compiled, and a caller here narrowing what came back changed nothing, since a callee cc compiled narrows, as Apple’s arm64 says it does; that was taken out. Two have no witness and cannot: a parameter stored as four bytes and read as two, and data wrapped to sixteen bits, which only a constant out of range needs and cc warns of every one; each is kept with a note. 638 → 639 checks.

6.15’s third part, _Bool, and its question answered by a mark. A _Bool is a byte that is 0 or 1 whatever it is given, so each place a value becomes one has to test it rather than cut it, and its type text was an unsigned char’s, so no place could tell. The text took a seventh field, B, which no other type has and which leaves the fifth and sixth, a struct’s and a function’s, empty, so nothing that reads those changed. Nine places ask it. The one that needed more was an argument: a callee cc compiled counts on being handed 0 or 1, so the caller converts, and the list a call reads of its parameters, which said only which were longs, says l, b or nothing now; the one clause that read it as a boolean reads a letter. The witnesses gave every conversion a value whose low byte is zero, 256 and 2^32, so that a narrowing where a test belongs prints 0 where cc prints 1, and the breaks bore it out: each conversion point taken out failed a witness, once a static _Bool initialised alone was added for the one that did not. 639 checks.

6.15’s fourth part, enum, and a question settled with Hans before it was built. Probing cc for the witnesses had found that an enum with no negative constant is an unsigned int here, and enums.c asks it. That makes an enumeration’s type depend on its constants’ values, which are constant expressions, and constant expressions are worked out by the constants pass, after the types pass that needs the type. Proem’s enumerators are 1u << n, so the values are real expressions. Three ways were put to Hans: work enumerators out where they are declared, in the locals pass; make every enumeration an int and depart from this cc; or prototype both. He chose the first, and it is the one place a constant expression is worked out before the constants pass: numbers in any base, earlier enumerators, parentheses, unary -, + and ~, and + - * / % << >> & | ^, in exact integers, an operand past 2^32 or a shift past 31 not worked out so that nothing traps, and the value checked against an int’s range. An enumeration’s key is int or unsigned int, so its objects are that type through the tag table structs use, and each constant is a name whose entry carries its value.

Reading it for how it could be wrong found that a file-scope enumerator looked its value’s names up where the globals are not, so enum e { A = x } said x was not declared, which is not what is wrong with it; and two missing witnesses, an enumerator in hex and octal and ++ of a constant. The suite found one more: an octal constant with an 8 in it, which the types pass refuses in its own words, stopped the compiler when this part read it in base 8, and is not read now. And since the working out runs at every Binary in a program and not only in an enumerator, unsigned-folds.c, which multiplies two numbers near 2^32, overflowed the notation’s sixty-four bits until an operand past 2^31 was not worked out. Fourteen breaks, all caught. 6.15 closed with it, its four witnesses agreeing and every refusal it listed held, and pending/ is empty again. 639 → 649 checks.

2026-10-02: escapes, and strings side by side

6.16 opened for the lexical row, cut to what Proem stops at and what shares its question. Proem’s lib/ and driver/ were read for every escape they write: '\r', '\v' and '\f' once each, '\0' as the only octal escape, no hex escape at all, and a diagnostic written as two literals side by side in every file that reports one. The octal and hex escapes are in the entry anyway, because they and \0 in a string are one question, how many bytes a literal is, and answering it for \0 alone would answer it wrongly for "\01", as the journal said on 2026-09-22.

The assembler was asked before it was relied on, as it was for \': one escape at a time in an .asciz. It refuses \v, \a and \?. It reads at most three octal digits, as C does, so \0135 is \013 and a 5. It reads every hex digit after \x, as C does, and keeps the low byte where C refuses the value. So the octal escapes can be handed over as they are written, and a hex escape can once its value is checked.

Part 1 was the six simple escapes, two characters and one byte each, so the length rule held and only the lexer, the table of codes and the emit pass changed. \v and \a go to the assembler as \013 and \007, and \? as a question mark; each rewrite is made with the escaped backslashes set aside, as \'’s is, or "\\v" would become a backslash and a vertical tab. Four breaks, all caught.

Part 2 joined literals side by side, which the grammar does now, string { string }, with the node’s text all of them written together and "" where they meet: two characters, no byte. The emit pass writes each piece as a string of its own in one .ascii, with the NUL after the last, because "\1" "2" run together is \12, one byte where C has two. One step I wrote was not needed, and a break showed it: setting \" aside before counting the "", for the case "q\"" "r". Three quotes in a row hold exactly one pair whichever two are taken, so the count is right either way; the emit pass does need it, because there which two are taken decides where the text is cut. _Static_assert took a joined message as well, since cc does and the parse stopped on one. Six breaks, four caught, one a step removed, and one that needs part 3’s witness.

Part 3 was the octal and hex escapes, and the entry’s question. An escape had always been one byte less than its spelling; these are two to four characters, and more in hex, and the notation still cannot walk a string. So the locals pass works each down to one marker by substitution, a digit at a time: \x to one control character, it and a hex digit to the next, and so on, and the octal ones likewise, from a \ and a digit, with a separate line of markers for a first digit of 4 to 7 so that three digits from there, past 255, show as a marker of their own. After that every escape is one character or one backslash, and the old count holds. It is about a hundred join(split(...)) deep, generated by a script and pasted, which is the notation saying again that it has no loop over text. A hex escape of more than two digits is refused by name, because C reads every digit and allows more only as zeros, and a bound on the substitutions needs a bound on the digits.

Three mistakes of mine, each caught by running something. The first hex stage counted from \x, so the marker I took for a third digit was the second, and the witness’s own \x41 was refused. A check in the types pass read the attributes of its own clause, which a check runs before, and phx said so; the character constant’s checks moved to constants. And a hex digit fed to int(..., 8), because lookup evaluates every argument, so each conversion is guarded by what it converts. Breaks found what the witness did not hold: a digit dropped from a stage went unseen where the witness never wrote that digit there, so numeric-escapes-every-digit.c is generated, as char-constant-every-one.c was, every digit in every place a stage looks for one; it had to be extended once, for a first octal digit with a second after it. The two stages that only find refusals are held by the digits their refusal programs write, not by every digit. Twenty-seven breaks over the three parts, twenty-four caught.

A literal with a numeric escape goes in __TEXT,__const, since the linker cuts __TEXT,__cstring at each NUL and may share what follows one; cc moves a literal with a NUL there too. A break that left them in __cstring was caught, and showed it: "\08"[1] read 1 and not '8', the bytes after the NUL being some other literal’s once linked. 6.16 closed with it, its three witnesses agreeing and every refusal it listed held, and pending/ is empty again. 649 → 656 checks.

Proem’s source was run through the check driver, which no entry had done; each had been chosen by counting words in it. Every file was put through cc -E and then check. With Apple’s headers, none reached a line of Proem: long double in <stddef.h> and a union in Darwin’s __mbstate_t come first, in every program that includes <stdio.h>. So the second run used stub headers declaring only what Proem uses, written in the subset, which cc -pedantic compiles Proem with cleanly, and with va_start left to a builtin the subset does not have, so that defining a variadic function shows rather than being stubbed away. Two files were accepted whole. The other eight stop at four things, and three of them were on the list already: va_start, a function returning unsigned char, and ?: with NULL. The fourth, and the commonest, had been written down and lost: T *p = calloc(n, sizeof *p), a name used in its own initialiser, which C11 6.2.1p7 allows and the locals pass refuses, because it binds a name at the end of its declaration and not its declarator. COMPLETED said so on 2026-09-23 and a refusal program held it as a refusal, and no list carried it after that day. A word count would not have found it, since nothing in it is a word the subset lacks.

The own-initialiser fix was a question of what is known on the way in. The locals pass bound a name on leaving its declaration, after the initialiser, because the entry it binds holds the type text, and the type text of a declarator in parentheses is worked out by its Paren children on their way out. Without parentheses it is the stars and the base, and the base is the specifiers’, a sibling walked already, so a plain local can bind on the way in; a down on the env thread is what sets it for the initialiser. The slot has to be claimed on the way in too, which the first sketch missed: an initialiser can take frame bytes of its own, a compound literal, so the late entry’s offset would have moved past them and named a different slot from the early one. The frame thread is set past the variable before the initialiser is walked, and the witness takes the variable’s address inside its own initialiser beside a compound literal and compares it after. Every other form, braces, static, a global and parentheses, sets a thread naming the declaration, and a Variable that finds no entry under that name is refused by name rather than as undeclared, which would be false. Five breaks: four caught. The fifth, a wrong star count in the early entry, has no witness a defined program can give: the count is read only to ask whether a value is a struct, and the one program that reaches it reads an uninitialised local. One full run failed in the Solveig leg, on two programs printing nothing, with Solveig untouched since 2026-09-18; the leg run alone agreed on all 82, and the cause was not found. The next full run is what the count stands on. 656 → 659 checks.

The probe was run again with the fix in. Of the four files the own-initialiser stopped, three are accepted whole, and source.c stops at has_id ? &id : NULL, the same ?: with a null pointer constant that stops output.c. Five of Proem’s ten files are accepted now, and what stops the other five was already on the list: ?: in two, va_start in two, a function returning a char in one.

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

The obstacle COMPLETED gave for it had gone two days before anyone looked. On 2026-09-26 the null pointer constant was held out because the check is in types and whether a thing is a constant 0 was the constants pass’s answer, which runs after it. On 2026-10-01, for enum, the locals pass began working out ev and eok, an integer constant expression’s value and whether it is one, because types had to know an enumeration’s signedness. That is the question C11 6.3.2.3p3 asks, in the pass before the check. So a null pointer constant is one attribute with a default, npc, true where eok holds and ev is 0 on something of no pointer and no struct, and true on a Cast to an unqualified void *, with no parentheses in its declarator, of such a thing. (void *)0 matters more than the bare 0: it is what NULL expands to in Apple’s headers, so it is the form Proem’s seven uses reach the compiler in.

Choose takes every part of its type from one arm, and which arm is now useo: the second, when it is a pointer and the first is a number or a null cast to void *. Each attribute that read $then reads through it, the decay of an array or a function included. The refusal of a pointer beside a number asks whether the number is a null pointer constant. The first draft asked whether either arm was, and so let c ? (void *)0 : 1 through. The new refusal program choose-void-zero-or-one.c caught that before the suite ran.

Thirteen breaks, eleven caught at the first try. Two were not: taking the second arm’s function decay from the first, and its const levels. The first wanted a call through the answer, (y ? 0 : twice)(6), which went into choose-a-null-pointer.c. The second wanted a write through it, refused because the pointer is to const, which is choose-a-null-or-a-const-pointer-assigned.c. With both, all thirteen are caught. ROADMAP 6’s fix for ?: taking its const levels from its first arm only is narrowed by this, not closed: beside a null the levels are the pointer’s, and between two pointers they are still the first’s.

The probe was run a third time, and its stub headers had to be written again, since the last ones were in another job’s scratch: a NULL of ((void *)0), va_list left to the builtin, and what each file calls declared in the subset, with cc -pedantic -Wall clean on all ten. output.c and source.c are accepted whole, where the build before this change stops both at the ?:. Seven of the ten are accepted. expr.c and pp.c now show their stop as __builtin_va_list in a typedef, which is the same missing feature reached one line earlier than va_start. 659 → 664 checks.

A function returning a char was refused only because nothing narrowed it, and short had since built the narrowing. The refusal dates from when a char result would have come back in a register with whatever the callee’s arithmetic left above its low byte. ROADMAP 6.15’s second part made a short return sign- or zero-extend w0 before the jump to the epilogue, as Apple’s arm64 has a callee do, and the char row of that table is one more entry: sxtb, or and #255 for an unsigned one. The one care is _Bool, also a byte, which retbool makes 0 or 1; narrowing first would turn 256 into false, so a _Bool is left to it. Five breaks: four caught, by return-a-char.c and by the ABI test, which now returns both kinds of char across cc both ways. The fifth, letting a struct of one byte reach the narrowing, changes nothing a program can see: a struct returns through its own path and never reads it.

The probe of lexer.c goes 364 lines further and stops at ?: again, len <= sizeof small ? small : malloc(len): an array of char beside the void * that malloc returns. C11 6.5.15p6 makes the answer a void *, and this subset refuses two kinds of pointer by name. It is not a null pointer constant, so the morning’s change does not reach it. It goes on ROADMAP’s expressions row as the next stop. 664 → 663 checks, one refusal having left.

The open fixes were sorted by what each costs, easiest first, at Hans’s asking, from where each sits in c.phx and not from a count: the char return, an enumerator from a character constant and the escape witnesses at the bottom, because the machinery for each exists; the function-pointer check at an argument and a return, binop add;, int x, f(void); and the other forms of a name in its own initialiser in the middle, because each is a new binding or check on an existing pattern; and () as unprototyped, the const levels of ?: between two pointers and the limits the notation sets, at the top. The void * case of ?:, found by the probe that afternoon, went to the front, since it is the one stop between lexer.c and accepted.

?: with a void * beside a pointer to an object was begun and not finished, and is uncommitted at the close. Two defaults join npc in the types pass: fptr, whether a value is a function or a pointer to one, read off its text as the initialiser’s check reads it, and vptr, whether it is a void * that is neither. Two things the notation said on the way. A check may not read an attribute its own rule computes, so these could not be attributes of Choose; they are defaults every node has, and Choose reads them off its arms. And a default reading xlv has to be declared after xlv’s own default, which is why they sit below it and not beside npc. The answer’s const is the void *’s own, not the two pointees’ together as C11 6.5.15p6 has it: a void * is not written through and a dropped qualifier is not refused here, so no program could tell. With it, lexer.c is accepted whole, eight of Proem’s ten files. Eleven breaks were tried: nine caught, and two not, a missing guard against a pointer to a function in useo and in vptr, which may be breaks no program can see, since that pairing is refused anyway and a pointer to a function has no void tag. That is the first thing to settle tomorrow. The full suite also shows that choose-a-pointer-or-a-const-void-zero.c is no longer refused as two types: a const void * beside an int * is now a void * answer, as C has it, and the program stops at the * of it instead, so that witness has to be rewritten, not its message moved.

The void * case was finished after the closeout, in a session that began from a crash, with the work still in the tree as the standup had left it. The two breaks that no witness caught turned out to be guards no program can reach, and were taken out rather than kept. vptr had asked not $fptr, and a function or a pointer to one never has a void tag whatever it returns, so the guard repeated what the tag already says: written in or out, a void function and a pointer to one were refused on either side alike. useo had asked that the first arm be no pointer to a function, and a ?: with one beside a void * is refused, so which arm its answer took is read by nothing. The witness the standup said to rewrite did not need it: choose-a-pointer-or-a-const-void-zero.c still tells a null pointer constant from a const void *, since the first makes the answer an int * that can be read and the second a void * that cannot, which cc refuses too, so only its message moved. Which arm the answer is can be shown only by a refusal, since a void * can be neither read nor counted in here, so useo’s new arm has its own: choose-an-int-pointer-or-a-void-pointer-read.c reads through an int * beside a void *. Ten breaks, ten caught. With the change, lexer.c is accepted whole, eight of Proem’s ten files, where HEAD before it stops at the ?:. 663 → 669 checks.

The probe’s stub headers went into the repository, at Hans’s word, in languages/c/tests/proem/, with a run.sh that does what had been typed by hand three times. They had been written in a job’s scratch twice, once on 2026-10-02 and again on the morning of the 3rd, when the first set was in a scratch no longer to hand, and the second set survived a crash only because the job’s directory outlived its session. The headers are the same declarations, one to a line, each file saying what it is for; the probe gives the same eight accepted and two stopped through them. Two choices are written into the script’s header. Every file must pass cc -std=c11 -pedantic -Wall against the stubs before Phoenix sees it, and a file that does not is reported as the stubs falling short, not as a stop, so a missing declaration cannot pass for a missing feature. And it is not part of make test, since Proem is not in this repository and CLAUDE.md promises a suite that needs nothing outside it: it reads a checkout, beside this one by default.

2026-10-04: an enumerator from a character

The easiest of the fixes first, in the order Hans set on 2026-10-03. An enumerator’s = is worked out in locals, as ev and eok, because types has to know before constants runs whether an enumeration is signed. A character constant’s code was worked out in types, one pass too late, so A = 'a' was refused by name. The refusal’s reason was only where the rule happened to stand: the rule reads nothing but the constant’s text. It moved to locals, beside the string escapes that pass already reads, with ev its code and eok true; types and constants read code from it as before, and the arm64 emit reads it unchanged.

enumerators-from-characters.c gives an enumeration letters, escapes, hex and octal, and a difference of two characters, and '\xff', which is -1 here as in cc and so makes the enumeration an int. Two breaks, ev from the unsigned cval and eok false, were both caught by it. enumerator-from-a-character.c was its refusal and is gone. 669 → 668 checks.

The escape witnesses, the second fix. On 2026-10-02 the two stages of a string’s escapes that only find refusals, a third hex digit and a third octal digit after a 4 to 7, were held by the digits their refusal programs happened to write, not by every digit, and the closeout listed it. A digit left out of either stage is not refused: it is read as a character of its own, and the string is accepted a byte longer than cc makes it. One refusal in a file cannot show that, since any other line’s still fails the program, but check reports every refusal with its line, so string-escapes-every-third-digit.c writes the 22 hex digits and the 32 octal cases one to a line and the check in tests/run.sh wants lines 5 to 26 refused as hex and 27 to 58 as octal, exactly. cc -ferror-limit=0 refuses all 54 lines too. Thirty breaks, each third-position pattern in c.phx sent to a marker that never occurs, thirty caught. 668 → 669 checks.

The function-pointer check at an argument and a return, the third. 6.14’s fourth part put the check at an assignment and an initialiser and left these two, a refusal missing and never a false one. The check is the initialiser’s four clauses copied, with the place’s level text taken from somewhere new: a return reads the function’s own, rlv from locals, handed down as glv; an argument reads its parameter’s from a new table, plvs, bound beside pwidths in the three places a signature is, and is checked in the constants pass, where an argument already knows its position, guarded so that one through ... is not compared with anything.

It found a false refusal on the first probe. Proem’s output.c, one of the eight accepted, stopped at proem_pp_on_file(pp, NULL, NULL): NULL in the stubs is ((void *)0), as Apple spells it, and the check refused it as a pointer to something that is not a function. So had the old ones, at =, a local’s initialiser and braces, which no program had tried: C11 6.3.2.3p3 makes a 0 cast to void * a null pointer constant, and cc -pedantic takes it into a pointer to a function. npc, from the ?: work yesterday, already said which values are one, and the clause now asks it. A copied check copies its mistakes, and only a real program found this one; the witness, function-pointers-passed-and-returned.c, gives (void *)0 at every site and calls through the pointers it can.

Fifteen breaks: each of the eight new clauses dropped, and npc dropped at each of the seven sites; thirteen caught. The two missed were a global’s and a static’s initialiser, where the constants pass refuses (void *)0 for any pointer before it could matter, so the clause was a guard no program reaches and was taken out, as the standup said to do. That refusal is false too, since a plain 0 is taken, and it goes on ROADMAP’s initialisers row. A comment of mine wrote (void *)0, whose *) ended it, and the description stopped loading; the comments say “a 0 cast to void *” instead. Proem is eight of ten again. 669 → 677 checks.

The fourth fix, and a wrong answer found on the way. The standup’s line was “?: in a constant expression with const operands”, and nothing else in the records said more. Hans confirmed the reading: an enumerator’s value, the ev and eok of locals, took no ?:, comparison, !, && or || even when every operand was a constant, which C11 6.6p6 makes an integer constant expression; and since npc reads the same two, 1 ? 0 : 0 was no null pointer constant either.

Before writing it, a probe of what ev answers beside an unsigned number found wrong code: ev is exact, and C converts an operand beside an unsigned one to unsigned, so enum { A = -1 / 2u } compiled to 0 where cc gives 2147483647, and -2 % 3u to -2 where it gives 2. No test had an unsigned constant meet a negative one in an enumerator. Hans chose to refuse it by name rather than build unsigned arithmetic here: eu says a value is an unsigned int, a u or a hex or octal constant past an int, and eun that an unsigned answer would differ from the exact one, which is when an operand or the answer beside an unsigned is negative, or the answer passes 2^32. The enumerator refuses eun in its own words. Where nothing is negative and nothing wraps, exact and unsigned agree, which is every use in Proem: 1u << 0 and |.

Comparisons are Compare nodes, not Binary, which the first draft assumed and the probe showed when 1 < 2 was still refused; they got a rule of their own. Twenty-one breaks first, fourteen caught. Of the seven, two were guards no program reaches, eok on - and ~ of an unsigned, since eun already refuses the enumerator and a negative is never a null pointer constant; they were taken out. One showed the shift clause asked the wrong thing, a negative left side, which only a child already marked can be, where what can happen is passing 2^32, (3u << 31) >> 31; that had been refused already by the operand bound, so it was never wrong code, and it is now refused as unsigned. Four wanted witnesses: a variable as a ?:’s condition, eu carried through + and through ?:, and < and <= on equal operands. With them, and -1u alone as the negated witness, twenty-five breaks, twenty-five caught. A cast and a sizeof in an enumerator are still refused by name, and are on ROADMAP’s types row with the unsigned case. 677 → 685 checks; Proem still eight of ten.

The fifth fix, its first part: a name in its own initialiser in braces, a static and a global. On 2026-10-02 LocalInit began binding the name on the way in, so that T *p = calloc(n, sizeof *p) worked, and the other four kinds of declaration with an initialiser were left refused by name through ownname. Each of the four only needed the same early entry. A static’s label is what it is on the way out, because only a static declaration moves statics and no initialiser holds one. A global’s goes into the env its initialiser is walked with, not into globals, so the checks of a second definition still see only what came before. LocalAgg claims its frame slot on the way in, as LocalInit does, because braces can hold a compound literal that takes frame bytes of its own. An array in braces stays refused, since its size can be what the initialiser counts, and so can the declarator in parentheses.

Two mistakes of mine, both caught at once by the description refusing to load: a label written "{}.{}.{}" of $fn, $name, $statics inside a list, where of takes the list’s commas as its own, and a down dstars and down dbase added to rules that already had them. Eight breaks: early off in each of the five rules, and LocalAgg’s three slot clauses; seven caught. The byte count LocalAgg hands on has no witness, as the frame count in Sig has none, since a slot counted twice makes a larger frame and every offset still right; it is kept, and the comment says so. own-initialiser-braces-static-global.c puts each beside a compound literal and reads back the address and the sizeof; the three refusals it replaces are gone. 685 → 682 checks; Proem still eight of ten.

The fifth fix, its second part: int x, f(void);. It was a syntax error, since a file-scope declaration’s list took only a global’s declarators. The first try put a Prototype in the list with the specifiers as its ret, and the notation said no: a group inside a rule cannot name the rule’s labels. So it is a node of its own, FnDecl, a prototype whose return type is the base the Decls hands down, as every other declarator’s is; the 77th. Its three rules are Prototype’s copied with $ret read as $base. The copy was made by text substitution, which also turned $rets into $bases; the description said so at once. list-function is a rule of its own because it is a scope in %names, as function is, so a parameter named like a typedef hides it only to the ;; it is tried before a global’s declarator at each place, which would take the name and stop at the (. The parse error of function-returning-a-function.c gained a , among what it expected.

Nine breaks, and the first run missed three: the sigs, rets and plvs bindings, because every function the witness declared in a list it also defined further down, and the definition binds the same entries, which the types pass is handed whole. A function only declared in a list is either the C library’s or a refusal, so the witness calls malloc declared beside a global, and two refusals pass a struct parameter a number and a pointer to a function of another type. Nine of nine then. In a block, int x, f(void); is still a syntax error, and ROADMAP’s declarations row says so. 682 → 686 checks; Proem still eight of ten.

The fifth fix, its third part: binop add;. A Global whose base is a typedef of a function’s type, with no stars, parentheses or brackets of its own, is fnty, and is a prototype: it binds no global and the emit writes no storage for it, and it copies the typedef’s entries in the five tables a call reads, which the typedef’s Sig bound under its key, to its own name. Two things the functions pass did for every other prototype it cannot do for this one, since it runs before any typedef’s parameters are known: count them to check a function’s declarations agree. So a new thread, tdfns, keeps each name declared this way with its count, and Prototype, FnDecl and Function check theirs against it in locals; the Global checks its own against a declaration before it.

The count is kept in tdfns and not read back from funcs, because the first version read funcs in Function, which binds its own entry with a down clause, so the check compared the definition with itself and never fired; what fired instead were the checks of which parameters are structs and which longs, whose words are about one parameter against another and were wrong for a count. Those two now ask only when the counts agree, in all three rules, so a count that differs is said once; a check in run.sh holds that it is. The checks that an object’s type is complete skip a function, since a typedef of one returning void * has void for its base’s tag, which the probe found as alloc malloc; refused as void.

Nineteen breaks; the first run missed two. pwidths showed nothing because wide was defined below main, and the definition’s entry is the one the emit reads, so the witness calls labs through a typedef with a negative int; and the Global’s own return-type check was masked by the Prototype’s when the prototype came second, so its refusal puts it first. Nineteen of nineteen then. The old refusal of binop add; is kept, moved into a block. 686 → 696 checks; Proem still eight of ten.

The sixth fix: () as no prototype. A declaration int f(); was read as int f(void), so int f(); int f(int a) { ... } was refused as two arities, which C11 6.7.6.3p14 allows and cc compiles. () is now its own alternative in a prototype and in a listed declarator, ahead of params, which would take it as an empty list, and the node’s variadic is 2, unprototyped. The call’s two arity checks already ask for 0 and 1 only. In locals it binds [0, 2] and empty lists where nothing is bound yet and never over a real prototype; whatever comes after it binds over it. At the call, the types pass gives an unprototyped callee one blank width per argument, $args.blank, since each cannot write a node, so the emit counts every argument as named and puts each in a register. Apple’s arm64 puts only what follows a ... on the stack. cc’s own build of the first test segfaulted, because it declared printf with (), and a variadic function called without a prototype is undefined.

It found a hole in the arity check, by a break that missed. The functions pass compares the number of distinct shapes with the number of distinct names, and an unprototyped declaration added a name with no shape, which made room for another name’s second shape: int puts(); beside int f(int); int f(int a, int b) {} passed. An unprototyped declaration now counts no name either, and two refusals hold it, one per form. Two witnesses were also too kind at first: puts, one argument, showed nothing of the registers, since the stack machine leaves the last argument in x0 anyway, so the oracle calls strcmp declared with () in a list; and a struct to an unprototyped function was refused as “through ...”, which it is not, so the message says which. Sixteen breaks, sixteen caught. () in a pointer to a function, int (*f)(), is still read as no parameters, on ROADMAP. 696 → 701 checks; Proem still eight of ten.

After the closeout, the first fix of the standup’s list: an integer cast to a pointer in data. int *p = (void *)0; as a global’s or a static’s initialiser was refused as not worked out before the program runs, since the constants pass settled no cast to a pointer, and a plain 0 was taken. C11 6.6p9 makes any integer constant cast to a pointer an address constant, so the fix is that and not the null pointer constant alone: a cast to a pointer is settled when its operand is, keeping all sixty-four bits, where a cast to an integer is cut to its type’s width; a pointer cast to another pointer keeps its number too, so (char *)(void *)0 is settled. The emit already wrote a settled value as the number. The types pass’s check that a pointer to a function is given a function had let a 0 cast to void * through everywhere but a global’s and a static’s, on the ground that constants refused it there first; the ground went, and the two checks ask npc as the rest do.

What had kept this safe was that no value with a pointer’s type was ever settled, and the comment on why said so. Now some are, so everything that takes a settled value as an integer has to ask that it is not a pointer: Binary and Compare asked already, and a cast to an integer, !, &&, || and the condition of ?: did not. Without those, int x = !(void *)0; would have been folded. cc takes all five as an extension and C11 6.6p6 and p8 do not, so they stay refused as they were, each held by a refusal. The witness, pointers-cast-from-numbers-in-data.c, has each form in a global, a static, an array and a struct in braces, and a pointer to a function; and (int *)(1u - 2), which is what catches a pointer cut to thirty-two bits with its sign put back. Ten breaks, ten caught. 701 → 706 checks; Proem still eight of ten.

The second fix: a name in parentheses. int (x) = 3; was a syntax error. Every declarator rule spells its name as n:name, a factor of the alternative, because %names binds the factor an action fills a hidden name from, the moment it is matched. That is any factor whose value is text, not only a token, so a rule dname = name | "(" n:dname ")" -> $n answers the name’s text and binds as name did, from the last ), which is where C11 6.2.1p7 puts the name’s scope. It replaces n:name in the five object declarator rules, the inner name of their pointer parentheses too, so int ((x)), int (x)[2] and int (*(p)) are each the declarator written without the extra parentheses, which say nothing of the type and leave no node. T (T) = sizeof(T); hides the typedef as T T does.

What this did not do is the other half of the standup’s line: a name in its own initialiser where the declarator has a * in parentheses, int (*f)(void) = ...f.... Its type text is the Paren children’s, worked out on the way out, and the early entry is made on the way in, before them; making it after them, before the initialiser, means each of the five rules with an initialiser handing its own kind of entry to a Paren. It stays refused by name, and the message now says a * in parentheses, since a name alone in them is bound early like any other. One refusal’s parse error moved: int (f(void))[3]; reads (f as a name now and stops at the second (. names-in-parentheses.c joins the oracle. Seven breaks, one per rule, the recursion, and the inner name: seven caught. 706 checks, unchanged; Proem still eight of ten.

The third fix: () in a pointer to a function. fn-suffix takes () as its own alternative now, variadic 2, as a declaration’s took it this morning, so a pointer, a typedef, a cast and a parameter each say no prototype, and a call through one already read the key’s arity and passed each argument in a register. The grammar was one line. The rest was the type check a pointer to a function is given, which compares four things a call reads, the count among them, so int (*f)() = twice; was refused as another type. C11 6.7.6.3p15 makes the two compatible when they return the same type and the prototyped one is not variadic and has no parameter its promotion changes, and cc agrees, refusing char, short, _Bool and ... and taking a struct. The check is seven copies of one text, so it was rewritten by a script that found each comparison, read its two keys, and wrapped it, which keeps the seven the same; char, short and _Bool are three level texts looked for in plvs, which the types pass now reads as constants did.

A typedef was the other place. typedef int F(); F add; copies the typedef’s entries to add and records its count in tdfns, so the later definition with two parameters was refused as a second count. A () says no count, so the three checks against tdfns skip it, and the copy is made only where the name has none, as a () prototype binds: long labs(long); G labs; with G returning long and no prototype keeps the prototype, and the witness calls labs(-5), which loses its sign extension if the copy wins. Fifteen breaks: the alternative, each of the three narrow types, the variadic test, the result’s equality, each side of the rule, the three tdfns skips, the copy, the Global’s own count check, plvs in types, and StaticInit’s copy alone, which needed a static pointer added to the witness first. Fifteen caught. 706 → 713 checks; Proem still eight of ten.

The fourth fix: functions declared in a block, and a parameter of a function’s type. The parameter was the easy part: C11 6.7.6.3p8 makes binop f a pointer to a function, as an array parameter is a pointer to its element, so its level text gets a p level in front, beside adj. A member of a function’s type is no C, 6.7.2.1p3, and cc refuses it; only its words were wrong, saying C allows it. int f(void); in a block is a FnDecl, read by a statement alternative that tries list-function before a local’s declarator, as a file-scope list does. The first run lost x of int x = 4, add(int, int);: a FnDecl resets env, scope, bytes and pindex for its parameters and never put them back, which file scope could not show. It puts them back now, as Sig does, and in a block it puts its name in the block’s scope, so a local of its name after it is declared twice.

The functions pass counts a function’s declarations at file scope and does not walk a body, so a block’s declaration is not counted there. That is what tdfns was made for this morning, for a typedef’s function: a count the functions pass cannot see, which each declaration checks against. A block’s FnDecl records its count there, and checks its own against what funcs holds already, which covers a definition before it. binop add; in a block is a Local that copies the typedef’s entries, takes no slot and binds no local, the Global’s way; its attributes are downs, since a check cannot read what its own rule computes on the way out. A function declared in a block where a local of its name is in scope is refused by name, since the name would be found as the local; and one declared in a block is still known after it, since funcs has no block scope, which cc refuses and this does not, on ROADMAP.

Twenty-three breaks, and the first run missed seven. Two have no witness and cannot: putting pindex back, which nothing after it reads, and the Local taking no slot. One was dead code: a static-list rule for the static form, which specifiers read already, since they take the word; it is gone. Three were witnesses too kind: labs declared through a typedef in a block is called with an int and with a 64-bit answer, which is what shows its widths and its return type copied, and a refusal holds a definition after the block’s declaration with another count. And the storage-class refusal in a block printed the file scope’s words too, which a refusal that looks for its own words cannot see, so a check holds that they are absent. Twenty of twenty then, the dead rule gone and the two without a witness said so where they stand. 713 → 727 checks; Proem still eight of ten.

The fifth fix: ?: between two pointers takes both arms’ const. The answer’s quals were the first arm’s, so *(c ? p : q) = 1 with q to a const int was accepted and *(c ? q : p) = 1 refused. ROADMAP had said the notation has no direct way to combine two texts a character at a time, which is true, and no loop either; but two arms of one type have one number of levels, and a type here has at most twelve stars and a base, so the answer is sixteen positions written out, each the other arm’s digit where it is not 0 and the first’s where it is. slice takes an end and not a length, and gives nothing past the end, so a position past both is nothing and nothing traps. **(c ? pp : qq) = 5 with qq an int *const * is still taken, as cc takes it, which the new oracle program holds.

It first left out a null pointer constant and a void * arm, as the void * case had kept its own const before. No break of either guard failed, and none can: a 0 cast to void * has no const to add, and the answer beside a void * is a void *, which is not written through. So the two guards are gone and the comment says why, which also retires the old sentence about the void * keeping its own. Three breaks, three caught. The first full run stopped the C oracle at its 600 seconds, which it takes about four minutes of alone; the machine’s load was over three, and it finished on the second run. 727 → 729 checks; Proem still eight of ten.

va_list: a variadic function defined. The last stop in expr.c and pp.c, and the one Hans picked after the fixes. Proem writes only va_start, va_end and a va_list handed to vsnprintf, but the builtins came as four, since cc’s headers make the macros __builtin_va_start, __builtin_va_arg, __builtin_va_end and __builtin_va_copy, and va_list a typedef of __builtin_va_list. Apple’s arm64 makes the last a char * and passes everything after a ... on the stack, eight bytes a slot, where the caller’s sp was; this compiler’s prologue pushes x29 and x30 and sets x29 to the new sp, and refuses a ninth parameter, so no named one is ever on the stack and the first after the ... is always at x29 + 16. So va_start stores that, va_arg loads from where ap points and adds eight, and va_end is nothing.

__builtin_va_list is a typedef no declaration wrote, which %names cannot know, so it is a word of its own, tname, beside typedef-name in the six places that take one, and Named gives it the entry typedef char * would have bound. va_copy is an Assign, since a va_list is a pointer. va_arg’s type is worked out as a sizeof’s and typed as a cast’s. va_start in a function without ... is refused, as cc refuses it, and naming another parameter than the last, of which cc warns; va_arg of a type promotion changes, which cc warns is undefined, of void, and of a struct, which cc compiles and this refuses as it refuses a struct passed through .... It worked the first time it ran, and expr.c was accepted whole: nine of ten. pp.c goes on 644 lines, to file_key k = {..., id ? *id : (proem_file_id){0, 0}, path};, a ?: between two structs as a member’s value in braces.

Sixteen breaks, and two missed. va_arg typed as a number showed nothing because the witness stored the pointer in a char * before reading it; it indexes va_arg(ap, char *) now. And loading eight bytes for a four-byte argument has no witness and cannot: AAPCS64 leaves the slot’s other half unspecified, but cc, asked, writes it zero, as this compiler does, so no caller to hand can show it. It is kept, and says so. 729 → 738 checks; Proem nine of ten.

2026-10-05: four fixes from the standup

Hans said “do fixes”, which is the four on the standup that do not need his call: a * in parentheses inside another’s, a name in its own initialiser where the declarator has a * in parentheses, and the two about a function declared in a block. The notation’s limits and the oracle’s 600 seconds were left, as the standup says they are his.

The first fix: int (*(*p)). It is int (**p), since parentheses around stars and a name say nothing of the type, so the stars inside the inner parentheses only have to be added to the outer Paren’s. The trouble is the parse. The name is a factor that %names binds, and the checks refuse a name field filled from anything but one factor whose value is text, so a rule cannot answer both the name and the stars it read around it. What it can do is read the inner (*s before the name as a repetition, o:{ pstar }, each answering its stars, and the )s after it as another, k:{ ")" }, and leave whether they balance to a pass. The second repetition has to take the outer ) too, because a repetition takes all it can and would leave none for a ")" written after it. So the Paren has a field extra, the )s less the (*s less one, 0 in every Paren but these sixteen alternatives’, and the locals pass refuses either sign with a reason that says which. That int (*p)) is now refused there and not by the parse is the one change a program that was refused can see.

Twenty breaks. Breaking all sixteen alternatives at once was caught, but the first try broke them one at a time and nine went through: the program had no typedef of a pointer to a function, no global without a value, no member or static with a function inside, and so on. It has them now, and all sixteen are caught, as are the two refusals and the pstar that counted one star short. Parameters were left: they do not take a name in parentheses either, and the ROADMAP row says so. 738 → 741 checks.

The second fix: a name in its own initialiser, with a * in parentheses. A plain declarator’s early entry is made on the way in, from the stars and the base its specifiers give. With parentheses the type text is the Paren child’s, which exists only once the Paren has been left, and a down clause cannot read a child. But the Paren is walked before the initialiser, its sibling, and env is a thread, so the Paren can make the entry itself on its way out and the initialiser is walked with it. The declaration hands it the fields it knows as a thread of its own, pown, and the Paren adds the three its text gives, the offset, the stars and the text, claims the slot when it is a local’s, and empties pown. LocalInit, StaticInit and GlobalInit hand it; the three *Aggs were given it first, and the oracle program then could not be written, because no declarator with a * in parentheses takes braces here unless it is an array, which is the form still refused. So theirs came out again.

The first round of breaks said all caught, and every one of them was a lie: $O held a command and its argument, and zsh does not split a variable, so each break ran a command that does not exist and failed. Run again, five went through. The ownname change was not needed, since the name is in env before the initialiser reads it, and the reset of pown for the Paren’s own children was not either, since what one in a parameter binds a Sig puts back; both came out. Emptying pown on the way out has a witness once the initialiser holds a sizeof of another type with parentheses, which is a Paren too and would bind the name again with its own type. The stars in the entry have none, and the early entry without parentheses has none either, so it says so; nor has the byte count LocalInit leaves alone, for the reason LocalAgg’s has none. Twelve breaks in all: two found code that was not needed, which came out, two have no witness and say so, and eight are caught. 741 → 740 checks, one refusal gone.

The third and fourth fixes: a function declared in a block has the block’s scope. They are one change. The obvious way, putting funcs back at the block’s end as env is put back, cannot be taken: the types pass reads the locals pass’s tables as they stand at the end, $allfuncs and the four beside it, so a function declared only in a block, puts say, would have no count there, and nor would the Sig keys of every pointer to a function declared in one. What Variable asks first is env, and for a function’s name it makes an entry of its own from funcs. So a declaration in a block binds that same entry in env, which hides a local of its name and which the block already puts back, and a new thread, fblock, marks the names known only from a block, which Variable does not look up in funcs. Every declaration at file scope, a prototype, a definition, a typedef’s or one in a list, sets it false. Both refusals by name went.

Eleven breaks, one of which changed nothing and does not count. Two went through: the FnDecl and the typedef’s Local each fell back to not defined($funcs, $name) for a name fblock did not have, and since every declaration of a function now binds fblock, a name in funcs is always there and the fallback is never reached. It is true now, which says the same thing. Eight caught. 740 → 743 checks.

2026-10-06: the C compiler on hold

Hans decided this morning that the workspace’s C compiler will not be the one Phoenix generates. It will be written by hand in C, in DevTools, which since today is the one project for the Ouroboros development tools: the editor, the preprocessor, the C compiler and those to come. The toolchain document moved there the same morning, from ~/Projects/docs/ to ~/Projects/DevTools/docs/.

So section 6 of the roadmap is on hold where it stands, the next stop included: a ?: between two structs as a member’s value in braces, which keeps pp.c out and Proem at nine files of ten. Nothing is removed. languages/c/ stays in make test, because 354 programs agreeing with cc is the strongest evidence this repository has that a pass-oriented description carries a real language, and that was the claim the arc was also testing. The map of what is left between the subset and C11 stays as written, as the record of where a resumed arc would begin.

What changes is the destination. Ouroboros was to receive a cc that Phoenix wrote; it is now to receive Phoenix, ported whole at some point in the future, with no date. Its compilers are one-file C11, so the port needs only a C compiler and a C runtime there, which are DevTools’s and Ouroboros’s to build. 6.11, the ELF target, existed for the old destination and is on hold with the rest. Ouroboros was told with a notice in its inbox, docs/handoffs/2026-10-06-from-phoenix-c-compiler-on-hold.md, since its roadmap names “a compiler that Phoenix generates” as part of its toolchain.

DevTools was told as well, with a notice of the same name in its own inbox, after the first pass at this entry named only Ouroboros. It is the project the decision hands the work to, and its toolchain document still gives Phoenix steps 1 and 3 of the order and the retarget that was 6.11; the note lists those lines and what it could borrow from here without depending on it: the oracle’s programs and refusals, the map of C11 left undone, and the Proem probe. The workspace’s notice about the document’s move was answered in the same commits: the three links were the ones the hold’s paragraph was already rewriting.

2026-10-09: Proem is cpp again, and Futamura is out of the chain

The day was the inbox. Two notices had arrived on 2026-10-06 after the closeout, and neither asked for anything, but each left a line here untrue.

DevTools gave the preprocessor its first name back the same afternoon it moved in: Proem became cpp, and DevTools/proem became DevTools/cpp, with the driver now driver/cpp.c and the library’s identifiers cpp_.... The probe’s default had been rewritten that morning to follow the move, and it broke again with the rename: run with no argument, it stopped with “no Proem checkout”. It reads ../DevTools/cpp now, and the count is the one it had: nine of ten, pp.c stopping at line 971 on the ?: between two structs, the stop the hold left in place. The stubs named no Proem identifier, so the rename reached nothing in them. The probe keeps its directory, tests/proem/, which the notice left to us: the journal, the postmortem and ROADMAP section 6 cite it by that path, and a name that is history in those records is better left matching them. For the same reason the quotations of proem_... source in them stay as they were printed. The links in ROADMAP and COMPLETED name the current path and keep CPP and Proem as the names it had before.

Two renames in one day, 2026-10-06, each broke a path into another project that this probe reads, and they will not be the last: its default is a guess at where a checkout lives, and it is the guess, not the probe, that a rename invalidates. That is acceptable only because the probe is outside make test, and the notice system is what brought each break here rather than a run.

Futamura’s notice records Hans’s decision of 2026-10-06 that Futamura will not describe the arm64 assembler for the Ouroboros toolchain. The assembler and the linker are DevTools’s, written by hand in C, as the C compiler now is. ROADMAP 6.10’s Outside the grammar had the assembler waiting on Futamura; it no longer does. Both notes are done and in docs/handoffs/closed/. Ouroboros, the one notice of ours still unanswered at the last closeout, has answered it, done, in its own closed/.

2026-10-10: if, and the 1,072 tables it replaced

Hans asked whether the notation has structs and arrays, and then whether c.phx would be easier, shorter or more to the point with mutation, a map or a dictionary. The answer looked at c.phx rather than at the notation. A map is already there: an environment, bind and lookup, and the C description’s tables of linkage, tags and typedefs are written that way. Mutation is there as far as a walk can have it: a thread accumulates, and a down clause gives it a scope that is put back on the way out. Real mutation would break the rule that an attribute is computed once per node, and that rule is what makes a pass checkable, so it was not proposed.

What the count found was the missing if. lookup([[true, a]], c, b) on 418 of c.phx’s 4,854 lines, 876 times, nested, and both arms worked out every time. ROADMAP 3.5 had asked for exactly this to be recognised as the decision arriving, and § 1 of the postmortem had scored it held twice. Hans’s direction was that the C exercise exists to find what Phoenix lacks, so the gap is closed in Phoenix.

The form is an expression, if c then a else b, read where a value can start, with each arm running as far as an expression does. So else if needs nothing, and an if inside an operator wants parentheses, which is how ML reads it. The condition must be a boolean, by the no-truthiness rule not and and already keep. The else is required. Only the arm taken is evaluated, which is the one thing it adds over the table and the thing awk had changed its grammar to avoid needing. if, then and else are words only where an expression starts or an arm ends, so IfElse(then: $t, else: $f) in calc.phx still reads. languages/phx/phoenix.phx, whose grammar reserves every word it uses, had to add the three to word for the same reason it already lists of and down.

Every table was rewritten, by a script, and held three ways. The script parses each argument list and its strings and comments, so that a format in the answer, [true, "{}.{}" of a, b], takes its commas with it, as the reader takes them. It converted 876 in c.phx, 144 in c-arm64.phx, 37 in solveig-sob.phx, 6 in awk-c.phx, 5 in solvm-sob.phx and 4 in z80.phx. Then: make test gave the same lines with the old file and the new, but for the Solveig line counting programs that do not repeat themselves, which moved between two runs of the old file as well; all 354 C oracle programs, run through c-arm64.phx with both, gave byte-identical assembly and byte-identical refusals; and phoenix.phx reads every description. The time across the 354 was 26.1 seconds before and 27.9 after, which is noise, so no speed is claimed for it. One make test run in the middle was spoiled by editing tests/run.sh while it ran: a shell reads its script as it goes, and the edit moved the text under it. It was run again with the tree left alone.

The second gap proposed was not one. It was offered that a pass cannot build a record, and that this is why c.phx’s types are text, a:24:2:4|b:4:0:4. A probe built Ty(kind: "int", size: 3) in a clause and read $t.size, and 2026-09-30’s entry says the text was chosen over a list that read better, by ROADMAP 3.4’s rule. A typed record therefore needs no new mechanism; it needs c.phx’s types rewritten as nodes, which is C work, and waits with the hold. The postmortem has both failures as § 39.

The comments in the Pascal, awk, Solveig and SolVM descriptions that said “there is no if in the notation” now say why each choice is written the way it is, which in most of them was a reason that never needed the absence.

Then repeat, the same afternoon. Asked what else could be improved, the answer was measured in c.phx again, and the cheapest large thing in it was a table: [[0, ""], [1, "p:8:0:8|"], ... [12, ...]], the text of n pointer levels, looked up by n and pasted 65 times, with four more places cutting a twelve-copy string to 8 * n characters. About 48 KB of a 528 KB file. The notation had no way to make anything of a computed length, so the table was what could be written, and it stopped at twelve.

Before replacing it, the edge was tried, as the postmortem’s § 39 had said to do that morning. Twelve levels compiled and agreed with cc. Thirteen were refused, “‘*’ wants a pointer, and this is an int”, which was false: the lookup’s default is the empty text, so the thirteenth star removed every level and left an int. Nothing recorded the bound.

repeat(x, n) makes text or a list’s elements n times over, refuses a count below zero and one whose result would not fit. A script replaced each table after checking every row was the unit repeated its own number of times, and a pattern replaced the four cut strings. All 354 C oracle programs gave byte-identical output again. The thirteen-level program and a forty-level one now agree with cc, 15 and 17, and the thirteen-level one joins the oracle as pointer-thirteen-levels.c, since a fix that nothing holds is one a later rewrite can lose. That lifts a refusal while the C arc is on hold, and it is kept because the refusal was not a construct the subset declined but a wrong answer the notation forced.

And %define, last. The third improvement asked for was the one the morning’s question had really been about: c.phx writes the same expression again and again with a different $name in it, 34 times for the pointer count alone, and nothing in the notation could name it. An attribute shares a computation within a node, and COMPLETED 1.3 settled on that and called everything else a second mechanism. This is one, and it was made as small as a function can be: a name, parameters, and a body that sees only the parameters. That last rule is the one that matters. A body that could read the caller’s fields and threads would be a macro, and mean something different at every call; this one is a function of its arguments, which is checked when the description is read along with its arity, its name against the library’s, and that no define reaches itself.

Measured before named. A script grouped every repeated subexpression in c.phx with its $names abstracted, and the biggest families were all one thing: field k of level n of a type held as text, written out 1,101 times with four different paddings. The paddings were checked to answer the same for every field each one read, and one define, lvfield, took all of them, with nine more in c-arm64.phx. On that, five more shapes became small enough to see and were named: fnkey, layoutof, ptrsof, elemof, declof, 453 expressions between them. c.phx went from 480 KB to 402 KB, and all 354 C programs compiled to the same bytes.

The first run of the oracle failed on every program, and it was lvfield’s own body: "{}||" of $t, "|", where of took the separator as a second value. The original had parentheses for exactly that reason. The message came at run time, which docs/semantics.md says it should not: a template’s holes are counted “when the grammar is read”. They were not. They are now, over every expression a description holds, and nothing here had a template it refuses. Two more checks came out of the same work: a read inside a define’s body counts as a read by every clause that calls it, so the driver’s order check sees through a call; and the library’s list of names, which a define may not take, is held by make test against the names the library dispatches on.

languages/phx/phoenix.phx reads %define. The C types are still text; the defines name the idioms over them, and the change to nodes, when the C arc resumes, now rewrites 320 calls of one name rather than 1,110 spellings of four.

Then the calls, which closed the day. Asked once more what could be improved, a probe answered first: jion(["a"], 1, 2, 3) in a clause read cleanly and failed only when a node reached it. That is the template count again, a mistake the notation can see on the page and left for the walk to find, and in a clause no test input reaches it is never found. The library’s name list, made that afternoon so a define could not take a name, became a table of names and how many arguments each takes, and every call is checked against it, or against its define, when the description is read. Nothing in the repository was refused.

The table is a second statement of what the dispatch accepts, so it is held two ways: its names against the names the dispatch tests, and its counts by the library’s own claims, which call every function at the fewest and the most arguments it takes, so a count too strict would refuse them. A count too loose is still refused by the dispatch, at the node, as before.

Writing it up found a third claim of the same kind, in the reference’s own table of read-time checks: “the wrong number of {} holes, counted when the description is read”, listed under actions. It had never been true anywhere, actions included. The row now says when it became true.

And find. The last of the day’s small library entries, and the second of two recommended after the calls: nothing in the notation asked where one text is in another, or whether. c.phx asked whether by splitting on a word and counting the pieces, 56 times, and asked where in one place only, but 18 times: a struct’s members are one character each of an 86-character alphabet, and a character went back to its number by splitting the alphabet on it and measuring what came before. find(t, part) answers where, from one, or 0, and refuses to look for nothing, as split refuses to split on it.

The decode was the one that needed care. Split-and-measure answers 86 for a character not in the alphabet, and find answers 0. The characters that reach it are the alphabet’s and ~, which the description appends to mean “past the last member”, so memberindex(c) is find over the alphabet with ~ on the end, minus one, which answers 86 for ~ as before. The encode beside it became memberchar(n). The 86-member limit is the alphabet’s length, and stays: it is the encoding, which is C work.

The converted description was held as each before it: all 354 oracle programs compile to identical bytes, and this time all 501 refusal programs were compared as well, diagnostic for diagnostic, before and after. The first attempt to add the two defines failed in the script that wrote them, and the read-time call check, a few hours old, refused every call to them with its position.

And, after the closeout, a sweep for what nobody had looked at. The question was what else could be improved, and the records already answer it for the notation, so the afternoon went to the one thing they could not say about themselves: whether the C under the tool and under what it writes is sound. phx was built with AddressSanitizer and UndefinedBehaviorSanitizer and given the whole suite, then 748 malformed inputs: four descriptions each cut short at 37 points, and descriptions and Pascal programs with bytes flipped, dropped and copied. The generated Pascal compiler and the generated C compiler were built the same way, and the C one compiled all 856 programs in its oracle and refused directories. Nothing faulted anywhere, and an expression nested 200,000 deep was refused at 10,000 by name rather than overflowing the stack.

The first fuzz run reported all 148 of its inputs bad. Every one had exited 127, which is the shell failing to find timeout, because macOS has none. That is the reason tests/limit.c exists, and its own comment says so; the run went through bin/limit and came back clean.

What the sanitizers did not find, a stricter compiler did. Every compiler -o writes failed cc -std=c11 -pedantic-errors, five to nine errors each, and all of one kind: a table the description left empty, its rewrites or embeds or passes or drivers, written as = {}. That is C23’s empty initialiser and an array of no elements, and plain cc takes both without a word, which is all the suite ever asked of it. The same {} is what c.phx refuses in a C program as “C23’s, not C11’s”. The emitter already knew the answer for three of its tables, phx_names, phx_defines and a define’s parameters, which are written {0} or NULL when empty because the count beside each is what is read; the fix was to do the same everywhere. Two written-out compilers are compiled that way by the suite now, calc-c.phx’s, which has no rewrites or embeds, and phoenix.phx’s, which has no passes or drivers, and both checks were made to fail against the old emitter before they were trusted.

Three things in the records had drifted. The roadmap opened by naming three languages and a fourth description; the tree has eight and the notation’s own. ROADMAP 3.5 was a stub and 3.6 a whole entry under “What is deliberately not here”, and both were built, 3.6 on 2026-09-01 with stage 7, so 3.6 moved to COMPLETED and section 3 opens with a table naming the two, as sections 1 and 2 do. Two links still named roadmap sections that had left long ago, 1.2 and 6.9, and every link into a section of either file now resolves. The README’s “Three languages” was left as it is: read in place, it counts the three whose grammars came from somewhere else, which is still true.

And the claim counts. The standup’s open fix, the suite reporting 54 claims from semantics.phx and 82 from library.phx. Counting ! not gave 54 for the first as well, which disagreed with the standup’s 55, until one claim turned up written without a not: ! (false and (1 div 0 = 0)), that and short-circuits. So 55 and 83 are right, and each file lost one only because its first claim shared a line with Case. Case has its own line in both now, as in define.phx; the count itself was left alone, and a claim written on that line again would hide the same way.

The harnesses run at the same time now. Asked what else could be improved, the one answer not already on a page was the suite itself: 450 seconds on a twelve-core machine, every check one after another, and the C oracle four minutes of it alone against the ten each harness is given, a cap a loaded machine had already stopped it at once. The fifteen harnesses are separate scripts, and each writes only under a directory of build/ that no other names, so tests/run.sh starts all of them once the limit’s own checks have passed, behind the same guards their checks have, and collects each one’s output where its check has always been. What is printed, and in what order, is what it was. The same run took 236 seconds at the same load of about eleven, 752 passed.

Diffed against PHX_SERIAL=1, which starts none and is the old order, one line differed: the .sob traces were 43 identical and 40 unsteady serially, and 42 and 41 in parallel. That was not the change. examples/time.sol prints the current time to the second, so its two traces agree only when both land in the same second, and a busier machine makes that rarer. No check asserts the split and no record quotes it.

An interrupt was tried both ways. A Ctrl-C reaches every limit, each passes it on, and nothing was left running. A SIGTERM to the shell alone waits for the collect it arrives in, here the Pascal oracle’s, about fifty seconds, and then the trap stops the rest. The first attempt at that test said otherwise and was wrong about itself: started with & from another shell, the run inherited SIGINT ignored, and sh cannot trap a signal it was born ignoring.

languages/units/ closes the uses graph. The A -> B -> C -> A that divergent/three-cycle.pas had held since units were described was waiting on iteration over data, and ROADMAP 2.5 gave the notation that on 2026-09-23 without anyone writing the pass. It is a reach pass, run until the root’s table settles: each round, an interface reaches the units it uses and whatever they reached the round before.

The first shape was a list of names per unit, and it cannot work. On a cycle each round adds names already there, the list never comes out equal to the last one, and the library has no way to say each name once: positions keeps the first of each, but gives back pairs, and nothing takes a column of pairs back out. So a unit is a bit, bit(i) is int of a one and i zeros in base two, and what a unit reaches is bitor of the bits of what it uses and what they reached. A set of bits only gains bits, so it settles within as many rounds as there are units, and the price is 63 of them, which is refused by name past that.

fpc decides the wording. ub3.pas(3,9) Fatal: Circular unit reference between ub3 and ua3 names the unit being compiled and then the one it uses, and the description had the two the other way round. The two-unit test passed anyway, because both of its uses are refused and one of the two messages has the pair in fpc’s order. Now each uses on a cycle is refused in fpc’s order, and fpc stops at the first. The file moved to tests/, and programs/deep-chain.pas, four units deep with two implementation uses pointing back, is the case that must not be refused, held against fpc. Making reach ignore last round’s table fails three-cycle and nothing else.

Every refusal had come with a second line. ... of a failure complained that it was not a list, naming the line of the description where Init spreads its environment, and it had done so since the refusal was written. Every other place a failure meets the library passes it on, and tests/grammars/one-complaint.phx says why; ... was the one left. The witness needed the spread in a clause of its own, because a template stops at its first failed argument and the spread was never reached. And the check was first trusted against a stale phx: git stash pop left eval.c and the binary on the same second, and make took that as up to date.

Initialisation order, and divergent/ empty. fpc was asked first. Ten shapes of uses graph, each unit’s initialisation printing its name, then two more: all twelve are a depth-first walk from the program, a unit’s interface uses and then its implementation’s, in the order written, each unit entered once and initialised when it is finished. The old description, the written order, got eleven of the twelve wrong.

A walk is a stack, and the tree a pass walks is not the uses graph, so the walk cannot be the pass’s own. The order pass carries it as a state from round to round, a thread steps it once at every node, and until stops it when a finished walk steps to itself. The stack is text, name next; with the top first, because a pop wants what comes after the first entry and slice takes that of text and not of a list. The steps were tried in a scratch grammar, four by hand, before they were trusted inside the pass.

They were right, and the pass was wrong, because of a name. The table of what each unit uses was handed down from the root as uses, and three node types have a field called uses. A field is read first, so at those nodes the step saw the node’s own list of UnitRefs, not the table, and the walk entered ud and found nothing to go on to. No warning: the check that exists is for a down clause naming a field of its own node, and this one was on the root, which has none. Renamed to graph, and written in ROADMAP §5 as a check nobody has built, since it is decidable and has been met once.

Two breaks were tried. The written order back as the answer fails circular-impl and init-order; a walk that forgets what it has entered never settles, and the tool stops it at 256 rounds and says so. circular-impl.pas moved to programs/ with fpc’s D, E, D beside it.

And -Wcast-qual. Seven casts dropped a const the code never needed to drop: Expr and Rule names, field lists, a source’s text, an embed’s, and what value_format hands back, are only read. Declared const, the casts went, and so did the char * the emitter wrote for the field tables of a compiler it writes out, which -pedantic-errors refused once the runtime’s types changed. The Makefile and the suite’s C11 check both ask for it now. Twice in the day a rebuild was skipped because the binary and the source it was built from had the same second for a timestamp, and make, comparing seconds, took it as current. A stale phx was trusted both times, briefly.

The C oracle stopped once, and the cause was the machine. The first full run after the units change failed: the C oracle ran out of its 600 seconds, which failed the run’s count and its log of stopped programs. The load was about 40, from another session’s qemu and leaks, none of it this tree’s. Run again at about 13, the suite passed in 284 seconds. The cap is what the morning’s change was partly for, and it held at the load this machine usually has; at three times that, the oracle alone does not fit, and making it run its own programs side by side is C work and waits.


2026-10-11: a checksum where make compared seconds

The standup carried one fix that needed no decision: make had taken a stale bin/phx as current three times the day before. The make here is GNU make 3.81, the one macOS ships, and it compares modification times to the second. A source saved in the second the binary was linked is not newer than it, so nothing is rebuilt and nothing says so.

Two ways were on the list: a stamp of the sources’ checksum, or make test always relinking. Relinking costs about a second, but it would mend only make test and not a bare make, and it changes what make means. A stamp file alone would not have worked either, since a stamp written in the same second as the binary has the same problem one step further along. So the checksum is compared when the Makefile is read, before make looks at any timestamp: cksum over the sources, the headers, tests/limit.c and the Makefile itself, against bin/sources.sum. When they differ the binaries, the sum and the generated phoenix/runtime.h are removed, and the ordinary rules rebuild them. The sum is written only after phx links, so a failed build leaves nothing that claims to be current.

The witness is the case that went wrong: append a line to eval.c, set its time to the binary’s own second with touch -t, and run make. It rebuilt, where the old Makefile would have said nothing to be done. Restoring the file rebuilt again, and a second make did nothing. The Makefile is in the checksum so that changed flags rebuild too. The cost is one cksum over about 390 KB at every make.

And the warning ROADMAP § 5 had written and not built. The standup left it as Hans’s call, whether one description meeting it once was enough. It was, because when it happens the answer is a well-typed wrong one, and a warning breaks nothing that reads cleanly today.

The first version asked every clause. A clause reading a bare $x at a node type with a field x, in a pass that hands x down from somewhere else, was warned about, and the repository answered with twelve warnings, all in c.phx and none of them a mistake. functions, locals, types and constants all hand a declaration’s specifier down as base, and a Cast, SizeOfType, VaArg, Param or Literal inside a declaration reads its own base field, a type name, as $base.tagname or to hand it on under another name. fn was the same with Call. Twelve to one is a check people learn to ignore.

What told the two apart was where the read was written. A clause in a rule for Cast was written looking at Cast’s fields, and reading one there is what it says. The units mistake was in an otherwise clause, which names no type and runs at every node, so it cannot mean the field at three node types and the table everywhere else. The check asks only defaults now, and only where the default actually runs: a node type whose own rule answers the attribute is left out. The repository is silent, and the units description with graph named back to uses is warned about at the line where the mistake was made, otherwise walk = step($walk, $uses, $outs).

tests/grammars/down-read-at-field.phx holds both halves: a pass whose default reads a handed-down tag at a W with a field tag, which prints the words where root was meant, and the same pass with a rule answering at W, which is not warned about. The suite counts one warning. The tool is 10,193 lines, and the three records that state its size say so.