Ideas considered
Each idea from the notes, with a verdict and the reasoning behind it. The ones recommended against are here so the reasons survive, and so the same idea does not have to be re-argued from scratch in six months.
The trigger each deferred entry names is the rule this page runs on, and method.md states it along with the rest of how work here is decided and checked.
Everything this document said to build has been built, and the entries are in COMPLETED.md. So the verdicts below are read backwards now — not as a queue, but as a record of what was guessed and how the guess turned out.
6.32 arrived here the other way round: it was a roadmap entry, the last open decision, and came back to the idea box on 2026-08-22 without being taken. It kept its number.
Every code sample here has been run against the current build unless it is marked as a sketch.
At a glance
| Idea | Verdict |
|---|---|
do is forEach? |
Yes — and collect is map, select is filter |
| Bytecode / assembly reference | Built — BYTECODE.md, checked against the header by the test suite |
startsWith / endsWith |
Built on 2026-08-30 in lib/text.sol — the trigger had fired three times over: three programs, nine call sites, two independent endsWiths, a defect already caused, and the entry’s nothing is slower wrong by 2000× on a non-match. Including it then caught expect.sol measuring a class it had just extended |
| What a string is — bytes or code points | Defer, and toward bytes with a contract — the editor asked first, and was corrupting a file on $x. Fixed on 2026-08-30 in the editor, which is the argument against making size count characters — though the estimate here was wrong: nine lines became seventeen definitions. A text type with a !"..." literal was asked and answered in the same entry — right instincts, wrong end of the pipe |
!character literals, Unicode |
Defer — gated on deciding what a string is, and that entry recommends settling it against: the character type Unicode would want here is the one-character string the language already has |
| Checking that a link points at a heading | Built, on instruction and not on its trigger — the entry says so. 1,313 links against 1,496 headings in make test on the day it went in. Writing it found two faults in CHANGELOG.md that it does not itself catch, and the throwaway’s one reported finding turned out to be an artefact of its own fence rule |
| awk, as the nineteenth program | Written on 2026-09-01, and the not next call was right about the order and wrong about the finding — the scoping put lib/re.sol first, and that held: full ERE was already built by the time awk started, so the two things left shrank on contact exactly as predicted — a lenient numeric read of nine lines, and %e and %g written in the program, which is where a format belongs. What actually pressed was not on the list: 3.2, wanted three separate times in one file, and the second real customer for an entry that had had one since August. Ten cases agree with /usr/bin/awk and one differs, and five of the six defects the oracle found came from the comparison rather than from a test |
| A truncating divide on integer | Defer — one customer, and its workaround is exact rather than approximate |
| A path with a NUL in it | Defer, and it is a silent wrong answer rather than a missing feature — found by sha256sum on 2026-08-31: a Solum string may hold a NUL and a C path may not, so fileExists and readFile both answered about a prefix and agreed with each other. The reference now says so; the check that would refuse it is small and has one customer with an exact workaround |
| Integer sizes: byte, word, long | No — and it was tested on 2026-08-31 rather than argued again. SHA-256 is defined on mod-2^32 arithmetic and is the first program here to want them; it does not need them, because a 64-bit integer holds the sum of five 32-bit values with fifty-nine bits to spare. The cost of refusing is twenty-three masks in one program |
| Separate float and double | No — same reason, less benefit |
include another file |
Built — was the most valuable thing on the list |
System:exit(code) |
Built — as system:exit, and the system object grew well past it |
| Keyboard input | Both built — readLine, and readKey once it earned its own entry |
| File handling | Built — whole-file, and the filesystem around it |
| JIT to native code | No — possible, and it would dwarf the project; the cheap alternative it named was measured in the end and is slower than the switch |
| More examples covering everything | Audited — the guide was clean; four messages had no demonstration and now have one |
doUntil |
Built in — and inlined, so a definition in Solum would now be bypassed |
| switch / case | Already writable, and now written — array:switch in control.sol, once an interface turned up worth committing to |
#10:repeat({...}) |
Built in — a primitive, measured 3.2x the version written in Solum |
for loop with start/end/step |
Built in — [#a, #b, #step]:loop |
forIn |
It is do |
ifTrue{...} without parentheses |
No — it would teach a rule that does not generalise |
| Performance timing | Built — the clock came with it |
['red,'green,'blue] as an enum |
Works today — document the pattern |
A:with{ :m1(#1). } cascades |
No — chaining already covers it |
Document (group) versus {block} |
Written — in the guide, with your own example |
| Go-style concurrency | No, for now — it changes the whole VM |
Subclass integer, a byte subclass |
Not possible — see below |
More @ directives: @define, @ifdef, @once |
No — each one’s job is already done by something that is not a directive |
Infix operators, @expr(a^2 + b/2) |
Built, on 2026-08-28 — scoped in the morning and in by the evening: arithmetic, then sin(x) once limiting it turned out to be the expensive half, then comparison and logic, and the name with them |
@expr{...}, a region that is a block |
Built on 2026-08-29, the day after it was scoped — the sentence was tried first and lost; the entry predicted one hard part and the second was the one that mattered, a notation that silently stopped inlining |
@with obj { ... }, a region where x := e writes a slot |
Held, with a trigger: asked on 2026-09-14 from engine.sol; same class as @expr and judged by the same sentence: switchless and no .sob change, but it puts a second meaning on := and takes grep 'rect:paint' away from every program. Trigger: the paste bug, or a reading that the long form hid something |
Taking @expr out of solas, now that Parasol is in-tree |
Scoped as a removal on 2026-09-14 and built the same morning as a flag, the third answer: bare solas, solis and solid refuse @expr, --expr turns it on, and a program outside the tree written to the region adds one word to its build line. Parasol already refused @expr because supporting both would be supporting two; three files used it and none was a program; a .psol header reproduces the region instruction for instruction, checked. The entry’s one real cost, the REPL, was not one: solis -e |
| Phoenix — a second language whose output Solum uses | Defer — the machinery is proven three times over; the unexplored half is whether a hosted language can publish a library rather than a program |
| A conformance suite for a second implementation | Built, both halves, on 2026-09-03 — conformance/, 89 cases scored on their bytes with both tools taken from SOL_COMPILE and SOL_RUN, and in make test. Three kinds and not two: a refusal is compile-time and a trap is run-time, which the scoping had run together — 13 of the 15 demonstrations in examples/ turn out to be the machine’s business, not the front end’s. Every answer written from the documentation before it was run. Two findings, both in the documentation: a REFERENCE.md paragraph wrong about onError in both halves, and a self-including file that PRODUCING.md filed as a refusal when it is a warning — it compiles, leaves with 0, and runs. The scoping has the shape; what is left is the five 65,535 limits, which are a generator’s business |
| A generated file that says where it came from | Scoped 2026-09-16, deferred behind a trigger: three shapes counted against the fourteen maps in the tree: solas --map reading the map Parasol writes is recommended, per piece rather than per line, so the .sol stays clean, the .sob format stays and the span half of 3.31 goes with it; a @line directive would be 508 directives on 945 lines; --source-name would put the right file on the wrong line and is argued against outright |
Programs that would press on something — Pascal, predicate logic, a parser toolkit, tail, and which Unix tool next |
Defer, and none needs permission — each is predicted to find one thing, written down before it is written. The editor was written, and found what this page said it would. So was sha256sum, on 2026-08-31, the first off the Unix survey and the first program here with no I/O in its inner loop: the prediction held in both halves and produced the number it was written for — 208 bytecode instructions a byte, 4.3 ns each, 234M a second. And diff on 2026-09-02, where one prediction of four held — the output format, which was the whole difficulty — and the three that did not are more useful than the one that did. And gzip -d on 2026-09-04, the last of the three the survey named and the one whose prediction measured the wrong thing: it asked for the cost of a 32 KB window as boxed values, and the window is 4.8% of the program. And sort the same day, which had been filed among the also-rans and is promoted to an entry of its own: the gap it was predicted to find was not there, because a write is not the reverse of a read — a producer knows what comes next — and what its merge wanted was the ranged read, already built |
| An SQLite file, read and then written, as the twenty-third program | Scoped on 2026-09-15, chosen over a format of our own for the oracle; all six steps were built the same day, and the positioned write was wanted at step 4 and not before, as predicted; the reader pressed on nothing, as predicted: the entry predicted the positioned write is wanted at step 4 and not before, and it was, raised and closed the same day as 3.27, writeFile(path, from, text); that a page as strings is bearable, and it is so far at 0.36 ms a row; and that locking, flushing and a truncate are not found at all. Six steps, each held against sqlite3 and PRAGMA integrity_check |
The database as objects, and object:new(dictionary) |
Scoped on 2026-09-15, the evening the SQL front was done, and built the same night: the entry moved the engine into lib/sqlite.sol, put a table, a query and a row over it, and asked for one slot-creating message at construction, object:new(dictionary), built after a version with rows as dictionaries had run through the sweep, so that the program asked and not the page. The shell became a client of the objects and sqlite3 judged both fronts at once, 632 of 632; UPDATE, added so that update could be judged, found a defect the writer had carried since step 5. Two predictions wrong in a detail: the primitive needs no GC root, and a slot walk past twenty columns loses nothing, since an object has a hash index past twelve |
| Forms over a database, the way VB3 did it | Scoped 2026-09-17, and all three steps built the same day: the entry is the customer the objects entry above was waiting for. The controls are C in the GTK binding, eighteen messages; lib/recordset.sol is Solveig here and knows no widget, so the suite runs it; form.sol binding the two is Solveig in the binding’s new lib/. The prediction it was most sure of was wrong: self is bound into a block and not captured, so 3.1 never fired; what pressed was a column written by a name held in a value, answered by recordset:set |
| A dropdown over a code table, and a form that says where and how big | Scoped 2026-09-18, seven calls answered the same morning and all three steps built the same day: the entry takes the 'choice kind the forms above named and the size and position a form cannot say at all, and makes them one signature, since both are what field answers. Three layers again: setSize and setExpand in the binding, which travel together because a size request under a control that expands is invisible; a codes read of a code table here, where the suite can run it; and in the binding the kind, field:size, field:at and the attach moved into show, which is what lets a placement be a slot write rather than a detach. The prediction it is most sure of is a silent one, read off form:store rather than reasoned from a sentence: a record holding a code the table no longer has loads as the blank and saves as nil. Two of the seven answers collided, and that is the finding: a key appended at load time is a dropdown’s list changing, so call 3 could not be carried out while call 6 held setChoices back, and the trigger call 6 named was the wrong one. Three predictions held exactly, and what actually got in the way was the spelling: no non-local return, and no concat on an array |
| A child table on a form, and what a grid would be | On the list 2026-09-18, with a trigger, scoped 2026-10-12 before it fired, and its seven calls answered the same day: the entry puts following in the recordset as follow, shows the rows in the existing gtk:list with no new C, and found while scoping that a deleted contact’s rowid goes to the next one added, orders and all. Six calls went as recommended, and the seventh ruled solveig-gtk this project’s tree; step 1, recordset:follow, was built the same day as 9cd2158, and the widget waits for the form to be used. The note it grew from is the master-child form with the shape Hans gave it, orders under a contact. Half is built, since a child recordset is find narrowed by the parent’s rowid; what is missing is a hook after load and a widget. gtk:grid is not the widget: it is GTK’s layout mesh, and what VB3 called a grid is a GtkColumnView, a family the binding has never touched. The forms entry’s 3.1 prediction about master-child is void rather than pending, and what it does press on is a cost, a child refreshed on every parent move scanning an unindexed column. Trigger: the contacts form growing orders, after the single-record form has been used |
| Networking, and sending code to a running machine | The first half is built, on 2026-08-29 — extensions/net, five messages, and the waiting question answered with a timeout rather than a block; the second half is untouched and still needs 3.4, 6.32 and a proxy |
| SQLite, SDL2, GTK | One project, not three — extensions; GTK and SDL2 fire that trigger and SQLite does not, and wanting both toolkits is what settles the mechanism |
| A game controller, and more of the sound, in solveig-sdl | Held, with a trigger, said by Hans on 2026-09-14 after the eleventh game and written where that binding’s rule for growing lives: every cabinet had a stick and the arrows stand in for it, and one beep has carried twelve games and a tune and would not carry a console’s music. Neither is a trigger yet. The famous console games come first, and they are what will ask; the binding grows only when a program wants what it cannot have |
| Graphics in SolaBasic, over SDL2 | No — asked and closed on 2026-08-30, and the premise was wrong to begin with: graphics were never parked for want of extensions, they were refused as the PC. No program wanted a screen, so the trigger never fired. The throwaway exercised the foundation without a language change — an extension send at 205ns, sdl:present vsync-locked at 8.3ms, and 1.49x from 0.39.0 on a globals-heavy loop. A demo written that evening then corrected the entry: a present keeps nothing, so immediate-mode PSET is not slow on this surface but absent. And there is no oracle for a pixel, which is what would decide it if it were ever asked again |
| What Python has that this does not | Surveyed on 2026-08-30 — most of it is already here under another name; five had no line on this page, and named arguments were then investigated and refused — name: is already a valid send, = lowers cheaply but would not generalise, and the options array turns out to catch every mistake it was accused of passing. Decorators turn out to be writable today; a backtrace is already captured and then discarded, though no handler in the tree could use one; a file being readable only whole is an absence the reference now states, with the edge measured — 2 GB hard, twice the file while reading, and since 2026-08-31 a range reads part of one |
@dict[k=v], and = moved to == |
Half yes, half unnecessary — = never needed freeing: the lexer’s mode flag exists for - alone, and := with == is half of C and half of Pascal. But [...] is measurably sugar over array:of, and there is no dictionary:of — both built on 2026-08-30 — dictionary:of, then #["key" = value] over it — it shipped with no caller, and converting run/capture was then scoped and refused: every options bag would grow 13 characters and lose the repeated-name error. Which is the best measured argument yet for the literal |
| Fuzzy logic | A library that would teach nothing — arithmetic on floats, and the arithmetic all landed |
| Namespaces for included files | Defer — the trigger is somebody else writing a library |
| Splitting the reference into pages | Defer — the trigger is the message reference outgrowing the rest |
| Restricting what a script may reach (6.32) | Defer — the trigger is a script somebody else wrote, or input from a stranger |
| Extensions: a capability from a C binary | Triggered by GTK on 2026-08-28, probed rather than argued, and built the same day — --extension=, extend.h, the ABI handshake and the contract. dlopen beats embedding on a combinatorial argument; the callback into a main loop is free; the build blocker this page named was wrong and the real one was quieter. The second half is SolForeign and a callback registry, both built — real sockets found that bytes are the wrong currency for a scarce resource, so a foreign cell carries a collection pressure of its own; the registry nothing above anticipated, and it hands back a token so a released block says so rather than answering a plausible wrong one |
| Regular expressions | No to a literal, still — but the engine is built: lib/re.sol, on the call that the patterns here are ours rather than a stranger’s. Both POSIX dialects, groups, back-references; sed’s oracle went from 60 agreeing cases to 62 and lib/pattern.sol is gone. The scoping has the measurements, including the two claims of mine it had to take back |
| An early exit from a loop | Defer — the flag idiom recurs across six files; the trigger is a body that must skip its remainder (3.13) |
| Intercepting a message not understood | Defer — Smalltalk’s doesNotUnderstand; small to build, and nothing has wanted a proxy |
| A set, and the collections that are not there | Defer — write them in Solum and measure first, as the four loops did |
| Mathematics, and randomness | Promoted, and built — sqrt, the comparisons, random:new, and then the whole of 3.14: pow, exp, log, the trigonometry, float:pi and float:atan2 |
| Tail calls | No — the programs that seem to ask for them are recursive-descent parsers, which never recurse in tail position |
| Coroutines | No — the interpreter re-enters on the C stack, so a Solum stack is not a value |
| Multiple return values | No — every send has a fixed stack effect, and the verifier checks height on that basis |
| Resuming from an error | No — the frames are gone by the time a handler runs; retry is writable, resuming is not |
| More than one parent | No — via already names an ancestor, which is what multiple parents are wanted for |
An assert that compiles away |
No to stripping; defer the message itself |
| Default values for block parameters | Defer — the trigger is a program threading a nil it did not want to pass; the case for it is that built-ins already do this and user code cannot |
| Constants | Defer, and probably no — the speed argument pointed at 3.17, which is now built and took the argument with it; the memory argument runs backwards |
| Solas written in Solum — self-hosting | Proved, then parked — it compiles itself to a fixpoint; the code is in experiment/, off the search path, below |
| The exported symbol surface | First half built on 2026-08-30 — 146 exported where 23 were declared, so an ordinary refactor could break an extension silently. SOL_API and -fvisibility=hidden take it to 29, both directions tested; the -flto it unblocks is 5–29% and stays a separate call |
| Computed-goto dispatch | No, and it was built to find out — 1% to 13% slower than the switch on all nine benchmarks and on a real program, because clang tail-merges the 21 dispatch sites back into one and the extra code size stays. The tail-call form is the technique that would work, and is much larger |
| An inline cache at the send site | Defer, and the entry was about the wrong ten percent — profiled rather than argued: lookup is 9.7% of the benchmark that asked for it, and the two things above it are a missing inline and -flto, which is 5–29% across the suite and silently takes the extension ABI with it |
| Making the interpreter faster — four candidates | Two built on 2026-08-30, each measured first: the receiver check inlined, and a global remembered where it was found rather than hashed every time. 1.04–1.28× across the suite, 1.065× on a real program, and the CPython geometric mean 1.02 → 0.885. Computed-goto dispatch was then measured and refused; the LTO symbol surface is the one left |
Already there, or already writable
The language has no control-flow syntax, so most of the loop and branch ideas are library code, not language changes. All of these run today, and all of them were written in Solum first — which is the verdict this section was making and it was the right one.
Four of the five then left the library for the VM. They were collected into
lib/control.sol, measured, and repeat, doUntil and the
counted loop all turned out to be worth building in as primitives; a primitive
repeat measured 3.2x the version that lived in the file. What is left in
control.sol is timesCollect, the one nobody has measured, and a comment
saying why the others are not there — redefining one now would shadow the
primitive with the slow version, and for doUntil the compiler splices the loop
in anyway, so the definition would be bypassed exactly where it was most wanted.
So the snippets below are still the record of what the language can express without help, which is what they were written to show. They are no longer how you would get these loops:
@include "control.sol".
caseOf was deliberately never in that file: it is a fine demonstration that
the language needs no switch, and an array of two-element arrays of blocks
reached into with pair:at(#1) is not an interface worth committing to. A
library is a promise, and the bar is higher than “it works”.
The snippets here also end in demonstration calls, which a library file must not: including one should bind names and print nothing.
integer:repeat := { body | | i | i := #0.
{ i:lessThan(self) }:whileTrue({ body:value. i := i:add(#1) }). nil }.
block:repeat := { n | n:repeat(self) }.
#3:repeat({ "tick":display }). ; tick tick tick
{ "tock":display }:repeat(#2). ; tock tock
doUntil, running the body before the test:
block:doUntil := { cond | | done |
done := false.
{ done:not }:whileTrue({ self:value. done := cond:value }).
nil }.
i := #0.
{ i := i:add(#1) }:doUntil({ i:greaterOrEqual(#3) }).
i:print. ; #3
A for loop with start, end and step:
integer:toByDo := { limit, step, body | | i |
i := self.
{ i:lessOrEqual(limit) }:whileTrue({ body:value(i). i := i:add(step) }).
nil }.
#1:toByDo(#10, #3, { n | n:display }). ; 1 4 7 10
forIn is do. ["a", "b"]:do({ e | e:display }) is the loop being asked
for; there is nothing to add.
switch/case needs no syntax either. A list of test-and-action pairs does it,
and reads better than a switch would:
object:caseOf := { pairs | | answer, found |
answer := nil. found := false.
pairs:do({ pair |
found:not:and({ pair:at(#1):value(self) }):ifTrue({
found := true. answer := pair:at(#2):value })
}).
answer }.
#2:caseOf([
[{ n | n:equals(#1) }, { "one" }],
[{ n | n:equals(#2) }, { "two" }],
[{ n | true }, { "many" }]
]):display. ; two
There used to be an integer:caseOf := object:slotAt('caseOf) above that call,
copying the method onto integer so a number could be sent it, and a note about
how slotAt fetches a method and binds it to another class because self comes
from the send. Both are still true of slotAt, and the line is no longer
needed: since every built-in class delegates to object, a method defined there
is found from a number, a string, or anything else. The single root took a
paragraph of cleverness and made it unnecessary, which is the better outcome.
The dispatch this shows is the conditional kind, tried in turn. For the far commoner question — which of these values is it — a dictionary of blocks is one hash rather than a walk, and is roughly nineteen times faster over twenty cases. dispatch.md has both, and the two traps that come with putting blocks in a table.
And it is in the library now, which took the interface rather than the idea
caseOf above was refused a place in control.sol on a specific ground, and it
is worth re-reading before the update: an array of two-element arrays of blocks
reached into with pair:at(#1) is not an interface worth committing to. A
library is a promise, and the bar is higher than “it works”.
That judgement was right, and what changed is the interface.
array:switch went into control.sol on
2026-08-23 in this shape:
@include "control.sol".
n := #2.
[{ n:equals(#1) }, { "one" },
{ n:equals(#2) }, { "two" },
{ "many" }]:switch:display. ; two
(As ifElseIf; it was renamed switch on 2026-09-14, and the sample is kept
runnable under the current name.)
Three differences from caseOf, and each is why it cleared the bar:
- Flat, not pairs of pairs. No
pair:at(#1)anywhere, and the conditions and their answers line up in two columns a reader can scan. - The conditions are plain blocks that close over whatever they test, rather
than one-argument blocks handed the receiver. That gives up switching neatly
on
selfand buys testing anything at all — which is what a scanner deciding betweenisAlpha,isDigitand six literal characters actually needs. - The else is the odd one out, positionally, rather than
{ n | true }. A list of pairs with one left over is exactly a list of pairs and a default.
What prompted it was not this entry. It was a real complaint about real
code: a ten-deep nest of ifElse in the scanner of the parked Solum compiler,
and a four-deep one in disasm.sol that is now flat. So
this stayed an idea for as long as it was an idea, and became a library method
when a program made the case — which is the same rule the roadmap runs on,
applied to a library.
The cost is measured and is in control.sol: 5.8× a nested chain over 200,000 dispatches, because the chain compiles to jumps and this makes a block call per condition; and three frames a level through a recursion, against none. Flat dispatch yes, recursive descent no.
Symbols already are enums. ['red, 'green, 'blue] is a list of interned
names compared by pointer, which is what an enum is for. The only thing missing
is exhaustiveness checking, which needs a type system.
And yes, do is forEach. collect is map and select is filter; the
names are Smalltalk’s rather than JavaScript’s.
So what would building them buy?
Only speed, and only for the loops. whileTrue written literally compiles to
jumps; a repeat written in Solum costs a block and a frame per iteration.
Building repeat and doUntil in makes them inlinable the same way. That is a
real but modest gain, and it is in the roadmap as 6.6 rather than here.
Worth building — and every one is now built
These were in ROADMAP.md section 6 with the detail, in rough order of what a real program would miss first. The order held: the list was worked down roughly as written, and the entries are now in COMPLETED.md.
Kept here with the original reasoning, and with what each one turned out to be, because the guesses are the part worth keeping — and the ones that missed are noted below, in place, rather than quietly corrected.
include was the one that mattered, and it is built —
@include "lib.sol"., and
the reference has the rules.
The design question was never the mechanism but the namespace, and it stayed
flat: an included file’s globals are the including file’s, exactly as though its
text had been written there. A module system with a namespace of its own is a
much larger change to the object model, and nothing so far has needed it.
A system object — exit(code) first, then arguments and a clock. Small,
and exit is the difference between a script and a program.
Built, and it did not stay small:
systemanswers its arguments and the environment, walks the filesystem, and runs another program.exitturned out to matter for a second reason nobody had in mind here — it unwinds rather than leaving from under the machine, so a script that exits ends itself and hands the decision back to whoever called. That is what lets a host survive the script it is running, and 6.32 names it as the behaviour an embedding would most expect to be wrong.
A clock, which is what the performance-timing idea needs. This project’s own
changelog is full of measurements taken with /usr/bin/time; being able to take
them from inside the language would be better.
Built. The
timeclass is there, and performance timing came with it.
Reading input, starting with a whole line. readLine is a few lines of C and
portable. Waiting for a single key is a different job — it needs raw terminal
mode, which is platform-specific and belongs behind its own decision.
Both built, and splitting them was right:
readLinecame first and single-key went off to be its own entry, 6.10. What that entry then caught is the thing worth remembering: it was closed once by mistake, because Solis had grown raw-mode line editing for its own prompt — the same machinery, and not the same thing, since a program still could not read a key.system:readKeyis the message. It answers one byte rather than one key: an arrow is three bytes, and which bytes make a key belongs to the terminal rather than to the language.
File handling, whole-file first: read a file into a string, write a string to a file. That covers most of what scripts do. Binary files want a byte-array type and should wait for a program that needs one.
That last sentence was half right. A program did turn up wanting a byte’s number —
lib/json.sol, and for text rather than for binary files — and what it needed was not a byte-array type but two messages,asByteandasCharacter, on the types that already existed. Waiting was correct; the shape guessed at was not.
A bytecode reference. design.md has an instruction table that is missing
six opcodes — OP_JUMP, OP_JUMP_IF_FALSE, OP_EXIT_IF_FALSE, OP_LOOP,
OP_CHECK_BOOL and OP_SYMBOL, which is every jump and the newest two. The
disassembler already exists and prints them; the document simply fell behind.
Built, as BYTECODE.md, and the fix for why it fell behind is the more useful half: the table is checked against
solum/include/solum/bytecode.hbytests/test_bytecode.c, so an opcode added without a line describing it fails the suite. design.md now points here rather than keeping a second table, because one document that cannot drift beats two that agree today.
More examples, chosen by auditing which concepts have none rather than by adding more of what is already covered.
Done, and the split is what made it answerable. The files now sit in two directories —
examples/for the twenty-five written to show a feature,programs/for the seven written to do a job — so the question had a shape: does every concept the guide names have a demonstration inexamples/?The guide came out clean. All 22 of its sections carry a
Run:pointer and every pointer resolves. What the audit found was on the message axis: four of 121 built-in messages were sent by nothing inexamples/—values, andmodeOf,setModeandsetModifiedAt. Not lost in the move. They had never had a demonstration, and had been carried the whole time bymirror.solandlog.solhappening to need them, which is precisely what nobody notices by reading.Each went into the example it belonged in, and the coverage test now asks for
examples/alone, so a program can no longer stand in for a demonstration. Three examples the guide discussed without naming —walk,time,keys— are named now, and §18 gained the two paragraphs its pointer was promising.
A (group) versus {block} document. Your own example is the whole of it:
m := { x | x:add(#1) }.
(m:value(#42)):print. ; #43 -- a group evaluates
{ m:value(#42) }:print. ; <block> -- a block does not
Both are “code in brackets”; one runs now and one is a value. That distinction is obvious once you have it and invisible before, which is exactly what a tutorial section is for.
Written:
(group)and{block}in the guide, and the entry is 6.8. Your example is what it opens with, unchanged.
Nothing checks that a link points at a heading that exists
Found on 2026-08-31 by moving a heading. 6.40 closed, so its section moved from ROADMAP.md to COMPLETED.md, and four links written that morning went on pointing at an anchor that no longer existed. A sweep for the rest of them found two more — one written the same day, one older — of which one pointed at a heading in a different file from the one it named.
expect.sol checks a great deal and not this. It
runs every code block, recounts every marked number, holds GRAMMAR.md against
solum.bnf and checks that each changelog entry names a commit. A link is the
one cross-reference in these documents that nothing verifies — and this
repository moves headings, because an entry moving from one file to another when
it closes is the whole filing system.
It is small. Collect every ## /### heading per file, lower-case it,
strip everything but word characters, spaces and hyphens, and turn spaces into
hyphens — that is GitHub’s rule and about ten lines. Then every markdown link
carrying a #fragment, in docs/, README.md, index.md and the .sol
headers, is either in the set or it is a finding. The sweep that found these three was that, in
Python, in a scratch directory.
Against building it now: it is a checker for the documents, and this repository’s checkers have all been asked for by something going wrong more than once. This is once. Nothing that shipped was broken — a dead anchor lands the reader at the top of the right file rather than nowhere.
Trigger: a second time a heading moves and takes links with it, which is a release away — closing entries is the routine that does it. Or somebody following a link in the published documentation and landing in the wrong place, which is the version of this that costs a reader rather than a writer.
Built on 2026-09-01, and the trigger never fired. There was no second heading move; it was built on instruction, and saying so is the whole use of having written a trigger down. It is in expect.sol and in
make test— 1,313 links across 124 files against 1,496 headings the day it went in, and every anchor and every resolved link agreeing character for character with an independent implementation in Python. Those two totals move with every document that gains a link, so they are written here as of a day rather than given a<!--count-->marker: a marker that goes stale on every documentation commit is noise, which is the opposite of what the marked counts are for.What the throwaway found is not what the checker finds, and that is the part worth keeping. The Python version reported one dead anchor in
CHANGELOG.md. Chasing it turned up two real faults in that file, both of them markdown that renders as something other than what it says: a paragraph wrapped so that ``` began a line, which is a code fence; and an inline code span wrapped so that<if-statement>began one, which kramdown reads as raw HTML and which stopped the published page rendering from there to the end of the file. 64 of that page’s 327 headings reached the site, and had not since 0.20.0.The dead anchor it reported was neither of them. It came from the throwaway’s own fence rule, which closed a block on any line beginning with ``` rather than on a bare one; under the rule the renderer keeps, that link is fine, and the shipped checker reports nothing on a tree with both faults still in it. A finding that is right that something is wrong and wrong about what it is costs as much as a check that cannot fail, and this one was one edit away from being written up as a defect of the kind it was not.
What actually found them is not in
expect.sol: the count of headings the published site renders against the count in the file, over every page, which needs the network. That is the check with a case now, and it has no trigger written for it yet.
Being built
Solas written in Solum — self-hosting
The question, asked on 2026-08-23: could the compiler be written in Solum
itself? Not to replace solas, but as proof that this is a language you can
write a system in rather than a scripting language that happens to be pleasant.
Nothing here had ever discussed it. No roadmap entry, no idea, no note — so this is the first record.
The strongest form of the proof is the classic one: the Solum-written
compiler, compiled by itself, produces the same bytes the C compiler produces.
That is checkable to the byte, and this repository already knows how to make
that comparison — disasm.sol was written to check one
implementation of the .sob format against another and found three faults doing
it.
What was measured before anything was written
Four things could have killed it. None did.
Solum can read a .sob |
Already shipped. disasm.sol decodes the whole format, i64 sign edge included, and agrees with solvm --dump over 5,737 instructions. Half the format work exists. |
| Solum can write arbitrary binary | The one real unknown. All 256 byte values, NUL included, built with asCharacter, joined, written and read back identical. |
| The format is documented | serialize.h gives every field, BYTECODE.md every opcode, and a test holds the document to the header. |
| It is fast enough | disasm.sol decodes a 12.6KB .sob — 1,462 instructions — and prints all of it in 60ms of CPU. Compiling is heavier per byte than decoding; this says seconds, not minutes. |
And the verifier is on the writer’s side. A .sob is untrusted input, so
solvm verifies every chunk before running it. A Solum-emitted file with a bad
jump target or a wrong stack height is reported, not run — which turns a class
of emitter bug into a message instead of a crash.
What is actually hard
- The frame limit dictates the parser’s shape.
3.5 was 62 frames and a
recursive-descent parser spends about three per nesting level —
evaluator.solmanaged 18 brackets andlib/json.sol28. A Solum parser for Solum would run out on ordinary source, so it must carry an explicit stack. That is not a workaround invented for this:lib/html.solalready does it and reaches a thousand levels. - The size.
solasis 2,323 lines of C. The Solum version is likely 2,500 to 3,500, against 3,774 lines of Solum inprograms/andlib/together — it roughly doubles the Solum in the repository. - Two compilers rot. Every language change becomes two changes and the second
is easy to forget. The mitigation is not discipline, it is the corpus test in
stage 2: compile every
.solhere with both and compare, inmake test, so the rot is immediate rather than silent.
The stages, and the gate
| 0 | Done — emit.sol. Two chunks written out by hand, both byte-identical to solas, both running, both decoded by disasm.sol. In make test as cmp. |
| 1 | Done — compile.sol turns source into bytes, and examples/hello.sol comes out byte-identical to solas. lexer.sol, parser.sol and sob.sol are the three pieces. Blocks, temporaries, methods and @include are stage 2. |
| 2 | The full language, checked over every .sol here: both compilers, same bytes, in make test. Done, and stopped by the frame limit rather than by a missing construct — 42 of 46 files identical, 0 disagreements. The 4 refusals are call depth exceeded (3.5), including on this compiler’s own source. |
| 3 | Done. The Solum compiler compiles its own source and produces the file solas produced from it; the compiler that comes out compiles its own source again to the same bytes, and still agrees with solas on everything else. In make test. |
Where byte-identity turns out to rest on something arbitrary — a table ordering
neither compiler is obliged to agree on — the fallback is instruction-level
equivalence through disasm.sol.
What stage 0 found
The back end is not the problem. "hi":display. and #45:print. both come
out of emit.sol byte-identical to solas, run, and
disassemble. Between them they cover every section a method-free chunk has:
names, constants, code, line runs, files, file runs, slot names.
- Writing an i64 is easier than reading one, which was not the expected
direction.
disasm.solcarries careful arithmetic because rebuilding the top byte by shifting it into bit 63 overflows and this language traps; writing,shiftRightis arithmetic and masking after it is right for negatives as readily as positives. - The verifier catches a bad emitter. One corrupted opcode byte gets bytecode is internally inconsistent and exit 65 rather than a crash, so a whole class of back-end bug arrives as a message.
- A float constant is the one thing not yet writable, and the reason was
already on the record: nothing reinterprets a float’s bits as an integer, so
readFloatindisasm.soltakes a double apart field by field and the emitter needs that inverted. Laborious, not blocked, and no program compiled so far has a float literal.
So the gate is passed and stage 1 is a real option rather than a hope. What it costs is unchanged: 2,500 to 3,500 lines, and a second compiler that has to be kept honest by the corpus test.
What stage 1 has found so far — the tokenizer question, answered
lexer.sol scans every token Solum has, and the test suite
compares it against solas/src/lexer.c over every .sol
file in the repository: 33,034 tokens across 44 files, kind, line, column and
text, all identical.
The question it was written to answer was whether a pattern class or a built-in scanner had to come first. It did not. The evidence is the file:
solas/src/lexer.c |
265 lines |
experiment/lexer.sol |
297 lines, 169 of them code |
Solum needed fewer lines of code than the C to say the same rules, and what
it wanted was at, copyFrom and comparison — all of which the language had
before it had a collector. Nothing was added for this and nothing was missed.
The one place the language showed through is that a 14-way character dispatch is
a nest of ifElse where C has a switch, and the punctuation half of it moved
into a dictionary of symbols to keep it flat. That is a readability note, not a
capability gap, and it is 3.13’s
territory rather than a new entry.
Speed: 3,470 tokens from a 475-line file in 62ms, VM start included. Slow next to C and irrelevant at this scale — a 500-line file scans in the time it takes to fork a process.
And the corpus was not enough on its own, which is the more useful finding.
It passed on the first run, so a rule was deliberately broken to check the test
could fail — and the corpus still passed, because 33,000 tokens of working
Solum contain no 1e followed by a non-digit. Working code does not contain
the corners. A fixture of them now runs beside the corpus: bare exponents, 45.
against 45.5, a string with a newline inside it, and five ways to be wrong,
since an error token has a position too and both scanners have to recover from
it identically or everything after it disagrees.
And stage 1 is done: source in, the same bytes out
compile.sol compiles
examples/hello.sol to the file solas produces from
it, byte for byte, first attempt. The test offers every .sol file in the
repository to it: 3 accepted and identical, 43 refused as outside the subset,
0 disagreements. The zero is the number that matters — nothing is quietly
mis-compiled — and the shape of the test means a construct that starts
compiling is counted the moment it does.
Byte-identity earned its keep. It forces agreement on what a compiler is otherwise free to decide, and each of these had to be worked out rather than guessed:
- Names are interned when the instruction mentioning them is emitted, so the table’s order is the order the code refers to things.
- Constants are shared by value and type.
#45and45are two entries, and keying them by text alone produces a program that pushes an integer where a float was written — which runs, and is wrong. Breaking exactly that is what the test was checked against. - Line runs count bytes rather than instructions.
The one thing that was real work is the float encoder in
sob.sol. Nothing reinterprets a float’s bits as an integer, so
a double is taken apart by arithmetic — sign, the exponent by halving and
doubling into [1, 2), then 52 bits of mantissa — and reassembled as two 32-bit
halves so nothing has to reach bit 63, which would overflow on the way in
exactly as it does when reading. Checked against the C library at twelve values
including -0.0, DBL_MAX and infinity, bit for bit, because a byte count
would have passed on any of them. Stage 0 had listed this as the one thing not
yet writable; it is written.
Still nothing added to the language. Three library files and a program, all in Solum as it already was.
Stage 2, first half: blocks and the frames they need
Blocks, parameters, temporaries, groups, slot assignment, frame slot allocation,
lexical capture and nested chunks. 9 of the repository’s 46 .sol files now
compile byte-identically, and 0 disagree — the 37 refusals are all the same
thing, a file using control flow that solas compiles to jumps.
Two things it got wrong, both caught by the byte comparison and neither by anything else:
- A chunk’s slot count was written twice — once in the method header, where the format wants it, and again at the head of the nested chunk. The file was four bytes long and ran perfectly well, because nothing reads past what it needs.
- A byte takes the line of the token just consumed, not the line its
construct began on. Those coincide for one-line statements, which is the whole
of
hello.sol, so stage 1 passed without knowing.parser.solnow records an emit line on every node for this alone.
The refusals are deliberate and are the interesting design decision here.
Compiling ifTrue as a real send would produce a file that runs correctly and
compares differently. That is the one answer this program must not give, because
the whole value of the exercise is that the comparison means something — so it
refuses by name instead.
Stage 2, second half: control flow compiled to jumps
ifTrue, ifFalse, ifElse, and, or, whileTrue and doUntil, with the
jump patching, the backward loop, and the two restrictions that keep the
optimisation from changing what a program means — every block written right
there, with no parameters and no temporaries, or it falls back to a real send.
33 of 46 files now compile byte-identically, up from 9, and 0 disagree. All
thirteen refusals are @include, which is the last construct in the language
this does not do.
One mistake, and it was the interesting kind: the first version never compiled
the receiver. In solas the condition is already on the stack by the time the
selector is read, because the send loop put it there; splitting the inlined path
out into its own method dropped that step silently, and the jump offsets then
looked wrong in a way that pointed at the patching rather than at the missing
value. The lesson is the ordinary one about extracting a function from a loop —
what the loop had already done for you goes with it.
@include, and the wall at the end of it
The last construct: the search-beside-then-search-path rule, compile-once, the depth limit, and the per-chunk file table that lets a line number say which file it is in. 42 of 46 files now compile byte-identically and 0 disagree.
The four that do not are not a missing construct. They are call depth
exceeded — and this is the finding the whole exercise was most likely to
produce. It manages nine levels of nested blocks and fails at ten; solas,
recursing on the C stack, is untroubled at thirty. The files that nest deeper
include experiment/lexer.sol, experiment/parser.sol and the compiler’s own source.
So the language cannot yet compile its own compiler, and the reason is a
documented limitation of the language rather than anything about the compiler.
That is 3.5 with the
best evidence it will ever have, and it was predicted here before any of this
was written: the deep case, a block inside a block inside a block, is the one
this subset does not do yet — when it does, it will carry an explicit stack the
way lib/html.sol does.
The prediction named the right fix and the wrong half, which took a measurement to find out. The first account of this said the parser was what ran out and an explicit stack in it was the answer. So the compiler was split into compiler.sol — a library rather than part of the program, so that a tree nobody parsed could be handed to it directly — and it fails at exactly the same depth: nine levels pass, ten do not, whether you parse alone, compile a hand-built tree alone, or do both. Each half spends about six frames a level.
So fixing the parser alone buys nothing at all, and the honest options are two:
both halves carry their own stack, or the cap moves. Built with
SOL_FRAMES_MAX at 512 rather than 64 both halves reach 83 levels — the same
six frames a level with eight times the room — which is the one-line change 3.5
has always named, and a decision about the language rather than about this
program.
One thing worth recording about the comparison: both compilers have to be
given the same search path. The file table records where an included file was
found, so the path is part of the output, and solas derives its default from
where its own binary sits — which nothing in Solum can see.
Stage 3: it compiles itself
Solum is self-hosting. solas compiles
compile.sol to a first generation; that generation
compiles its own source to a second, byte-identical to the first; the second
compiles its own source to a third, identical again; and the second still agrees
with solas on every other file. All four claims are in make test.
It was one line of C away the whole time, and the line was not in the
compiler. The last four files failed on call depth exceeded, and the cap had
been left at 64 frames because SOL_STACK_MAX was derived from it – so raising
it looked like it meant an eightfold larger SolVM, which lives on the C stack
and would no longer fit on a thread. The two numbers did not have to be one
number. Frames are 56 bytes; giving the stack its own size made 256 frames cost
4% more memory for four times the depth, and
3.5 has the table.
What that bought, beyond the compiler: plain recursion from 62 levels to 254,
evaluator.sol from 18 brackets to 83, lib/json.sol from 28 levels of nesting
to 124. Three programs that had each written down a limit found it moved.
The whole exercise added nothing to the language except a number that was always a choice. Six library files and two programs, in Solum as it already was: lexer.sol, parser.sol, compiler.sol, sob.sol, emit.sol and compile.sol.
Parked, on purpose
The proof is finished, so the code stops being maintained. Everything moved
to experiment/: the four libraries off the search path, the
two programs out of programs/, and all of it out of make test.
The reason is the one the roadmap’s admission rule would give. A second compiler
has to be taught every construct the first one learns, and that tax falls on
every change to solas — for no gain, because the proof does not need
repeating to stay true. It was true on 2026-08-23 and the account of what was
true is written down.
So this is expected to fall behind the language, and the first sign will be a
file in there failing to compile. That is the trade, not a defect.
experiment/prove.sh runs both halves again on demand —
the 47-file comparison and the fixpoint — and
experiment/README.md says what would have to happen
to bring it back: not the compiler, which is correct on every input here, but
the front door, where an error is raised with a VM stack trace instead of
reported the way solas reports one.
An aside: the trap the cheatsheet warns about, walked into
self:included:includes(path):ifTrue({ nil }):ifFalse({ ... }) — chaining
ifTrue into ifFalse, which the cheatsheet’s six rules that bite names in
so many words. ifTrue answers the block’s value, so the ifFalse went to nil
and the send failed. Written by the same hand that wrote the warning, four hours
later.
The test’s it runs check turned out to be three different claims, and only
one of them was true. Requiring exit zero failed on the examples that exit
non-zero deliberately. Comparing the two files’ output failed because a
byte-identical program that reads the clock prints something different every
time. What is left is the only thing a byte comparison cannot already tell you:
that the file gets past the verifier.
No features were added to make it possible, and that is the point
The suggestion that raised this also proposed a pattern class and a built-in tokenizer, to make the compiler shorter to write. Deliberately not done, for two reasons.
The first is that it would weaken the claim: a language that compiles itself with help from a tokenizer written in C is proving something smaller, and the proof is strongest when nothing was added to make it possible.
The second is the more useful one. A 3,000-line Solum program is the biggest evidence generator this repository will ever have. It presses on the frame limit, on blocks that cannot escape their frame, on the absence of an early return, and on string building at a scale nothing here has reached — all at once, in one program that has to work. Adding features in advance to smooth its path throws that evidence away before it is collected. Whatever it genuinely cannot have becomes an entry the ordinary way, with a program behind it.
The tokenizer question specifically is open and gets answered by stage 1: the lexer gets written by hand, and if 300 lines of character scanning is tolerable the question is closed, while if it fights the language that is the entry. Nothing here argues against a scanner — only against building one before knowing. Worth noting that pattern matching has never appeared in this document at all, so stage 1 would be the first evidence either way; and that Solum’s own lexer is 265 lines of C for a language with no keywords, which is a small thing for a regular expression engine to be bigger than.
Deferred, with a trigger
A truncating divide on integer
quotient(n) and remainder(n) beside div and mod — the same division
cutting towards nought instead of flooring.
The floored pair is not in question and should not change. #-7:div(#2) is
#-4 and #-7:mod(#2) is #1, which keeps a remainder inside [0, n) where
indexing and cyclic arithmetic want it; design.md gives that
reason and it is the right default. This is about the other pair, which some
languages mean by the same two symbols.
One program wants it. BASIC’s \ and MOD cut towards nought and take the
sign of the left-hand side — -7 \ 2 is -3 and -7 MOD 2 is -1 — and so
do C’s / and %. programs/sola.sol compiles them, and
is the only customer there has ever been.
It already gets the right answer, which is why this is deferred and not needed. The truncating quotient is the floored one plus one when there is a remainder and the two signs differ, and nothing in that leaves i64:
#-7:div(#2). ; #-4 -- floored
#-7:mod(#2). ; #1 -- so there is a remainder
; the signs differ, so add one: ; #-3 -- which is what BASIC says
So the case for building it is size and speed, not correctness: one send
against twelve instructions and two frame slots, at every \ and every MOD a
listing writes.
What it replaced is the useful part of the story. The first version of that
compiler went through the float divide — a:asFloat:div(b:asFloat):truncated —
which is four sends, gets every sign right, and is wrong above 2^53 where a
double can no longer hold every whole number:
#9007199254740993 \ #1 via float 9007199254740992 exact 9007199254740993
#9007199254740993 \ #3 via float 3002399751580330 exact 3002399751580331
That is the shape of thing this list exists to catch: a workaround that looks like a performance trade and is quietly a correctness one. Trading bytes in a file nobody reads for an answer that is always right is the easy direction, and it was taken.
Not divRounded, which was the first name suggested for it and is wrong
twice over. Rounding is a third operation and it disagrees with truncation in
both directions, so it would fix nothing and break the positive case that
already works:
| true | div (floored) |
rounded | truncating (wanted) | |
|---|---|---|---|---|
#-7 ? #2 |
-3.5 | -4 | -4 | -3 |
#7 ? #2 |
3.5 | 3 | 4 | 3 |
quotient and remainder are the ordinary names for the truncating pair, they
do not collide with div and mod, and they sit beside a float half that
already says which way it goes — floor, ceiling, rounded, truncated.
Trigger: a second customer, or somebody measuring a SolaBasic loop where \
or MOD is the cost. One program wanting a message is what
indexOf(what, #from) had before a second one turned up
and it was built; this is at the same stage.
A path with a NUL in it is silently a different path
Found on 2026-08-31 by sha256sum.sol, reading a
checksum list written with -z, where each entry ends in a NUL rather than a
newline.
A Solum string is length-counted and may hold a NUL anywhere; a path handed to
the operating system is a C string and stops at the first one. So every
filesystem message on system answers about a prefix of the name it was
given, and says nothing:
system:writeFile("nul-path.txt", "hello\n").
name := "nul-path.txt":concat(#0:asCharacter):concat("zzz").
name:size:print. ; #16
system:fileExists(name):print. ; true
system:fileSize(name):print. ; #6 -- which is nul-path.txt
system:remove("nul-path.txt").
What made it worth an entry is how it failed, not that it failed. The program printed
h.txt<NUL>e258d248... w.txt: OK
— and the digest in that line was right. fileExists and readFile both
quietly saw h.txt and agreed with each other about a file the program had not
asked about, so nothing anywhere raised, and the only visible symptom was a
mangled name in a line that said OK. A wrong answer that agrees with itself is
the expensive kind, and this language usually refuses rather than guessing:
arithmetic traps instead of wrapping (strictness.sol),
a short read is an error rather than a shorter string, and run takes an array
so that nothing in it can be read as syntax. A path quietly becoming a different
path is out of character with all three.
The documentation invited it. The reference’s paragraph on files says a NUL
is a byte like any other and that split, indexOf and copyFrom all go by
the length rather than stopping at one — which is true, and is about a file’s
contents. Nothing said the path is the one string in the system that does
stop. That sentence has been added, because it is the fix that costs nothing and
is right whatever is decided below.
The shape, if it is ever built. One check, in whatever turns a Solum string
into a const char * path — if the length and strlen disagree, raise
a path cannot contain a NUL rather than proceeding. It is a handful of lines
in one place, it cannot break a program that was working (no reachable file has
such a name), and it converts a silently different answer into an error naming
the fault.
Against building it now: one program has hit it, in one place, and its
workaround is exact rather than approximate — a name is cut at the first NUL,
which is what a filename is, and the tool on this machine does the same. This
also cannot be reached by accident from a literal, since \0 cannot be written
in one (#0:asCharacter is the only way): the name has
to arrive from a file or a computation, which is exactly the -z case and
nothing else so far.
Trigger: a second program building a path from input it did not write.
Anything reading names out of a file, an argument, or a socket — a -z-aware
xargs, a manifest reader, a server mapping a request to a file. The third of
those is where a silent prefix stops being a curiosity, and it is also the case
6.32 is about,
which is deferred rather than taken.
startsWith and endsWith
Found by writing server.sol on 2026-08-29,
which reads a one-line protocol and wanted to ask whether a request began with
"add ". There is no startsWith, and the message it wrote instead is
text:indexOf("add "):equals(#1).
Deferred on 2026-08-29 — one customer, and the workaround is exact rather than
approximate. indexOf answering #1 is what starting with something means,
so nothing is being approximated and, it said, nothing is slower: indexOf
stops at the first match either way. What a startsWith would buy is that the
sentence reads as the question.
endsWith would come with it, and is the half with an actual argument: it is
copyFrom and a size subtraction today, which is three sends and an
off-by-one waiting to happen.
The trigger is a second program, as it was for replace — which waited for
the editor’s port to want it three times in one line, and was built the day it
did.
The trigger fired, and the paragraph above is wrong about the cost
Counted on 2026-08-30. Three programs, nine call sites, and two
independent implementations of endsWith:
| where | what it wrote |
|---|---|
| server.sol | text:indexOf("add "):equals(#1), with a comment saying there is no startsWith — the original customer |
| expect.sol | six indexOf(...):equals(#1) tests, and its own string:endsWith method |
| plugins.sol | one of each, with endsWith written again as a local block |
And the absence has already cost a defect, which is more than a trigger.
expect.sol’s own note records it: matching .md anywhere rather than at the end
“called both of them files to check, and would have handed a.md.sol to the
markdown checker. Nothing in the tree is named that way today, which is exactly
how it went unnoticed.” That is the gap producing a bug, found by reading rather
than by failing.
The claim that nothing is slower is false, and by three orders of magnitude.
indexOf does stop at the first match — but when there is no match it has
scanned the whole string, and no match is exactly the case a prefix test is
written to detect. On a 128 KB string that does not contain the needle, 2000
repetitions each:
| per call | |
|---|---|
big:indexOf("add "):equals(#1) |
308 µs |
big:copyFrom(#1, #4):equals("add ") |
150 ns |
A prefix test is O(needle); the workaround is O(haystack). The entry compared them on the matching case and generalised from it.
It is also on network input. server.sol’s test runs on a UDP payload, and net.c receives into a 65536-byte buffer — so a maximum-size datagram from a stranger buys a full scan for a question that should read four bytes. Bounded, and a thousandfold.
Where they go, and why the first step is already taken
text.sol is the precedent and says so itself: a method on a
built-in class needs no name of its own, which is what control.sol does with its
loops, and it is the better answer whenever the thing being added is behaviour on
a value. string:startsWith and string:endsWith belong there, and expect.sol
has already written the second one in exactly that shape — so the method
the collections entry
prescribes, write it in Solum, use it, and measure before promoting it, is a
step further along than it looks. Two programs wrote it; a third would be copying
rather than deciding.
A Solum startsWith is copyFrom and equals, which allocates a substring to
throw away but is already the right complexity — the win over indexOf is
the asymptote, not the allocation. Whether it then earns a primitive is the
measurement that comes after the library, not before it.
Built the same day, in lib/text.sol, in Solum. startsWith retired nine
call sites and one performance cliff; endsWith retired two copies of itself and
the bug that wrote one of them. Seventeen cases checked, the empty affix included
— it answers true both ways, and a prefix longer than the text answers false
rather than raising, which the size guard is there for rather than for speed.
And including the library into expect.sol broke expect.sol, which is the
part worth keeping. That program reports integer:slots:size as the number of
messages an integer answers, and class-and-instance.md states it. text.sol
puts asUtf8 and utf8Tail on integer, so the figure moved 37 → 39 the moment
the include landed, and the checker caught its own contamination on the first
run. The rule it leaves is general and was not written down anywhere: a program
that measures a class cannot measure it after loading a library that extends
it. scan.sol had never shown this because it binds an object and adds nothing
to a built-in. Reading the number before the includes is the whole of the fix.
string:first and string:last do not come with them. The dominant idiom in
the tree is a different function: copyFrom(#1, size:sub(n)) — all but the last
n — in shell.sol, basic.sol, edit.sol, serve.sol and expect.sol. last(#n) is
what both endsWith implementations use inside themselves, so it would follow
from the library rather than lead it, and all but the last has never been
named at all. Left where it is until the library above exists and says which of
the two it wanted.
What a string is — bytes, code points, or bytes with a contract
Three documents point at this one and none of them owns it.
!character defers to it by name.
2.13 files it as a
restriction the language lives under rather than a question waiting on an
answer. performance.md says
the strings row does not compare like with like and leaves it there.
Written up on 2026-08-30 because it was asked for, and the asking turned up something none of the three had: a program that has already got this wrong, which is further along than a deferred idea is supposed to be.
What is true today
"café":size:print. ; #5 -- four characters, five bytes
"café":at(#4):asByte:print. ; #195 -- 0xC3, the first byte of é
"café":indexOf("é"):print. ; #4 -- a byte offset
"CAFÉ":asLowercase:display. ; cafÉ -- case is a-z and A-Z, by range
A SolString is a length and a pointer
(object.h), immutable, compared by value,
with a one-byte string answered from the machine’s cache rather than allocated.
Nothing in the VM interprets a byte except the two ASCII case ranges and the
digit parsing in asInteger. There is no encoding anywhere in it: a string is
as happy holding a JPEG as a sentence, and #0:asCharacter puts a NUL in one.
The sharp edge is the one 2.13 names, and it is silent — a cut on a byte boundary answers a string whose last byte is the front half of a character, and nothing anywhere says so:
"café":copyFrom(#1, #4):size:print. ; #4 -- three letters and a piece
"café":copyFrom(#1, #4):at(#4):asByte:print. ; #195 -- é's lead byte, orphaned
What is missing from today’s model, rather than from a different one
integer:asUtf8 in text.sol turns a code point into bytes,
and has two customers: json.sol for \uXXXX and
html.sol for é. Nothing goes the other way. No
message anywhere in the tree reads UTF-8 bytes back to a code point.
That gap looks like an oversight and is not one. Both customers are producing text from a number some format handed them, and a decoder is wanted by something reading text and asking what character is sitting there — which nothing here did. The asymmetry in the library is an honest report of the asymmetry in the programs, and it is the reason this decision has never been forced.
The three models
size answers |
at answers |
can hold a JPEG | the migration | |
|---|---|---|---|---|
| A. Bytes — today, and C | bytes | a one-byte string | yes | none; it is what is here |
| B. Code points — Python 3 | characters | a character | no — needs a second type | every message, every boundary |
| C. Bytes with a contract — Go, Rust | bytes | a one-byte string | yes | additive; nothing changes meaning |
The editor has already asked, and it is worth reading how
edit.sol says, in its own notes:
A tab is one byte and eight columns, and everything that positions a cursor holds both numbers at once. Every editor ever written has this; it is where most of the arithmetic in this file went.
An é is two bytes and one column — the same mechanism with the numbers the
other way round, and the editor does not know about it. Driven with scripted
keys through checks.sol’s harness, against a file
holding café:
file "café\n"
keys $x
written "caf" and a lone 0xC3 -- half of é, on disk, with nothing said
$ lands on the last byte, which is the second byte of é, and x deletes
it. The screen has the matching error: on café x, the cursor-positioning
escape the editor writes for $ is ESC[1;7H — column seven, one to the right
of the x it is meant to be sitting on. dw on the same line deletes caf and
stops, because a byte above 127 is not a letter to its word test.
But look at what fixing that needs. edit:expand is nine lines and is
already the single place where bytes become columns; teaching it that a
continuation byte is worth no columns is
c:asByte:bitAnd(#192):equals(#128) — three sends, using nothing that is not
in the language today. The word test needs the same three. Neither wants size
to change its mind, and neither wants a character type.
(That paragraph was written before the fix and got the size wrong by a factor of five. What it got right is the part that matters — the three sends, and that nothing in the language had to move. See below.)
That is evidence, and it points at C. The program that finally asked for
Unicode asked for it locally, in the two places that face a screen, and what
it wants underneath is exactly the byte string it has: the cursor is a byte
index because copyFrom takes byte indices, and every edit is a copyFrom. A
language where size counted code points would have solved the editor’s
problem by taking away the index it does its work with, and the editor would
have had to build it back.
Why B is the expensive one, and the encoder is not the expensive part
A string that promises code points cannot hold readFile of a JPEG, so a second
type appears beside it. Then every message has to be decided onto one type or
both; every boundary — readFile, readLine, readKey, system:arguments,
the char * in extend.h, a socket in
extensions/net — needs a declared encoding and an
answer for input that is not valid in it; and equals between the two types has
to be false in a way that surprises everybody exactly once. That split was the
costly half of Python’s own 2-to-3 migration, and it is the whole of this work
rather than an aside to it.
The part that looks hard is already done and was cheap: the UTF-8 arithmetic
is under twenty lines of Solum in text.sol, first written before the language
even had shiftRight — with div(#64) for a shift and mod(#64) for a mask.
Carrying it is what made the case for the bit messages.
There is a second cost peculiar to here. A one-byte string is answered from the
machine’s cache of 256 rather than allocated (bytes in
vm.h), which is the optimisation
performance.md credits with making that benchmark row a fair
comparison at last. A code-point string does not have 256 of anything, and that
cache would go.
Why C is cheap here, and the reason is a shape the language already has
Go needs a rune and Rust needs a char because indexing a string in either
one hands you an integer. at here answers a one-character string, and
vm.h already says why in passing — “string:at
answers a one-character string, there being no character type”. So a
code-point-aware at answers a two-byte string, and no new type appears
anywhere:
"café":at(#4) ; today: one byte, 0xC3 -- half a character
"café":characterAt(#4) ; a sketch; not valid today -- "é"
"é" is already the literal for the value that would answer. Which settles
!character as a side effect, and settles it
against: the character type Unicode would want here is the one-character
string the language already has, so ! never becomes the right spelling for
anything. That entry can stop waiting on this one.
The shape, and it is a sketch rather than a proposal: a small set of messages
that say and I mean characters — a character count, a character-indexed at,
a walk over characters, and the byte count of the character at a byte index,
which is the one the editor actually needs. Beside them, size, at,
copyFrom and indexOf keep meaning bytes, so no program written today changes
behaviour and no boundary grows an encoding.
The cost of C, said plainly: two answers to how long is this, and a reader has to know which one a line wanted. That is what Go lives with and people do trip over it. The defence is only that today there is one answer and it is the wrong one for anything facing a screen, which is worse than ambiguous.
A text type beside string, with a prefixed literal to make one
Asked on 2026-08-30, in the same conversation this entry came from: a second
type specialised for Unicode, with a prefixed literal to build one, so that
x := !"unicode text" makes an instance of text and "unicode text" goes on
meaning bytes.
It deserves a hearing rather than a paragraph, because two of its instincts are right and one of them is right about something this entry got wrong.
The polarity is correct, and it is the opposite of Python 3’s. Python 3 made
Unicode the default and bytes the opt-in, which is why every open became an
encoding decision and why the migration cost what it did. This keeps bytes as
the default and makes Unicode the thing a program asks for. That is Python 2’s
polarity — str and u"..." — and Python 2’s version failed for one specific
reason that does not apply here: implicit coercion. "a" + u"b" decoded as
ASCII on the quiet and raised in production six months later. This language
refuses coercion as a matter of principle, and gives
the same reason for refusing byte and word integers;
"abc":concat(!"déf") would be a strict error, and the whole Python 2 failure
mode goes with it.
And it is the only design where copyFrom cannot split a code point — which
is a real advantage over C, and one this entry undersold above. Under C the
silent half-character is still reachable and the defence is that a programmer
remembers to reach for the other message. A text whose indices are character
indices makes the corruption unrepresentable. Invariant carried by the value
rather than by somebody’s memory is the stronger design, and saying otherwise
would be dodging.
Both spellings were already taken, which is the small objection — and is why
this page now writes ! for a sigil nobody has proposed in earnest. It was
asked as $"...", and $ is the hexadecimal prefix: $FF08 is #65288, %
is binary, and the reference argues in as many words that $FF needs no #
because it has one reading. & was the second guess and is not free either —
it is logical and inside a @expr
region, TOK_AMP in the lexer. Between them the infix work and the bases have
spoken for twenty-six characters; what the lexer’s switch never reaches is !,
?, _ and a backtick, and of those _ belongs to identifiers and a backtick
fights the prose it would be written in. That leaves two, and this entry spells
it ! so that a future reading of the argument is not also a puzzle about which
$ is meant.
And text is the name text.sol deliberately declined to bind,
with the incident on record in its own header: the first draft bound a global
called text, the first program to use it had a variable called text, and the
library broke from a distance. A class is a global.
Neither of these decides anything — a spelling is the cheapest thing here to change, which is what just happened to it. They are worth knowing before the spelling is argued, and they are the reason the argument below is about the shape rather than the character.
The literal marks the wrong end of the pipe, which is the real objection.
!"unicode text" and "unicode text" are the same bytes in the source file.
The prefix is not marking that the text contains Unicode; it is marking
validate these as UTF-8 and tag the result — so the type does all the work and
the literal is a constructor, which "...":asText already spells without
touching the grammar. And text worth worrying about never arrives in a literal.
It arrives through readFile, readLine, readKey, system:arguments, a
socket. A literal somebody typed is the one case that was never broken.
So the coherent version is at the boundary — and that is where the editor
kills it. Say system:readFile(path, 'text) decodes. Decoding needs an answer
for a byte that is not valid UTF-8, and both answers are wrong for the one
program that asked:
| answer | what the editor becomes |
|---|---|
| raise | an editor that cannot open a Latin-1 file, or a binary one, at all |
| replace with U+FFFD | an editor that silently corrupts on write — worse than today’s defect, which damages only what was edited |
vi opens anything, and so does this editor. It would stay on bytes and keep
doing the code-point arithmetic locally, which is what its tab-and-column split
already is. That is not a coincidence: Vim’s buffers are bytes and its cursor is
encoding-aware, for this reason. A type the real programs decline to hold is a
type that rots, and the honest test of a second type is which side edit.sol
comes down on.
What it would cost. Twenty-five registrations on string_class today, of
which about twenty want reimplementing for text — every message that counts,
indexes, cuts or compares. Then a fourteenth SolValueType, GC marking, a
.sob serializer tag, print, display, asString and equals, the
dictionary-key rule, and a decision at both foreign boundaries: embed.h and
the char * in extend.h — bought for a
guarantee the only customer cannot use.
And it does not have to be paid to find out. The trigger the
extensions
entry set for putting a capability in C is wanting something Solum cannot
express, and this is not that. A text object over a byte string is
writable in a library today: it would hold the bytes, validate them once on the
way in, and answer a character count, a character-indexed at and a
character-safe cut, over asByte and the bit messages. What it could not have
is a literal, a print form, or equals against a string — and none of those is
needed to learn the only thing worth learning, which is whether any program
wants to hold one. That is the throwaway that comes before the design, and it
costs an afternoon rather than a fourteenth value type.
What survives it, and it is the useful half. The instinct — an invariant
should be carried by a value, not remembered — is right, and there is a cheap
form: a validity predicate on a string rather than a type. One primitive,
isValidUtf8 or whatever it ends up called, invents no type, claims no global,
leaves the byte index where copyFrom needs it, and is the part of the text
type the editor would actually have called. It is the boundary check without the
boundary conversion.
And a separate type becomes right the day this language wants normalisation, collation, locale-aware case, or grapheme clusters — those genuinely need an invariant that a set of messages cannot enforce. None of them has a customer, and none is near having one.
Verdict on the variant: no — but it is model B with a better front door, and the front door is on the wrong end. Recorded at this length because it is the kind of proposal that is re-made in six months by somebody who has not seen the editor’s answer to it.
And the editor was fixed, which cost more than this entry said
On 2026-08-30, the same day. Sixteen sessions were added to checks.sol, eleven of which failed on the editor as it stood; all 181 pass now, and the screen transcript session.out is byte-identical, sideways-scrolling tab line included, which is what says the ASCII path did not move.
The estimate above was wrong by a factor of five, and wrong in an interesting
direction. Nine lines in expand turned out to be seventeen definitions and
four new helpers. expand was barely one of them: it draws the bytes, so the
column count had to move out into a widthOf beside it, and visible — which
sliced the drawn text with a copyFrom because a column was a byte — became a
walk.
What the estimate got right is the part the decision rests on. The three
sends really are the whole of it: isTail is bitAnd(#192):equals(#128), and
charSize, charAt and widthOf are four lines each on top of it. Nothing in
the language moved. No message changed its mind about bytes, no boundary grew an
encoding, and the editor still opens a file that is not UTF-8 — a stray byte is
one character, x takes exactly it, and every byte the editor was not asked to
change is written back untouched.
Two of the seventeen carried most of the weight, and both are places the program already had:
| where | what one line did |
|---|---|
clamp |
a cursor is never inside a character, made true for every command at once — $ included, which is where the corruption was |
operateChars |
the single add that turns an inclusive motion’s last character into a range: d$, de, dfx and x, answered together |
And one exemption is load-bearing: insert mode. A character outside ASCII
arrives from readKey one byte at a time, so the column must be allowed to
stand between the bytes of a character while it is being typed. The rule is
therefore about normal mode and not an invariant on the buffer — which is
exactly the shape a text type could not have had, and is the sharpest evidence
in this entry for keeping the invariant in the program rather than in the value.
What this did not do: f and r read one key, so they still take a byte;
they are safe because no ASCII byte appears inside a multi-byte UTF-8 character,
which is the property the encoding was designed around. There is still no
decoder in text.sol — the fix never needed one, which is the
second prediction on this page that held.
Verdict
Defer — and the model to defer toward is C, not B, including when B arrives wearing a second type and a prefixed literal. The trigger has half fired: a program asked, and the same program can answer for itself with what is already in the language. That is the outcome this page keeps predicting, and it is worth noticing when it happens — the pressure that would have justified a language change turned out to be pressure on one function in one program.
Trigger for the language change: a second program wanting character
arithmetic. The editor has now written the three sends once; a second program
writing them again is when they belong in the language, which is the rule
replace and the cursor were both built under. isTail, charSize, charAt
and widthOf in edit.sol are what that second program
would be copying, and copying them is the signal — not the inconvenience of
having to.
Trigger for a decoder in text.sol: the editor’s fix, if it is written. It is
a dozen lines beside asUtf8 and it is the inverse of code that is already
tested; what it has never had is a caller.
And one thing is cheap enough to want a customer rather than a decision: a validity check on a string. One primitive, no type and no global, and it is what a boundary actually needs — see the variant above, which is where the case for it came from.
What would not count. Parsers, protocols, compilers, solas, json,
html, pattern, the Pascal front end, the assembler, the server — all
byte-shaped by nature, and all of them are most of what has been written here.
A program is not asking for Unicode because its input contains some; it is
asking when it has to count or cut characters and cannot say what it means
in bytes. Only a screen has wanted that so far.
!character literals and Unicode
!x for a character, and !😊 for one outside ASCII.
The character type on its own is small. The problem is that it cannot be decided
separately from what a string is, and today
a string is bytes:
size counts bytes, at answers a one-byte string, and "café":size is 5.
So !x forces the question. If a character is a code point, then at should
answer one, and size should count them, and every string operation changes —
that is the Unicode work, and it is a different piece of work rather than a
larger version of this one. If a character is a byte, then !😊 cannot exist
and the type buys almost nothing over a one-character string.
Adding an ASCII-only !x now would make the Unicode decision harder later,
because there would be a character type with the wrong semantics to migrate.
Trigger: decide what a string is first. If strings become code-point aware,
a character type follows naturally and ! is the right spelling for it.
And that decision now has an entry of its own, written on 2026-08-30 —
what a string is.
It recommends against the branch this paragraph was waiting for, which removes the trigger rather than firing it: if
strings stay bytes and gain code-point messages, the value a character-aware
at answers is a two-byte string, "é" is already its literal, and there is
no type left for ! to spell.
What Python has, and which of it this language wants
Asked on 2026-08-30. lineage.md says this page carries the survey of what the relatives have that Solveig does not, and names them: Smalltalk, Self, Io, Lua and Ruby. Python is not among them, because when that sentence was written Python was not in the project. It is now — nine matched programs, a geometric mean of 0.885, a fairness section that concedes two rows. Python arrived here as a stopwatch and was never asked what it has, which is the gap this closes.
It is also the relative least like the rest. Smalltalk and Self are where the object model came from and Lua is the size to aim at; Python shares ancestry with none of that. Which is the reason to ask rather than a reason not to: a feature that turns up in a language with different parents is likelier to be answering a real problem than passing on a habit.
Already answered here, under another name
Most of it, which is the honest headline:
| Python | here |
|---|---|
| list and dict comprehensions | collect, select |
with, context managers |
ensure — same guarantee, no protocol to implement |
| f-strings | fill, and asString(spec) for the padding half |
repr and str |
print and display, and one asString serves both |
modules, import |
@include and exports, with namespaces deferred |
| duck typing | the object model, which has no other kind |
| the REPL | solis |
enumerate, zip, any, all, sum |
named already in the collections entry, with a method rather than a verdict |
str as code points |
settled, toward bytes with a contract |
| tuple unpacking | refused as multiple return values |
| arbitrary-precision integers | refused with the integer sizes; performance.md names the trade openly |
generators, yield |
refused with coroutines, and the reason is the C stack rather than the idea |
assert |
has its own entry |
Five are left that this page has never had a line on.
Decorators — writable today, and the sharp edge is the interesting half
A decorator wraps a method in another one. That needs a slot read, a slot write and a send, and all three exist:
counter := object:new.
counter:count := #0.
counter:bump := { self:count := self:count:inc }.
counter:bumpInner := counter:slotAt('bump). ; the original, under a second name
counter:bump := { self:bumpInner:mul(#10) }. ; and a wrapper that sends it
counter:bump:print. ; #10
counter:bump:print. ; #20
The original has to be given a name, and that is not a wart. The obvious shorter version does not work:
c := object:new.
c:count := #0.
c:bump := { self:count := self:count:inc }.
c:slotAt('bump):value.
solvm: nil does not understand 'count'
value runs a block with no receiver. A method’s self is bound by the
send, not by the block, which is exactly what makes one definition serve every
receiver — so a block pulled out of a slot and run directly has no self to
find count on. Putting it back under another name is how it gets sent again.
Verdict: already writable, and it belongs in the guide rather than the VM. The thing worth writing down is not the recipe but the error above: it is the one place where a method is a block in a slot stops being the whole story, and a reader who has not met it will read that message as a bug in their program.
Named arguments — the one on this page with a customer already
Python has keyword arguments; Smalltalk had the same idea as selectors, and
lineage.md records dropping those deliberately — copyFrom(#2, #4)
and not copyFrom:to:. What the language does instead is in its own reference,
described as a workaround in as many words:
an array of alternating name and value — the options bag this language can spell, since there is an array literal and no dictionary literal.
system:capture(noisy, ["stderr", 'discard]).
system:run(cmd, ["stdin", typed, "stdout", 'discard, "stderr", 'discard]).
Four call sites in the tree use that shape and every one of them is
system:run or system:capture.
Looked at properly on 2026-08-30, and the case is much weaker than the paragraph that used to sit here. That paragraph said the pairing is positional and silent — a dropped element shifts every name onto the wrong value and nothing says so. That was written without trying it, and it is wrong.
What the options array actually does when it is wrong
Every way of getting it wrong is caught, and each by name:
system:capture(cmd, ["stderr"]).
'capture' wants a value for every stream named, and got 1 of them
system:capture(cmd, ["stdrr", 'discard]).
'capture' does not know the stream "stdrr" -- there is "stdin", "stdout" and "stderr"
system:capture(cmd, ['discard, "stderr"]).
'capture' wants a stream's name as a string, and #1 is symbol
system:capture(cmd, ["stderr", 'discrd]).
'capture' does not know 'discrd' for "stderr" -- a stream takes 'share, 'discard, 'merge, or a path as a string
An odd count is caught, a misspelled name is caught and the alternatives listed, a swapped pair is caught by type, and an unknown manner is caught with its alternatives too. That is better than most languages’ keyword arguments manage.
One case is silent, and it turns out to be a deliberate trade rather than a gap. The residue has to be even and a name has to land in a value slot:
system:run(cmd, ["stderr", "stdout"]). ; the two manners dropped
That runs, answers #0, and writes the child’s stderr into a file called
stdout. But the reference has already reasoned about exactly this: a value is
“a manner, as a symbol, or a path, as a string — and the type is what tells them
apart, which is what keeps a file called discard a file.” A string is always a
path, on purpose. The silence is the price of that decision, taken knowingly, and
it lives in the option values rather than in the option shape — named
arguments would not touch it.
The spellings, and why the obvious one is not available
name: is not a free slot. It is an existing valid parse:
name := object:new.
name:v := #99.
show := { a, b | [a, b]:print }.
show:value(#1, name: v). ; [#1, #99] -- a send, not a named argument
: is the send operator, so Smalltalk’s own spelling is the one spelling this
language cannot have. = is free — outside a region it is refused today, with
an error that points at where operators live:
show:value(#1, mode = #2).
solas: this is written as a send here; '@expr(...)' is where the operators are at '='
That refusal is what makes it available, and it is Python’s spelling. It is not
free inside an @expr region,
where = is equality and a send’s arguments are parsed as expressions, so
obj:f(a = b) already means call f with the boolean.
And the lowering is cheap, which is not the same as being right
f(a, mode = v) rewriting to f(a, ["mode", v]) needs no change to any
existing primitive, because the array it lowers to is the one run and
capture already take. A parser rule, no new bytecode, no new value type, no VM
change.
The objection is that it would not generalise, which is the objection that
already refused ifTrue{...}.
A pure rewrite lowers the same way whatever the receiver is, so
f(a, mode = v) is spelled like a language feature and works only where the
callee happens to take an options array as its last argument — a convention
encoded in syntax, which is worse than a convention in a library, because the
syntax promises something it cannot check. Making it general means a real
parameter kind: a calling convention, arity checking, bytecode. Cheap does not
generalise; general is not cheap.
Verdict: no to the sugar, and the entry that used to say defer was
overstating a case it had not tested. What is left standing is the smallest
part of the original observation — that an options bag is spelled as an array
because there is no dictionary literal — and that is a note about the literal
syntax rather than about arguments. If anything here ever moves it should be
a dictionary literal — or rather the
dictionary:of underneath one, which serves every options bag and needs no new
rule about where a name may appear.
A file is read whole, or not at all
Built on 2026-08-31 as a range, and the entry is kept as it stood because the
reasoning below is what the answer was built to — readFile(path, from, count),
no handle, nothing to close. It became
3.22 and then
3.22 done; what
finally moved it was that its own trigger — nothing here has a file that does
not fit — turned out to be a fact about this repository’s inputs rather than
about the world, and a sparse file made one in four seconds.
system:readFile answers the whole file as one string, and there is no other way
to read one: no handle, no line at a time, no seek. fileSize answering without
reading is the single concession.
For everything in this tree that is correct rather than merely tolerable. The
editor loads a file to edit it, solas loads a source to compile it,
expect.sol loads a document to check it — all three want the whole thing,
and a handle would be ceremony around a single call. The largest input measured
here is a 50,000-line scan in pattern.sol, held entire
without complaint.
But it means a program cannot read a file it cannot hold, and the reference never says so: its file section argues about missing files and copying modes and mentions no limit at all. That is the part worth fixing regardless of the verdict — an absence a reader cannot see is worse than one they can.
This is the gap Python’s iterator protocol actually names. for line in open(f)
is the shape and generators are the machinery, and neither is proposed here:
generators go with coroutines and the refusal there is about the
C stack, not about laziness. Nor is a readLines answering an array the answer,
since that holds the file too.
And the edge was measured, which is what the limit was missing
On 2026-08-30, the same day. mirror.sol had already reached this conclusion for itself — a whole-file copy is fine at this size and not at every size — and ended by saying it is worth knowing where the edge is rather than discovering it. Nobody had gone and looked. It is now in the reference, in the prose and in the limits table:
| hard stop | 2 GB, a string’s length being a signed 32-bit count, refused by name and checked before anything is allocated — instant on a sparse file of any size |
| peak cost | twice the file: read into a buffer, then copied into the string, which is what makes the string immutable |
| a copy | twice, not three times — writeFile streams from the string it was handed and adds nothing |
| speed | 256 MB in 0.17 s, peaking at 514 MB resident |
The measurement corrected the program that asked for it, which is the part
worth keeping. mirror.sol’s note said a mirror of something large holds it in
memory twice, and read as though the copy were what doubled it. The copy is
free; readFile is the whole of the expense, and a mirror’s rule is therefore
twice the largest file, not twice the largest pair. That note now says so.
If it is ever built, the cheap answer is not a handle
Worth writing down before the trigger fires, because the obvious design is the expensive one and it is not the one this language wants.
A handle — system:open, read(n), close — brings a lifetime with it,
and a lifetime is the thing this VM has been careful about: it needs a value
type or a foreign cell, a release discipline, a GC interaction, and an answer
for a handle used after closing. extensions/net
paid all of that for sockets and the retain registry exists because of it. A
socket has no alternative; a file does.
A ranged read — system:readFile(path, from, count) — has none of it. No
new type, no lifetime, nothing to close, nothing to leak, and ensure is not
needed because there is nothing to unwind. fileSize already answers without
reading, so the pair composes: ask how big, then take it in pieces. It is one
primitive beside the one that exists, and it makes both costs above go away —
the 2 GB stop and the doubling are properties of reading it all at once, not
of files.
Its real cost is that a record spanning two chunks is the caller’s problem, and for line-oriented text that is the whole job rather than a detail. That is writable in Solum and is the sort of thing scan.sol is already shaped for.
Verdict: defer, and toward the ranged read rather than a handle. The trigger is unchanged — a program with a file that does not fit — and the documentation half is done, which was the part that never needed one.
A backtrace, and an error object that was built to carry one
The reference says the error is an object rather than a
string so that it can “say more about a failure later without breaking every
handler that already exists”. Later has not arrived: message is still the only
message on it. Python hands a handler a traceback, Ruby a backtrace, Smalltalk
the live stack.
The VM already has the information. An uncaught error prints it:
solvm: nil does not understand 'count'
[x.sol:3] in block
[x.sol:4] in script
So this is plumbing rather than a new capability.
Read on 2026-08-30: it is not merely available, it is thrown away
sol_vm_runtime_error calls append_stack_trace before it sets the flag, so
the trace is captured while the frames are still standing and lands in
vm->error_trace. error:raise goes through the same function — the message is
what was given, and the stack is where it was given. Then onError catches:
SolValue caught = error_from(vm, vm->error_message.chars, ...);
vm->had_error = false;
vm->error_message.length = 0;
vm->error_trace.length = 0; <- two lines later
error_from builds the object out of the message alone, and the line after it
discards the trace. The information is complete, correct, already paid for, and
dropped on the floor. Putting it on the object is one sol_string_new and one
sol_object_define, at catch time only.
And the capture is already free of the thing that would have made it costly. The walk is bounded — eight frames from the top, three from the bottom, and “… N more frames …” in between — so it does not grow with the stack. Measured over 20,000 raise-and-catch pairs, the cost of raising and catching over the cost of the same call not raising:
| stack depth | raise + catch |
|---|---|
| 0 | ~0.8 µs |
| 30 | ~1.0 µs |
| 200 | ~0.9 µs |
Flat, which is the elision doing its job. Nothing is saved today by discarding the trace; the saving would have to come from not building it, and it is built whether or not anyone catches.
But no handler in this tree wants it, and the reason is not laziness
Fifty-nine onError sites; forty-nine bind the error, thirty-eight read
e:message. Every handler that wants to say where already tracks that
itself, and could not use a stack trace if it had one:
| handler | what it says instead |
|---|---|
| json.sol | its own cursor into the text — ’{}’ is not four hex digits |
| html.sol | rewinds its cursor and complains at the mark |
| manifest.sol | the path it was reading, with e:message after it |
| serve.sol | rewinds and takes the byte literally |
A stack trace answers where in the code, and every one of these is asking
either what now or where in the input. A trace handed to json.sol’s handler
would say [json.sol:87] in fail — the library’s own insides, and not the line
of the caller’s JSON, which is the only location its user cares about. The
absence is not a gap these programs are working around. It is a different
question that none of them asks.
And it would not fix the re-raise caveat, which this page claimed it would
The paragraph above used to say a stack carried on the value would make the
lossy re-raise “a non-question instead of an honest caveat”. That is wrong.
error:raise takes a string, and builtins.c refuses a second spelling in as
many words — two spellings of raising would have been a new-shaped mistake —
so a re-raise constructs a fresh error and captures a fresh trace no matter what
the old value was carrying. A trace slot improves the first catch and does
nothing for the chain. Fixing the chain needs raise to accept an error, which
is the design that was deliberately not taken.
Verdict: defer — and the trigger is narrower than this entry first said. Not a program that catches an error and cannot say where it came from, because that is all of them and none of them mind. It is a program that runs code it did not write and has to report a failure to somebody else — which is the unbuilt half of sending code to a machine that is already running, and is on this page already. Until something is executing a stranger’s block, the trace has no reader.
If it is ever built: a string first, since vm->error_trace already holds
exactly the formatted text, and an array of frames only when something needs to
read the trace rather than print it. That is the same order the reference gives
for not inventing a taxonomy of failures to go with a catch mechanism.
Negative indices — no, and string:last is a different question
a[-1] in Python. Here an array has first(#n) and last(#n), and a string
has neither — the last three bytes are copyFrom and a size subtraction,
which is the same three sends and the same off-by-one that
endsWith was deferred over.
No to the negative index. It gives one slot two meanings decided by sign, and
this language spent its literal convention — #45 against 45 — on the
principle that a value’s reading should not depend on inference. An index that is
sometimes from the front and sometimes from the back is that same trade taken the
other way, and at(#-1) on an empty array would have to mean something.
string:first and string:last looked like the useful half and are not.
Counting the tree on the same day found the dominant idiom is all but the last
n rather than the last n, and that what actually wants building is
startsWith and endsWith,
whose trigger had already fired three times over. last(#n) is what an
endsWith uses inside itself, so it follows that library rather than leading
it.
A dictionary literal, and the message that has to come first
Asked on 2026-08-30, as @dict[key = value, key = value], together with
moving @expr’s equality to == so that = was free for it.
The freeing is not needed, and that is the whole of the second half
= is scanned unconditionally and given meaning by the parser’s context.
lexer.h says the mode flag exists for one
operator and names it: the two are -, which is the reason for infix.
Everything else — + * / ^ = & ~ < > — is a token always, and refused where it
does not belong by the compiler rather than the lexer, which is why the
complaint is a compiler’s:
show:value(#1, mode = #2).
solas: this is written as a send here; '@expr(...)' is where the operators are at '='
Regions carry their own delimiters, so @expr(...) and a @dict[...] could
never overlap; = would mean equality in one and pairing in the other the way
, already means different things in an array literal and an argument list.
And spending it would cost coherence. This language assigns with := and
compares with =, which is one convention — Pascal’s, and BASIC’s, which is the
family the infix entry
took its precedence from. C’s is = to assign and == to compare. Keeping :=
while taking == is half of each and lands on a pairing neither language uses,
and it strands <>, which would then want != and drag a second change behind
the first. That entry already names this exact pull, in this exact operator set:
C binds ! tightest and would have read the other; that is the one place here
where a C habit misleads.
And @ is the wrong mechanism for a literal in any case
The directives entry reserves @ for what
happens while compiling and refuses a new one when the job is already done by
something that is not a directive. @expr earns its @ by changing how a whole
region parses. A literal changes no parsing mode — it is grammar, and [...]
needs no directive to be one. If a spelling is ever wanted, #{ is a lexer error
today (expected digits after ‘#’) and so is free, with no directive and no
operator moved.
What a literal would buy, measured: the spelling and nothing else
[#1, #2, #3] has no opcode of its own. It compiles to a global load and a send:
GLOBAL 'array' … then `of`
and it times the same as writing the send out by hand — 0.0138 s against
0.0131 s over 200,000 iterations. The array literal is sugar over a message,
and there is no dictionary:of, so a dictionary literal would be sugar over a
message that does not exist yet.
The message, and the case for it is the one the asker gave
A dictionary is already allowed as an argument:
show := { d | d:at("mode"):display }.
d := dictionary:new.
d:atPut("mode", "already works as an argument today").
show:value(d). ; already works as an argument today
What it cannot be is built as one. Three statements and a name, for a value used once, is why every options bag in this tree is an array of alternating names instead — which the reference calls, in as many words, the options bag this language can spell, since there is an array literal and no dictionary literal.
dictionary:of("mode", 'quiet) is one primitive, symmetric with array:of, and
needs no grammar at all. What it has to settle is small and none of it is new:
an odd argument count is an error; a key must satisfy sol_dict_key_ok, so a
value and never an object, because two objects that look alike would be two
keys; a repeated key takes the last value, as a repeated atPut does; and
dictionary:of with no arguments is an empty dictionary, as array:of is an
empty array.
The 255-argument ceiling — 127 pairs — does not bind, and marks who this is
for. The largest tables here are 53, 33, 26, 20 and 19 entries, and every one
of them maps a name to a block: the parsers and emitters in
sola.sol, the builtins, the key tables in
edit.sol. None wants of. Fifty-three arguments spread
over fifty-three lines reads worse than fifty-three atPut statements, each of
which is self-contained, greppable, and already where a reader would look. The
customer is the small inline bag — two to eight arguments, at a call site,
where the alternative is a temporary — and the eight escapes in
json.sol.
Both built on 2026-08-30, the message first and the literal after it, which
is the order the measurement asked for. #["key" = value] compiles to a global
load of dictionary and a send of of, held byte-for-byte against the
written-out form by a test — the same bargain [...] strikes with array:of.
Bracket, not brace, and = did not have to be freed. [ ] in this language
already says a collection written out and { } says code, so the brace that
most languages use for a table is the one bracket here that means something
else. And #[ is one token, the [ following the # as a digit must, which is
what makes it unambiguous: a digit was the only thing that could ever follow a
#, so #[ was a lexical error in every file written before it. The = cost
one flag on the compiler, saved and restored around the key so that
#[#["a" = #1] = "x"] nests; inside a region it is still equality and outside
one it is still a stray operator.
It shipped with no caller, which is worth saying rather than hiding. Every
dictionary:new in the tree is an accumulator filled a key at a time or a named
table of blocks, and neither is the inline shape; the only use is the
demonstration in dictionaries.sol. This was built
because it is what a literal would compile to and because it was asked for — not
because a program asked, which is the usual bar on this page and is not met here.
The honest test of whether it earns itself is whether the next options bag
written reaches for it.
Converting run and capture to take one — scoped, and refused
The obvious first customer, measured on 2026-08-30 and it does not survive the measurement. These are the only options bags in the tree, four of them:
| today | as a dictionary | |
|---|---|---|
["stderr", 'discard] |
dictionary:of("stderr", 'discard) |
+13 |
["stdout", 'discard, "stderr", 'discard] |
dictionary:of(…) |
+13 |
["stdin", typed, "stdout", 'discard, "stderr", 'discard] |
dictionary:of(…) |
+13 |
Every call site gets thirteen characters longer and nothing else changes —
thirteen being exactly dictionary:of against [ and ]. The array is winning
because it has a literal and the dictionary does not, which is precisely what
the reference said when it called the
array the options bag this language can spell. dictionary:of did not answer
that complaint. Only a literal would.
And the conversion would lose an error. The array form is checked harder
than a dictionary can be, because a dictionary has already thrown away what
capture wants to complain about:
system:capture(cmd, ["stderr", 'merge, "stderr", 'discard]).
solvm: 'capture' is given "stderr" twice
A dictionary dedupes on the way in, so the same mistake would arrive as a single setting and be obeyed. Of the five ways to get an options bag wrong — an odd count, an unknown name, a name that is not a string, an unknown manner, and a repeated name — the last one is only catchable because the argument is a list. That is the general point and it is worth keeping: an argument bag is not a degenerate dictionary. Deduplication is a feature of a dictionary and a defect in an argument list, where saying the same thing twice means the writer believed something untrue.
The one argument for it is symmetry, and it is not enough. capture
answers a dictionary — at("output"), at("status") — and takes an array, so
the two ends do not match. But they are not doing the same job: the input is a
list of settings to be checked, and the answer is a bag of results to be looked
up in. Each is shaped for its half.
Verdict: no. The array stays. What this scoping produced is the best argument yet for the deferred literal, and it is a measured one: every options bag in the language pays thirteen characters to become a dictionary, and a literal is exactly what would give them back.
Asked again once the literal existed, the same day, because this paragraph said it should be. The literal closes almost all of the gap:
| today | as a literal | |
|---|---|---|
["stderr", 'discard] |
#["stderr" = 'discard] |
+2 |
["stdout", 'discard, "stderr", 'discard] |
#["stdout" = 'discard, "stderr" = 'discard] |
+3 |
["stdin", typed, "stdout", 'discard, "stderr", 'discard] |
#["stdin" = typed, …] |
+4 |
Thirteen characters became two to four, and the pairing that a reader had to
count is now written down. So the brevity objection is gone and the answer is
still no, on the one ground that never depended on spelling: a dictionary
dedupes on the way in, so 'capture' is given "stderr" twice — a mistake caught
today — would arrive as a single setting and be obeyed. An argument bag is not
a degenerate dictionary. That objection is now the whole of the case rather
than half of it, which is a better place for it to sit.
And it sharpens what dictionary:of is for, which needed sharpening after it
shipped with no caller: not argument bags, which want checking and ordering, but
values that want looking up — built where they are used rather than three
statements earlier.
And the temporary root came out again, which is the part that taught
something. The first draft rooted the new dictionary on the reasoning that
sol_dict_put grows its entries and growth allocates. Growth does allocate and
cannot collect — object.c says so where it does it, calloc and free rather
than a heap allocation, so nothing can be collected in the middle of the
rebuild. Removing the root and running 200 dictionaries and a 120-pair one
under SOLUM_GC_STRESS found no difference because there was none to find. A
guard against a hazard that is not there is worse than no guard: it tells the
next reader the hazard exists.
Namespaces for included files
An included file binds into the one global namespace, so a library’s names and a program’s names sit in the same space. Should an include get a namespace of its own?
Not yet, and the trigger is somebody other than you writing a library. A flat global space works while one person can see every name in it; that is the thing which stops being true first when a library arrives from outside.
What is already covering it. A module system does four jobs, and the score today is one and a half:
| job | status |
|---|---|
| once-only initialization | done — a file is compiled once, keyed by where it lands on disk |
| a namespace | approximated — json:read(...) is namespaced access already; only the name json is in the flat space, and a collision on it is a compile-time warning (6.21) |
| an export boundary | done — exports draws one; from outside an object is its export list |
| declared dependencies | absent, and not wanted — see below |
system:load keeps that first row true, which took a second pass to get
right. It shipped without a once-only memory — a message that silently declined
to do its work looked like the stranger thing — and that was wrong for the
reason the row already gives: once-only is not a nicety, it is what lets two
files each load what they need without arranging between themselves who loads
what. The memory is now the machine’s, keyed by realpath exactly as @include
keys its own, and the second load answers false rather than doing nothing
silently.
So the score is unchanged, and the trigger above is sharpened rather than moved. A library arriving from outside was already the moment a flat space stops working; a library arriving already compiled, from someone who never saw your names, is that moment with less warning. The two absent rows are still absent, and they are the ones a namespace would answer.
And the three tiers
that came out of 6.21 do most of the rest. Two of the four shipped libraries
claim no global at all — integer:asUtf8 and integer:timesCollect are
sends rather than bindings, so there is nothing there to collide with. The other
two claim one name each.
The design argument against, which this document has used before. A module
is a new kind of thing: not an object, not a message, but a compile-time
scope. That is the same objection that turned down
@ifdef — a second mechanism where an
existing one does the job — and an object holding slots already is a namespace.
If it is ever built, the shape is an object rather than a module. Not a module system: an include that binds into an object instead of into the root, with the includer naming it.
Json := @include "json.sol". ; a sketch; not valid today
Json:read(text).
Two things make that more tractable than it was:
- The compiler already tracks which names each file binds. That record —
BoundName { name, file }, shared across a compilation — went in for the rebinding warning. Sending those bindings to a fresh object rather than to the root is a smaller step from there than it looks. - It would not touch methods on built-in classes, and should not:
integer:asUtf8belongs tointegerrather than to whoever included the file.
The obstacle is syntactic rather than deep. @include is deliberately a
statement — x := (@include "lib.sol") is refused by name — so the binding form
would have to be designed rather than falling out.
The export boundary is built, and this paragraph used to say why it could
not be. It said privacy needed something the language has not got, since slots
cannot be removed and slots lists everything, so it would be a new concept
rather than a use of existing ones.
Half of that was right. It is a new concept — a slot now carries a bit saying
whether anything outside the object may reach it, which is a thing the object
model did not have. What was wrong was the implied conclusion that a new concept
is too expensive. It is one message, exports, and one rule: from outside, an
object that has drawn a boundary is its export list, and a name off the list can
be neither sent nor bound. Nothing was removed and slots still lists
everything — to the object itself. From outside it lists what is exported, which
is the same answer to a different question.
json:digits := "abc" from outside is now refused, and drawing the line on
json immediately found something: quote and keyText had to be exported too,
because string:asJson is a method on string that calls back into json, so
its sends arrive from outside. They were public in fact before they were public
on purpose, and nothing had ever said so.
Two triggers, either of which is enough:
- Somebody other than the author writes a Solum library.
- One program needs two libraries that clash on a name. Today the answer is “rename one”, which works right up until you do not own one of them.
Declared dependencies: the row that should stay empty
The other three rows each closed on a failure somebody could hit — a file run twice, two libraries claiming one name, a private slot overwritten from outside. This one has no such failure behind it, and the case against it is worth writing down rather than leaving as an omission.
@include "json.sol". is a declared dependency. It stands alone as a
statement, it comes at the top, it names exactly what the file needs, and the
compiler acts on it. What a module system adds is not the declaration but the
separation of declaring from fetching — which earns its place when the thing
has to be found among alternatives, resolved against a version, or fetched from
somewhere. None of those exists here. A file is beside you or on the search
path, and there is one of it.
The mechanical jobs are already done by the two rows above it. Ordering and
cycles are what dependency graphs are usually computed for, and both mechanisms
settle them without anyone declaring anything: @include is once-only keyed by
realpath and says a cycle “ends on purpose”, and system:load now does the same
at run time. Two files may each ask for what they need without arranging between
themselves who asks — which is the outcome a dependency declaration is meant to
buy, arrived at without one.
It would be a fourth mechanism where three already reach. That is the
objection this document used against
@ifdef, and it applied to the namespace
row too, where an object holding slots turned out to be a namespace already.
The one real gap, recorded so the trigger is legible. A .sob does not say
what it needs. @include is gone by the time a program runs — it leaves no
trace in the bytecode, deliberately — and system:load is a message, so a
compiled file’s requirements are not visible without running it. Today that
costs nothing, because a .sob either finds what it wants in the globals or
fails on the send that wanted it. It would start costing something if anybody
wanted to ship compiled Solum and know before running it what else must be
loaded first, and in what order.
So the trigger is packaging, and it is a different trigger from the two above. Those turned on somebody else writing a library. This one turns on somebody else distributing one as bytecode — and until that happens, a declaration would be a form to fill in with information nothing reads.
6.32 A script cannot be run with less than the whole machine
Deferred, and it kept its roadmap number — cited from about thirty places, and numbers are never reused. It sat in section 6 for four days as the last open decision and was moved here on 2026-08-22 without being taken.
Why it is here and not there. Every other entry that reached the roadmap came from a program wanting something and not having it. This one came from a concern about a use nobody has: a webserver producing pages by running Solum, where injection could turn untrusted input into code the server runs. That is a real risk in that shape and the shape is hypothetical. Solveig is an experimental language and was never planned for web services; the question was asked because this might one day be a thing, not because it is one — and it may never be, in which case the right amount of mechanism to have built is none.
The trigger, said exactly: somebody runs a Solum script they did not write, or embeds the machine somewhere its input arrives from a stranger. Either makes this urgent and neither has happened. Until then the honest position is the one embedding.md already takes — a host gets limits, and gets told plainly that nothing here is a sandbox.
What was learned while it was open is kept in full below, because deciding this later from a blank page would cost far more than keeping it does. Two things came out of it that were worth having on their own and are already built: 6.33, the limits a host may set, and the whole embedding interface, which exists because working out what a permission would attach to meant first saying what a host may rely on.
Everything a program can reach, it can reach: run another program, delete a file, write anywhere it has permission to. That is right for a script somebody wrote for themselves and wrong for one that arrived from elsewhere, and there is currently no way to say which this is.
The shape suggested is a restricted mode, with the dangerous messages refusing, and a flag to allow them.
The case this came from is an embedding, not a shell. A webserver that produces pages by running Solum, where the risk is injection — untrusted input reaching the program and becoming part of what it runs. The one choosing the restriction is the webserver, and what it is protecting is itself.
That is a different shape from a person running a script they were sent, and it moves three things.
Who chooses, and how often. Not somebody at a prompt who may not think to
ask, but a program that decides once, at startup, and then runs the same policy
over every request for as long as it is up. A server author thinks about this
exactly once and deliberately, which weakens the argument that the protection
must be on before anybody considers it — and strengthens a different one: the
restriction has to be settable from C, before the program runs. A --unsafe
argument is one front end for that, not the mechanism. If the mechanism is argv
parsing, the case that asked for it cannot use it.
What is untrusted. Not the file — the server wrote that. The data. So the permission cannot be attached to where the code came from, or decided per file: it is a property of the run, fixed before the first instruction and the same for everything the program subsequently reaches.
And embedding is not a documented use today. The headers make it possible and
nothing claims it: no page says how to hold a SolVM inside another program.
Deciding this is therefore also deciding to have an embedding interface, which
is the larger of the two and should be admitted up front rather than discovered
halfway.
That has now been tried, and then written down — in that order, and the
order was the useful part. embed/host.c holds a machine and
runs serve.sol once per request; it is 136 lines of
code, two more than solvm’s own front end. It found a use-after-free on its
first run: a chunk recorded which VM had interned its names by pointer, and a
host builds each request’s VM as a local, so every one landed at the same
address and the chunk went on reading the freed machine’s name table. Fixed in
0.14.1 — the record is a serial now — and the point for this entry was that
nothing in the repository was shaped to catch it, because nothing had ever run
two VMs in sequence at one address.
The interface is now stated. solum/embed.h
is the whole supported surface, embedding.md is the contract in
prose, and tests/test_embed.c has a case for every
promise it makes. Writing it caught a second mistake at once: this project had
said a host must call sol_vm_intern_chunk, and sol_vm_run does it — the
defect was inside that function rather than in a call somebody could miss, so
the interface is one rule simpler than it had been written up as.
So this entry’s precondition is met. A permission is a promise about what a host may rely on, and there is a list of that now — including what is deliberately not promised, which is where a permission scheme would have to live: no route for a run’s output except by an agreed name, no way to silence a failing run’s stderr, and a fresh VM per request being the only safe choice. What remains here is the decision itself, unchanged in kind and better furnished than it was.
Which way round is the default still matters for the command line, where the
chooser is a person. Safe-by-default with --unsafe to enable protects the
script you did not write and breaks every existing use, including this
repository’s own tests and programs/tools.sol. Unsafe-by-default with --safe
breaks nothing and helps only the people who remember to ask. For a person, that
argues for safe-by-default and for paying the breakage; for an embedding it
barely signifies, since the host states what it wants either way.
What “dangerous” means is two things, not one. The suggestion names the messages that change the machine, and there is a second set that reveals it:
| messages | |
|---|---|
| changes | run, capture, remove, makeDirectory, rename, writeFile, appendFile, setMode, setModifiedAt |
| reveals | readFile, filesIn, isDirectory, fileExists, fileSize, modifiedAt, modeOf, environment |
Reading ~/.ssh/id_rsa and printing it changes nothing and is not safe.
environment alone will hand over a token from half the CI systems there are.
So a mode that only stops writing is a mode that stops the obvious half — and
in a server the revealing half is the worse one, since the process it is
running inside holds the credentials the pages are built from.
system:exit was in the first list and has been taken out of it: it sets a flag
the interpreter loop unwinds on and sol_vm_run answers SOL_EXIT, so a script
that exits ends itself and hands the decision back to whoever called. A
webserver stays up. That is already the right behaviour and is worth naming
here, because it is the one an embedding would most expect to be wrong.
That suggests capabilities rather than a switch — something nearer
--allow-read --allow-run than one flag — which is more useful and more work,
and the embedding case wants it: a template renderer wants to read files and
never to run a program, which one boolean cannot say.
And one capability per message is not fine enough either.
serve.sol is the case written down, and it is told what
it was asked entirely through system:environment — PATH_INFO and
QUERY_STRING are how CGI hands a handler its request. So a scheme that can
only say yes or no to environment has to say yes, and has then also said yes
to AWS_SECRET_ACCESS_KEY and everything else the server process is holding.
The permission that a webserver must grant is the one that gives away its
secrets.
That does not settle the shape, but it rules one out: the granularity has to be finer than the message where the message names something. Which is a familiar answer — it is a list of allowed variables, or of allowed paths — and it is more work again, since a capability that names things is a capability that has to be matched against them.
A complication worth knowing before starting: @include reads files, and
the search path reads the shipped library. A mode with no reading at all cannot
compile a program that uses lib/json.sol, so reading the program is not the
same permission as reading a file the program names, and the line runs between
them rather than around readFile.
Enforcement is cheap and must not be reachable from inside. A flag on the VM and a check in each primitive costs a branch on messages that are already doing system calls. What matters more is that there is no message to turn it off: whatever sets it must be the host, before the program runs, or the whole thing is a suggestion.
And it is not a sandbox, which is the thing to say loudest, because “safe mode” invites more trust than it can earn. A restricted script can still loop forever, allocate until the machine swaps, fill a disk through a file it is allowed to write, or simply compute the wrong answer and be believed. It stops a script reaching for the machine; it does not make a hostile script harmless. This is the same honesty as 3.3, which says the verifier proves a chunk is well-formed and not that it stops.
The webserver case is what makes that caveat expensive rather than academic: on a command line a program that does not stop is an annoyance, and on a server it is the server. That half was 6.33 and is built: a host may now say how many instructions a program gets and how much it may hold. It was a different decision — limits, not permissions — which is why the two were separable, and why one of them could be settled while this one is still being thought about.
Raised after the fact, by noticing what system:run had made possible
rather than by anything going wrong; the embedding case above was supplied the
following day as the reason it had occurred to anybody. Nothing is asking for it
yet, and it is written down because the decisions above — where the mechanism
lives, which default, capabilities or a switch — are much cheaper to take now
than after somebody has written scripts that depend on either answer.
Extensions: a capability from a binary rather than from the VM
Asked again on 2026-08-25, with two customers named: SQLite and SDL2. They do not stand alike, and the difference is this entry’s own trigger — wanting something Solum cannot express.
SDL2 fires it. A graphics surface cannot be expressed in Solum at all, at any speed, and no amount of library writing gets there. That is the trigger stated exactly.
SQLite does not. A database can be written in Solum — slowly, and without thirty years of somebody else’s work in it, but written. The argument for it is performance and ecosystem, which are good arguments and are not this one. It would ride along after the mechanism exists rather than justifying it.
And the cost is the sentence on the front page. No dependencies beyond a
C11 compiler and make is checked on three platforms by CI, and an extension
mechanism is the first crack in it even if every extension is optional. That is
possibly worth paying and it should be paid deliberately, which is why this
is a decision and not a task.
Could Solum gain something it cannot express — a database, a graphics surface, a
compression codec — from a C library loaded at run time, rather than by growing
the core VM? The sketch offered with the question was a load message for the
binary and a .sol file providing the interface.
Yes, and the architecture is more ready for it than anyone had noticed. A
primitive is already nothing but a C function pointer hung on an object, the
functions that hang it there are already public, and system — the closest
thing here to a namespace of C functions — is built from exactly the three calls
an extension would make: sol_object_new, sol_object_define into the root,
then a run of registrations. The .sol-interface half of the sketch needs no
invention at all; it is lib/shell.sol’s shape exactly, one
global with everything hung off it.
Half of it works today, for anyone building their own binary.
embed/host.c is most of the example: include
solum/object.h, call sol_object_define_primitive before sol_vm_run, and
the messages are there. That is how all 285 built-in slots are installed, and
nothing distinguishes an extension’s from a built-in’s.
The catch is a promise rather than a mechanism.
embed.h says it is the whole supported
surface, and that anything else under solum/include is the machine’s own
business and may change without notice — and SolPrimitive lives in
object.h, outside it. So it works, and nothing says it will keep working.
Three things are missing, in ascending order of difficulty.
A supported surface. An extend.h standing to extensions as embed.h stands
to hosts, saying what an extension may rely on. It would have to state three
things a newcomer gets wrong: arity is not checked for you, only the receiver
type is; errors are out of band, through sol_vm_runtime_error; and the
collector’s rule, which is that nothing may hold a heap pointer across an
allocation unless it is reachable from a root. That last one has a trap worth
naming — the temporary-root stack is eight deep and overflowing it calls
exit(1) with no diagnostic.
A loader and an ABI. dlopen, one exported entry point per library, and a
version handshake. There is no ABI version today: SOLUM_VERSION is printed and
never compared to anything, and the only version check in the project is
.sob’s exact-equality refusal, whose policy — refuse, do not guess, recompile
— is the one to copy. SolValue is passed by value and SolObject’s layout is
exposed, so nearly any struct change is an ABI break.
There is also a concrete build blocker. libsol.a is a static archive and the
four binaries each link a private copy, with no -fPIC and nothing exported.
As built, a loaded bundle could not resolve sol_* back into solvm at
all. The fix is to build libsol shared and link everything against it, which
is a real change to how the project ships.
That paragraph was written from reading and is wrong in both halves. It was measured on 2026-08-28 and the truth is narrower, worse, and fixed by one linker flag rather than by changing how the project ships — below.
Somewhere for a foreign resource to live, and something to close it. This is the only real design decision, and it is the interesting one.
The collector has no finalizer of any kind. Sweeping frees a cell’s own
parts and then the cell; nothing user-supplied runs. So a database connection
stashed in a Solum value would be never marked (harmless), never freed, and
never counted against --memory. The shape that answers it is a foreign cell
carrying its own release function —
typedef struct {
SolGCHeader gc;
void *handle;
void (*release)(void *handle);
const char *kind; /* "sqlite connection", for the error message */
} SolForeign;
— as a new value type rather than as a use of SolObject.payload, which is
declared, written once as zero, read by nothing in the whole tree, and would
still leave no home for the release function. A new value type also earns a
compiler warning from mark_value, which has no default on purpose; the two
to watch are free_cell and cell_size, which are if-chains and will not
warn.
And that shape dissolves what would otherwise be ugly. A limit-stop is
deliberately not catchable and ensure does not run
(6.33),
so a script relying on an explicit close leaks every time it is stopped. But
the whole heap is freed at VM teardown regardless of reachability, and
3.10 already says a fresh VM
per request is the only safe choice — so the VM always dies, and a release hook
fires even for a program that was taken away. Explicit close has no such
property.
What it would cost, said plainly.
- It falsifies a sentence the design leans on.
design.mdsays “nothing has to be released: a file is read or written whole, and no message hands back anything a program is obliged to close.” A connection would be the first, and that sentence is the reason an uncatchable stop is cheap. - Loading native code is unlimited authority, past every check there is — which reopens 6.32 in the largest possible way. True of every language’s FFI, and the mitigation is the same: a host that does not want it does not enable it.
- An ABI is a promise that constrains refactoring. Today
SolObjectandSolValuecan change freely because everything is rebuilt together. - Extensions are where a portability story goes to die. Four binaries and a
static archive build wherever
ccdoes; bundles anddlopendo not.
Trigger: somebody wants a capability that cannot be written in Solum and is not worth putting in the VM. A database, a window, a codec. Nothing wants one today, and the language has managed four days of real programs without.
And if it is ever picked up, the first move is not any of the above. Write one throwaway extension — fifty lines, something with nothing to release, a hash or a checksum — build it, load it, and find out what the path actually wants. That is the method that has paid here repeatedly: serve.sol found 3.7, host.c found a use-after-free, disasm.sol found three faults in the documents it was written from. An afternoon of that would settle more than another page of this.
The first candidate to be proposed for this was a regular expression engine, on 2026-08-24, and it is worth saying where that landed: it fails the trigger above, because a matcher can be written in Solum and this entry is for things that cannot — but it is close to the ideal throwaway, because a compiled pattern is the smallest thing that has to be given back and so the smallest test of the foreign cell. The reasoning is below.
GTK, and the afternoon that was supposed to be a page
Asked on 2026-08-28, and it fires the trigger. A window cannot be written in Solum, which is the test this entry sets. But the question came with a constraint the entry above had not considered, and the constraint is the more interesting half:
I don’t want it permanent in the core of solvm but something that can be linked in as an API — because I’d also like SDL2, or other graphics libraries, and they can’t all co-exist in the core. Embedding creates another problem: if I later want a large-number-math library together with GTK, I have to create a host for every combination.
That argument is correct and it is the one that settles the mechanism. The entry above treated embedding and loading as two routes to the same place. They are not, and the difference is arithmetic. A host is a binary, so n capabilities is 2n binaries and every new library re-multiplies the ones already there. A bundle is a file, so n capabilities is n files and the combination is chosen when the program runs rather than when it is compiled. Nothing else here needed a combinatorial argument, which is why it had not come up: this is the first want that is a set of wants.
So: dlopen, not embedding. That much is decided by the question.
Then the entry’s own instruction was followed — write one throwaway extension, build it, load it, and find out what the path actually wants; an afternoon of that would settle more than another page of this. It was an afternoon, and it settled five things, three of which this page had guessed wrong. Measured on macOS/arm64 against 0.35.0, with GTK4 from Homebrew.
One. The loader is smaller than the paragraph describing it. Seventy lines
of host — dlopen, dlsym("sol_extension_init"), call it with the VM and an
ABI number before sol_vm_run — and forty lines of bundle, which is a working
hash:fnv1a("hello"). No VM change of any kind.
Two. The build blocker above is wrong, and the real one is quieter. It is
not true that nothing is exported: dyld_info -exports bin/solvm lists 100
sol_* symbols today. A Mach-O executable exports its global symbols without
being asked, and adding -Wl,-export_dynamic to the probe host changed the
count not at all — 118 either way.
What actually fails is that a symbol is exported only if the executable
already referenced it, because a linker takes objects out of an archive on
demand and leaves the rest. bin/solvm exports sol_object_new,
sol_object_define_primitive, sol_vm_runtime_error and sol_vm_call_block,
because builtins.c uses all four. It does not export sol_vm_set_global
or sol_vm_global_text, because those live in embed.c and no front end calls
them. The first load of the probe died on exactly that:
dlopen: symbol not found in flat namespace '_sol_vm_set_global'
Which is worse than a blocker, because a blocker is visible. The surface an
extension may link against is not a decision anybody took. It is a side effect
of which objects the front end happened to pull out of the archive, it differs
between solvm and solis and solid, and it would change silently on the day
a front end stops calling something. An extend.h promising a surface that is
determined this way is promising nothing.
The fix is one flag rather than a change to how the project ships:
-Wl,-force_load,build/libsol.a on macOS, -Wl,--whole-archive on Linux — and
on Linux -rdynamic as well, because ELF executables really do export nothing
by default and Mach-O’s generosity is the outlier here. Exports went 118 to 139,
and the probe ran. The Linux half is reasoned, not measured: the probe ran on
macOS only.
Three. A foreign main loop calling back into the VM needs nothing built.
gtk:every(#5, { ... }) handed a Solum block to g_timeout_add, and gtk:run
called g_main_loop_run. Five ticks, the loop quit when the block answered
false, gtk:run returned, and the statement after it ran. sol_vm_call_block
re-enters from a GLib callback exactly as it does from array:do. An error
raised inside a callback set had_error and formatted a trace that named the
frame beneath it — [tick.sol:9] in script, which is the gtk:run line. This
was the half expected to be hard. It is free.
Four. And that is what makes the collector the problem, in the worst form it
could take. A block held as GTK’s gpointer user_data lives in a C struct.
mark_roots walks the value stack, the frames, the eight temporaries and the
class objects, and that struct is none of them. The same program with
gc_stress on:
#1
probe: callback failed: 'block' takes 1 argument, got 0
Tick one ran. The collection between ticks swept the block. Tick two called
whatever now occupied that cell — the inner { x | x:asString } from the same
script — and the failure is an arity complaint about a block the program never
registered anywhere. Not a crash, not a null, not anything that points at the
collector. Four lines putting the block into an array hung on the extension’s
own global — somewhere mark_roots already walks — and the same binary under
the same stress runs all five ticks and prints "done".
This is the finding the afternoon was for. The foreign cell designed above is for what an extension hands out, and the whole discussion of it is about release. It says nothing about what an extension holds onto, which is a separate requirement with a separate mechanism, and which a database connection would never have revealed — SQLite does not call you back. A callback registry is not an optimisation of the foreign cell; it is the other half, and its failure mode is silent misdispatch, which is the kind of bug that costs a week.
Five. Two bundles into one machine, which was the question underneath the
question. ./host hash.so gtk.so both.sol: GTK’s main loop calling a Solum
block that calls the other bundle’s primitive. Independent dlopens, any
number, any order, no arrangement between them. The combinatorial problem is not
mitigated by this design — it does not arise in it.
GTK4 also comes up from inside a loaded bundle: gtk_init_check succeeds and
widgets build. The toolkit half was never in doubt and is now not in question.
And a third platform, which changes one decision
Added on 2026-08-28, and parked the same day. Solveig is to be ported to an
operating system being written from scratch. That OS will have neither GTK nor
SDL, and will probably have a Plan 9 draw-style interface instead.
Parked, deliberately, within the hour. The port waits until that system is ready, and it will have
dlopenby then — so designing for its absence now would be paying for a problem that has been promised not to arrive. The trigger for taking this back up is the port actually starting; the reason it is written down anyway is that two of its conclusions were kept, on grounds that have nothing to do with the port, and a later reader should be able to tell which. They are marked below. Everything else here is future work with a named trigger rather than a decision anybody is acting on.
Three things follow, and the first one is not about graphics at all.
The risk is not the toolkit. It is that a from-scratch OS has no dynamic
linker. dlopen is not a small thing to be owed — it is image loading,
relocation, and symbol resolution — and a young system runs static binaries for
a long time before it has one. A mechanism that is dlopen cannot be ported;
it can only be replaced.
So the contract and the loader must be two things, and extend.h must not
mention dlopen. One registration contract, two ways to reach it:
/* extend.h -- no operating system anywhere in it */
#define SOL_EXTENSION_ABI 1
typedef int (*SolExtensionInit)(SolVM *vm, int abi);
/* An extension linked into this binary. Needs nothing from the platform. */
bool sol_extension_register(SolVM *vm, SolExtensionInit init,
const char *name, char **error);
with sol_extension_load(vm, path, &error) — the dlopen half — alone in
solum/src/extend_dl.c, a file a port does not compile. --extension=NAME
resolves against the statically registered table first and the filesystem
second, so a build with no dynamic linker answers the same flag, and the .sol
file is unchanged either way.
Kept — and not for the port. This is what makes the thing testable now: The plan’s test for step 0 was going to have to build a shared
object during the test run, which is fragile on any CI and impossible on some.
A statically registered extension compiled into tests/test_extension.c needs no
compiler at test time and runs everywhere. The port requirement and the test
requirement want the same design, which is usually the sign of a right one.
Second: draw inverts control back, and the easier way. GTK and SDL own the
loop and call into the VM, which is what makes
the rooting problem
unavoidable there. Plan 9 does not: /dev/mouse and /dev/kbd are read, so the
program owns its loop and an event is an ordinary blocking primitive —
{ running } whileTrue({
ev := draw:nextEvent.
... }).
— with no block held by foreign code and nothing for the collector to lose. So
the callback registry must be a service an extension may use, not the shape an
extension has to take. Designed the other way, the draw backend would spend
its life pretending to be GTK.
Kept, and also not for the port — SDL2 is already the second back end, on the
same machine. The one that is expensive to reverse: do not let GTK’s vocabulary
become the language’s. If the first bundle publishes window, widget and
signal, that wording reaches the examples and the tutorial, and every later
backend has to emulate a toolkit it has nothing to do with. Each backend should
publish its own global in its own idiom — sdl, draw — and any portable
layer over them should be written in Solum, on top, where it costs a .sol
file and can be rewritten per platform. A common C abstraction underneath would
be the same mistake as one host per combination, made in a place that is harder
to get back out of.
And the reframing the measurement produced. The core is already ISO C:
vm.c, gc.c, object.c, bytecode.c, serialize.c, value.c, embed.c
and the whole of Solas include nothing but <string.h>, <stdio.h>,
<stdlib.h> and their kin. The entire platform surface is two files —
builtins.c (fork, execvp, waitpid, pipe, dup2, dirent, stat,
termios) and stdin.c (poll, termios).
Which means extensions are not only how a window gets in. They are how the
POSIX half gets out. system:run has no fork to call on a system that has
no fork, and today that is a compile error in the core rather than a capability
somebody declined to load. Nothing here proposes moving it — but the contract
must be good enough that it could move, which means an extension’s primitive
has to be indistinguishable from a built-in in registration, in speed, and in
what it may rely on. It already is. The point is to keep it that way rather than
to let extensions become a second class with a smaller surface.
What this does not change. The verdict, the ordering, the foreign cell, and
the argument against a message or an @link directive all stand. draw gives
file descriptors and image ids exactly as SDL gives textures and the socket demo
gives a bare int, so it is a third customer for SolForeign rather than a
reason to reconsider it. What changes is one decision: dlopen is a back end
of the loader, not the loader, and the static path is a first-class front door
rather than a fallback.
What is left, and which parts are decisions
| The link change | -force_load / --whole-archive + -rdynamic. Not a decision — the alternative is a surface nobody chose. Costs nothing to anyone who never loads a bundle, and applies only to a build that compiles the dlopen back end: a static port needs none of it, because nothing is being resolved at run time. |
| Two doors, one contract | sol_extension_register for an extension linked in, sol_extension_load for one dlopened, and extend.h mentioning only the first. The dynamic half lives alone in a file a port omits. Not a decision either, once the port is a goal — and it is what makes the whole thing testable without building a shared object mid-test. |
extend.h |
The three the entry above already names, plus a fourth found here: an extension must check had_error after every sol_vm_call_block. A limit-stop sets it, and a main loop that does not look will keep calling into a machine that has been stopped. |
| The ABI handshake | Still nothing to compare. Copy .sob’s policy exactly — equality, refuse, do not guess. |
SolForeign |
Built on 2026-08-28. As designed above, and the entry’s best argument held: release runs from free_cell, which both the sweep and sol_gc_free_all go through, so a stopped program’s sockets are closed too. Two things the design did not have — a kind checked with strcmp, so one extension’s handle cannot reach another’s primitive, and a footprint, without which --memory would measure the pointer rather than the texture. And one thing only real sockets could have found: bytes are the wrong currency for a scarce resource, so foreign cells carry a collection pressure of their own. |
| A callback registry | Built on 2026-08-28. New, from finding four; nothing above anticipated it. A token rather than a value, because the point is that a released one says so where a stale value answers a plausible wrong block — the same silent misdispatch, one layer up. Still a service an extension may use and not the shape an extension takes: a draw back end owns its own loop and needs none of it. |
| Where loading is invoked from | A decision, and the one to take deliberately. |
On that last one, the recommendation is a flag on the binary —
solvm --extension=gtk.so program.sol — and not a message, and emphatically not
a directive.
A message (system:extension("gtk")) puts the decision to load native code
inside the script, which is 6.32
run backwards: a mechanism a script can invoke is not a mechanism a host can
withhold, and this is the largest possible thing to be unable to withhold. An
@link directive is worse again, because it would put dlopen in Solas —
the compiler would load native code to compile a file, and a .sob would carry
the requirement into every machine that ever ran it. The flag keeps the choice
where 6.32 established that this kind of choice belongs: with whoever starts the
program, settable from C before the run, with argv as one front end over it
rather than the thing itself.
And the front-page sentence survives, if extensions stay out of the default
build. No dependencies beyond a C11 compiler and make stops being true the
moment CI needs GTK to build the tree. It does not stop being true if a bundle
lives in its own directory, outside make all, outside the default matrix, and
built by whoever wants it. The four binaries still build wherever cc does.
That is the shape to insist on — and it means GTK does not go in this
repository, which is a smaller sacrifice than it sounds, because the whole
point of the mechanism is that it does not need to.
One thing to know before starting rather than after. GTK must own the main thread on macOS. That costs nothing today, since a VM is one thread’s and 3.11 already says so — but it forecloses ever running a UI on a second thread, and it is easier to accept that now than to discover it.
The first three steps were built on 2026-08-28, within hours of this being written: the link change,
extend.h,sol_extension_loadandsol_extension_register, the ABI handshake,--extension=onsolvm,solisandsolid— every front end that runs a program, and pointedly not Solas — docs/extensions.md, tests/test_extension.c and a real bundle in tests/ext_probe.c. One thing written below turned out to be wrong in the building and is corrected there: the test that checks the link cannot live in the test binary, because a binary that callssol_vm_set_globalitself finds it exported however the link was done. The two steps that remain are the ones with the real surface area.And then the foreign cell, the same day:
SolForeignwith its release hook,foreignbound as a class object,tests/test_foreign.c, anddesign.md’s “nothing has to be released” rewritten rather than left standing. The design above survived contact except in one place, and that place was found by opening real sockets rather than by reasoning: the byte threshold is the wrong currency for a scarce resource. A foreign cell is forty bytes however scarce the thing it holds, so a program opening descriptors in a loop died at a 256-descriptor ceiling with no collection having happened. Foreign cells now have a pressure count of their own. Nothing on this page anticipated that, and nothing would have: it is not visible until the resource is real.And the callback registry, which was the smallest of the four and turned up one bug of its own on the way: a slot’s
next_freemeant both in use and end of the free list, so releasing into an empty list marked the slot live again. Two states in one field, caught by the test that looked least likely to fail. The probe that started all of this has been rewritten onto it and its#ifdef PROBE_ROOTEDis gone.And GTK itself, in its own repository, which is where this entry’s whole cost argument lands: no dependencies beyond a C11 compiler and
makeis still true, and still checked on three platforms, because the toolkit is not here. Fourteen messages, a widget as a foreign handle, every callback held through the registry — and the two things nobody could settle by reasoning both hold. A GTK signal handler re-enterssol_vm_call_blockexactly asarray:dodoes, andg_object_ref_sinkmakes widget lifetimes and the collector agree rather than compete.The four steps are done and the entry is closed. What it asked for on 2026-08-25 — somebody wants a capability that cannot be written in Solum and is not worth putting in the VM — was asked on 2026-08-28 and answered the same day, in the order this page set, with the throwaway first.
Recommended order, each step falsifiable before the next: the link change
with extend.h and the handshake and the flag, tested by the hash bundle and
nothing graphical; then SolForeign with its finalizer; then the callback
registry; then GTK, out of tree, as the first bundle that was worth it. SDL2
after that changes nothing about any of the four, which is the whole claim.
The port is parked and the order is unchanged. Two small things survive it
on their own merits: step one keeps the register/load split, because its test
wants a statically registered extension rather than a shared object built
mid-test, and no back end gets to name itself the general case, because SDL2 is
already the second one. Neither costs anything. Everything else about the third
platform waits for the port to start.
Graphics in SolaBasic, through the SDL2 extension
Asked on 2026-08-30, and the premise needs correcting before the answer is
any use. The question was put as we stopped SolaBasic at the point of not
supporting graphics because we didn’t have the foundation for it — but nothing
in this repository ever said that. SOLABASIC.md
puts SCREEN, PSET, LINE, CIRCLE, PAINT, PALETTE and GET/PUT
under Never — the PC, alongside PEEK, POKE and CALL INTERRUPT, and the
Not yet table beside it — the one that does hold
deferred work, each with a trigger — has never mentioned graphics. Stage 7 was
the last stage and it is done. SolaBasic did not stop at graphics; it finished
without them, on purpose.
That matters because it changes what is being asked. This is not resuming a parked stage. It is reversing the single claim the document is built on:
Everything QBasic has that CB80 also had is language. Everything QBasic adds beyond it is either the PC or convenience, and neither is here.
Graphics is the flagship example of the PC. There is no reading of that
sentence under which SCREEN 13 is language.
The throwaway went first, and it answered the engineering question so completely that only the design question is left. Half an afternoon, no changes to anything tracked — the five programs are parked in experiment/graphics-probe/, so every number below can be re-run rather than believed:
- solveig-sdl builds clean
against 0.39.0 and draws.
SOL_EXTENSION_ABIis still 1 and the restricted export surface took nothing it uses. The foundation is not in question. - An extension send costs 205ns against an ordinary send’s 55ns — 200,000
sdl:fillcalls in 41ms, 200,000 ordinary four-argument sends in 11ms. Cheap. A per-pixel graphics API throughdlopenis affordable, which was the thing most likely to have killed this. sdl:presentcosts 8.3ms, because it is vsync-locked. 200 of them is 1.66 seconds. This, and not drawing, is the whole design problem, and it is the finding no amount of reading would have produced.
The last one deserves its own line, because it is a language problem wearing
an implementation problem’s clothes. QBasic graphics is immediate-mode:
PSET draws, and you see it. SDL is double-buffered, and the buffer is
shown by a call that waits for the display. A faithful PSET would present
after every statement, at 8.3ms each — 120 pixels per second, so a QBasic
program drawing a circle would take a minute.
And that is the optimistic reading. A present does not keep the canvas, so
PSET after PSET would not accumulate a picture at all: each one would show
its own dot on a field of undefined memory. QBasic’s model assumes a screen that
stays drawn, and a renderer of this shape has none — which means immediate
mode is not slow here, it is absent, and any SCREEN built on this surface
would have to buffer a whole frame and decide for itself when the frame is
finished. That is a language-visible decision, not an implementation detail.
Measured three ways, same Mandelbrot, 320×200, 400 iterations:
| 0.38.0 | 0.39.0 | |
|---|---|---|
| the arithmetic alone, no graphics | 1.59s | 1.07s |
| drawn, presenting once per row | 2.20s | 1.90s |
| drawn, presenting at most every 16ms | — | 1.29s |
The two drawn rows measure a call pattern and not a picture, and that
correction arrived on 2026-08-30 when a real example was written and looked at.
Neither of them drew the Mandelbrot. sdl:present does not preserve what was
drawn — the buffer handed back for the next frame holds undefined memory rather
than the picture just shown, checked in C against SDL’s Metal renderer — so
presenting once per row shows one row of fractal and stale video memory
everywhere else, and presenting every 16ms shows one strip of it. Both numbers
are honest measurements of what those call patterns cost. Neither is a
measurement of two ways of drawing the same thing, because the thing was never
drawn.
Which sharpens the finding rather than weakening it. The policy is not present less often, it is present only a frame that is complete — five presents on this program rather than a hundred and eighty. And the price of getting it wrong is not a slow picture. It is not a picture at all.
And the optimisations answer for themselves in the left column. 1.59s to 1.07s is 1.49x on a program written after they landed and chosen to be unkind to them — a loop whose every operand is a global, which is 4.5’s exact case. It is a larger gain than any of the nine CPython pairs recorded, and it is a fair number rather than a flattering one: nothing here was tuned, and the program was written to draw a picture rather than to win.
How it would be built, and why the mechanism is already there
Nothing new is needed in the compiler’s shape. sola.sol already has the
escape hatch this wants, and file handling already uses it: a builtin emits
an arbitrary send sequence, and SOLAREADFILE$ is emitGlobal("system") plus
emitSend("readFile", #1). Graphics is the same move against a different
global:
builtins:atPut("SOLAGFILL", [['integer, 'integer, 'integer, 'integer], 'integer, 'block,
{ m, args |
m:emitGlobal("sdl").
m:emitGlobal("SOLAGSCREEN").
m:builtinArg(args, #1, 'integer). ...
m:emitSend("fill", #5) }]).
Above that, the runtime SUBs are written in SolaBasic like every other one —
SOLAPSET, SOLACIRCLE, the 16-colour palette table, and whatever decides a
frame is finished —
and the statement parsers and emitters are the most mechanical work in the file.
solas still loads nothing, which is the property
extensions.md protects: the compiler only ever
emits the name sdl, and a name costs nothing to write down.
The counter-argument, which is not weak
The .sob inherits a requirement it cannot state. solas refuses to load
extensions precisely so that compiling cannot put a native dependency into the
bytecode, “where every machine that ever ran it would inherit it — including one
that only wanted to disassemble it”. Emitting sdl: sends puts that dependency
in anyway. It arrives later and with a better error — undefined name 'sdl' at
the first graphics statement rather than a failure at load — but a SolaBasic
program with SCREEN in it is no longer a program that any Solum runs. That is
the disease the rule was written against, with a longer incubation.
The eleven messages do not reach. start window clear colour fill line
present poll wait ticks is what solveig-sdl has. PSET is a 1×1 fill and
LINE’s B/BF forms are line and fill, so those are free. CIRCLE has
no message either, and that one has since been answered by a program rather
than left hypothetical: solveig-sdl’s examples/circles.sol draws a filled
disc in six lines, one sdl:line per row with half the width the square root of
r² - dy², and the extension was deliberately not grown to hold it. One
customer satisfied in six lines of the language is not a trigger, and every
later back end would have had to match a circle it has no equivalent for. So
CIRCLE is a runtime SUB and not a message. PAINT needs to read a pixel
back and there is no message that can; and LOCATE/PRINT on a
graphics screen needs a font, which is a larger piece of SDL than everything
else here combined. So this is not the graphics statements. It is a subset,
and two repositories change rather than one.
And the divergence list grows by a semantic rather than a detail. The eight
entries in Where this is not QBasic are
things like no SINGLE and a file is written with line feeds — narrow,
checkable, and none of them changes when a statement takes effect. Drawing
appears when the runtime decides to present it is a different kind of entry.
And there is no oracle for any of it
This is the argument that decides the entry, and it did not turn up until the
rest had been written. Every SolaBasic feature is held against a real
QuickBASIC 4.5 under DOSBox, and oracle.sh works
by comparing printed bytes: agree/ must match to the byte, differ/ must
not. Graphics print nothing. There is no way for that harness to check a
single pixel.
So graphics would be the first part of this language checked only against
transcripts its own author recorded — which is the exact failure the oracle
exists to prevent, and which its own header names: eighty-three claims in
basic.sol caught none of the seven real defects the NBS suite found, because
those check what the author thought to check.
The hole is the same size whether graphics go in the language or in a module. What differs is what it costs: inside the language, SolaBasic is verified against a real implementation quietly stops being true of a whole area.
Verdict — not built, and the reason is the trigger rule
Asked on 2026-08-30 and closed the same day. No program wanted a screen.
The question came with two halves — implement the graphics statements, and
exercise the extension foundation and the new optimisations. When the design
question was put back as is there a SolaBasic program you want to write that
needs a screen?, the answer was that it had really only ever been the second
half. So the trigger never fired, and the language change is not made:
SCREEN and the rest stay under Never — the PC,
that section is not amended, and the three documents that promise the whole of
the PC is not coming stay true.
The exercising happened anyway, and cost no language change at all. Every
measurement above was taken with Solum programs talking to sdl: directly —
which is the honest shape of the finding, since it is also the cheapest way to
get a picture on a screen out of this project. The foundation was exercised, and
the answer is that it holds: a clean build against a release it predates, an
extension send at 205ns, and one genuine surprise in sdl:present.
Kept for whenever the trigger does fire. Everything above the verdict is the
design, and it is worth more now than it would be later: the mechanism is
identified (a builtin, exactly as SOLAREADFILE$ reaches system), the frame
question is settled by measurement rather than argument — a SCREEN here has to
buffer and decide when a frame is done, because the surface keeps nothing — and
the two things out of reach, PAINT needing a pixel read back and text on a
graphics screen needing a font, are named. The recommended shape, if it is ever built, is
an opt-in '$GRAPHICS metacommand in QBasic’s own idiom rather than the
language growing a screen, so that the CB80 cut line survives unamended and the
.sob’s extension requirement is visible in the listing that produced it. That
is a recommendation held in reserve, not a plan.
The premise correction is the part to remember, and it is why this entry is long for something that was not built. We stopped at graphics because the foundation was missing was a memory of a decision that never happened. The foundation was never the reason and was never asked. Writing it down here is cheaper than having the same wrong recollection start the same afternoon again.
Regular expressions
No to a literal; defer the capability; and the most useful finding is neither of those. Raised on 2026-08-24, with a second question behind it: if the objection to an engine is its size, is this what extensions are for? It is. But size was the weakest of the four objections, and two of the other three turned out to be answered already, by things this repository had decided for other reasons.
The argument that was not available, and would have been false
No program here has wanted one is exactly the reading of an absence that design.md now rules out, and it was written down two days before this was asked. So the question had to be settled on shape.
It is also untrue. Every .sol file in the repository was surveyed for
hand-written scanning, and there is roughly 460 lines of genuine
character-class, repetition and alternation work:
| file | scanning lines | what |
|---|---|---|
| lib/html.sol | ~150 of 475 | names, entities, attribute values, text runs |
| experiment/lexer.sol | ~120 | every token class; the float scanner backtracks by hand |
| programs/expect.sol | ~100 | commentAt, asCount, wordBefore, markersIn |
| lib/json.sol | ~70 | the number grammar, string escapes, control bytes on output |
| programs/serve.sol | ~20 | urlDecoded, isNoteName |
json.sol has -?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)? written out by
hand — the canonical textbook expression — and html.sol has three-way
alternation over quoted attribute values. The demand is real and it is not
small.
What repeats is the cursor, not the pattern
Three idioms account for most of it, and the counts are the finding:
| idiom | sites | what it is |
|---|---|---|
{ pred(peek) }:whileTrue({ step }), then copyFrom(start, pos:dec) |
at least 15 | X+ with a capture |
"<set>":indexOf(c):notNil |
at least 12 | a character class |
split(x):join(y) |
2 | replace, which the language does not have |
The first is takeWhile; the second is a character-class predicate. Neither is
a pattern language — they are methods on something holding a position, and
every one of the five files above hand-rolls that same cursor. page.sol even
fakes a six-way alternation out of one: "h1 h2 h3 h4 h5 h6":indexOf(node:name).
So the actionable half of this entry is a lib/scan.sol — peek, match,
skipWhile, takeWhile, takeUntil — which is perhaps eighty lines of Solum,
needs no change to the VM, adds no notation, forces no decision about what a
string is, and is bounded by --steps because every step it spends is an
instruction. It would collapse the first idiom across five files. Nothing about
it requires this entry to be settled first.
That half is built. It went to ROADMAP 5.5 on 2026-08-25 and is
done the same
day — lib/scan.sol, with json.sol converted onto it. The survey above was
its case: five programs wanted a cursor and
each wrote its own, which is the admission rule met as duplication rather than
as a failure. What stays here is the argument about regular expressions, which
is what this entry is for. The two are separable, and separating them is most of
the finding.
And 3.1 decides its shape
Whatever gets built, the combinator spelling — the one everybody reaches for first, where a matcher is a block returning a block — does not work:
makeDigit := { | lo, hi |
lo := "0". hi := "9".
{ c | c:greaterOrEqual(lo):and({ c:lessOrEqual(hi) }) } }.
makeDigit:value:value("7"):print.
solvm: block outlived the frame it was written in
It has to be object-shaped instead, which composes today with nothing added:
range := object:new.
range:lo := "0".
range:hi := "9".
range:matches := { c | c:greaterOrEqual(self:lo):and({ c:lessOrEqual(self:hi) }) }.
runOf := object:new.
runOf:inner := nil.
runOf:matches := { s | | i, n |
i := #1. n := #0.
{ i:lessOrEqual(s:size):and({ self:inner:matches(s:at(i)) }) }:whileTrue({
n := n:inc. i := i:inc }).
n }.
digits := runOf:new.
digits:inner := range:new.
digits:matches("8080ab"):print. ; #4
That is not a workaround: a cursor holds a position, and position is state, so the object spelling is the one the thing wanted anyway. It is the same finding lib/html.sol reached by writing an explicit stack.
Against a literal in the language, and this part is a firm no
A regular expression is a second language with its own operators — *, +,
?, |, {n,m} — its own precedence, its own escaping, and its own control
flow, since alternation and repetition are branching and looping. Put in a
literal, it is invisible to solas, unreportable until run, and the one thing
here that could not be overridden: integer:add := ... works, and no
spelling of regex:\d := ... ever could.
The precedent is already set at a smaller scale and went the other way.
fill was kept from growing into a format language — bases went out to asBase
and asInteger(#n) so that, as the reference puts it, nothing in the spec
starts looking like a conversion character. The three notations that do live
inside strings here — fill’s blanks, asString’s padding spec, asTime’s
strptime format — are descriptive and terminate trivially. A pattern is
computational. That is the line, and it was drawn before this came up.
But a library carrying a foreign grammar in a string is settled precedent
lib/shell.sol takes an entire second language inside a
string — sh, with operators, quoting, globbing and && — and the design
accepted it, with the bargain written into the header: build the command out of
things you wrote, not out of things a file or a user gave you.
A lib/re.sol taking a pattern as an ordinary string is that shape exactly,
down to the identical hazard: a pattern built from a stranger’s input is the
same class of mistake as a command built from one. So the objection above is
to the literal, not to a library, and conflating the two was an error made in
arguing this out.
As an extension, which was the question
Of the four objections, the extension route settles two outright:
| objection | does an extension answer it? |
|---|---|
| ~1,500 lines, and the third-largest C file in the project | Yes — and POSIX regex is in libc, so it is zero lines |
| 2.13: byte semantics become migration debt | Yes — an extension’s semantics are not a language promise; version the bundle |
sōlum: a second language in a string |
Not its to answer — shell.sol answered it, for libraries |
| 3.7: unbounded work inside one instruction | No — but see below; the engine decides this, not the mechanism |
The last one was argued badly. Catastrophic backtracking is a Perl property,
not a regex property: POSIX requires leftmost-longest and ERE has no
backreferences, which pushes an implementation towards simulating the automaton
rather than backtracking through it. Measured against the system regexec, the
classic bad patterns are flat —
^(a+)+b$ n=24…40 0.0 ms
^(a|a)*b$ n=18…34 0.0 ms
^(a|aa)+b$ n=18…34 0.0 ms
^(a*)*b$ n=18…34 0.0 ms
^(a?){20}a{20}$ n=18…26 0.0 ms
— and matching is linear in the input, four times the bytes for four times the
time: 77ms at 1MB, 2,562ms at 64MB. 3.7’s own table has indexOf over 64MB at
0.27s, so this is the same complexity class, about ten times the constant.
It is not a new hole in --steps; it is the existing one, one primitive wider.
Choosing POSIX ERE over PCRE2 removes the objection and the dependency together,
and leaves a bundle that builds wherever cc does — which softens extensions
are where a portability story goes to die rather more than a third-party engine
would.
It fails the trigger, and suits the first experiment
The trigger above is a capability that cannot be written in Solum and is not worth putting in the VM. Regex satisfies the second half and fails the first — a POSIX-semantics matcher is a couple of hundred lines of Solum, built the object way for the reason given above. A codec, a socket, a window cannot be written here. This can.
This is the one entry in this section whose deferral is not about demand, and it is worth saying so out loud, because everything around it defers for want of a second customer and a reader is entitled to assume the same here. It does not. The survey above found the demand and called it real and not small; what this paragraph declines is the extension, on the grounds that the thing can be written in Solum. Read as no customer, it says the opposite of what it means.
But the extensions entry closes by asking for something else: one throwaway,
fifty lines, something with nothing to release, built to find out what the
path actually wants. Regex is close to the ideal thing to build it with, and
the wrong thing to want it for. regcomp allocates a regex_t that
regfree must take back, which makes a compiled pattern the smallest possible
test of the one real design decision in that entry — the foreign cell carrying
its own release function, and whether the hook fires for a program stopped by a
limit. A checksum cannot test that. A database can, but costs a dependency, I/O
and a network first. A compiled pattern is deterministic and holds exactly one
thing that must be given back.
Staged, that is two afternoons: v0 compiles, matches and frees in one call,
retaining nothing — the literal nothing to release case, which finds out what
dlopen and the version handshake want. v1 keeps the compiled pattern and
tests the finalizer, including under a limit-stop. Both come after the build
restructure and the supported surface, which are the real work and are
regex-independent: libsol.a is still a static archive with no -fPIC, so as
built, a loaded bundle could not resolve sol_* back into solvm at all.
One thing the survey found that is not about patterns at all
expect.sol used indexOf(suffix):notNil where it meant endsWith, at six
sites, because the language has neither startsWith nor endsWith:
isSol:value("hello.sol.bak"). ; true -- not meant
isSol:value("notes.solid"). ; true -- not meant
isMd:value("draft.md.orig"). ; true -- not meant
isMd:value("a.md.sol"). ; true -- a .sol file, checked as markdown
check dispatched on path:indexOf(".md"):isNil, so the last of those would
have been run through the markdown checker. Nothing triggered it — no such file
exists — but notes.solid is not a fanciful spelling in a repository that ships
a binary called solid. That is a real defect with a program behind it, and it
is the one thing here that satisfies the roadmap’s admission rule outright.
Fixed the same day. Not by adding endsWith to the language: the six sites
share one helper in the program that needed it, which is the answer this entry
reaches for scanning as a whole, at a scale small enough to settle in an
afternoon.
string:endsWith := { suffix |
self:size:greaterOrEqual(suffix:size):and({
self:copyFrom(self:size:sub(suffix:size):add(#1), self:size)
:equals(suffix) }) }.
"hello.sol":endsWith(".sol"):print. ; true
"hello.sol.bak":endsWith(".sol"):print. ; false
"a.md.sol":endsWith(".md"):print. ; false
Whether that belongs on string for everyone is the same question
lib/scan.sol asks, and it is not answered by one program wanting it once.
Trigger: lib/scan.sol never got one — it became
5.5 and is
built. For regex itself, the trigger is the extension
mechanism existing, which has its own trigger and is not this one. The literal
stays refused whatever happens to the other two.
Scoped on 2026-09-01, and the number nobody had taken
Asked for after awk was scoped and turned out to want this rather than the other way round. The entry above settles what to build and says a Solum matcher is a couple of hundred lines. What it never asked is whether that matcher is fast enough to be the thing two programs run on every line, and the answer changes the recommendation.
It has two shipped customers today, and neither of them is awk. This was
first written as though sed were the only one, which invited a fair question —
if the second customer is awk, and awk waits for the library, what unblocks
what? Nothing, on that framing. It is not the framing the tree supports.
sed.sol says in its own header that a script using
\(...\) is refused rather than misread, because \( is a literal
parenthesis to pattern.sol. edit.sol uses the same
engine for /, ?, n and N, over the same subset — and it is the worse
case, because it does not refuse:
/a\|b no match -- alternation, silently
/ab\+ no match
/colou?r no match
/\(ab\)c matched at #20 -- the literal text "(ab)c", not the group
Two shipped programs whose users type patterns the engine cannot take, one of them answering the wrong thing without saying so. awk would be the third, and it is not needed to justify anything. The admission rule was met before awk was proposed.
The instruction set is already here. The compilation rules are not.
check_syntax.sol carries an LPeg machine —
eleven instructions, Choice / Commit / LoopCommit, an explicit backtrack
stack, no recursion, 2,000 levels of nesting where a tree walker managed 13.
A regular expression is a PEG without rule recursion, so the temptation is to
compile ERE onto it directly.
Two measurements against the tool on the machine say not to, and both are one line:
| oracle | a PEG’s answer | |
|---|---|---|
a\|ab against ab |
<ab> |
a — ordered choice takes the first that succeeds |
a*ab against aaab |
<aaab> |
fails — Commit makes * possessive |
POSIX is leftmost-longest and its * is greedy with give-back. LPeg’s
Choice is ordered and its LoopCommit throws the choice point away, which is
the right semantics for a grammar and the wrong one for a regex. The eleven
instructions carry over; { a } compiling to a committed loop does not. Worth
knowing before the design rather than after, and it cost two printfs to find
out.
Two dialects, and the back-reference decides the strategy
sed needs POSIX BRE: \(...\), \1…\9, \{n,m\}. awk needs POSIX
ERE: |, (...), +, ?, and no back-references at all. A
back-reference is precisely the construct that stops an implementation
simulating the automaton, so it decides the strategy for both:
- a backtracker does back-references, needs the longest match taken by exhausting alternatives rather than stopping at the first, and has the exponential cases;
- an automaton simulation gives leftmost-longest and linear time for free,
and cannot do
\1at all.
Designing for sed alone lands on a backtracker by default. Designing for both
means choosing it on purpose, or carrying two engines and dispatching on whether
the pattern has a back-reference — which is what several real implementations do
and is the honest third option.
The measurement the entry above never took
pattern.sol over a megabyte of this repository’s own prose — 20,000 lines,
[a-z]*ing, matched on 5,130 of them — on an -O2 build:
| 1 MB, 20,000 lines | |
|---|---|
pattern.sol |
2.53 s |
grep -E |
0.03 s |
awk |
0.03 s |
libc regexec, from the table above |
0.077 s |
That is the simple engine, with no alternation, no groups and no instruction dispatch — an array of items walked by a loop. An instruction machine with a backtrack stack is slower than that, not faster, and the interpreter’s own dispatch cost is measured at 38% for exactly this kind of inner loop.
So the entry above is right that a POSIX matcher can be written in Solum, and that is not the question a hot path asks. Two hundred lines of Solum at roughly 0.4 MB/s is fine for a sed script over a config file and for awk over the oracle corpus; it is not fine for a log. Nobody has said which of those this is for, and that is the call this scoping exists to surface rather than to make.
The throwaway was built, and it answered more than it was asked
Two hundred lines rather than fifty, thrown away afterwards: POSIX ERE parsed to
a tree, compiled to Pike’s instruction set, run by a loop with an explicit
backtrack stack. | ( ) * + ? . [ ] ^ $ and nothing else.
The semantics come out exactly. Six cases, against /usr/bin/awk, compared
by diff rather than by eye — a|ab, a*ab, (ab)+c, [a-z]+@[a-z]+,
^ab*$, x?y — identical, all six, including the two a PEG gets wrong. So
the instruction machine is the right foundation and the compilation rules are
the whole of what had to change.
And leftmost-longest is free, which is the thing this section worried about. Taking it by exhaustion rather than stopping at the first match, over the same megabyte, three runs each: the two are indistinguishable, with ±10% run-to-run variance swamping the difference. A single run had shown 15% and that was noise — a fourth reminder in one day that one measurement is not a measurement.
The cost of the machine, same corpus, same -O2 build:
| 1 MB, 20,000 lines | |
|---|---|
| the throwaway | 5.7–6.1 s |
pattern.sol |
2.8 s |
libc regexec |
0.077 s |
grep -E, awk |
0.03 s |
So an instruction machine is twice pattern.sol and about 190 times the
tools. The scoping guessed two to four times and the low end held.
The finding it was not sent to get
It is exponential, and this section’s parent entry said it would not be.
That entry argued catastrophic backtracking is a Perl property, not a regex
property: POSIX requires leftmost-longest and ERE has no backreferences, which
pushes an implementation towards simulating the automaton. On ^(a+)+b$
against a run of a, the throwaway doubles per character:
| n | 12 | 14 | 16 | 18 | 20 |
|---|---|---|---|---|---|
| 0.011 s | 0.044 s | 0.174 s | 0.682 s | 2.7 s |
The entry’s own table has libc flat at 0.0 ms for n=24…40. What POSIX pushes a
C library towards is not what it pushes you towards, and building for sed —
which needs back-references — is what lands you on the backtracker in the first
place.
The standard fix works and is cheap. Pruning a (pc, sp) pair already seen
turns the backtracker into an automaton simulation by another name: 0.000 s,
flat to n=24. What it costs on ordinary work is the only question, and the
answer depends on the key — a built string cost 63%, an integer pc × (n+2) + sp
costs 20–30%. That is cheaper than the risk it removes.
Corrected on the same day: the headline number was the worst case
[a-z]*ing is close to the worst pattern this engine can be given, and it
was chosen for the benchmark without noticing that. It opens with a starred
character class, so every position in the line is a candidate and nothing can be
skipped. Measured over the same megabyte, same build, against patterns anybody
would actually type:
| pattern | the throwaway | grep -E |
|---|---|---|
[a-z]*ing |
5.59 s | 0.03 s |
threshold |
0.91 s | 0.03 s |
system:[a-z]* |
0.89 s | 0.03 s |
[0-9]+\.[0-9]+ |
0.93 s | 0.04 s |
So the ratio is 25–30×, not 190×, for everything except the case that was
measured. And the throwaway has none of the optimisations pattern.sol already
carries: its leader — scan for the first literal byte before trying to match —
takes a whole-buffer search of docs/REFERENCE.md, 4,269 lines, to 0.008 s.
An engine with a leader and a typical pattern is not in the same conversation as
one without a leader and a pathological one.
Reporting the worst case as the number is the fourth measurement mistake in one day, and the same shape as the others: one sample, taken once, generalised.
And 3.7 is a weaker argument than this section first claimed
The parent entry lists 3.7 — unbounded work inside one instruction — as the one objection an extension does not answer. Measured from the other side:
$ solvm --steps=20000000 ere.sob # ^(a+)+b$ against 26 a's, unmemoised
solvm: stopped: the step limit of 20000000 was reached
exit 124
An engine written here is bounded by construction, because every step it takes is an instruction the machine counts.
But there is no blow-up inside regexec to be bounded, and saying otherwise
was overreach. Measured against the system’s own matcher, both dialects, on the
case that ruins the throwaway:
| n=16 | n=20 | n=24 | n=28 | n=40 | |
|---|---|---|---|---|---|
grep -E '^(a+)+b$' |
— | 0.029 s | — | — | 0.026 s |
grep '^\(a\+\)\+b$' (BRE) |
0.027 s | 0.023 s | 0.022 s | 0.023 s | — |
Flat, both dialects. The exponential is a property of a hand-written
backtracker, not of regular expressions, which is what the parent entry said
in the first place and this section briefly claimed to have refuted. What
survives is the milder form: a regexec over 64 MB is 2.5 s of work inside one
instruction, linear rather than unbounded, and --steps still cannot see into
it. That is a real difference and a much smaller one than the objection belongs
to the C route.
So does the price fire the extension trigger after all?
Asked on 2026-09-01, against this section’s own claim that regex fails the
trigger because it can be written in Solum. The question is a good one and the
precedent is already here:
6.34 was
a number that had always been reachable — stty size through a shell, at 7 ms
an ask — and it became a primitive anyway, with the finding written down as the
absence was never the finding, the price was. Price fires triggers in this
repository. Can be written in Solum is not the same claim as is usable by
the thing that wants it, and this section conflated them.
What the price actually is, now that it has been measured properly:
| typical patterns, in Solum | ~1 MB/s, 25–30× the tools |
with a leader, as pattern.sol already does |
a 4,269-line buffer searched in 0.008 s |
| pathological patterns | 190×, and pattern.sol is no better |
| libc, either dialect, everything | flat |
At 25–30× the two shipped customers are comfortable — an editor search is already instant with a leader, and a sed script over a config file is not a measurement anybody would take. At 190× nothing is comfortable, and the pathological case is exactly what a pattern from a stranger looks like.
So the trigger question resolves into a narrower one, and it is not about speed in general:
- for the customers that exist, Solum is fast enough and the trigger does not fire;
- for a pattern this repository does not choose — a
serve.solroute, a user’s/in the editor — the worst case is 190× and exponential without the visited set, and that is a price the caller sets rather than the author; - libc is flat on both, which removes the pathological case entirely and
costs the
--stepsbound and a build restructure that is not done.
That is the call, and it is sharper than what input is it for. Not how fast but who chooses the pattern.
The calls, revised by the throwaway
Throwaway first?Done, and it changed two of the four answers below.What input is this for?Replaced by a better question: who chooses the pattern? For patterns this repository writes, Solum at ~1 MB/s is comfortable and the extension trigger does not fire. For patterns a user or a request supplies, the worst case is 190× and exponential without the visited set, and libc is flat on everything. Recommendation: Solum for the two customers that exist, and treat a stranger-supplied pattern as the thing that would fire the trigger — the same shape asshell.sol’s bargain, build the command out of things you wrote.- One engine or two?
Decide after the throwaway.One. The visited set makes the backtracker behave like the automaton on the cases that matter, and back-references still work, which no automaton gives you. - Memoisation on by default? New. Recommendation: yes — 20–30% is a smaller price than an unbounded one, and a pattern that never needs it pays only that.
- Does
pattern.solstay? Unchanged. Recommendation: replace, and letsed’s oracle corpus prove the swap.
Built on 2026-09-01, on the call that the patterns here are ours
lib/re.sol replaces lib/pattern.sol: both POSIX dialects, groups,
back-references, {n,m}, leftmost-longest, and the leader optimisation carried
over because it is worth more than anything else in the file. The header carries
the bargain in shell.sol’s words — build a pattern out of things you wrote,
not out of things a file or a user gave you — and guarded is there for a
caller who turns out not to control its input.
The proof was the corpus rather than a test. sed.sol swapped onto it and
oracle.sh went from 60 agreeing cases to 62 — the two that had been added
to differ/ an hour earlier, documenting the gap, now agree with the oracle.
The harness said so itself: AGREES — the divergence has gone, and the file
still claims it.
And a differential against the file it replaces: 4,485 comparisons over 23
patterns and 19 texts, 28 disagreements, all one shape — pattern.sol’s
endOfMatchAt ignored ^, so an anchored pattern answered a match at a
position other than the first. The oracle agrees with the new one. No shipped
caller could reach it, because every path to endOfMatchAt goes through
findFrom, which handled the anchor separately.
Two things it cost that the throwaway had not shown. A sequence had to be a
list rather than a spine of pairs: the emitter walks the tree, so abcd… was
as deep as the pattern was long and ran out of frames at about 220 characters,
where pattern.sol compiled two thousand. Caught by measuring a claim before
writing it down. What remains is real nesting — 48 groups deep compile and 49
do not — which nothing writes.
And deleting a file found a hole in
the link checker
built the same morning: it checked only links carrying a #, on the entry’s
reasoning that a missing file is a different question and nothing here has got
one wrong. Nineteen links pointed at the deleted file and it reported none —
and the sweep turned up three that had been broken all along, two naming
sob.sol beside them when it lives in lib/, one naming follow.sh beside it
when it is in tail/. The scope was not narrow, it was wrong, and in the
direction that hides things. It checks paths now: 2,162 of them.
An early exit from a loop
3.13 records the
fact: a whileTrue body cannot end its own loop, so a loop that must stop from
inside carries a boolean whose only job is to stop it, and the only other exit is
to raise an error and catch it outside. Six files do the first, and that entry
deliberately no longer counts them — the number went stale twice and is not what
the case rests on. This is the feature
that would answer it, and why it is not being built yet.
What the relatives do:
| Lua, Ruby, JavaScript, C | a break keyword |
| Ruby, additionally | throw/catch — a tagged non-local exit, kept deliberately separate from exceptions because leaving a loop is control flow and not failure |
| Common Lisp | block / return-from, named and lexical |
| Smalltalk | nothing of its own — detect:ifNone: and friends, each implemented with ^ |
| Scheme | call/cc, which is every answer at once and none of them small |
Ruby’s pair is the interesting one, because it is exactly the distinction Solum
is missing: error:raise already works as an exit and is the wrong register, in
the same way throw exists in Ruby so that leaving a loop does not have to
pretend to be an error.
Why not a break keyword. Two reasons, neither about difficulty. Control
flow here is message sending, and a keyword would be the language’s first
control-flow keyword — which is the argument that already refused
ifTrue{...}: it makes a
message send look like syntax exactly where the language works hardest to prove
it is not one. And break and continue are already Solid’s commands, so the
word is taken inside the project’s own toolchain.
Why not detect and its family, which is the Smalltalk-flavoured answer:
neither site it would have to serve is a collection enumeration.
html:element:find is a worklist traversal that pushes onto the very array it
is walking, and json:parseArray is a scanner over a cursor. It would also have
to answer the reference’s standing argument against multiplying search messages
— “one message that answers where is worth more than two, one of which only
answers whether” — which is why there is no includes.
The shape that would fit, if this is ever built: a message a block sends to
leave the loop it is the body of, answering a value, and distinct from
error:raise so that a caller’s onError is not the thing that catches it.
Cheap in the spelling the compiler inlines and not cheap anywhere else — see
3.13 for that fork, which is the whole cost.
Trigger, said exactly: a loop whose body must skip its remainder once the
flag is set. Today none does — every site either sets the flag at the tail of a
branch or wants the rest to run, and html:closeThrough deliberately runs a
self:pop after setting it. The moment one does not, the flag has to be
threaded through the body as done:not:ifTrue({ ... }) and the workaround stops
being a condition and starts nesting. That is a bug class rather than a
readability complaint, and you would know it had happened.
forever, and break as a message — 2026-08-23
A shape was proposed that this entry had not considered: a loop with no condition at all, left only by breaking out of it.
{ ... :break ... :continue ... }:forever
Two things about it are better than what is above, and one problem survives untouched.
break need not be a keyword. Written as a message on boolean it reads
i:greaterThan(#10):break. — a send, sitting exactly where ifTrue would sit,
and answering nil when the receiver is false. That dissolves the first of the
two objections recorded above: there is no new keyword, and nothing starts
looking like syntax. The second objection survives: break and continue are
still Solid’s commands, and the word is still taken inside the project’s own
toolchain.
A conditionless loop makes break unambiguous. Bolting an exit onto
whileTrue raises the question of what it means when the condition would also
have stopped the loop; forever has nothing but its exits, so a break is the
only thing it can be about. That is a cleaner construct than the one this entry
was imagining, not merely a different spelling.
And it is writable today, entirely in the library, with break and
continue raising markers that forever catches and anything else passing
through:
boolean:break := { self:ifTrue({ error:raise("--break--") }) }.
block:forever := {
{ { true }:whileTrue({ self:value }) }:onError({ e |
e:message:equals("--break--"):ifFalse({ error:raise(e:message) }) }).
nil }.
What it costs, measured over 200 runs of a 1,000-iteration loop against the flag idiom it would replace:
the flag, in a literal whileTrue the compiler inlines |
0.058s |
forever with break |
0.097s — 1.7× |
forever with a continue firing every other pass |
0.289s — 5.0× |
Some of that is unavoidable in any library loop: control.sol records about
1.30× for a block call per iteration, which { ... }:forever pays and an
inlined whileTrue does not. The rest is the error machinery, and continue
is where it hurts — a raise per skipped iteration, which is exactly backwards,
since skipping is meant to be the cheap case.
So the fork in 3.13
is unchanged and now has numbers on both sides. A compiled forever is free —
a backward jump, a forward jump, and a jump to the top — and makes the inlining
semantic, which the compiler’s own comment warns against by calling it “an
optimisation only; the meaning is exactly that of the message”. A library
forever exists, works, and costs 1.7× to 5×.
The trigger has still not fired, and it is worth being exact about why, since
this proposal is nearly it. The trigger is a loop whose body must skip its
remainder — which is what continue is for. But wanting the construct is not
the same as a loop needing it: no loop in this repository has yet had to thread
done:not:ifTrue({ ... }) through its body. The prototype above is what to
reach for on the day one does.
Intercepting a message that was not understood
Smalltalk’s doesNotUnderstand: and Io’s forward: when a lookup fails, send
the receiver a message about the failed send instead of reporting it. It is
the single largest capability on this list — proxies, recording mocks, remote
objects, a DSL that answers anything — and it has never been considered here.
A customer for it was named on 2026-08-25, and it is not a proxy for its own sake: sending code to a machine that is already running wants a remote object, which is the canonical use of this message everywhere it exists. See networking. Neither has fired; if either does, they arrive together.
Mechanically it is small, which is the surprise. There are exactly two real
lookup failures in the machine: vm.c in the dispatch loop, and again in
sol_vm_send for the C-side entry. At the first, the receiver and its arguments
are still laid out on the value stack in frame order and nothing has been popped
— which is most of what a re-send needs.
Three things it would cost.
A recursion guard: a receiver whose doesNotUnderstand is itself missing would
come straight back to the same site.
The error message. Today a typo answers “integer does not understand ‘pritn’” and that is one of the language’s better diagnostics; the default handler would have to keep producing it, so the feature is really “a hook before the existing error” rather than a replacement for it.
And a third site that cannot play. Inlined conditionals report “boolean does not understand ‘ifTrue’” from a synthesised failure with no receiver object and no lookup — the compiler kept the selector purely so the complaint matches. A program could not intercept that one, so the feature would be almost uniform, which is the kind of exception this language usually declines.
Trigger: a program that wants to stand in front of an object — a proxy, a recorder, a stub — and cannot. Nothing here has wanted one; every program so far has owned both sides of every call.
A set, and the collections that are not there
There is no set, no bag, and no way to ask whether a value is in one except
indexOf(v):notNil down an array. A dictionary with values nobody reads is the
only stand-in, and dictionaries.sol uses exactly that shape to count distinct
words.
Related absences, all of them writable in Solum today: detect, reject,
any, all, sortBy, zip, flatten, reverse, isEmpty, sum.
The measured lesson applies before any of it is built. Four loops began in
lib/control.sol and left for the VM because a Solum
version costs a block and a frame per element — a primitive repeat measured
3.2× the library one. So the answer to “should there be a detect” is not yes
or no but write it in Solum, use it, and measure before promoting it, which is
the route every one of those loops took.
A set is different in kind: it wants hashing, and sol_dict_key_ok already
settles what may be hashed — values only, never an object, because two that look
alike would be two keys.
Trigger: a program that keeps a dictionary whose values it never reads.
Mathematics, and a source of randomness
integer and float have arithmetic, comparison, bit operations and rounding,
and nothing else. There is no sqrt, pow, min, max, between or clamp,
no trigonometry, no pi, and — more conspicuously for a scripting language —
no random number source at all, anywhere in the VM, the library or system.
min and max are two lines of Solum and want no decision. The rest divides:
- Pure functions of a number —
sqrt,pow,abson a float — are primitives overlibmand cost nothing but the decision to have them. - Randomness is not a function, it is state, and where that state lives is
the actual question: a global seed makes two runs of a program differ, which
is exactly what a test suite is built to prevent, and this project’s own
suite compares output byte for byte. So a random source wants to be a thing
you make with a seed you can name, not a message on
integer.
Trigger: a program that wants one. None has — and the absence has gone unnoticed for nine programs, which is itself a finding about what this language has been used for.
The trigger fired, with the tenth. bench.sol wanted a standard deviation, a minimum, a maximum and two uses of randomness, and carried all of them itself. The entry moved to 3.14.
Half of it is now answered, and the split is the interesting part. sqrt is
a primitive; min, max and between are math.sol, a
library file with no VM change behind it. The line between them is not
importance — it is that the comparisons were written correctly the first time
and there is nothing in them to get wrong, and the square root was written
twice and was wrong both times without saying so, the second version worse
than the first by nineteen orders of magnitude above 1e21. A thing every program
would get wrong the same way belongs in the machine.
The paragraph above about where randomness should live was right, and it is
what got built. A random source wants to be a thing you make with a seed you
can name, not a message on integer — which is random:new and
random:new(#seed), state in the object, nothing global, written here before
anything was measured and not improved on afterwards.
The trigger was the part that was wrong. It said this waited on a program
wanting randomness for what it does rather than for how it measures, and
counted bench.sol as the second kind. That misread the program: its product is
the confidence interval and the interval is computed by bootstrap resampling, so
the randomness is the algorithm rather than the instrumentation. The trigger had
fired on the day it was written and went on looking unfired for four releases.
What actually settled it was measuring the generator that program carried. It
was correct and its seeding was not — the clock being the only entropy a Solum
program can reach, two runs a microsecond apart got consecutive seeds, and the
first coin flip was then exactly the parity of the start time. Neither that nor
the modulo bias on the way out could be fixed in Solum, which is the argument
that made sqrt a primitive.
The trigonometry named at the top of this entry went to 3.14 too, with its own trigger and the reason it would be a primitive rather than a library — argument reduction, harder to get right than the square root was and failing the same way. The trigger fired on 2026-08-25 and it is built.
Splitting the reference into pages
docs/REFERENCE.md is about 2,500 lines. Should it become several pages?
Not yet, and the trigger is measurable rather than a feeling: when the message reference is longer than everything above it.
What one page is worth. A reference is read by searching, and one page means one search. Splitting it means knowing which page a thing is on before looking for it — which is the question the reader had in the first place. Every multi-page reference answers that with an index, and an index is what was just added, so the split would be paying a cost to buy something already in hand.
The seam is already visible, which is the useful part of asking early. The sections divide cleanly in two:
| lines | |
|---|---|
| the language — syntax, values, blocks, control flow, objects, errors | ~1,910 |
| Message reference — what each type answers | ~700 |
Everything above the message reference describes a language that has nearly stopped changing; the message reference grows with every built-in. So the split, when it comes, is the language and the messages, and the message index becomes the bridge between them rather than a thing to build later.
The trigger, said exactly: when Message reference exceeds the sum of the sections above it. That is a number the file can be measured for, it does not depend on anyone’s patience with scrolling, and it marks the point where the document is mostly a catalogue with a language attached rather than the other way round. At roughly 700 against 1,910 it is not close.
What to do before then, if it gets uncomfortable sooner: the message reference could move to its own page while everything else stays, which is one file and one link rather than a reorganisation. That is the same split, taken early, and it is worth preferring to a scheme with four pages and a navigation scheme of its own — this project has one reader today and adding a table of contents to a table of contents is not what would help them.
Re-measured, since a trigger nobody checks is a trigger that never fires: the file is about 2,800 lines, the message reference is about 700 of them, and everything above it about 1,910. Both halves grew and the ratio barely moved, which is the answer — the language sections have gone on growing alongside the catalogue rather than standing still while it filled up. Not close, and not trending towards close.
An assert, and compiling it away
Two proposals in one: a message that raises when a claim is false, and a compile-time switch that removes it from a production build.
The switch: no. Three reasons, and the third is the one that decides it.
It reopens conditional compilation, which is turned down
above on the grounds that a file would
stop being the program you can read. An @assert that vanishes has exactly that
property. Side effects vanish with it — assert(advance()) is a bug C
programmers have been making for forty years, and it is silent.
And the reason particular to this language: the checks people write here are validation, not assertion, and those are not the same thing at all.
| what it checks | may it be stripped? | in this repository | |
|---|---|---|---|
| validation | the input is wrong | never — it is the program’s error handling | ~23 |
| assertion | my own code is wrong | in principle | none |
Every hand-rolled check in the examples and libraries is the first kind.
bracket:equals("["):ifFalse({ error:raise("not an arrow") }) in
keys.sol reads exactly like an assert and must never
vanish: it is parsing input from a terminal. A switch that removes one kind will
be pointed at the other by somebody wanting a faster loop, and what it deletes
is the error handling.
The message itself: defer, and the reason is the count. This project let
inc and dec in on the strength of add(#1) being three in ten arithmetic
sends. The equivalent number here is zero — nothing in the tree writes a
debug-only invariant check, and what it does write is served by what exists:
x:greaterThan(#0):ifFalse({ error:raise("x must be positive") }).
What it costs, measured over 300,000 turns of a loop, since the argument for stripping is speed:
| seconds | against a bare loop | |
|---|---|---|
| bare loop | 0.0424 | — |
a library assert helper |
0.1051 | 2.5× |
| the condition written inline, as above | 0.0615 | 1.4× |
The expense is the helper call, not the check: ifFalse written literally
compiles to a jump, and a helper is a block call per iteration. So the fast form
already exists, and 1.4× on an empty loop is the worst case there is.
If it is ever added, the shape is a message the compiler inlines the way it
inlines ifFalse — x:greaterThan(#0):assert("must be positive") — costing a
comparison and a branch, and always running. That adds the word without
adding a second meaning to the same file.
The trigger: a program that writes a check it would genuinely want off in production, and can say why the inline form is not enough. None has.
Default values for block parameters
Asked on 2026-08-23: if a block takes a parameter the caller usually gives the same value for, could it carry that value itself?
myfun := { x := #0 | doSomething }. ; a sketch; not valid today
myfun:value. ; -- x would be #0
myfun:value(#5). ; -- x would be #5
The case for it is stronger than convenience, and it is not the syntax. The language already has defaulted arguments — it is just that only C can write them:
d := dictionary:new.
d:atPut("port", #80).
d:at("port"):print. ; #80 -- one argument
d:at("host", "any"):display. ; any -- two, and the second is a default
"45":asInteger:print. ; #45
"2d":asInteger(#16):print. ; #45 -- and again
at, asInteger, asString, sorted, first, last, timeToRun and
perform all take an argument or do not, and in several of them the extra
argument is exactly a default value. A block cannot:
f := { x | x }.
{ f:value }:onError({ e | e:message:display }). ; 'block' takes 1 argument, got 0
So this is one of the few places where user code cannot do what built-in code
does, and the language otherwise works hard to keep those the same thing — a
class is an object, control flow is message sending, [a, b] really is
array:of(a, b). That asymmetry is the argument, and it is a better one than
saving a word at a call site.
Three spellings, and the third is the one to build
Two were proposed, and the difference between them is not taste — one of them answers a design question the other leaves open.
{ x := #0 | body } ; a sketch; the first proposal
{ x:{#0} | body } ; a sketch; the second
{ x := { #0 } | body } ; a sketch; what they suggest between them
All three are free. The grammar refuses each today, so nothing becomes ambiguous:
> f := { a:{45} | a }.
[line 1:10] solas: expected a message name after ':' at '{'
> f := { a := #0 | a }.
[line 1:16] solas: expected '.' between statements at '|'
{ x := #0 | ... } reads the way a default reads and costs the most to
scan. solas decides parameters with a copy of the lexer rather than a parser,
skipping identifiers and commas; this makes it skip an arbitrary expression to
find the |, and one containing a block means balancing braces anyway.
{ x:{#0} | ... } is cheaper to scan and says the wrong thing. Cheaper
because skip_block already exists — it balances braces and is what the
inlining probe uses — so the rule is IDENT COLON LBRACE, and the scan is
bounded by construction. Wrong because : is how this language sends a
message, and a:{45} is not one. That is
the objection that refused ifTrue{...}
seen from the other side: that proposal made a send look like syntax, and this
makes syntax look like a send. The language spends a lot of ink insisting : is
always a send; a second meaning for it is expensive in a way fifteen lines of
parser is not.
{ x := { #0 } | ... } takes what each got right. := still means bind,
which is what a default does; the default is a block, so the scan is skip_block
and stays bounded; and — the part that matters most — the default is code that
runs when the argument is missing, rather than a value fixed once.
That last point settles one of the three questions below before it is asked.
{ xs := { [] } | ... } makes a fresh array per call, where a value evaluated
once would share one array between every caller — the mistake nearly every
language with this feature has shipped at least once, and Python’s
def f(xs=[]) is the famous one. The block spelling makes the right answer the
only one that can be written.
It costs two characters against the first proposal and reads honestly: a block
is code, and a default that can call system:clock was always going to be code.
A parameter defaulting to a block value nests, { x := { { #0 } } | ... },
which is consistent and rare.
None of this is built. The trigger below has not fired, and the syntax being settled does not change that — it means the entry is ready if it does.
The machine is the larger half, and it reaches the file format.
| arity stops being a number | It becomes a range, [required, total]. .sob carries u16 arity per method (serialize.h), so a second number is a format version bump — every .sob recompiled. |
| the callee has to know what it got | sol_vm_call checks argc != code->arity and builds the frame; nothing tells the body how many arguments actually arrived, and filling defaults means knowing. |
| a default is code, not a constant | { x := system:clock \| ... } has to run somewhere. Running it in the callee’s frame gives a block a prologue with jumps — the same shape an inlined conditional has, but generated rather than written. |
Two questions left to settle, the third having been answered by the spelling
above: may a default see an earlier parameter ({ a, b := { a:inc } | ... }),
which decides whether the prologue is one pass or ordered; and what respondsTo
and the arity error should say about a block that takes one argument or two.
What works today, and it is not nothing. The caller says nil and the block substitutes:
greet := { name |
name:isNil:ifTrue({ name := "world" }).
"hello, {}":fill([name]):display }.
greet:value(nil). ; hello, world
Or one parameter carries the options, and the defaults are spelled with the tool the language already has:
draw := { options |
"{} at {}":fill([options:at("shape", "circle"),
options:at("size", #10)]):display }.
o := dictionary:new.
o:atPut("shape", "square").
draw:value(o). ; square at 10
Both cost the caller something — a nil it did not want to write, or a
dictionary to build — and neither is wrong.
Trigger: a program here writing the same block twice, or threading a nil
through a call it did not want to make, for one optional argument. Nothing has
yet. This is written down rather than started because
ROADMAP.md’s admission rule is that an entry means a program
wanted something and could not have it, and no program here has — the idea came
from thinking about the language rather than from writing in it, which is what
this document is for.
Constants, and whether they should be a thing
Asked on 2026-08-23. There are no constants: every name is a binding and :=
can rebind any of them. Two ways to add them were put:
- New syntax — a second kind of assignment the compiler refuses to rebind. Set aside by whoever asked, on the grounds that the syntax is clean and this would cost some of that.
- A directive —
@constant pi 3.14159, which the compiler knows, refuses to rebind, and can substitute at the use site. Thenpineed not be a primitive; it can live in a library and cost nothing at run time.
Two different things are being asked for, and only one is new. A name for a
value already exists — the @define entry
settled it: maxRetries := #3. is a name holding a value, and it obeys scope.
The idiom for a library is a slot on an object, math:pi := 3.141592653589793.,
which needs no language change and namespaces better than a flat directive
could: two libraries with @constant max would be an unshadowable collision
where two objects with a max slot are simply two objects.
Enforced immutability is the new part, and it collides with a bargain already struck. The collision warning (6.21) is a warning rather than an error, and compiler.c says why: “rebinding is legal and sometimes meant: a program may want to replace something a library bound.” A constant would be the first name a program is forbidden to replace.
The language holds both positions at once, which is worth seeing together. The
inlined and/or emit a constant true/false rather than reading the
globals, because “a program can rebind” them and the shortcut and the long
path would disagree. And [a, b] deliberately sends to the ordinary global
array, so that rebinding that name changes both spellings and they cannot
drift apart. Rebindability is a hazard in one place and load-bearing in the
other.
The performance argument is right, and it points somewhere else
The case for constants is that OP_CONST is an array index where OP_GLOBAL is
a lookup. That is true, and measuring how much it is true is what made this
entry interesting: global lookup walks a list, linearly, at about 1.35ns a slot
— 3.17 has the numbers. At
800 globals a constant is 16× faster.
But the fix that number argues for is not constants. A hash on the root
speeds up every global read in every program; a constant speeds up only the
names somebody remembered to declare. That is the same reasoning the @define
entry gave for making loops primitives rather than macros — it speeds up every
caller rather than the ones who remembered.
That is now built, and it argues against constants harder than this entry
did. An object with more than a dozen slots keeps a table beside its list, and a
global read is a constant-time lookup at any depth: 2.88× at 60 globals, 1.37×
at 16. It also turned out to be worth more to sends than to globals, which is
the half of the reasoning nothing here had — the built-in messages are
registered in order and a new slot goes on the front, so add sat 35 slots down
integer’s list of 38 and every integer send paid for it. Real programs came
out 1.09× to 1.31× faster with no change to the language at all. See
3.17.
The memory argument runs backwards
A constant is not cheaper to hold than a global, because the constant table is per chunk and a block is a chunk. Three blocks using the same literal:
a := { 3.141592653589793 }.
b := { 3.141592653589793 }.
c := { 3.141592653589793 }.
compiles to three chunks with one constant each — three copies of the
double. The global it would have replaced is one slot on one object, read from
all three. So substituting a constant into n chunks costs n copies where the
binding cost one.
Verdict
Defer, and probably no. Not because the speed argument is wrong — it is
right — but because the measurement it rests on argues for fixing the lookup,
which helps everything, over adding a second kind of name, which helps what it
is told to. And a second kind of name is expensive in a language that has one
kind of everything: := binds, later wins, the warning says so, and slots
lists what is there.
Trigger: a program measurably slowed by global reads — which was 3.17, and is now done, so this would have to be a program still slowed by a lookup that is already constant-time; or a case where a name genuinely must not be rebindable and a warning is not enough.
pi needs none of this, incidentally, and in the end it did not go in
math.sol either. It is float:pi, a class-side message that
arrived with the trigonometry
(3.14) — the argument
being that infinity and nan are globals because they are values the
arithmetic reaches, while pi is a constant and pi is a name a program is
entitled to want.
Infix arithmetic, as a compile-time notation
@expr(a^2 + 3 * ((a/2):sin + b:sqrt)), lowering to exactly the sends it reads
as. Raised on 2026-08-28 from use rather than from reasoning: equations are
hard to write in this language and harder to check by eye.
The difficulty is narrower than arithmetic is unreadable, and naming it
narrowly is what makes the answer small. A send chain reads strictly
left-to-right and arithmetic precedence does not. A linear formula is already
fine — x:add(y):mul(z) is (x + y) * z, in that order. What breaks is
nesting: the outermost operation ends up in the middle of the line and an
argument runs to the end of it. With a = 5 and b = 9:
5.0:pow(2.0):add(3:mul(5.0:div(2.0):sin:add(9.0:sqrt))):print. ; 35.79541643231187
That is a^2 + 3*(sin(a/2) + sqrt(b)), and the two are hard to check against
each other, which is the whole complaint.
The counter-argument first, because it is a real one. The existing style
already answers the general case by naming the parts: stddev in
bench.sol is a mean, a squared deviation and a division,
each with a name, and it reads perfectly. A notation for dense one-liners makes
it possible to write the density that naming avoids. So the case for this is
not everyday arithmetic — it is transcription fidelity: when a program
implements a standard, the dense form is the checkable form, because it is the
form printed in the standard being copied from. That is the same argument
sola.sol and pascal.sol already
run on, and it is the argument this entry rests on. If it turns out that nothing
here transcribes a formula, the answer is don’t.
Two things already on the record pull opposite ways
For it, design.md states the rule this would live under: two
spellings of the same thing mean the same thing; where a shorthand exists it is
notation, never a second semantics. [#1, #2] and array:of(#1, #2) produce
identical bytecode, and the compiler gets there by supplying the selector name
itself — array_literal() emits a global load and an of send, both from names
it makes up. An infix form that lowers to add, sub, mul, div and pow
is the second member of that family rather than a new mechanism.
Against it, solum.bnf opens by saying
there are no operators, no control-flow syntax and no keywords, and offers
that as the point of the language rather than a fact about the file. And
ifTrue{...} was refused for
making a message send look like syntax exactly where the language works hardest
to prove it is not one.
What does not apply is the refusal of
the rest of the preprocessor. @ifdef
was turned down because the text on the screen stops being the program — a
reader has to know which switches were set before they can say what a file
means. This has no switches: it is a total, local, deterministic
transliteration, and the same characters mean the same thing on every build.
That entry ends by setting the test this one has to pass — if a real one
appears, @ is ready, and the case for it will be that nothing in the language
already does the job — and the honest reading is that the language does do the
job, badly. This is a legibility argument, which is a weaker kind of argument
and has to say so.
Four findings from the compiler
One: it has to be named, and @expr( is free. The lexer requires a letter
after @ and makes the whole directive a single lexeme, so @include is one
token and never an identifier following a symbol. @expr lexes today with no
change at all; @( does not lex and would need one.
Two: it would be the first directive that is an expression. primary()
refuses one now — a directive must stand alone as a statement — and the
grammar says the same in prose: @include is the only statement that is not an
expression, because there is nowhere inside an expression to compile a file
into. result := @expr(...) changes what @ means from a compile-time
statement to a compile-time thing that can also be a value. The code change
is small and the conceptual one is not, and it is the part to argue about.
Three: - is the one genuine conflict, and it is provably harmless. A
leading - belongs to the literal today — there is no negation operator to
mistake it for, and b := a - 3. fails with ’-‘ must be followed by digits.
So the region needs a lexical mode in which - is an operator, which is the
kind of thing this language avoids. What makes it acceptable is that
it changes the meaning of no program that is currently legal: a - 3 and
a-3 in expression position are both syntax errors as things stand. Inside the
region a bare -3 reads as unary minus applied to 3, which is the same value;
fold it at compile time and it is the same bytes. One character conflicts, and
the conflict is value-preserving — which is a claim a test can hold.
Four: bare numbers are already floats, so no coercion rule has to be
invented. 3 is a float and #3 an integer, which means
a^2 + 3 * (...) reads correctly exactly as written. This was the collision
expected to sink the idea — sugar that looks like ordinary mathematics, in a
language that refuses to coerce — and it does not happen. The strictness shows
through in one place instead: pow, sqrt and the trigonometry are float-only,
so #4^#2 is a run-time integer does not understand ‘pow’. That is the
existing language being visible, not a new rule, and it is the right behaviour.
What it should be, and what it should not
In: + - * / onto add sub mul div; ^ onto pow, right-associative and
binding tighter than unary minus so that -2^2 is -(2^2). That last call has
already been made once here — sola:parsePower is the only right-associative
level in SolaBasic’s ladder and sits exactly there — and agreeing with it costs
nothing.
Out, at least to begin with: prefix function calls. sin(a/2) needs a rule
mapping f(x) to x:f, and the rule breaks on the second and third cases it
meets: atan2 is class-side float:atan2(y, x), and pow takes an argument.
A rule with exceptions is precisely what
ifTrue{...} was refused for.
Leave a term as an ordinary Solum expression and the difficulty disappears
along with the rule:
result := @expr( a^2 + 3 * ((a/2):sin + b:sqrt) ).
Nothing new to learn, no second naming convention, and the stated problem is
solved. Whether sin(x) earns a place is a second decision, and the thing
that should settle it is a page of real transcribed formulas rather than the one
example that prompted this.
Cost, including the part that is not code
A precedence climber lands exactly on the emission the compiler already does —
compile the left operand, compile the right, emit a one-argument OP_SEND — and
that is byte-for-byte what a:add(b) produces today. Call it 150 to 200 lines
in solas, of which the ladder is the small half; the ladders in
sola.sol and basic.sol are 71
and 36 lines respectively, and Pascal’s is 224 only because it carries a type
system through each level, which this must not.
The tail is longer than the head. GRAMMAR.md and
solum.bnf both change and are held against
each other production by production; check_syntax.sol
reserves every word-shaped literal a rule mentions; the reference, the guide and
the cheatsheet each gain a section; an example is owed. And solum.bnf’s
opening boast becomes there are no operators outside @expr, which is a
sentence somebody has to be willing to write.
Trigger. Partly fired already, from use rather than from a program — which
is a weaker report than this document usually acts on, and is why it was here
rather than in the roadmap. What would have settled it is a file in programs/
or lib/ that transcribes formulas from a reference and is checked against it.
Built the same day, and the four findings held
@expr(...) is in solas. The estimate was 150 to 200 lines and the ladder
itself is about ninety; each of the four findings above came out as written, and
the byte-identity claim is a test rather than a hope — eighteen pairs, @expr(
a + b ) against a:add(b), compared as bytes and not as answers.
The fold works, which was the sharpest of the four. @expr( -3 ) and -3
compile to identical files, so the region’s - is value-preserving to the byte
and not merely to the value. It needed one token of lookahead, because ^ binds
tighter than the minus and in -2^2 the literal is not what is being negated —
and scanning a copy of the lexer to settle a question before a byte is written
is what inlinable_arguments already does.
The grammar found a fifth finding, and it is the one worth keeping. The
first draft put the ladder inside a math production of its own, which is the
obvious shape and is wrong: a region is lexical, so an argument, an array
element, a group and a block body all read as infix within one, and a ladder
that only the region reaches cannot say that without duplicating the whole
expression grammar — eight productions, on a page whose virtue is being short.
Writing the ladder once at the top of expression says it in five, and
GRAMMAR.md and
solum.bnf agree on 28 productions where
they agreed on 23.
What that costs is one line in a table that already existed. The grammar now
admits a + 2 outside a region, which the compiler refuses — so it joins self
at the top level and a bad string escape in GRAMMAR.md’s short list of refused
by the compiler rather than by the grammar. And float lost the leading - it
used to claim, since a lexical grammar has no regions to be inside of and -3
read as the operator applied to 3 is the same value either way.
And the prefix form went in the same day, on a weaker trigger than the one written down
The trigger written above was a page of transcribed formulas. What fired was a
report from use — the notation worked, and the question came back: should the
functions have the prefix form too? That is the same weight of evidence that
produced @expr itself, and it is recorded as weaker than this document usually
acts on rather than dressed up as something else.
The proposal was to limit it to float, and the limit turned out to be the
expensive half. A blessed list of names has to appear in
solum.bnf as word literals, and
check_syntax reserves every word-shaped literal a syntactic rule mentions —
so it answers reserved against <identifier>: cos sin, and the language’s
there are no reserved words at all stops being true. That claim is checked:
test_cli asserts the report has no such line. Scoping to the mathematical
functions would have cost a checked property of the language; the general rule
costs nothing, because identifier is not a word.
And the general rule has no exceptions once it is unary. The objection
recorded above was that f(x) to x:f breaks on float:atan2, which is
class-side, and on pow, which takes an argument. Both are two-argument. A
prefix form that takes exactly one has no two-argument form for them to break,
so float:atan2(y, x) is written out as the class-side send it is and ^
covers pow. The objection dissolved rather than being worked around, which is
worth separating from the ones that were simply overruled.
So sin(x) is x:sin — prefix application is a send to its argument — for
any name, one argument, inside a region. The line this entry opened with is now
the line it can be written as:
result := @expr( a^2 + 3 * (sin(a/2) + sqrt(b)) ).
What it costs is one thing a reader has to be told, and it is the reason to
hesitate: prefix looks like calling a function and is not calling a block. A
global holding one is called with value, so f(3) is 3:f, and somebody will
write f := { x | x:mul(x) }. @expr( f(3) ) and get float does not understand
‘f’. It fails loudly rather than doing the other thing quietly, which is the
trade taken and not a defect that went unnoticed.
Comparison and logic, and the rename that came with them
The region covers = <> < > <= >=, ~, & and | as well, decided the
same evening — and once it did, @math was describing the first half of its
job. It is @expr now. The rename cost nothing because the feature was hours
old and nothing outside this repository used it; in six months it would have
cost a deprecation, which is the whole argument for doing it at the moment the
scope changed rather than later.
Three calls were needed and all three are visible in the grammar.
~ is looser than a comparison, so ~a = b is ~(a = b) — what the words
say, and what BASIC reads, its NOT sitting below the comparisons and above
AND. C binds ! tightest and would have read the other; that is the one place
here where a C habit misleads.
This entry used to say Pascal read it that way too, and Pascal is a
counter-example. pascal.bnf has
factor = ... | "not" factor, which is the tightest level there is — so Pascal
sides with C, and the disproof of the citation had been in this repository since
the day that grammar went in. Corrected on 2026-08-29, when the question came
back from the other side: why is ~ not tight, the way a prefix minus is? The
verdict is unchanged and its support is one language and the words rather than
two languages, which is narrower and true.
Comparison does not chain. a < b < c would compare a boolean to c and
fail while running, so it is refused while compiling instead. In the grammar
that is not a check at all, it is the shape of the rule: an optional tail rather
than a repeated one.
| was the one operator the language already used, for a block’s parameters
and a group’s temporaries. Those are matched before a body is, so a | reaching
the operators is one standing where an operator may stand — { a | b } is still
a block taking a, inside a region exactly as outside. That is a rule with a
position in it, which this language mostly refuses, and it was taken because the
position was already load-bearing and the alternative was an asymmetric & with
no | beside it. There is no third character: || stops at the same parameter
scanner, and ! and ? mean other things to every reader.
& and | are the only operators whose right-hand side is not compiled where
it stands. and and or take a block so that they can stop early, so the
right side goes where the block’s body would have gone, behind the jump — and
the bytes are still the block form’s bytes, short-circuiting included, which a
test compares.
The grammar checker paid for itself twice in one evening. It refused the
first draft of the operator list — ’<’ is written before ‘<=’ and would always
win, the longer one has to come first — which is an ordering bug the
hand-written lexer never had, because it peeks. And the suite found that
a := #1 & #2. had been a fixture for both the compiler and the grammar
refusing a file; & is an operator now, so the grammar admits it and only the
compiler refuses, which is the third row of that table and now has an assertion
of its own.
@expr{...}, a region that is a block rather than a group
Asked on 2026-08-29, the day after the notation shipped. @expr(...) takes
a (, and this language already teaches that (a group) runs now while
{a block} is code held as a value —
the guide has a page on it. So a reader who knows
that pair will predict
@expr{...}, a block whose body reads infix, and will not find it: @expr{ a +
#1 } answers expected ‘(‘ after ‘@expr’ pointing at the brace. The question
is whether the prediction should come true.
It is already sayable, twice, and one of the two is not widely known
The region covers nested constructs, which was decided when the notation was
built and is what makes f:value(-3) mean what it looks like. That has a
consequence nobody wrote down: a whole loop fits in one region, condition
block and body block together.
j := #0. total := #0.
@expr( { j < #5 }:whileTrue({ j := j + #1. total := total + j }) ).
total:print. ; #15
k := #0.
{ @expr( k < #3 ) }:whileTrue({ k := @expr( k + #1 ) }).
k:print. ; #3
The first wraps the statement and converts the body as well as the condition.
The second pushes the marker inside each block and converts one expression per
marker. Both work today, and @expr{...} would be a third spelling of the same
bytes.
Three programs reached for the same one, and it was not the outer wrap
Every use of @expr in this repository outside
examples/operators.sol is inside a block with the
marker pushed inward:
| tick.sol | n := @expr(n + #1). in a timer callback |
| game.sol | frame := @expr(frame + #1). in a frame loop |
| both.sol | gtk:every(#5, { n := @expr(n + #1). ... }) |
Three programs, written the day the notation landed, for reasons that had nothing to do with it. The outer wrap was available to all three and taken by none. That is the trigger rule satisfied without anyone setting out to satisfy it — not a document arguing for a feature, but the only three programs that have used the notation landing on the same shape.
The argument that is not taste: a region is not inert
- is the one character whose meaning the mode changes. Outside a region a
leading - belongs to the literal; inside one it is always the operator, which
is the whole reason infix exists in the lexer. So the width of a region has
semantic reach, and the outer wrap buys its convenience by widening: @expr(
gtk:every(#5, {...}) ) swallows the receiver and the other argument in order to
reach the block.
@expr{...} makes the region exactly the block, which is the narrowest true
scope for what is being asked. A notation whose extent can be stated precisely
is worth more than one that has to be widened to reach, and this is the same
argument the region itself was built on — a lexical mode that says where it
begins and ends.
What it would cost
Small, with one wrinkle that is worth naming because it is where the subtle bugs in a lexical mode live.
| The mode’s edges | math_directive sets the mode before consuming ( and clears it before ), so the tokens either side are scanned by the rules of where they are. A block’s } is consumed inside block_body, so that discipline needs a flag threaded through it — two call sites. This is the only part that is not mechanical. |
| The directive | A { branch beside the ( one. @expr{ is a clean error today, so nothing that compiles now can contain one. |
| The bytes | None. @expr{...} emits exactly what { @expr(...) } emits, so notation, never a second semantics holds without a new rule. |
| Parameters | Free. @expr( xs:collect({ x | x * 2 }) ) already answers [2, 4, 6], so a block in a region already keeps its parameters and @expr{ x | x * 2 } inherits that. |
| The tail | One alternative in GRAMMAR.md and solum.bnf, the reference table, the guide, the cheatsheet, the example, and tests/test_expr.c. |
The alternative that costs a sentence
It is possible the outer wrap is undocumented rather than rejected. Nothing in the reference or the guide says a region covers a nested block body, and the three programs above may have pushed the marker inward because nobody knew the other form existed rather than because it reads worse.
If that is the whole of it, the fix is a line in the guide and a converted example, not a notation. The way to tell them apart is to rewrite those three call sites both ways and read them, which costs ten minutes and is the same method the extension entry used: build the throwaway before trusting the argument.
Recommendation: build it, and try the sentence first. The sentence is
cheaper and might be enough, but it cannot fix the scope argument — the outer
wrap will still be a region wider than the thing it is marking, and the
narrowest form of what a program wants to say will still be unsayable. If the
rewrite makes the outer wrap read well, this becomes documentation and the
entry closes; if it does not, the case is already made three times over in
experiment/.
Built on 2026-08-29, and the sentence lost
The rewrite was done first, as the recommendation said, and tick.sol’s loop
settled it: the outer wrap makes a reader hold an open region across the send
and its argument list, closes on ) ), and puts gtk:every and #5 inside a
mode neither needs. So the notation, and it is @expr{...} — the block form
answering the block, the group form answering what its expression comes to.
The entry named the hard part and it was not the hard part. Handing the mode
back at the closing brace took what this predicted — one value threaded through
block_body, which is now the mode that should hold once the block is closed
and is the mode already in force for every block but this one. That was fifteen
minutes.
What it missed was the inlining, which is the whole reason the notation is
free. { ... }:whileTrue({ ... }) written literally compiles to jumps, and the
probes that decide so compared against TOK_LBRACE — so the first working
version of @expr{...} parsed, ran, and quietly emitted a real send with two
blocks in it. Twenty-nine bytes against fifty, a frame per pass, and every test
of what it answered passing.
It was caught by the one test that compares bytes rather than answers, which is in the suite because the notation’s claim is the bytes are the chain’s bytes and a claim about bytes has to be checked in bytes. A notation that stops inlining is a second semantics, whatever it answers.
The fix is two probe helpers that read a block in either spelling, and the part
worth keeping is why they set the probe’s mode: scanning @expr{ x - 1 } under
the file’s mode gives ’-‘ must be followed by digits, an error token, which
reads as not inlinable — so the region would have cost the jumps exactly where
its body used the operator that makes a region necessary. The mode is put back
after each block, so a plain block beside one in an argument list is read by its
own rules.
@with obj { ... }, a region where an assignment is a slot write
Asked on 2026-09-14, from reading engine.sol in the SDL extension. An object there is declared as a run of statements on one receiver:
rect := object:new.
rect:x := #0. rect:y := #0. rect:w := #0. rect:h := #0.
rect:alive := true.
rect:make := { left, top, w, h | | r |
r := self:new. r:x := left. r:y := top. r:w := w. r:h := h. r }.
rect:right := { @expr(self:x + self:w) }.
The proposal is a region that writes the receiver once and reads every
statement in it as a slot assignment on that receiver. The name is open;
@with is used below. Sketch:
@with rect {
x := #0. y := #0. w := #0. h := #0.
alive := true.
make := { left, top, w, h | @with self:new { x := left. y := top. w := w. h := h } }.
right := { @expr(self:x + self:w) }.
}.
The stated purpose is readability, and that a mistake in the receiver is easier to see when the receiver is written once.
Held, with a trigger. It is the same class of proposal as @expr, and it
is judged by the same sentence: the @ namespace is for a thing nothing in the
language already does, and this fails that
test as written, since rect:x := #0 does the job. So it argues on legibility,
as @expr did. What it shares with @expr is the part that let @expr win:
no switches, so the text on the screen never stops being the program, and no
.sob change, since the region desugars to the OP_SET_SLOT the long form
already emits. What it does not share is the size of the change in meaning.
What it would have to be, to be sound
Four rules, each forced by something:
- The receiver is evaluated once, into a temporary the region dups for each
write. That is what makes
@with rect:make(...) { ... }sound, and it is the rule pascal.sol already keeps for Pascal’swith, for the reason the standard gives: a designator with a side effect cannot be re-read. - Only the head of an assignment is rewritten. A bare name on the right is
what it is everywhere else, so
w := wabove means slotwgets parameterw. Pascal’swithrewrites reads too, and that is its famous defect: a field added to the record later changes what an existing line means. This form would not have it, and the docs would have to say so beside the Pascal compiler, which implements the other one. - Nested blocks are ordinary code.
r := self:newinside a method body must stay a local. That is the opposite of@expr, whose mode reaches into nested blocks on purpose, so the two directives would nest by different rules. - It answers the receiver, so it can stand where
makebuilds its instance. That makes it an expression, like@exprand unlike@include.
The case, measured
The run-of-one-receiver shape is not a rect thing. Counting runs of four or
more consecutive recv:name := at the top level: engine.sol has ten (rect 9,
ball 8), and in this tree edit.sol has a run of 68,
sola.sol one of 92, basic.sol
one of 39. Whatever this is, it is not a corner: it would become the way every
object in every program is declared.
On the bugs, the two typos that come first to mind are caught already, and these two were run:
rect := object:new.
rcet:y := #2. ; solvm: undefined name 'rcet'
rect:make := { l | | r | r := self:new. rx := l. r }.
rect:make(#3). ; undefined name 'rx' -- declare it with '| rx |'
; or assign it at the top level
The one the region removes is the paste: a line copied from another object’s
run with a receiver that is a valid other name, ball:paint left under
rect, which lands the method on the wrong object and nothing reports. The
one it adds is the mirror: an assignment to a global placed inside the region,
running := false inside @with engine { ... }, which becomes a slot with the
same silence.
What it costs, and why that decides it for now
Grep. Today grep 'rect:paint' finds the definition of any method in the
tree, and the journal, the reference and the programs’ own comments cite
methods that way. Inside a region the definition is paint :=, which every
object has. That cost is permanent and lands on the same side of the ledger the
proposal argues from.
A second meaning for :=. Cascades were
refused because :m1(#1) sends to a receiver that is not written down. This is
that shape with :=: the head of every statement in the region names a slot on
something the line does not say. @expr changed the meaning of one token, -,
and the entry above records what even that cost in probes and modes.
The build is about a day, and the compiler is the small half: the lexer
already tokenises any @name; the region is expression for the receiver,
which stops naturally at the {, then a loop of name := expression over a
dup. The large half is the documents: a production in solum.bnf, GRAMMAR.md
and check_syntax.sol, which the suite holds together; conformance cases with
-legal neighbours; the reference, the guide and the cheatsheet; the editor’s
colouring; and the counts.
Trigger: a program that shows the paste bug, or a reading of the engine
that finds the run form is what hid something. Neither has happened; the six
games and six readings on 2026-09-14 found the engine readable in the long form.
If it is built, the name should be @with, with the Pascal difference stated,
since @using reads as C#’s resource disposal and the region disposes of
nothing.
Taking @expr out of solas, now that Parasol has the general form
Asked on 2026-09-14, from reading the two compilers’ front pages side by
side. The toolkit has two ways to write a + b * c. In solas it is a region,
@expr(...), with one ladder fixed in compiler.c; in Parasol it is whatever
the module’s header declares, and lib/arith.psol declares the same ladder.
Parasol has already refused to carry @expr, and
its roadmap says why in one sentence: Solveig’s
fixed infix region is the special case of what @infix generalises. Supporting
both would be supporting two. So the line between the tools is drawn on one
side only. The question is whether solas should draw its side too, and become a
compiler with no operators at all, leaving every piece of syntax that is not the
language to the tool whose job that is.
The proposal is a removal, which this page has not scoped before: the region
goes, the lexer stops being told where it is, and infix is spelled in a .psol
file that Parasol turns into the sends solas always compiled.
What the region costs solas today, counted
The compiler carries 337 lines between the @expr section comment and
expression at compiler.c:656, plus the parts that
are harder to see because they are threaded through code that is not about
infix: block_literal and block_body take an infix_after argument so a
block can hand back the mode of where it was written; probe_block re-reads a
block in its own mode so that inlining is not silently lost inside a region;
and the lexer has a flag, infix, that
lexer.h describes as the only character
that has to be told which region it is in. - is a literal’s sign outside a
region and an operator inside one, with one token of lookahead in the fold so
that -2^2 negates the power and not the 2. That is the whole of what makes
the lexer context-sensitive.
Around it: test_expr.c is 533 lines, of which the
eighteen byte-identity pairs are the part worth keeping in spirit; a
region production in GRAMMAR.md and solum.bnf, with two
check_syntax fixtures; three refused conformance pairs under
conformance/refused/expr/ and two accepted cases that cite the region; four
cheatsheet rows, a guide section, twenty-one mentions in the reference, three
in the README and one on the front page; three colouring rules in the VS Code
extension; and the sentence in design.md that there are two of
those shorthands and they are the only two, written because the second has
operators in it.
What uses it, counted
Three files that are not tests of the feature itself. operators.sol
is the exhibit, and exists to show the region. dictionaries.sol
has one line, there to show that = is still equality inside a region while it
pairs a key outside one. sha256sum.sol mentions it
in a comment only, to record that the region has no bit operators and so could
not be used by the one program here that is nothing but arithmetic. Nothing in
lib/ uses it, no program uses it, and Phoenix emits .sob and never sees the
front end.
That is thinner than the trigger that put it in. The
entry above says so itself:
partly fired already, from use rather than from a program, which is a weaker
report than this document usually acts on, and what would have settled it is
a file in programs/ or lib/ that transcribes formulas from a reference.
That file never arrived. Seventeen days on, the customer the region was built for
is still the example that prompted it.
What Parasol covers, run rather than argued
Everything the region does, in a header. The ladder is arith.psol; & and
| are && and || there, as templates over and and or so that the
right-hand side is a block and stops early; ~ is !, a prefix; f(x) is a
@syntax form; and @expr{...} needs nothing, because a block whose body is
infix is just a block in a module that declares operators. ^ is the one
operator arith.psol does not declare, and it is one line, right-grouping as
the region’s is:
@use "arith.psol".
@infixr ^ 80 pow.
a := 3.0. b := 4.0.
(a ^ 2.0 + b / 2.0):print. ; 11
(a < b && !(a == b)):print. ; true
(#2 + #3 * #4):print. ; #14
Against the same three lines written as regions:
a := 3.0. b := 4.0.
@expr( a^2.0 + b/2.0 ):print. ; 11
@expr( a < b & ~(a = b) ):print. ; true
@expr( #2 + #3 * #4 ):print. ; #14
The two .sob files were compared instruction by instruction on 2026-09-14
and are the same program: every opcode and every operand agree, and the only
bytes that differ are line numbers, because Parasol’s output puts each
statement on a line of its own. The byte-identity the region’s test holds
against a:add(b) holds for Parasol’s output too, which is what one would hope
from two things that both lower to the same sends, and is now checked rather
than hoped.
-3 stays a difference between the tools, and becomes a simpler one. Parasol
cannot fold a sign into a number while - is declarable, which
its roadmap records as forced rather than
missing. Without the region, solas has one reading of -: it belongs to the
digits after it, always, and the lexer’s mode flag goes with the region.
What is lost, and it is the REPL
@expr(#2 + #3 * #4):print. answers #14 at the solis prompt today, because
solis compiles through solas. After the removal, solis has sends only, and
#2:add(#3:mul(#4)) is what interactive arithmetic looks like until something
puts a Parasol front on the REPL, which is a separate question with its own
cost and is not being scoped here. For a file the loss is one pipeline step,
parasol before solas, and --map already writes the table that carries a
generated line back to the .psol line that caused it. That step is what Parasol is for, so calling it a
cost is calling the tool a cost.
The other loss is a sentence that reads well: README.md:8 says no operators
outside @expr(a^2 + b/2). It becomes no operators, which is the sentence
solum.bnf opened with before 2026-08-28 and the one the region’s own entry
called a sentence somebody has to be willing to write. Somebody was, and now
nobody has to.
What is gained
Each tool answers to one description. Solas is the language and only the
language: sends, literals and blocks, @include, and the collection literals
that lower to of. Parasol is syntax, all of it, and
nothing in it is not syntax. Today a reader who meets + has to ask which
compiler they are in before they know whether it is a fixed ladder or a
declared one; afterwards + in a .sol file is a compile error and in a
.psol file is what the header says.
The @ namespace gets its rule back. The test this page applies is that
@ is for a thing nothing in the language already does.
@expr passed it on 2026-08-28 because nothing did: there was no other way to
write a formula in the order it is read. Since 2026-09-12 there is, in-tree,
built by make, and on that day the region stopped passing its own test.
The lexer is context-free again, design.md’s principle drops its
qualifier, and the @with entry above is answered without being built: a
region where an assignment writes a slot is a @syntax pattern in Parasol, and
the grep cost and the second meaning for := land on a module that asked for
them rather than on every program in the tree.
What it would take
The compiler is the small half, and it is a deletion. The section from
compiler.c:656 to expression, the infix flag and the - case in the
lexer, the infix_after threading through the block functions, the mode in
probe_block. The operator tokens can stay scanned, so that a + b in a .sol
file is refused by the compiler with a sentence that names the other tool,
there are no operators in Solveig; a .psol module declares them, rather
than by the lexer with unexpected character. About half a day, and the
byte-identity test goes with the region since there is no longer a second
spelling to compare.
The documents are the longer half. region leaves GRAMMAR.md and
solum.bnf, held against each other by the suite; the two check_syntax
fixtures go; the three refused pairs in conformance/refused/expr/ are
rewritten as refusals of an operator, since that is what they become, and the
two accepted cases lose a sentence each; the cheatsheet rows, the guide section
and the reference’s twenty-one mentions go or point at Parasol; the README,
the front page and design.md lose their qualifier; the VS Code rules go. Then
operators.sol moves to Parasol as an example if it is not already one, which
it is: arith.psol and clike.psol are the exhibit now. And the counts in
programs.md are re-synced, because every one of those documents carries
claims.
It is a language change, so a minor version, with a changelog entry that
names the REPL loss in its first paragraph rather than its last. Nothing in
.sob moves: the region never reached the format, which was the point of it.
Recommended, and not built. This page runs on the trigger rule, and the
rule is that a program asks. No program is asking for this; a reading is, and
the reading is that the toolkit has two of something it says it has one of.
That is the same standing @expr went in on, so the symmetry is honest: it
arrived on a reading that a formula should be checkable against its page, and
it can leave on a reading that a language should have one syntax mechanism and
that the mechanism is the other tool. What would make the case stronger either
way is a program: a .psol that wants infix and shows the pipeline step is
bearable, which would settle it for; or a .sol that wants @expr and
cannot reasonably be a .psol, which would settle it against. Neither exists,
and the call is the owner’s. If it is made, the changelog should quote Parasol’s
sentence, because it is the whole argument.
Built the same morning, as a third answer the entry had not weighed
Neither removed nor kept: behind a flag. Hans’s call, within the hour, was
that the region should leave the default and stay in the tool: bare
solas, solis and solid refuse @expr, and --expr (or -e) turns it
on. The reason was the one this entry filed under cost and did not weigh as
an argument: a program outside this tree, the SDL extension’s engine.sol
among them, is written to the region, and a flag keeps it compiling while the
language draws its line. So the two tools now answer to one description each
without a removal: solas is the language, and carries a fixed ladder for the
programs that asked for it, behind a flag that says so; Parasol is where
operators are declared.
What the entry got right. The compiler was the small half and the
documents the longer, as written; the gate is one function at the two places
a region opens, and the refusal is at the directive with the flag named. The
conformance corpus follows the default, so the region-internal cases went out
and a case saying the region is off unless asked for went in, which is the
sentence a second front end needs. examples/operators.sol is the one shipped
file that needs the flag, and the Makefile and test_compile.c give it and
nothing else the flag, so nothing else can come to depend on it unnoticed.
What it got wrong. The REPL loss the entry called the one real cost is not
a loss: solis -e has the region, and a prompt that wants infix says so once.
And the lexer’s context-sensitivity, which the entry counted as what would be
gained back, stays: the operators are scanned either way and - is still told
where it is, because a flag that could be on has to leave the lexer able to
serve it. What went was the default, not the mechanism. The @with question
is answered the same way this one was, by which tool syntax belongs to, and
the entry above stands as held.
Programs that would press on something
These are programs rather than language features, and they are here because what makes each of them interesting is the language question it would answer. A program needs no permission to be written — the rule this repository runs on is that you write one and find out what it wants. What follows is the finding each is predicted to produce, written down before it is written, so that it found nothing stays an available answer.
One of the four has been written since, and its prediction is kept above its outcome rather than replaced by it — a prediction rewritten after the fact teaches nobody anything.
A terminal editor, in the manner of vi. Written on 2026-08-25, and the prediction held. It is programs/edit.sol, and it wanted the terminal’s size in its first hour, exactly as this entry said it would. The prediction as it stood:
Predicted finding, and this one is already confirmed absent rather than guessed: nothing lets a program ask the terminal its size. No rows, no columns, and no notification when either changes. Every full-screen editor needs that in its first hour.
system:writeandsystem:readKeycover the rest — escape sequences out, raw bytes in — and both landed on 2026-08-25, so the editor is the first program in a position to want the third thing.
What the writing added to it is the part the prediction could not have: the
number was always reachable, through stty size in a shell, at 7ms an ask. A
program that measures every time it draws forks a process per keystroke; one
that measures at startup draws a resized window wrong until it is restarted. So
the absence was never the finding — the price was, and one ioctl at about a
microsecond is what made the missing resize notification stop mattering.
6.34 was
raised and closed the same day.
It also confirmed something that had only ever been a warning: a byte-level reader cannot tell the escape key from the start of a sequence, which examples/keys.sol had said in the abstract because nothing had yet bound that key. A modal editor binds it to the most frequent action there is — and that warning became 6.35 the next morning, which is the oldest known gap in this language closed by the first program to be annoyed by it.
Finished on 2026-08-26, and the score is worth keeping. The prediction named one finding and the program produced four, plus a first customer for an entry that had been waiting for one:
| 6.34 | the terminal’s size — the predicted one |
| 6.35 | a read that gives up, so the escape key can be told from an arrow |
| 6.36 | two readers that did not share a buffer, found by reading the code beside 6.35 |
| 6.37 | indexOf could not say where to start, wanted by the matcher its search needed |
| 3.2 | its first real customer: a dispatcher that wants to leave a method |
Eleven commits, ten of them inside one morning, and
the postmortem is in the journal. The prediction being partly
wrong is the useful part: what the editor could not have was never in doubt,
and what that cost — a fork per keystroke to ask stty — is what made it an
entry rather than a shrug.
A Pascal interpreter. Not another BASIC: that shape has been taken, and basic.sol argues at length that a line-numbered language never nests and that is why it fits inside 254 frames. Pascal’s recursive procedures are the case the comment sets up and never runs. A tree-walker for a lexically nested language spends frames in proportion to the interpreted program’s call depth, so 3.5 would be met head on rather than dodged. That is the whole of its value, and it is a real one.
It was built, and as a compiler — so this prediction was wrong before it started
2026-08-27, written before the first line. The decision was to compile Pascal
to .sob rather than to interpret it, the way
sola.sol does SolaBasic, and that takes away the value
this entry predicted. A tree-walker spends three to six host frames per
interpreted call; a compiled call is one OP_SEND and one frame. So Pascal
recursion would reach something like 250 levels rather than 40, and 3.5 is met
less squarely than an interpreter would have met it. The entry’s own reasoning
still holds — it is the shape that changed, not the argument.
Measured when the compiler was finished: 254. Which is the machine’s plain
recursion limit exactly, so a compiled Pascal call costs one frame and not a
byte more — there is no wrapper, no trampoline and no bookkeeping frame
between a Pascal call and an OP_SEND. The guess of “something like 250” was
close by luck rather than by reasoning, and the exact number is the more useful
fact: it says the compiler adds nothing, which is not what a guess can say.
Recorded rather than quietly dropped, because a prediction that is falsified by a decision is still a prediction that paid: it is the reason anybody looked at the frame cost before choosing the shape.
Two better predictions take its place, and both are falsifiable.
Both were settled on 2026-08-27, and both held. Stage 4 of the compiler is
nested procedures, and it needed nothing added to the machine: a nested
procedure is a block made inside its parent’s activation, OP_OUTER depth slot
is the static link, and the blocks it produces are the first in this repository
to set the capture flag. Nothing a conforming Pascal program can write reaches
3.1, which was the falsifiable half. The predictions are left below as they were
written.
One: 3.1 is not a limitation for this language, it is the language’s own rule. A capturing block may not outlive the frame it was written in. A Pascal nested procedure may not be called after its enclosing procedure has returned. Those are the same sentence, and if they really are, then the entry that has been open since the start is — for Pascal exactly — the specification. The prediction is that nothing in a conforming Pascal program can reach the restriction, and the way to falsify it is one program that does.
Two: OP_OUTER gets its first customer from a compiler. The instruction
takes a depth and a slot, which is a static link by another name, and Pascal’s
uplevel variable access is what static links were invented for. sola.sol
emits neither OP_OUTER nor OP_SET_OUTER — SolaBasic has no nested
procedures — so no compiler here has ever produced one. The prediction is that
Pascal’s scoping needs no mechanism the machine does not already have.
What neither prediction covers is the type system, which is where the work actually is: Pascal has records, sets, subranges, enumerations, pointers and files, and SolVM has objects, arrays, strings and two kinds of number. That is a mapping to be argued rather than a limit to be found, and PASCAL.md is where it is argued.
Predicate logic — unification, resolution and backtracking. Wanted:
coroutines, continuations, and a non-local return, all three of which are
recorded as No further down this page with reasons. It would need an
explicit trail and choice-point stack instead, which is exactly what basic.sol
found GOSUB and FOR to be, so the shape is not unprecedented. Prediction: it
works, awkwardly, and 3.5 bites on deep resolution. The sharpest single finding
on this list and the largest job.
A parser toolkit in the manner of lex, yacc, sed and awk — grammars written
in something like BNF. The most interesting of these, because the answer to
the obvious design is already on record: lib/scan.sol says a
matcher built the combinator way dies with block outlived the frame it was
written in, which is
3.1. So this program
either gives that entry its first customer, or shows that a non-combinator
design is fine and the limitation is livable. Both outcomes are worth having,
which is rare enough to be the reason to pick it.
A first data point arrived on 2026-08-26, from the side.
lib/pattern.sol is a matcher rather than a toolkit and was
written because an editor wanted /, not to answer this — and it is
non-combinator by construction: the pattern compiles to an array of items
walked by a loop, and 3.1 never came up, because nothing there is a block that
outlives anything. It also measured what recursion costs when it is unavoidable:
one frame per *, so 250 of them fit. That is the smaller half of the
question. A grammar is a tree where a pattern is a list, and a tree is exactly
what multiplies that measurement — so the entry stands, with its second outcome
now the likelier of the two.
Built on 2026-08-26 — and the second outcome is the one that happened. check_syntax.sol reads a grammar in Wirth’s EBNF and checks a file against it, with pascal.bnf as its first customer. 3.1 never came up. Not because it was worked around, but because the design that avoids it is also the design that is right: the grammar is a tree of objects and the matcher is one method that recurses over it, so nothing is ever a block that has to outlive the frame it was made in. The combinator shape is the one 3.1 refuses, and it was never reached for.
What multiplied instead was the measurement, exactly as predicted. A tree
cost one frame per node and about two per rule reference, which against Wirth’s
Pascal was 19 levels of nested begin … if and 28 nested parentheses. That
was the number this entry had been waiting for, and it read at the time as
saying the limitation was livable — 19 being past anything written by hand and
short of what a generator emits.
It was not livable, and the file that said so was already here. See below.
The finding worth carrying back here is a different one. Inlining a rule’s
alternation into the reference that names it should be worth a third of the
frames — one of the three per level — and it was worth a sixth, 16 levels
becoming 19. Most of Wirth’s Pascal rules have a sequence for a body rather than
an alternation, so most never had the middle frame to save. A measurement of
the matcher predicts a third; only a measurement through a real grammar gives a
sixth — which is the same lesson programs/ keeps producing, that a program
written to suit a feature cannot report that the feature was awkward.
And then it was settled, the same day. This entry said an explicit stack
machine — its own instruction list, its own backtrack stack — would have no
depth limit at all and would meet 3.5 head on rather than living inside it, and
that check_syntax.sol had recorded the trade rather than taken it. What took
it was not an argument but a file: experiment/lexer.sol, already in this
repository, nests ifElse 24 deep and the tree walker could not read it.
The matcher is LPeg’s
instruction set now — Call, Ret, Choice, Commit — and 2,000 levels of
nesting check where 13 did. The prediction this page made about a tree
multiplying a measurement taken on a list was right, and the fix is the one it
named.
What the page could not have predicted is the price: 38% of the running time, and 3.7% of that recovered by two hand optimisations that were expected to be worth far more. The dispatch loop’s cost is the instruction fetch and the sends inside an arm, not the comparisons that choose the arm. An interpreter written in this language pays for its dispatch and cannot get it back by hand — which is worth carrying to the two entries above this one, since both of them are proposals for interpreters.
Phoenix — a second language whose output Solum uses. Proposed on 2026-08-28, alongside the infix notation above, and the two arrived together for the same reason: equations are awkward to write here. The equation motivation is the wrong reason for this one, and saying so is most of what this entry is for. A language whose distinguishing feature is infix arithmetic is a few thousand lines answering a question that a notation answers in two hundred — and then there are two languages to keep true, two grammars and two references, with the warning the self-hosting entry already recorded: two compilers rot; every language change becomes two changes and the second is easy to forget.
The machinery, though, is not in doubt, and that is worth stating precisely
because it is the part that usually kills an idea like this. Three compilers
here already target .sob: sola.sol at 4,778 lines,
pascal.sol at 2,840 — eight stages in a single
day — and experiment/, a Solum compiler in Solum at 1,619
lines that compiles itself to a fixpoint. lib/sob.sol writes a
whole .sob from a plain dictionary, so the file-format half costs nothing;
experiment/compile.sol is a 70-line command-line shell to copy; and a
precedence ladder is forty to seventy lines. A small typed imperative language
is on the order of 2,800 lines and a few focused days. (For the record, since
it is easy to get backwards:
basic.sol is an interpreter. The BASIC that emits
bytecode is sola.sol.)
So the question is not whether another language can be written. It is the
half of the proposal that has no precedent here at all: could its output be a
library rather than a program? Every hosted language in this repository
produces a closed program. Neither sola.sol nor pascal.sol emits an
exports call; both end their chunk with HALT, so a system:load of one
would run it and halt the loader; Pascal’s globals are deliberately prefixed
pas. so that they cannot collide with the machine’s, which is the opposite
of an interface. The mechanisms are all present — nested method chunks,
capturing blocks, system:load keyed by realpath, exports inherited by
whatever an object makes — and not one of them has ever been pointed at a
chunk a different front end produced.
The prediction, written before anything is built, so that it found nothing
stays available. .sob is a language-neutral object format, and a second
language can publish an object Solum sends messages to with nothing added to the
machine — the same shape Pascal’s stage 4 prediction had, and it held. The way
to falsify it is one thing a hosted compiler cannot emit that a Solum library
can. The two candidates worth watching are the HALT at the end of a
top-level chunk, which is a real difference between a program and a file you
load, and whatever it turns out an exports list has to be built out of at the
point where a foreign compiler wants to write one.
And it would put a second customer on two entries that have only ever had
one. 3.4 — no
compatibility across .sob versions — currently costs one compiler a version
bump; it would cost two, and the second is the one that finds out whether the
number is checked anywhere it matters.
3.8
is a host and a script agreeing a name with nothing checking, and a cross-language
exports boundary is that entry with the stakes raised: the two sides are now
written in different languages, so the shared name is not even the same kind of
identifier at each end.
What it should not be. A dialect of Solum with infix and classes. Solum is
already object-oriented, system:load and exports are already three of a
module system’s four jobs, and the fourth is
refused in writing. A second language earns
its place by being a different language — a different type discipline, a
different notion of what a program is — or by answering the interop question
above. Being a nicer skin on this one is the case that has to be refused.
Trigger: wanting a library that Solum consumes and that is not written in Solum. Nothing has wanted one. The name, should it happen, is Phoenix.
Fuzzy logic. A library, and worth an honest note rather than a place in the queue: it is arithmetic on floats, and all of the arithmetic landed with 3.14. It would teach nothing about the language. Build it if the thing itself is wanted; not to find something.
Two absences noticed on 2026-08-31 that nothing has asked for
Both were seen while building the range and tail, and neither is a roadmap
entry, because nothing wanted either and could not have it. They are here so the
observation survives and so the trigger is written down rather than re-derived.
A positioned write. system:readFile(path, from, count) was built this
morning and there is no writeFile(path, from, text) beside it: a write either
replaces the file or goes on the end. The asymmetry is not obviously wrong —
reading part of a file is how you work on one too large to hold, and writing
part of one is a different and rarer thing — but it is an asymmetry that arrived
today rather than one that was decided.
Trigger: sort, or anything else that writes runs to temporary files and
merges them. An external merge sort is the shape that wants it, and it is on the
survey below as the finding sort is predicted to produce. A second would be a
program updating a record in place in a file it cannot hold.
Whether standard input is a terminal. tail.sol wanted
to know, because its no-argument case means two things — the house rule says
demonstrate on input you carry and ... | tail says read standard input —
and no other program has had that collide.
It found an exact answer rather than a workaround, which is why this is a
note and not an entry: system:keyWaiting(0.0) answers is there a byte right
now, and it is documented as true at the end of input, so a pipe says true
whether it is full or finished and an idle terminal says false. That property is
a nuisance in every other program and is precisely the question here. Verified
through a pseudo-terminal both ways.
That paragraph is wrong and is kept because of how. It enumerates three cases, each correct, and reads as though they are all the cases. There is a fourth — a pipe that is open, empty and not yet finished — which answers false exactly as an idle terminal does, because no byte right now is true of both. Both programs printed their demonstration and threw away the input of any pipeline slow to produce its first byte, and it went unnoticed because a pipeline in a test has its first byte ready before the program starts. Found on 2026-08-31 while building the replacement, by asking what the old spelling had been answering. An enumeration is a proof only if it is complete, and the way to check one is to ask what states the thing actually has: a pipe has four, and the fourth has neither data nor an end.
So the case for an isatty is not that the question cannot be answered — it is
that the answer arrives through a message about reading, and a reader coming
to that line has to be told why it works. Trigger: a second program wanting
it, or a first one wanting it about standard output, which keyWaiting
cannot answer at all and which is the more common thing to want — whether to
colour output, or to draw a progress line.
The trigger fired the same day it was written, which is why this paragraph
is here rather than the note being edited.
sha256sum.sol hit the identical collision — the
house rule says demonstrate on input you carry, ... | sha256sum says read
standard input, and both are an empty command line — and it was built the same
day as
6.40,
system:isTerminal('input).
And writing that entry found the output half was answerable too.
system:terminalSize calls ioctl on standard output and answers nil when
that fails, so terminalSize:notNil is isatty(1) today. The note above
predicted keyWaiting “cannot answer at all” about output and was right about
keyWaiting and wrong about the machine: a second accident was already there,
in a message written for something else. Checked through a pseudo-terminal both
ways rather than read out of the source.
Which Unix tool next, and what each would press on — surveyed 2026-08-31
Written after sed and tail, and the survey is here rather than in the roadmap because ROADMAP.md’s admission rule is that an entry means a program wanted something and could not have it. None of these has been written, so none of them has wanted anything. What follows is a prediction apiece.
The tools written so far are all parsers or filters over text, and three axes have never been touched: pure arithmetic, an algorithm over two inputs at once, and array-heavy work. That is the shape of the list rather than any tool’s popularity.
And the oracle is why these are worth more than a library would be. It is not
a text-tool trick that runs out on leaving text: sha256sum has published
vectors, diff has diff, gzip has gzip, a matrix multiply has numpy, a
Prolog has swipl. What the frontier directions lose is exactly the oracle — a
neural net has no byte-for-byte answer to be held to — which is the argument for
spending the cheap oracles on the questions those directions depend on, while
they are still cheap.
sha256sum — the first program here with no I/O in its inner loop
Sixty-four rounds of shifts, masks and additions per sixty-four bytes, and
nothing else. Every other program here spends its time in split, indexOf or a
syscall.
It presses on a refusal rather than a gap. The At a glance
table says No to integer sizes — byte, word, long — on the grounds that they
reintroduce the coercion this language refuses. SHA-256 is defined on mod-2³²
arithmetic, so it is the first thing to want them. It is writable without them:
a 64-bit integer holds the sum of two 32-bit values without the overflow trap
firing, and a bitAnd puts it back in range. So the prediction is not
impossibility.
Predicted finding: the number. Megabytes a second, hashing a file. That is the cheapest possible answer to the question standing behind every numeric ambition on this page — what does this interpreter cost per arithmetic operation when there is nothing else going on — and it is a measurement no amount of reading the dispatch loop will produce. Second, an ergonomic report on whether a mask after every add reads as arithmetic or as bookkeeping.
Oracle: /usr/bin/shasum and the published test vectors, which are two
independent checks rather than one. Perhaps two hundred lines.
It was written on 2026-08-31, and the prediction held in both halves
Kept above the outcome rather than rewritten, which is what this section does with predictions. programs/sha256sum.sol has the full account; what belongs here is whether the entry above was right.
The prediction was not impossibility, and that held. A 64-bit integer
holds the sum of five 32-bit values with fifty-nine bits to spare, so every
addition is exact and the mask is a narrowing rather than a repair. Nothing in
SHA-256 comes near
3.12: the largest
shift in the program moves a value under 2^32 left by thirty places, which
lands under 2^62. That is luck rather than design — SHA-512 rotates a
64-bit word and could not be written this way at all — and it is the answer to
whether byte, word and long are wanted. They are not. Twenty-three masks
is what refusing them costs one program, against a coercion rule on every
arithmetic operation in the language.
The prediction was the number, and here it is. Measured rather than
counted, with --steps=N, which stops a program after N instructions: the
smallest N that lets a run finish is that run’s exact instruction count, and a
binary search finds it.
| bytes hashed | instructions | blocks | per block |
|---|---|---|---|
| 0 | 14,671 | 1 | |
| 64 | 28,049 | 2 | 13,378 |
| 640 | 147,767 | 11 | 13,302 |
| 6,400 | 1,344,947 | 101 | 13,302 |
13,302 instructions per 64-byte block — 208 per byte — and a megabyte takes
9.30 s at -O2 for ten of them. So the interpreter runs 234 million
bytecode instructions a second and each one costs 4.3 nanoseconds, which is
the first absolute
figure this project has for what an instruction costs. Everything in
performance.md is a ratio against CPython or against an
earlier Solveig.
In megabytes: 1.08 a second, against /sbin/sha256sum at about 1800 and
Perl’s shasum -a 256 at about 320, each measured on 200 MB. The first is C plus the M2’s SHA
instructions and is not a fair comparison; the second is the interesting one,
because it is an interpreter too and is about three hundred times faster for not interpreting
the hash.
The ergonomic half, which the entry asked for in one sentence: it reads as bookkeeping. Not because the mask is hard but because it is not in the standard — every line a reader wants to check against FIPS 180-4 carries a term FIPS 180-4 does not have.
And three things the entry did not predict.
- A third of the program was a method call. On a megabyte,
rotrwritten as a method takes 1.36 s, written out in the rounds 1.10, written out in the schedule too 0.92 — 1.48x. The arithmetic is identical in all three and so is the digest; what the method cost was a frame and a return, ten times a round. Worth holding against the inline cache entry, which measured lookup at 9.7% of the benchmark that asked for it. This is the call itself. @exprhas no bit operators, so the one file here that is nothing but shifts, xors and masks is the one file that cannot use the notation at all.&and|are already taken, by the short-circuiting logical pair. Not an argument for adding them — one program is one program — but a notation introduced for “a formula you are transcribing” met a formula it could not.- The
-gdefault build costs 4.9x here, against the 1.9x to 4.1x the nine benchmarks show, which is what a program that is nothing but arithmetic inside the dispatch loop should be expected to do.
It fired one trigger on this page: the isatty note got its second program, and is now 6.40.
It did not fire the early exit, and a draft of
this paragraph said it did — which is worth keeping, because the mistake is
one an entry can invite. sha256sum carries a flag through four ifTrues to
decide whether a line is a checksum line, so it looks like the sixth file’s
idiom. It is not: that entry is about
3.13, a loop
that must stop from inside, and there is no loop here — it is a straight-line
guard chain wanting a return, which is
3.2. One spelling, two limitations, and
counting the second as an instance of the first would have made the case look
stronger than it is. The entry had already stopped counting files for
exactly that reason, having watched the number go stale twice.
Where 3.2 did turn up is worth its own line: in the parsing, not the
hashing. Every loop in the block function runs a fixed number of times and
none wants out early, which is what a specified algorithm looks like — so
tail’s prediction that 3.1 and 3.2 would not appear was right about the file
work and the gap is where it has always been.
diff — the first program here that computes rather than recognises
Everything written so far reads one input and reports on its structure. diff
holds two and computes a relationship between them.
Predicted findings, in the order they are expected to bite: 3.5, because the divide-and-conquer form of Myers recurses on the halves of the edit graph and a large file has many; a two-dimensional array, which nothing here has needed and which is an array of arrays with a send per index; and memory at scale, where the classic table is quadratic and the linear-space refinement is the interesting half of the algorithm rather than an optimisation.
It is also the first tool whose output format is hard. Unified diff hunk
headers, context, and the rules for coalescing nearby changes are fiddly in a way
tail’s were not, so the corpus would earn its keep before the algorithm did.
Oracle: /usr/bin/diff. Around four hundred lines.
It was written on 2026-09-02, and one of the four predictions held
Kept above the outcome rather than rewritten, which is what this section does with predictions. programs/diff.sol has the full account; what belongs here is whether the entry above was right. It named four things and got one.
| predicted | what happened |
|---|---|
| 3.5 | No. The recursion belongs to the linear-space variant, which this entry named – and that variant buys memory at the cost of the trace the output is read back out of. The greedy forward pass is two whileTrues |
| a two-dimensional array | No. Myers keeps one array of diagonals and a ragged trace of the bands it passed through, which is not the table the entry meant |
| memory at scale | Half. Quadratic in the edits, not in the files: two files of ten thousand lines differing in one line cost one band of one |
| the output format is hard | Yes, and it was the whole of the difficulty |
The 3.5 prediction is the one worth carrying, because this is the third time
it has been made about a program here and the third time it was wrong.
basic.sol argued a line-numbered language never nests; check_syntax.sol
replaced its tree walker with an instruction set; pascal.sol compiles rather
than walks. Each time the entry predicted the limitation and each time the
program was written in the shape that avoids it – because the limitation was
known before the implementation was chosen. A prediction of 3.5 is really a
prediction about which of two algorithms gets written, and this repository
keeps choosing the other one. That is worth writing down as a rule rather than
as a fourth instance waiting to happen.
What the one that held cost, in the currency this page cares about. Four
format faults, none discoverable by reading the algorithm: a count of one is
written @@ -1 +1 @@; hunks merge at a gap of exactly twice the context and
split at one more; \ No newline at end of file follows a context line too;
and an empty range is written at the line it follows except at the start of
a file that has lines, where it is written at line 1. The algorithm itself was
right on its first run against the corpus.
Three of those four came from the corpus and the fourth did not, and that is the sharper finding. Twenty-four hand-written cases passed a wrong empty-range rule, and only seven of them could have shown it at all – the rule lives in the unified header and the rest never print one. All seven put their empty range where the simple rule and the real one agree. A random sweep – file pairs built from nine possible lines, seven option forms – disagreed 44 times in 1,050 runs. After the fix, 2,400 runs over six option forms and files up to forty lines: zero. This is an author-written corpus tests what its author thought of with a generator as the second author rather than a standard, and it is the cheapest second author available for a tool that has an oracle: no vectors to find, no specification to read.
And then the first pair of real files disagreed, an hour after that
sentence was written — this repository’s own docs/method.md at two revisions.
Where a line inside an inserted block equals the line at the seam, the
insertion can be placed as one run or split around that line for the same
number of edits, and the tool splits where this program does not. Over sixty
pairs of real files at two revisions: 48 byte-identical, 12 not, and all 12 the
same cost.
The generator could not have found it. It mutates one line at a time, and the shape needed is a block inserted whole where a line inside it matches the seam — which is what editing prose does every time and what nobody constructs on purpose. Nor does it reduce: no window of thirty lines either side reproduces it, because the tool’s algorithm makes a global choice. So the sweep and the corpus were blind in the same direction, and the second author has to be real data rather than more generated data.
What answers it is a third judge. programs/diff/apply.sh writes the unified
diff, hands it to patch(1) and compares the result with the second file:
60 of 60. The property is is this the diff from A to B, where the oracle
answers is this the tool’s diff — and the moment two right answers exist, the
second question stops being the interesting one. A tool with an oracle can
still want a check that is not the oracle.
And the oracle turned out to disagree with itself. Under -i, on input
holding no uppercase at all, /usr/bin/diff picks a different one of two
equally minimal answers than it picks without the flag – 41 runs in 400 under
-i and none without it. programs/diff/differ/ignore-case-ties.case pins the
minimal example. An oracle can be wrong, which
the FIPS 180-4 argument
already
said; what is new is one caught being two things at once, where a published
standard would have settled it and there is no standard for which minimal edit
script to print.
It fired two entries on the roadmap, both about standard input and neither
predicted here:
6.43,
a program cannot read a pipe whole and readFile("/dev/stdin") answers "" on
one rather than the contents or an error — both halves fixed on 2026-09-03,
and what is left of the entry is sort’s want rather than diff’s, a pipe
taken in bounded pieces; and
6.44, an instant
cannot be written in local time, which a unified header needs and which costs a
fork of date that is not even exact across a daylight-saving change.
Closed on 2026-09-12 as system:utcOffset(t), and the inexactness was not
a footnote: the fork was an hour out on every file untouched since March, for
ten days, while the oracle agreed because it writes its operands fresh.
And it generalised the harness twice. oracle.sh
ran one input because every tool it had been asked about read one; diff holds
two, so a case may carry a first: section. And it is the first program here
whose exit status is documented behaviour – 0 the same, 1 different,
2 trouble – so the harness compares that too now, for every program it runs.
Both changes are the shape this file already has: written for sed,
generalised by its second caller and its third rather than copied.
gzip -d — the one that answers the question behind the neural net
Inflate only; compression is a second program and a harder one. A 32 KB sliding window, a bit-level reader over a byte-level input, and two Huffman tables rebuilt per block.
This is the array-heavy workload with a definitive oracle, which is what
makes it worth more than a tensor experiment. A SolValue is a tag and a union,
so a 32 KB window is 32,768 tagged values rather than 32,768 bytes, and every
access is a send. Predicted finding: the cost of that, in a number, on a
problem where the right answer is known to the byte. If packed numeric arrays are
ever going to be needed — and
extensions
is the likely shape rather than a new core type — this is the cheapest place to
find out, and the only one on this page that cannot be argued with afterwards.
Oracle: gzip, on files gzip produced. The largest of the three.
It was written on 2026-09-04, and the prediction measured the wrong thing
Kept above the outcome rather than rewritten. programs/gzip.sol is the twenty-second program; programs.md has the full account and the file has the argument. What belongs here is whether the entry above was right.
It asked for the cost of the window and the window is 4.8% of the program.
The number is 220 instructions a byte of output and 1.32 MB/s – 40,775,088
instructions for docs/REFERENCE.md, counted exactly with --steps rather than
sampled. The Huffman decode is 70.7% of that, the CRC-32 11.4%, turning the
output array back into a string 10.5%, and the window 4.8%.
And it is not that the window is little used, which is what would have made
this a boring answer. 172,699 of the 185,364 bytes came out of it and 12,665
were literals: 93% of the output is out:add(out:at(at)), that pair of sends
costs about eleven instructions, and it is still not where the time goes. What
costs is reading bits one at a time – 59,710 symbols at about 483 instructions
each.
So the question standing behind the entry has an answer, and it is no. The entry put this program on the list to find out whether packed numeric arrays would ever be needed, naming extensions as the likely shape. The boxing is 5% and the interpretation is 70%: a packed array would buy back a twentieth of this program, and the same effort spent on the send would buy back most of it.
The oracle found the thing no specification has. gzip -l’s ratio column is
not 100 * (uncompressed - compressed) / uncompressed – it is integer
arithmetic with a floor at -99.9%, and eighteen bytes in a twenty-seven byte
file is -44.5% where the obvious formula says -50.0%. This program printed the
obvious one until it was held against the tool. RFC 1952 does not contain it
because it is not part of the format; it is a property of the program that
prints the listing. A standard cannot be wrong about what it does not
specify, which is the shortest statement of why an oracle is worth having that
this page has produced.
And the language wanted nothing. No roadmap entry came out of it, which is the outcome this section keeps available on purpose. What it is a customer for is 6.45 – which names it – and not for the reason given there; see the entry, which has been corrected.
sort — filed below as pressing on less, and written anyway
Promoted out of the also-rans below, where this prediction was written and where its outcome does not belong. The account of a program that exists had been filed under the heading of one that does not, and above rather than below the prediction it answers, which is the one arrangement this section is supposed never to take.
The prediction, kept as it stood:
array:sorted(block)at scale, and its stability, which nothing has had to care about. Its real finding is predicted to be a gap we already know about and nothing has wanted: an external merge sort writes runs to temporary files and merges them, and there is no positioned write —writeFilereplaces andappendFileappends. That is the mirror of the ranged read built this morning, andsortwould be its first customer. Oracle:sortunderLC_ALL=C, since collation is otherwise a divergence about locales rather than about the program.
It was written on 2026-09-02, and the predicted gap was not there
Kept above the outcome rather than rewritten. programs/sort.sol has the full account; what belongs here is whether the entry was right. It named one finding and the finding is absent, for a reason worth more than the prediction was.
An external merge sort never writes into the middle of a file. A run is
produced whole – read a budget’s worth of lines, sort them, write them once –
and then it is only ever read; the output is produced in order, so it appends.
writeFile and appendFile are exactly the two writes it needs and there is
no third.
The entry called a positioned write the mirror of the ranged read, and the mirror is where it went wrong. The ranged read exists because a program wants part of a file it did not write. Nothing wants to write part of a file it is producing, because a producer knows what comes next. A write is not the reverse of a read, and the entry reasoned from the symmetry of the names.
What the merge did want was the ranged read, and it was already there. A
k-way merge needs k independent positions in k files at once – the thing a
language with file handles has to think about, and the reason
3.22 was built
without one. A reader here is a path and an integer. The half of the program
the entry worried about is the half that needed nothing.
And the generator missed both real defects, again
programs/sort/sweep.sh runs generated inputs and then this repository’s own
files, under twenty-three option forms. It was written before any claim was
made about it, which is the only thing diff had wanted a week’s worth of
hindsight to do. Both defects it found came from the real half:
| what | how it was found |
|---|---|
-n rejects a leading + – the tool reads -1 as minus one and +5 as zero |
this repository’s README, on a line beginning +0.2% to +3.4% |
-f folds to upper case, not lower – visible only beside punctuation, where folding down puts [ below a and folding up puts it above Z |
three README files here begin lines with **[ |
The generated alphabet had a minus in it because somebody thought of one, and no plus because nobody did; it had letters and digits and no punctuation beside them. That is the same shape twice in one day, and it is the argument for the third rung rather than for a better generator: a generator produces what its author imagined, one level up.
The claim it nearly published
The entry’s first sentence was array:sorted(block) at scale, and its
stability, which nothing has had to care about, and the write-up was going to
be sorted is stable, deliberately, and nothing says so – the shape
6.42
closed for the bytecode format five days earlier.
It is not true. REFERENCE.md has said it all along,
in prose under the sorting examples rather than in the message table, in almost
the words the C comment uses. The paragraph was written because a grep for
sorted found the table row and stopped. An absence asserted from a search
that did not cover the document is the argument from absence
design.md rules out for features, and it
is the third time in three days it has turned up somewhere nobody had ruled it
out.
What is true is smaller and is worth keeping: this is the first program here
that depends on that guarantee. -s is exactly no last resort, and let the
sort keep the order, and the merge relies on the same promise across runs.
Until now the sentence had no customer.
What it was a customer for
6.43,
with a reason the entry does not have. diff wants a pipe read whole; a
sort that spills wants the opposite, a pipe read in bounded pieces so that
memory stays inside -S however large the input is. The entry is about the
absence of a middle, and the second customer is what shows the middle is
what is missing rather than the whole-file read.
And the second customer for a lenient numeric read, which the awk
entry below named first and never had one for. The honest report is that it cost nine lines written
out, which is why it stays a paragraph here rather than becoming an entry: a
message that saves nine lines in two programs is not one this language is short
of.
Two that would press on less, and one that cannot be written at all
sort was the third of these, and it was written. Its prediction and what
came of it are above.
unzip -l. A foreign binary format, which nothing has read — sob.sol
reads one this repository wrote. The central directory is at the end of the
file and is found by scanning backwards from it, which is the ranged read’s
textbook case, and every field is a little-endian integer that has to be
assembled from bytes by hand. Small, and it would say whether byte-level
unpacking wants a message of its own.
Answered on 2026-09-04 without being written, and the prediction is kept above for that reason. All three of its claims were settled by gzip.sol hours before anyone got to this one. A foreign binary format nothing has read — that program reads two, RFC 1952 around RFC 1951. Byte-level unpacking wants a message of its own? It got written:
le32is four lines, that plus three two-byte reads is the whole of the byte assembly in a program that lives on bytes, and the conversion is 16 instructions an input byte and 2.6% of the run. The answer is no. The central directory is at the end, which is the ranged read’s textbook case — tail.sol has been that customer since 3.22, reading backwards fromsize, andsort’s k-way merge is a second.So the finding it would report is nothing new, which is a legitimate outcome and a poor reason to spend a program. Recorded here rather than quietly dropped, because an entry that has been answered is worth more than an entry that has been deleted: the next person to think of
unzip -lshould find out why it is not worth writing without having to write it.What survives is the other half of the same tool, and it is a better question.
unzipthat extracts rather than lists reads members that are raw deflate with no container, so it would makegzip.sol’s inflate a second customer — and two customers is what turned lib/re.sol from an argument into a library. Checked rather than assumed on 2026-09-04: the payload of azip -9member, wrapped in a ten-byte gzip header and a trailer, comes back throughgzip.sobbyte for byte. The engine is already written; what it has not got is a second caller and a home.
xargs -P. Unwritable today, and that is the whole finding.
system:run blocks and there is no spawn, so nothing can have two children at
once — and 3.11 is
about threads rather than about processes, so the gap is not even the one the
roadmap records. Worth writing down here precisely because attempting it would
produce a page rather than a program.
And the ones worth skipping: grep, cut, tr, uniq, wc, head. Each
is the ground sed.sol already covered, through the same
code paths, and the rule this page runs on is that a program that cannot report
anything is not worth writing. grep is the least bad of them, and only if the
point is to force a decision about pattern.sol’s missing half — groups,
alternation and case folding, which its own header says want a different engine.
The counter-argument, which is on the page already
design.md records that there is no
geometry anywhere near this language was “a true sentence about seventeen
programs and an empty one about a language”, and nearly lost the language its
trigonometry. Every text tool added makes that sentence truer and more
misleading at once. sha256sum and gzip -d are on this list partly because
they are not text tools wearing the same clothes: they are numeric experiments
that happen to have a Unix name and an oracle.
tail, and the file this language cannot read — scoped 2026-08-31
Chosen out of the Unix tools because it is the one where reading a file whole stops being a cost and becomes a wall. sed priced the whole-file route on 2026-08-30 — about 4.7 times the file for a line-oriented program, against a flat 2.5 MB for the same work through a pipe — and closed by saying 3.22’s trigger had still not fired, because nothing here has a file that does not fit.
That is no longer true, and it took four seconds to stop being true. A sparse file is 3 GB of holes and 8 KB of disk, and this is what the two sides say about one:
/usr/bin/tail -n 3 0.003 s, and the right three lines
system:fileSize #3221225623, immediately
system:readFile '...' is too large to read into a string
The language can measure that file and cannot read a byte of it. Not slowly:
readFile checks the size before allocating and refuses by name, which is the
entry’s own documented behaviour working exactly as written. This is measured
rather than predicted — it is a fact about readFile and needs no tail to
establish it. What tail adds is a program with an obvious reason to care, and
a shape to ask for.
The order in this entry was wrong, and the measurement above is why
Corrected the same day it was written, on the question what was tail for?
The entry below recommended writing tail on the whole-file read first, and
deciding the language change afterwards, on the reasoning that a program asks
and a page does not. That is the rule, and it is not what happened here: the
evidence arrived before the program did. Four seconds, a sparse file, and no
tail anywhere.
The original recommendation is kept in the calls below rather than replaced, because a recommendation quietly rewritten teaches nobody why it was made.
What was wrong with it is sharper than being unnecessary. A tail written
on the whole-file read cannot call the thing it is supposed to be asking
about. It would re-prove a wall already measured and say nothing whatever
about whether readFile(path, from, count) is the right shape, because it could
not use it. So the program that was meant to inform the design is the one
program guaranteed not to.
The question splits in two, and only one half still wants a program.
- Whether — settled by the measurement above. A file the language can measure and cannot read is as clear as evidence gets, and 3.22 already names the shape: no handle, no lifetime, nothing to close.
- What shape — not settled, and a caller is what settles it. Which means the caller has to come after the call exists.
So: build the ranged read first, and write tail against it as the check.
Then tail does the job it is actually good for — first caller, and the report
on whether the shape survives contact — instead of re-proving what is proved.
This is method.md’s throwaway before the design with the order
put right: fifty lines that use the new call are worth more than two hundred
that cannot.
And building it is small. prim_system_read_file already opens the file,
fseeks to SEEK_END and ftells to size it. The ranged form is that function
taking one argument or three — primitives answer at two arities, as at(key)
and at(key, default) do — with fseek to from - 1 in place of the rewind
and an fread of count.
One line already in that function decides call 3 below. It reads:
A short read is a failure rather than a shorter string:
fopenon a directory succeeds on some systems, and reading one does not.
Right for a whole-file read and wrong for a ranged one: asking for 4 KB from a hundred bytes before the end is an ordinary thing to do, and a hundred bytes is the correct answer rather than an error. So clamp or refuse is not a matter of taste — the policy that is there cannot carry over, and whichever replaces it needs writing down beside that comment saying why the two reads differ.
The subset. -n N and -n +N, -c N and -c +N, several files with their
==> name <== headings, -q and -v, standard input when no file is named,
and a default of ten lines. Left out: -r, -F, and --pid, which are BSD or
GNU rather than the tool. Expected size 150 to 200 lines, or 200 to 250 with
-f — about a third of sed, because there is no script to parse.
Predicted finding, the one it is for. tail is the first program here that
cannot be written correctly at all, rather than written awkwardly or
expensively. Above 2 GB it must refuse a file that /usr/bin/tail answers in
three milliseconds, and no amount of care in the program changes that. If
3.22’s ranged readFile(path, from, count) is ever built, this is its first
customer and the first thing able to say what shape it actually wants — which
is what the entry has been missing, since a proposal argued on a page cannot
report that its own arguments are in the wrong order.
Predicted finding, second: -f has nothing to wait on. There is no
system:sleep. The only thing in this language that waits is
system:keyWaiting(seconds), which waits on standard input and is documented
as answering true at the end of input — so a follow loop built on it spins
at a hundred percent the moment standard input is closed, which is how tail -f
is ordinarily run. The predicted resolution is not the absence but the
price: -f gets written with shell:run("sleep 1"), a fork per poll, and the
finding is what that costs. That is exactly how the terminal’s size went — the
number was always reachable through stty at 7 ms an ask, and
6.34 was
raised on the price rather than on the absence.
Both halves were tested on 2026-08-31, and the second was wrong
Kept above the outcome rather than rewritten, which is what this section does with predictions.
The first half held. keyWaiting cannot stand in for a wait, and the numbers
say why — twenty asks of keyWaiting(0.5):
| standard input is | twenty asks take |
|---|---|
| an idle terminal | 10.02 s — it genuinely waits |
| a pipe at its end | 56 microseconds — it spins |
| a pipe with something in it | 32 microseconds — it spins |
The second half was wrong, and it was wrong in its analogy. The prediction
was that shell:run("sleep 1") would work and the price would be the finding,
the way the terminal’s size was reachable through stty at 7 ms an ask and the
price was what made 6.34
an entry. A fork of /bin/sleep measured 2.23 ms, which at a one-second poll
is 0.22% — perfectly livable. The stty case was a fork per keystroke and
this is a fork per second, and the entry reasoned from one to the other without
noticing they differ by four orders of magnitude in how often they happen.
So the case for system:sleep had to be made on something else, and it was:
waiting is one call to the kernel, a program should not start a process to do it
or depend on where a system keeps its sleep, and of the twenty-eight messages
on system this was the only obvious hole — clock and time could say how
much time had passed and nothing could spend any. It was built, and -f with it.
That is a weaker argument than the one predicted, and it is the true one. Worth more than being right for the reason expected, and the reason this paragraph exists rather than a quiet correction.
Predicted finding, third and weakest: following a growing file is quadratic.
Each poll re-reads the whole file, so a minute of following a log that gains a
line a second reads it sixty times. This is a second and independent argument
for the same change, and it is the one that says the change wants both from
and count rather than a read the last N bytes convenience.
What it is predicted not to find, so that it found nothing stays an available answer for most of the file:
-cis bytes, and so is a Solum string — NUL included, which the reference states ofreadLineandreadFileboth. Byte-exact output is expected to work without comment. If it does not, that is news about strings rather than abouttail.- No lookahead problem. sed needed a line of it for
$;tailknows where the end is by construction, and the reader it wants is simpler. - 3.1 and 3.2 are not expected to appear. There is no early exit worth the name and no closure to outlive anything.
- The missing final newline will come up again and is expected to teach
nothing new: sed already found that
readLinecannot report it, and the same three-case treatment should carry over unchanged.
The oracle corpus, in the shape sed’s already
has, since it generalises for the cost of a different args: line: default ten;
fewer, more and exactly N lines; -n 0; -n +N; -c N; -c +N; an empty
file; no trailing newline; one line; several files and their headings; -q;
-v; standard input; NUL bytes. Around twenty cases, each run both by name and
by pipe.
The calls only you can make
-
Order. This entry recommended one thing and now recommends the opposite, and both are here on purpose.
As first written: write
tailon the whole-file read first, then decide the language change — because that is how the evidence is produced and it is what sed did.Corrected: build the ranged
readFilefirst and writetailagainst it. The evidence was already in hand before the program was proposed, and atailon the whole-file read cannot call the thing it is meant to be asking about. The section above has the reasoning.What is left for you is not really the order any more but the appetite: this makes the next piece of work a change to the machine rather than a program, which is a different kind of afternoon and wants saying out loud.
-f, or not.It presses hardest on the missing wait, and it is the one part an oracle cannot check the ordinary way: it does not terminate, so it needs a timeout harness that the other twenty cases do not.Built on 2026-08-31, and the timeout harness turned out to be fifteen lines of shell in tail/follow.sh rather than a reason to leave it out — give it a deadline is the whole idea. It found a real difference on its fourth scenario, which is more than the case for skipping it expected.- If the ranged read follows, four things 3.22 does not settle:
fromone-based, like every other index in the language? Recommended yes; anything else makes it the only zero-based index here.- Past the end: clamp or refuse? The language has both conventions —
first(#n)andlast(#n)clamp,copyFromrefuses. Recommended clamp, and the argument is a race rather than a taste: a file’s size can change between thefileSizeand the read, so refusing turns an ordinary race into an error a caller cannot prevent. - A negative
from, meaning from the end? Tempting for exactly this program. Recommended no — no other index here is negative, andfileSizealready composes. - Does a short read say so, or must the caller compare what it got against what it asked for? Falls out of the clamp answer and should be decided with it rather than after.
- Does the corpus carry the 3 GB case? It needs a sparse file and a way to
say this is expected to fail until the language changes, which is the state
argued against on 2026-08-31 for the
pattern.soldefect: a red case inagree/that is meant to be red teaches the next reader to ignore red. Recommended: a separate opt-in script, run when somebody wants to know, the wayoracle.shandexperiment/prove.share.
solex and yax — lex and yacc that emit Solveig. Proposed on 2026-09-01
and parked for discussion until awk is written, which is deliberate: awk is
the first customer of lib/re.sol’s extended dialect and of a scanner built on
it, and what it turns out to want is most of the evidence these two would rest
on. Recorded now so the proposal is not reconstructed from memory later.
The twist is the whole of it: the output is Solveig, not C. Every generator
of this kind emits the host language of its own implementation, and here that
would mean a generator written in Solveig emitting Solveig — which makes the
generated scanner and parser ordinary .sol files that solas compiles, that
solid steps through, and that --steps bounds. A lex emitting C would be a
program this repository could not run the output of.
What is already here and is not this. check_syntax.sol reads a grammar and checks a file against it with an LPeg machine at run time; it generates nothing. sola.sol and pascal.sol are hand-written front ends for two languages, and experiment/ is a Solum compiler in Solum. So there are three hand-written parsers here and one interpreter for grammars, and no generator. Whether that is a gap or a well-populated space is the first thing the discussion has to settle.
The open question named at the proposal, and not answered here: whether
POSIX lex and yacc are the right models, or whether GNU’s flex and bison
are — the latter having %union, GLR parsing, better error recovery, reentrancy
and locations, and the former being a standard that can be held against a
published document rather than an implementation. This repository has taken the
standard side twice, with sha256sum against FIPS 180-4 and basic against the
NBS suite, and both times the standard found what the implementation had not.
That is an argument, not a decision.
Two things worth measuring before the discussion rather than during it: what
awk costs to hand-write, since that is the labour a generator would remove;
and whether a generated LALR table is a shape this language can hold at all —
pascal.sol is recursive descent and check_syntax.sol is a PEG machine, so
nothing here has ever built a parse table.
Trigger: none needed — a program needs no permission. What needs a decision is the language change it is predicted to ask for, and that is what the four calls above are.
awk — and the finding is that it should not be next. Proposed on 2026-09-01. Written up here rather than built, because the prediction was worked out first and it changed the answer: awk’s largest demand already has a customer, and that customer is not awk.
What it would press on, predicted before writing
Three things were measured rather than guessed, and two of them are real:
| full ERE | pattern.sol’s own header says no groups, no alternation, no + or ?, and POSIX awk requires all of them. The largest demand by far. |
| a lenient numeric read | awk’s "3abc" + 0 is 3, "" + 0 is 0, " 7 " + 0 is 7. All three raise here: asInteger and asFloat are strict on purpose and the reference says so. |
%e and %g |
asString(spec) has width, fill, alignment, thousands and fixed decimals — 45.8:asString("6.2") is " 45.80" — but not significant digits. awk’s CONVFMT and OFMT default to %.6g. |
The second is a genuine gap and a small one; the third is laborious rather than
blocked, since float has the arithmetic to build %g out of. Both are the
kind of thing a program’s comment records and one of them might become an entry.
What it would not find, which is most of it
3.5 has been met head on three times already — by basic.sol, which argued a line-numbered language never nests; by check_syntax.sol, which measured 19 levels and then replaced the tree walker with LPeg’s instruction set; and by pascal.sol, which compiles rather than walks. awk’s user functions would land somewhere already mapped.
The dispatch cost is measured: an interpreter written in this language pays 38% for its own dispatch loop and cannot get it back by hand. A fourth interpreter re-measures a constant.
And the rest is already here. Associative arrays are dictionary; record
splitting is split; getline from a command is system:capture, whose stream
options closed 3.15;
delete is dictionary:remove. awk’s string-and-number duality is real work but
it is modelling work — the interpreter carries its own value type, and the
language’s strictness is a decision rather than an obstacle.
The finding: the regex library has a customer today, and it is sed
sed.sol says it in its own header — no groups, no
backreferences, no + or ? — and it does not merely go without them: a sed
script using \(...\) is refused, because \( is a literal parenthesis to
pattern.sol and would silently match the wrong thing. That is a shipped
program declining real input, today, for want of a library that
the regular expressions entry already argued out and
recommended: object-shaped, in Solum, a couple of hundred lines, no VM change,
no notation, no literal.
So writing awk to justify lib/re.sol is the wrong order, and it is the
same shape of mistake this repository made twice on 2026-09-01 — a scoping that
is right about the answer and wrong about what comes first. The library is the
work; awk is a customer for it, and it already has two — sed, which refuses
\(...\), and edit, whose / silently misreads it. awk is the third and
unblocks nothing, which is the whole reason it is not next: not that it fails
to count, but that the count was reached without it.
The two customers want different dialects, and that is worth knowing before
the design rather than after. POSIX BRE — sed’s — has back-references and
escaped groups; POSIX ERE — awk’s — has alternation and +/? and no
back-references. One engine must carry both, and a back-reference is exactly the
feature that stops an implementation simulating the automaton. Designing for
sed alone risks a backtracker; designing for both is the honest brief, and is
an argument for scoping the library with awk named rather than written.
What awk would still be worth, afterwards
Not nothing, and the case is narrow enough to state:
- A second dialect through one engine, which is the check that the library is a library rather than one program’s matcher.
- An oracle that is on every machine.
/usr/bin/awkis BSD’s here,gawkandmawkexist elsewhere, and POSIX specifies the language — so it satisfies both halves of the rule that heldsha256sumto FIPS 180-4 as well as to the tool: an implementation to compare against and a standard that can be wrong in a different direction. - The nineteenth program, and the second in the tradition of a language whose whole shape is text in, text out.
The calls
lib/re.solbefore awk, or awk first anyway? Recommendation: the library. It has a customer refusing input today; awk would be its second.- BRE and ERE in one engine, or ERE only? Recommendation: both, designed together, because a back-reference decides the implementation strategy and retrofitting it is how a matcher becomes a backtracker by accident.
- Is awk still worth writing once the library exists? Recommendation: yes, but as a customer rather than as a reason — and this entry should be re-read then, because it found nothing new is a legitimate outcome and half of the prediction above already says so.
Not built when this was written, and not recommended next. The prediction is written down here so that the answer, whenever it is written, can be held against it. It was written the same day, once the library existed.
It was written on 2026-09-01, and the order was the half that held
Kept above the outcome rather than rewritten, which is what this page does
with predictions. programs/awk.sol is the nineteenth
program, built in four stages, each of which ended by holding the result against
/usr/bin/awk rather than against what I expected. Ten cases in
programs/awk/agree/ agree with it; the one in programs/awk/differ/ is
for (k in a),
whose order POSIX explicitly leaves undefined, so the two disagree and neither is
wrong.
Call 1 was the one that mattered, and it held. The library went first, and by
the time awk started, full ERE — the largest demand by far, and the whole reason
this entry said not next — was already there. So the prediction’s value was
entirely in the ordering, and the other two shrank on contact exactly as
predicted: the lenient numeric read is nine lines and wanted nothing new, and
%e and %g are written in the program, which is where a format belongs.
Call 3 asked whether awk was still worth writing afterwards, and the answer is
yes — for a reason this entry did not name. It predicted a second dialect
through one engine and got one. What it did not predict is the thing that
actually pressed: 3.2 was wanted three
separate times in one file, and ^ was written three times before remembering it
is not there. An interpreter dispatching on a tag is the shape that wants to
answer and leave; without it, evalBinary guards every arithmetic branch against
having already settled ~, and five unwinding statements become five flags.
That is the second real customer for an entry that had had one since August,
and it arrived unpredicted by an entry whose whole job was to predict this kind
of thing.
The oracle found six defects, five of them by comparison rather than by a
test: -F: joined to its flag; run calling setDefaults after the command
line had set FS, which showed on only the one flag in three that had input to
act on; re.sol not exporting lastEnd, which no earlier caller had wanted;
truncated asked before its own magnitude guard; and ln(1e30)/ln(10) landing
at 29.999999999999996, which put one number in thirteen on the wrong side of a
switch. Not one of those would have been in a test written from what I believed
the program did.
And one was found by running a form that a note said was not implemented.
getline line < "file" parsed as getline line and then a comparison against
the filename: it read standard input, threw the answer away, printed nothing and
left with 0. That is
a claim in a header is a case waiting to be written
firing on the day the rule was written, in a file written after it — and the
tell is the note, because in a program that parses something, not written and
quietly means something else are the same syntax.
An SQLite file, read and then written: scoped 2026-09-15
The first of the directions intended to be reached, and it is reached the way the method says: by a program, not by a page. The design table says what stands in front of a database here: a ranged read exists since 3.22, a file identity since 6.39, and what is still absent is a positioned write, and anything about locking or flushing. Nothing has asked for the write. This is the program that would.
Three kinds were weighed on 2026-09-15, and the choice was made on the
oracle. A log-structured store (append, index in memory, compact by rewrite
and rename) presses on nothing absent and answers a question already
answered. A paged B-tree in a format of our own presses on the write in its
first hour, but the only thing checking the file is a check command we
would write ourselves, and
the method
says what that is worth. The same B-tree in SQLite’s file format presses on
exactly the same things and is held against sqlite3, which is on this
machine at /usr/bin/sqlite3 (3.51.0), reads what we write, and has
PRAGMA integrity_check to say whether the tree is well-formed. That is the
oracle rule in its
strongest shape, the one gzip had: the oracle produces the inputs, and later
consumes the outputs, so a disagreement cannot be about what a byte meant.
Chosen by Hans the same day, knowing the scope is the larger of the three.
The format, in the amount needed to bound the work
A file is pages of one size, 512 to 65536 bytes, numbered from 1. Page 1
starts with a 100-byte header: the magic string, the page size at offset
16, a change counter at 24, the page count at 28, the freelist head and count
at 32 and 36, a schema cookie at 40, the text encoding at 56, and at 92 and 96
the version-valid-for number and the writing library’s version, which is what
makes the page count at 28 trustworthy. Page 1 after its header, and most
other pages, are B-tree pages: an 8-byte header (12 on an interior page, the
extra four being the right-most child), then an array of 2-byte cell pointers
growing down from the header, then free space, then the cells growing up from
the end. Four page kinds by the first byte: table leaf 0x0d, table interior
0x05, index leaf 0x0a, index interior 0x02. A table cell carries a
varint rowid and a record; an interior table cell carries a child page
number and a rowid; an index cell carries a record whose last column is the
rowid. A record is a header of varint serial types followed by the values,
integers big-endian in 1, 2, 3, 4, 6 or 8 bytes, a REAL as a big-endian
double, text and blobs by length. Deleted cells leave freeblocks, a
two-byte chain inside the page; freed pages join the freelist, whose trunk
pages list leaves. Table sqlite_schema is the tree rooted at page 1, and its
sql column is the CREATE text, so a schema is a string the program has to
write in the spelling sqlite3 would.
Payload that does not fit a page spills to overflow pages, chained by a
4-byte pointer, with a threshold formula that is the same in every
implementation because integrity_check checks it. This is the one part of
the format where the scope can be drawn tight or loose, and the plan draws it
tight: read overflow chains, never write one, and refuse an INSERT that
would need one. That is a limit stated by the program, not a defect.
In scope, and out of it
In: rowid tables (an INTEGER PRIMARY KEY is the rowid and its column is
stored as NULL, which is the one non-obvious rule of the record format); the
five storage classes; one or more indexes per table, maintained on every
insert and delete; CREATE TABLE, CREATE INDEX, INSERT with literal
values, DELETE ... WHERE, and SELECT of named columns or * with a
WHERE of one comparison, ORDER BY one column, and rowid as a column;
splitting a full leaf and adding a level; freeblocks on delete and the
freelist for pages a delete empties; overflow chains read only; the header
kept as sqlite3 keeps it, so that a file the program has touched is one
sqlite3 will open without complaint. Output in sqlite3’s default list
mode, | between columns and nothing for NULL, so the two can be compared
byte for byte.
Out, and each is a sentence the program’s header says: WAL and the
rollback journal, so a crash mid-write is a corrupt file and the oracle is run
only over finished ones; locking, since there is one process; WITHOUT ROWID
tables; autovacuum; collations other than BINARY, which is memcmp and is
what lessThan on bytes already is; UPDATE, which is DELETE and INSERT
at the SQL level and adds nothing at the page level; joins, expressions,
aggregates, NULL semantics beyond storage; writing overflow; VACUUM;
shrinking the file, which sqlite3 also never does without being asked.
The steps, and what each is held against
Each step ends with the sweep green before the next begins, the way the
programs since sed have gone.
| step | builds | held against |
|---|---|---|
| 0. The corpus | programs/<name>/sweep.sh: a generator that writes SQL scripts from a seed, has sqlite3 execute them into files, and keeps both. Three rungs as gzip has them: author-chosen shapes (an empty table, one row, a page exactly full, a three-level tree, every storage class, negative and nine-byte integers, a text longer than a page), generated schemas and data, and one real file if one is to hand |
nothing yet; the corpus is what the rest is held against |
| 1. The reader, tables | the header, varints, records, a table B-tree walked leaf to leaf through interior pages, sqlite_schema parsed for names and root pages, SELECT * and SELECT cols, WHERE rowid = n by descent |
sqlite3 file 'SELECT ...' on every corpus file, byte for byte |
| 2. The reader, indexes and overflow | index B-trees, WHERE col = v through an index when one covers the column and by a scan when not, ORDER BY through an index or by sorting, overflow chains followed |
the same, over a corpus rung whose rows are longer than a page |
| 3. The writer, from nothing | CREATE TABLE, CREATE INDEX, INSERT: pages built in memory and the whole file written with writeFile. Leaf split, interior split, a new root |
sqlite3 file 'PRAGMA integrity_check' says ok; then sqlite3 reads the file and its answers match ours, and match the answers over the file sqlite3 built from the same script |
| 4. The writer, into a file that exists | open a file sqlite3 made and INSERT into it: the header’s change counter, page count and version-valid-for; the schema cookie when the schema changes; pages appended and pages changed in place. This is where the positioned write is predicted to be wanted, and the step is done first with writeFile of the whole file so that the cost is measured rather than assumed |
integrity_check, then sqlite3 reading what it did not write; a timing table of inserts against file size, whole-file rewrite against the one-page write once it exists |
| 5. Delete | DELETE ... WHERE: a cell removed and its space a freeblock; coalescing; a page emptied to the freelist, trunk and leaves; an index entry removed with its row |
integrity_check, whose freelist and freeblock checks are the strictest thing in it; then a script of interleaved inserts and deletes, both sides |
| 6. The records | the section of programs.md, this entry’s outcome under its prediction, the design table’s database row updated, and whatever entry the write became |
make test, and the counts in programs.md re-synced |
The whole of SQL that the program parses is smaller than awk’s language and
much smaller than Pascal’s, and the tree has written six parsers; the parser
is not where the time goes. The B-tree writer is, and step 3 is where the plan
is most likely to be wrong about the size of the work.
What it would press on, predicted before writing
| the positioned write | Not until step 4. Step 3 writes a fresh file whole, and a fresh file is small. The first INSERT into a file of a few megabytes is the moment: one changed leaf, one changed interior page, one changed header, and writeFile rewrites every page between them. Predicted shape: system:writeFile(path, from, text), the mirror of the ranged read, a range and not a handle as the read’s design sentence says. The measurement that makes it an entry rather than a shrug is the timing table in step 4. |
| a byte buffer | A page under construction is either a string rebuilt at each cell, or an array of integers at sixteen bytes a byte. Predicted bearable: a leaf is 4 KB, a split touches three pages, and gzip and sha256sum have already built binary output from asCharacter and join at a speed that was not the finding. If it is not bearable, the answer is the one the design table already gives for floats: an extension owning the buffer, not a core type. This is the prediction I am least sure of. |
| a negative integer from eight bytes | 3.12: the top byte of a 64-bit two’s-complement value cannot be shifted into place. lib/sob.sol already assembles a double from two 32-bit halves for this reason, so the pattern exists; nine-byte varints and eight-byte integers meet it again, in two places. A comment, not an entry. |
%!.15g |
sqlite3 prints a REAL with fifteen significant digits and always a decimal point, and asString(spec) has no significant-digits form. awk.sol wrote %g in the program and the reference says that is where a format belongs; it moves to the library if this is its second customer, which it is. |
What it would not find
Locking and flushing. One process, and an oracle that runs after the program has exited, never wants either. They stay on the design table, and this entry does not pretend to have tested them.
A truncate. A delete never shrinks an SQLite file; the pages join the freelist. What looked like a third missing file operation is not wanted by this format at all.
3.5. A B-tree of 4 KB pages over a million rows is four levels deep. The descent recurses four times.
Float bits. Serial type 7 is a big-endian IEEE double, and lib/sob.sol
has the encoder and disasm.sol the decoder, checked against the C library
bit for bit. Reused, not rewritten.
The dispatch cost. An interpreter for SQL of this size runs a few hundred
instructions per row, and a page holding dozens of rows is one ranged read of
30 µs. The reader’s time is I/O, and the number to watch is calls to readFile per query, which a page
cache under --memory is what bounds.
Steps 0 and 1 were built the same day, and the reader pressed on nothing
As predicted for the reader, and the prediction was cheap. The findings
were in the corpus and the oracle rather than in the language, and are in
programs.md:
a REAL column stores whole numbers as integers; an unordered query has as many
answers as the planner has plans, which cost 39 of 200 generated cases before
the generator stopped asking; sqlite3 prints a REAL from an approximate
expansion and an exact printer disagrees with it one value in ten thousand or
so, plus everything past 1e130; and the shell escapes control characters. The
sweep is 852 of 852 over four seeds, the third of which found the program
reading -9223372036854775808 as a real, which is a defect in the program and
a mark for the generator: two seeds and 426 cases had not asked.
One prediction above is already wrong in its detail. %!.15g did not move
awk’s %g into the library as its second customer: awk’s divides by a power of
ten and is near, and fifteen digits is where near shows, so the printer was
written exact instead, from the mantissa and a small big integer. Two
implementations of %g in two programs, neither reusable by the other, which
is the trigger rule’s count reaching two with no library entry to show for it.
The right shape, if a third wants one, is the exact one.
3.12 was met twice and is a comment, as predicted. Nothing else was. Step 2, the index trees, followed the same evening and pressed on nothing either: an index cell is a record with the rowid last, an interior index cell is an entry in its own right, and the walk is an in-order traversal that prunes. Automatic indexes have no SQL in the schema and are left alone, as is anything with DESC, COLLATE, an expression or a WHERE.
Step 3 the same evening, and the size was about right
The writer from nothing is built: CREATE TABLE, CREATE INDEX and INSERT
into a file that did not exist, pages as objects in memory and the file
written whole at the end. The step above said this was where the plan was
most likely wrong about the size of the work, and it was an evening and some
five hundred lines, which is what the step guessed. integrity_check found
two defects on the way to its first ok and named the byte both times, which
is the oracle doing what it was chosen for; the account is in
programs.md. The
writer’s rung of the sweep is 420 of 420 on seed 1 and 420 of 420 on seed 2,
each case judged by integrity_check, by sqlite3 reading the written file,
and by this program reading it.
The second prediction has its first number. A page as an array of cell strings joined on demand costs 0.36 milliseconds a row, 0.6 with two indexes, ten thousand rows in 3.6 seconds; bearable, as predicted, and no byte buffer has been asked for. The first prediction is untouched, as it said it would be: a fresh file is small and is written whole, and step 4 is the moment.
Step 4 the same night, and the first prediction fired where it said
INSERT into a file sqlite3 made: its pages decoded into the writer’s
objects, changed, and the file written whole, since that is the only write
the language has. The table is in
programs.md and in
3.27,
which this raised: one INSERT into 100 MB needs 4 pages read and 3 written,
and the whole-file route reads 24,386 more and writes 24,390, 1.16 s against
sqlite3’s 0.039. The prediction held in every particular: the moment
(step 4, the first INSERT into a file of a few megabytes), the three pages
(leaf, interior, header), the cost growing with the file and not the work,
and the shape recommended, writeFile(path, from, text). That is the second
prediction on this page to hold outright, after the editor’s, and it was the
cheap kind: an absence already known, with a program found to want it. The
sweep’s third rung, sqlite3 building the first half of a script and this
program the second, agrees with the other two.
What it did not predict: that a program keeping the file open is only
slowed, 1.7 s for a thousand inserts in one run into 100 MB, so the entry’s
customer is the program invoked once a statement, which is how a shell
script uses sqlite3. The decision on the shape was call 2 below; Hans took
the mirror the same afternoon, and
3.27
closed by the evening with the after column beside the before: 0.03 s at every
size, 12 KB written for 12 KB owed. Level with the tool for one statement,
which is process start-up on both sides and says nothing about the engines;
over a thousand statements the interpreter is twenty times slower, and the
entry says so beside the table, since the first draft of it did not.
Step 5 the same night, and the plan is done
DELETE, the freelist, and a WHERE that is a conjunction. A leaf that
empties is dropped and freed; a root left with one child takes its
contents; a page below the root left with one child hands its child’s
entries back to the root rather than the child itself, since every leaf
must stay at one depth; an index entry that cannot stay where it is goes
back in the same way. integrity_check named two wrong shapes on the way
(Child page depth differs, and seventeen leaves never used) and accepts
a third that a read then refuses, an empty leaf left standing, so the judge
is the three checks together. The account is in
programs.md. The
sweep, with deletes in the writable rung and its author case, agrees on two
seeds both ways.
The scoring, prediction by prediction. The positioned write: held in
every particular, built. The byte buffer: bearable, 0.36 ms a row, no
entry. The negative integer from eight bytes: a comment, twice. %!.15g:
wrong in its detail, since the exact printer was written rather than awk’s
moved. Not found: locking, flushing, a truncate, 3.5, float bits, the
dispatch cost. All as written, with the one detail wrong. What the plan
did not predict: that the oracle would be approximate in one place
(%!.15g) and generous in another (an empty leaf), that an unordered query
has as many answers as the planner has plans, that sqlite3 reserves
twelve bytes a page, and that the whole of it would fit in one day. Six
steps, five commits, and the sixth step is this paragraph and the ones
above it.
The calls only you can make
- The name. The directory under
programs/and the section ofprograms.md.sqlitesays what the file is and overstates what the program is.Provisional until you say:sqlite.sqlite, confirmed on 2026-09-15. - Where the positioned write lives, if step 4 asks for it. A third and
fourth argument to
writeFile, mirroringreadFile(path, from, count), or a name of its own. Recommended the mirror; it is a language decision and waits for the measurement. Step 4 asked, the measurement was taken, and Hans took the mirror on 2026-09-15:writeFile(path, from, text), 3.27. - The byte buffer, if the second prediction fails. An extension after
net, or the program lives with arrays and says what it costs. Recommended the second until a number says otherwise. - Whether step 4 keeps the whole-file rewrite as a fallback for a machine
whose
solvmpredates the write, or drops it the day the write lands. Recommended drop: two paths through the writer is the shape that hides a defect in the one not taken. Dropped, the day the write landed, on the recommendation and without a separate call.
The database as objects, and the first slot made from a run-time name: scoped 2026-09-15
The idea Hans had for the database was never SQL. It was the shape Ruby’s
ActiveRecord has: the database an object, each table an object, a row an
object whose columns are properties, and messages to search, insert, update
and delete. SQL arrived in sqlite
for one reason, that the oracle speaks it: to be compared byte for byte with
sqlite3 the program had to accept the statements sqlite3 accepts, so
the SQL front is the test harness’s language and not the design’s. Under it
the engine is already functions over a database and a table, eachRow,
findRow, eachIndexMatch, matchingRows, treeInsert, treeDelete, and
the parser is the only thing that knows there is SQL at all. An object front
is a second front on the same engine, and this entry is the plan for it.
What it looks like, written before a line of it exists. A sketch, not run:
@include "sqlite.sol".
db := sqlite:open("notes.db").
notes := db:create("notes", ["title TEXT", "done INTEGER"]).
notes:insert(#['title = "milk", 'done = #0]).
notes:where(#['done = #0]):each({ n | n:title:display }).
n := notes:find(#1).
n:title := "eggs". n:save.
notes:where(#['done = #1]):delete.
db:close.
A database, opened or created; a table, found by name or created with its
columns spelled as SQL spells them, since the schema is SQL text in the file
whatever the program says; a query, which where narrows and orderBy
sorts and each, first, count, all and delete run; a row, whose
columns are its slots and which carries rowid, save and delete. That
is the whole of it. There is no execute(sql) on the library: the SQL front
stays in the program, as a client of these objects, which is the judge
below.
The wall, and the three ways through it
n:title is where this meets the language, and it meets it at an entry
that already exists. A column name comes out of the file at run time, and
a row cannot grow a slot from a run-time name:
2.14 says reflection
cannot write, there is no slotAtPut, and the Stateful work row of the
design table names the
same absence. Three ways through, weighed on 2026-09-15:
- Rows are dictionaries.
n:at('title)andn:atPut('title, v). Works today, and everything in the sketch but the property spelling stands. - A narrow write:
object:new(dictionary), a fresh object delegating to the receiver with one slot per pair, at construction and never after. Not aslotAtPut. The argument 2.14 records against dynamic slots is 6.15’s: a slot name is interned in the permanent name table, so keys read from data would leak a name apiece, and slots are a list walked linearly, so it would not have beaten the array of pairs it replaced. A schema is a handful of names a table, not one a row, so the first half does not bite here, and the second half is the measurement below. 2.14 says the trigger is a program that does, and names an interpreter, a debugger and a serialiser as the shapes that would; a row builder is a fourth, and this would be the first to arrive. - A full
slotAtPut. More than this wants, and argued against once.
Recommended 2, built after 1, so that the language change is asked for
by a program that already runs rather than by this paragraph, and its shape
(what a key may be, what a name that is not an identifier does, what a
column called save does) is decided by a caller. Hans took 2 after 1 on
2026-09-15, calling it a fair compromise for the column properties.
One fact found while scoping, and worth its sentence: an extension can
already do this. sol_object_define(vm, obj, name, value) is how
extend.h tells an extension to answer
an event with fields, and nothing in the language reaches it. So reflection
cannot write is true of programs and not of the machine, and object:new(dictionary)
is thirty lines that hand a program what C already has.
Where it lives
A library, lib/sqlite.sol, binding sqlite. An object API is what a
library is, and @include "sqlite.sol" is how a program would ask for it.
The engine moves there whole, under the one global the library convention
allows, and what is left of programs/sqlite.sol is the SQL shell: the
tokenizer, the parser, the statements, list-mode rendering, the
demonstration and main. The program cannot keep its name: @include
looks beside the includer first, so programs/sqlite.sol asking for
"sqlite.sol" finds itself, which is the case the compiler’s self-include
warning exists for. The library takes the name, since it is the file format
and the name says what the file is, and the program becomes
programs/sql.sol, the shell. The scoping entry above said sqlite
overstates what the program is; this is that sentence resolving.
The steps, and what each is held against
Each step ends with the sweep green before the next begins.
| step | builds | held against |
|---|---|---|
| 0. The move | the engine into lib/sqlite.sol under sqlite:, every f:value(...) a self:f(...); the shell into programs/sql.sol; the sweep and programs.md renamed to match; the library added to test_compile.c’s list |
the sweep, unchanged, before anything is added: 629 of 629 on the same seeds, which is the check run against the unfixed code |
| 1. The objects, rows as dictionaries | open, close, tables, table, create; table:insert, find, where, all, index; the query with where, orderBy, each, first, count, all, collect, delete; update, which the engine has not got and which is a delete and an insert under the same rowid with the index entries rebuilt; the shell’s INSERT, DELETE and SELECT rewritten as clients of these, and UPDATE added to the shell and to the generator so the oracle sees update |
the sweep, now judging both fronts at once, since every statement passes through the objects; UPDATE in the generated rung and an author case |
2. object:new(dictionary) |
the primitive, keys symbols or strings, a slot per pair, refusing a key that is neither; tests/test_object.c including the GC-stress case; the reference’s object table, the Reflection section, the Limits row, 2.14 and the design table’s Stateful work row |
the tests, and the GC proof: the new object is defined into while the dictionary’s values are live, so the temp root is predicted load-bearing, and removing it under SOLUM_GC_STRESS is the proof |
| 3. Rows as objects | a row prototype a table, carrying rowid, table, save, delete; each row proto:new(values); n:title, n:title := v, n:save; a column named for one of the four refused by name at table, with slotAt still the way to it |
the sweep, unchanged in its cases, since the shell already goes through the rows; and a timing of n:title against n:at('title) over a wide row, which is 6.15’s second half measured |
| 4. The example and the pages | examples/database.sol, with its claims checked by expect; the library’s section of REFERENCE.md and its line in CHEATSHEET.md; the sql section of programs.md; the counts re-synced |
make test |
| 5. The records | this entry’s outcome under its prediction, COMPLETED.md for the entry the primitive becomes, the changelog |
make test |
What it would press on, predicted before writing
| the move | A mechanical rewrite of some hundred and fifty call sites and nothing found by it: a block passed inward captures self, as every method in json.sol relies on, so the engine’s inner blocks keep their receiver. Predicted zero defects, and the sweep is what says so, run before step 1 adds a line. |
| the shell over the objects | The reader’s bytes must not move. The two places predicted to break: rowid as a column, which the shell resolves and the query must too; and ORDER BY over mixed storage classes, which compare ranks and the query’s orderBy must call rather than reinvent. The sweep finds either on the first seed. |
object:new(dictionary) |
Thirty lines in builtins.c, one GC root that is load-bearing, and the shape questions the caller decides: a string key is interned as the symbol would be; a name that is not an identifier makes a slot only slotAt and perform reach, which is stated and not refused; the dictionary is read in its own order, which is hash order, so slots on a row answers the columns in no order worth relying on, as keys on the dictionary would, and a program that wants them in schema order asks the table. The name leak is bounded by the schema and is a sentence in the reference. |
| rows as objects | n:title walks the row’s own slots, then the prototype’s. A row of five columns is five comparisons of interned pointers; a dictionary at on a symbol key is a hash and a compare. Predicted within 2× of each other either way at five columns, and the linear walk losing past twenty. This is the prediction least sure of, and it is 6.15’s argument put to a number. |
a column called save |
Refused at table, by name, since a row that cannot be saved because its column shadows the message is the kind of silence the house rule is against. Predicted to be met by nobody and to cost one sentence. |
| UPDATE in the shell | The plan above put it out of scope as DELETE and INSERT at the SQL level, and it was; it comes in now because the oracle cannot judge save without it. Predicted a morning: SET of literals, the same WHERE, and the generator emitting it. |
What it would not find
Transactions and a journal. save writes a page and nothing rolls it
back; that is the program’s stated absence and this front inherits it.
Joins, or a query across tables. A query is one table, as the SQL is.
A block as a WHERE. where({ n | ... }) would be general and would
scan; all:select(block) is that already, on the array, and the entry
does not add a second spelling of it.
Step 0 the same evening, and the move found nothing
As predicted. Every top-level name became a slot on sqlite, every
f:value(...) became sqlite:f(...) by a script that skipped comments and
strings, and four things the script could not do by rule were done by hand:
a constant array called as a value, a function called with no arguments, two
blocks passed to takeWhile as values, which are sqlite:slotAt('isDigit)
now, and a local named page inside the function that reads an overflow
chain, renamed so that the prototype’s name could be rewritten without it.
The sweep over seed 1 was 629 of 629 before a line was added, the same
number as the day before, which is what the step was for. The rename went
further than the plan said: the corpus directory is programs/sql/ too,
and the two historical links in the changelog point at where the files
live now, as pattern.sol’s does.
Step 1 the same evening, and UPDATE found a defect in the writer
Rows as dictionaries, the shell a client, UPDATE in the shell and the
generator. The objects went in as sketched, with one change the sketch
could not have known: a block has one arity, so where(dictionary) and
where(column, op, value) cannot both be where, and the comparison is
filter(column, op, value). The SQL spelling of the operator was taken, as
call 2 recommended. update is the delete and the insert the plan said it
was, and the first author case of UPDATE found a defect the step-5 writer
had from the day it was written: a rowid put back after being taken out
descended past the interior divider equal to it and landed on the wrong
leaf, because the descent used the leaf’s rule, which steps past an equal
rowid to refuse it as a duplicate. integrity_check named the rowid. No
generated script had met it, since it takes a DELETE of exactly a divider’s
rowid and then an INSERT with that rowid given; the account is in
programs.md.
That is the held against column earning its place, and it is the second
prediction of this entry to be wrong in a detail it did not name: the
shell over the objects broke nowhere the table predicted, and the writer
under them broke where it did not look.
Steps 2 and 3 the same night: the primitive, and the rows on it
object:new(dictionary) is thirty-eight lines of builtins.c, the keys
checked before a slot is made, a string interned by its length so that a
NUL cannot cut a name short, and no root, which the prediction had
wrong: defining a slot mallocs it and interns its name in the permanent
table, neither of which goes through the collector, so nothing can run
between the child being made and its return. The case in
tests/test_object.c makes two hundred rows and a wide one under
SOLUM_GC_STRESS without one, and the comment beside the primitive says
why there is none, which the method asks for in place of a guard against a
hazard that is not there.
The rows became objects under a prototype a table, rowid, table,
save and delete on the prototype and the columns on the row, and the
shell did not change its cases: row:at(w) became row:slotAt(w) and
UPDATE builds its new values through asDictionary, since there is no way
to set a slot by a name in a value, which is the boundary the entry drew.
The sweep is the same 632 of 632 on seed 1 over the object rows, and 629 of 629 on seed 2, run after the records were written. The
measurement, 6.15’s second half put to a number, 400,000 reads:
o:c5 as a send 0.036 s against d:at('c5) 0.069 s, so the property is
twice as fast as the dictionary it replaced, and slotAt by a symbol held
in a variable 0.088 s, a fifth slower; at twenty-five columns the same
within noise, because an object past twelve slots has a hash index already,
which 6.15’s walked linearly predates. Making 20,000 rows costs 20% more
as objects than as dictionaries, the dictionary being made either way. So
the prediction’s first half held, within 2× either way at five columns, and
its second half, the walk losing past twenty, was wrong about the machine
it was measuring.
Steps 4 and 5, and the entry the primitive did not become
The example, the pages and the records, and one thing step 5’s row
predicted wrongly: the primitive became no COMPLETED entry, since it closes
nothing. 2.14 stays open with a paragraph saying the narrow form arrived
and from which shape, and the case is this entry. The four commits are
a238cf8 (the scoping), 9836029 (the move), 268ecaf (the primitive)
and 817edb7 (the objects, the shell, the example and the pages); the
journal has the evening and the changelog the three that landed.
The calls only you can make
- The names. The library
lib/sqlite.solbindingsqlite, the programprograms/sql.sol. Taken on 2026-09-15, on the recommendation. - How an operator is spelled in
where.where(#['done = #0])is equality; a comparison wants a spelling, and the choice is between the SQL one the engine already holds,where("count", ">", #3), and the language’s,where("count", 'greaterThan, #3). Recommended the SQL spelling: it is the file’s own language and the shell passes it through untouched, and a name likegreaterThanwould promise a send that never happens. Taken, asfilter(column, op, value), on 2026-09-15: a block has one arity, so the three-argument form could not share the namewhere. - Whether
closeis the only way the file is written, orsavewrites the row’s page as it goes. Recommendedclose, withflushbeside it for a program that wants to look withsqlite3before it is done: the engine batches changed pages, and one write of every changed page at the end is what step 4 of the plan above measured. Built as recommended, without a separate call. - Whether
object:new(dictionary)takes string keys as well as symbols. Recommended yes: column names are strings, andasSymbolon each before the call would be the caller doing the primitive’s job. Built as recommended, without a separate call.
Networking, and sending code to a machine that is already running
A socket exists now, and it is not in the VM.
ext_net.c is 156 lines of UDP loaded
with --extension=: net:udp answers a <socket> foreign cell, port asks
the kernel which one it got, send writes a datagram to loopback and poll
takes one if it is waiting. There is no close — the
collector closes a socket the program has let go of, and teardown closes one it
was still holding, which is the case an explicit close could never cover. It
lives in experiment/extension-probe,
off the search path, because it was written to press on the extension mechanism
at full size rather than to give this language networking.
It was not a demo, which is why it counts as evidence. It was the argument
for SolForeign: the first version handed a descriptor back as a plain integer,
so nothing closed it when a program was stopped, it went uncounted against
--memory, and a program could invent one and pass it to close. All three
became the case for the foreign cell, made by a file that needed it rather than
by a paragraph. Opening real sockets through it is also what found that bytes
are the wrong currency for a scarce resource — forty-byte cells holding
descriptors exhausted the process at a 256-descriptor ceiling with the heap
still nearly empty — which is where the collector’s foreign pressure count came
from.
So the sentence this entry used to open with — there is no socket anywhere in this repository — is false, and the conclusion it carried goes with it. A client and server pair no longer means new primitives in the VM. That was true while the VM was the only place a capability could come from; since extensions it is a choice, and the choice is the entry now.
The extension argument does not settle it, because sockets are the other
shape. dlopen won for GTK on a combinatorial one: two toolkits, neither
wanted by most programs, every pair of them a build, and a dependency that would
have cost no dependencies beyond a C11 compiler and make. There is one
sockets library, everyone who wants networking wants the same one, and it costs
no dependency beyond POSIX — so the reasoning that put GTK outside does not
reach it. Recommendation: an extension all the same, when it happens. A
socket in the VM is a capability every script gets whether or not the host meant
to grant it, which answers
6.32 by
pre-empting it; a bundle a host names on the command line is that entry’s
coarsest form already built. Moving it inward later is easy, and moving it back
out is not.
What the probe dodged is the part worth naming. Its poll is non-blocking
and runs from a frame loop SDL owned, so the socket never had to wait for
anything — the graphics library did the waiting. A server owns its own loop, and
waiting is what this machine has no answer for: a blocking read stops the only
thread there is, there is no second one
(3.11), and
concurrency is recommended against on the grounds that
it changes the whole VM. connect, bind, listen and accept are an
afternoon. How a program waits on one of two things is the entry, and no
amount of extension mechanism supplies it.
Sending code fragments to a running solvm is a step further, and depends on
three things rather than one. The sockets above; then
3.4, because a .sob is
not compatible across versions, so a service and its clients are lockstep or the
wire carries source; and then
6.32, because
this is that entry’s threat model stated exactly — input from a stranger — and
it is deferred precisely because nobody had one. Extensions shade that third
dependency without removing it: a host that does not name the bundle has no
networking at all, decided from C before the program runs, which is the shape
6.32 asked for and not the granularity it asked for.
It has a fourth dependency that is a feature rather than a decision. A remote object is the canonical customer for intercepting a message that was not understood, which is deferred below because nothing has wanted a proxy. Networking would want one, and the two should be considered together if either is.
The trigger fired on 2026-08-29, and from the direction this entry did not name: not two machines, but the question can we pull the probe’s socket out and have two programs talk? — which is the same want one step short of the wording. serve.sol still answers HTTP through environment variables; what is new is beside it.
Built: the sockets, as extensions/net
Five messages — udp, port, send, receive, waitFor — and a
client and server
that hold a counter between them. An extension and not a machine, which is
the recommendation this entry made when it was re-read: a socket in the VM is a
capability every script gets whether or not the host meant to grant it.
Two things the programs decided that no argument here had.
A packet has to say who sent it. The probe read with recv, so the first pair
written against it could not answer each other — the client wrote its own port
inside the message for the server to parse out. That is a protocol invented to
work around a missing field, which is what a missing field looks like from
inside a program.
And waiting is bounded rather than blocking, which this entry had guessed
was the hard part and was right about for the wrong reason. It is not only that
a blocking read stops the only thread there is: it stops the dispatch loop,
which is where --steps counts and --memory is checked. A program parked in a
syscall inside a primitive is a program no limit can reach, so a blocking read
would quietly suspend
6.33.
A timeout keeps that window a window.
What is still absent is absent for the usual reason: no TCP, no IPv6, and no
name resolution — getaddrinfo blocks, which is the thing above.
Still deferred: sending code to a machine that is already running
Untouched by any of that. It still wants 3.4, 6.32 and a proxy, and now that a socket exists the second of those is the one that matters: input from a stranger stopped being hypothetical the moment a program could receive a datagram. Nothing here does anything with what it receives except compare it against three words, which is the shape to keep until 6.32 is decided.
An inline cache at the send site
Where this came from. Solveig was measured against CPython 3.14 on 2026-08-29 — nine matched programs, interleaved through bench.sol, the first time anything here had been compared with another implementation. Level overall, and two of the nine stood out as losses: a character scan at 2.13×, which turned out to be a defect and is 4.4, and recursion at 2.03× — eighteen million sends of one method — which is this entry.
The proposal. Cache the resolved slot at each send site, so a monomorphic call site stops walking the proto chain after the first time. It is the standard answer, CPython has done a version of it since 3.11, and the JIT entry already names it as the thing that would have to exist first for a JIT to be worth anything.
And the claim made for it was wrong. It was written down as most of the recursion gap, on no evidence. Measuring says lookup is 9.7% of that benchmark:
$ sample <solvm running fib(34)> 807 samples
run_frames 525 65% the dispatch loop itself
sol_object_lookup_interned 78 9.7% the proto-chain walk
receiver_suits ⎫
+ sol_slot_accepts 106 13% the per-send receiver check
push_frame 44 5.5%
prim_less / sub / add 42 5.2% the arithmetic being asked for
sol_chunk_name 12 1.5%
The second measurement agrees. Two programs identical but for how far the method
sits from the receiver — one where it is the receiver’s own first slot, one five
objects up a chain with four slots to skip at each level — differ by 18%
(391 ms against 462 ms). So the entire walk in a deliberately deep case is
about a sixth of the run, and fib is not that case: self:of finds its slot
immediately, every time. A cache would remove something that is already nearly
free.
What the profile points at instead is more interesting than the proposal.
sol_slot_accepts is three predictable branches — is there a primitive, does it
take any receiver, does the type match — and it costs more than the lookup does,
because it lives in object.c and is called from vm.c on every send with no
inlining across the two. That is not a design question at all.
Which is where the build flag beat the design change. Compiled -O2 -flto,
with no source change, the machine is faster on all nine benchmarks:
strloop 1.29× · float 1.27× · loop 1.27× · array 1.25× |
|
higher 1.21× · fib 1.18× · object 1.17× · strlib 1.07× · dict 1.05× |
|
so fib against CPython |
2.03× → 1.61× |
And it silently breaks extensions, which is why this is a decision and not a
flag. The Makefile takes some care to publish the sol_* surface a loaded
bundle resolves against — whole-archive linking, and the comment beside it
explains that four binaries used to export four different accidental sets.
Link-time optimisation undoes exactly that:
sol_* symbols exported by bin/solvm -O2 147 -O2 -flto 0
make test catches it — test_an_extension_reaches_the_program fails, having
loaded a bundle that can no longer find a single one of the functions
extend.h promises it. So LTO here is not free
speed, it is speed traded for the extension ABI, and taking it would mean
keeping those 147 deliberately — an exported-symbols list, or visibility
attributes — which is a real piece of work and a new thing to keep in step.
Worth doing before anything on this page, and worth its own entry when somebody
wants it.
If all of that were done, an inline cache would still be worth about a
tenth, and the shape it would take here is awkward in a way worth writing down
now. There are no hidden classes to key on. An object is a slot list and a proto
pointer, slots are added at any time — p:x := #1 defines one — and nothing is
ever removed, so a cache needs an invalidation story that a global modification
counter would answer badly: the object benchmark defines two slots per pass
and would invalidate every cache in the program four million times. Keying on
the receiver’s own address works well for the case that prompted this, self:of
sending to the same object eighteen million times, and not much beyond it. The
cheap version and the general version are different features, and the cheap one
is the one whose customer exists.
Verdict: defer, and not for the usual reason. This one was measured rather than argued, and the measurement says the entry was about the wrong ten percent.
Trigger: a program that is actually too slow, with a profile putting
sol_object_lookup_interned at the top of it. Nothing here is and nothing here
does — and the two things above it in that profile are a missing inline and a
linker flag, neither of which is a language change.
Where the interpreter’s time actually goes — two built, two left
The entry above ended by pointing at two things above the inline cache in the profile. Asked what else could be done, the answer was got the same way: profile six benchmarks and a real program — basic.sol interpreting 39,000 BASIC statements, which is 2,774 lines of Solveig with objects, methods and dictionaries in it, rather than a loop somebody wrote to be timed.
$ sample <solvm running basic.sob> ~1550 samples
run_frames 851 55% the dispatch loop itself
sol_object_lookup_interned 205 13% name → slot
receiver_suits + sol_slot_accepts 195 12.6% the per-send receiver check
push_frame 61 4%
find_entry, memcmp, malloc ... the program's own work
Four candidates came out of it, ranked by what they were measured to be worth against what they cost. Two are built and are 4.5; two are still here.
1. The per-send receiver check, inlined — built. sol_slot_accepts is three
predictable branches, and it lived in object.c while its only caller lived in
vm.c, so every send paid a call across a translation unit for them. Moving it
to a static inline in the header is 1.4% to 6.5% depending on how send-heavy
the program is. It takes one symbol off the export table, 147 to 146, and that
one appears in neither extend.h’s surface nor
extensions.md, so nothing documented lost anything.
2. A global is a hash lookup — built. The measurement that found it: the
same loop written with globals and with block temporaries, which differ in
nothing else, are 1.255× apart, and the profile agrees independently —
sol_object_lookup_interned falls from 218 samples to 25. Every top-level
script and every REPL line pays it. The fix is to remember the slot at the site,
and it is safe for a reason that is this language’s rather than a general one:
a slot is malloc’d on its own and linked, and nothing removes one, so the
address is good for the life of the machine. ROADMAP 3.10 records that fact as a
problem; here it is a guarantee.
3. Computed-goto dispatch — built, measured, and it is slower. No. It was
the biggest thing left, at 55% of a real program, and it was the one item here
with an estimate rather than a measurement behind it. The estimate was wrong:
threading the loop with labels-as-values is 1% to 13% slower than the
switch, on all nine benchmarks and on the real program. See
below.
4. Keeping the exported symbols under LTO — still open, and the only one
left. -O2 -flto is 5–29% faster across the suite with no source change and
takes the whole extension ABI with it. It has an entry of its own now:
the exported symbol surface,
where the finding is that the surface is worth declaring whether or not LTO ever
follows — 146 symbols are exported, 22 are declared, and 13 are used.
What the two built ones came to, measured against the same source without
them, all at -O2:
basic.sol interpreting BASIC |
1.065× |
loop 1.284 · float 1.276 · array 1.237 · strloop 1.228 |
|
higher 1.126 · object 1.120 · dict 1.054 · strlib 1.037 |
|
fib, which touches no global at all |
1.003 — unmoved, and see 4.5 |
Against CPython 3.14 the suite’s geometric mean went 1.02 to 0.885, and Solveig is now ahead on five of the nine rather than four.
The verdict this changes is the inline cache’s, and only by agreeing with
it. Two of the four things worth doing to the dispatch loop were a missing
inline and a table lookup, and neither is a design question. That remains the
finding.
The exported symbol surface, and the LTO it is blocking
Two things, and the second is the reason the first got looked at. They are worth separating, because one of them is worth doing on its own and the other is a trade.
What is actually exported
An extension is a separate .so whose calls back into the machine are left
unresolved on purpose — -Wl,-undefined,dynamic_lookup — and bound at dlopen
against the symbols bin/solvm exports. So the executable’s symbol table is
the ABI, and the Makefile works to make one exist: a linker takes objects out
of an archive on demand, so -Wl,-force_load (and --whole-archive on ELF) is
what stops the four binaries exporting four different accidental sets, which is
what they used to do.
That fixed the inconsistency and left the size:
sol_* functions extensions/net actually calls |
10 |
| union of what every bundle in this repository needs | 13 |
sol_* functions extend.h declares |
22 |
sol_* functions bin/solvm exports |
146 |
The surface is not chosen, it is whatever is not static. An extension can
bind to any internal function that happens to have external linkage, and there
is no declared line between the contract and the insides. So an ordinary
refactor — marking something static, renaming it, changing a signature —
breaks a third-party extension silently, and nothing in the tree notices.
That is not hypothetical, and it nearly happened here.
4.5
made sol_slot_accepts a static inline, which took it off the export table:
147 symbols to 146. It was checked by hand against extend.h and it was not
there, so nothing was owed. Nothing in the build checked, and nothing would
have said so if the answer had been the other way.
What LTO does to it
-O2 -flto is 5% to 29% faster across the benchmark suite with no source
change at all — the largest unclaimed number this project has. It also deletes
the entire ABI, because internalising symbols nothing in the program references
is exactly what whole-program optimisation is for, and no extension is part of
the link:
exported sol_* symbols -O2 146 -O2 -flto 0
$ solvm --extension=build/extensions/net.so probe.sob
solvm: cannot load extension build/extensions/net.so:
dlopen(...): symbol not found in flat namespace '_sol_foreign_handle'
It compiles, links, and passes everything that does not load an extension.
test_an_extension_reaches_the_program is the whole of what stands between that
and a release.
The two halves
Declaring the surface is worth doing whether or not LTO ever follows. It
turns 146 accidental symbols into a decision, and it is testable: compare nm
against the declaration and fail the build when they disagree, which is a check
this repository has no equivalent of today. It is also the only way to find out
what the 146 minus 22 are — some of them are certainly wanted and undocumented.
Turning LTO on is a separate call with its own costs, and they are not
small: links get much slower, inlined frames make a profile and a debugger
harder to read, and make builds -g — so the thing developed against and the
thing shipped would diverge further than they already do. A bug that appears
only under -O2 -flto is a bad one to go looking for. Worth remembering too
that hand-inlining one function was 6.5% on a real program: selective
inlining takes part of the win with none of the cost.
The mechanism, and the objection it has to answer. macOS wants
-exported_symbols_list, ELF wants --dynamic-list or -fvisibility=hidden
with explicit default on the exports. Either way that is a list — and this
Makefile argues in three places that a hand-kept list goes stale. So the list
must be generated: a SOL_API marker on each intended export, and the build
writing the linker’s file from those, the way $(BUILD)/config.h is already
written and replaced only when it changes. Then the surface is declared at the
function it belongs to and there is no second place to forget.
The first half is built and is
4.6:
SOL_API on each export, -fvisibility=hidden on everything else, 146 exported
symbols down to 29, and both directions tested. Six were promoted on review,
three of them closing the dictionary gap extensions.md had
already written down as waiting for somebody to decide.
The second half stays deferred, and now on its own merits rather than
because it breaks extensions. -flto is 5–29% and costs slower links, inlined
frames in a profile and a debugger, and a wider gap between the -g build
developed against and the one shipped. Trigger: a program that is too slow at
-O2. Nothing here is, and hand-inlining one function was already 6.5% on a
real program.
A conformance suite — a corpus a second implementation can score itself against
The name is conformance suite — compliance suite and validation suite
are the same thing, and the ones worth reading are
test262 for ECMAScript, ACATS for Ada, and
the NBS Minimal BASIC programs this
repository already runs basic.sol against. A torture test is a different
genre — GCC’s — and tests the compiler rather than the language.
The distinguishing property is that it tests the language, not this
implementation: every case is written in the language, depends on nothing
outside what is documented, and states its expected result where a stranger’s
program can read it. No make target, no bin/solvm, no wording of ours.
Three checks that look like this one, and none of them is
| what it holds | why it is not a conformance suite | |
|---|---|---|
tests/*.c — 40 files |
the C API, the compiler and the VM | internal by construction; a second implementation has none of these symbols |
| expect.sol — 1196 claims | the examples and the documents against their own comments | the author’s corpus, matched as a subsequence — its own header says why, and why that is right for a document |
| oracle.sh — six corpora | sed, diff, sort against BSD’s |
a second implementation of a program, not of the language |
The subsequence rule is the sharp one. expect.sol requires each claim to
appear in the output after the one before it, because one statement can print
many lines — right for a document, and it cannot score conformance, where the
answer is the bytes or it is nothing.
What varies decides the shape, and there are three strangers
| writes | runs the case how | what it can fail | |
|---|---|---|---|
| a second front end | .sol → .sob |
their compiler, our machine | scanning, parsing, lowering, the scope rules, the limits |
| a second machine | .sob → behaviour |
our compiler, their machine | dispatch, arithmetic, errors, the collector |
| Phoenix | another language → .sob |
neither | the answers, and nothing else |
Phoenix is the customer that exists and it cannot consume .sol at all. It
emits bytecode directly from a language of its own, so a corpus of Solveig
source is not input to it — only the answers are, and it reaches them by
translating each case by hand, which is exactly how a person uses the NBS suite.
That settles the unit. A case is (program, byte-exact output, exit status), the source is the notation rather than the subject, and the same case scores all three strangers because only one of the two tools is ever swapped.
The negative half is already written down, and nothing runs it
This is the part that argues for building rather than deferring, and it is a finding about what ships rather than a proposal.
PRODUCING.md tabulates eleven refusals a grammar cannot
carry — self outside a block, assignment to self, a duplicate temporary,
a temporary shadowing a parameter, a duplicate parameter, a chained comparison,
an operator opening an @expr, an infix outside a region, a directive not
standing alone, an unknown directive, a self-include. Three are pinned by a
test (test_include.c, test_compile.c) and eight are not, including
every scope and name rule and all three @expr rules.
It tabulates eleven chunk limits — 255 slots, 255 array elements, 127
dictionary pairs, 255 arguments, 65,535 names, constants, blocks, and the jump
distances. No test names one. Each was found by generating programs until
solas refused, and nothing since keeps them where they were found.
And examples/ carries 15 commented-out programs across 8 files, each with
the diagnosis it expects written beside it:
; #2:add(1.5). ; solvm: 'add' expects integer, got float
; #7:div(#0). ; solvm: division by zero in 'div'
; @expr( f(3) ). ; solvm: float does not understand 'f'
Nothing runs one, and expect.sol’s rule excludes them on purpose — a line
that begins with ; never runs, so it has no output to match. They are the
first thing a newcomer reads about what fails, held true by somebody having
looked once.
Three were re-run on 2026-09-03 and all three still hold — self := #1,
{ | t, t | t } and @expr( #1 < #2 < #3 ) each refused with the sentence the
page prints, and solas leaves with 65 on a refusal and 0 on success. So
the scoring is mechanical in both directions, which is the property the NBS
suite has not got.
The tree
conformance/
README.md what it covers, what it does not, how to point it at your tools
run.sh the harness -- SOL_COMPILE and SOL_RUN out of the environment
accepted/
00-lexis/ literals, the type tag, '#[' as one token, comments, line ends
01-values/ integer, float, string, symbol, boolean, nil, and the strictness
02-sends/ unary and keyword, chaining, the order the arguments evaluate in
03-blocks/ parameters, 'self', closure over a slot, the non-local answer
04-objects/ proto, slots, 'via', class side against instance side
05-errors/ what raises, what it says, and that 'onError' catches it
06-limits/ 255 slots, 255 elements, 127 pairs, 255 arguments -- at N
07-library/ only what the reference calls built in
refused/
scope/ 'self' outside a block; assignment to 'self'
names/ a duplicate temporary; one shadowing a parameter; a duplicate parameter
expr/ a chained comparison; an operator opening a region; an infix outside one
directives/ not standing alone; unknown; a file that includes itself
limits/ each of the eleven, at N+1
A case is two files, name.sol and name.out, and the .out is compared
byte for byte. Not inline in a comment: a trailing space and a missing final
newline are both things an implementation gets wrong, and a comment cannot carry
either unambiguously.
Everything else lives in a header inside the case, so there is no second list to go stale. Every check here enumerates by extension, and this one reads the case rather than a manifest beside it:
; conformance: integer overflow is an error rather than a wrap
; varies: machine
; status: 0
; conformance: a temporary may not shadow a parameter
; varies: front
; refused: names/shadows-a-parameter
; status: 65
A refusal case names the rule, never the wording. A stranger’s compiler will
word its errors differently and should; what conformance can demand is that the
program is refused, that nothing reaches standard output, and that the rule
broken is the one named. Ours is then held to the sentence by tests/, which
is where a wording belongs.
The harness, and what makes it not our test suite
SOL_COMPILE='./bin/solas %s -o %s' # the default, and the only mention of our tools
SOL_RUN='./bin/solvm %s'
./conformance/run.sh # every case
./conformance/run.sh accepted/03-blocks
A second front end sets SOL_COMPILE; a second machine sets SOL_RUN; Phoenix
sets both and translates. The suite must run against the installed binaries
rather than the working tree, which is
the rule that has caught this before.
And it belongs in make test. The oracle scripts are out of it because they
need the network and twenty megabytes; this one needs neither, and a conformance
suite the reference implementation is not continuously scored against is the
decoration case — nobody would find out the day one of the eleven limits moved.
What it is not, and one thing it must not resurrect
It is not a directory of malformed .sob files. PRODUCING.md refuses that
deliberately and the refusal stands: sol_chunk_save will not write a chunk
that fails to verify, so producing one means patching bytes, and a producer does
not need our broken files — it needs its own diagnosed, which is what the
verifier’s split into a sentence apiece bought. The corpus that keeps those
sentences still is test_serialize.c, where a chunk can be built directly. This
tree is source, and does not overlap it.
It is not a specification. It is a set of answers, and every case it does not contain is unspecified by it. That is worth writing in the README, because the failure mode of a conformance suite is a second implementation treating a gap as a licence and this one as a definition.
The trigger, and where it has and has not fired
For the negative half, it fired before the suite was proposed: eight documented refusals and eleven documented limits with no check, and 15 error demonstrations in the examples that nothing runs. Those are sentences that were true when written, and the trigger for that is the sentence existing.
For the front-end half it has not fired. Nobody is writing a second
compiler for this language. The cases would still be right and would still be
run — by us, against solas — but the audience for varies: front is
hypothetical in a way varies: machine is not.
And the half with a live customer is the one the question did not ask for. Phoenix’s chunks are checked today by the verifier accepting them, which is a check that they are well-formed and not that they compute the right answer. Nothing scores what a Phoenix program prints. That is the gap, and a corpus of answers closes it whether or not a second front end ever exists.
The recommendation
Build accepted/ first, with the harness and the two-file case. It closes
the Phoenix gap, it is the half that scores every stranger including the ones
that cannot read .sol, and it can be seeded from the examples rather than
invented — 37 files of claims already exist and want
only exact output beside them instead of a subsequence.
Then refused/, and it is mostly transcription. The eleven scope rules and
the eleven limits are already tabulated with their triggering programs;
generating a case at N and at N+1 is what found the numbers in the first place.
Expect it to find something: the last time this page’s limits were checked
against what ships, SOL_MAX_LOCALS turned out to be 256 where the format
writes a byte.
Do not seed it by copying examples/. An author-written corpus tests what
its author thought of, and this one would inherit the same blind spot twice
over. The examples are the starting set because they are cheap and already
true; what makes the suite worth more than expect.sol is the cases nobody
wrote a demonstration for — evaluation order, the boundaries, and the errors.
The calls
- Is a second front end worth writing cases for at all, or is this a corpus
of answers for producers? The recommendation is answers first: it serves
Phoenix, which exists, and
varies: frontcosts nothing to add later since the header already carries the field. make test, or beside the oracles? The recommendation ismake test— it needs nothing external, and a conformance suite that is not run is the thing this page calls decoration. The cost is that every new case slows the build a little.- Byte-exact output, or a declared tolerance? The recommendation is exact, with no escape hatch. A float that prints differently is a conformance failure and should read as one; the alternative is a tolerance that grows until it hides something.
- Does the README promise stability? A suite a stranger scores against is
a thing they will pin. The recommendation is to promise nothing beyond what
.sobpromises — the format has no compatibility window and this should not invent one — and to say so in the README rather than leave it inferred.
Built on 2026-09-03 — the accepted half, and what it did and did not find
conformance/, 42 cases in eight directories, with
run.sh taking both tools from the environment: SOL_COMPILE and SOL_RUN,
each a template with %s where a path goes. A second front end sets the first, a
second machine sets the second, and a producer that emits bytecode from another
language sets both and translates — which is the case the two-file shape exists
for, since a .out file is an answer and a program with assertions in it is not.
Every expected output was written from the documentation before it was run,
which is the only arrangement under which a corpus like this can find anything:
recording what an implementation prints produces a file that agrees with it by
construction and can never fail. 40 of the 42 held on the first run. Both that
did not were the author’s arithmetic — @expr( #1 + #2 = #3 ) written down as
false, and "{{}}" read as {} where the documented rule gives {}}.
So the corpus found nothing, and that is the finding. Every claim it asked
the reference for was true, including the ones nobody had run: the floored
division’s four sign cases, the shift that agrees with it, split never dropping
a piece, the format spec’s flag order, and the three literal limits, each of
which was confirmed to accept N and refuse N+1 rather than being taken from the
page.
What it did find is beside it rather than in it, and there were three.
- REFERENCE.md was wrong about
onErrorin both halves of one paragraph. “The handler is checked when it is run, not whenonErroris sent, so{ #1 }:onError(#2)is quiet when nothing fails. That is how every block argument here behaves:false:ifTrue(#5)says nothing either.” Neither is true — both are refused — and Control flow in the same document says at length why they must be, that being the change this paragraph predates. Corrected. It is the exact shape the suite exists to catch and the checker could not: a sentence, in prose, contradicted by another sentence in the same file. The order arguments are evaluated in is unspecified.Said on 2026-09-03: the receiver first, then the arguments left to right, all the way down — a nested send finishes before the next argument begins,[...]is itsarray:of, and#[...]runs each pair’s key and then its value. An@exprregion is the same order because it lowers to the same sends and compiles to the same bytes. The exception is a block, which runs nothing until it is sentvalue, however far left it sits. REFERENCE.md has it andconformance/accepted/02-sends/evaluation-order.solscores it. Measured before it was written down, which is the only way that sentence could have been right.- The 256-frame ceiling is not a language fact. How deep a program can
recurse depends on what each construct costs in frames — an inlined
ifElsecosts none, a send costs one — so a case pinning the deepest working recursion would score an implementation’s accounting. Measured at 252 levels for one shape, and deliberately not written down as a case.
The harness was proved able to fail, which is the rule this page applies to
every check: a deliberately broken tree fires all seven of wrong output, missing
.out, missing header, an unknown varies, a compile refusal, a wrong exit
status and anything on standard error, and the run leaves with 1. Pointed at a
machine that runs nothing, all six lexis cases fail; pointed at the real
binaries by absolute path, all six pass. Both directions, because a harness that
only ever passes is the decoration case with an extra step.
Call 2 was answered the same day: make test runs it, first. A corpus a
second implementation is invited to score itself against has to be one this
implementation is scored against continuously, and the day one of the eleven
chunk limits moves is the day that matters. It needs no network and no clone,
which is what keeps it out of the company of the oracles.
Ordering it first was decided by a measurement rather than by precedence,
and the measurement is the surprise: make test is 84 seconds on this
machine, of which test_cli alone is 79.75. Everything else together is
about four, and the corpus is one. So a broken case now says so at the start
instead of after a minute and a half — and the Makefile’s own comment, arguing
that the nine comparison benchmarks stay out because ninety seconds would go
into a suite that takes eight, is comparing against a number that is no longer
true. The conclusion probably survives; the argument does not, and it is left
where it stands rather than quietly rewritten.
And test_cli is not slow — it is where the whole-repository checks live,
which was worth breaking down before anybody set about making it faster.
test_everything_written_down_is_true compiles expect.sol and runs it over
examples, docs, the two root pages and extensions: 54 of the 79.75
seconds, at 41% CPU, because it shells out to compile and run each claim. The
grammar sweep beside it is 0.6. So two thirds of the test suite is the
documentation checker, most of that is waiting, and it sits in a binary named
for the command line — which is a filing question before it is a performance
one.
The refused half is not built, and nothing above changes its estimate: the eleven scope rules and the eleven limits are still tabulated in PRODUCING.md with their triggering programs, and the three literal limits now have a confirmed N+1 apiece waiting for a corpus to hold them.
And the refused half, the same day — 89 cases, and a warning that was filed as a refusal
The scoping was wrong about the shape of this half, and building it is what
showed how. It sketched one tree of refusals — scope/ names/ expr/
directives/ limits/ — and those are all compile-time: solas rejects them
and nothing runs. But the 15 commented-out demonstrations in examples/ that
the entry counted as the trigger are 13 run-time and 2 compile-time.
#2:add(1.5) and #7:div(#0) get as far as the machine and stop there, and a
corpus cannot score them the same way: one says your front end must reject
this, the other says your machine must trap rather than wrap, and the second
is a claim about a second machine that a tree of refusals has no place for.
So there are three kinds and three directories, and the header says which, so it is one tree read one way rather than three read three ways:
| scored on | ||
|---|---|---|
accepted/ |
compiles and runs | the exact output, status 0, silence |
trapped/ |
compiles, runs, stops | the output up to the stop, a nonzero status, and that something was said |
refused/ |
the compiler rejects it | that it was rejected, and that the legal neighbour was not |
Every refusal carries a -legal neighbour — the same program with the one
offending thing put right, which must compile and run — and the harness fails a
refusal that has none. Without it a front end that refused everything would
score full marks on the whole of refused/. That is
oracle.sh’s arrangement exactly, where agree/ must
match and differ/ must not, and it was the first thing the design borrowed
rather than the last.
The finding: a file that includes itself is a warning, not a refusal.
PRODUCING.md listed it in a three-row table under
the rules solas enforces, beside a directive must stand alone and unknown
directive, both of which reject. A self-including file compiles, leaves with
0, and runs with the include having done nothing. A producer reading that
table would implement a rejection; what it must actually implement is noticing
the cycle and carrying on, which is the harder of the two and the one a naive
front end gets wrong by not stopping.
Corrected, and the case moved to the accepted side where it belongs — which
needed one new header field, stderr: expected, because a program that warns is
neither silent nor stopped.
The other ten fire exactly as documented, each triggered before its case was written rather than taken from the page.
And the stderr rule got stronger while that was being settled. Silence is now checked in both directions — a case claiming it must be silent, one claiming a diagnosis must produce one — so the field is a claim rather than a waiver. And what the compiler said on the way past counts with what the machine said, which is what makes a warning visible at all. That change made the 42 cases already there prove something they had not: that a clean program compiles silently.
89 cases now — 45 accepted, 29 refused (14 refusals, 14 neighbours, one
included file that is a case in its own right), 15 trapped. Every expected
output still written from the documentation before it was run, and the five new
failure modes proved on a deliberately broken tree: a refusal that compiles, a
refusal with no neighbour, a rule id that is not group/rule, a trapped case
that runs to the end, and a case claiming a diagnosis that says nothing.
What is left is the five 65,535 limits — names, constants and blocks in one chunk, and the distance a conditional or a loop jumps over — and a case at N would be a file of that many lines. They stay a generator’s business rather than a corpus’s, which is what PRODUCING.md already says of them.
A generated file that says where it came from: scoped 2026-09-16
Asked by Parasol on 2026-09-01, while it was a repository of its own, and
numbered here on 2026-09-15 as
3.29.
solas, solvm and solid report positions in the file they were handed.
Parasol compiles calc.psol to calc.sol and writes calc.sol.map beside
it, every generated position against the .psol position that caused it, so
the information exists and nobody but a person reads it: a failure says
[calc.sol:3] and the person opens the map. The entry lists three shapes in
order of how little each asks of this side. This scoping counts what each
would cost and do, and recommends one of them, under a trigger that has not
quite fired.
What the machine already carries
A chunk records, per emitted byte, a line and an index into a table of file
names; @include is why the table exists, since a library’s code lands in
the same chunk as the file including it. The compiler writes both at one
place, emit in compiler.c, from the previous
token’s line and the path it was given, and the parser prints a compile
error’s position at two. Three sites read a position. solvm prints
[file:line] from the table, and solid opens the file the table names
and shows the line. So anything that changes what goes into the table at
compile time is seen by every tool afterwards with no change to any of them,
and the .sob format does not move. That is the observation the entry ends
on, and it decides most of what follows.
What the map carries, counted
The fourteen generated files under build/ on 2026-09-16, the five examples
and the nine from the seven programs, with their maps:
| generated lines | 973 |
| of them mapped, the rest blank or banner | 945 |
| map entries, one per emitted piece | 7,988 |
| generated lines carrying more than one line of their own module | 125 |
| generated lines whose first piece came from a template in another file | 198 |
| generated lines with no piece of their own module at all | 49, of which 47 in minibasic.sol |
The 125 is the span problem
3.31 names,
and bignum.sol gives 16 of 103, which is the figure the 7.10 table found
on 2026-09-13; the count agrees. The 198 and the 49 are the question a
line-granular answer has to face: a generated line built from control.psol’s
while template and the module’s condition has two origins, and the one a
person wants is the use site, not line 25 of the library. The map records
both; anything coarser than the map has to choose.
The shapes, and what each would do to [calc.sol:3]
A. solas --source-name=<path>. One flag, one field: the file table
records the name given instead of the one on the command line. The line stays
the generated file’s. So [calc.sol:3] becomes [calc.psol:3], and line 3
of calc.psol is a comment; the real origin is line 29. A wrong line in
the right file is worse than the right line in the wrong file, because the
second sends a person to the map and the first sends them to the wrong place
with no reason to doubt it. Recommended against, and it is the only shape
this scoping argues against outright.
B. A @line directive. @line 29 "programs/bignum/calc.psol"., in the
generated text, setting what the next statements record, which is what every
generated-source language ends up with and is what #line is. Per line,
because a directive stands alone as a statement. Counted against the maps,
emitting one wherever the origin is not the previous line’s plus one, and
taking the use site where a line has one, it is 508 directives across 945
generated lines: a directive on more than every other line, in a file
Parasol’s own page says is meant to be read with less and diffed with
git. It fixes file and line and leaves the 125 spans as they are, since a
line can only name one origin. On this side it costs a lexer case, a compiler
field for the running offset and the overriding path, the three read sites,
and the tail every language-visible thing carries: GRAMMAR.md, solum.bnf,
a check_syntax fixture, conformance/refused/directives re-read, the
cheatsheet and the reference. Small, and visible everywhere.
C. solas --map=<path>, reading the map Parasol already writes. A text
reader for the five-column format, and a lookup at the three sites: a
token’s line:column in the generated file to the entry at or before it on
that line, which is the source-map rule, answering a file and a line, and for
a compile error a column too, since the map has one. The generated .sol
carries nothing. The .sob format carries nothing new; the table it already
has holds .psol names and .psol lines. And it is per piece rather than
per line, so the 125 spans resolve on their own: bignum.sol:27 is four
.psol lines, and each instruction on it was emitted from one token, and
that token has an entry. The line half of 3.31 goes with it, and the column
half is what it was. solid opens calc.psol at line 29 and shows what the
person wrote, since it opens whatever the table names. parasol --sob passes
the flag through when --map was given, as it already passes -o and -I.
What it costs is a reader of perhaps a hundred lines, a lookup, the three
sites, and a contract: the map format, today # parasol source map 1 and
Parasol’s, becomes a format solas accepts, which is a file format and a
command line, the coupling the two tools have had since the first day. Nothing
under parasol/ includes a Solveig header for it, and nothing under solas/
knows the map came from Parasol.
D. Parasol re-says the diagnostics. Since 2026-09-14 parasol --sob holds
solas on a pipe, and could read [calc.sol:3:1] off it against the map and
print [calc.psol:29:1] in its place, asking nothing of this side. It
answers for compile errors only. A run-time failure is solvm’s, hours or
days later, with no Parasol in the process, and the run-time failure is the
one both occasions below were. On its own it is half an answer; beside C it
is redundant.
E. The chunk carrying the map. A format bump, so that a frame could show both the generated and the source position. Nothing has asked for both, and with C the table already holds the one that matters. Not before something asks, as the entry says.
The trigger, and where it has and has not fired
Two occasions, and both were exercises about the map rather than work
interrupted by it. On 2026-09-13 programs/bignum put a deliberate error in
a copy of its library to test the map across two modules, and found the span
problem, which is 3.31. In run 4 of the reader experiment solvm reported
undefined name 'total' at banner.sol:8, the failure this project names as
the one that kills syntax-extension systems, and no map had been written
because the reader had been told to omit --map; with one the recovery was
exact, by hand.
So the mechanism has been needed twice and used by a person both times, and neither time was somebody stopped in the middle of something else. The trigger is the first run-time failure in a Parasol-written program that is met while doing something else, where reading the map by hand costs a wrong guess or a second run; or the reader experiment’s next run with the published invocation, which 7.10 says is the run that settles whether a person reaches for the map at all. Either fires this.
The recommendation
C, when it fires, and not before. It is the one shape that uses what the
map already has instead of throwing most of it away, it leaves the generated
file readable and the .sob format alone, and it dissolves half of a second
entry on the way. B is the conventional answer and is smaller by perhaps
forty lines, and every one of those lines is paid back in 508 directives a
reader has to see past. A is harmful. D is for the compile-time half only and
is already possible on Parasol’s side whenever it wants it.
If it is built: the reader and the lookup in solas, with a test that
compiles a generated file under its map and asserts the .psol positions in
both a compile error and a run-time frame; parasol --sob handing the flag
through; the map format’s five columns written down once as what solas
reads, on the page that describes --map; and 3.31’s account amended to say
its line half went this way and its column half stands.
The calls
- Does the map format become the toolkit’s, or stay Parasol’s?
solasreading it makes it a contract between the two, like.soland.sob. The recommendation is to say so on the page that describes--mapand to leave the# parasol source map 1header as it is, so a map is still recognisably Parasol’s even when something else reads it. - Source position only, or both? A compile error mapped through C would
say
[calc.psol:29:1]; it could also say where incalc.solthat was. The recommendation is source only, since the generated file is not the file anybody wrote, andsolaswithout--mapstill says the generated position for anyone who wants it. - Wait for the trigger, or build on the two staged occasions? The recommendation is to wait: both occasions were about the map, and a mechanism whose customers are its own tests has been built here before and regretted. The reader experiment’s next run is the cheap way to find out.
- B anyway, for a generator that is not Parasol? Nothing in the tree
generates Solveig source but Parasol;
pascal.solemits bytecode. A shell script writing.solcould use@linewhere it could not write a map. The recommendation is not to build for a generator that does not exist, which is the rule Parasol’s optional parts were refused under three times.
Forms over a database, the way VB3 did it: scoped 2026-09-17
Asked on 2026-09-17, two days after the objects went in, and it is the
customer that entry was waiting for. The database as objects
put a table, a query and a row over the engine so that a program could say
n:title := "eggs". n:save without SQL, and the first program to want that
is a form: a window over a table, one record at a time, with labels and
entries for its columns and buttons to move, add, save, delete and search.
The inspiration is Hans’s and it is named: VB3 and .NET, where a form is
controls, a data binding that ties a control to a field, and event
procedures written between them. The designer those tools had, a form drawn
rather than written, is for later and this entry does not plan it; the
first thing is a form that is written, connects to a file, and takes the
program’s own logic in the middle, which is the control layer in the sense
that word had before the web took it.
What it looks like, written before a line of it exists. A sketch, not run, and the file is a contacts table because that is the smallest one that wants every kind of field the first form needs and a code table the second step will:
@include "sqlite.sol".
@include "recordset.sol".
@include "form.sol".
db := sqlite:open("contacts.db").
db:table("contacts"):isNil:ifTrue({
db:create("contacts", ["name TEXT", "email TEXT", "phone TEXT", "kind TEXT"]) }).
people := recordset:on(db:table("contacts")).
gtk:start.
f := form:on(people, gtk:window("Contacts", #480, #320)).
f:field('name, 'text).
f:field('email, 'text).
f:field('phone, 'text).
f:field('kind, 'text). ; a dropdown over a code table, later
f:navigation. ; First Prev Next Last New Save Delete Find
f:onValidate({ r | r:name:size:greaterThan(#0) }).
f:onSave({ r | r:email := r:email:asLowercase }).
f:show.
gtk:run.
db:close.
Three layers, and the line between them is the one the GTK binding’s page already draws for itself, each binding publishes what its toolkit has:
- The controls are C, in
solveig-gtk, one message per GTK thing and nothing about records. Today the binding is twenty-two messages and five widgets: a window, a label, a button, a box, and a canvas (its page said seventeen, a count the canvas had outgrown). A form needs an entry, a check button, a dropdown, a text view for a memo, a scrolled window, a grid to lay labels beside entries, and, for the master-child step, a list. With their reads, writes and events that is about twenty messages:entry,check(text),dropdown(strings),textView,scrolled(child),grid(#columnSpacing, #rowSpacing)andattach(grid, child, #column, #row, #width, #height),listwithlistAddandlistClear;textandsetTextextended to an entry and a text view by asking GTK what the widget is;checkedandsetChecked;selectedandsetSelected, an index counted from#1as everything here is;setSensitive, since a Save button on a record nobody changed is grey in every tool the inspiration names; andonChange,onActivatefor Enter in an entry,onToggle,onSelect. The binding doubles, mechanically, and every message checks its arity and its widget’s kind as the seventeen do. - The recordset is Solveig and knows nothing of widgets. It is what
VB’s
Datacontrol and .NET’sBindingSourceare: a table, a query over it narrowed or not, the rowids that query answers in order, a position, and the current row.on(table);first,next,previous,last;current,position,count;new, which is a state of the recordset and not a row, since the library makes rows only byinsertandfindand a row without a rowid is nothing it has;save, which isinserton a new record androw:saveotherwise;delete, which moves to the neighbour;find(pairs), which narrows towhereand stands on its first row, andall, which drops the narrowing. This is the half that can be tested without eyes, and the reason it is a separate object is that reason: a window cannot be driven down a pipe, which is why the binding’smake testcompiles its examples and runs none of them, and a form’s logic that lived in the window would be tested by clicking or not at all. - The form is Solveig, in
solveig-gtk, and is the binding between the two.on(recordset, window);field(column, kind), which makes a label and a control, attaches them to the grid on the next row, and remembers which column they stand for;navigation, the standard button row, opt-in;button(text, block)for one the program adds;load, which puts the current row into the controls, andstore, which reads them back into it;onValidate(block), a block over the row that answersfalseto refuse a save, andonSave(block), run on the row before it is written;show. The kinds are'text,'integer,'real,'checkand'memoto begin with;'choiceover a code table is the third step. A kind is where a control’s string meets a column’s type, and it is the only place that knows both.
Where each lives, and one fact about the search path
lib/recordset.sol here, binding recordset, beside sqlite.sol which
is all it includes, on the library list in test_compile.c, with its
section in REFERENCE.md, its line in CHEATSHEET.md and an
examples/recordset.sol whose claims expect checks. It names no gtk,
so the front page’s no-dependency sentence stays true and the suite runs
it on every platform the CI has.
lib/form.sol in solveig-gtk, binding form, and that repository
grows a lib/ it has not had. It names gtk, so it cannot live here,
and it names recordset, so it is found through the search path as
edit.sol finds re.sol today. That sets one thing: the binding’s README
says Solveig 0.37.0 or later, and a form.sol that includes
recordset.sol needs the release that ships it, so the minimum the
Makefile checks moves to that release on the day the form goes in. And
examples/contacts.sol there says @include "form.sol", which is looked
for beside the includer first and then on the search path, so the
binding’s Makefile passes -I lib to solas, which it has a comment
about already. The records stay here, as that repository’s CLAUDE.md
says they do; a closeout there is a README and a commit.
The steps, and what each is held against
Each step ends green before the next begins, and the second is the one the suite can see.
| step | builds | held against |
|---|---|---|
| 0. The controls | the twenty messages in gtk.c, the README’s reference grown to match, and a widgets.sol example that puts every one in a window so that each can be seen once |
make test there, which compiles every example; and eyes, since nothing else can see a window |
| 1. The recordset | lib/recordset.sol, examples/recordset.sol, the library list, the reference and the cheatsheet |
make test here, the example’s claims under expect; and sqlite3 reading the file the example wrote, by hand, since the sweep speaks SQL and cannot judge a recordset |
| 2. The form, single record | lib/form.sol and examples/contacts.sol in the binding, -I lib in its Makefile, the version minimum moved, the README |
being used: a contacts file kept for a while, and sqlite3 reading it after |
| 3. The records | this entry’s outcome under its prediction, the changelog, the journal, the design table’s Graphics and Databases rows | make test |
Not in these steps, and named so that they are not smuggled in: the
master-child form, where a list on the parent’s window shows the child
recordset narrowed by the parent’s current rowid; the 'choice kind,
a dropdown over a code table storing the key and showing the text; and
the designer. Each is a scoping of its own once a single-record form has
been used, and the first of them is the one that tests the prediction
below.
What it would press on, predicted before writing
| 3.1, and this is the prediction the entry is most sure of | Every handler in the binding’s three examples is hung at the top level of the script, whose frame lives for the run. A form library hangs its handlers from inside a method: navigation makes a Next button and says gtk:onClick(b, { self:next }), and that block captures the method’s frame, which is gone before the button is ever pressed. Predicted to be met on the first button of step 2, reported as block outlived the frame it was written in, and the way through is the one the language allows and the one the journal argued on 2026-08-24 when a pattern library was first weighed, an object rather than a closure: the handler reads no local and no self, and reaches the form through a global, form:active, which is right while there is one form, which is the stated scope. The master-child step, with two forms alive, is where that runs out, and the entry predicts it as the third real customer for 3.1 after Pascal and awk, and the one that asks for either its upgrade path or an onClick whose block is handed the widget it fired from. |
text on an entry |
The C asks GTK_IS_EDITABLE before GTK_IS_LABEL, and a text view is a buffer with bounds, ten lines. Predicted nothing. |
load fires onChange |
Putting the row into the controls is setText on each, and each setText fires the change handler that marks the form dirty, so a record just loaded would ask to be saved. Predicted on the first load, and a loading flag the handlers check is the whole fix, which is the guard every binding in the inspiration has under one name or another. |
| What an entry gives an INTEGER column | Found while scoping: the library already does what sqlite3 does on the way in, storeAffinity makes "42" the integer 42 under a numeric affinity and leaves "abc" as text. So the 'integer kind converts nothing; it refuses "abc" before the row sees it, which is validation and not conversion, and one sentence on the page. |
| The empty table | A form over zero rows: current is nil, every field blank and insensitive, New the only live button, and next on nothing must not fail. Predicted the first defect found by eyes, in some corner of the empty state, since the sketch above was written with a row in mind. |
| Widget lifetime | A form holds twenty foreign handles in slots; the handles are GTK’s, as the counter’s are, and the window’s close releases what the retain rule in gtk.c retained. Predicted nothing new, because the mechanism is the one the editor has used since August. |
| The split policy | A contacts file written by a form will run 1.4× sqlite3’s in pages, the item held on 2026-09-17 as cosmetic until a program cares. A form is the program that might, and it is predicted not to care at a hundred contacts. |
What it would not find
Transactions. save writes the row’s page and close writes the
file; nothing rolls back, which is the library’s stated absence and the
form inherits it.
Two programs on one file. The library has no lock and a form is one
program; a second sqlite3 writing while the form is open is undefined
today and stays so.
A general layout. Labels beside entries in a grid, top to bottom, is
what field makes; a program that wants something else makes the widgets
itself with the controls of step 0 and binds them with field(column,
kind, control), which is why the control is an argument the form may take
rather than only one it makes.
Step 0 the same afternoon, and the one thing GTK would not do
Eighteen messages and three grown ones, d9d0a1b in the binding, one
more than the entry counted because focus was wanted the moment a form
was imagined with a cursor in its first field, and two fewer under their
own names because add takes a list as it takes a box and clear is the
list’s only message of its own. A probe that called every message, read
every answer back and closed its own window after 600 ms was run before
the example was written, and it found the one thing on this step that was
not mechanical: a GTK dropdown cannot show nothing. setSelected(d,
#0) was written to mean none, as it does on a list, and read back #3,
because GtkDropDown re-selects the first item the moment nothing is
selected. So #0 on a dropdown is refused with a sentence, and a form
that wants no value puts an empty string first, which is what every
combo box since VB has done and is now what the page says. text on a
widget is asked in an order that matters, since a check is a button;
onChange is one message over four signals, and a dropdown’s is a
notify::selected with a GParamSpec in the middle, so it has its own
door. examples/widgets.sol puts every control in one window with a
status line and a Save button that wakes when something changes; it was
seen, once, on a screen, and make test there compiles five examples
now, the new one bare. The --expr case in that Makefile inverted to
name the two that need the flag rather than the one that did not.
Step 1 the same afternoon: the recordset, and two names the language decided
lib/recordset.sol, 184 lines, and examples/records.sol with 33
claims under expect, on the library list and the example list in
test_compile.c, with its section in the reference and its line on the
cheatsheet. The shape is the one sketched, a table, a narrowing, the
rowids in order, a position and the row there, re-read from the pages on
every move, with changed comparing the current row to the pages column
by column so that a form can ask before it moves. Two names went the way
the language sent them rather than the way the sketch spelled them:
new could not be the message that makes a new record, because new
is how a recordset itself is made, recordset:on saying self:new
and finding its own slot first, so the message is addNew, which is
DAO’s own word and the one the inspiration used; and the example could
not be examples/recordset.sol, since a file that says @include
"recordset.sol" finds itself beside the includer before the search path,
which is the self-include the compiler warns about and the reason the SQL
shell became sql.sol two days ago, so it is records.sol. A new record
is made under the table’s own prototype with every column nil and no
rowid, so that its columns are slots as any row’s are and insert takes
it as it takes a row, which is why a state of the recordset and not a
row in the plan above was half right: it is a row, and isNew is the
state. One thing found by the probe and worth its sentence on the
page: every move answers the row, so r:at(#2):delete deletes through
the row and not the recordset, with no flush and no refresh; a recordset
message after a move is a second statement. Held against make test,
1,162 claims now, and sqlite3 reading build/rs-probe.db after a probe
of every message: the three rows it should have, integrity_check ok.
The empty-table prediction above was for step 2’s eyes; the recordset
met the empty table on its own first line, count, position,
current and next on nothing, and on answered nil there once,
because it ended in first, which is the defect the prediction was
about, found by the probe rather than by eyes.
Step 2 the same evening: the form, and the prediction the entry was most sure of was wrong
lib/form.sol in the binding, 309 lines, examples/contacts.sol, a
lib/ that repository had not had, -I lib in its Makefile and a check
for Solveig’s lib/recordset.sol by name rather than by version, since
the file is the thing needed and no release carries it yet; daf32a7
there. A field a column in five kinds, the standard row greyed to match
the position, onValidate answering false or a reason, onSave over the
record before it is written, button(text, block) for the program’s own,
a move off a changed record refused with save or revert first, and Find
by example, all as the calls below recommended. Driven end to end without
a person by a probe that set the controls and pressed the buttons by their
methods, thirty answers read back, and sqlite3 reading the file it left;
then seen once, over an empty table, every field grey and New and Find the
live buttons.
3.1 did not fire, and the reason is a fact about the machine the entry
did not know. self is bound into a block when the block is made, by
sol_block_new, and is not a capture: o:m := { { self:x } } answers a
block that outlives m and reads x. What a block may not outlive is the
frame whose locals it reads, so { self:next } hung from inside
navigation is fine and { column | ... }’s column read from a handler
is not. So the global form:active was never needed, the master-child
prediction below is void before it is tested, and the field is an object
for the plainer reason that load, store and value differ by kind and
want the column beside them. What did press, three times, was the
language’s own rules, each met in the probe’s first minute:
- A slot cannot be written by a name held in a value. A control’s
value goes into a column named by the schema at run time, and there is
no
slotAtPut; the SQL shell met this on 2026-09-15 and went throughasDictionary. The answer went into the recordset, where the suite can see it:set(pairs)makes the current record again under the table’s prototype with the pairs applied, the same rowid,isNewkept, which isobject:new(dictionary)doing the job it was built for a second time. Two claims inrecords.soland a row in the reference. - A slot holding a block is a method.
f:onValidate(block)stores the block in a slot, andself:validatethen runs it with no argument, which is ‘block’ takes 1 argument, got 0. The program’s blocks are read withslotAt, assqlite.solreads itsisDigit. ifTrueanswers nil when the condition is false, soc:ifTrue({ a }):ifFalse({ b })is a send to nil. Written twice, in the recordset and the form, bothifElsenow. Not a defect in the language; the spelling the language has is the two-armed one.
Two predictions in the table above held: load fired onChange on
every field, and a loading flag the handlers check is the whole fix; and
the 'integer kind converts nothing and refuses "x" by name before the
record sees it, which the probe checked. The empty-table prediction had
already fired in step 1. And one thing the entry did not predict and the
binding’s page now says: a GTK dropdown cannot show nothing, found in
step 0, which is what the 'choice kind will meet first.
The calls only you can make
- The names.
recordsetis VB’s own word, from DAO, and says what the thing is better thancursor, which here is a terminal’s and a scanner’s already;formfor the binding. Recommended those. - Moving off a changed record. VB’s
Datacontrol saved silently on every move; .NET does not and leaves the change pending. Recommended neither: the form refuses the move and says save or revert first, withreverta button beside Save, since a silent write is the kind of thing the house rule is against and a silently dropped edit is worse. - What Find is. A dialog is a second window and a second form; query
by example is none: Find clears the fields, the user types into any of
them, Find again narrows to
whereon the ones filled, and Find with nothing filled isall. Recommended query by example, because it needs no widget the form has not got and is what the inspiration’s users already know. - Whether the standard button row is the library’s. Recommended yes,
as
navigation, opt-in, withbutton(text, block)beside it for a program’s own; a form that draws its own row from step 0’s controls loses nothing. - How a handler reaches its form. Recommended the global
form:activefor step 2 and deciding again at master-child, where it is tested; the alternative, anonClickthat hands its block the widget, is a change to the binding’s contract and should be asked for by the form that needs it rather than by this entry. - Which is first, the controls or the recordset. Recommended the controls, because they carry no design risk, and the recordset second so that its shape is asked for by a form being written against real widgets rather than by this page.
A dropdown over a code table, and a form that says where and how big: scoped 2026-09-18
Asked on 2026-09-18, the morning after the first form ran. Forms over a
database named
the 'choice kind as the next scoping and deliberately did not plan it. Hans
asked for a second thing the same morning: a program has to be able to say how
big a control is and where it sits, which a form cannot say at all today. They
are one entry because they are one signature. Both are what field answers and
what show does with what it answered.
What a form can say today, read out of the two files rather than
remembered. form:field(column, kind) makes a label, attaches it at grid
column 1 of the next row, attaches the control at column 2, and answers the
field. That is the whole of the layout. A size is set in exactly two places in
gtk.c, scrolled and canvas, both at construction; every other control
asks GTK for nothing, and entry, dropdown, textView, scrolled and
list each set hexpand true in C, so a control fills the width of its cell
and a program cannot say otherwise. The only size a form owns is the memo’s,
gtk:scrolled(self:control, #200, #80), a constant inside field:widget; the
only spacing is gtk:grid(#8, #8) inside form:on. The forms entry named
field(column, kind, control) as the escape hatch for a program that wants a
layout of its own, and that third argument was never built: form:field takes
two, and a method’s arity here is exact.
What it should look like. A sketch, not run, and the table is the contacts
one the first form already has, with a kinds code table beside it:
kinds := codes:on(db:table("kinds"), 'code, 'name).
f := form:on(people, gtk:window("Contacts", #520, #480)).
f:field('name, 'text):size(#240, #0).
f:choice('kind, kinds).
f:field('notes, 'memo):size(#320, #120):at(#1, #6, #2).
f:navigation.
f:show.
Three layers, the same three the forms entry drew, and the line between them is the same line:
- Two messages in the binding, and they travel together.
setSize(widget, #width, #height), overgtk_widget_set_size_request, with#0meaning ask for nothing, which is GTK’s own-1spelled the way everything here counts. AndsetExpand(widget, across, down), overgtk_widget_set_hexpandandset_vexpand, because a size request under a control that expands is invisible: the request is a minimum and the expansion takes whatever the cell has left. A narrower entry asked for withsetSizealone would look exactly as it does now, which is why the second message is recommended into the same step rather than held behind a trigger of its own. - The code table is read here, in Solveig’s
lib/, and knows no widget.codes:on(table, keyColumn, textColumn)reads a table once into an ordered array of key and text, and answers an object that can saytexts,keyAt(index),textFor(key)andindexOf(key).codes:of(pairs)makes the same thing from an array written in the program, for a form that wants three fixed choices and no second table. This is the half the suite can run, and it is the reason the recordset lives here: a window cannot be driven down a pipe, so anything that can be tested without eyes is put where eyes are not needed. - The kind and the placement are in the binding’s
form.sol.'choicejoinsfield:kinds;form:choice(column, codes)is the maker, a message of its own rather than a third argument tofield, since arity is exact and every kind that needs a thing beside its column can have a maker of its own.field:size(#width, #height)andfield:at(#column, #row, #columns)are slot writes that answer the field, so they chain. And the attach moves out offieldand intoshow, which is what makes them slot writes rather than a detach and a re-attach: there is no message in the binding that takes a child out of a grid, and there does not need to be one if the grid is filled once, at the end, from what the fields were told.
What a choice stores, and the one thing that can lose a column
A choice shows a text and stores a key, which is what a code table is for. The
mapping is an index: selected answers the dropdown’s position counted from
#1, the codes object turns that into a key, and load turns a key back into
a position. Two facts about GTK decide the edges of that, and both were found
in step 0 of the forms rather than argued here. A dropdown cannot show
nothing: setSelected(d, #0) is refused by name, because GtkDropDown
re-selects the first item the moment nothing is selected, so no value is a
blank text put first and the key behind it is nil. And text refuses a
dropdown, having no branch for one, so a choice is read through selected
and never through the string.
Which leaves the case the entry is most sure of, and it is a silent one. A
record whose column holds a code the table no longer has. form:store writes
every field into the record on every save, not only the ones that changed, and
field:load has nowhere to put a code that is not among the choices: it cannot
show nothing and it cannot show what it was not given. So the record would load
as the blank and save as nil, and a column nobody touched would be quietly
emptied. That is read off form:store and field:load as they stand rather
than reasoned from a sentence, which is the correction the last entry earned.
What it would press on, predicted before writing
| Nothing in the language, and this time the machine was asked first | No roadmap entry is predicted to fire. The one language fact that shapes every recommendation above was checked in three lines before the entry was written rather than read off a page: a method’s arity is exact, 'block' takes 2 arguments, got 1 and 'block' takes 1 argument, got 2, so field cannot grow an optional third argument, and what looks like an optional argument anywhere here is a second message or a wider one. |
| A key the code table no longer has | The prediction this entry is most sure of, and the reason it is sure is above: store writes every field and load cannot show an unknown code. Predicted the first defect of step 2, and predicted to be found by a probe rather than by eyes, since it is a wrong value and not a wrong picture. The way through recommended below is that the field appends the unknown key as a choice of its own. |
hexpand over a size request |
Predicted the surprise of the size half: a width asked for with setSize alone is predicted to change nothing visible, because every control the form makes expands. Settled by eyes and by nothing else, since no allocated width is published to a program, which is what the binding’s checks have been since August. |
load fires onChange on a dropdown too |
setSelected fires notify::selected, which is the door onChange already goes through, so a record just loaded would mark the form dirty. The loading flag covers it, as it covered setText at step 2. Predicted, nothing to build. |
field:widget called twice |
Found while scoping, and true today: field:widget makes a fresh gtk:scrolled on every call and happens to be called once. Moving the attach into show calls it again, so a memo would get a second scrolled window while its text view stayed in the first. The widget becomes a slot made once in make, which is one line and is named here so it is not met as a mystery. |
| Find by example over a choice | field:isFilled asks gtk:text, which refuses a dropdown. A choice is filled when its selection is past the blank. Predicted one line, and predicted to be the whole of it. |
| The empty code table | A choice over a table with no rows is a dropdown holding one item, the blank. setSelected(#1) answers it, value answers nil, and nothing is a special case. Predicted nothing, and in the probe anyway, because the empty table is where step 1 found its one defect. |
| Tab order | With the attach moved to show, GTK’s order is the order the fields were declared and not the order they were placed. That is usually what is wanted and is why the default stays the declaration order; a form that places fields out of order gets a tab order that does not match what is seen. Predicted, and predicted not to be worth a message until somebody minds. |
What it would not find
A code table that changes while the form is open. A dropdown’s strings are
copied into the widget by gtk_drop_down_new_from_strings at construction, and
there is no message that replaces them. A setChoices is small, and it is held
below rather than built, because nothing asks for it yet.
A collision. Two fields placed in the same cell is GTK’s business, and nothing here would check it. A designer would have to.
A layout that is not a grid. Rows of label and control, placed where the
program says, is still a grid; a form that wants something else still builds
its own widgets from the controls of step 0, and still has no way to hand one
to field, which is the escape hatch the forms entry named and nobody built.
Transactions and locks, as before and for the same reasons.
The steps, and what each is held against
| step | builds | held against |
|---|---|---|
| 0. The two size messages | setSize and setExpand in gtk.c, the README’s reference grown, examples/widgets.sol grown to show one control that does not fill its cell |
make test there, and eyes, since a size is a picture and nothing else can see one |
| 1. The code table | codes in Solveig’s lib/, its reference section, its cheatsheet line, claims in examples/records.sol |
make test here, the claims under expect, and sqlite3 reading the file a probe wrote |
| 2. The kind and the placement | 'choice and form:choice, field:size and field:at, the attach moved into show, field:widget held in a slot, all in the binding’s lib/form.sol; examples/contacts.sol given a real kinds table and one placed field |
a probe that presses every button with a code deleted under it, sqlite3 reading the file after, and eyes once |
| 3. The records | this entry’s outcome under its prediction, the changelog, the journal | make test |
The calls only you can make
All seven were answered on 2026-09-18, the same morning, and every one went the way it was recommended. The recommendations stay as they were written so that the page says what was argued as well as what was decided; what follows is the argument, and the answer to each is the first thing under it.
- Where the code table’s read lives. Recommended here, in Solveig’s
lib/, as a small object beside the recordset, because the suite can run it and a window cannot be driven down a pipe. Inform.solit would be the first piece of form logic no test can reach. - What a choice stores. Recommended the key, with the text only shown.
Storing the text needs no code table at all, and is worth having as the same
kind over
codes:of(["friend", "work"]), where the key and the text are one string. - A key the code table no longer has. Recommended appending it as a choice of its own, shown as the key itself, so the record says what it holds. The alternatives are blanking it, which is the silent overwrite above, and refusing to load, which makes a bad row unreachable from the one form that could fix it.
- How a field says where and how big. Recommended a chain on the field
that
fieldalready answers,f:field('name, 'text):size(#240, #0):at(#1, #4, #2), because it changes no call that exists and reads the way the language reads. Weighed against: an options bag as a third argument, which the exact arity turns into a change to every call ever written; and a cursor on the form,f:at(#3, #1)said before the next field, which is closer to how the inspiration’s designer wrote its files and hides a field’s placement from the line that makes it. - Whether
setExpandgoes in besidesetSize. Recommended yes, for the reason in layer 1: alone, the first of them is predicted to do nothing a person can see. - Whether
setChoicesis built now. Recommended no, held behind a trigger: a form that edits the code table it reads. - Whether the blank first choice is the form’s or the code table’s.
Recommended the form’s. A dropdown cannot show nothing is a fact about
GTK, and
codesis the half that is not allowed to know one.
All three steps the same day, and the two calls that collided
Step 0, 2d22114 in the binding. setSize and setExpand, together, and
the prediction that they had to be was checked rather than believed: three
entries in one grid column, one told nothing, one told a width, one told a
width and setExpand(e, false, false). The first two were
indistinguishable and only the third was narrow, which is the picture the
prediction asked for and the reason the two messages are one commit.
setExpand also sets that direction’s alignment, which the entry had not
foreseen: GTK’s default is to fill, and a fill ignores the size request it was
given, so an expansion turned off without an alignment would have left the
first two entries indistinguishable as well. #0 asks for nothing on a
measure, GTK’s -1 counted the way everything here counts. A probe called
both on an entry, a scrolled window, a dropdown, a list and a window, and read
back five refusals. widgets.sol grew an Age entry, the one control in it
that does not fill its cell.
Step 1, 7ffb27c here. lib/codes.sol, and the probe decided two things
the page had not. on takes a table or a query, told apart by isKindOf,
which is the one line sqlite.sol is included for: a query is how an order of
its own is asked for, table:orderBy('name), and the sketch above had only a
table. And a column that is not there is refused there, naming the table,
because slotAt would otherwise refuse it one row at a time saying only no
slot named ‘code’. Fifteen claims in examples/records.sol, and sqlite3
read the file after. One thing shipped wrong and was taken out an hour
later in f645453: codes:includes, which the reference argues against
three sections above where it argues that array has none, since indexOf
answers where and notNil is how to ask whether. A name given up in one
place had survived in another, which is the defect method.md has
a rule about.
Step 2, 1f8f662 in the binding. The kind and the placement. A field
answers itself, so size and at chain, and the attach moved into show,
which is what makes them slot writes. form:choice is a message of its own
and field(column, 'choice) is refused by name saying which message to use.
Driven by a probe over three records, one holding a code nobody kept, with
twenty answers read back and sqlite3 reading the file after; then seen once.
The two calls collided, and the collision is the finding. Call 3 said a
key the code table no longer has is appended as a choice of its own. Call 6
said setChoices waits for a trigger, on the reading that nothing asked. A
dropdown’s strings are copied when it is made, so the first decision cannot be
carried out without the second: appending a choice at load time is changing
a dropdown’s list. The two were answered in the same minute and one of them
was already false. setChoices went in with form.sol as its caller, named in
the comment over it, which is the trigger rule being met rather than waived:
what asked was a program, and the program is the one this entry was written
for. The trigger named in call 6, a form that edits the code table it reads,
was the wrong trigger, and it was wrong in a way the entry could have caught,
since the mechanism was written down two sections above it under what it would
not find.
Three predictions held exactly: hexpand over a size request, above;
field:widget making a fresh scrolled window on every call, which mattered the
moment the attach moved and became a slot made once in make; and find by
example over a dropdown, which is one line asking whether the selection is past
the blank, since text refuses a dropdown. load firing onChange needed
nothing, as predicted, because the loading flag was already there.
And the language pressed twice. There is no non-local return,
3.2, so the three methods that wanted an
early exit per kind are nested ifElse. That is the local case that entry
records, the one edit.sol and awk.sol found, and not the loop half
json.sol and html.sol cite: a field asked what kind it is wants to answer
and leave its own body. And an array has no concat, so the blank that goes
in front of a dropdown’s texts is put there by a do into a new array. Neither
is a defect and neither was predicted, which is the usual ratio: the entry’s
eight predictions were about the widgets and the data, and what actually got in
the way was the spelling.
A child table on the form, and what a grid would be: scoped 2026-10-12
Put on the list on 2026-09-18. It is the master-child form the forms entry named, with the shape Hans asked it to have: the child rows shown together in a grid, and the example is contacts and their orders. This is a note rather than a scoping, and it names what is already true, what is missing and what fires it.
Half of it is built. A child recordset is a recordset narrowed by the
parent’s rowid, orders:find(#['contact = people:current:rowid]), which is
recordset:find doing exactly what it does. What is missing on that side is
one hook: a block the parent’s form runs after each load, so that the child
follows the parent without the program hanging a handler on every button.
The widget is the real question, and gtk:grid is not it. The binding’s
grid is GTK’s layout container, a mesh of cells filled by attach; it has no
row of data, no header, no selection and no sort, and it is what the form
already puts its labels and controls in. What VB3 called a grid is GTK4’s
GtkColumnView: a column each with a factory that makes a cell widget, a
selection model under it, headers that sort. That is a widget family the
binding has never touched, and it would be the first thing in it that needs a
callback per cell rather than per event. What the binding does have today is
gtk:list, a GtkListBox of one selected row at a time whose rows are
widgets, so a line of orders is a label per row with the columns run together
by fill, ordered only by the query underneath and sorted by no header.
So the choice is between a list of formatted labels, which exists today and is enough for a first master-child form, and a column view, which is a step the size of step 0 of the forms. A scoping should say which, and it should be written after the single-record form has been used, which is still outstanding from 2026-09-17.
One prediction from the forms entry is void here rather than pending. It
predicted master-child as the third real customer for
3.1, on the
reading that two live forms is where a handler reaching its form through a
global runs out. self is bound into a block and is not a capture, so each
form’s handlers reach their own form and no global was ever needed. What
master-child does press on is untested and is a cost rather than a
limitation: a child recordset refreshed on every parent move re-reads its
rowids through the pages, and a where on a column with no index is a scan of
the table. A hundred contacts with a few orders each will not notice; the entry
that scopes this should say at what size it would, and measure rather than
argue it.
Trigger: the contacts form growing orders. What stands before it is the
single-record form being used with typing, and the dropdown entry above going
first, since both want field to have settled what it answers and how a field
is placed.
Scoped on 2026-10-12, before its trigger
Asked for by Hans on 2026-10-12, and the trigger above has not fired. Its
second half has: the dropdown entry went first and field has settled what it
answers and how it is placed. Its first half has not. The single-record form
has still not been used with typing, and nobody keeps orders in the contacts
file. This scoping is written on request and not because a program asked,
which method.md says the entry has
to say. Whether the build waits for the trigger is call 7.
Two things the note argued were asked of the machine first. Both came out
of a throwaway against Solveig’s lib/ before a line of this was written.
A deleted parent’s rowid is handed to the next parent, and its children go
with it. Two contacts, an order under the second, the second deleted, a
third contact inserted: the third was given rowid #2, and
where(#['contact = #2]) answered the deleted contact’s order. That is
sqlite:deleteRow doing what its comment says, putting maxRowid back to nil
so that the next rowid is one past the largest that remains, as SQLite assigns
it. It happens only when the deleted row held the largest rowid, which in a
contacts file is the one most recently added and the one most likely to be
deleted by mistake. So an orphaned order is not untidy, it is wrong: it
belongs to somebody else the moment a contact is added. SQLite’s own answer is
AUTOINCREMENT and its sqlite_sequence table, which this reader does not
have. That decides call 4.
The cost the note asked to have measured rather than argued. One hundred
contacts, orders spread evenly over them, and a recordset over orders
narrowed ten times to one contact’s rows with recordset:find, which is what a
child following its parent would do on every move. Timed with timeToRun on
the -g build make leaves, which is slower than a release build, so these
are an upper bound:
| orders in the table | each contact’s | no index | orders:index("orders_contact", ['contact]) |
|---|---|---|---|
| 500 | 5 | 10.3 ms | 2.3 ms |
| 3000 | 30 | 62.9 ms | 13.4 ms |
| 10000 | 100 | 217 ms | 47.6 ms |
Without an index the cost is the table, about 21 µs a row; with one it is the
children, about 0.47 ms each, whatever the table holds. A move starts to be
felt at about a tenth of a second, which is near 5,000 orders unindexed, and
near 200 orders under one contact indexed. The note’s a hundred contacts with
a few orders each will not notice holds, with a factor of a hundred to spare.
The index is already built and where already uses it on =, so the remedy is
one line in the program, which is call 5.
What it should look like. A sketch, not run, over the contacts file the first form already has:
db:table("orders"):isNil:ifTrue({ | t |
t := db:create("orders", ["contact INTEGER", "item TEXT", "amount REAL"]).
t:index("orders_contact", ['contact]) }).
people := recordset:on(db:table("contacts")).
orders := recordset:on(db:table("orders")):follow(people, 'contact).
f := form:on(people, gtk:window("Contacts", #520, #640)).
f:field('name, 'text).
...
f:rows(orders, [['item, #240], ['amount, #80]]):size(#360, #160):at(#1, #9, #2).
f:navigation.
f:show.
Three layers again, and the line between them is where it was:
- Following is the recordset’s, in Solveig’s
lib/, and knows no widget.recordset:follow(parent, column)narrows a recordset to the rows whosecolumnholds the parent’s current rowid, and the parent keeps a list of the recordsets that follow it. Every move the parent makes goes throughgoTo, whichrefreshcalls too, so one line there and one inaddNewkeeps every follower on the parent’s record after a move, a save, a delete and a find alike. A parent with no record, or a new one with no rowid yet, is followed by nothing, whichwherealready answers sincesqlite:holdsis false for nil on either side. A follower’saddNewfills the column with the parent’s rowid, and is refused by name while the parent is new. Itsallgoes back to following rather than to the whole table, and itsfindadds the column to the pairs. A child that is followed in turn is followed for nothing extra, since its own moves go through its owngoTo. This is the half the suite can run, which is why it is here and not a hook on the form as the note had it: a block the form runs afterloadwould be the first piece of master-child that no claim can reach, and a program moving the recordset without a form would have no children. - The rows are shown in the binding’s existing
gtk:list, a row of labels each, and no new C. A list row is any widget, so a row can be a horizontalgtk:boxof one label a column, each label given its column’s width bysetSizeandsetExpand(label, false, false). The columns line up because every row’s labels are the same widths, not because the list knows about columns. A header is one more such box above the list, and the two go in agtk:scrolledso a long list scrolls rather than growing the window. This is a list of formatted rows, and it is what the note called enough for a first master-child form.GtkColumnViewstays where the note left it, behind a trigger of its own, since a header that sorts, a cell that edits and a list that recycles its rows are the three things it would buy and this scoping asks for none of them. - The rows field is the binding’s
form.sol.form:rows(recordset, columns)makes an object beside the fields, placed and sized by the sameatandsizechain and attached inshowlike them.form:loadalready runs after every move, so it refills each rows object there. The program hangs nothing: the follow keeps the recordset right, and the form’s own load repaints it.
What it would press on, predicted before writing
| Nothing in the language | No roadmap entry is predicted to fire. The follower is an object holding its parent and its column as slots, so no block outlives the method that made it and 3.1 is not met, the same reasoning that made a field an object. 3.2’s local case is predicted once more, in the follower deciding between no parent, a new parent and a saved one, which is three branches in nested ifElse. |
| The list filled by moving the recordset reads every row twice | refresh already reads every child row to collect the rowids, and filling the list by goTo on each position reads each again through findRow. At 0.47 ms a child that doubles the cost of a move for nothing. Predicted the first thing step 1 measures, and the way through is that the list is filled from one read of the query’s rows rather than by walking the position. |
| Deleting a parent that has children is refused, and the form does not catch it | The refusal is raised in recordset:delete, and form:delete has no onError, unlike form:store. What an error does inside a GTK handler is not written down in the binding’s README. Predicted one line in form:delete that says the refusal in the status, and predicted found by the probe. |
| A label is centred in the width it was given | A GTK label’s text sits at the middle of its allocation by default, and nothing in the binding sets it otherwise. A column of amounts given 80 pixels would be centred, which is neither how numbers nor words are set in a table. Settled by eyes and nothing else. If it looks wrong, the fix is a message the binding does not have, an alignment, and that is a customer for it rather than a reason to build it now. |
| A list makes every row’s widgets | GtkListBox builds all its rows, where a column view builds only the ones on screen. Tens of rows are nothing; predicted to be felt somewhere in the hundreds, which is past what one contact’s orders will be, and is the column view’s trigger rather than a defect here. Not measured, since it is a picture’s cost and the binding publishes no timing of one. |
| Find by example on the parent | The children follow to the first record found, through goTo, with nothing to build. Find on the child list is not offered, since the rows field is not a field and has no control to type into. |
A selected row emptied by clear |
gtk_list_box_remove_all on a list with a selection fires row-selected with nothing, which onSelect would hear as nil. Nothing is hung on onSelect, since a read-only list has no use for a selection yet, so predicted nothing. |
What it would not find
A child table that nothing follows. The refusal to delete a parent is the
recordset’s, and it knows only the recordsets that follow it. A table that
names contacts and is not followed in that run of the program is not
protected, and neither is a program that deletes through table:delete rather
than the recordset. This reader has no foreign keys, so nothing below the
recordset could check it.
The rowid reuse itself. Refusing the delete keeps the form from orphaning
an order; it does not stop the reader handing a deleted parent’s rowid on. A
key that is never reused is AUTOINCREMENT, a change to sqlite.sol and its
oracle and a scoping of its own.
Editing, adding or deleting an order from the contact’s form. The list is
read-only. An order is still added by a second form over orders, in a window
of its own, whose addNew fills the contact because the recordset follows.
Sorting by a header, a cell that edits, and many-to-many, which is the column view and then something beyond it.
The steps, and what each is held against
| step | builds | held against |
|---|---|---|
| 0. A throwaway list of rows | fifty lines in the binding, not kept: a header and a dozen rows of fixed-width labels in a scrolled list | eyes, since the alignment prediction and the whole of call 2 are a picture |
| 1. The follow | recordset:follow and what it changes in goTo, addNew, all, find and delete, in Solveig’s lib/; claims for orders under contacts in examples/records.sol, a move, a new parent, a new child, a refused delete; the fill cost of the prediction above measured both ways; reference and cheatsheet |
make test here, the claims under expect, and sqlite3 reading the file the example wrote |
| 2. The rows field | form:rows in the binding’s lib/form.sol, refilled by load, placed by at and size; form:delete catching the refusal; examples/contacts.sol given an orders table with its index and a second small form to add an order |
a probe that moves through contacts with and without orders and deletes one of each, sqlite3 reading the file after, and eyes once |
| 3. The records | this entry’s outcome under its prediction, the changelog, the journal | make test |
The calls only you can make
All seven were answered on 2026-10-12, the same session, and six went the way
they were recommended. The recommendations stay as they were written, as the
dropdown entry’s did, so the page keeps what was argued beside what was
decided. Calls 1 to 5 and 7 went as recommended: following in the recordset,
the list of label rows, read-only, a refused delete, the index the program’s,
and step 1 built now with steps 0 and 2 waiting for the form to be used.
Call 6 carried no recommendation of its own, only a request for a ruling,
and the ruling is that solveig-gtk is this project’s tree: Solveig’s
session builds and commits there, as it did on 2026-09-18, and no handoff note
goes across. That is an exception to the workspace rule of 2026-10-01, made by
Hans in the session that would cross, which is the only way that rule allows
one.
- Where following lives. Recommended in the recordset, as
recordset:follow(parent, column), because the suite can run it and a program without a form still gets children that follow. The note’s alternative, a block the form runs after eachload, would leave the half that holds the logic where no claim can reach it. - The widget. Recommended the existing
gtk:listwith a row of fixed-width labels a record, and no new C. Weighed againstGtkColumnView, which buys sorting, editing in place and recycled rows, costs a widget family with a callback per cell, and has no customer yet. - Whether the list is read-only. Recommended yes for the first build. An
order is added from a second form whose recordset follows, so
addNewstill fills the contact without the program saying so. - A parent deleted while it has children. Recommended refusing, by name, saying how many rows follow it. Weighed against: cascading, which deletes a person’s orders on one press of a button that asks nothing first; and orphaning, which the rowid reuse above turns from untidy into wrong.
- Who makes the index. Recommended the program, in the line that makes the
table, as the sketch does, with the reference saying why. Weighed against
followmaking one when there is none, which is a recordset writing schema into a file it was asked only to read, and against nothing at all, which is fine until about 5,000 orders. - Which session builds steps 0 and 2. They are in
solveig-gtk. That repository keeps no records of its own and itsCLAUDE.mdsends everything here, and the three binding commits of 2026-09-18 were made from Solveig’s session. Since 2026-10-01 the workspace rule is that a project changes only its own tree and that only you can make an exception. Recommended that you say once whethersolveig-gtkcounts as this project’s tree; otherwise steps 0 and 2 go across as a handoff note and step 1 is built here alone. - Whether the build waits for the trigger. Recommended that step 1 goes ahead and steps 0 and 2 wait for the contacts form to have been used with typing. Step 1 is testable without eyes and has the example as its customer. The form being used is the evidence the note asked for before a widget was chosen, and it has been outstanding since 2026-09-17.
The calls read against each other before they were put, which is the
lesson of the dropdown entry. Calls 3 and 4 collide. With a read-only list
and a refused delete, a contact who has orders cannot be deleted from the
contacts form at all, since nothing on it can take the orders away first. The
second form over orders can, one order at a time. That is accepted here
rather than resolved: it is the trigger for the list growing a Delete, and it
is written down so that it is met as a known limit and not as a defect. Call
4 depends on call 1: a refusal can only be raised by something that knows the
children, so if following goes to the form, the refusal goes with it and a
program deleting through the recordset gets none. Call 7 depends on call 6
only in where the waiting steps are built, not in whether they wait. Calls 2
and 5 touch nothing else.
Step 1 the same day, and a way through that was a second read
Step 1, 9cd2158 here. recordset:follow, sixteen claims in
examples/records.sol written before the run and all sixteen as written,
and sqlite3 reading the file after: integrity ok, both of Eve’s orders
under rowid 2, and orders_contact in the schema. The binding’s six examples
still compile. Three things were settled by the code rather than the page.
A follower is narrowed with filter(link, "=", key) and not with a
where, so a find inside it is a where over that filter and the two
compose, and matchingRows still finds the index, since what it looks for is
an = term and not the message that made it. The refusal counts every row
in the column, not the follower’s narrowed count, because a follower that
has been told to find would otherwise let a parent with orders be deleted.
And follow refuses a column the table does not have, naming the table,
for the reason codes:on does: otherwise it is an empty result that looks
like a parent with no children.
The prediction about filling the list was right about the cost and wrong
about the cure. It said filling the list by walking the position reads every
child twice, and that the way through is one read of the query’s rows. Timed
on a contact with 100 orders out of 1,000, indexed, on the -g build:
| the parent’s move, which narrows the follower | 26.7 ms |
filling by goTo on each position |
25.2 ms |
filling by one read, orders:query:all |
27.2 ms |
The one read is not cheaper, because it is a read: query:all runs the
query again, through the index and findRow for every match, which is what
goTo does one row at a time. A move followed by a fill costs twice the
narrowing whichever way the list is filled. The only fill that costs nothing
is from the rows refresh has already read and kept only the rowids of, and
that is a change to what a recordset holds. It is not built: 25 ms at 100
orders under one contact is not felt, and its customer is step 2’s list, which
will say whether it minds. 3.2’s local
case did not come: the three branches the entry predicted nested ifElse
for came to one or in parentKey, since a parent with no record and a new
parent both answer nil, and filter holds for nothing on a nil either way.
Steps 0 and 2 wait, as call 7 decided, for the contacts form to have been used with typing.
Recommended against
Computed-goto dispatch — measured, and refused
The textbook optimisation for a bytecode interpreter, and here it is slower.
Replace the switch with a table of label addresses and end every opcode with
its own goto *table[next], so that each one gets its own indirect branch and
the predictor learns which opcode tends to follow which. It is what every
account of interpreter performance recommends, and
the JIT entry named it as this project’s cheap
alternative at “10–20%”.
Built, run, and refused on the numbers.
fib 0.89 · float 0.91 · array 0.94 · higher 0.94 · strloop 0.95 |
|
loop 0.97 · object 0.98 · strlib 0.98 · dict 0.99 |
|
basic.sol interpreting BASIC |
0.92 |
Every one below 1, which here means the switch is faster — by 1% to 13%, and
by 8% on the real program. The transformation is behaviour-preserving and was
checked as such: all nine benchmarks print the same answers and the whole suite
passes against it.
The reason is that the compiler undid it, and the disassembly says so plainly:
indirect branches instructions bytes
switch 2 954 3816
computed goto 1 1086 4344
One. Twenty-one hand-written goto *table[...] sites were tail-merged back
into a single dispatch point — which is the shape a switch already compiles
to, except now with 132 more instructions and 528 more bytes wrapped around it.
Clang merges identical tails; identical tails are exactly what this technique
consists of. So the benefit is optimised away and the cost is not, on a loop
4.5
had already shown to be instruction-cache sensitive to within 8.5%.
Two things were checked before concluding, because a negative result from a bad
prototype is worth nothing. Padding the jump table to 256 entries to remove the
per-dispatch bounds test changed nothing (fib 0.87, basic.sol 0.92). And the
switch version already carries two indirect branches rather than one, so clang
had done a little of this duplication by itself, unasked.
It also would have cost the C11 promise. Labels-as-values is a GNU
extension, so this needs &&label behind a __GNUC__ guard, a second copy of
the dispatch structure for compilers without it, and -Wpedantic silenced where
the table is declared. Paying that for a slowdown is an easy decision; it is
worth writing down that even a speedup would have had to be worth those three
things.
If this is ever revisited, the technique to revisit is a different one. The
modern answer on clang is the tail-call interpreter — each opcode a function
ending in a musttail call to the next — which gets the per-opcode indirect
branch in a form the optimiser cannot merge away, and which is what CPython 3.14
itself adopted. That is 21 functions rather than 21 labels and a much larger
change, and no program here is waiting on it. Recorded so the next person starts
from the measurement rather than from the folklore.
Trigger: somebody willing to write the tail-call form, with a profile showing the dispatch loop still on top. The 55% is real; this particular way of attacking it is not.
Integer sizes — byte, word, long
Numbers ride unboxed in SolValue: an integer is a tag and 8 bytes of payload,
with overflow that traps rather than wraps. Adding widths breaks that in a way
the language would feel everywhere.
- It reintroduces coercion.
#1b:add(#1L)has to either promote — which is exactly the implicit coercion the language refuses between integer and float, with an error message that says so — or be an error, which means every arithmetic expression has to be width-annotated. - Each width multiplies the pairs. Two numeric types today means one coercion question, already answered “never”. Five means ten.
- The usual reason does not apply. Widths buy packed memory, and there is no
packed array here: a
SolArrayholdsSolValues, so a byte array would cost 16 bytes per byte either way. The saving would be zero until there is a real byte-buffer type, which is a different feature. - The actual need is a range check. “This must fit in a byte” is
n:between(#0, #255), not a type.
If binary file handling arrives it will want a byte buffer — but that is one new collection, not five new number types.
Separate float and double
The same argument with less upside. float is C double — binary64 — which is
what a language this size should have exactly one of. A 32-bit float would add a
third numeric type, a third set of coercion questions, and a rounding surprise
between them, in exchange for memory that nothing here is short of.
A JIT to native code
Possible? Yes — any bytecode VM can be JITted, and this one has a clean dispatch loop to start from.
Worth it here? No, and not close.
- It would be larger than everything else in the project combined. Code
generation per architecture, register allocation,
W^Xhandling, unwinding, and a second correctness surface that the.sobverifier does not cover. - There is nothing to specialise on. A JIT wins by turning dynamic dispatch into direct calls, and that needs type feedback and inline caches first — which is a bigger machine again. Without them a JIT emits the same lookups the interpreter does, in more code. The inline cache was later profiled and is worth a tenth of the one benchmark that wanted it, so the first step of that bigger machine is smaller than it looks and buys less.
- It fights the stated goal. The VM is written for clarity first, and 4.1 and 4.3 got the language 40% faster with changes that fit in a paragraph.
The cheap alternative, if speed is ever wanted: computed-goto dispatch
(&&label threading) in place of the switch — a contained change to one
function, and the folklore puts it at 10–20% on interpreter-bound work.
That was written before anybody tried it, and it is wrong here. Threading
this loop is slower than the switch on all nine benchmarks and on a real
program, because clang tail-merges the twenty-one dispatch sites back into one
and leaves the extra code size behind — see
computed-goto dispatch. The
sentence above is kept as it was written, because being wrong in public is what
this page is for.
ifTrue{...} — a block argument without parentheses
a:equals(b):ifTrue({ dosomething }). ; today
a:equals(b):ifTrue{ dosomething }. ; proposed
Decided against — but not for the reason this entry first gave, and the first reason was wrong enough to be worth correcting rather than quietly replacing.
What the rule actually is
This entry originally called it a special case: it works for ifTrue and
whileTrue and not for ifElse. That misread the proposal. The rule is not an
exception carved out for two messages, it is:
A lone block argument may drop its parentheses.
ifElse is not an exception to that. It is outside it, having two arguments.
And the rule reaches most of the language rather than a corner of it — of the
eleven messages that take a block, nine take exactly one:
and collect do ifFalse ifTrue or select sorted whileTrue
ifElse does not, and neither does inject, which came later and takes a
starting value before its block. So the rule is close to uniform, and the
special-case objection does not apply to it.
That inject moved the count is worth noticing: the rule’s reach is a fact
about which messages happen to exist, not a property of the design, and a
language that keeps growing will keep moving it. It was never the deciding
argument here — the objection that carried the day was about what the shorthand
would teach a reader — but an argument that rests on a headcount is worth less
than one that does not.
Nor is the cost
The grammar has room. A block cannot follow a send today, so nothing becomes ambiguous:
> #1:print { #2 }:value:print.
[line 1:10] solas: expected '.' between statements at '{'
#1:print { #2 }:value:print.
^
It is one branch in the argument parser and one in the inlining probe. Perhaps fifteen lines. Cost is not why this is not being built.
Neither is “a second spelling for one thing”, which this entry also gave. That
objection does not distinguish the idea from [...], which is a second spelling
for array:of(...) and was accepted — because it is byte-identical sugar, and
a trailing block would be too. The principle is satisfied either way.
Why not, then
It makes a message send look like syntax, exactly where the language works hardest to prove it is not one.
a:equals(b):ifTrue{ dosomething }
reads as if (...) { ... }. Every document in the project makes a point of
saying there is no if here — the tutorial’s aside is titled “An aside: there
is no if in this language” — and the parenthesised form is the proof, sitting
at every use site. ifTrue(...) is visibly a message with an argument;
ifTrue{...} is visibly a keyword with a body.
What makes that awkward is that the objection is use-site specific. On
stock:do{ e | ... } or stock:collect{ e | e:name } it barely applies —
nothing there is pretending to be syntax, and the parentheses are noise around a
closing }). So the rule is least costly where it is least needed and most
costly on the conditionals, where it reads best. One rule cannot tell those apart
without becoming the special case it set out not to be.
And it teaches a rule that does not generalise, which is the decisive one.
A reader meeting ifTrue{ ... } in an example has no way to see where the rule
stops. The natural next guess is ifElse{ ... }{ ... }, which is not valid and
never will be, and the guess after that is that braces attach to selectors
generally. The shorthand’s cost is not paid by someone who learns the rule
properly from the reference — it is paid by someone who infers it from a
snippet, and infers something wider than what is there.
The parenthesised form has no edge to fall off. A block is an argument, and it is written where arguments are written, in every case, with no rule to remember about when it may be written otherwise.
More @ directives: @define, @ifdef, @once
@ was reserved for the things that happen while compiling rather than while
running, and @include is the only one in it. The question is whether the rest
of C’s preprocessor belongs there too. None of it does, and the reason is the
same each time: the job is already done by something that is not a directive.
@once is already the behaviour, unconditionally. A file is compiled once
per compilation, keyed by where it lands on disk. C needs #pragma once because
C compiles an included file every time it is reached and leaves each file to
guard itself; Solum does not, so the directive would be a way of asking for what
you already have. There is nothing for it to switch on.
@define has three jobs and Solum has three answers.
A named constant is a binding. maxRetries := #3. is a name holding a value
rather than a name standing for text, which means it obeys scope and cannot
surprise anybody by expanding in the middle of an expression.
A function-like macro is a block. And the one thing macros have over functions in C — that their arguments are not evaluated before the call — is the thing blocks were built for:
unless := { condition, body | condition:not:ifTrue({ body:value }) }.
unless:value(false, { "only now":display }).
ifTrue itself is that shape, so a user-defined control structure is ordinary
code and needs no macro. A block goes further than a macro can: it is a value,
so it can be stored, passed on, and called later.
Compile-time computation to avoid a run-time cost is the job left, and the answer here has consistently been to measure and make it a primitive — which is what happened to the four loop constructs. That is the better answer, because it speeds up every caller rather than the ones who remembered to use the macro.
@ifdef is the one worth arguing about, and it is still no.
- Platform differences. The language has none. The VM absorbs them in C, which is where they belong; even 6.10, the first genuinely platform-divergent feature, would diverge inside the VM rather than in Solum source.
- Debug and release builds.
system:environmentanswers at run time, and a block that is never called costs a slot. - Feature detection, which is the real one: a program that wants to run on two
releases cannot use
asByteon the one that has not got it.respondsToalready answers that, at run time, with no second language:
"A":respondsTo('asByte):print. ; true
"A":respondsTo('asRunicGlyph):print. ; false
What @ifdef would cost is out of proportion to that. It introduces a second
language with its own scoping rules, and it makes the text on the screen stop
being the program — a reader, and every tool, has to know which switches were
set before they can say what a file means. This language has spent its whole
design avoiding that kind of second mechanism: control flow is message sending,
a class is an object, a method is a slot holding a block.
So @ stays a namespace with one thing in it. That is a result rather than
an oversight. The reserved space says this is where compile-time things go if
any more turn up, and the honest position after looking is that none have —
each candidate is answered by a binding, a block, or a message. If a real one
appears, @ is ready, and the case for it will be that nothing in the language
already does the job.
One has since been proposed, and it is judged against that sentence rather
than around it.
An infix notation for arithmetic
would be the second thing in the namespace and the first directive that is an
expression. It does not pass the test as written — the language does do the
job, since a:add(b) computes the sum — so it has to argue on legibility
instead, which is a weaker kind of argument. What it does not share with the
three above is the reason they were refused: it has no switches, so the text on
the screen never stops being the program.
Cascades: A:with{ :m1(#1). :m2(#45). }
Smalltalk needs cascades because its setters answer the argument, so
a add: 1; add: 2 is the only way to chain. Solum already avoided that
problem: add answers the array, so b:add(#1):add(#2):add(#3) chains
natively — the changelog records that as a deliberate choice.
So the syntax would buy a second way to do something the language already does,
at the cost of a construct where :m1(#1) means a send to a receiver that is
not written down. That is a large exception to “: sends to what is on the
left”.
Tail calls
The obvious motivation is 3.5: recursion stopped at about 62 levels then, two programs had hit it, and a language that reuses the frame for a call in tail position would relieve that. Scheme requires it, Lua has it, Smalltalk does not.
It would not have helped either program, which is the whole entry. evaluator.sol stopped at 18 brackets and lib/json.sol at 28 levels of nesting — 83 and 124 since the cap moved — and both are recursive-descent parsers — where the recursion is never in tail position:
parseExpression := { | left |
left := parseTerm:value. ; the recursive call, and then
{ isOneOf:value("+-") }:whileTrue({ | op |
op := next:value:text.
left := binary:value(op, left, parseTerm:value) }). ; work after it
left }.
parseFactor is the same: it calls parseExpression, then checks for a closing
bracket, then answers. A tree walk has work waiting on the way out by
definition, so the frames cannot be reused and the depth is real.
So tail calls would buy loops-written-as-recursion, which nothing here writes, because the language has loops. Verdict: no — not because it is hard, but because the case for it evaporates on inspection, and the programs that appear to ask for it are asking for something else.
Coroutines
Lua’s headline feature and one of Io’s, and the natural way to write a generator, an incremental parser, or a scheduler. Rejected for a reason more specific than the one that refused Go-style concurrency below.
The interpreter re-enters itself on the C stack. When a primitive calls back
into the language — whileTrue calling its body, collect calling its block —
sol_vm_call_block calls run_frames again, nested inside the C frame of the
primitive, which is nested inside the run_frames that dispatched it. So the
live state of a running Solum program is not only in vm->frames: it is also
prim_while_true’s for(;;), prim_array_collect’s loop index, and the GC
temp-root push each of them is holding.
A coroutine has to suspend a Solum stack and resume it elsewhere. The Solum
frames would move — SolFrame is plain data and slots points into a stack
that could be relocated. The C frames interleaved with them cannot.
Verdict: no, and the note worth keeping is that this is a consequence of a
choice that has paid elsewhere: re-entrancy is what lets ifTrue and
whileTrue be ordinary messages implemented in C, which is the thing this
language is most pleased with. The price is that its stack is not a value.
Multiple return values
Lua’s a, b = f(). No. Every send here has a fixed stack effect — the
design leans on SEND 'add' (1 args) always having exactly two values beneath
it, and the verifier checks the height at every instruction on that basis. A
second return value would make the height depend on what a method decided at run
time, which is the property the verifier exists to have.
An array already carries several answers, and
programs/disasm.sol returns ['ok, page] pairs
throughout without the shape being uncomfortable.
Resuming from an error
Smalltalk’s retry, resume: and pass. No. The error system is
unwind-only on purpose and the reference says so — “What is gone is the
frames. Nothing can be resumed.” By the time a handler runs, the stack between
the raise and the catch is gone; resuming would mean keeping it, which is a
different error system rather than an addition to this one.
retry specifically is writable today: a block that calls itself, or a loop
around the onError. What is not writable is resuming at the raise, and that
is the part that would cost the design.
More than one parent
Self allows an object several parent slots, and multiple inheritance with it.
No. One proto is the model, and the thing multiple parents are usually
wanted for — reaching a specific ancestor’s version of a message — is already
self:via(ancestor), which names the ancestor rather than inferring it. That
naming is what lets no frame record where a method was found; several parents
would put the ambiguity back and need a rule to resolve it.
Go-style concurrency
Goroutines and channels are a whole-VM change, not a feature:
- The interpreter is re-entrant but single-threaded, and frames live in one
fixed
SolFramearray on the VM. - The collector is stop-the-world over one heap with no synchronisation, so every allocation would need a lock or a per-thread nursery.
- Globals are one shared namespace with no memory model to say what a write on one thread means to a read on another.
Go’s model also depends on a scheduler that can preempt, which needs either safepoints in the dispatch loop or OS threads with all of the above.
Not a bad idea in itself — a bad fit for a VM of this size, and the sort of thing that is designed in from the start or bolted on painfully. If concurrency becomes the point of the project, it is a rewrite worth planning, not an item to add to a list.
Subclassing integer, or a byte as a subclass
Not possible, and the reasons are worked through with a demonstration in class-and-instance.md.
In short: an unboxed value carries no class pointer, so there is nowhere to
record a different class; and integer’s methods are C primitives that read an
8-byte payload, so a class made of them hands down an interface and no
implementation. An object delegating to integer inherits every method name
and can run none of them — including methods you write yourself, the moment they
touch anything inherited.
Adding methods to integer itself works and is the supported route. A distinct
type wants an object that holds a value.
The “properties on the metaclass” half is the open design question in 2.5.