Solveig

Changelog

Notable changes to Solveig, newest first.

Each entry names the commit it landed in. Dates are the day the work was done. What is still outstanding is in ROADMAP.md.

sort’s reader: two quadratics under a question about a constant — c91d5ec 81368b1, 2026-09-04

The review of the conversions left one thing measured and not acted on: readChunk at 65,536 cost 1.03 s on a 4.7 MB file where 8,192 cost 0.86, and a larger read being the slower one should not be true. It was a symptom.

fill rescanned the whole buffer for a newline on every read, so a line longer than one read costs a scan per piece over a buffer that keeps growing. That was quadratic before the conversion too — what the conversion did was multiply its constant by sixteen, since readKey accumulated 65,536 bytes before each rescan and readUpTo answers out of a 4,096-byte window. A 2 MB line went from 0.95 s to 1.90 s on the route being converted, and nothing caught it: the sweep and the oracle check answers, and the answers were right.

And concat built that buffer by copying the whole of it per piece, which is the same shape in a different line. So a reader holds the fields of the piece last read plus the fragments of the line still being read, joined only when its newline arrives. One line, through a pipe, -g, best of three:

line scan and concat scan fixed lines not buffer
1,000,000 0.48 s 0.01 s 0.01 s
4,000,000 7.71 s 0.25 s 0.03 s
16,000,000 4.48 s 0.15 s

readChunk stopped being a question rather than getting a better value: the spread from 4,096 to 262,144 went from 17% with the largest read worst to 2% with the largest read best. The constant stays at 65,536 and the table is retired. The two routes now cost the same in wall clock as well as instructions; the named file had been 16% behind.

An instruction count is not a cost model. One percent of the instructions against 14% of the wall clock — a copyFrom is one send and a memcpy of everything behind the line — so --steps, which is the measure reached for first here because it is exact, could not see any of this. That is the second instrument finding in two days, after --memory=N turning out to be a step function, and both came from asking what a number meant rather than what it was.

The two conversions readUpTo was built for, and the sweep that ran one route — e54d5c4, 2026-09-04

sort and gzip -d were the two programs the entry named, and neither had been converted when it closed. Both are now, for about twenty lines between them.

sort: the pipe costs what the name costs. The byte-at-a-time readKey loop became readUpTo into the buffer the file branch already fills, so the two branches differ by which call they make and by nothing else. 584,997 bytes in 11,350 lines, --steps binary-searched: 28,846,431 → 15,402,663, against 15,398,455 for the same file named on the command line. Two ways in, one performance story, to within 0.03%. 4.7 MB through a pipe went from 1.50 s to 0.81 s at -O2, best of five; the instruction counts hold under a -g build too and the seconds do not.

gzip -d: the input is gone rather than discounted. Standard input arrives in 4,096-byte pieces, each replacing the last — nothing in the program looks backwards, since the window a back-reference reads from is the output. Smallest --memory=N that finishes: 6,615,294 → 4,528,936 for 187,655 bytes out, and 13,121,439 → 8,942,620 for 397,342. That is 35.3× → 24.1× and 33.0× → 22.5× held per byte produced. The entry predicted a bounded read retires two copies of four and was right about which two.

And that it is the input which went was shown rather than inferred. Seven streams that all produce the same 187,655 bytes, each two members with the first k stored and the rest deflated, so the input varies while the output does not: before climbs 6,615,294 → 8,710,802 as the input goes 65,881 → 187,693, and after does not move — 58 KB of scatter, no trend. A gigabyte through the pipe holds the same 4,096 bytes of it a kilobyte does.

The before column also showed what --memory=N actually reports. It steps rather than climbing — one jump of 2,095,509 bytes, nothing in between, and five compression levels of one file give 6,615,294 to the byte across 200 KB of boxed integers. The smallest limit a run survives is where the collector’s heap threshold next lands, not what the program holds. Every such number here is a ceiling with that grain: honest for a comparison run both ways on the same input, which is what they are all used for, and not honest read to the byte as what the program holds.

It cost 1,206 instructions, after costing 725,751. nextByte runs once per input byte, so calling the refill test unconditionally is a block send in that loop — 1.8%, paid by the named-file route too, where nothing ever refills. The guard is written twice now, with a comment saying why it is not tidied away.

And programs/gzip/sweep.sh was naming its input file in all 66 of its cases, so the pipe — the route 6.45 exists for — was checked by nothing. programs/oracle.sh has run both routes since sed; sweep.sh is a separate script for a program the shared harness does not fit, and the rule did not come with it. A check that was right where it was written and absent from the file written next to it is a different failure from one got wrong. Both sweeps run both ways now — gzip at 131 cases, sort with a piped section over the eight repository files longer than one read — and both were proved to fail rather than assumed to: a reader that stops at the first short answer is caught by 64 of gzip’s 131 cases, and by every one of sort’s 184 piped runs and none of its 943 file runs.

And the checks themselves had the hole one level down, which review found. gzip’s truncated-stream case would have hung the sweep rather than failed it — the defect it is for produces a run that never ends — so it carries a --steps deadline and wants exit 1 rather than non-zero, 124 being what the deadline leaves. And no line anywhere in the repository crosses a 4,096-byte read: the piped files are 130 KB and up but their lines top out at 1,694 bytes, so the branch that fills a second time was reached by nothing. sort’s sweep generates an input with 30,000- and 4,097-byte lines now; a fill that reads once per call is caught by 23 of 23 forms on it and 0 of 23 on docs/programs.md. programs/tail/agree/chunk-longline.case had had the idea and it had not travelled.

No language change, so no GC proof is owed and none is claimed: this is two programs and two shell scripts.

0.43.0 — 2026-09-04

A decompressor, and the read it turned out to want.

programs/gzip.sol is the twenty-second program. It inflates a gzip stream — a bit reader, canonical Huffman decoded a bit at a time, a 32 KB window that back-references copy out of, CRC-32 and the length checked against the trailer — and its oracle produced every input it is held against, which is the strongest shape a check here has taken: /usr/bin/gzip compresses and this decompresses, over 66 round trips, so a disagreement cannot be a difference of opinion about what the input meant.

It was written to measure one thing and the measurement said the opposite. The survey put it on the list to find what a 32 KB window costs when it is 32,768 tagged values and every access is a send. The window is 4.8% of the program; the bit-by-bit Huffman decode is 70.7% — and 93% of the output comes out of that window, so it is not that it goes unused. 220 instructions a byte of output, counted exactly with --steps. The question behind the entry — whether packed numeric arrays are wanted — has an answer, and it is no.

And it found what no specification could. gzip -l’s ratio column is not 100 * (uncompressed - compressed) / uncompressed; it is integer arithmetic with a floor at -99.9%, so eighteen bytes in a twenty-seven byte file is -44.5%. RFC 1952 does not contain it because it is not part of the format — a standard cannot be wrong about what it does not specify.

system:readUpTo(#n) closes 6.45: up to n bytes of standard input exactly as they were sent, nil at the end, read(2)’s contract rather than fread’s. It is the third reader through one window, and what it buys is that memory stops depending on the size of the stream — 41,983 bytes held whatever the input, against a whole read that tracks it. The entry was marked decision because the open question was the name, and four were tried before readUpTo, which won on an argument already in the language: random:upTo(#n) means an inclusive upper bound on the answer, and this means it identically.

145 messages, up from 144, across 248 registrations. .sob files are unchanged at format version 14, so anything 0.42.0 compiled still runs.

Compatibility verified rather than asserted. All 35 examples compile to byte-identical bytecode under 0.42.0’s solas and this one, both run from the same working directory. All 34 of 0.42.0’s own chunks answer identically on both machines with the same exit status, except examples/system.sol, which prints how long things took and is read rather than compared.

One chunk does not run on the previous machine, and that is what a new message means. examples/reading.sol now sends readUpTo, so 0.42.0’s solvm answers object does not understand ‘readUpTo’ and leaves with 70. Backward compatibility is the promise a .sob format version carries — a chunk built then runs now — and it holds. Forward compatibility is not promised by anything and never was.

Extensions were untouched this release, and 0.42.0’s net bundle was loaded on this build anyway, because the ABI question is not the bytecode question and 0.39.0 is the release where the two disagreed. Both bundles bind a socket, report its port, send four bytes to it and see them arrive within 500 ms — identically.

gzip -d, and the window that was not the cost — 563a508, 2026-09-04

programs/gzip.sol is the twenty-second program, and the last of the three the Unix survey named. It inflates a gzip stream: a bit reader, canonical Huffman decoded a bit at a time, a 32 KB window that back-references copy out of, CRC-32 and the length checked against the trailer, and the container’s optional fields. -d -c -k -t -l, concatenated members, and a pipe. Compressing is a second program and a harder one.

The oracle produced every input it is held against, which is the strongest shape one here has taken: /usr/bin/gzip compresses and this decompresses, so a disagreement cannot be a difference of opinion about what the input meant. programs/gzip/sweep.sh runs 66 round trips over chosen shapes, generated text at three levels and this repository’s own files — and was proved able to fail before it was believed: a one-character off-by-one in the back-reference index is caught by 47 of the 66, and a program that runs nothing by 63.

The prediction asked for the cost of the window, and the window is 4.8% of the program. 40,775,088 instructions for docs/REFERENCE.md, counted exactly with --steps — the Huffman decode 70.7%, the CRC 11.4%, turning the output array back into a string 10.5%, the window 4.8%, at 220 instructions a byte of output and 1.32 MB/s. And 93% of the output comes out of that window, so it is not that it goes unused. The expensive thing is the one that happens most often, not the one that looks heaviest. The question the survey put this program on the list to settle — whether packed numeric arrays are ever wanted — has an answer, and it is no: the boxing is 5% and the interpretation is 70%.

gzip -l’s ratio is in the tool and in no specification. It 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 — a standard cannot be wrong about what it does not specify.

No roadmap entry came out of it, and 6.45 was corrected rather than added to. It named this program on the grounds that its input has no lines, so it would have exactly one route in; that half is right and the route works, since readFile reads a pipe whole. What makes it a customer is memory, which is sort’s reason: thirty bytes held for every byte produced, measured with --memory, where the format asks for a 32 KB window however large the stream is. The entry has two customers and one argument.

6.45 closed: system:readUpTo(#n)eab21d6, 2026-09-04

Up to n bytes of standard input, exactly as they were sent, and nil at the end. The third reader through the window 6.36 built, so readLine, readKey and readUpTo interleave without losing a byte between them. 6.45 is in COMPLETED.md.

The open question was the name, which is why the entry was marked decision and could not be closed by the roadmap alone. Four were proposed and three dropped on evidence rather than on taste. readBuffer names a thing the language does not have — there is no buffer type, and every use of the word in REFERENCE.md is about the machine’s own plumbing — and collides with the 4 KB window in stdin.c the message reads out of. readPart collides with the other half of the distinction the entry existed to protect: the range test is test_a_range_reads_part_of_a_file and sha256sum.sol already calls what readFile(path, at, count) gives back a part. readPiece was free and is still not right — a piece says the answer is part of something and says nothing about n.

readUpTo won on an argument that was already in the language. upTo is a message here: random:upTo(#n) answers an integer from #1 to #n, both included, so upTo already means an inclusive upper bound on the magnitude of the answer — which is precisely what this argument is. The name is the vocabulary already there rather than new vocabulary.

The contract is read(2)’s and not fread’s. It waits for the first byte and then answers what is there, so a short answer is ordinary and the size means something on every call rather than only the last. A caller wanting exactly n writes a three-line loop over this; a caller wanting what is there could not have written that out of the blocking shape, which is what makes this the primitive of the two.

Two things fell out that nobody asked about. #0 is refused rather than answered with "", which keeps a non-nil answer from ever being empty and so keeps nil unambiguously the end. And it takes no pathreadLine and readKey take none, a bounded read of a file is already readFile(path, from, count), and a path here would have wanted the file handles this language does not have.

Measured, because the entry was about memory. Two scripts counting the newlines in one stream, both agreeing with wc -l, the ceiling binary-searched with --memory=N:

input whole a piece at a time
187,667 bytes 226,303 41,983
397,342 bytes 435,199 41,983

The first column tracks the input and the second does not move.

145 messages across 248 registrations, up from 144 across 247. .sob files are unchanged at format version 14. Neither customer is converted yetsort and gzip -d still read the way they did, and that is ordinary work on two programs that each have an oracle to be re-run against.

0.42.0 — 2026-09-03

A corpus another implementation can score itself against, and two programs that found what a corpus written by its author cannot.

conformance/ is 90 cases in three kinds — a program that runs and prints, a program that runs and then stops, and a program the compiler must reject — each scored on its exact bytes by a harness that takes both tools from SOL_COMPILE and SOL_RUN. A second front end swaps the compiler, a second machine swaps the machine, and a producer emitting bytecode from another language sets both and reads the answers, which is the case that settled the shape: Phoenix cannot read a .sol file at all. Every expected output was written from the documentation before it was run, 40 of the first 42 held, and both misses were the author’s arithmetic. It runs in make test.

What it found was in the documents, twice. REFERENCE.md was wrong about onError in both halves of one paragraph, contradicted by another section of the same file; PRODUCING.md filed a self-including file as a refusal when it is a warning, which reverses what a front end has to do about it. Both are prose about a program that would fail — the shape a fenced block cannot carry, and so the shape expect.sol cannot execute.

diff and sort are the twentieth and twenty-first programs and the first to be held against a tool byte for byte over a corpus that includes deliberate divergences. diff is the first here that computes rather than recognises; sort spills to disk past -S. Between them they raised both of the roadmap’s open entries.

readFile answered "" for every pipe, silently — the size came from a seek, a seek fails on a stream, and nought is indistinguishable from an empty file. It reads them whole now, which closed both clauses of 6.43 in one change and left 6.45 behind it.

144 messages, unchanged, and .sob files are still format version 14 — this release adds a way of checking the language rather than anything to check.

The test suite was two thirds documentation checker, filed under the command line: expect.sol was 54 seconds of test_cli’s 79.75, because both run the binaries as a shell would. tests/test_documents.c holds it now, and the cost is attached to the thing that has it. And the order things are evaluated in — receiver first, then arguments left to right — is written down and scored, after being measured rather than assumed.

6.43 closed, and the half it left behind is 6.45 — f9b43e8, 2026-09-03

6.43 is in COMPLETED.md, both clauses of its title answered by one change. It read as two jobs — a program cannot read standard input whole, and the call that looks as though it can answers "" — and the first was a consequence of the second: the only reason a program could not read a pipe whole was that the call which should have done it returned early, and the early return was indistinguishable from a correct answer about an empty file.

What did not close was recorded inside it rather than as an entry, which is the filing this corrects. sort arrived as a second customer on 2026-09-02 wanting the opposite of a whole read — a pipe in bounded pieces, so memory stays inside -S however large the input is — and that want was written into 6.43’s body because it looked like a second reason for one thing. It is not: a whole read is the opposite of it, so closing 6.43 helped with none of it.

6.45, with the measurements that were 6.43’s and the customer that is its own: 84 MB/s by line against 4.2 by byte, readLine lossy and readKey exact at 238 nanoseconds a byte, and gzip -d waiting as a third customer whose input has no lines at all.

It is marked decision, because the obvious spelling is taken. readFile refuses a range on a stream, deliberately — a range means positions, and a caller asking twice for the same range expects the same bytes. So a bounded read of a pipe is a different question from a bounded read of a file, and one name for both would be the mistake new made. What it wants is a message saying take up to this many bytes and say how many arrived; what it is called is not this document’s to pick.

Seventeen links across eight files followed the move — the changelog, the journal, the roadmap, the reference, ideas, programs.md, sort.sol and stdin-cost.sh. The link check in expect.sol found every one of them, including the two outside a document, and rejected the anchor this entry first guessed at.

readFile answered "" for every pipe, and reads them whole now — 0514076, 2026-09-03

The defect half of 6.43, and the want came with it. readFile sized a file with fseeko(SEEK_END) and ftello. On a pipe the seek fails, the size kept its initial nought, and that is indistinguishable from an empty file — so the want == 0 path answered "" before any read was attempted. readFile("/dev/stdin") gave the contents from a redirect and the empty string from a pipe, with no error either way:

solvm prog.sob < big.txt        #628890
cat big.txt | solvm prog.sob    #0

The function already refused a directory and already checked a negative size. A failed seek was the case between them that nothing looked at — the same shape as a path with a NUL in it: a silent wrong answer rather than a missing feature, found by a program with a reason to try what nobody had tried.

It asks whether the stream is seekable now, and reads an unseekable one into a buffer that doubles from 64 KB. Both routes answer the same string, byte for byte, NUL and CR included, and it says whether the last line ended with a newline — which is the whole of what diff needed and could not get from readLine. The two-gigabyte limit still applies, met while reading rather than before it.

A range on a stream is refused rather than served by reading forward and discarding. A range means positions, and a caller asking twice for the same range expects the same bytes; a stream would answer whatever had not been consumed yet. A redirect is seekable, so the same range still works there.

No GC root was wanted and the test says so rather than the comment. Nothing is held across an allocation in the read loop — malloc and realloc are the whole of it, and the one string is built at the end, exactly as the sized path does. The case runs under SOLUM_GC_STRESS for that reason.

The test is in test_cli.c because the fault only exists when the process’s standard input is a pipe, which nothing running in one process can arrange for itself. It was proved able to fail: with the fix removed it stops at the piped read. 330,000 bytes, past four doublings of the buffer, compared with cmp rather than by length — a growing buffer is exactly the thing that can lose or repeat a chunk without changing the total.

And stdin-cost.sh is a check now where it was a demonstration. It printed a pipe answers “” — that is 6.43; it runs both routes over the same bytes and exits 1 if they ever stop agreeing.

What is left of 6.43 is sort’s want and not diff’s: a pipe taken in bounded pieces, so memory stays inside -S however large the input is. A whole read is the opposite of that, so nothing here helps with it.

The documentation checker was two thirds of make test, filed under the command line — 652deea, 2026-09-03

tests/test_documents.c, holding the two checks that hold this repository against itself: expect.sol over examples, docs, the two root pages and extensions, and the grammar sweep that holds solum.bnf to what solas accepts. Moved out of tests/test_cli.c with nothing changed — same assertions, same floors, same order.

They were filed by how they run rather than by what they check. Both run the binaries as a shell would, which is what test_cli.c is for; what they check is the repository. And the filing was hiding a number: 54 of test_cli’s 79.75 seconds were the documentation checker, two thirds of the whole suite, under a name that said command line.

     
test_documents 55s 41% CPU — it compiles and runs each of the 1060 claims in a process of its own
test_cli 27s 96% CPU
the other thirty-eight, and the conformance corpus ~7s  

The Makefile’s wildcard picked the new file up with no list to edit, which is what that wildcard has always been for.

And an argument in the Makefile is corrected rather than left standing. The comment explaining why the nine comparison benchmarks are compiled and not run said the ninety seconds would go into a suite that takes eight. The suite is about eighty-eight, so the case is a doubling rather than a twelvefold. The conclusion is unchanged, the reason is now the right size, and the breakdown is written down beside it so the next person does not have to measure it again.

Two references followed the move, and one of them was carrying a stale number of its own: programs.md said the documentation check runs in about sixteen seconds, which nothing had measured since.

The refused and trapped halves, and a warning that was filed as a refusal — 7c471f7, 2026-09-03

Three kinds, not two, and the scoping had run two of them together. It sketched a single tree of refusals — scope/ names/ expr/ directives/ limits/ — and every one of those is compile-time. But 13 of the 15 commented-out demonstrations in examples/ that the entry counted as the trigger are run-time: #2:add(1.5) and #7:div(#0) reach the machine and stop there. The two cannot be scored alike. One says your front end must reject this; the other says your machine must trap rather than wrap, which is a claim about a second machine that a tree of refusals has nowhere to put.

So accepted/, trapped/ and refused/, with the header saying which, so it stays one tree read one way rather than three read three ways.

Every refusal carries a -legal neighbour — the same program with the one offending thing put right, which must compile and run — and a refusal that has none is a failure. Without it a front end that refused everything would score full marks on the whole of refused/. That is oracle.sh’s agree/ and differ/ exactly, borrowed first rather than arrived at last.

The finding: a file that includes itself is a warning, not a refusal. PRODUCING.md had it in a three-row table beside a directive must stand alone and unknown directive, both of which reject. A self-including file compiles, leaves with 0, and runs, the include having done nothing. A producer reading that table would implement a rejection; what it must implement is noticing the cycle and carrying on — the harder of the two, and the one a naive front end gets wrong by not stopping. Corrected, and the case is on the accepted side now, which took one new header field: a program that warns is neither silent nor stopped.

The other ten refusals fire exactly as documented, each triggered before its case was written.

And the standard-error 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 made the 42 cases already there prove something they had not: a clean program compiles silently.

89 cases — 45 accepted, 29 refused (14 refusals, 14 neighbours, and 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. What is left is the five 65,535 limits, where a case at N would be a file of that many lines.

The conformance corpus runs in make test, first — and test_cli is 95% of the suite — e5470bd, 2026-09-03

A corpus a second implementation is invited to score itself against has to be one this implementation is scored against continuously. Otherwise the day one of the eleven chunk limits moves, nobody finds out — which is the argument method.md makes about checks that are not run. It needs no network and no clone, which is what keeps it out of the company of the oracles, and it takes about a second.

It runs before the C suite, and that was decided by a measurement. make test is 84 seconds on this machine, of which test_cli alone is 79.75 — every other binary together is about four, and the corpus is one. So a broken case says so at the start rather than after a minute and a half of something else.

Which leaves a stale argument standing in the Makefile. The comment explaining why the nine comparison benchmarks are compiled but not run says that the ninety seconds would go into a suite that takes eight. The suite takes eighty-four. The conclusion probably survives — ninety seconds is still worth not spending — but the ratio it rested on is gone, and the number is left where it is rather than quietly corrected, since what wants deciding is the argument and not the digit.

A conformance suite a second implementation can score itself against — e280a1e, 2026-09-03

conformance/, 42 cases in eight directories, each a program and its exact output. run.sh takes both tools from the environment — SOL_COMPILE and SOL_RUN, each a template with %s where a path goes — so a second front end swaps the compiler and keeps the machine, a second machine swaps the machine and keeps the compiler, and the defaults are the only mention of this repository’s binaries in the directory.

The shape was settled by the customer that exists. Phoenix emits .sob from a language of its own and cannot read a .sol file at all, so the corpus is not input to it — only the answers are, reached by translating each case, which is how the NBS suite is used here already. That makes the unit (program, byte-exact output, exit status) rather than a program carrying assertions, and one case then scores all three strangers because only one of the two tools is ever swapped. What nothing checked before this is that a Phoenix chunk computes the right answer; the verifier accepting it says only that it is well formed.

Every expected output was written from REFERENCE.md before it was run. A .out recorded from what this implementation prints agrees with it by construction and can never fail. 40 of the 42 held on the first run and both misses were the author’s arithmetic, so the corpus found nothing — which is the result: the floored division’s four sign cases, split never dropping a piece, the format spec’s flag order and three literal limits at exactly N were all true as written, and the limits were confirmed to refuse N+1 rather than taken from the page.

What it found is beside it. REFERENCE.md was wrong about onError in both halves of one paragraph — it said the handler is checked when it runs rather than when the message is sent, and that false:ifTrue(#5) says nothing. Both are refused, and Control flow in the same document argues at length why they must be, that argument being what the paragraph predates. Corrected. It is the shape expect.sol cannot catch: a sentence in prose about a program that would fail, rather than a claim in a fence that can be run.

Two more are unspecified rather than wrong and neither became a case: the order arguments evaluate in is nowhere in the documentation, and the 256-frame ceiling is frame accounting rather than a language fact — measured at 252 levels for one shape and deliberately not written down.

The harness was proved able to fail in all seven of its modes, and scores a machine that runs nothing as 42 failures. It was not in make test when this landed; the entry below put it there the same day. The refused half — eleven documented refusals of which three are tested, and eleven chunk limits of which none is — is still transcription waiting to be done, and the case for it is in ideas.md.

Three measurements that lived only in a scratch directory — 75d1020, 2026-09-02

Found by asking what the documents cite that would not survive the session. Three figures were quoted in the deliverable and measured by throwaways in a temporary directory: 44 disagreements in 1,050 runs and 2,400 runs, zero – in five documents and in diff.sol – and 238 nanoseconds a byte, twice in 6.43 and once in sort.sol.

method.md already had the rule – a throwaway that measures something the documents will state is not a throwaway – and sort’s sweep obeyed it because it was written as a check. diff’s was written as a throwaway an hour earlier and its numbers went into the documents anyway. Both are in the tree now: programs/diff/sweep.sh and programs/stdin-cost.sh, the second of which also demonstrates 6.43’s defect rather than describing it.

And keeping one of them found a defect in it. sweep.sh 200 minimal asks whether the tool ever uses more edits than the minimum, and reported 55 of 200 on its first run – which would have overturned a retraction made the same morning. The check was wrong: it split a file on newlines and counted the trailing empty string as a line, so a pair differing only in its final newline came out one edit cheaper than any diff can manage. Fixed, it reports 0 of 800.

The nanosecond figure reproduces at 240 to 260 and the ratio at 20 to 22 times. That is run-to-run variance rather than a moved conclusion, so the entry keeps its first numbers and now names the script instead – correcting the basis of a figure rather than polishing its digits.

Six links published as 404s, every one written the same day as a method.md rule about that class of fault. A markdown link whose text wraps across a line publishes site-absolute: jekyll-relative-links rewrites the .md target to .html and does not prepend the baseurl. The markdown is correct, which is why site.sh exists.

But this half of the fault is visible in the file, and nothing local looked. expect.sol scanned links a line at a time and never asked whether the [ that opened one was on the same line as its ](. It does now, scoped to .md targets because those are the only ones Jekyll rewrites – which also disposed of four false positives from ]( inside a string literal and inside this repository’s own prose about ](target). A fragment-only target is exempt, and three have been wrapped for weeks without site.sh minding.

The first attempt to prove it can fail proved nothing: the injected link went in against a heading GUIDE.md does not have, so the file was unchanged and every claim holds was about the tree as it stood – the vacuous check, made while demonstrating a check. The second injected against a line that is there, the checker named docs/GUIDE.md:7, and restoring the file made it quiet. It has since caught two more, both in paragraphs describing itself.

A stray file, and the class of thing no check here enumerates — bd5dd34 and 428f98d, 2026-09-02

programs/:= had been in the tree since 2026-08-31 – 117 kilobytes, byte-identical to pascal.sol, committed by accident, and about to ship in the 0.41.0 tarball, since make dist archives HEAD.

Not one check here looks at the set of files. They look at files of a kind: expect.sol counts solFilesIn:value("programs") and reads .md, the link checker walks markdown, site.sh fetches published pages, and make test compiles what the Makefile names. A file with no extension is outside all of them at once – so nineteen programs stayed true, recounted on every build, while the directory held twenty files.

The remedy is one command and it is not git ls-files, which answers what is tracked and this file was. git diff --name-status <last tag>..HEAD is the only view here that asks what a stretch of work added, and it is the first step in releasing.md now. method.md has the rule.

And the release procedure had the fault it warns about. Then the page, and the two fixups it needs had been listing three ever since the links were absolutised – written when there were two and never counted again. It surfaced only because renaming the heading made the link checker report the one link into it.

A merge that scanned, and the check that was too slow to finish — 0e60ff4, 2026-09-02

sort’s k-way merge picks its winner off a heap now, where it scanned every run’s head. The cost was lines x runs, so at -S 16 over docs/CHANGELOG.md – 14,707 lines in 788,815 bytes, some forty-nine thousand runs – it was a comparison per run per line.

  scan heap
400 lines, -S 64, 366 runs 0.24 s 0.09 s
docs/CHANGELOG.md at -S 16 did not finish 3.88 s
sweep.sh at full width, 1,610 comparisons did not finish 5 min 28 s

The scan carried a comment naming its own falsifying conditiona heap would matter at a few hundred runs – and the condition was met the same day by the check written to find exactly this kind of thing.

And the finding is how it was found. The sweep did not fail: it ran for two hours and fourteen minutes without finishing its generated half, and was reported as still running three times before anybody asked why. A check that has not answered looks exactly like a check being thorough. method.md has the rule now: know roughly what a check should cost, and read a large overrun as a result rather than as weather. Ten minutes was the estimate here and two hours was the answer, and the factor of thirteen was news on the first check rather than the third.

What the handle-free reader bought is worth naming. A merge over forty-nine thousand runs is ordinary for an external sort at a small budget, and a program holding a file handle per run would have run out of descriptors long before it ran out of patience. A reader here is a path and an integer, so a large k cost only the scan – an algorithm to choose rather than a wall to hit.

sort, and the gap that was not there — cf4b1c5, 2026-09-02

The language answers 144 messages, unchanged, and .sob files are format version 14. sort is the twenty-first program and the first that does not have to hold its input: past -S bytes it sorts what is in hand, writes it out as a run, and merges the runs at the end.

The prediction named one finding and it is absent. An external merge sort never writes into the middle of a file – a run is produced whole and then only ever read, and the output is produced in order, so it appends. 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, and 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.

What the k-way merge did want was the ranged read, and it was already there: k independent positions in k files at once, with nothing to open, close, or use after closing. A reader here is a path and an integer.

And the generated half of the sweep missed both real defects. programs/sort/sweep.sh runs generated inputs and then this repository’s own files under twenty-three option forms, and it was written before any claim was made about it – which is the only thing diff had needed hindsight to do the same day. Both defects came from the real half: -n must reject a leading + (the tool reads +5 as zero), found in this README’s +0.2% to +3.4%; and -f folds to upper case, not lower, visible only beside punctuation, found in three README files that begin lines with **[. The generated alphabet had a minus because somebody thought of one and no plus because nobody did.

Its first draft kept the counters inside a pipeline’s subshell, so it would have reported nothing disagreed whatever happened – the check that cannot fail, in a script written to be the check the corpus is not. It is a redirect now, proved by folding down again and watching it report four.

A claim was retracted before it shipped. sorted’s stability was going to be reported as undocumented, on the shape 6.42 closed for the bytecode format. REFERENCE.md has said it all along, in prose under the sorting examples; the grep found the table row and stopped. What is true is smaller: this is the first program that depends on the guarantee, and until now the sentence had no customer.

oracle.sh sets LC_ALL=C for every tool now, because a string here is bytes and a tool under a collating locale is being asked a different question – under en_US.UTF-8 it answers apple Apple banana where every program here answers Apple banana apple. All five earlier corpora are unmoved by it.

And expect.sol’s ordinal list ran out at twentieth with twenty programs, so sort fired the guard rather than the slack the comment above it promised. Five spare, and the sentence is true again.

diff, and the corpus that agreed with a wrong rule — ab56576, 2026-09-02

The language answers 144 messages, unchanged, and .sob files are format version 14. diff is the twentieth program here and the first that computes a relationship rather than recognising a structure: everything before it reads one input and reports on what is in it, and this one holds two and answers a question neither contains.

Myers’ greedy forward pass, the normal format and unified, -q, -s, -i, -U N, either operand from a pipe, and the tool’s three exit statuses. Held against /usr/bin/diff over twenty-four cases that must agree and five that must not.

The prediction named four findings and one held, which is the entry. 3.5 never came near it, there is no two-dimensional array, and the memory is quadratic in the edits rather than in the files. What held was the output format is the hard part, and it was the whole of the difficulty.

Three of that difficulty’s four faults came from the corpus and the fourth did not. Twenty-four hand-written cases passed a wrong rule for where an empty range is written – and only seven of them could have shown it, since 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; the real one has an exception at the start of a file that has lines. A random sweep against the tool disagreed 44 times in 1,050 runs; after the fix, 2,400 runs over six option forms and files up to forty lines disagree none. That is an author-written corpus tests what its author thought of with a generator as the second author, which is the cheapest second author available to a tool that has an oracle.

And then the first pair of real files disagreed, an hour after the sweep reported nothing — 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. The tool splits and this program does not, and neither is wrong.

programs/diff/apply.sh is what that produced, and it asks a different question from the oracle: it writes the unified diff, hands it to patch(1), and compares the result with the second file. Over sixty pairs of real files at two revisions: 60 of 60 reproduced exactly, 48 byte-identical to the tool, 12 not, and all 12 the same cost. Neither the corpus nor the generator could have found the divergence – the sweep mutates one line at a time and the shape needed is a block inserted whole – and it does not reduce to a small case, because the tool’s algorithm makes a global choice. A tool with an oracle can still want a check that is not the oracle.

And the oracle disagrees 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, none without it. Pinned as a divergence rather than chased, because matching it would mean reproducing a tie-break the tool does not apply consistently to itself.

Two roadmap entries, both about standard input and neither predicted. 6.43: a program cannot read a pipe whole, and readFile("/dev/stdin") – which works from a redirect – answers "" on a pipe rather than the contents or an error, because the size comes from a seek a pipe refuses and a failed seek is indistinguishable from an empty file. The exact route is readKey, at 4.2 MB/s against readLine’s 84, and diff pays it because a diff that cannot tell ...c from ...c\n is wrong rather than slow. 6.44: an instant cannot be written in local time, which every unified header carries.

oracle.sh generalised twice, the way it was for sed and by its later callers rather than copied. A case may carry a first: section, since diff is the first program here to take two inputs; and the exit status is compared alongside the bytes for every program it runs, since diff is the first whose status is documented behaviour rather than a 0 or 1 nobody had checked. All four existing corpora still pass.

method.md gains a rule from the miss: a predicted limitation decides which implementation gets written. Three entries have now predicted 3.5 for a program and three times the program was written in the shape that avoids it, because a limitation written down is known before the implementation is chosen. Such a prediction is nearly unfalsifiable, and it owes the shape of the program that would hit it.

0.41.0 — 2026-09-02

Every defect this release fixes was found by holding something here against something this repository did not write. The language answers 144 messages, up from 141, across 247 registrations rather than 244. .sob files are format version 14, unchanged. Six roadmap entries closed, and the open list emptied four times in a day.

A regular expression engine, and the matcher it replaced. lib/re.sol carries both POSIX dialects in one engine — basic and extended, groups, alternation, +, ?, back-references — and lib/pattern.sol is gone. The two dialects were designed together rather than one after the other, because a back-reference is exactly the feature that stops an implementation simulating an automaton, and retrofitting it is how a matcher becomes a backtracker by accident.

It was written because a shipped program was misreading valid input. sed.sol’s own header said a script using \(...\) would be refused. It was not refused, it was misread — \( was a literal parenthesis — so such a script came out inverted, substituting the line that contained the text (ab)c and leaving the line that contained abc alone, with no error and exit 0. Sixty cases sat in programs/sed/agree/ and not one had used a group.

awk is the nineteenth program, and the engine’s second customer rather than its reason: ten cases agreeing with the tool on the machine, and a second dialect through one engine, which is the check that lib/re.sol is a library rather than one program’s matcher.

Three new messages, each from a program that could not do its job without one. system:sleep, because waiting is one call to the kernel and a program should not start a process to do it. system:isTerminal, and the idiom it replaces turned out to be wrong: a pipe has four states, and the fourth — open, empty, and not yet finished — answers exactly as an idle terminal does, so keyWaiting(0.0) had been throwing away the input of any pipeline slow to produce its first byte, in every program that used it, for as long as they had existed. system:fileId, device and inode with only equals promised of it, so tail -f can tell a rotation from a truncation when the two files agree on size and on time.

A file can be read in part, closing 3.22. readFile(path, from, count) is a range and not a handle: nothing to open, nothing to close, nothing to leak, and no question about what a handle used after closing should do. A short range is the answer rather than a failure, since the last four kilobytes of a file that turns out to be one kilobyte is a reasonable question. The wall was measured before the program that wanted it was written, which corrected the order the scoping had recommended: a sparse file is 3 GB of holes and 8 KB of disk, and the language could size that file and could not read a byte of it.

And a path that is not there answers nil rather than raising, closing 6.41 — which is what a program watching a file that may be rotated out from under it actually needs.

What one bytecode instruction costs. sha256sum is the first program here with no I/O in its inner loop, and it was written to produce a number: 13,302 instructions per 64-byte block, 208 per byte, 234 million bytecode instructions a second at 4.3 nanoseconds each. Everything in performance.md until now had been a ratio — against CPython, or against an earlier Solveig. It is held to the digests published in FIPS 180-4 as well as to /sbin/sha256sum, because an oracle can be wrong in the same direction as anything derived from it and a number printed in a standard before this language existed cannot.

Three checks that nothing had held before. A link that names a heading is checked against the headings that exist — 2,811 links across 142 files. The grammar is held to the compiler by a fifteen-construct corpus, where before this GRAMMAR.md and solum.bnf were held only to each other. And site.sh holds the published pages against the source, which is how eleven 404s were found, and 263 changelog headings that had not reached the site since 0.20.0. Both fault classes it exists for are markdown that is correct and publishes wrong, so nothing that reads the file could have seen them.

The first outside user. One person, reading the documents for an afternoon and asking three questions, moved a cheatsheet row, a comment convention, a checker’s matching rule, a grammar nothing had ever verified, a compiler emitting bytecode its own verifier refused (SOL_MAX_LOCALS 256 → 255), a dictionary limit off by a factor of two, one sentence standing for thirty-two faults, and a format version that was a habit. None of it needed him to be an expert; it needed him not to already know what the answer was supposed to be.

So .sob has a contract now. PRODUCING.md is what a second producer must get right beyond the grammar, the verifier says which of thirty-two things is wrong rather than bytecode is internally inconsistent, and seventeen cases pin thirteen of those diagnoses by asserting the sentence rather than the result code.

The format version is a promise rather than an accident. Format 15 will refuse 14 and everything before it, and 14 already refuses 15 — the check is an equality, so a newer file is exactly as unreadable as an older one. No code changed. What was missing is that it had never been said, and a rule nobody has written down cannot be relied on, because it can change without anybody noticing they have broken it.

Compatibility, checked rather than asserted. All 35 examples compile byte-identically under 0.40.0’s compiler and this one, with both compilers run from the same directory, since an @include records the library’s path in the chunk. Every .sob gives the same answer on both machines in both directions, with two exceptions, and both of them are the new messages doing their job: 0.40.0’s machine refuses examples/files.sol with ‘readFile’ takes 1 argument, got 3 and examples/system.sol with object does not understand ‘sleep’. A new message cannot be answered by an older machine, and both say so by name rather than misbehaving. examples/system.sol is read rather than compared in any case, as always, because it prints how long things took.

SOL_EXTENSION_ABI stays 1, nothing in extend.h changed, and both builds export 29 sol_* symbols. 0.40.0’s net.so was loaded on this build and this build’s net.so on 0.40.0’s machine; both bound a socket and reported its port, rather than being assumed compatible.

The format version is a promise now — 5a5df7b, 2026-09-01

Format 15 will refuse 14 and everything before it, and 14 already refuses

  1. The check is an equality rather than a floor, so a newer file is exactly as unreadable as an older one, and the diagnosis is unsupported bytecode version with nothing else examined.

It needed no code, and that is the entry. The behaviour was already exact-match in both directions — verified by handing solvm files claiming 13 and 15, both refused. What was missing was that it had never been said. A promise nobody has written down is not a promise, and an outside producer had no way to tell a deliberate rule from an accident of the reader.

BYTECODE.md and PRODUCING.md now carry the header layout, the rule, and what follows from it: no compatibility window, none planned, a rebuild rather than a migration, and read SOL_SOB_VERSION from serialize.h rather than writing the number into your own source — the number is the only thing that has to move when the format does.

This closes 6.42 on the day it was opened, and section 6 is empty for the fourth time. Three of the entry’s four recommendations were narrowed or reversed by building them: the split went to a sentence a site rather than two buckets, the corpus is assertions rather than a directory of files, and this one turned out to need nothing at all.

The corpus, and the shape it turned out not to be — c8391c5, 2026-09-01

Seventeen cases, thirteen distinct diagnoses, each constructing a chunk with one fault and asserting the sentence rather than the result code. A diagnosis changing, or two of them merging back into one, fails the build.

It is the third piece of 6.42 and could not have been written before the second: a case cannot assert a specific diagnosis while every diagnosis is the same sentence.

Nine existing malformed cases now name the fault they mean, and all nine passed first time — which is the check that the sentences match what the verifier says rather than what it was meant to say. Four are new and cover the faults a generator writes rather than the ones a corrupted byte reaches: a jump off the end, a jump into the middle of an instruction, a block index naming nothing, OP_BLOCK naming a method that is not a block.

And a directory of malformed .sob files was the wrong shape. It is what this entry proposed and what building it argued out of: sol_chunk_save refuses to write a chunk that will not verify, so every such file has to be made by patching bytes of a valid one — brittle against a recompile, and answering a question nobody asked. A producer does not need this repository’s broken files. It needs its own diagnosed, which is what the split gives it. The corpus is for keeping the sentences still, and that is done where the chunks can be built directly.

6.42 is now complete but for one call, and that one is a decision rather than work: .sob is format 14 and files from 0.17.0 and earlier are refused, which is a promise made to solvm’s own past and not to a second producer. A program targeting 14 is entitled to know what 15 will do to it, and nothing says.

The verifier says which of the thirty-two — d6529b3, 2026-09-01

No change to the language, and none to the result codes either. SOL_SER_MALFORMED stood for thirty-two separate conditions and every one of them reported bytecode is internally inconsistent: a jump past the end of the code, a stack height that does not balance, slot_count < arity + 1, a name or constant index out of range, a chunk not ending in HALT or RETURN.

That is the right bar for solas, whose output is checked byte-for-byte against a second implementation and whose author has the source in front of them. It is the wrong bar for anybody else, and 6.42 exists because there is now an anybody else: a program generating .sob from outside this repository is told that it is wrong and not what is wrong, and these are exactly a code generator’s bugs.

Each site carries a sentence now:

solvm: cannot load 'x.sob': bytecode is internally inconsistent
       -- a jump lands in the middle of an instruction

A single-byte fuzz over one small .sob produces twelve distinct diagnoses where it produced one, which is how the split was checked rather than asserted: every byte of a working file, set to five values, and the messages counted.

The enum did not move, and that was the compatibility question. The detail comes back through sol_chunk_load_why and sol_chunk_verify_why as an out-parameter; the old two are wrappers passing NULL. No caller and no test had to change — fifteen tests assert the generic code and still do. An out-parameter rather than a file-static string because system:load is reachable from a thread, and two threads verifying two chunks must not tread on each other.

Four of test_serialize’s malformed cases now name the fault they mean rather than asserting the code, which is what stops the split collapsing back: change a sentence and that test fails.

And it went further than the entry recommended. Splitting by who is at fault — a producer bug against a damaged file — was the plan. The sites turned out to be one condition each already, and grouping them would have thrown away the part a producer uses. The recommendation was too coarse and the entry says so.

The first outside user, and what answering him cost — 72a3fcb, 8cfbdbc and e1dec62, 2026-09-01

Somebody who did not write this language started emitting .sob from outside it. Phoenix is a compiler-generator; the experiment is a bytecode compiler for Solveig, and the plan is bytecode for other source languages after that. Three questions came back over an afternoon, and answering them changed five things.

display writes a newline and the cheatsheet did not say so — it says it for print, on the row above, which reads as a difference where there is none. And #3:repeat({ "tick":display }) is written ; tick tick tick and prints three lines, which is this repository’s comment notation and not a claim about the language. Both were documented only inside expect.sol, where no reader looks. CHEATSHEET.md now opens by saying how its own comments are to be read.

Checking the second one found the checker accepting a class of false claim. satisfies took any claim whose first token matched the output, so ; tick would satisfy tick tick tick and ; #5 would satisfy #5 anything at all. That looseness existed to support a third comment convention the checker had learned rather than declared wrong. Twenty-two comments were converted to the -- form in one pass and the rule is equality now.

Then: is solum.bnf current? It is, and nothing had ever asked. expect.sol holds GRAMMAR.md against solum.bnf — two documents written by hand from one understanding, which this repository calls not a comparison everywhere else. Held against solas instead: 94 of 94 shipped files, 15 constructs, and agreement on 9 of 10 malformed programs. The tenth is a scope rule, which a grammar cannot carry.

programs/check_syntax/syntax/ is one small file per construct and test_cli requires both solas and the grammar to accept every one, which catches a construct added to the language and not to the grammar in about two seconds. The full sweep is sweep.sh and stays out of make test at 44 seconds — almost all of it one 196 KB file, where the grammar itself parses in 0.046 s.

And PRODUCING.md, which is what he actually needed. The rules solas enforces that neither the grammar nor BYTECODE.md carries: self’s scope, duplicate declarations, the three extra rules inside @expr, directives, every chunk limit with its number, and what the verifier checks. Every rule was triggered before it was written down.

Writing it found two defects. SOL_MAX_LOCALS was 256 where the format writes slot_count as a u8 — so a frame of 256 slots compiled cleanly and then failed to serialise: solas emitting bytecode its own verifier refuses, reported as bytecode is internally inconsistent rather than too many parameters. It is 255 now. And a dictionary literal takes 127 pairs, not the 254 its message implies, because it lowers to dictionary:of and the ceiling is the argument list’s.

awk, the nineteenth program — faea93b and e0137fa, 2026-09-01

No change to the language. programs/awk.sol is the POSIX pattern-action language in 1,800 lines: BEGIN and END, expression and regular-expression patterns, ranges, fields and FS, printf, arrays, user functions, getline in all four of its forms, and twenty-one built-ins. Ten cases agree with /usr/bin/awk and the one divergence is the order for (k in a) visits an array, which POSIX explicitly leaves undefined.

It is the first customer of lib/re.sol’s extended dialect, and it wants it by standard rather than by taste — POSIX says what /a|ab/ matches, so a divergence is a defect and not a preference. That is a stronger thing for a library to be held to than another program’s opinion.

The three predictions the scoping made, and what each turned out to be.

predicted what it was
full ERE already built. The prediction’s value was that the library came first
a lenient numeric read nine lines, wanting nothing new. asInteger is strict on purpose and awk needs "3abc" + 0 to be 3
%e and %g written here, which is where a format belongs. fill takes {} and no conversion at all, deliberately

A fourth thing pressed harder than any of them and the prediction missed it. 3.2, no non-local return, was wanted three separate times in one file — by the expression evaluator, the statement executor and the parser’s primary. An interpreter dispatching on a tag is exactly the shape that wants to answer and leave, and without it every later branch is guarded against having already finished. next, exit, break, continue and return are five flags where one mechanism would do. The editor’s dispatcher was that entry’s first customer; this is its second, and it wanted it once per dispatch rather than once.

And 3.5 shaped the parser, as ideas.md said it would: twelve levels of binary precedence at three frames a level would run out at six parentheses, so it is precedence climbing, which costs three frames for the whole chain.

Six defects, and the oracle found five of them. re.sol did not export lastEnd, which match wants for RLENGTH. -F: joined to its flag was not read, and every awk script writes it that way. run called setDefaults a second time and put FS back to a blank after the command line had set it — found by the one flag in three that had input to act on. random answers fraction, not next. numToStr asked truncated before its magnitude guard, and and evaluates its receiver. And ln(1e30)/ln(10) is 29.999999999999996, which put 1e30 on the wrong side of the %g switch.

The sixth is the one worth keeping. getline line < "file" parsed as getline line and then a comparison with the filename — so it read standard input, threw the answer away, printed nothing and exited 0. That is the same shape as sed reading \( as a literal parenthesis, in a file written the same afternoon, and it was found the same way: by running the form rather than believing the note that said it was not written.

lib/re.sol, and pattern.sol retired — 8e27372, 2026-09-01

No change to the language. A library replaced, and the two programs that ran on it now answer what sed and vi answer.

lib/re.sol is regular expressions in both dialects POSIX describes: re:on reads a basic one — sed’s and vi’s, operators backslashed — and re:ere an extended one, which is awk’s. Groups, back-references, \{n,m\}, alternation, \+ and \?, and leftmost-longest, which is POSIX and is not what a Perl-style engine gives: a|ab against ab answers ab.

It was built on a decision rather than a measurement: the patterns here are ones this repository writes, not ones a stranger supplies. That is what chose Solum over libc-through-an-extension, and the bargain is in the header in shell.sol’s words — build a pattern out of things you wrote, not out of things a file or a user gave you. A backtracker is exponential on a starred group inside a starred group; --steps bounds a runaway and says so, because every step it takes is an instruction the machine counts, and guarded removes the exponential outright at 20–30% for a caller that turns out not to control its input.

The corpus was the proof, and it said so in its own words. oracle.sh went from 60 agreeing cases to 62: the two added to differ/ an hour earlier to document the gap now agree with the oracle, and the harness reported AGREES — the divergence has gone, and the file still claims it. A case moving from differ/ to agree/ is what closing a gap looks like from the corpus’s side.

Held against the file it replaces, over 23 patterns and 19 texts: 4,485 comparisons, 28 disagreements, all one shape. pattern.sol’s endOfMatchAt ignored ^, so an anchored pattern claimed a match at a position other than the first. The oracle agrees with the new answer, and no shipped caller could reach the difference — every path to it went through findFrom, which handled the anchor separately.

The depth moved from the matcher to the compiler. pattern.sol recursed once per * while matching and stopped at 250 of them; this matches a 2,000-character pattern at no depth at all. What costs frames now is compiling: a sequence had to be a list rather than a spine of pairs, or the emitter ran out at about 220 characters. 48 nested groups compile and 49 do not, which nothing writes.

And deleting a file found a hole in the link checker built the same morning. It read only links carrying a #, on the reasoning that a missing file is a different question and nothing here had got one wrong. Nineteen links pointed at the deleted file and it reported none — and the sweep found three broken all along. It checks paths now: 2,162, and every one of them is there.

A group was two literal parentheses, and two programs said otherwise — 92c748e, 2026-09-01

No change to the language. A defect in lib/pattern.sol and the two shipped programs that ran on it.

\( was read as a literal parenthesis, so a valid sed script came out inverted on both counts:

input            /usr/bin/sed      sed.sol, before
xx (ab)c xx  ->  xx (ab)c xx   ->  xx YES xx      substituted; should not have
yy abc yy    ->  yy YES yy     ->  yy abc yy      did not; should have

No error, exit 0. A back-reference was the same: s/\(a\)\1/DOUBLE/ answered aa b where sed answers DOUBLE b.

Both programs claimed otherwise, one of them in as many words. sed.sol’s header had said for the life of the file that such a script will be refused rather than misread — a description of what somebody meant to write, never run. edit.sol’s / said nothing at all, which is worse in an editor whose users arrive with vi muscle memory: /\(ab\)c searched for the characters (ab)c and found the wrong thing.

The guard went into the library, not the callers. \(, \), \{, \}, \1\9, \+, \? and \| now raise and name themselves; \., \\, \$ and the rest are untouched. pattern.sol is what knows its own subset, and two copies of that test is what the admission rule exists to prevent.

The corpus is where this should have been caught, and the finding is worth more than the fix: sixty cases in programs/sed/agree/ and not one used \(. An author-written corpus tests what its author thought of. method.md carries the rule now, with the two missing cases added to differ/ — because a refusal is a behaviour and belongs in the corpus with its reason.

system:fileId, and the line tail -f was losing — ee4e905, 2026-09-01

The language answers 144 messages, up from 143, across 247 registrations. .sob files are format version 14, unchanged.

system:fileId(path) answers what the filesystem calls the file at that path — device and inode, as a string, "16777234:231399178" — and only equals is promised of it. It closes 6.39 and empties section 6 of the roadmap for the third time.

The gap it closes is a lost line, not a wrong offset. A log and the log that replaced it can agree on size and on time, so a replacement of exactly the same size read as unchanged — and the next growth was printed from an offset into a file that no longer had one. Against the tool on the machine, a five-byte log replaced by a five-byte log: the oracle printed three lines and tail.sol printed two. No error, no notice, no status. That is a different argument from the one the entry had made for two days, and it was measurable on the day the entry was written.

A string because the pair does not fit an integer. dev_t here is a signed four-byte integer — /dev/null has a negative device number — and ino_t an unsigned eight; on Linux both are unsigned eight. An answer whose type depends on the platform is the one thing a portable program cannot be given. It is readable rather than opaque because an id turns up in a trace and in Solid, and opacity in a string is a convention either way: the reference promises equals rather than pretending the type enforces it.

Nil for a path that is not there, following 6.41 — the use rather than a nicety, since a follower asks every poll and a rotation takes the path away for a moment. Built in the other order it would have been a message whose only caller died before it could call it.

A hard link answers the same id — which is what says this is the filesystem’s answer and not a hash of the path — a rename carries the id with it, and stat is followed through a symbolic link. An inode can be reused after a delete, so equality across a long gap is not quite proof; the question this answers is has the file under this path been replaced since a moment ago, which reuse does not reach.

No -F flag, and that is deliberate. This tail polls a path and has no open file to keep, so it was already -F-shaped; BSD’s -f follows the descriptor and goes on reading the renamed file, which this cannot do. A second flag would have been a second name for one behaviour, so -f stopped losing data. follow.sh gained the scenario and goes on comparing against the oracle’s -F — and now refuses to run on a .sob older than its source, after a stale build reported that very scenario as a difference and cost a wrong diagnosis.

And <stdint.h> is now included explicitly. intmax_t was arriving through some other header on this machine, which is not compatible with a front page that says no dependencies and portable C11.

The published pages, checked at last — 7140e4d, 2026-09-01

No change to the language. .sob files are format version 14, the message count is unmoved. A new check and eleven fixed links.

site.sh fetches every page GitHub Pages serves and holds it against the source at origin/main — headings rendered against headings in the source outside fences, site-absolute links against the base the site is served from, and every internal #link against the ids the renderer emitted. It closes 3.23.

Against origin/main and not the working tree, which is the one thing that keeps it from being noise: the site renders what was pushed, so a local file would report every unpushed edit as a fault. HEAD and the tree are both compared against the ref and the difference said out loud. Not in make test and it must stay out — the suite is offline and dependency-free.

Writing it found the fault class that makes its case. A markdown link whose text wraps across a line loses the baseurl when Jekyll rewrites its .md target to .html: eleven of them were writing /docs/X.html where the page lives at /Solveig/docs/X.html, and every one was a 404. On pages whose markdown is correct, whose local link check passes, and which expect.sol will never see. Ten were live; all eleven are unwrapped.

They turned up while reading the rendered hrefs to write the fetch loop, before the check itself ran — the same way the morning’s fault turned up. The artefact says things the source cannot.

And the version this entry was scoped from would have missed them. That throwaway only checked links whose target it had already fetched, so a link with a wrong base resolved to nothing and was skipped: it reported 0 dead on a site with ten 404s in it. Checking that a link’s anchor exists and checking that its address does are two questions.

What each branch catches, measured. The baseurl branch has ten live catches. The heading branch, held against the broken page saved that morning: source 316 headings, page rendered 64 — so it catches the <if-statement> fault, which stops rendering outright. It would not have caught the stray fence alone, because a ``` at the start of a line moves both counts together; that one’s signature is the eleven headings falling inside a fence, which is what expect.sol reports locally. Two checks, one fault each, neither redundant.

A path that is not there answers nil, and 6.41 closed — 6106115, 2026-09-01

The language answers 143 messages, unchanged, and .sob files are format version 14. Two of them change what they answer.

system:fileSize and system:modifiedAt answer nil for a path that is not there, where both used to raise. A real failure still raises: the split is ENOENT and ENOTDIR against everything else, and EACCES is deliberately on the raising side — a permission that stops the question being asked is not an answer to it, and a program told nil would conclude a file is gone when it is sitting there.

It closes 6.41, which was opened the same afternoon and is a defect rather than a feature. tail -f polls fileSize once per file per interval, so a log rotation — or a plain rm — ended the program with cannot measure and status 1, where the tool on the machine waits and picks up the replacement. It did not fail to follow a rotation; it died on one.

fileExists and isDirectory had answered rather than raised since they were written. Four messages get asked about a path in the same breath and two of them called absence an error while two called it an answer. Absence is the commonest thing a path can be and it is not a fault.

tail -f survives a rotation now, holding nil to mean gone when last looked at and reading from its beginning whatever comes back — without the file truncated notice, because a file that returns is a different file rather than the same one cut short. Two scenarios went into follow.sh, a rename and a removal, both of which ended the run before this.

What it does not fix is 6.39, exactly. A replacement that appears before the next poll never shows the path absent, so the file is judged by its size: smaller reads as a truncation and restarts, which is right by luck because a fresh log is empty; equal or larger reads as growth and prints from the wrong offset. That still wants an identity.

And a measurement of the oracle was wrong, which is the part worth keeping. The scoping said BSD’s -f follows the name across a rename and that its man page was wrong about its own flag. It does not, and the page is right: -f follows the descriptor and goes on reading the renamed file, which lsof on the running process shows it holding open. The throwaway that measured it ran both flags in one script and reproduced its own wrong answer four times; follow.sh caught it on the first run, by putting the two sides under one set of conditions. A comparison whose two sides did not run alike is not a comparison — a rule already written down here, applied to the checks and not to the throwaway measuring the oracle.

No change to the language. .sob files are format version 14, unchanged, and the message count is unmoved. This is a check and two documentation fixes.

expect.sol now reads every markdown link that carries a # and asks whether the heading it names is there — 1,313 of them across 124 files, against 1,496 headings on the day this went in, in make test with a floor beside the ones for claims, counts, positions, SolaBasic blocks, grammar productions and commit hashes. It was the one cross-reference in these documents that nothing verified, in a repository whose filing system is moving a heading from one file to another when an entry closes.

Every heading in docs/, the two pages at the root and every .sol header is turned into the anchor GitHub would give it — lower-cased, everything but word characters, spaces and hyphens dropped, spaces to hyphens, a repeat getting -1 — and a link is either in that set or it is a finding. A link with no fragment is counted and not checked: a missing file is a different question and nothing here has got one wrong.

Held against a second implementation. The anchors and the resolved links were dumped from this and from an independent version in Python and compared: 1,487 anchors and 1,309 links, identical, character for character. One uses an alphabet string and a hand-written walk, the other isalnum and a regular expression; one resolves .. with an array as a stack, the other with os.path.normpath.

The trigger did not fire, and the entry says so. ideas.md deferred this behind a second heading move that took links with it. There has not been one. It was built on instruction, and recording that is the whole use of having written a trigger down.

Two faults in CHANGELOG.md, both markdown that renders as something else, and neither found by the check. A paragraph had wrapped so that ``` began a line — a code fence, which turns 380 lines of prose into a code block. Four thousand lines above it, an inline code span had 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. Both are one rewrapped line.

The finding that started it was an artefact, and that is the part worth keeping. The throwaway reported a dead anchor; its fence rule closed a block on any line beginning with ``` rather than on a bare one, and under the rule the renderer actually keeps that link is fine. The shipped check reports nothing on a tree with both faults still in it. What found them was a different comparison that is not in expect.sol — the headings the published site renders against the headings in the file, which needs the network. 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 broken link.

The fences are tracked all the same, by the renderer’s rule, because a heading inside one is not a heading on the page. The count of those is reported and has a ceiling in the test: it is 1 — COMPLETED.md quotes a changelog heading inside a block to show what the hash rule reads — and it goes to 12 the moment that wrapped paragraph comes back.

system:isTerminal, and a workaround that was not exact — c599e65, 2026-08-31

The language answers 143 messages, up from 142, across 246 registrations. .sob files are format version 14, unchanged.

system:isTerminal(which) answers whether one of the three standard streams is a terminal. which is 'input, 'output or 'error; one primitive over isatty, no state.

Three symbols and not three messages. The stream is the thing that varies and the question is one question. 'input rather than 'stdin follows readLine, write and writeError, which spell them out; run’s options array is the one place the C names appear, and there they are keys a child process cares about. A symbol that is none of the three is an error rather than a false — there is no stream it could be answering about.

6.40 opened and closed the same day, and it arrived the way that list would like everything to. tail found on 2026-08-31 that keyWaiting(0.0) answers is standard input a terminal by accident, and wrote it down as a note with a named triggera second program wanting it — rather than arguing an entry into existence on the spot. sha256sum was the second the same afternoon. The promotion cost one sentence because the reasoning was already on the page.

And building it found the workaround had been wrong all along, which is not what the entry expected to buy. Both programs used keyWaiting(0.0):not, and the entry and both programs called that exact rather than approximate on this reasoning: an idle terminal answers false, a pipe with data answers true, a pipe at its end answers true. Three cases, each correct.

The enumeration was the mistake. 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. With no arguments:

{ sleep 1; printf 'a\nb\n'; } | solvm tail.sob

printed the demonstration and threw the input away. So did sha256sum. For as long as either program has existed, and nothing was going to catch it: a pipeline typed at a prompt or written in a test has its first byte ready before the program starts, so the case is invisible everywhere it would be looked for. It was found by asking what the old spelling had actually been answering — the question you only ask when you are replacing something.

An enumeration of cases is a proof only if it is complete, and three cases, all correct reads exactly like all the cases. 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.

And the output half had been answerable by accident too. system:terminalSize ioctls standard output and answers nil when that fails, so terminalSize:notNil has been isatty(1) since 6.34 — at the price of building a dictionary and throwing it away. A test now keeps the two agreeing.

The test that could have been wrong quietly is the mapping: a pseudo-terminal is put on exactly one of the three descriptors at a time, files on the other two, and all three symbols are asked each time. Nine answers, three of them true, each a different one, so any swap fails it. A test that only ever saw one stream answer would pass with two of them exchanged.

sha256sum, and what one bytecode instruction costs — 96f1bfe, 2026-08-31

No language change: the eighteenth program, and nothing on system moved. The language still answers 142 messages and .sob files are format version 14.

programs/sha256sum.sol is 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. -b, -c, -t, -w and -z, which is the whole usage line of the tool on this machine, plus a check mode with its singular and plural warnings.

208 bytecode instructions per byte hashed, at 4.3 nanoseconds each. Measured rather than counted: solvm --steps=N stops a program after N instructions, so the smallest N that lets a run finish is that run’s exact 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

Flat from ten blocks to a hundred; the same search on a megabyte answers 217,955,855, against 217,954,715 from the four rows, which is the fit confirmed to five figures. Ten megabytes take 9.30 s at -O2, so the interpreter runs 234 million instructions a second. The cost of one instruction had never been stated hereperformance.md has whole-program times and the ratios between them, and nothing below that — and it is now a section of that page.

  on a megabyte
/sbin/sha256sum ~1800 MB/s
shasum -a 256 ~320 MB/s — an interpreter too, and not interpreting the hash
this, -O2 1.08 MB/s
this, the -g build make gives you 0.22 MB/s — 4.9x, outside the 1.9x–4.1x the benchmarks show

It did not need byte, word and long, which the At a glance table had refused on general grounds and which SHA-256’s mod-2³² arithmetic is the first thing here to want. A 64-bit integer holds the sum of five 32-bit values with fifty-nine bits to spare, so every add is exact and the mask is a narrowing rather than a repair. The cost of refusing them is twenty-three bitAnds in one program — and they read as bookkeeping, because they are not in the standard.

A third of the program was a method call. On a megabyte: rotr written as a method 1.36 s, written out in the sixty-four rounds 1.10, written out in the message schedule too 0.92 — 1.48x, with identical arithmetic in all three and the same digest out of all three. What the method cost was a frame and a return, ten times a round, and it is 32% of the readable version’s running time. Worth holding against the inline cache entry, which measured lookup at 9.7%.

@expr has 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 the short-circuiting logical pair. Not an argument for adding them; written down because a notation introduced for “a formula you are transcribing” met a formula it could not.

6.40 is open, and its trigger was written down first and then fired. tail found that keyWaiting(0.0) answers is standard input a terminal by accident and recorded it as a note with a second program as the trigger; sha256sum is the second program, for the identical collision between the house rule and the tool. Writing the entry then found the other half was already answerable too: system:terminalSize calls ioctl on standard output, so terminalSize:notNil is isatty(1) today — checked through a pseudo-terminal both ways.

A path with a NUL in it is silently a different path. A -z checksum list found it: a Solum string is length-counted and may hold a NUL, a path handed to the operating system may not, so fileExists and readFile both answered about a prefix and agreed with each other. The program printed a mangled name and OK, with the right digest, and nothing raised. REFERENCE.md now says so beside the paragraph that invited the opposite conclusion, and whether the machine should refuse is scoped and not built.

Three checks, and one of them is a kind this repository did not have. sh programs/oracle.sh sha256sum runs 21 corpus cases both as a named file and down a pipe, with three more that must not agree; programs/sha256sum/check.sh runs 18 -c cases against the oracle on a directory of files, with three that must not; and programs/sha256sum/vectors.sh holds the program against the digests printed in FIPS 180-4, which does not depend on another implementation being right at all. An oracle can be wrong in the same direction as anything derived from it; a number printed before this language existed cannot. It is the second check here of that kind — the NBS suite basic.sol is held against was the first — and the difference is that a digest can be compared by a machine where the NBS programs are written for a person to read. method.md says so now.

The oracle earned itself twice in ten minutes. Against this program: -c dropped every empty piece of the split, under a comment saying “a blank line is not a malformed line in either tool” — which had not been tried, and is false. Against itself: -c reports a missing file with two spaces, having kept the separator in front of the name, which check.sh records as a divergence rather than copying.

A ranged read costs about 30 microseconds whatever it reads, because the cost is the fopen and not the bytes, against 0.65 for the stat behind fileSize. Having no handle means every call opens the file again — which tail, reading once or twice per invocation, could not see, and which a streaming caller pays 16,384 times a megabyte. So a chunk size is not an arbitrary constant: 64 bytes is 62% slower than 64 KB on a megabyte, flat from about 4 KB up. Plain C measures 28 us for the same open, read and close, so it is the machine’s price rather than the primitive’s, and it argues for a sentence rather than for a handle. REFERENCE.md and 3.22 now carry it.

The subset is bounded by a corpus rather than by a sentence. This program writes [-bctwz], which is the whole of the oracle’s usage line and not the whole of the oracle: /sbin/sha256sum also answers to --binary, --text, --check, --warn, --zero, --tag, --quiet, --status, --help and --version, and mentions none of the ten. Three of those are in sha256sum/differ/, so a sentence that was true about a smaller thing than the tool is now a check that fails if it stops being true.

And one defect the review pass found, worth recording for its shape rather than its size: a directory named inside a -c list was reported as No such file or directory while the same directory on the command line was reported as Is a directory. fileExists deliberately answers false for a directory, so isDirectory has to be asked first — and that three-line decision had been written twice, with one copy corrected during the writing and the other not. 5.5 at the scale of one file in one afternoon. path:complaint is the one place now.

programs/oracle.sh grew two things, the way it grew for its second caller. An oracle that is not in /usr/bin is now looked up on the PATH — sha256sum is in /sbin here — and pipenames: bounds the two routes of a program that names its input: the pipe’s output must be the file’s with the path replaced by a dash, which is a full check rather than a waiver.

system:sleep, and tail -ff30bc5d, 2026-08-31

The language answers 142 messages, up from 141, across 245 registrations. .sob files are format version 14, unchanged.

system:sleep(seconds) waits and answers nil. Seconds as a float like every duration here; a negative wait and nan are refused, because there is no length of time either could mean; 0.0 returns at once. Interrupted by a signal it sleeps out the remainder, since a caller that asked for a second has no way to learn it was cut short.

It is the one obvious hole among the other twenty-eight system messages: clock and time could say how much time had passed and nothing could spend any.

The prediction that asked for it was half wrong, and ideas.md now says which half.

The half that held: keyWaiting cannot stand in for a wait, because it waits on standard input and answers true at the end of it. 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 half that did not: the price was predicted to be the finding, by analogy with the terminal’s size being reachable through stty at 7 ms an ask. A fork of /bin/sleep measured 2.23 ms, which at a one-second poll is 0.22% and perfectly livable. The stty case was a fork per keystroke; this is a fork per second. 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 had to be made on something weaker and truer: waiting is one call to the kernel, and a program should not have to start a process to do it or depend on where a system keeps its sleep. Being right for the reason expected would have been worth less than finding out the reason was wrong.

tail -f, with -s for the interval. Everything else it needed was already there — fileSize notices growth without reading and a ranged read collects exactly the new bytes, so a poll is two syscalls and a short read rather than a re-read. Following an idle file for five seconds costs 0.00 s of CPU, which is what /usr/bin/tail costs. A file that shrank is treated as replaced and started over, with the note on standard error — BSD tail prints nothing there and GNU prints a line, so stdout agrees with the oracle either way.

And it is checked, which the scoping said it could not be. programs/tail/follow.sh gives a program that never stops a deadline: start both tails, feed the files on a schedule, stop them, compare. Six scenarios, and it earned itself on the fourth — BSD tail puts a blank line before the first heading when following and not when it is not, which is not arbitrary, since with -f the headings go on arriving and the first is one of a series rather than the top of a page. Nothing but a check that runs the real thing would have found it.

-F still cannot be written. Following across a rotation has to notice that the file at a path is a different file, and nothing here answers a file identity: fileSize and modifiedAt are the whole of what can be asked of a path, and both can coincide across a rotation. Left as a gap with its trigger named — a second program wanting to know whether two paths are the same file.

And a notation on the wrong sentences, found because the message count moved for the first time in four releases. Three Status paragraphs in README.md and one in index.md carried the live-count marker on statements about past releases, so a count that moved would have quietly rewritten what 0.38.0 and 0.39.0 answered. releasing.md already states the rule — a marked number is a live number, and a historical statement must not carry one — and it had been written for the release page without being applied to the README.

tail, and a call that was asked for nothing — 0213b0d, 2026-08-31

programs/tail.sol is the seventeenth program: the last N lines, the last N bytes, or everything from line N onward, of the files named or of standard input. -n, -c, their +N forms, -q, -v, and the ==> name <== headings.

It was written to check a call rather than to ask for one, which is the wrong way round here and was decided rather than drifted into. The rule is that a program asks and a page does not — but 3.22 closed before this existed, because its evidence arrived without a program, and a tail on the whole-file read could not have called the thing it was meant to be asking about. So the range was built and this is its first caller.

The answer is that the range wanted no change of any kind. No extra argument, no convenience, no different rule at the edges. ideas.md put it found nothing on the table as an available answer, and for the file API it is the true one.

Measured against /usr/bin/tail: 29 corpus cases, each run twice — input named as a file, input on a pipe — plus seven by hand for the several-file headings the harness cannot express, and the same commands on a 3 GB file. Every one byte-identical.

file tail -n 3 sed -n '$p', which reads it whole
618 KB 2.1 MB 5.4 MB
6.4 MB 2.1 MB 32.4 MB
3 GB 2.0 MB, in 5 ms refused

Three things it did report.

The predicted price was real, and split paid it. 3.22 said a range’s cost is that a record spanning two chunks becomes the caller’s problem, and it is: lastLines walks backwards a chunk at a time and a line may be cut in half by a boundary. split counts a chunk’s newlines and join puts back exactly what split removed, so the offset of the last few lines inside a chunk is arithmetic on their joined length rather than a second search. Twelve lines, and one integer carried across the boundary. The entry guessed scan.sol was the shape this would take; it is a cursor over one string and never came into it.

Clamping earned itself, in two of the four places this reads — both of the two that stream, since the last chunk of every file is short. Refusing would have made every chunk ask fileSize and take a minimum first: the caller re-deriving a number the call already had, with a race in the gap.

And one that is not about files. No arguments means two things in this program and in no other here: the house rule says demonstrate on input you carry, and ... | tail says read standard input. system:keyWaiting(0.0) separates them, and is the nearest thing this language has to asking whether standard input is a terminal — it works because of the answering-true-at-the- end-of-input property that is a nuisance in every other program. Verified through a pseudo-terminal both ways.

3.2 cost more here than it did in sed, and in a different shape. sed met it as an early exit from a loop and reported it cost nothing. This meets it as a guard — three routines opening with a test for an empty file, a zero count, a line number of one — and each is one line with a return and here wraps the entire body in an ifElse. Third customer for that entry, and the first to want the guard shape, which the entry currently treats as the same thing as the other.

The oracle harness moved to programs/oracle.sh and takes the tool’s name, because the second caller was about to make it a hundred and fifty lines of shell in two files — 5.5 is what that costs. Nothing in it was sed’s; what is sed’s is the corpus. It gained one wider escape, tworoutes:, used exactly once, for -v: the heading names the input and a pipe has no name.

-f is not here, and is the one part with a finding still waiting — there is no system:sleep, and a follow loop built on the only thing that waits would spin at a hundred percent exactly when tail -f is normally run. Left out rather than half-built, because an oracle cannot check a program that does not stop.

A range of a file, and 3.22 closed — 1603ab6, 2026-08-31

system:readFile(path, from, count) answers count bytes from the one-based position from, and a file is read whole, or not at all stopped being true.

size := system:fileSize("huge.log").
system:readFile("huge.log", size:sub(#4095), #4096).   ; the last 4 KB

A range and not a handle, which is what 3.22 had already argued for and which held up: no lifetime, nothing to close, nothing to leak, no answer needed for a handle used after closing. extensions/net had to pay for all of that because a socket has no alternative. A file does.

Why now. The entry named its own trigger — a program with a file that does not fit — and nothing here had one, which had been true for as long as the entry existed. A sparse file is 3 GB and 8 KB of disk, and on one of those:

/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 could measure that file and not read a byte of it, and the file cost four seconds to make. Nothing here has a file that does not fit had been a fact about this repository’s inputs rather than about the world.

A short range is the answer rather than a failure, and that is the only place the two forms part. Asking for the last four kilobytes of a file that turns out to be one kilobyte is a reasonable question, and the string that comes back says its own size. A whole-file read that comes up short is still a failure, since its length came from ftello a moment earlier and anything less means a fault.

That was settled by a comment already in the function rather than by taste, which is the part worth keeping. It read: a short read is a failure rather than a shorter string: fopen on a directory succeeds on some systems, and reading one does not. Right for a whole file and wrong for a range — so clamp or refuse was never a preference to argue about, and ferror is what catches the directory either way.

#0 is refused as it is on a string; past the end is a position and answers "". The size is asked for even in the ranged form, one fseeko before the read, so the buffer is the size of what is there rather than of what was requested — and got rather than want decides the string’s length, because a file may change between the seek and the read. fseeko and ftello rather than fseek and ftell, since the whole point is files past what a long is where a long is small.

Five tests, including a 3 GB sparse file that skips itself if the filesystem materialised the holes rather than writing three gigabytes to somebody’s disk. No new GC root: the primitive allocates once, at the end, with nothing live across it — the buffer is malloc’s and not the collector’s. Run under gc_stress anyway, because that is the claim being made rather than an assumption.

sed, and the sed that was already on the machine — 09dd8ce, 2026-08-31

programs/sed.sol is the sixteenth program, and the everyday half of the stream editor: addresses (17, $, /re/, \,re,, ranges and !), the commands s p d q = y a i c { }, and -n, -e and -f. The other half — the hold space, branching, the multi-line commands, r and w — is deliberately absent and is a coherent half rather than a list of leftovers: it is what makes sed a stream language, and it wants a pattern space that is a two-line window and a program counter that can jump.

The regular expressions are pattern.sol’s, which is the whole reason to write this one. The matcher and the substituter were already here; what sed adds is the cycle and the addressing, and 500 lines of that was enough to find out what the library underneath is short of.

It is held against /usr/bin/sed. programs/oracle.sh runs 60 cases that must produce the same bytes under both, and three that must not, each carrying at the top of its own file what each sed does and why this one is allowed to differ. So the list of divergences is something that fails rather than prose. sola makes this argument for QuickBASIC and pascal for fpc; this is the first whose oracle needed nothing installed.

Every case runs twice, by file and by pipe, and that is not ceremony: system:readLine reads standard input a line at a time and a named file is read whole and split, so the two routes into the program are different code paths. A stream editor answering two ways about the same bytes would be wrong where a single-route check cannot look.

What it priced. 3.22 says a file is read whole and that the peak is twice the file. Same script, same bytes, -n '$p', peak resident:

input lines named file standard input
618 KB 20,000 5.3 MB 2.5 MB
6.4 MB 200,000 32.3 MB 2.5 MB

The stream is flat and the file is not, and the slope is about 4.7 times the file — twice is right for readFile alone, and a program working line by line holds a string object per line as well. The entry’s trigger has still not fired: nothing here has a file that does not fit. What moved is the price, not whether it has been paid.

And one bit that readLine cannot report. A file whose last line carries no newline must not gain one. This gets it right for a named file and cannot for a pipe, because readLine answers the line without its terminator and there is no way to ask whether there was one. Three oracle cases declare that difference and bound it: the pipe’s answer must be the file’s plus exactly one newline, and anything else is news. \r\n is the same boundary from the other side — readLine strips the carriage return and split does not — and the program takes it off on the file route to agree with itself.

Two limitations were exactly as advertised and cost nothing. 3.2, no non-local return: d and q are early exits and the runner threads a verdict symbol through its loop instead, three lines longer than a return. 3.1 never came up at all, because a compiled script here is data — a command is slots, not a closure. A sed built as one block per command would have met 3.1 on its first line.

What it costs: 20,000 lines, one substitution each, -O2, 0.26 s against the system sed’s 0.01 s. The Makefile’s default CFLAGS has no optimiser and that build takes 0.83 s, which is performance.md’s standing warning arriving again.

An empty match where the last one ended is not a match — 322c50e, 2026-08-31

A defect in pattern.sol, found by the oracle above on its first run, and in the substituter rather than the matcher:

pattern:on("o*"):replaceAllIn("aoc", "-")     ; was "-a--c-", every sed says "-a-c-"
pattern:on("o*"):replaceAllIn("oo",  "-")     ; was "--",     every sed says "-"
pattern:on("b*"):replaceAllIn("abc", "-")     ; was "-a--c-", every sed says "-a-c-"
pattern:on("o*"):countIn("aoc")               ; was #4, and there are three

The star matches the o, and then matches nothing at the position the o ended on — which is the same position seen twice. The rule beside it was there and was right: a zero-width match must not stand still, or the loop never ends.

The library’s own example is the single case that cannot show the difference. s/x*/-/g over abc answers -a-b-c- under both readings, because the star never matches a character there and so no match has an end for a later empty one to land on. Telling the two rules apart needs a pattern that matches something — which an oracle supplies and an example written by the author of the code does not. That is the argument for an oracle in one sentence, and it was made by the oracle rather than about it.

Fixed in substitutionIn and again in countIn, which walks the text separately and on purpose; the comment in each names the other. Four cases went into examples/matching.sol so expect.sol holds it, and the editor’s 181 scripted sessions still pass — edit.sol’s :s goes through the same code and had the same defect.

0.40.0 — 2026-08-30

A literal for the dictionary, and five sentences that turned out not to be true. The language answers 141 messages, unchanged — dictionary:of was added and of was already a name — across 244 registrations rather than 243. .sob files are format version 14, unchanged. There is new syntax, which is the first time in some releases.

#["key" = value] is a dictionary literal, and real desugaring: it compiles to a global load of dictionary and a send of of, held byte-for-byte against the written-out form by a test, exactly as [...] is held against array:of. It arrived as a proposal with two halves — the literal, and moving @expr’s equality to == so that = was free for it. The second half was never needed. = is scanned unconditionally and given meaning by whoever is parsing, so a region, a stray operator and a literal’s own pairing never meet.

A bracket rather than a brace is where this parts company with the languages a reader arrives from: here { } is a block and [ ] is a collection written out, so the familiar spelling was the wrong one to borrow.

The editor was corrupting files. $x on a line holding café wrote caf and a lone lead byte to disk, the cursor was drawn a column right of the character it sat on, and dw stopped inside a word. The fix is not a Unicode library — isTail is three sends — and it survived 165 scripted sessions because every one of them was ASCII. Sixteen more were added, eleven of which fail on the editor as it stood.

string:startsWith and string:endsWith, in lib/text.sol, in Solum. Deferred a day earlier on the reasoning that indexOf(x):equals(#1) means the same and costs the same. Three programs had written them by then, two of them endsWith independently, one absence had already produced a defect, and the cost claim was wrong by three orders of magnitude — a search that fails has read the whole string, and failing is the case a prefix test exists for.

What the release is really about. Five claims standing in the documentation were tested and four were wrong, and not one was found by a program failing: ensure already existed where a gap was assumed; decorators are writable but not the obvious way; a file’s memory edge is twice the file and the doubling is not the copy; named arguments were recommended and then refused when the options array turned out to catch every mistake it stood accused of passing; and the backtrace is not missing but discarded, one line after the error object is built. ideas.md carries each with its measurement.

Compatibility, checked rather than asserted. All 34 examples compile byte-identically under 0.39.0’s compiler and this one — with both compilers run from the same directory, since the include path is recorded in the chunk and a library found at a different path is a different name for the same code. Every .sob gives the same answer on both machines in both directions.

Two exceptions, both being the new syntax doing its job. examples/dictionaries.sol is refused by 0.39.0’s compiler with expected digits after ‘#’, and its 0.40.0 bytecode is refused by 0.39.0’s machine with object does not understand ‘of’. New syntax cannot be read by an older compiler and a new message cannot be answered by an older machine; both say so by name rather than misbehaving. examples/system.sol is read rather than compared, as always, because it prints how long things took.

SOL_EXTENSION_ABI stays 1, and nothing in extend.h changed. 0.39.0’s net.so was loaded by this build and driven end to end over UDP rather than assumed.

The literal’s first callers, and the tables that declined it — a671967 and 839560b, 2026-08-30

Four constant tables converted across two libraries, and two more looked at and left alone, which is the more useful half of the result.

json.sol had two, and they are the same table in opposite directions — eight escapes for reading, five for writing. Converting one and leaving the other would have left a matched pair written two ways in one file, so both went. The read table’s comment already records that it was deleted on 2026-08-21 with its two references left behind, so json:read answered object does not understand ‘escapes’ for four days and four releases. It is one statement now where it was nine, so it cannot be partly removed — a smaller failure than that one and the same family — and eight pairs written out can be counted against the eight escapes JSON names, which is how \b and \f were noticed missing in the first place.

html.sol had four and two of them converted: entities, eight pairs, and implied, seven. The block-element half of implied still adds to the dictionary the literal built, which is what a literal being an ordinary value rather than a compiler form allows.

void and raw did not convert, and the file now says why. They are sets: nothing reads the value and true stands in for a membership this language has no type for. Written out they would be fourteen = trues and two more — noise proportional to a thing it says nothing about, where a space-separated list of names reads as what it is. A dictionary literal makes the missing set more visible rather than less, and the note points at the entry that has been waiting for a customer. That comment exists so the next reader does not finish the job and make it worse.

Checked by recompiling against each version rather than by reading, and the first attempt at that was vacuous: @include resolves while compiling, so running the same .sob twice says nothing about the library underneath it. Done properly the output is identical and the bytecode is 203 bytes smaller — fifteen atPut statements were fifteen global loads and fifteen sends, where each literal is one of each.

The dictionary literal — cf58923, 2026-08-30

ports := #["http" = #80, "https" = #443].
describe:value(#["count" = #7]).

Sugar for dictionary:of, and real sugar rather than a lookalike. It 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, so rebinding the class name moves both spellings and they cannot drift apart.

A bracket rather than a brace, which is the one place this differs from the languages a reader arrives from. [ ] here already says a collection written out and { } says code, so the brace that Python, Ruby and JavaScript use for a table is the single bracket in Solveig that means something else.

#[ is one token — the [ follows the # immediately, as a digit must, and that 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 this and cannot now mean something it used to. # [ with a space is still refused, and the old complaint offers the alternative:

x := #z.
solas: expected digits after '#' -- or '[' for a dictionary

= pairs a key with its value, and moving @expr’s equality to == to free it was not needed — which was half of the proposal this came from. The token is scanned unconditionally and given meaning by whoever is parsing: inside a region it is still equality, outside one it is still a stray operator, and here it is the literal’s own. That cost one flag on the compiler, saved and restored around the key so #[#["a" = #1] = "x"] nests.

The pairing is the whole reason this is not simply a shorter dictionary:of: alternating elements pair positionally and a reader has to count. The cap is the argument cap seen through pairs — 255 arguments is 127 pairs, and the complaint says pairs because that is what was written.

Both grammars carry the production, and the BNF one needed a lesson the compiler did not. Its key is a sum rather than an expression, or it swallows the = that ends it, comparison being where = lives there — and nothing is lost, since a comparison is only legal inside a region and a region is a primary. check_syntax rejected examples/dictionaries.sol until that and the #[ token rule were both added, which is the grammar files being held to the implementation rather than only to each other.

And converting run and capture was asked again, as the scoping said it should be, and is still refused. The literal takes the conversion’s cost from thirteen characters a call site to two, so the brevity objection is gone; the one that never depended on spelling is not. A dictionary dedupes on the way in, so 'capture' is given "stderr" twice — caught today — would arrive as a single setting and be obeyed. An argument bag is not a degenerate dictionary.

dictionary:of, and a guard against a hazard that was not there — 7eca48c, 2026-08-30

One message, and the language still answers 141 of themof was already a counted name from array, and the counter adds a name only where slotAt raises, so it counts primitives and ignores Solum-defined library methods. Registrations go 243 to 244.

Asked as @dict[key = value, ...], with @expr’s equality moved to == to free the sign. Neither half of that was needed. ideas.md carries the argument: = is scanned unconditionally and given meaning by context — the lexer’s mode flag exists for - alone and says so — and := with == would be half of C and half of Pascal, stranding <>. And [#1, #2, #3] has no opcode of its own: it compiles to a global load and a send of of, and times the same as writing array:of by hand. The array literal is sugar over a message, so a dictionary literal would have been sugar over a message that did not exist. This is that message.

sizes := dictionary:of("small", #1, "large", #9).
describe:value(dictionary:of("count", #7)).

What it buys is the inline form. A dictionary could always be passed; what it could not be was built as an argument, and three statements and a name for a value wanted once is why every options bag here is an array of alternating names instead. An odd count is refused rather than rounded off, a key must be a value for the reason at gives, a repeated key takes the last value as a repeated atPut does, and no arguments is an empty dictionary.

The temporary root came out again, and that is the part worth reading. The first draft rooted the new dictionary, 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. The comment now says why there is no root.

It shipped with no caller, which the entry says rather than hides. 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. It 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 here and is not met. Whether it earns itself is whether the next options bag written reaches for it.

startsWith and endsWith, and a checker that caught itself — a99f802, 2026-08-30

Two methods on string, in text.sol, in Solum. Deferred on 2026-08-29 with one customer and the reasoning that indexOf(x):equals(#1) is what starting with something means, so nothing was approximated and nothing was slower. Counting the tree a day later found both halves of that had stopped being true.

Three programs, nine call sites, and two independent copies of endsWithserver.sol, which found the gap and left a comment saying there was none; expect.sol, with six prefix tests and its own string:endsWith; and plugins.sol, which wrote endsWith again as a local block. Two copies of one function is the trigger replace was built on.

The absence had already cost a defect, which is more than a trigger. expect.sol’s own note records it: asking whether a name contains .md rather than ends with it called draft.md.orig a document and would have handed a.md.sol to the markdown checker.

And nothing is slower was wrong by three orders of magnitude. indexOf stops at the first match, but a search that finds nothing has read the whole string — and finding nothing is the case a prefix test exists for. On 128 KB without the needle, 2000 calls each: 308 µs against 150 ns. A prefix test is O(prefix); the search is O(text). One replaced call site reads a UDP payload, up to the 65536 bytes net.c receives into, at a length the sender chooses.

Seventeen cases checked. An empty affix answers true both ways and an affix longer than the text answers false rather than raising — copyFrom is forgiving about an empty range and a start past the end but refuses an end past it, so the size guard is what makes these total rather than what makes them quick.

Including the library into expect.sol then broke expect.sol, and that is the part worth keeping. It reports integer:slots:size as the number of messages an integer answers and class-and-instance.md states that number; text.sol puts asUtf8 and utf8Tail on integer, so it moved 37 to 39 the moment the include landed, and the checker caught its own contamination on the first run. The rule 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 it, binding an object and adding nothing to a built-in. Reading the number before the includes is the whole of the fix, and the reference now says so.

A cursor that is never inside a character — 6184995, 2026-08-30

edit.sol was writing half a code point to disk. $ went to the last byte of a line, so $x on a line holding café left caf and the lead byte of the é in the file. The cursor was drawn a column to the right of the character it sat on, and dw stopped in the middle of café because a byte above 127 was punctuation to its word test.

The editor’s own notes had the shape of the answer from its first day — a tab is one byte and eight columns, and everything that positions a cursor holds both numbers at once. An é is two bytes and one column: the same sentence with the numbers the other way round, and four days of building never asked it.

Found by writing a decision down rather than by using the editor. ideas.md now carries what a string is — bytes, code points, or bytes with a contract — which three documents had pointed at and none had owned. The entry needed a program that had actually been hurt by the byte model, went looking for one, and the first place it looked was already broken.

The fix is not a Unicode library. isTail asks whether a byte is 10xxxxxx; charSize, charAt and widthOf are four lines each on top of it. The rest was replacing every add(#1) that meant the next character with one that means it — seventeen definitions. Two 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, true for every command at once — $ included, which is where the corruption was
operateChars the single add turning an inclusive motion’s last character into a range: d$, de, dfx and x answered together

Insert mode is exempt from that rule, and has to be. A character outside ASCII arrives from readKey one byte at a time, so the column must be free to stand between them while it is being typed. The invariant is about normal mode and not about the buffer — which is the shape a second string type could not have had.

A file that is not UTF-8 still opens, which decided the shape rather than falling out of it. isTail asks what a byte is instead of trusting a lead byte’s declared length, so a byte that cannot start a character is one character by itself: x takes exactly it, and every byte the editor was not asked to change is written back untouched. An editor that refused such a file, or quietly rewrote the byte, would be the worse answer.

Sixteen sessions were added to checks.sol, eleven of which fail on the editor as it stood. All 181 pass now, and session.out — every byte the editor draws, sideways-scrolling tab line included — is byte-identical, which is what says the ASCII path did not move.

Nothing in the language changed. A string is still bytes, size still counts them, "café":size is still 5, and there is still no decoder in text.sol — the fix never wanted one. That is the entry’s recommendation arriving as evidence rather than as an argument: the program that finally asked for Unicode asked for it locally, and answered itself with three sends the language already had.

The estimate in that entry was wrong by a factor of five, which is recorded beside it. Nine lines in expand became seventeen definitions: expand 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.

0.39.0 — 2026-08-30

Measured against another implementation, for the first time. The language answers 141 messages, unchanged, and .sob files are format version 14, unchanged. Nothing here is a language change; what grew is the speed and what shrank is the export table.

Every number this project had ever quoted was Solveig against an earlier Solveig, which says whether a change helped and nothing about where the whole thing stands. Nine matched programs against CPython 3.14 — each pair checked to print the same answer before either was timed — put it level, geometric mean 1.09. It is 0.885 now, ahead on five of the nine, and none of the difference is a rewrite.

Three defects, and every one had been invisible from the inside. A heap object allocated for every character read, and again for the literal it was compared against (4.4). A hash lookup for every read and every write of a global, which is every top-level script and every line typed at the prompt (4.5). And a three-branch function that no compiler could inline because it sat in a different translation unit from its only caller.

Two textbook optimisations were built and refused, which is worth as much as the three that landed. An inline cache at the send site, proposed as most of the recursion gap, profiles at 9.7% of it. Computed-goto dispatch — the standard answer, and this repository’s own roadmap put it at 10–20% — is slower than the switch on every benchmark, because clang tail-merges the twenty-one dispatch sites back into one and the extra code size stays. ideas.md carries the disassembly.

The extension ABI is declared rather than inferred, and it is the one thing to read before upgrading. bin/solvm exported 146 sol_* functions where extend.h named 23; the surplus was the parser, the lexer, the REPL’s line editor and the bytecode reader. It exports 29: the 23, plus six promoted on review, three of them closing a gap extensions.md had recorded and left for somebody to decide. SOL_API marks each export at its declaration and everything else is compiled -fvisibility=hidden (4.6).

SOL_EXTENSION_ABI stays 1. Nothing named in the header changed shape or meaning and six calls were added, so a bundle built against 0.38.0 loads and runs unchanged — checked by loading 0.38.0’s net.so on this build rather than asserted. A bundle that reached past the documented surface will fail at dlopen, which is the change doing its job; bumping the number would not improve that message, since the ABI check runs after the load it never reaches.

Compatibility is the clean case. All 35 examples compile byte-identically under 0.38.0’s compiler and this one, and every one gives the same answer on both machines in both directions — the single exception being examples/system.sol, which prints how long things took.

Also in it: comparisons/ holds the programs that took the measurements, so the figures can be re-run rather than believed; docs/performance.md is the account of what measuring found; and extend.h stopped saying two different things about its own surface.

The extension ABI is declared rather than inferred — a711b1d, 2026-08-30

bin/solvm exported 146 sol_* functions where extend.h named 23 and the bundles here used

  1. It exports 29 now — the 23, plus six promoted on review — and an extension can no longer bind to the parser, the lexer, the REPL’s line editor or the bytecode reader.

The surface was inferred, not chosen. Whole-archive linking had made the four binaries agree on one set; the rule producing it was still “everything in libsol.a with external linkage”. So marking an internal function static could break a third-party extension with no error naming the cause — which 4.5 came within one hand-check of doing.

SOL_API marks each export at its declaration and everything else is compiled -fvisibility=hidden. At the declaration rather than in a list beside the linker, because a list there goes stale and the Makefile says so in three other places. Not on the bundle rules: sol_extension_init is the bundle’s symbol, and hiding it would break every extension source in the world.

Six were promoted, and one group closes a gap extensions.md had already recorded: sol_dict_new, sol_dict_put and sol_dict_get, the language’s own shape for an answer with fields, which net had to work around by answering an object. With them sol_type_name — rule 1 asks a primitive to say what it was given and the function for saying it was withheld — and sol_value_equals and sol_vm_class_of.

Both directions are tested and both were broken on purpose to check. test_the_promised_surface_is_exported carried a comment admitting it had never caught anything and is load-bearing now; test_the_surface_stops_where_it_says is new and had never been possible.

This is the first half of the exported symbol surface. It unblocks -flto, worth 5–29%, which stays deferred on its own costs. The case is 4.6.

A global is remembered, and the receiver check is inlined — 50242c1, 2026-08-30

Two changes to the dispatch loop, both found by profiling a real program — basic.sol interpreting 39,000 BASIC statements — rather than a loop written to be timed.

   
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  
against CPython 3.14, geometric mean 1.02 → 0.885

sol_slot_accepts was a call across a translation unit for three predictable branches, on the hot path of every send. It is a static inline in object.h now, worth 1.4% to 6.5%. One symbol leaves the export table, 147 to 146, and it is in neither extend.h’s surface nor extensions.md.

A global was a hash lookup on every read and every write. The same loop with its counter as a block temporary instead is 1.255× faster, and a chunk now remembers the slot each of its names resolved to — beside the interned name, and emptied by the same interned_for rule, because a slot pointer belongs to one machine’s root as surely as an interned name belongs to its name table. Sound because nothing removes a slot, which 3.10 records as a problem.

Together they were slower than either alone, on the one benchmark that uses no globals: deep recursion lost 8.5% to instruction cache, having gained nothing to pay for it. Moving the two slow paths out of the switch fixed that and improved everything else — fib 0.922 to 1.003, loop 1.251 to 1.284.

The case is 4.5; the two candidates still open are in ideas.md.

One string per byte value — ffd348e, 2026-08-29

string:at answers a one-character string, there being no character type, so walking a string used to allocate a cell per character. The machine now keeps one string for each of the 256 byte values and answers it.

  before after
9.0M characters scanned and compared 1.371 s 0.758 s
the same against CPython 3.14, startup removed 2.13× 1.22×

The literal was half the cost, which is the part that was not expected. "o" in the loop’s condition is built fresh by OP_STRING on every evaluation, so a scan comparing each character to a constant made two strings a pass. The test sits in sol_string_new rather than at string:at, so every way of making a one-byte string gets it — at, asCharacter, copyFrom(#i, #i), a split that yields one character, an extension, and the literal.

Strong where the symbol table is weak, and the count is the reason. Symbols are unbounded, so a table holding them would be a leak; there are 256 byte values and never more, so the whole of this one is about six kilobytes and holding it is what makes the second read free. That is why this needed none of the weak-table machinery 1.3 said interning would need — and why interning every literal is still open.

Filled on first use rather than at startup, because 3.10 makes VM construction a third of a request: measured on hello-world, building a machine is unmoved. Join/split, dictionaries and object allocation are unmoved too, four ways.

The case is 4.4, and it came from measuring against another language, which nothing here had done before.

make dist writes into dist/f85a751, 2026-08-29

Four tarballs had accumulated at the repository root, one per release since 0.35.0, because that is where the rule had always written them. They are in dist/ now, ignored as a directory as well as by the *.tar.gz pattern that still catches anything dropped beside the tree.

make clean does not take dist/, and the Makefile says why where the rule is: a tarball is a release artefact rather than an intermediate one, and cleaning before a rebuild should not delete the thing you were about to publish.

It broke CI, which is the part worth recording.github/workflows/build.yml built the tarball and then looked for it at the root. Fixed in 6c11a10 by taking the path from the rule that writes it, since make dist already echoes it. Both the local suite and the document checker passed on the change that broke it: a change to where a build artefact goes is a change to every consumer of it, and the consumers are not all in the tree.

solid --exports: what a file puts into the machine — ca66078, 2026-08-29

A .sob or a .so, and what may be sent to what it bound:

$ solid --exports lib/json.sob
lib/json.sob
  json
    read                 takes 1 argument
    write                takes 1 argument
    quote                takes 1 argument
    keyText              takes 1 argument
    -- and 19 behind an `exports` boundary; `--exports=all` for those too

6.38 carries the case, including why the static reading was tried and dropped.

Nothing here is new capability — slots, exports and respondsTo have answered this since the export boundary landed. What was missing is that you had to already know the name to ask, and the one question a program cannot ask itself is what the names are: the globals are slots on an object with no name in the language (ROADMAP 2.10). So this could not have been a program in programs/ beside disasm.sol, however much it belongs there. Solid holds the root object, which is the whole reason the mode lives in the debugger and not in a fifth binary.

It reads a .so the same way, and that case has no other answer at all. An extension’s surface is not written down anywhere; it exists only once sol_extension_init has run. With one named there need be no file to give, and naming both a bundle and a file gives two reports rather than one heap.

It runs the file rather than reading its bytecode, and a throwaway is what settled that. The static reading — collect every OP_SET_GLOBAL — prints nothing for lib/text.sob, which binds no name at all and hangs asUtf8 on integer. Every built-in class is measured before the run and again after, so a library that only extends one is not invisible. The cost is that the file runs, with whatever else it does on the way; --exports says so, and a file that fails part-way reports what it had bound by then and leaves with status 70.

The hazard that was closed rather than survived. The report holds an object across the run, and a file may rebind the name an extension bound — leaving what was recorded pointing at memory the collector has taken. The name is looked up again and the two pointers compared before either is read, which is a comparison and never a dereference. There is a test for it, and it is one of the nine in test_solid.c that run under the sanitisers.

0.38.0 — 2026-08-29

Two things that add no messages. The language answers 141 messages, unchanged, and .sob files are format version 14, unchanged. What grew is a notation and a bundle, and neither is the machine.

@expr{...} is the infix region over a block rather than a group — (a group) runs now and {a block} is code held as a value, so @expr{ i < #5 } answers a block whose body reads infix and goes where a block goes. It emits exactly what { @expr(...) } emits, jumps included.

extensions/net is UDP sockets as a loadable bundle: five messages, the first extension to ship inside this repository, and a client and server that hold a counter between them. solvm has no networking until a host says --extension=, which is the whole arrangement — the capability exists and granting it is a decision somebody takes on a command line.

Compatibility, and this time it is the clean case. Bytecode from 0.37.0 runs here and this release’s runs on 0.37.0 — and a program using the new notation compiles here and its bytecode runs there unchanged, because the notation is not semantics: @expr{...} produces the bytes the block form always produced. The old compiler refuses the new source, which is the language having grown; nothing about the machine changed to receive it. That is the difference between a notation and a message, and 0.37.0’s replace was the other side of it.

A program that uses net of course needs the bundle, and says undefined name ‘net’ at the first line that names it if it was not given one.

Two documents were corrected against the code rather than reread. The networking entry in ideas.md rested on there is no socket anywhere in this repository, which extensions had made false and which took its conclusion with it. And ~ binding looser than a comparison was on the record as the call BASIC and Pascal make: BASIC yes, Pascal no — programs/check_syntax/pascal.bnf has "not" factor, the tightest level there is, and had been in the tree since the day that grammar landed.

extensions/net, and two programs that talk — b524f83, 2026-08-29

UDP sockets, as an extension and not as a machine. Five messages, built by make into build/extensions/net.so and loaded by nobody unless a host asks:

solvm --extension=build/extensions/net.so server.sob 7777
   
net:udp(#port) a bound socket; #0 asks the system for a free port
net:port(socket) the port it actually got
net:send(socket, "127.0.0.1", #port, text) bytes written
net:receive(socket) the packet waiting, or nil; never waits
net:waitFor(socket, #ms) true if one arrived inside the timeout

No message was added to the language, .sob files are format version 14, and the VM is the size it was — which is the point. A socket built in is a capability every script gets whether or not the host meant to grant it; a bundle is one a host names on a command line and can decline to name.

It ships inside this repository where GTK and SDL2 may not, and the difference is the front page’s sentence rather than a policy about extensions: a bundle needing a toolkit installed would make no dependencies beyond a C11 compiler and make false, and sockets need POSIX, which every dlopen and fork here already assumes.

The programs decided two things no argument had. A packet has to say who sent it — the socket in the extension probe read with recv, so the first client and server written against it could not answer each other and 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, for a reason larger than the obvious one. A blocking read stops the only thread there is — and 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 have quietly suspended 6.33.

One root, and it is proved rather than assumed. packet_new allocates three cells and roots the object, because sol_string_new collects. Take the root out and the suite fails under SOLUM_GC_STRESS=1 with object does not understand ‘notNil’ — an object swept between being made and being filled. The strings need no root, checked the same way: sol_object_define takes its slot from malloc and interns its name in a permanent table, so nothing between a string and its slot can collect.

Two smaller findings are on the record rather than worked around. The extension surface has no way to build a dictionary, which is the language’s own convention for an answer with fields, so a packet is an object with host, port and text. And sol_foreign_handle answers NULL for a released cell, which cannot be told from a handle that is itself NULL — so a descriptor is stored as fd + 1, because descriptor 0 is a real descriptor.

The reference is NET.md — every message with its arguments, its answer and what it refuses, the packet’s three slots, the shape a program using it takes, and the example’s protocol. Its blocks are transcripts rather than checked claims, because the checker runs Solum and these need a bundle loaded; what stands behind them is test_the_net_extension_carries_a_datagram, which runs a real round trip through the real binary under SOLUM_GC_STRESS=1, and every refusal quoted there having been produced by running it.

No TCP, no IPv6, and no name resolution, each because no program has asked.

@expr{...}, a region that is a block — f6f7026, 2026-08-29

A region opens with either delimiter now. @expr(...) answers what its expression comes to, and @expr{...} answers a block whose body reads infix — which is the language’s own (group) and {block} pair applied to the region rather than a rule of the region’s own. No message was added and .sob files are format version 14, unchanged.

i := #0. total := #0.
@expr{ i < #5 }:whileTrue(@expr{ i := i + #1. total := total + i }).
total:print.                                   ; #15

Three programs asked for it without meaning to. Every use of @expr in this repository outside examples/operators.sol was inside a block with the marker pushed in — tick.sol, game.sol and both.sol, all written the day the notation shipped, for reasons that had nothing to do with it. Wrapping the whole send was available to all three and taken by none.

And wrapping is not merely longer, it is wider. A region is lexical, so @expr( gtk:every(#5, {...}) ) reads the same — while putting the receiver and every other argument inside a mode that changes what - means. The block form makes the region exactly the block. That was the argument a rewrite of those three call sites settled: the wrap makes a reader hold an open region across a send and its argument list, and closes on ) ).

The hard part was not the one the idea named. Handing the mode back at the closing brace is one value threaded through block_bodythe mode that should hold once the block is closed, which is the mode already in force for every block but this one. What was missed is that { ... }:whileTrue({ ... }) written literally compiles to jumps, and the probes deciding that compared against TOK_LBRACE: so the first working version parsed, ran, answered correctly, and quietly emitted a real send with two blocks in it. Twenty-nine bytes against fifty, and a frame per pass.

It was caught by the one test that compares bytes rather than answers, which exists because the notation’s claim is that the bytes are the chain’s bytes. A notation that stops inlining is a second semantics, whatever it answers. The fix reads a block in either spelling — and sets the probe’s mode while doing it, because scanning @expr{ x - 1 } under the file’s mode gives ’-‘ must be followed by digits, an error token, which reads as not inlinable. The region would have cost the jumps exactly where its body used the operator that makes a region worth having.

0.37.0 — 2026-08-28

One message, asked for by a program. The language answers 141 messages, up from 140, and .sob files are format version 14, unchanged.

"a-b-c":replace("-", "+").        ; "a+b+c"
"a,b,c":replace(",", "").         ; "abc" -- an empty replacement deletes
"aaa":replace("aa", "b").         ; "ba"  -- forward, and non-overlapping

It replaces every occurrence, because the idiom it replaces is split then join and that pair replaces all of them. A replace that did only the first would not be shorter than what programs were already writing — it would mean something different, and tidying an old program up would quietly change what it did.

Compatibility, stated exactly, because this release is the first in a while where the two halves differ. Bytecode from 0.36.0 runs here and this release’s runs on 0.36.0 — checked by building the old release from its tag, where the two compilers emit byte-identical files. But a program that uses the new message needs a machine that has it, and says so rather than misbehaving:

solvm: string does not understand 'replace'

That is the format being compatible and the language having grown, which are different things and are worth not conflating.

Where it came from is the point. Porting Solveig’s 1,766-line editor to solveig-gtk wanted it three times in one line, to escape &, < and > for markup — and it is the only thing that port asked the language for. The workaround was exact, so the port shipped without it and the absence was written down instead. That is the rule every other entry here was decided by, running for once from a program to the language rather than from this page to the compiler.

string:replace, which a program asked for — 06c775a, 2026-08-28

141 messages, up from 140, and the first one added since the extension work. .sob files are format version 14, unchanged.

"a-b-c":replace("-", "+").         ; "a+b+c"
"a,b,c":replace(",", "").          ; "abc" -- an empty replacement deletes

It replaces every occurrence, and that is not a taste. The idiom it replaces is split then join, which replaces all of them — so a replace that did only the first would not be shorter than the thing programs were already writing, it would mean something different, and tidying an old program up would quietly change what it did. A first-only replace is indexOf and two copyFroms, which is what wanting it looks like and is rare enough not to name.

Forward and non-overlapping, so "aaa":replace("aa", "b") is "ba". An empty needle is refused the way split and indexOf refuse one. A receiver with nothing to replace is the answer, allocating nothing, which a string can do because it is immutable.

Asked for by a program rather than by this page, which is the rule every other entry here was decided by. Porting Solveig’s editor to solveig-gtk wanted it three times in one line, to escape &, < and > for markup. The workaround was exact — split then join is what a replace does — so the port shipped without it and this was written down instead of worked around silently. It is the only thing that port asked the language for.

0.36.0 — 2026-08-28

A notation that adds no messages, and an interface that adds no dependencies. The language answers 140 messages, unchanged across the whole release, and .sob files are format version 14. Bytecode from 0.35.0 runs here and this release’s runs on 0.35.0 — checked by building the old release from its tag and running each compiler’s output on the other machine, where the two compilers turned out to emit byte-identical files.

@expr: infix arithmetic, and not a second mechanism

@expr(a^2 + b/2) is arithmetic, comparison and logic written the way the notation is read, inside a region that has to be asked for. Every operator lowers to the send it already read as, so the bytes are the chain’s and the language gained nothing: a + b is a:add(b), and a program that overrides add is honoured by both spellings.

~ binds looser than a comparison, which is the reading the words have and the one BASIC and Pascal take; comparisons do not chain, which is the shape of the grammar rule rather than a check; and & and | are the only operators whose right-hand side is not compiled where it stands, because and and or take a block in order to stop early.

The region was called @math for a few hours. The name went wrong the moment the scope grew past arithmetic, and it was changed the same day — the cheap moment to rename a thing is the moment you notice it is misnamed.

Extensions: a capability from a C binary

solvm --extension=gtk.so program.sob

A C file compiled on its own hangs a global off the machine’s root, and its primitives are primitives: same slot, same dispatch, same speed, found by respondsTo and listed by slots. extend.h is the contract, docs/extensions.md is the prose, and tests/test_extension.c holds it.

Loading is a decision belonging to whoever starts the program. There is no message that loads an extension and no @link directive: native code runs past --steps and --memory, so a capability a script could invoke is one a host could not withhold. solvm, solis and solid all take the flag — every front end that runs a program — and Solas pointedly does not, since a compiler that loaded native code would put the requirement into the .sob.

A resource an extension owns is a value the collector gives back. A socket, a window, a connection: release runs from the sweep when the program lets go, and at teardown for whatever is still held — so a program a limit took away mid-flight still has its sockets closed, which an explicit close could never promise. There is no close message for that reason. This is the first thing in the language that has a release, and design.md’s “nothing has to be released” is sharpened rather than falsified: a resource has one, and it was never the program’s to run.

And a value foreign code holds is kept alive on request. sol_extension_retain answers a token rather than the value, because a released token says so where a stale value answers a plausible wrong block.

foreign joins the reserved names as a class object, so a program handed a handle can ask isKindOf(foreign). It publishes no new selector — 140 messages, across 242 registrations rather than 236 — a number the suite holds against builtins.c and REFERENCE.md, so it cannot go stale here.

Two bundles, out of tree

solveig-gtk and solveig-sdl, each in its own repository and built by nothing here — which is why no dependencies beyond a C11 compiler and make is still true and still checked on three platforms.

The second one is the check on the first. It needed no change to the mechanism, and it is deliberately unlike it: GTK owns the loop and calls into the program, SDL hands the program a frame and gets out of the way. So sdl has no callbacks and uses none of the retain registry — which is the evidence for a decision taken when there was only one back end to argue from, that the registry is a service an extension may use rather than the shape an extension takes.

The language is called Solveig

Solum moved down a layer rather than away: it names the machine, its bytecode, and the ground a program is finally laid on — which is what solvm had been saying all along, SOLVM being how solum was written before the alphabet split V into two letters. Nothing in the source tree is renamed, and solum/, SOLUM_VERSION, .sob and every sol_* call are more accurate for it.

Documents that record history keep the old name, because they record what was true when they were written.

Also

A mark: one disc parted, sól above the line and solum below, replacing a placeholder emoji favicon. float gained the trigonometry and sqrt reached @expr as sin(x). The changelog’s own hashes are checked now (3.21), which is what caught a literal %s that had been sitting where a hash belonged since 2026-08-26. 18k lines of C11.

A second back end, and the one name it found missing — 3214691, 2026-08-28

solveig-sdl, written to find out what solveig-gtk had got away with. It needed no change to the mechanism — same extend.h, same ABI, same loader, same foreign cell — and one addition to the promised surface: sol_symbol_intern, which an extension answering what happened wants immediately, and which was reachable and unpromised.

The interesting result is that the two bundles look nothing alike. GTK owns the loop and calls into the program, so it has gtk:run and gtk:onClick(button, block). SDL hands a program a frame and gets out of the way, so sdl has no run, no callback, and nothing registered anywhere: the loop is an ordinary whileTrue and sdl:poll answers the next event or nil.

That difference is evidence for two decisions taken on argument when there was only one back end to argue from:

decision what the second one showed
The retain registry is a service, not the shape of an extension solveig-sdl uses none of it. Had callbacks been the shape, every file there would be working around the interface.
No back end names itself the general case gtk: and sdl: share no vocabulary, and neither had to pretend to be the other. A Plan 9 draw binding would be shaped like the SDL one.

And footprint earned itself. A screen carries the window’s pixels — a 1024×768 window is about 3MB — so --memory=2M stops a program that opens one:

solvm: stopped: the memory limit of 2097152 bytes was reached, with 3179480 live

An extension declaring nothing would have let it open a thousand. That field was added on reasoning during the foreign-cell work and this is the first time it has mattered to anything real.

sol_symbol_intern joins the list in extend.h, docs/extensions.md and test_extension.c. A dictionary is still deliberately unpromised: sol_dict_new exists, nothing has needed keys built at run time, and promising an interface before something has used it is how the accidental surface happened the first time.

Keeping a value alive while foreign code holds it — 36f199b, 2026-08-28

sol_extension_retain, sol_extension_retained, sol_extension_release. The other half of the collector’s rule: sol_gc_push_temp covers a window inside one primitive, and a toolkit holding a callback holds it between calls, where nothing the tracer walks can see it. Three calls and one line in mark_roots. No language change, and nothing a program can see.

The API hands back a token, not the value, and that is the whole design. The collector does not move cells, so a retained SolValue would stay valid — but a token that has been released answers false, where a stale value answers a plausible wrong block. A token carries its slot’s generation as well as its index, so one outliving its slot is detected rather than resolving to whatever was retained into that slot next. Without that, this registry would have reproduced the exact failure it exists to end, one layer up.

That failure is worth restating, because it is what the shape is chosen against. Measured before any of this, with a GTK timer and collection on every allocation:

#1
probe: callback failed: 'block' takes 1 argument, got 0

An arity complaint about a block the program never registered anywhere. Not a crash, and nothing in it pointing at the collector.

One bug of its own, in the least likely place. A slot’s next_free meant both in use and end of the free list — both -1 — so releasing into an empty free list marked the slot live again and a second release answered true, putting it on the list twice. Two states in one field, found by test_releasing_twice_is_not_an_error, which is exactly the sort of case that looks too obvious to write.

Not reference counted: two retains give two tokens, each released on its own, and retaining twice while releasing once leaves the value rooted — the safe direction. Everything still retained is released with the VM.

Both probe extensions are rewritten onto it. probe_ext_gtk.c loses the #ifdef PROBE_ROOTED it was built around, and ext_sdl.c loses the array it hung on its own global; between them those were the four lines every extension with a callback was going to have to write, and they are one call now.

A resource an extension owns, and the promise that it comes back — 2308bde, 2026-08-28

SolForeign: a socket, a window, a connection, a compiled pattern — a value the machine holds for a program and gives back when the program is done with it. Before this an extension had to hand such a thing over as an integer, and all three things wrong with that are gone: nothing closed it when the program was stopped, it was not counted against --memory, and a program could invent one. No new message; the language still answers 140 across 242 registrations, and a value type is not a message.

release runs from free_cell and nowhere else, which is what gets both guarantees out of one line. The sweep calls it when the cell becomes unreachable; sol_gc_free_all calls it for everything at shutdown whatever its reachability. So a socket is closed when the program drops it and when a limit takes the program away mid-flight — the case an explicit close could never cover, since a limit-stop is uncatchable and does not run ensure. That is why there is no close message, and it turns design.md’s “nothing has to be released” into something still true rather than something falsified: a resource has a release, and it was never the program’s to run.

Four sites in the collector had to be named and none of them warns. blacken and cell_size are if-chains that fall through to the SolObject branch, so a foreign cell taken for an object would have had its release pointer walked as a slot list; free_cell would have leaked; check_constants in the serializer would have let one into a .sob. Eight further switches do warn, because none of them has a default — which is the arrangement a swept dictionary earned some releases ago.

And bytes turned out to be the wrong currency, which real sockets found. A foreign cell is forty bytes however scarce the thing it holds, so a program opening descriptors in a loop exhausted the process while the heap was still nearly empty: measured at a 256-descriptor ceiling, it died there with no collection having happened. Foreign cells now carry a pressure count of their own — SOL_GC_FOREIGN_PRESSURE of them forces a collection whatever the byte figure says. The same program opens 5,000 sockets under a ceiling of 256.

An extension does not have to do anything about that, and in particular must not inflate footprint to buy scheduling: footprint is what the resource costs where the machine cannot see it — a texture, a connection’s buffers — and is added to what --memory measures, so a wrong number there makes a limit lie.

kind is how a handle is asked for, with strcmp, so one extension’s socket cannot reach another’s primitive expecting its own; a released one answers nothing rather than a dead pointer. foreign is bound as a global alongside the other class objects so that a program handed one can ask isKindOf(foreign), and foreign:new refuses the way string:new does.

experiment/extension-probe/ext_net.c was rewritten onto it and is the before-and-after: its net:close is gone, and the sockets are closed by the machine instead.

The debugger takes an extension too — 7b3e27b, 2026-08-28

solid --extension= was missing from the entry below, which left the one program most worth stepping through — a graphical one, going wrong — as the one that could not be. It failed on an undefined name at the first line instead.

So the rule is not the front ends: it is every front end that runs a program. solas is excluded deliberately, and that is now written where somebody will read it rather than only reasoned about — a compiler that loaded native code in order to compile a file would put the requirement into the .sob, and every machine that ever ran it would inherit it, including one that only wanted to disassemble it.

Stepping stops inside Solum and never inside an extension, which is C and has no lines to stop on. The usage text says so, because it is the first thing anybody will expect to work.

Extensions: a capability from a C binary, loaded at run time — c0c4b30, 2026-08-28

solvm --extension=probe.so program.sob, and the same flag on solis. A C file compiled on its own hangs a global off the machine’s root, and its primitives are primitives — same slot, same dispatch, same speed, found by respondsTo and listed by slots. The contract is solum/extend.h, the prose is docs/extensions.md, and tests/test_extension.c holds it. No language change; .sob files are format version 14, unchanged.

The build blocker everyone expected was not the one that existed. The argument for this feature had said libsol.a is static, nothing is exported, and a loaded bundle could not resolve sol_* back into solvm at all. Measured, that is wrong twice over: bin/solvm already exported 100 sol_* symbols, because a Mach-O executable exports its globals without being asked, and -Wl,-export_dynamic changed the count not at all.

What actually failed is quieter. A linker takes objects out of an archive on demand, so a symbol reaches the export table only if the executable already referenced one in its object. sol_object_define_primitive was there, because builtins.c uses it. sol_vm_set_global was not, because it lives in embed.c and no front end calls it — and every other function in that file was missing with it. The four binaries exported four different accidental sets: 100, 118, 133 and 118. Whole-archive linking makes all four 139, and 139 is a surface somebody chose rather than one the linker arrived at.

   
macOS -Wl,-force_load
ELF -Wl,--whole-archive, and -rdynamic as well, because an executable there really does export nothing

The test for that is not where it looks like it should be, and the reason is the interesting part. tests/test_extension.c registers its extensions as ordinary functions and cannot check the link at all: it calls sol_vm_set_global on its own account, so it would find that symbol exported however the link had been done. The first draft asserted otherwise and passed against a deliberately broken build. The decisive case is in test_cli.c, which hands a real bundle (tests/ext_probe.c, built by the Makefile) to the real binary — and against the old link fails with symbol not found in flat namespace '_sol_vm_set_global'.

Loading is a decision taken by whoever starts the program. There is no message that loads an extension and no @link directive. Native code runs past --steps and --memory — those bound the machine, and an extension is not the machine — so a capability a script could invoke is one a host could not withhold, which is 6.32 at its worst. A directive would have been worse still: it would put dlopen inside Solas, and the .sob would carry the requirement into every machine that ever ran it.

The ABI is compared for equality and refused, never guessed.sob’s policy exactly, and for the same reason, since SolValue is passed by value and SolObject’s layout is exposed. A refusal binds nothing and leaves the machine as it was. SOL_EXTENSION_ABI is deliberately not SOLUM_VERSION: a release that changes no struct should not invalidate every bundle.

Two doors into one contract. sol_extension_load is dlopen and sol_extension_register is for an extension linked in; extend.h mentions the dynamic linker nowhere. The split is what lets the suite test the contract without building a shared object mid-run, which is fragile under three CI configurations and impossible under a sanitiser.

Four rules an extension must keep, three of them known and one found by building a throwaway GTK bundle first. Arity is not checked for you; failure is out of band; nothing may hold a heap pointer across an allocation unless it is reachable from a root — and check vm->had_error after every call back into the language, because a limit-stop sets it and a callback loop that does not look will keep calling into a machine that has been stopped.

That third rule has a case with teeth, and it is why a throwaway came before the design. A block held as a toolkit’s user_data is reachable from nothing the tracer walks, so a collection between one callback and the next sweeps it and the next call runs whatever now occupies the cell. The observed failure was 'block' takes 1 argument, got 0 — an arity complaint about a block the program never registered. Not a crash, and nothing pointing at the collector.

What is deliberately still missing: there is no value type that can carry a file descriptor or a window handle, and no finalizer of any kind, so an extension hands such a thing back as an integer today. Nothing closes it when a program is stopped, it is not counted against --memory, and a program can invent one. That is the next piece of work, argued in ideas.md.

Comparison, logic, and the region is @expr now — 8ae150e, 2026-08-28

@math is @expr. The region covers = <> < > <= >=, ~, & and | as well, and once it did the old name was describing the first half of its job. 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. The two entries below keep the old name, which is what those commits did.

Every operator still lowers to the send it reads as, and the bytes are still compared rather than the answers:

     
a = b a <> b equals notEquals  
a < b a > b a <= b a >= b lessThan greaterThan lessOrEqual greaterOrEqual and they do not chain
~a a:not looser than a comparison
a & b a:and({ b }) stops early
a \| b a:or({ b }) stops early

Three calls, and all three are visible in the grammar rather than only in the compiler.

~ is looser than a comparison, so ~a = b is ~(a = b) — the reading the words have, and BASIC’s and Pascal’s. C binds ! tightest and would have read the other, which is the one place here a C habit misleads.

Comparison does not chain, and in the grammar that is not a check but the shape of the rule: comparison = sum [ op sum ], an optional tail rather than a repeated one. a < b < c would compare a boolean to c, so it is refused while compiling — comparisons do not chain; the left of this one is a boolean.

| 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, and ( a | b ) is a disjunction because a group’s temporaries have to come first and did not. A rule with a position in it, taken because the position was already load-bearing and the alternative was an asymmetric & with no | beside it.

& 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, the way inline_logical already puts it. a < b & b < a and a:lessThan(b):and({ b:lessThan(a) }) compile identically, short-circuiting included, and a test asserts the right-hand side does not run.

The grammar checker paid for itself twice. It refused the first draft of the operator list — in <operator>, '<' is written before '<=' and would always win; the longer one has to come first — an ordering bug the hand-written lexer never had, because it peeks. And 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. That is the third row of GRAMMAR.md’s list of things refused by the compiler rather than by the page, and it has an assertion of its own rather than a quiet substitution.

examples/infix.sol is examples/operators.sol, and tests/test_math.c is tests/test_expr.c. GRAMMAR.md and solum.bnf agree on 33 productions where they agreed on 29, the reserved-word count is still nought, and nothing in the VM changed: .sob is format version 14 and every one of these emits sends or jumps that already existed.

sin(x) is x:sin, and limiting it was the expensive half — a87b398, 2026-08-28

The prefix form went into @math on the afternoon of the day the region landed, and the argument that decided it is not the one anyone expected.

The morning’s entry had held it back for a reason: f(x) to x:f breaks on float:atan2, which is class-side, and on pow, which takes an argument. The proposal that came back was to scope it to float and keep it safe. Scoping it is what would have cost something. 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 there are no reserved words at all stops being true. That is a claim the suite checks, test_cli asserting the report carries no such line. The general rule costs nothing there, identifier not being a word.

And once the form is unary the objection dissolves rather than being worked around. Both broken cases are two-argument. A prefix form that takes exactly one has no two-argument form for them to break: float:atan2(y, x) is written out as the class-side send it is, ^ covers pow, and a second argument is refused with the prefix form takes one argument; write ‘a:name(b)’ for a send that takes more rather than guessed at.

So the rule is one sentence with no exceptions — prefix application is a send to its argument — for any name, one argument, inside a region. Which makes the line that started all of this writable as it was first written:

@math( a^2 + 3 * (sin(a/2) + sqrt(b)) )

The one thing a reader has to be told is that it is a send and not a block call. A global holding a block is called with value, so f(3) is 3:f, and f := { x | x:mul(x) }. @math( f(3) ) answers float does not understand ‘f’. It fails loudly rather than quietly doing the other thing, and that is the trade taken rather than a corner nobody looked at.

One production in each grammar file, call = identifier "(" expression ")", tried before primary since both open with an identifier. GRAMMAR.md and solum.bnf agree on 29 productions, and the reserved-word count is still nought. Nothing in the VM changed, again: sin(x) emits the bytes x:sin already emitted, and tests/test_math.c compares them as bytes.

@math: infix arithmetic, and the bytes are the chain’s — 8834514, 2026-08-28

The language has one notation for arithmetic now, and it adds nothing to the language. @math( a^2 + 3 * ((a/2):sin + b:sqrt) ) compiles to the bytes a:pow(2.0):add(3:mul(a:div(2.0):sin:add(b:sqrt))) compiles to — not the same answer, the same bytecode, which a test compares over eighteen pairs. It is the rule [#1, #2] already lived under: two spellings of the same thing mean the same thing.

What it is for is a formula being transcribed. A send chain reads strictly left to right and precedence does not, so the outermost operation of a nested formula ends up in the middle of the line and cannot be checked against the page it was copied from. Everyday arithmetic was never the problem — stddev in bench.sol reads perfectly by naming its parts, and the entry written this morning says so before it says anything else.

+ - * / lower to add sub mul div, ^ to pow. ^ groups to the right and binds tighter than the minus in front of it, so -2^2 is -(2^2) and 2^3^2 is 2^(3^2) — the two calls SolaBasic’s own ladder made first.

A term is an ordinary expression, which is why there is no sin(x) form: (a/2):sin needs no rule, and the rule sin(x) would need breaks on float:atan2, which is class-side, and on pow, which takes an argument. That was recommended out in the scoping and stayed out.

@math is the first directive that is an expression. @include is a statement because a file compiled in has nowhere to go inside one; this has nowhere else to be. It may stand as a receiver, an argument, an array element or a statement of its own.

- is the only character that had to be told where it is. Outside a region a leading - belongs to the number — which is what left the language with no negation operator to mistake it for — and a - 3 is the lexical error ’-‘ must be followed by digits. So the mode changes the meaning of nothing that was legal. Inside, - is always the operator, and -3 is the operator applied to 3, folded back to the one constant: @math( -3 ) and -3 compile to identical files. The fold needs one token of lookahead, because in -2^2 the literal is not what is being negated, and scanning a copy of the lexer to settle that before a byte is written is what inlinable_arguments already does. The other four operators were unexpected character before today.

The grammar found the finding the scoping missed. A region is lexical — it covers an argument, an array element, a group and a block body alike — and a ladder reached only from inside a math production cannot say that without duplicating the whole expression grammar, eight productions on a page whose virtue is being short. Written once at the top of expression it takes five, and GRAMMAR.md and solum.bnf now agree on 28 productions where they agreed on 23. float lost the leading - it used to claim, a lexical grammar having no regions to be inside of, and the grammar now admits a + 2 outside a region where the compiler refuses it — the third row in GRAMMAR.md’s list of things refused by the compiler rather than by the page.

Nothing in the VM changed. No opcode, no message, no new root for the collector to know about: .sob files are format version 14 and bytecode from 0.35.0 still runs, because the region emits sends that already existed. The whole of it is in the front end, and tests/test_math.c is 8 cases over the claim that this is notation only.

The changelog’s own hashes are checked now — d572543, 2026-08-28

ROADMAP 3.21, closed the day after it was raised. Every entry above names the commit it landed in, and a commit cannot carry its own hash — so an entry goes in saying pending and a follow-up commit substitutes the real one. Nothing asked whether the substitution had worked, and once it had not: the PRINT USING entry of 2026-08-26 carried a literal %s where its hash belonged, through every make test for two days, and was found by a person reading the page while cutting 0.35.0.

expect.sol reads the headings now. Everything backticked after a heading’s last em dash is a commit, and must be seven hexadecimal characters or the literal pending. The last em dash because a title may contain one of its own; everything backticked rather than the first token because two entries are not shaped like the rest — one names two commits joined by and, one names a commit and no date — and a narrower rule would have been a rule those two had to be rewritten to satisfy. The run says how many entries name a commit and how many headings name none, and reports a pending still outstanding, which is the state a release cut had been looking for by eye.

The stronger version was declined, as the entry said it would be. Asking git whether the hash names a real commit catches a well-formed hash that is simply wrong, and would couple this checker to a repository: it reads files and runs programs today, and a tarball with no .git in it checks clean. The failure that actually happened is caught without giving that up.

Five headings name no commit and are sections inside an entry, which is indistinguishable from an entry that lost its em dash and everything after it. That is the guard’s blind spot, so the number is reported rather than passed over — and test_cli carries a floor beside the ones for claims, counts, positions, SolaBasic blocks and grammar productions, so a guard that quietly stops finding hashes fails too.

The changelog’s fenced blocks are still skipped, and for the same reason as ever: they record what was true at each release. Its headings are a claim about now.

0.35.0 — 2026-08-28

Two languages and a module system, and the machine needed one new message for all of it. .sob files are format version 14, unchanged, and bytecode from 0.34.0 still runs — checked both ways round, old compiler to new machine and new compiler to old.

programs/pascal.sol compiles ISO 7185 Pascal to .sob, in eight stages, against fpc -Miso as an oracle installed before the lexer existed. Nested procedures, sets, records, pointers, file of T’s absence argued rather than assumed — and the machine needed nothing added: not one instruction, not one message, not one roadmap entry. OP_OUTER turned out to be a static link and a capturing block turned out to be Pascal’s own scoping rule, both written down as predictions before the stage that settled them. A recursive Pascal function reaches exactly 254 levels, which is the machine’s own limit and proves a Pascal call costs one frame and nothing more.

programs/check_syntax.sol is told the syntax rather than knowing it — a grammar in Wirth EBNF and a file, and it says where the file stops matching. It is a stack machine, so there is no depth limit; against the recursive matcher it reached 19 levels and now reaches thousands. Solum’s own grammar is written down in solum.bnf, and GRAMMAR.md is held against it production by production.

One .sob can load another. system:load is @include’s run-time twin: the same flat namespace, but a message rather than a directive, so the file can be chosen while running. It is once-only, keyed by realpath as @include is. Nothing in the VM needed a new memory model — every chunk already carried its own names, constants and slots, and a frame already recorded which chunk it belonged to. Only the globals were ever shared, and that sharing is the mechanism.

And an object can now say what it publishes. exports draws a line: from outside, an object that has drawn one is its export list, and a name off the list can be neither sent nor bound. Inside, nothing changes. It is opt-in, inherited by whatever an object makes, and five shipped libraries now use it — shell deliberately does not, having four slots of which all four are the API. Drawing the lines found two names public in fact rather than on purpose, and one library reaching into another’s internals to reimplement a message that already existed.

Three of a module system’s four jobs are done, and the fourth — declared dependencies — is refused in writing rather than left undone: @include "json.sol". already is a declaration, and ordering and cycles are settled by once-only loading without one.

Four more libraries say what they publish — 89c2c9a, 2026-08-27

3.20 closed the day it opened, and the answer for one of the five was don’t. scan, pattern, sob and html now draw export boundaries — html twice, since it binds both a parser and the node prototype a read answers. shell does not: it has four slots, all four are the API, and a line listing everything hides nothing.

The semantics had to change first, and finding that out was the point. As shipped, a boundary was per-object — and scan and pattern are prototypes, so hiding scan:src would have hidden the prototype’s default and left every actual cursor’s src public. That is the half that matters: every piece of state a program holds lives on an object made from a prototype. Boundaries are inherited now, so an object under one is its export list however it got there.

That needed a second rule to stay usable: a method on a prototype may reach into an object made from it, which is what a constructor is — scan:on runs with scan as its self and has to fill in a cursor that is not itself yet. Only downward; reaching up into a prototype by name is still refused.

One library was reaching into another’s internals. html sliced a cursor’s own text with self:cur:src:copyFrom(start, self:cur:pos:sub(#1)), where scan:since(start) says the same thing and had existed the whole time. Nothing had stopped it, so nothing had noticed. The boundary paid for itself there — not by preventing a bug, but by finding an API that had been bypassed and reimplemented.

And two more names were public in fact. html:element publishes add and at for the reason json:quote is published: the parser builds the tree from outside an element, because the factory is a method on html while a node delegates to html:element.

Cost. The assignment path settles self:x := ... with two comparisons before consulting anything, that being nearly every assignment a method makes. A send-only loop differs from the previous build by about 2% in one ordering and is indistinguishable in the other; a real program is indistinguishable.

Where the export boundary begins — 8bfd1a4, 2026-08-27

A question worth a section: does exports apply to @include too? It does, and the reference now says why rather than leaving it to be inferred. The boundary belongs to the object, not to how the object arrived — @include and system:load are two ways of getting a library into your globals, and once it is there the line is the same one, because what decides the question is self and neither mechanism touches that.

The consequence is the part that was never written down. The file that draws the line is outside it too, from the next statement on: a method written above exports goes on reaching what it kept, because it runs with the object as its self whenever it is called, while the top level below has no self and never did.

So exports goes last in a library. lib/json.sol builds its escape tables with json:escapes:atPut(...) at its top level, and those are outside sends — they work only because the boundary is not drawn until the file’s last line. Drawn first, a library would lock itself out of its own construction. That was true when exports shipped this evening and nothing said it.

A loaded name may be worked out while running — 08d5d1a, 2026-08-27

examples/plugins.sol runs code it never names. It looks in a directory, finds the compiled modules there, loads each and uses it — which is the difference between the two mechanisms that outlives all the others. @include needs a literal string, because the file is found while the includer is being compiled and a name holding one has no value yet. system:load is a message and takes an expression, so the file can be chosen from a configuration, from system:arguments, or by looking.

The two modules it loads draw export boundaries, which is the case for having one: reaching into a module whose name nobody wrote down is exactly what you would rather not be able to do.

And it fixes a bug this morning shipped. examples/load.sol loads examples/library.sob, bytecode is a build artefact and is not committed, and nothing built it — so the example worked only on a machine where somebody had compiled the library by hand, which is to say only on mine. On a fresh clone it failed. The Makefile now compiles every example to bytecode, wildcarded rather than listed for the reason the install rule already gives, and make test passes with examples/*.sob deleted first — which is how this was confirmed rather than assumed.

load.sol’s own header said solas examples/load.sol && solvm examples/load.sob, which was never enough on its own. It says make now, and says why: compiling the file that loads does not compile the file it loads.

exports: an object decides what it shows — b32d990, 2026-08-27

Three of the four jobs a module system does are now done. An object with slots was already a namespace; what it had no way to say was which of those slots are anybody else’s business. lib/json.sol binds one global and hangs two dozen slots on it, and json:digits := "abc" from outside broke the parser — the failure ideas.md had named for years as the half worth having.

counter:exports(['bump, 'total]).

One rule: from outside, an object that has drawn a boundary is its export list. A name off the list can be neither sent nor bound. From inside — a frame running with that object as its self — nothing changes at all, which is the only reason a boundary is usable.

Refusing to add an unlisted name is the same rule, not extra strictness. Were binding allowed, a name colliding with something private would quietly overwrite a slot the binder is not permitted to read: the original accident in a hat.

Privacy is inherited, because the check compares the receiver against the sender’s self rather than against whichever object holds the slot. A child’s own method reaches what it inherited; an unrelated object does not.

Reflection keeps the line rather than walking around it. slots, slotAt, respondsTo and perform were each a way out and are each shut — respondsTo because its own argument for the receiver check applies here too: it must not promise a send that would fail.

Drawing the line on json found something. quote and keyText had to be exported alongside read and write, 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 said so.

The cost was 8.7% until it was measured. The check builds the sender’s self to compare against, and building it on every send and letting the check discard it cost that much of a loop doing nothing else. Testing the slot’s bit first, so the value is not built unless the bit is clear, brings it to where thirty runs cannot tell the two builds apart — on that loop or on a real program.

An object that never calls exports is unchanged in every respect, which the whole existing corpus checks and which examples/include.sol depends on, since it extends an included object from outside on purpose.

A loaded file debugs like any other — 21d3849, 2026-08-27

No code changed, which is the finding. Solid steps into a file brought in by system:load, steps over the load with next, comes back out with finish, and shows the loaded frame above the loading one in where with each naming its own file. A breakpoint can be set in a file that has not been loaded yet — the only order that is any use, since by the time it has loaded it has run. None of that needed a line of Solid, because sol_vm_call_chunk pushes an ordinary frame and the debugger was already written against frames.

The case worth having is the failure. An error inside a loaded file stops there, in that file, with the loading frame still standing underneath and both files’ globals readable by name — which is what a debugger is for and the thing solis --interactive cannot do, since it starts after the unwind.

list is the one thing that can fail, and it fails politely. A library shipped as bytecode without its source has nothing to show, so it says cannot read and every other command carries on. That case was a curiosity before loading existed and is ordinary now.

Five tests in test_solid.c, and a section in the reference. Behaviour that works by construction is behaviour nothing is holding in place; these are what stop it from quietly ceasing to.

Also: the reference’s contents had not listed Loading a compiled file since the section was added this morning. Every in-document anchor was checked, and the rest resolve.

system:load is once-only, as @include is — 32c969a, 2026-08-27

A file now runs the first time it is asked for and not again, which is the one thing the entry below listed as missing and defended as a choice. The defence was thin. Once-only is not a nicety: it is what lets two files each load what they need without arranging between themselves who loads what, and a message that runs a file’s top level a second time re-binds everything in it.

Keyed by identity, not by spelling — the realpath, exactly as @include keys its own list, so lib.sob, ./lib.sob and the absolute name are one file. The list belongs to the machine rather than to a compilation, which is the only difference between the two.

The answer says which happened: true for a file that ran, false for one already there. That is makeDirectory’s bargain, which answers the same question about the same kind of idempotence, and it is why a second load can be a no-op without being a silence.

A cycle now ends on purpose. The file is written down before it runs, so one that reaches itself — directly or round through others — finds itself already listed and does nothing. @include uses the same word for the same behaviour. It is written down only once it is known to load and to verify, so a file that was never usable is not remembered as though it had been, and a machine that refused one is still willing to take it later.

Two consequences worth stating. A program that loads itself runs its top level twice: the program the machine started with did not arrive through system:load and so was never written down, so the load inside it is the first time that file is asked for, and the second is the one that stops. And the memory is a second list that nothing shortens, beside the globals — 3.10 again, and a reset would have to clear both.

The frame-limit test had to be rebuilt, which is the honest cost of this. A file loading itself was how deep nesting was reached, and it no longer nests at all; the test now generates a chain of three hundred distinct files, and still ends in call depth exceeded with the machine standing.

system:load: @include’s run-time twin — 948fdf6, 2026-08-27

One .sob can now load another, from inside Solum. system:load("lib.sob") runs an already-compiled chunk in the machine that is already running, and the globals it binds are the caller’s. It shares @include’s namespace rule exactly — one flat space, nothing marking where a name came from — which is what makes the two files connect at all: names, resolved at run time, so the caller compiles alone and only finds out on running that something was never bound.

No new memory model, which was the question asked. Every chunk already carries its own names, constants, code and slot count, and a frame already records which chunk it belongs to — that is how a block defined in one file has always been callable from another. The only thing two chunks share is the globals, and that sharing was already the design.

The two bugs were both about lifetime, and neither was where the design was.

sol_chunk_load initialises the chunk it is handed — it must, because solvm gives it a bare one — and initialising clears the owner that sol_code_new had just set. So every method read afterwards inherited no owner, and a chunk with no owner is one the collector does not root even while a frame is executing it. The load worked and the call afterwards ran into freed memory, but only if a collection happened to fall in between. SOLUM_GC_STRESS made it fall there every time; ASan named the line, object.c:116, which is a block reading the owner out of a chunk that had been swept.

The second was a hard exit rather than a failure. The chunk was held across the nested run by a temporary root, and the temporary roots are eight deep with an exit(1) on top — so the ninth nested load killed the process with nothing a program could catch. The root was not needed at all: a frame executing a chunk roots it, so dropping it before the guest runs moved the limit from 8 to the machine’s own 256 and turned a dead process into call depth exceeded.

And then the root turned out to be unnecessary everywhere, which taking it out and running ASan is what showed. Loading allocates nothing the collector knows about — serialize.c is handed no VM and so cannot — and a string constant is chunk-owned bytes until OP_STRING makes a string of it. Solis roots its submission because compiling allocates. Loading does not, and a root whose window is empty is a comment claiming a danger that is not there.

What it does not do, both recorded rather than hidden: there is no once-only memory, so loading a file twice runs it twice — @include is keyed by where a file lands on disk and a message has no such key — and there is no namespacing, so the last binding of a name wins, silently. Both are 3.10 reached from a new direction, and namespaces for included files is still the answer to both.

Pascal, stage 8: the language, finished — f784f81, 2026-08-27

sqrt, sin, cos, arctan, exp, ln, trunc, round, page, and field widths worked out while running. Twenty-one programs now produce the same bytes as fpc -Miso, and three more must not, each exercising a divergence PASCAL.md records. All eight stages are done.

The eight functions the machine already had, under other names. ln is log, which is natural in both; arctan is atan. round is half away from nought here and there — which was checked rather than assumed, that being the one of them where two reasonable implementations differ, and trunc likewise cuts toward nought in both.

A field width may now be worked out while running, which the standard allows and this refused for seven stages. The constant case still folds into the spec string and costs nothing, so an ordinary writeln(i:6) is unchanged; a computed one builds the spec with three sends. The bug in it is worth the entry: a width and a place count are two values alive at once, and both asked for the same scratch slot — so x:w:d wrote a spec of >3.3 where >8.3 was meant and padded to three. Which slot is wanted is a question the caller now has to answer.

And the last of it is a \f the machine has no escape for. page writes a form feed, and Solum’s string escapes are \" \\ \n \t \r — so the compiler makes the one-character string itself rather than asking for one it cannot write.

Two closing programs. maths.pas for the functions and the computed widths, and sieve.pas — Eratosthenes with a set, which is the program sets were put in the language for, and the one that shows what an array of booleans costs and buys: the membership test in the inner loop is one index.

Pascal, stage 7: pointers, and a reference one case too narrow — 60515ef, 2026-08-27

Pointers, new, nil, dispose, and linked structures. Nineteen programs now produce the same bytes as fpc -Miso, and one of them builds a binary tree, walks it in order and measures its depth.

A var parameter is a container and an index now, and was a one-element cell. That cell is sola.sol’s answer and it is enough for BASIC, where the only thing that can be passed by reference is a whole variable. Pascal’s Insert(t^.left, k) is the idiom a tree is built with, and the storage it names is element two of the record t points atno cell can alias that. A pair names either exactly, and a whole variable carries its pair from the moment it is declared, so passing one costs nothing at the call.

Stage 5 had written that case down as stage 8, because the box goes over and an element has none. It was not a missing feature; it was a representation one case too narrow, and the case that shows it is the first Pascal program anybody writes with pointers.

A pointer type may name a type declared after it, and has to: Tree = ^Node before Node = record ... end is the only way round. An unknown name makes a pointer with nothing in it and joins a list the type section empties before it finishes — so a pointer to a type that never arrives is refused with the line the pointer was written on.

A dereference is a field at offset one, a cell being a one-element array, so p^ := v needed no case of its own: it is a container and an index like every other store.

nil has a type of its own, assignment-compatible with every pointer and identical to none — the one type a program cannot name.

And dispose frees nothing, SolVM being collected, which is now exercised rather than asserted: differ/dispose.pas uses a disposed pointer and must not agree with fpc.

Pascal, stage 6 finished: reading, and a divergence nobody could check — 007a520, 2026-08-27

read, readln, eof and eoln on standard input, which finishes stage 6. Eighteen programs now produce the same bytes as fpc -Miso, and the oracle feeds both sides the same .in file.

Only standard input, and that is a decision rather than a gap. ISO leaves the binding between a name in a program heading and a file on disk to the implementation, so a program that opens an external file has no answer the oracle could compare against — and a divergence nobody can check is a divergence nobody should write. file of T is out for the same reason: its representation on disk is the implementation’s too. Both are named in PASCAL.md with that reason rather than left to be discovered.

Input is read whole and then walked, because the machine has readLine and nothing that reads a character — the arrangement PASCAL.md already recorded for files generally. The slurp is emitted only into a program that reads, which is the first pass’s answer put to a second use.

Two of the three bugs were the same bug in different clothes, and both are about what the machine has. JUMP_IF_FALSE is the only conditional jump, so leave when this is true has to be spelled leave when its negation is false — and readln written without the not stops at the first character that is not a line marker, which is the one it is standing on. It then steps again, so every read after it is shifted by one character and the program looks nearly right.

The third was c:indexOf(" \t\n\r") where " \t\n\r":indexOf(c) was meant. Asking a one-character string whether it contains all four spaces is always no, so nothing was whitespace and the first token read was the whole file. A send takes its receiver from the stack and reads like an argument list on the page, which is the one place this language’s uniformity does not help.

Pascal, stage 6: sets, and a plan the machine corrected — 79417ca, 2026-08-27

set of T, the constructor with ranges, in, union, intersection, difference, and the four comparisons. Seventeen programs now produce the same bytes as fpc -Miso. Files are the other half of the stage.

PASCAL.md said a set would be an array of integers, one bit per member, and it was wrong. That meets 3.12: 1 shiftLeft 63 overflows, SolVM’s integers being signed with no unsigned type to borrow. A 64-bit word would have to be a 63-bit word, or the top bit special-cased everywhere it is read or written.

So a set is an array of booleans, one per member of its base type. That makes membership one index, which is the operation a program writes most, and costs a set of char 256 booleans rather than four integers. Union, intersection, difference, equality and subset are loops over the span either way — the bits would have bought memory and nothing else. The page now says so, and says why.

Every combining operation is a jump inside a loop, because the machine’s own and and or take blocks: union is this or that, intersection this and that, difference this and not that, and each is the same four instructions with two swapped. Comparison accumulates res := res and ..., which short-circuits, so a set that differs early stops being examined.

A set literal has no type of its own and takes one from where it stands: the variable it is assigned to, the set it is combined with, the value it is tested against, or the parameter it is passed to. Failing all of those, from its first member — and an integer member has no span, so it gets 0 .. 255, which is fpc’s choice and is recorded as one.

One bug worth the name. a >= b is b <= a, and the two operands were exchanged before they were stored rather than after — so the stores put them back and >= answered <=. It was the only one of the four comparisons that was wrong, and the only one where the fix is a line moved rather than changed.

Pascal, stage 5: arrays, records and with30db427, 2026-08-27

Arrays with any ordinal index and any lower bound, more than one dimension, records, with, and whole-array and whole-record assignment. Sixteen programs now produce the same bytes as fpc -Miso, up from fourteen.

An array and a record are the same thing at run time, and the whole difference is what the compiler knows. Both are a Solum array; a record’s field is an index worked out while compiling, so it costs an at and not a lookup, and an array’s subscript is the Pascal index less its lower bound, folded the same way and costing nothing when the bound is one. Neither carries its shape.

Making one is a loop and not an unrolled run of instructions, because a size is a constant the compiler knows and a program is free to declare a thousand of something. The emitted code grows with how deeply a type nests, not with how big it is.

Assigning a whole array or record copies it, which the standard says and the machine does not: a Solum array is a reference, so without the copy two names would mean one thing. The copy is as deep as the type goes, because a record of arrays is still one value in Pascal — and nothing is emitted for a simple type, an integer or a string being a value on this machine already.

A designator stops one step short when it is being assigned to, leaving the container and the index for an atPut, and goes all the way when it is being read. Which is wanted is known before the last step is emitted, so it needs no lookahead — and a whole variable with no selectors is a third case, because a store into one is a SETLOCAL and has no container at all.

with keeps its record in a scratch slot at a depth the body cannot reach. The standard says the designator is evaluated once, so with a[i] do cannot re-read the subscript — and a with lives across a whole statement where every other scratch use lives inside one expression.

Two things the oracle caught. hi - lo on a subrange of char asked a string for sub, the ends being held as characters because that is what the source wrote and what a case label compares against. And array [boolean] wanted the ordinal of a boolean, which on this machine is a jump and not a conversion — the index step now asks emitOrd for whatever the index type is, which was already written and already right.

Pascal, stage 4: the machine needed nothing added — 31daea0, 2026-08-27

Nested procedures and uplevel access, and the two predictions written into ideas.md before the stage was started both held. Fourteen programs now produce the same bytes as fpc -Miso.

A nested procedure is a block made inside its parent’s activation and kept in a slot of that frame. So OP_BLOCK captures the right frame, OP_OUTER depth slot reaches the right variables, and a fresh block is made every time the parent runs — which is what makes recursion of the enclosing procedure work without anything being said about activations. It holds through two levels of nesting, through that recursion, and through a nested procedure writing an enclosing var parameter, which travels out a frame and then through a box.

Nothing was added to the machine. OP_OUTER takes a depth and a slot, which is a static link by another name, and that is the whole of Pascal’s scoping. That was the second prediction.

And these are the first blocks this repository has produced that capture their home. sola.sol has never set flag 2 — SolaBasic has no nested procedures, and its header says so, which is how the prediction was made in the first place. disasm.sol reads the flag back, and a test asserts both halves: that add and deep capture, and that the procedures enclosing them do not.

3.1 is Pascal’s own scoping rule rather than a limitation on it. A capturing block may not outlive the frame it was written in; a nested procedure may not be called after its enclosing procedure has returned. Those are the same sentence, and the falsifiable half — nothing a conforming Pascal program can write reaches the restriction — stayed unfalsified. The entry does not close: Solum is not Pascal, and a Solum program can still write a block that outlives its home. What it gains is a statement of what the restriction is, which is the rule a language with lexical nesting and no first-class functions already has.

Pascal, stage 3: procedures, and the pass that had to be added — f6a76ec, 2026-08-27

procedure and function, value and var parameters, recursion and forward. Thirteen programs now produce the same bytes as fpc -Miso, up from ten — including mutual recursion through forward and a var parameter handed on through two procedures to write a program-level variable.

A routine is a block held in a global, made with OP_BLOCK and stored under its own name; a call is that global, the arguments, and value. Recursion needs nothing special because the body names the global and the global is bound before anything runs it — and forward needs nothing special for the same reason.

The var parameter cost the compiler its single pass. A box is sola.sol’s answer and Pascal is the easier half of it, var being declared where that compiler infers it by a fixed point. What is not easier is knowing which of the caller’s variables need boxing: a variable read in one procedure may be handed to a var parameter by another declared after it, and by then the read is emitted. So the source is parsed twice and the first pass’s output is thrown away. Boxing every variable instead would cost an allocation and two sends on every access in every program, to buy the one case.

A program’s variables became globals, since a procedure has to see them and a block cannot reach the script’s slots without OP_OUTER — stage 4’s business. They carry a pas. in front so a Pascal program declaring var system : integer cannot reach in and replace the machine’s own.

A method’s line runs have to cover every byte of it. Forgetting to close the last one is a file the verifier calls internally inconsistent, and the disassembler shows every instruction at line 0 — the only visible sign of what is wrong. That is now the third distinct mistake to produce that one message, after a jump offset measured from the wrong place and a slot one past the end of a frame.

And a function’s own name is two things, which falls out of asking the questions in the right order: this unit’s own names before the routine table, so Fact is the result variable on the left of := and a recursive call in an expression. That is the standard’s rule and it needed no special case.

Pascal, stage 2: types that are two things at once — bcdff66, 2026-08-27

const, type, enumerations, subranges, case, repeat, for in both directions, goto with labels, and ord, chr, succ, pred, odd, abs and sqr. Ten programs now produce the same bytes as fpc -Miso, up from five, and every stage 2 construct is in them.

A type became an object with two kinds, and that is most of the stage. run is what the machine is holding — an integer, a float, a one-character string, a boolean — and kind is what Pascal thinks it is. An enumeration is an integer at run time and a Colour at compile time; a subrange of char is a character at run time and a 1 .. 20 at compile time. Every check is on kind and every instruction emitted is chosen by run. ord of an integer emits nothing at all, which is the clearest case of the two being different questions.

repeat needs both jumps, and written the way it reads it runs once. OP_JUMP_IF_FALSE only goes forward and OP_LOOP is unconditional, so looping while a condition is false cannot be one instruction: the false case has to jump over an exit and into the loop back. Spelled the obvious way it inverts the loop, and repeat i := i + 1 until i >= 3 leaves i at 1 and looks almost right.

Three things went on the divergence list because building the stage found them. An unmatched case falls through, which ISO calls an error and fpc permits — matched deliberately so the two do not disagree by accident. A subrange is not range-checked, because fpc does not check without -Cr and a checking compiler would then differ from the oracle on every program that relies on it. And an enumeration cannot be written, which is the standard’s own rule: write takes an integer, a real, a char, a boolean or a string, and a Colour is none of them however it is held.

A goto to a label nobody marked is refused before the file is written, because otherwise it is a jump with a blank offset — and the verifier’s word for that is internally inconsistent, which says nothing about labels.

Pascal, stage 1: it compiles and it agrees with fpc — a36af6d, 2026-08-27

pascal.sol is the fifteenth program and the second compiler here. Stage 1 of PASCAL.md: the program heading, var of the four simple types, assignment, expressions, write and writeln with field widths, begin/end, if and while — emitting a .sob that solvm runs with nothing of the compiler present.

Five programs produce the same bytes as fpc -Miso, which is the first time anything here has been held to a real implementation of a real standard on the day it was written. oracle.sh runs them, and two more in differ/ must not agree — maxint, this integer being 64-bit against fpc’s 32, and the default field width for a real, which ISO leaves to the implementation. make test checks the recorded transcripts and needs no Pascal installed; the oracle re-establishes them on demand.

A type checker is not optional. Solum refuses #1:add(1.0), so a compiler for a language with an implicit conversion cannot avoid knowing every expression’s type: i / 2 needs an asFloat and i div 2 needs none, and that is settled before a byte is written. sola.sol’s header says everything a SolaBasic program computes is a Double — one numeric type needs no analysis and two need all of it.

mod is free and div is not, which is the reverse of SolaBasic. ISO’s remainder is non-negative for a positive divisor, which is a floored remainder, and the machine’s is floored. ISO’s division truncates toward nought and the machine’s floors, so div compiles through abs and a sign. Both languages wanted the opposite of the other from the same machine.

Booleans are jumps. The machine’s and and or take blocks, being short-circuit; Pascal’s are operators. OP_JUMP_IF_FALSE and a boolean constant do it in four instructions with nothing allocated.

The oracle earned its place twice on the first day. A differ/ program named MaxInt would not compile, a program’s own name being an identifier in scope — so maxint meant the program. And a claim written into this compiler’s header before it was checked — that fpc diverges from ISO on -3 mod 2was wrong: Pascal’s sign belongs to the whole term, so that is -(3 mod 2), and asked with a variable both answer 1. It is left in the header as a note.

Pascal on SolVM, planned before it is written — 30ef4bc, 2026-08-27

PASCAL.md: ISO 7185 Standard Pascal, compiled to a .sob and run by bin/solvm. Written before the compiler, the way SOLABASIC.md was — and not a language definition, which is the whole difference between them. That document exists because there is no standard for its dialect; Pascal has one, so the boundary is not this page’s to draw. What it draws instead is the mapping, the divergences, and the stages.

The mapping is the work, since Pascal is statically typed and SolVM is not. A record is an array with offsets fixed while compiling, so a field costs an at and not a lookup. A set is an array of integers, one bit per member, and union is bitOr. A pointer is a one-element array. A var parameter is a box — sola.sol’s answer, and Pascal is the easier case, because var is declared where QBasic made the compiler infer it.

Free Pascal 3.2.2 is installed as the oracle, and the first thing it was asked has already paid: fpc -Miso accepts both Pascal files this repository already ships, which is the first evidence that pascal.bnf describes Pascal rather than this project’s idea of it.

ideas.md had this down as an interpreter, and the entry now says why that was wrong. Its predicted value was meeting 3.5 head on: a tree-walker spends host frames in proportion to the interpreted program’s call depth. A compiled call is one frame, so Pascal recursion reaches about 250 levels rather than 40 and the entry’s finding gets weaker. Recorded rather than quietly dropped: a prediction falsified by a decision is the reason anybody looked at the frame cost before choosing the shape.

Two better predictions take its place, both falsifiable, both written down before the first line. That 3.1 is not a limitation for this language but its own rule — a capturing block may not outlive its frame and a nested procedure may not be called after its parent returns being the same sentence. And that OP_OUTER depth slot is a static link, so Pascal’s scoping needs no mechanism the machine does not already have. No compiler here has emitted one: SolaBasic has no nested procedures.

Stage 4 is nested procedures and goes early on purpose, the way SolaBasic put GOTO in week one — it is the claim the design rests on.

GRAMMAR.md is held against the grammar it says it is — 70ca3a9, 2026-08-27

GRAMMAR.md opens by saying it is the same grammar as solum.bnf “in a form a machine reads”, and nothing held that true. It is the largest claim on the page: everything else there is one production, and that sentence is all of them at once. expect.sol now compares every production character for character once runs of whitespace are collapsed. Twenty-three agree; two are prosestring and comment are written any character but a quote where the notation says ! '"', because the page is for a person and ! is an extension a first reader does not need. Both are counted and reported, so the excusing is visible rather than silent.

Making the comparison possible meant giving the two files one vocabulary, and the document’s is the better one: solum.bnf had name, hex and bin where the page had identifier, hexdigit and bindigit, and the grammar now uses the longer words. The page in turn gave up for .. and spells a backslash "\\", which are what the notation actually is — and its table gained a row for each, so it now describes the notation completely.

It found primary listing its alternatives in a different order in each file. Harmless: no token could match two of them, so the order is immaterial here. It is exactly the drift this check exists for, because the next reordering might not be. The grammar took the document’s order, and all fifty-seven .sol files still check clean.

And the block-forms became Solum. The four shapes showing where a | goes were written with an ellipsis for the body, so they were notation rather than code; they are real blocks now, in a plain fence, which means expect.sol compiles them on every run. A form that stopped being legal used to be a sentence nobody could check.

The SolaBasic documents check their own examples — 9981f58, 2026-08-27

expect.sol reads a second language now. The three SolaBasic documents carry seventy ```basic blocks and nothing ran one — a fenced block naming a language was the one thing this checker skipped, so a repository that defines a dialect, ships a compiler for it and writes three documents about it was checking all three by eye. Twenty-nine are compiled and run on every test, against the output printed under them.

The expectation is a ```text block, not a comment on the line, which is a different convention from the rest of the checker and BASIC’s output is the reason: PRINT 42 writes a space where a minus would go, then the digits, then a trailing space. Leading spaces are what a print zone and TAB are made of and a comment cannot show them; a trailing space in a comment is invisible and the first editor to touch the file would strip it. So trailing whitespace is ignored on both sides and leading whitespace is not — the trailing space is held instead by programs/sola/*.out, which test_cli already compares byte for byte.

The forty-one that are not checked are counted, not guessed at. Sixteen are declarations that print nothing, thirteen name a label or a SUB that lives in the prose rather than in the block, three loop for ever on purpose — that being what a backwards GOTO looks like — and one reads from the terminal, so what is shown under it is a session and not an output. The last is told apart by reading the statement rather than by the run failing, since INPUT #1, a takes from a file and is fine.

Writing the outputs down found five examples that only ever demonstrated half of themselves. A one-line IF ... ELSE whose variable was never assigned took the ELSE every time; an ELSEIF chain and a SELECT CASE both fell through to their last arm; and a counted loop printed twenty lines where three say the same thing. None was wrong. Each now lands on the arm it exists to show. An example nobody runs cannot report that it is demonstrating the wrong branch.

DEFLNG came back too — the reference gained it yesterday and the cheatsheet already had it, and now a program using it is compiled on every run.

A cheatsheet for SolaBasic, and the keyword the reference had lost — bc784e2, 2026-08-27

SOLABASIC-CHEATSHEET.md: every statement and every supplied function of the dialect on one page, in the shape CHEATSHEET.md has for Solum. The reference manual was already the full account and the definition says where the boundary came from; what was missing was the page for when you know what you want and not what it is called.

It was asked for as a reference document, and one already existed. The audit that established that found a real gap: DEFLNG is one of the four DEF statements the compiler accepts and the reference named only three. Added, and the definition had it right all along.

Every claim on the page was run before it was written down. One listing exercises the lot — the print zones at 14 columns, the sign character and trailing space on a number, 7 \ 2 against 7 / 2, -7 MOD 2, -2 ^ 2, INSTR being one-based, LEFT$ clamping, STR$ without the leading space that PRINT adds, STRING$ taking only the first character, PRINT USING restarting its format per item and writing %1234 for a number too wide, by-reference parameters against the bracketed copy, and a FUNCTION answering by assigning to its own name.

And a coverage check compares the page against the compiler, which is what found LET and AS LONG missing from the first draft: every keyword programs/sola.sol recognises, other than the reserved SOLA runtime names, is now on the page.

The matcher is a stack machine, and the depth limit is gone — 36a4c55, 2026-08-26

check_syntax.sol no longer walks the grammar tree. The grammar compiles once to a flat instruction list and a loop runs it, with the stack in Solum arrays rather than in the machine’s call frames. 2,000 levels of nesting check in both languages, where the numbers were 19 nested begin … if against Pascal and 13 nested blocks against Solum.

What settled it was a file that was already here. experiment/lexer.sol holds a 24-level nested ifElse staircase, the deepest expression in this repository; solas compiles it and the checker could not read it. Every earlier measurement on 3.5 needed a generator to reach the limit. And the shape that did it is the shape control.sol recommends — a staircase written instead of ifElseIf, precisely to save frames. Both are right: a staircase saves them in the program dispatching and costs them in anything walking the result as a tree.

The instruction set is LPeg’sCall, Ret, Choice, Commit, LoopCommit, FailTwice and the terminals — and every EBNF construct is two or three of them. Backtracking is a stack entry rather than an unwind: popping to a choice point discards every call made since it, which is exactly what recursion had been doing for free. One stack holds both return addresses and choice points, and that is not an economy, it is what makes the discarding correct.

Both halves run on it, lexical and syntactic, which is why the tokeniser’s own nesting limit — around 80, and never near anything real — went as well.

It cost 38% of the running time. programs/sola.sol went from 3.79 seconds to 5.25. Two attempts to get it back are worth 3.7% between them: reordering the dispatch staircase by frequency, 2.4%, and spelling out the hottest comparison rather than calling it, 1.3%. Both were predicted to be worth several times that. The 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 ideas.md now carries to the two interpreters it proposes.

What is left of the limit moved somewhere better. Compiling a grammar still recurses over its tree, so a grammar nesting brackets a few hundred deep still runs out of frames. That is a property of the grammar file, reported identically every run and before any subject is read — not a property of the input, discovered on the one file that happened to be deep.

The old matcher was the test. Both were run over every .pas and .sol file here and every error case and the output compared byte for byte: 63 runs, and the only two that differed were the two that used to exceed the limit. One of them is check_syntax.sol itself — the staircase dispatching the machine’s instructions is deep enough that the matcher this replaced could not read the program that replaced it.

Solum has a grammar written down — f894c78, 2026-08-26

solum.bnf is the whole of this language in the notation check_syntax reads — thirteen syntactic rules and nine token rules — and GRAMMAR.md is the same grammar written for a person, in Wirth’s notation and on one page.

./bin/solvm programs/check_syntax.sob programs/check_syntax/solum.bnf prog.sol

Taken from the compiler rather than from the documentation. The only grammar written down anywhere was the sketch at the top of solas/include/solas/parser.h, which says of itself that it goes only “as far as docs/design.md pins it down”: no blocks, no arrays, no symbols, no temporaries, no slot assignment. This is solas/src/lexer.c and solas/src/compiler.c read out.

Fifty-six of the fifty-seven .sol files here check clean, and the fifty-seventh is a depth limit rather than a disagreement. Every example and every library file is swept on each test run — thirty-eight files, none of them written with this grammar in mind, which is what stops it from quietly narrowing. It is held to agreeing with solas on four deliberate mistakes too, because a grammar that merely parses is not the same as one that is right.

Three things it makes visible. That there are no reserved words at all — the checker reserves every word-shaped literal a syntactic rule mentions, this grammar mentions none, and a test asserts the absence. That . separates rather than terminates, uniformly, in a file, a block and a group alike. And that := may follow a send that took no arguments and not one that took some, which is how a slot is bound and is why o:at(#1) := #2 is a syntax error.

A real file reaches the frame limit for the first time. Against this grammar the checker manages 13 nested blocks, and experiment/lexer.sol holds a 24-level nested ifElse staircase — the deepest expression in the repository. solas compiles it; the checker does not. Every earlier measurement on 3.5 needed a generator to reach the limit; this is a hand-written file that already existed. The shape that does it is the one control.sol recommends, and the advice is still right — a staircase saves frames in the program dispatching and costs them in anything walking the result as a tree.

And the token dump was quadratic. Line and column are computed by counting newlines from the start of the file, on the argument that a run wants four of them. That is right about errors and was wrong about tokens mode, which wants one per token: on programs/sola.sol it took seventeen and a half minutes to list the tokens of a file it checks in under four seconds. Tokens arrive in order, so the dump carries the line and makes one pass — 1,052 seconds to 3.6, which is 270 times. A design note saying how often something is wanted is a claim about every caller, including the one written afterwards.

expect.sol would have crashed rather than reported, which was found on the way. Its list of ordinal words is extended by hand when a program is added, and the failure when somebody forgets was index #14 is out of bounds for an array of size 13 — in the checker, on the run meant to report the mistake. The lookup is guarded now and the list runs to twentieth.

A syntax checker that is told the syntax — d9e1621, 2026-08-26

The fourteenth program: check_syntax.sol reads a grammar written in Wirth’s EBNF, then reads a second file and says where it stops agreeing with it. The grammar is the program — hand it pascal.bnf and it checks Pascal.

./bin/solvm programs/check_syntax.sob programs/check_syntax/pascal.bnf myprog.pas

Two dialects. Wirth’s notation, which the Pascal report uses and which describes itself, and the older <expr> ::= <term> | <expr> "+" <term> with angle brackets and no terminator. One reader takes both: a production ends where the next one starts, so the . is optional rather than required.

One file, two halves, and a declared seam. A grammar for Pascal is written over tokens and says nothing about how characters become them, so %syntax names the line between the lexical rules and the syntactic ones. It is declared rather than guessed because identifier and expression look alike, and a checker that guesses wrong reports a correct file as broken.

Three extensions and no more, all lexical, because Wirth’s notation cannot describe a lexer: "a" .. "z" for a range, ! factor for one character that is not that, and the string escapes so a tab can be written down. All three are refused in a syntactic rule.

The reserved words are derived, not declared. Every word-shaped literal in the syntactic half is reserved against the token kind it would tokenise as, which recovers Pascal’s 35 keywords out of pascal.bnf without a list anywhere — so x := begin is refused and nothing had to say that begin is special.

The error is reported at the furthest token any terminal ever failed at, recorded as the match goes and never rolled back. A backtracking matcher otherwise fails at position one with everything rolled back, and myprog.pas:1: does not parse is a sentence about the program that printed it. The innermost rule that had already consumed something is named too, which is the difference between reading <multiplying-operator> and reading <if-statement>.

Every diagnostic it has about grammars came from a grammar being wrong in a way that blamed the wrong file, which is why the checking half is as large as the matching half. Left recursion would otherwise exhaust the frames and report call depth exceeded against the subject. An alternative that is a prefix of a later one — symbol = "." | ".." — never produces the longer token, and the complaint surfaces two tokens later with nothing pointing at the cause. And %fragment, which is the one that cost the afternoon: letter and identifier both match T, longest-match ties go to the rule declared first, and the first Pascal file this read came back as 130 syntax errors in a file with nothing wrong with it.

The depth, measured through a grammar rather than guessed from the matcher: 19 levels of nested begin … if, 28 nested parentheses. It arrives as a diagnostic rather than a crash, call depth exceeded being catchable. Inlining a rule’s alternation into the reference that names it was expected to be worth a third of the frames and was worth a sixth — 16 levels became 19 — because most of Wirth’s Pascal rules have a sequence for a body and so never had the middle frame to save.

The report is bounded, which a file this was never meant to read made necessary. Handed a Mach-O executable it produced 1,673 error lines, one of them four thousand bytes wide. Lexical errors are capped at twenty and the rest counted, every unprintable byte is escaped rather than sent to the terminal, and the line shown under an error is windowed — in rendered columns and not in bytes, a byte that escapes to \x1b being four columns wide.

What it did not need was 3.1. ideas.md had this program down as either giving that entry its first customer or showing that a non-combinator design is fine, and it is the second: the grammar is a tree of objects walked by one method, so nothing is ever a block that outlives its frame. That entry is updated with the outcome.

A word count, which found nothing — 0c22c00, 2026-08-26

A third real program: read a text, split it on anything that is not a letter, tally the words in parallel arrays, sort by count then alphabetically, and lay out the table.

Written to work the string functions hard — that being where the hand-emitted clamping lives and the likeliest place left for an edge to be wrong. MID$ a character at a time down a line, UCASE$ on each, string comparison for the sort, concatenation in a loop, LINE INPUT # over a file, and two arrays of different types handed to one procedure, which nothing had done before.

It matched QuickBASIC byte for byte on the first run and asked for nothing.

That is the first real program here to find neither a defect nor a missing feature, and it is a result rather than a quiet success: the two before it found four things between them, and this one was pointed at the likeliest remaining weak spot and came back clean. Twenty agree/ programs.

Conway’s Life, and the two things it wanted — c465ab0, 2026-08-26

A second real program: Life on a grid, the canonical BASIC one and the only thing here that works a two-dimensional array hard.

An array parameter may now have any number of dimensions. That was on the not yet list with the reason its strides would have to travel with it — and they need not. The bounds are read off the call sites, every array handed to one parameter having to be the same shape, and a listing that hands it two being refused by name rather than answering the wrong element. A descriptor travelling with the array would cost every subscript a lookup to buy a case this refuses out loud.

An array element could not be assigned on a one-line IF. IF n = 3 THEN nxt%(r, c) = 1 is as ordinary as IF n = 3 THEN x = 1, and the list of what may go there had never had 'arrayset in it — nor the file statements. All of them do now.

And QuickBASIC wants the type spelled on an array parameterg%() and not g() — where SolaBasic takes it from a DEF. SolaBasic is the more permissive, so a listing written here may not compile there; that is in the reference manual rather than the divergence list, being guidance rather than a different answer.

Nineteen agree/ programs, and the glider glides.

A real program, and what it asked for — 1eb063a, 2026-08-26

Not a feature but a program: a sales report that reads records out of a file into parallel arrays, totals them, and lays out a table — written to find out what a real one would want. Four of the defects found so far came out of writing the runtime in SolaBasic, and none at all out of the transcripts.

It wanted INPUT into an array element. That was on the not yet list with the trigger the first program that asks, and INPUT #1, nm$(n), qty(n), price#(n) is how records go into parallel arrays — so the trigger fired because a listing wanted it, not because somebody decided it was time.

And it found a defect on the way. Those subscripts were never being typed — the walkers knew about an assignment’s subscripts but not an INPUT target’s — so an integer subscript was coerced as though it were a Double, giving integer does not understand 'rounded' from a perfectly ordinary line.

The report matches QuickBASIC byte for byte. Eighteen agree/ programs.

What it did not want was ON ERROR, the entry I had said was closest to firing: its trigger is the first program that needs to survive a bad file, and this one writes the file it reads. The trigger stays unfired — which is the point of having written a trigger down rather than a plan.

The colon the definition had promised, and two bad errors — b79c301, 2026-08-26

Three things a reader hits at once, and not one of them a new feature.

: between statements was in SOLABASIC.md from the day it was writtenLexical structure says it joins statements on one line — and the compiler refused it. That is precisely what the frozen-document discipline exists to catch, and it sat there through all eight stages while the divergence list was being checked by machine. A definition that promises what the implementation does not do is the same failure as a transcript recording what a program does rather than what it should, and it lasted longer.

A one-line IF takes everything after THEN to the end of the line, so IF c THEN a : b runs both when true and neither when false. Both readings were plausible; QuickBASIC was asked rather than guessed.

A missing file said the wrong thing in the wrong place. OPEN on something absent gave the machine’s own cannot read, against a line number inside the runtime reported as a line of the user’s listing — which that listing did not have. It says File not found now, measured.

And a chunk carries the file it was compiled from. The runtime is compiled into every program that prints, so its lines were attributed to whatever the user called their file. A failure inside it now says the SolaBasic runtime, and the trace goes on to the line the user wrote. Seventeen agree/ programs.

Files, and the eight stages are done — 391aba6, 2026-08-26

OPEN, CLOSE, PRINT #, WRITE #, INPUT #, LINE INPUT # and EOF, sequential only — and they matched QuickBASIC byte for byte on the first comparison, which no other stage managed. Sixteen agree/ programs, and every stage of SOLABASIC.md is now held against a real QuickBASIC.

There is no streaming underneath, the machine reading and writing whole files, so a channel open for reading holds the file and one open for writing holds what has been written until it is closed. Stopping the program closes what is still open.

Two things came out of writing it. INPUT # was not taking the quotes off a field WRITE # had put them on, so a round trip gave back "Hans" rather than Hans — and fixing it properly meant making the field splitter quote-aware, so a comma inside quotes stops separating and text with one in it survives.

And one divergence is new: a file is written with line feeds where QBasic writes CR LF. A carriage return is taken off what is read, so a file written by either is readable here.

The first feature here built the other way round. Twenty-one formats went through QuickBASIC 4.5 first — digit positions, decimals, thousands separators, leading and trailing signs, asterisk fill, floating dollar, exponential, the literal escape, the three string fields, and a format shorter than its list of items — and the formatter was written to reproduce what came back rather than what anybody remembered.

Every case matched on the first comparison but one, and that one was this compiler disagreeing with itself: PRINT USING wrote an exponent with E where plain PRINT already wrote D. Fifteen agree/ programs now, all matching.

The formatter is written in SolaBasic, beside PRINT’s and INPUT’s — it is a field scanner, a rounding routine and a good deal of padding, and each reads better as BASIC than as a sequence of emit calls.

Three defects, all found by writing a real program in the language — fc27e85, 2026-08-26

Building that formatter turned these up before a line of PRINT USING was wired in. None was reachable from the transcripts or the corpus, and all three are in what stages 4 and 5 had already shipped.

The corpus covers GOTO now, and finds nothing — b757f07, 2026-08-26

Five programs into oracle/agree/, closing the biggest hole in the coverage: GOTO forwards and backwards, a jump out of a block, a label just before NEXT, two loops woven from nothing but GOTO, numeric labels that descend, the three-deep by-reference chain, and the numeric functions with their edges.

GOTO was not tested against QuickBASIC at all — a hole in the middle of the design, it being the claim everything else stands on, and the only thing holding it was a transcript this compiler recorded of itself.

All fourteen match, and nothing new turned up. That is a result rather than a non-event: three defects came out of the first fourteen programs, and the five written to close the widest gap came out clean.

And one thing was settled. SOLABASIC.md says a number at the start of a line is a label and not a line number, taken from CB80, and that labels need not ascend — whether QuickBASIC agreed was not known when that was written. It does. The rule is not a divergence and the list needs no entry.

INPUT, and two more things the oracle found — 3e0caae, 2026-08-26

INPUT and LINE INPUT, matching QuickBASIC byte for byte. A prompt followed by ; gets a question mark and one followed by , does not; the answer is one line split on commas; a field that must be a number and is not gets Redo from start. The runtime for it is written in SolaBasic beside PRINT’s.

A function of no arguments was being read as a variable. RND on its own is a call and the parser made it a name, so r = RND quietly read an uninitialised variable and answered nought — and the test written for it passed, because it checked the answer was at least nought and less than one. Found only when SOLAREAD$ did the same and made the runtime ask for ever, which is a louder way to be wrong.

QuickBASIC echoes an answer it read from a file, so a redirected session reads the way the interactive one looked. SolaBasic did not, and basic.sol had recorded that as a limitation of its own rather than a difference from anything. It does now, when output is not a terminal.

And the end of input has to be told from a blank line — both are the empty string to a program. The runtime is handed a NUL, which no typed line contains, and stops with Input past end of file.

The harness feeds standard input now, from a .in beside the .bas, on both sides. Nine agree/ programs, all matching.

The oracle ran, and it found what transcripts could not — 7edfa49, 2026-08-26

QuickBASIC 4.5 under DOSBox, against the corpus. All eight agree/ programs match byte for byte and all five divergences are still there. Three things came out of the first run, and not one was reachable from anything already here.

A real defect. PRINT (1 < 2) printed truD. A comparison used as a number is -1 and SOLABASIC.md has said so since it was written, but PRINT emitted the value without saying what type it wanted — so the machine’s boolean went out as text, and the runtime’s exponent swap turned true into truD.

Eleven transcripts were green while that was true, and types.out recorded truD as the correct answer. A transcript records what a program does, not what it should do, so re-recording after a change bakes the change in whether it is right or wrong. That is precisely what basic.sol’s header says about the eighty-three claims that caught none of the seven defects the NBS suite found — demonstrated here on the first run.

Two divergence entries were wrong. INTEGER overflow does not stop the program: BC.EXE compiles without overflow checking, so QuickBASIC wraps to -32768. And the digit count — the one thing the definition said was not settled — is settled: QuickBASIC prints sixteen significant digits, SolaBasic prints the shortest that reads back the same.

Two literal forms were missing and were needed to ask the digits question at all: 1# and 1D20, both ordinary QBasic, both found by a corpus file refusing to compile.

And two notes for whoever runs it next. BC.EXE wants CR LF and says nothing when it does not get it — a Unix-ended file compiles, links, produces a .EXE, and that .EXE prints nothing, which looks exactly like output that cannot be redirected. And BC /O linked against BCOM45.LIB redirects into a file where the BRUN runtime cannot find itself.

An oracle harness, and no verdict yet — ef40e9a, 2026-08-26

programs/sola/oracle.sh compares SolaBasic against a real QuickBASIC — stage 7, and the only check here that can find something nobody thought of. Everything else this compiler is held to is a transcript recorded by its own author, which is the failure basic.sol’s header describes: eighty-three claims in that file caught none of the seven real defects the NBS suite found.

The corpus is in two halves and the split is the design.

   
oracle/agree/ must produce the same bytes under both — a difference is news
oracle/differ/ must not, each naming the divergence it exercises — one that agrees is also news

So the divergence list stops being prose and becomes something that can fail.

No verdict. This machine has no QuickBASIC, no DOSBox, no qb64, no fbc — and installing one is not the script’s business, the repository claiming no dependencies beyond a C11 compiler and make. The harness keeps that claim by saying what it needs rather than fetching it.

Nor is the harness taken on trust. Run with SolaBasic standing in as its own oracle, every agree/ matched and every differ/ was reported as having lost its divergence — precisely what that arrangement should produce, so both paths and the exit status are known good before anybody points it at the real thing.

make test checks that all thirteen corpus programs still compile, so they cannot rot between the days somebody has an oracle to hand.

Arrays, and by-reference that costs nothing — 42a7091, 2026-08-26

DIM with constant bounds and up to eight dimensions, OPTION BASE, CONST, DIM SHARED, and arrays passed to procedures — stage 5, leaving INPUT, files, PRINT USING and the QuickBASIC harness.

By-reference for an array is free, which is the opposite of what a scalar cost. Stage 4 needed a box, an analysis and a fixed point; a Solum array is a reference, so Sort(n(), 6) hands it over and the callee’s atPut writes the caller’s storage because it is the same array. arrays.bas is a bubble sort that sorts the caller’s array in place to say so.

Every subscript of a multi-dimensional array is checked, and a one-dimensional one is not. That asymmetry is deliberate: one out of range would otherwise land on a different element rather than off the end — a(1, 9) in an eight-by-eight is index 9, which is a(2, 1) — and answering the wrong element quietly is the one thing this must not do. A one-dimensional array has nowhere for a bad subscript to go except outside itself, and the machine refuses that already.

Two things are narrower than QBasic, and are in the divergence list: an array name means one array in the whole listing, and an array parameter is one-dimensional.

Eleven transcripts, and the test holds the subscript check — the one place a wrong answer would have looked like a right one.

PRINT’s rules, and a runtime written in the language it serves — 724e277, 2026-08-26

A number is a sign character — a minus, or a space where one would go — then the digits, then a trailing space. A string gets neither. , moves to the next print zone of 14, ; moves nowhere, a separator at the end of a line holds it open, TAB and SPC place things, and the margin is 80.

 14
-7
 .5
 1             2             3

Brought forward out of stage 6 on purpose. Stage 7 is a comparison against a real QuickBASIC, and it cannot compare anything while every line differs in its spacing — so output has to match before the oracle is worth building.

The runtime is written in SolaBasic, compiled by this same compiler and emitted into any program that prints. Those rules are a line buffer, three loops and a decision about a leading nought, and each reads better as BASIC than as a sequence of emit calls — which is what SGN had to be, and what got SGN wrong the first time. It costs a reserved prefix: names beginning SOLA belong to the runtime.

And writing it turned up two things the compiler had wrong, which is the argument for writing the runtime in the language rather than around it. nextIs compared a token’s text without its kind, so the string literal "-" answered yes to is the next token a minus and T$ = "-" + MID$(T$, 3) would not parse. And CALL was missing from the statements a one-line IF may hold, so IF x > 80 THEN CALL Wrap was refused. Neither was reachable from anything in this repository until a real program was written.

The numbers are QBasic’s and are not all settled — the zone, the margin and the D exponent are; how many digits a Double shows comes from the machine’s shortest round-trip rather than a count BASIC fixes. That is stage 7’s to settle, and the reference manual says so.

Ten transcripts, all re-recorded, and print.bas covers the rules themselves.

Integer divide and MOD are exact now — 31e579a, 2026-08-26

SolVM’s integer div and mod are floored and should stay so — a remainder inside [0, n) is what indexing and cyclic arithmetic want, and design.md gives that reason. BASIC’s \ and MOD are the other pair, cutting towards nought, so sola.sol has to bridge.

It was bridging through the float divide, which was quietly wrong above 2^53. 9007199254740993 \ 1 came out 9007199254740992. That is the shape of thing worth catching: a workaround that looks like a performance trade and is really a correctness one.

The correction is exact and stays in integers — the truncating quotient is the floored one plus one when there is a remainder and the signs differ, and nothing else is true of any sign combination. Twelve instructions against four sends, and right for every number an Integer can hold. types.bas holds all four sign combinations and the three large values the old route got wrong.

A quotient message on integer would make it one send, and is deferred with a trigger rather than built: the workaround is exact, so building it buys size and speed and not correctness, and one customer is below this project’s bar. The entry also records why divRounded is the wrong name — rounding disagrees with truncation in both directions, so it would fix nothing and break the positive case.

And the premise was half wrong, which is worth keeping: basic.sol never had this problem. Minimal BASIC has no integer-division operator, its INT is a floor and so is the machine’s, and its / is float division.

Three types, ten operators, and twenty-seven functions — 4a98f91, 2026-08-26

Stage 1 of the eight SOLABASIC.md lists, which leaves only arrays, PRINT’s real formatting, files and the QuickBASIC harness. Integer, Double and String by suffix or by DEF; the whole operator table including ^, \, MOD, NOT, AND, OR and XOR; &H and &O literals; and every supplied function the definition names. SOLABASIC-REFERENCE.md is brought up to it.

Types have to be settled before a byte is emitted, and that is the finding. A conversion acts on the top of the stack, so widening an Integer must happen after it is pushed and before the value beside it — by which time it is far too late to discover it was needed. The tree is typed in a pass of its own; emitting is a second walk that already knows where the conversions go.

There is no boolean type, as the definition says. A comparison is -1 or 0 used as a number, so NOT, AND and OR are bit operations and still read correctly. Internally it answers the machine’s boolean — a conditional jump wants one — and the jump that turns it into -1 is emitted only where the value really is a number.

Two operators follow QBasic against the machine. SolVM’s integer divide and remainder are floored, so -7 \ 2 would be -4 and -7 MOD 2 would be 1. QBasic says -3 and -1, and this says QBasic. The cost is exactness above 2^53.

A supplied function is emitted where it is called, there being nowhere to put a library — SGN is a scratch slot and two conditional jumps, LEFT$ clamps before copyFrom is allowed near it, LTRIM$ is a loop.

Two things went wrong in ways worth keeping. 3.1 caught this compiler — a helper built the block that emits a builtin and stored it, and the block read the helper’s parameters, so it captured a frame that had returned: block outlived the frame it was written in, exactly as the roadmap says. And SGN’s first draft had three arms sharing two jump holes, so one was patched twice; the verifier refused the file at load, exit 65, rather than running it — which is why a Solum-emitted .sob is checked before it runs at all.

Procedures, and a reference manual for the dialect — 4562ea0, 2026-08-26

SUB, FUNCTION, CALL, locals, SHARED, STATIC, EXIT SUB/EXIT FUNCTION and by-reference parameters in programs/sola.sol — stage 4, the one SOLABASIC.md called the most expensive item in the language. And SOLABASIC-REFERENCE.md, what stages 2, 3 and 4 add up to for somebody writing the language rather than reading about it.

A procedure is a block and a call is value — a close fit rather than a contrivance, since a block has its own frame, takes arguments in slots 1..n, and answers its last expression. It never captures its home frame, so 3.1 never bites: every name a procedure uses is its own slot or a global. 3.5 does, exactly as predicted before the compiler existed — recursion stops around 254 levels, and the trace names the BASIC procedure and the BASIC line.

By reference cost far less than billed, because of the representation. A variable ever passed by reference lives in a one-element array always, so the call hands the array over and the callee’s atPut reaches the caller’s storage — no wrapping, no copying back, no temporary to keep alive, nothing to get wrong when the call recurses. The part that was as billed is deciding which parameters: a fixed point, since a parameter is by reference when its procedure assigns to it or hands it on to something that does. byref.bas is three procedures deep with only the last assigning, and the write still reaches the caller.

Only CALL’s brackets are an argument list. CALL Double(n) passes n; Double (n) has no argument list to be, so the brackets group an expression and a copy goes instead. That is QBasic’s own spelling of by-value.

Two things BASIC requires needed doing rather than assuming. A variable never stored into is an undefined name to the machine, not a nought, so PRINT Z was an error where every BASIC prints 0 — every name a scope mentions now gets its nought first. And a STATIC cannot be a frame slot, a frame being new every call; it is a private global initialised once.

And FOR’s temporaries moved from hidden globals to frame slots, which is a correctness fix: a recursive FUNCTION containing a FOR would have had its inner call overwrite the outer call’s limit.

Stage 2 is stage 3 with a stack on top — fa35e34, 2026-08-26

IF in both shapes, SELECT CASE, FOR/NEXT, DO/LOOP, WHILE/WEND, EXIT FOR and EXIT DO, all compiled to jumps by programs/sola.sol. Stage 2 of the eight SOLABASIC.md lists, done after stage 3 — and the order is what made the finding visible.

Nothing was added to the back end. A GOTO needs a hole punched in the code and filled when its label turns up; IF, SELECT CASE, FOR, DO and WHILE need the same hole, filled when their closing line turns up instead. The whole of stage 2 is one stack of open blocks over machinery that already existed. Had stage 2 come first this would have been a thing to notice afterwards; this way round it fell out first.

The blocks are a stack and the statements stay flat, rather than a parser building a tree. BASIC’s blocks are an opening line and a closing line, half the errors worth reporting are the two not matching, and a stack has the mismatch in its hand:

DO
NEXT i        →  line 2: NEXT closes the DO opened on line 1

A tree would have refused to parse and had less to say about why. It is also what lets EXIT FOR find the innermost FOR rather than the innermost block — four lines down a stack, against a tree walk.

FOR is exact except in one place, and the place is written down. A literal step fixes the direction at compile time; an expression step does not, so the test is (limit - counter) * step >= 0 — right for either sign, and forever on a step of nought, as BASIC is. It is wrong only on a product underflowing to -0.0, which buys one extra iteration and is unreachable from a literal step.

And the two halves meet. A GOTO still leaves a loop from inside an IF, and a label just before NEXT is how BASIC spells continue. Both are in escape.bas, with a recorded transcript, because the halves meeting is the part worth a byte comparison. Five listings now.

sob.sol goes back on the search path, and the other three stay parked — dbf2e01, 2026-08-26

lib/sob.sol again, rather than experiment/. programs/sola.sol wanted the .sob writer and was reaching into the one directory whose README says nothing in it is on the search path and everything in it is expected to fall behind — a live program depending on parked code is the wrong shape.

Why only this one comes back is the useful part. The tax that parked the self-hosting compiler is that a second compiler has to be taught every construct the first one learns, and that falls on lexer.sol, parser.sol and compiler.sol, which track the language. sob.sol tracks the file format — it changes on a version bump, a deliberate act already held to serialize.h by the test suite, and not when Solum gains a construct. Different rates, conflated only because all four files arrived on the same day.

Nothing in experiment/ needed editing. Both files there say @include "sob.sol", and a name not found beside the includer is looked for on the search path, so they find it in lib/ without knowing it moved. prove.sh was run before and after and says the same thing both times — 54 identical, 0 differing, 2 refused, fixpoint intact. The two refusals predate this and are the falling-behind that README promises.

And it is registered in tests/test_compile.c now, so it is verified on every build where the experiment was not.

SolaBasic can GOTO, and the claim the design rests on holds — 4f4f74e, 2026-08-26

programs/sola.sol compiles SolaBasic to a .sob that solvm runs with nothing of the compiler present. Stage 3 of the eight, taken first, because that document says stage 3 is the claim everything else stands on.

The verifier was checked rather than assumed. Every statement compiles at depth 0 and ends with a POP, so a label is a depth-0 merge point by construction. Measured before anything was written, by hand-assembling a chunk with a backward jump to an arbitrary earlier offset, a forward jump over dead code and a conditional between them. Both ways of getting it wrong were checked too, because a test that cannot fail proves nothing:

   
a jump into the middle of an instruction refused at load, exit 65
a jump to a point at a different stack depth refused at load, exit 65

The opcode is not known when the jump is emitted, and that is the whole of the back end. Forward is OP_JUMP, backward is OP_LOOP, and which one a GOTO is depends on where its label turns out to be. Both are three bytes, so three zero bytes go down as a placeholder and a fixup list remembers where — nothing moves afterwards, which is the trap in every backpatching scheme that emits a short jump and grows it.

A label is a string of characters, not a number — CB80’s rule taken over whole, so an old listing passes through unaltered and nothing ever sorts one. programs/sola/ carries three listings and their recorded transcripts, compared byte for byte on every build. spaghetti.bas nests nothing on purpose: two loops woven from GOTO alone, with jumps that cross, because a structured program would not test the claim.

And it is 45 times the tree-walker — the same 200,000-iteration loop is 1.54s under basic.sol and 0.034s compiled, against the “order of magnitude” SOLABASIC.md first guessed. Both revisions are in that document’s own change log, which is what it is for.

A compiled BASIC gets its definition before it gets a compiler — 9e9ac55, 2026-08-26

SOLABASIC.md is the whole of SolaBasic — labels rather than line numbers, SUB and FUNCTION, block IF and SELECT CASE, three types, twenty-eight statements and twenty-seven functions, compiled to a .sob and run by solvm. Written before any of it exists, because there is no standard for this dialect and somebody has to hold the line a standard would have held.

There is no standard, and not for want of looking. Full BASIC — ECMA-116, ANSI X3.113-1987, ISO 10279 — is the only standardised structured BASIC, and it fails three ways: line numbers are still mandatory, it is 176 keywords and five optional modules against Minimal BASIC’s twenty statements, and nobody appears to have built a conforming implementation, so there is no counterpart to the NBS test programs either.

So the boundary is borrowed rather than invented, and it is CB80’s. The CBASIC Compiler of 1982 compiled to an intermediate file run by a separate runtime — this design, fifty years early — and added alphanumeric labels, nested IF, type declarations and multiple-line functions with locals. Everything QBasic has that CB80 also had is language; everything past it is the PC.

The reason to compile rather than transpile is that GOTO is not expressible in Solum. A transpiler would compile each statement to a block and dispatch on a label variable, which is a send per statement and is what basic.sol already pays as a tree-walker. In bytecode GOTO is OP_JUMP, and the verifier cooperates: statements compile at depth 0 with a POP at each boundary, so every label is a depth-0 merge point by construction.

Three decisions are the ones most likely to be wrong. SINGLE is removed rather than aliased, so a ported program prints more digits than it used to. GOSUB, RETURN and ON n GOTO are cut permanently, both needing the computed jump the machine does not have. By-reference parameters stay, with the boxing they cost, because passing by value would leave SWAP-shaped programs running and answering differently.

And it meets 3.5 head on. A SUB compiles to a Solum block, so an interpreted call is a real frame and recursion stops around 254 levels — exactly what ideas.md predicted when it argued for a Pascal interpreter. A line-numbered BASIC never could, which is why basic.sol fit inside the limit without noticing it.

The editor finishes vi’s alphabet, and its sessions become tests — 8018261, 2026-08-26

c, e, f, t, F, T, r and ~ in programs/edit.sol, which is the last of what a person who knows vi reaches for.

c is d and then insert, and it took two of vi’s rules with it. cc empties the lines rather than removing them — changing a line and deleting one are different, and the cursor has to have somewhere to type. And cw is ce: changing a word does not swallow the space after it, where deleting one does, which is vi’s oldest special case and the one people notice.

fx, tx, Fx, Tx find a character on the line, with a count, and none of them leaves it — a character search that wandered onto the next line would be a search, and / is that. Forwards they are indexOf(what, #from), built an hour earlier for the matcher and now with a third customer, so 3fx is three primitive calls rather than a walk.

e is the end of a word — a different question from w, and the one cw really asks. r replaces the character under the cursor and refuses rather than doing part of the job when a count asks for more characters than are left. ~ swaps its case and moves on, vi’s one command that is a change and a motion at once.

The clamp caught a third customer, too. c$ deleted the space before the cursor, because the cursor was clamped to the end of the shortened line before the mode changed to insert — where a cursor may not stand one past the end and an insert may. That is the same distinction that let dw leave the last character of a file two days ago, in its third disguise.

And the editor’s own tests are in the repository now

programs/edit/checks.sol: 165 scripted sessions, each writing a file, feeding the editor keys through a pipe and comparing what was written against what those keys should have done. It runs in make test in under a second.

They existed as a scratch harness across the four days this editor was built, and a hundred and sixty-five checks that live in a temporary directory are worth nothing on the fifth day. Every defect those four days produced — one per feature — is a line in that file now.

No message was added to the language: 138 messages, unchanged.

indexOf can say where to start — fd1e5fa, 2026-08-26

indexOf(s, #from), a second arity on the message that was already there, and 6.37.

Two shipped files had written the workaround, which is the number this repository takes to mean build it. A second search in the same string could only be had by copying what was left of it — lib/pattern.sol jumping from one candidate to the next, and programs/expect.sol walking the markers in a line.

at := "a-b-c":indexOf("-").
at := "a-b-c":indexOf("-", at:add(#1)).
at:print.                    ; #4

#from may be one past the end, where the answer is nil rather than an error — the rule copyFrom has, so a walk that runs off the end gets an answer rather than a fault.

What it was worth, including where it was worth nothing. On the workload that started this — a substitution over 50,000 short lines — it is 2.35s to 2.25s, four per cent, which is noise. A short line makes a short copy. The copy is quadratic in the length of a line, so where it matters is a long one: searching a single 80,000-character line for a pattern with a common first character and no match goes 0.14s to 0.05s, and a megabyte on one line would have been twenty seconds. Minified JSON, generated code and a log line are all one long line.

And the second customer got shorter rather than faster, which is the better argument: expect.sol walked its markers by cutting the line down after each one, with arithmetic to keep track of where the cuts had come from, and now walks an index over one string — four lines shorter with the bookkeeping gone.

The language answers 138 messages, unchanged: a second arity is not a second message. Claims go 876 to 877.

The matcher stops looking where it cannot match — 35dee28, 2026-08-26

programs/edit.sol was measured on a file worth the name for the first time — 50,000 lines, 2.3 MB — and everything interactive was 0.03 to 0.05 seconds. Then :%s/alpha/ALPHA/g across the whole file took 7.7 seconds, which is not slow, it is a hang.

Two things were wrong and both were the program’s rather than the language’s.

The matcher tried a match at every position of every line. A pattern beginning with a plain literal can only match where that character is, so lib/pattern.sol works that character out when the pattern is compiled and asks indexOf — a primitive, scanning in C — where the next candidate is. Measured over the same 50,000 lines:

pattern before after
alpha 2.45 s 1.08 s
zeta 2.20 s 0.27 s
theta 2.33 s 0.76 s

The difference between alpha and zeta is how often the leading character turns up as a candidate that still has to be checked — the win is skipping verification, so a rare first character wins more. A pattern beginning with ., a class or anything starred has no such character and searches as it always did. A pattern now also knows the shortest match it can make and stops looking when fewer characters than that are left.

And the editor walked every line twice — once to count the matches for its report, once to replace them. pattern:substitutionIn answers the new text and the count together, in one walk, the way capture answers "output" and "status"; replaceIn and replaceAllIn are that with the count dropped.

7.7 s to 2.4 s, the same 32,818 lines changed, and all 136 of the editor’s behaviour checks unmoved.

What is left is the honest floor. string:indexOf scans those 50,000 lines in 0.007 s and the same scan written as a loop in Solum takes 0.85 s — 120 times. A library’s speed lives at the boundary with the primitives, and the way to be fast is to hand the scanning back across it.

No message was added to the language: 138 messages, unchanged. Documented claims go 874 to 876.

. repeats the last change, by repeating its keys — f362d97, 2026-08-26

. in programs/edit.sol, and 3. to do it three times. It is the last piece of vi’s grammar the editor was missing.

It repeats the keys, not a description of them. The other way is to remember what was done — an operator, a motion, a count, some inserted text — and do it again, which is a second description of every command that can change the text and a second place for the two to disagree. Keys are what the editor already understands: feeding them back through the dispatcher is the same path they took the first time, so . cannot drift from what it repeats. iX and escape, 3dw, o and two lines of typing, p — all one mechanism.

What counts as a change is what undo already decided. remember is called by the three methods that alter the text, so it is the one place that knows whether a command changed anything; it sets a flag for . on the way past. A command that only moves the cursor records nothing, and yy records nothing either — a yank is not a change, which is vi’s rule arriving as a consequence rather than as a special case. Colon commands are left out on purpose: :s/a/b/ changes the text and . does not repeat it, here or in vi.

A count in front of . replaces the one that was typed, so x then 3. deletes three, not one three times.

And it found a bug by being the first command that runs other commands. The count was cleared after an action ran rather than before, so the 3 of a replayed 3x joined the count still pending: x3. deleted the whole line. An action that dispatches keys of its own has to start from a clean state, and nothing before . had ever dispatched one.

Eighteen new behaviour checks, and the editor’s recorded transcript now repeats a change as well as making one. No message was added to the language: 138 messages, unchanged.

One window over standard input — 1ca018d, 2026-08-26

6.36, opened and closed the same day, which is the shortest life any entry on that list has had.

readLine read through stdio, which reads a block ahead; readKey and keyWaiting read the descriptor underneath it; and everything that arrived in the same block as the line was lost without a word:

printf 'one\nXY\n' | solvm program.sob     # was: readLine → "one";  readKey → nil
                                           # now: readLine → "one";  readKey → "X"

solum/src/stdin.c is the whole of the fix: one window over standard input that everything reads through — system:readLine, system:readKey, system:keyWaiting, and Solis’ own reader, both the line editor at a terminal and the plain one behind a pipe. Four kilobytes, filled by one read, handed out as lines or as bytes.

It belongs to the process, not to a VM. Standard input is one descriptor however many machines are pointed at it, and a per-VM buffer would divide what the operating system does not. sol_vm_init forgets what is held, which is what lets a test replace stdin between cases and start clean.

keyWaiting had to learn about the window, or one buffer would be worse than two — it would answer nothing is coming while holding a byte. It answers true for a held byte without asking the system anything.

Solis is exact now rather than nearly. The reference has always said the program and the prompt are reading the same input; behind a pipe the prompt read a block ahead and a script asking for a key got what was left of it. Both its readers take from the window now, and sol_input_read_line lost its FILE * parameter — it reads standard input, which is the only thing it was ever given.

And a line may hold a NUL, which was not the point of the change and is the best thing in it. fgets plus strlen ended a line at the first one and threw the rest of the line away; taking the line by length makes readLine agree with readFile, which has always kept them.

What has not changed: reading ahead still reads ahead, up to four kilobytes from a pipe or a file, which matters only when another process wants the same input — a program that reads a line and then hands stdin to a child with run may find the child short. That was true of stdio’s buffer before; the difference is that it is now this repository’s behaviour to describe rather than the C library’s to discover.

readKey is four lines. The language answers 138 messages, unchanged.

A read that gives up, and a bug found beside it — dcf05f5, 2026-08-26

system:keyWaiting(seconds) answers true or false: is there a byte to read, waiting up to that long for one to arrive.

escape := #27:asCharacter.
key:equals(escape):and({ system:keyWaiting(0.05) }):ifTrue({
    system:readKey.                       ; the "["
    ["up", "down", "right", "left"]:at("ABCD":indexOf(system:readKey)) }).

This closes the oldest known gap in the language. 6.10 ended with a paragraph headed what it cannot do: tell the escape key from the start of a sequence, because an arrow is three bytes and readKey answers one and blocks. examples/keys.sol said the same on the day it was written and added “worth knowing before writing anything that binds the escape key on its own”. Nothing bound it, so the warning stood correct and untested — until programs/edit.sol bound it to the most frequent action a modal editor has, and escape stopped taking effect until the next key arrived. The warning was written by a program that was not annoyed by it and waited for one that was.

A question, not a second reader. readKey(seconds) answering the byte or nil was the other shape, and nil already means the end of input — which is how every read loop here finishes. Overloading it with nothing yet leaves a program unable to tell there is nobody there from they have not typed yet. True at the end of input, where the readKey after it answers nil: there is something to read, and what is there is the end.

Fifteen lines of poll, and then a bug no test here could have caught. A terminal in canonical mode holds what is typed until a newline, and readKey sets non-canonical mode only for the length of one read — so asking between two reads was told nothing had been typed however much had, and every arrow key stopped working the moment this message was used. All 118 of the editor’s behaviour checks and every C test passed either way, because they read through a pipe and a pipe has no line discipline. It was found by driving the editor through a pseudo-terminal and pressing an arrow. The fix is the same raw-mode dance readKey does, around a call that reads nothing, and the test that pins it makes its own pseudo-terminal, writes [B with no newline, and asks.

And a defect found by reading the code beside it, now 6.36. The comment above readKey claimed buffered input from readLine was “flushed before going underneath it”. There is no such flush and there cannot portably be one — fflush on an input stream is undefined in C — so a program that calls readLine and then readKey loses whatever arrived in the same block as the line:

printf 'one\nXY\n' | solvm program.sob     # readLine → "one";  readKey → nil

The comment now describes the behaviour instead of an intention, a test pins the loss, and the fix — one buffer both readers take from — is its own change with its own argument. This is the first entry on the roadmap that arrived from reading rather than from wanting: every other one came from somebody wanting something and not getting it, and this came from somebody being told they already had it.

The language answers 138 messages, up from 137.

Undo, which is one array copy per change — 33be6b9, 2026-08-26

u undoes and ctrl-r redoes, a hundred changes deep, in programs/edit.sol.

A change is remembered by keeping the whole buffer, which sounds extravagant and is not. A line is a string, and a string in this language cannot be changed — so a copy of the array of lines shares every line with the buffer it came from. The copy is one pointer per line, and the text is never copied at all.

Measured, because that is exactly the sort of claim that is believed and wrong. Ten thousand lines of ten characters, and ten thousand lines of a thousand characters:

buffer a snapshot
10,000 lines × 10 characters 0.095 ms
10,000 lines × 1,000 characters 0.078 ms

That is one measurement twice. A hundred times the text costing nothing is what sharing looks like from the outside, and it is the whole argument for the design.

So it is a stack of buffers and not a list of inverse operations. How to undo a delete is the shape a mutable-string language is pushed towards, and it is a second implementation of every command — one to do it, one to undo it, and the second one exercised only when something has already gone wrong. The price here is array slots instead: a hundred states of a ten-thousand-line file runs under --memory=16M and not under 15M.

A command cannot forget to be undoable. Every change to the text goes through one of three methods — setLineAt, insertLine, removeLine — and those three are the only callers of remember. Adding a command that changes text and forgetting to make it undoable would mean writing one that changes text without changing a line.

A change is one keystroke, except in insert mode, where everything typed between i and escape is one. That boundary is drawn in the dispatcher rather than in the commands: a key arriving in normal mode closes the group, and the next thing that touches the text opens a new one. It is what makes u after a typed paragraph useful rather than infuriating.

Marks are part of the state. A mark that survived an undo would point at a line the undo had moved — the same silent failure the marks themselves were built to avoid a day earlier.

No message was added to the language: 137 messages, unchanged.

The editor learns vi’s grammar: counts, operators, registers and marks — 198fdbc, 2026-08-26

d and y over any motion, p and P, ma and 'a, and a count in front of all of it. dw, 3dw, d3w, d$, dj, dG, d'a, dd, 2dd, yy, y'a, xp, 3p, 10G, 3j — and none of them is a special case.

Because the notation is a grammar and not a table of keys, which is the structural point of the change:

[count] operator [count] motion

Any of the three may be absent: with no operator the motion just moves, with no count it happens once, and an operator standing where its own motion would go means whole lines — which is what dd and yy are. An editor that implements that as a table needs a row per pair; this one has two dictionaries and one dispatcher. A motion answers a place and moves nothing, an action does something, and the dispatcher decides which a key is and whether an operator is waiting for a place to work over. Adding e or f later is one line in the motion table and no change anywhere else, which is the test of whether the grammar was implemented or imitated.

The motions are the ones the cursor already used. An operator runs wordForward and puts the cursor back afterwards, so dw and w cannot disagree about where a word ends. That is the whole of placeAfter, and it is where the one bug of the rewrite lived: a cursor may not stand past the last character of a line, and a range end must be able to. dw on the last word of a file left the last character behind, because the motion clamped itself to a place a cursor may be. clamp knows the difference now, and it is one boolean.

A place carries how it should be read — whole lines or a piece of text, whether the character it lands on is inside the range, and whether the jump lands on the first non-blank. dj is two whole lines, d$ includes the last character, dw does not include the first character of the next word. Those three sentences are most of why vi’s deletions feel right. And one real vi rule that is not decoration: an exclusive motion ending in the first column ends at the end of the line before instead, which is what stops dw on the last word of a line from dragging the next line up into it.

One unnamed register, and which kind of thing it holds decides what p does: yy p copies a line below this one, yw p copies a word after the cursor. x fills it too, so xp swaps two characters — the smallest thing in vi that only works because deleting and yanking put their result in one place.

A mark is a row and a column, and the row moves when the text does. insertLine and removeLine shift the marks below them, and a mark on a line that is deleted is dropped rather than left pointing at whatever moved into its place — the failure that would otherwise say nothing. '' is where you last jumped from, which is the mark nobody has to remember to set.

And it gave 3.2 its first real customer. The two libraries that cite that entry wanted to stop a loop, which is the smaller 3.13. This dispatcher wants to stop a method: dd having been handled, nothing after it applies. It carries a done flag and wraps the remainder in done:ifFalse({ ... }) — the local case of the same absence, in the shape every dispatch table has.

No message was added to the language: 137 messages, unchanged.

Substitution, and a claim about absence that was wrong when it was written — 6156a6c, 2026-08-26

:s/find/replace/ in programs/edit.sol, with /g for every match on the line and :%s for every line in the file. & in a replacement is what was matched, and the delimiter is whatever character follows the s — so :s#/usr/bin#/usr/local/bin# needs no escaping, which is vi’s rule and worth having the moment a path is being edited.

It is deliberately not /find/replace/. /src/lib is a perfectly good search for a pattern with a slash in it, so a bare /a/b/ would mean deciding that certain searches are silently substitutions instead. vi put substitution on the colon line for exactly that reason.

lib/pattern.sol gained three messages: replaceIn, replaceAllIn and countIn.

@include "pattern.sol".

pattern:on("an"):replaceAllIn("banana", "[&]"):display.   ; b[an][an]a
pattern:on("x*"):replaceAllIn("abc", "-"):display.        ; -a-b-c-

A match that consumed nothing gets out of its own way. x* matches the empty string at every position, and a replace that searched again from where it started would never finish — so a zero-width match carries the character it stood on across and moves one further. sed answers the same, and it is the only answer that terminates.

countIn is there because the report is counted rather than compared. 17 substitutions on 9 lines, where the number of lines whose text ended up different would be a smaller number and a wrong one: replacing a with a changes nothing and is still a substitution, and that is exactly the case somebody checks by hand.

And the entry’s own title. The editor’s file said, in yesterday’s commit, that substitution was missing because the library answers only where a match begins, which is the one place it was left deliberately short. The library has answered where a match ends since the hour it was written — endOfMatchAt, two screens above the sentence claiming it was absent. Nothing caught it, because programs/expect.sol checks claims about what a line prints and there is no way to check a claim about what does not exist. It cost nothing this time, and it is the second kind of stale that 3.16 is about.

No message was added to the language: 137 messages, unchanged. Documented claims go 865 to 874.

Searching, and a regular expression small enough to fit — b066a18, 2026-08-26

programs/edit.sol searches: /pattern, ?pattern, n and N. Most of it is a new library.

lib/pattern.sol is regular expressions in the subset vi searches with — a character matching itself, ., *, [abc] [a-z] [^abc], ^, $, and \ to escape any of them:

@include "pattern.sol".

pattern:on("^[a-z]*ing$"):find("everything"):print.   ; #1
pattern:on("[0-9]"):find("port 80"):print.            ; #6

No groups, no alternation, no + or ?, no captures. Those want a backtracker over a tree rather than over a list, and nothing has wanted one. What is here is the half vi searches with, and it is the half that earns its keep.

The shape it had to have, and the number that decided it. The matcher recurses once per * and nowhere else; a run of ordinary items is walked in a loop. Measured against 3.5: 250 stars in one pattern work and 251 answers call depth exceeded, while the length of the pattern and the length of the text cost no depth at all — a 2,001-character line is searched at a depth of two, in 0.9ms. The textbook shape spends a frame per character of the text it is searching, which would have made the length of somebody’s line the thing that broke it, and a line is longer than a pattern by a factor nobody controls.

A small thing the language decided. find(text) and findFrom(text, at) are two names for one idea, because a block has one parameter list and a slot holds one block: a library written in Solum cannot answer one message at two arities the way at(key) and at(key, default) do on a dictionary. Primitives can; Solum cannot.

What the editor added on top. A file here is not one string — it is an array of lines and the cursor is a row and a column — so a search is a walk over lines rather than one call over the text, and ^ and $ therefore mean the ends of a line without anybody having decided that they should. Both directions wrap and say so when they do, because a search that comes round to the line it started on looks exactly like one that found something new. And a pattern that will not compile is a typing mistake rather than a fault: /[ab puts a pattern has an unclosed ‘[’ on the bottom line and leaves the cursor where it was.

examples/matching.sol is the library’s example and carries 23 of the claims checked on every build; the editor’s recorded transcript now searches, wraps, and is told there is no second match.

No message was added: the language answers 137 messages, unchanged. Documented claims go 839 to 865.

An editor, and the one message it asked for — 56706dc, 2026-08-25

programs/edit.sol is a modal terminal editor in the manner of vi, and the twelfth program here. It is the first that draws: every other one writes a line and reads a line, and this one owns the screen, places the cursor and redraws between one keystroke and the next.

It was written to find one thing, and the thing was written down first. ideas.md predicted, before the file existed, that an editor would want the terminal’s size and find nothing to ask — the one prediction on that list about an absence already confirmed rather than guessed at. That is what happened, in the first hour.

system:terminalSize answers a dictionary of "rows" and "columns", or nil when the output is not a terminal (6.34, raised and closed the same day).

size := system:terminalSize.
size:isNil:ifElse({ "no screen" }, {
    "{} by {}":fill([size:at("rows"), size:at("columns")]) }):display.

The absence was never the finding; the price of the workaround was. The number was always reachable, because stty prints it:

asked each ask
stty size through /bin/sh 7.0 ms
stty size with no shell 2.3 ms
the ioctl this message is about 0.001 ms

7ms is a fork, an exec and a pipe per keystroke for a program that measures each time it draws — so the editor measured once at startup instead, and a window resized after that was one it drew wrong until it was restarted. At a microsecond it measures every frame, and the missing resize signal stops mattering: there is no SIGWINCH here and none is added. tput lines is the counter-example worth naming — down a pipe it answers the terminfo default rather than failing, confidently and wrongly.

Four decisions in one small message. One message for both numbers, because two asks can straddle a resize and compose a screen that never existed. A dictionary, the way capture answers "output" and "status", because rows and columns are exactly the pair everybody remembers backwards. Nil rather than 24 by 80, because a default is a lie a program cannot see through and what to do without a screen belongs to the program — edit.sol picks 24 by 80 in its own file, where a reader can see it. And the output’s size, not the input’s, which is why a program reading a script and drawing on a terminal still gets a true answer.

The editor confirmed a warning that had only ever been theoretical. examples/keys.sol says a byte-level reader cannot tell the escape key from the start of an escape sequence. A modal editor binds escape to the most frequent action there is, and here it takes effect on the key after it — the editor reads that byte, finds it does not spell an arrow, and keeps it to act on rather than dropping it. Nothing is lost and nothing is misread; the screen waits. That is as sharp as that warning can be made, and it took a program to make it.

Tested by a recorded transcript, the way basic is: a scripted stream of keys, and every byte that reached the terminal compared with the bytes it wrote when somebody last looked. It is deterministic because standard output is a pipe there, so the size is nil and the editor’s own fallback decides. The suite makes a real terminal for terminalSize itself — posix_openpt and a TIOCSWINSZ of a size the test chose, rather than openpty, which would want -lutil on Linux and the front page promises no dependencies.

The language answers 137 messages, up from 136.

0.34.0 — 2026-08-25

Two integer literals, and an interpreter checked against a suite it did not write. .sob files are format version 14, unchanged, and bytecode from 0.33.0 still runs — the only C touched is the scanner and one branch of the compiler.

$FF08 and %10101100 write the same integer in the base you are thinking in. A colour, a file mode and a set of flags are all patterns of bits, and #493 does not look like rwxr-xr-x to anybody. It is sugar: all three spellings reach the same constant and nothing downstream is told there was more than one. The gap was already written down — the reference’s own passage on file modes said “Solum has no octal literal” and then showed the round trip through asBase as the way round it, a language that could print hex and binary and not read one back.

And programs/basic.sol was run against the NBS Minimal BASIC Test Programs — 208 programs written at the National Bureau of Standards in 1980 against the standard ECMA-55 mirrors, and the first test of that interpreter this repository did not write. It found seven defects, and none of them had been caught by the eighty-three claims in the file, which is the whole argument for an external suite: those claims check what the author thought to check.

The disagreements went 16 to 5, and the five that remain want a person at a keyboard the harness cannot offer. The sharpest of the seven was a rule that had been invented here — a FOR re-entered on the same control variable abandoned the old frame, which guarded against a listing the standard already forbids and broke three it allows.

Thirty of the suite’s programs are accepted where the standard is stricter, and that is allowed only with documentation describing what the processor does with them. That documentation is now a table in basic.sol.

One number left these documents rather than being corrected. 3.13 had counted the loops carrying a flag to stop themselves, in four places, and the count was stale again. It cannot carry a marker — a loop carrying a flag is a property of source text, and the first attempt at recounting it returned sixty. The argument never rested on the number, so the number is gone and its absence is recorded as a decision.

The language answers 136 messages, unchanged. Claims go 830 to 839.

Hexadecimal and binary integers — deeb34b, 2026-08-25

$FF08 and %10101100 write the same integer in the base you are thinking in. A colour, a file mode and a set of flags are all patterns of bits, and #493 does not look like rwxr-xr-x to anybody.

%111101101:asBase(#8):display.   ; 755
$FF08:print.                     ; #65288

It is sugar and nothing else. One case in the lexer, one branch in the compiler; all three spellings reach the same constant and nothing downstream is told there was more than one. No opcode, no message, and .sob files are unchanged at format version 14.

The gap was already written down. The reference’s own passage on file modes said “Solum has no octal literal, so #493 is what 0755 looks like written down”, and then showed the round trip through asBase and asInteger as the way round it — a language that could print hex and binary and not read one back. That passage says %111101101 now, which has the three permission triples where a reader can see them.

Two decisions, both saying no to something. They carry no #: that tag exists because 45 and #45 are the same characters with two readings and it says which, and $FF has one reading, there being no hexadecimal float. And they take no sign, where #-45 is allowed — these are for looking at bits, and the language already declines to reach a negative that way (3.12). #0:sub($FF) is how to ask.

A digit the base does not use is an error rather than the next token. Without that, %1012 is the binary %101 followed by the float 2 — two good tokens, a wrong reading and no complaint. $FF.5 is refused for the same reason #45.5 is, and it had to be added deliberately: it compiled, ran, printed 5 and said nothing.

One test had to move. test_an_error_token_points_at_the_source needs a character nothing has claimed, and used %; the comment beside it already recorded losing @ to directives. It uses ? now, and says that the list of unclaimed characters gets shorter each time the language grows.

The NBS conformance suite, and the seven things it found — 6ca6245, 2026-08-25

Every test of programs/basic.sol was one this repository wrote, so they checked that the interpreter does what its author read ECMA-55 to say rather than what it says. The NBS Minimal BASIC Test Programs, Version 2 — 208 programs written at the National Bureau of Standards in 1980 against ANSI X3.60-1978, a US government work and public domain — are the first test of it that somebody else wrote.

programs/basic/conformance.sh fetches and runs them. They are not vendored: they are somebody else’s 208 files, this repository has never carried a dependency, and a suite is not the sort of thing to fork quietly. It is not in make test either — it needs the network, and the suite is written for a person to read rather than for a machine to score.

Seven defects, and not one had been caught by the eighty-three claims in the file.

   
DATA is raw text an unquoted datum runs to the next comma and may hold anything but one, so DATA +. - is legal. Reading it with the tokeniser refused a fifth of the suite.
a datum has no type until a READ takes it. DATA F,6 into D$ is the string 6; the same 6 into A is the number. Deciding at DATA time had it exactly backwards.
DEF needs no parameter DEF FNM=123, referenced as a bare FNM.
NEXT searches the stack a listing may GOTO out of an inner loop, and then its NEXT must find its own FOR further down rather than insisting the innermost matches.
FOR always pushes two loops may run on one control variable when the inner is reached through GOSUB. Abandoning the outer frame was invented here — a guard against a listing the standard already forbids, which broke three it allows.
DIM is a declaration the suite references arrays before the line that dimensions them, and says so in a comment. It joins DEF, DATA and OPTION BASE, all collected before anything runs.
exceptions that continue TAB(0) must use 1, carry on, and say so. Every failure here was fatal until this; there is a warn now, and where it goes is the caller’s business — standard error from a file, the screen at the prompt.

The result: 99 of 208 run to the end, 99 are refused and meant to be, and the five still refused all want a person at a keyboard the harness cannot offer. Five more exceed its step limit — the statistical RND tests, which is an interpreter running inside an interpreter and not a defect.

Thirty programs are accepted where the standard is stricter, and that is allowed: P054 says a processor may either reject such a program or accept it and be accompanied by documentation describing what it does with it. That documentation is now a table in basic.sol — lower case, lines out of order, line numbers outside 1–9999, long lines, END anywhere, unbounded strings, a letter used as both variable and array, lexically nested FOR on one variable (and which loop wins), and silent underflow.

One thing the language taught along the way: a block in a slot is a method, so it cannot be held as data and asked for back — self:report calls it. The default is a block that does nothing rather than a nil to test for, which is shorter and has no branch in it.

A number 3.13 kept getting wrong, removed rather than corrected — 65f04e2, 2026-08-25

3.13 said nine sites carry a boolean whose only job is to stop a loop. It was nine when it was written and is not now — basic.sol alone added two, one of them the loop its prompt runs on. The number was stated in four places: the entry, its decision table, ideas.md twice, and lineage.md.

It is described rather than counted now, which is the third time today a number in prose has turned out to be unchecked and the first time the answer was to delete it instead of fixing it.

index.md said nine programs and the README said 123 messages; both got markers, because the checker can recount them from the running machine. This one cannot. A loop carrying a flag is a property of source text, and a grep cannot tell one from an ordinary counted loop — the first attempt at recounting returned sixty, which is how that was learned rather than assumed. A real count would need an analysis of the parse, which is a great deal of machinery to keep a number that was never doing any work.

Because the argument never rested on it. It rests on the shape recurring, which it does, in more files than when the entry was written — and on almost none of them saying anything about it, since a complaint is somebody noticing and a file reaching for the same shape without comment is an idiom. The entry names the files and drops the tally, and says outright that the count is gone on purpose so that its absence reads as a decision.

This is the same call made this morning on index.md’s thirty-two files in two directories: a number that cannot be checked and does not carry the argument is better deleted than corrected.

0.33.0 — 2026-08-25

One message, and the program that asked for it got an interface. .sob files are format version 14, unchanged, and bytecode from 0.32.0 still runs.

system:writeError(text) closes the last thing on the roadmap. 3.19: until this, display, print and system:write all went to standard output and nothing went to standard error, so a program had no way to separate what it produced from what went wrong producing it. The machine had the stream the language did not — solvm writes its own diagnostics there and a test holds it to that. With it, programs/basic.sol run over a .bas file leaves the program’s output in a redirect and the complaint on the terminal.

Its own message rather than a destination on write, and deliberately not a second display: that message and print are about rendering a value and serve every type, so a variant of each pointing elsewhere would be the second mechanism behind the first this language exists to refuse.

And BASIC gained the interface it actually had, which is the thing the last three releases were building towards without saying so. One rule and six commands: a line beginning with a number goes into the program, a line that does not happens now, and LIST, RUN, NEW, LOAD, SAVE and BYE do the rest. It is written with system:write and read with system:readLine — the two halves of 3.18, in use a day after being asked for.

Two costs are recorded because they were measured rather than noticed. Reading a program and linking it had to come apart, because a prompt is where 10 GOTO 100 gets typed before line 100 exists — the thing that makes a jump an array index is the thing that makes an edit invalidate one. And keeping the source text so LIST can show it back put one more call between reading a line and parsing it, which took the deepest listing the parser handles from 60 brackets to 59. That would have shipped unnoticed if the number were a sentence rather than a running claim.

float and the rest are unchanged; the language answers 136 messages, up from 135. Claims stay at 830.

BASIC gets a prompt — 8e01bf8, 2026-08-25

./bin/solvm programs/basic.sob --repl is the interface BASIC actually had. One rule and six commands: a line beginning with a number goes into the program, a line that does not happens now, and LIST, RUN, NEW, LOAD, SAVE and BYE do the rest. A number on its own deletes that line, which is how a line is removed when the only editor you have is the line you type again.

It uses both of the things this program asked the language for, a day after asking: the prompt is written with system:write and the answer read with system:readLine, which is 3.18 in use rather than in an entry.

Reading a program and linking it had to come apart. The four load-time passes need the whole program — a GOTO cannot be resolved until every line exists — and a file supplies that at once. A prompt does not: 10 GOTO 100 is an ordinary thing to type before line 100 exists. So entering a line now only parses it, which catches a syntax error where it was typed, and RUN links. That is the cost of having moved the line lookups to load time arriving where it was always going to — the thing that makes a jump an array index is the thing that makes an edit invalidate one.

And keeping the text cost a frame. LIST has to show back what was typed, and the parser had been throwing it away; remembering it put one more call between reading a line and parsing it, which stands on the stack while the expression parser recurses beneath. The deepest listing this reads went from 60 brackets to 59. A feature at the prompt paid for itself in nesting somewhere else, which is invisible unless something is measuring it — and something was, because that number is a running claim in the file.

One defect found by driving it: every error at the prompt carried a line number, and it was whatever line had run last. LOAD "missing.bas" reported line 99: there is no file missing.bas. Line zero means there is no line now, and then the message says only what went wrong. It improves a file’s messages too — a first line with no number said line 0: before, which is a line that does not exist.

A recorded session is the test, because a prompt is exactly what a claim in a comment cannot check: what it does is a conversation, and the interesting part is what it remembers between one line and the next. programs/basic/session.in types a program out of order, lists it, runs it, reads a variable the run left behind, inserts and deletes a line, saves, clears, loads back, runs again, and then makes four different mistakes to check the prompt survives each. session.out is what it must still print, compared byte for byte — and what SAVE wrote is compared against what LIST showed, which is the claim that nothing is regenerated from the parsed form on the way out.

system:writeError, and the third entry closed — 084e130, 2026-08-25

system:writeError(text) writes a string to standard error and adds nothing, which is the only way a Solum program can reach that stream. 3.19 is built and moved to COMPLETED.md, and the roadmap is empty again — three entries from one program, all three closed.

Its own message rather than a destination on write, which was the question the entry left. system:write(text, 'error) would make the common case carry an argument it never wants and force every call site to name a stream almost all of them agree about. Two names read as the two streams a process has.

And deliberately no second display. That was the thing to get right rather than the naming: display and print are about rendering a value and serve every type, so a variant of each pointing elsewhere would be the second mechanism behind the first that this language refuses. There is one way to reach standard error and it is spelled as writing.

In the program that asked, the two streams now carry different things:

$ solvm basic.sob half.bas > out.txt
line 20: division by zero
$ cat out.txt
A

A is what the listing printed before it failed. The diagnostic is not part of that and is no longer in the file; 2>/dev/null silences the complaint without silencing the program, and the status is still 1.

examples/reading.sol had the same bug, and nobody had noticed because until now there was nowhere else to put it: its nothing on standard input complaint had always gone to standard output, mixed in with the numbered lines that are its result.

One test had to be rewritten, and how it failed is the entry in miniature. It compared the merged streams with 2>&1, and broke the moment they were separated — both because the two carry different things now and because merging puts them in the wrong order, stdout being block-buffered down a pipe where stderr is not. It asserts each stream on its own.

The message count goes 135 to 136, which is the third place that number has had to move today and the first time a marker moved it.

0.32.0 — 2026-08-25

The language gained twelve messages, which is the most it has gained at once in a long time — and every one of them because a single program asked. .sob files are format version 14, unchanged, and bytecode from 0.31.0 still runs: these are messages, not instructions.

programs/basic.sol is the eleventh program, an interpreter for ECMA-55 Minimal BASIC (1978). All twenty statements of that standard and all eleven of its supplied functions, running about 420,000 BASIC statements a second, with four recorded transcripts compared byte for byte on every build. It was chosen for being a different shape from the other ten — an interpreter for another language rather than a tool for this one — and what it found came from that shape rather than from anything anybody planned.

It put three entries on the roadmap in a day and closed two of them. The list was empty when this release opened and has one thing on it now, which is the mechanism this project runs on working at speed rather than an exception to it. 3.14 had been waiting since it was written for a program that wants an angle; this one wanted six of them and an exponent operator, because they are on the page of the standard it is measured against, and an interpreter cannot decide to want less. Eleven messages landed as one decision: pow, exp, log, sin, cos, tan, asin, acos and atan on float, and float:pi and float:atan2(y, x) on the class. 3.18 was found by the same program’s INPUT: there was no way to write to standard output without ending the line, so a prompt could not sit beside its answer. That is system:write now. The third, 3.19, is open: there is no way to write to standard error either, so a listing that fails puts its diagnostic in the output file. It was raised an hour after the list had emptied.

Two of the wrong turns are recorded rather than tidied away, because both are about the same thing. A dispatch comment offered a choice between two options when the repository already contained a third, documented and measured, in lib/control.sol — and the recovery measured both halves of that library’s own advice and settled, with numbers, what a primitive for it would and would not buy. And the interpreter was called finished twice before a direct question found a formatter that crashed on PRINT 1/0 and a deviation from the standard that had never been written down.

float answers 26 messages before this release and 35 after. Claims go 764 to 830.

A listing that failed said it had not, and left zero — 648977e, 2026-08-25

solvm basic.sob x.bas reported the error and exited 0, so solvm basic.sob x.bas && ... ran the next thing after a program that never worked. A missing file exited 1 correctly; a broken listing did not, which is the worse of the two because it is the case a script will actually meet.

The cause is that the same block ran the demonstrations inside the file and the listing named on the command line, and swallowing the error is right for the first and wrong for the second. It answers whether the listing ran now, which the demonstrations ignore and the file path does not.

And the pending output is flushed before the error, which the same fix turned up. PRINT builds a line and ends it, so a listing that fails halfway through one has already produced text that nothing would otherwise write — the cost of buffering, invisible until the moment it is not:

A
line 20: division by zero

Both are held by test_cli now, one failing at load and one part-way through, because the two leave by different paths.

One thing this could not fix, and it is 3.19 now: the message goes to standard output, where a diagnostic belongs on standard error. Solum has no way to write there — display, print and the new system:write all go to stdout — so solvm basic.sob x.bas > out.txt puts the error in out.txt and 2>/dev/null does not suppress it.

Both workarounds were measured before the entry was written, and neither is worse than the gap the way 3.18’s was — they are ugly rather than wrong. /dev/stderr is a path that only exists on Unix and spells the thing as writing a file; a shell costs half a second for a hundred diagnostics, five milliseconds a line to write a line. That is what makes this a smaller entry than its sibling, and why it is written down rather than worked around.

The list emptied at midday and had this on it an hour later, which is three entries from one program in a day. The roadmap says so in as many words: empty is a description of a moment rather than a destination.

The counts nothing was checking — ae5b5ea, 2026-08-25

Found while cutting this release, by reading the pages a newcomer reads.

index.md said there were nine programs, and listed nine. There have been ten since bench.sol and eleven since basic.sol, so it had been wrong for two releases. It said thirty-two files in two directories as well, which was a sum of two numbers that had both moved.

The reason is that it had no marker. Every other count of this kind carries one — ten<!--count programs--> — and the checker recounts it on every build; this sentence did not, so nothing looked. It has one now, which is the actual fix. The sum is gone rather than corrected, being a third number no marker can check.

And the README’s own first paragraph said 123 messages, where there are now 135. That number is the one this repository has got wrong most often: the journal records it going 125 to 124 to 123 in a single evening, by hand, from grep, twice. The reference’s index has been held to the registry by a test all along — nothing held the prose to the index.

So messages is a marker now too, and the checker computes it rather than reading it off a page. A name a class holds is a message when it is built in and a slot when it has a value, and slotAt tells them apart by refusing the first kind and answering the second. Without that distinction system:arguments and error:message count as messages and the total comes out two too high. It was verified by writing the wrong number down and watching the build fail.

The repository’s description on GitHub still says 123 and is the one place no test can reach.

Asking whether BASIC was finished, and finding it was not — 6757f56, 2026-08-25

Two things, both found by asking the question rather than by a test failing.

PRINT 1/0 failed inside the formatter, with 'floor' is out of integer range — a true sentence naming a Solum primitive the listing never sent. The cause was digits taking the logarithm of an infinity to find its decimal exponent. The fix is not in the formatter: Solum’s arithmetic reaches infinity and nan rather than trapping, which is IEEE and right for Solum, and the standard makes both an error a listing is told about. So the check went where the value is made — every arithmetic result, every numeric literal and every supplied function’s answer — and division by zero is named separately, because 0/0 is nan and 1/0 is infinity and neither message would have said what happened.

And one deviation from the standard was undocumented, which is worse than the deviation. ECMA-55 makes a space insignificant outside a string, so FORI=1TO10 and PRI NT are legal BASIC. Neither runs here. Nothing in the file said so, while the file said in several places that it implements the standard.

It is written down now, in a Where this is not the standard section, along with what fixing it would take: a tokeniser that ignores spaces cannot work left to right on characters alone, since FORI is FOR I only because a statement begins with a keyword, and 1TO10 is three tokens only because TO cannot continue a number. The scanner would have to know where it is in the grammar. That is a different design, not a missing branch. A second section records the four places the standard lets an implementation choose, so that a choice does not read as an accident.

Both are demonstrated rather than only described — six more listings at the bottom of the file, and the inline claims go from 77 to 83.

BASIC finished: what a number looks like, and transcripts that hold it to it — 5e88c1d, 2026-08-25

Stage five, and programs/basic.sol is done. The rest of PRINT’s formatting, and a recorded transcript for four of the listings in programs/basic/ compared byte for byte on every build.

PRINT shows six significant digits, with no nought before the point. Solum prints the shortest text that reads back as the same double, so 1/3 is 0.3333333333333333 and right; BASIC shows .333333. The standard requires at least six digits and leaves the rest open, so six is a choice made here rather than a rule being followed, and it is written down as one.

A million comes out as 1E+06, which looks like a defect and is the standard: seven digits to the left of the point is more than six significant digits can describe, so the scaled form is the only honest one. The thresholds either side of it are the same kind of implementation choice, also written down.

Computing the decimal exponent needed the mathematics that landed this morning — and it met the oldest trap in doing so. log(1000000)/log(10) is 5.999999999999999, whose floor is 5, which would print a million with its digits counted from the wrong place. The fix is the one every language has arrived at: work the exponent out, then look at what you got and correct it.

TAB(n) puts the next thing in a column, and does nothing when the column has already gone by — a blank line appearing in the middle of a table being harder to explain than a column that did not move. It is caught in PRINT’s own parser rather than in the expression grammar, because it says where the next thing goes and there is nowhere in an expression for that to mean anything. A margin at 72 columns, which a comma wraps at: five print zones fit, so a sixth number starts a line.

And the transcripts, which are what the claims in comments cannot be. programs/ is not one of the documentation checker’s subjects, so a comment there is true because somebody looked — and the output of a BASIC program is exactly where that fails: print zones, six significant digits and the trailing space after every number are invisible to a reader and all load-bearing. sieve.bas, temperature.bas, stats.bas and wave.bas each have a .out beside them now, compared exactly.

wave.bas is there for a second reason. 3.14 said it was waiting for “a plotter, a simulation, anything with coordinates or a waveform”, and until this morning this interpreter could not run one. It draws a sine wave with ATN, SIN and TAB.

The documented claim count does not move, and that is the point of the transcripts. It stays at 830 because programs/ is not a subject of the checker: this file’s own inline claims went from 68 to 77 and none of them is checked by it. The four .out files are what checks them.

system:write, and the roadmap empties again — c3fa1ec, 2026-08-25

system:write(text) writes a string to standard output and adds nothing — no newline, no rendering. 3.18 is built and moved to COMPLETED.md, which leaves the roadmap empty: one program raised two entries in a morning and both were closed the same day.

It went on system, beside readLine, which was the one question the entry left open. print, display and asString are a trio about rendering a value — the literal form, the text, and the text as a value — and this is not a fourth member of that. It is about a destination, and the destination is where readLine already lives; the two are the two halves of one terminal. It takes a string rather than any value, following writeFile, so there is no second rule about how a value becomes text.

It flushes, which the entry did not think of and which is most of the point. Text with no newline after it sits in a line-buffered stdout until one arrives — and for a prompt that means until after the answer has been read, which is the bug this was built to fix wearing a different hat.

And it writes to the same stream display does, which is the difference between this and the workaround the entry recorded. system:writeFile("/dev/stdout", text) opened a second stream on the same file and reordered the whole transcript the moment the output was not a terminal.

basic.sol’s INPUT got more than it asked for. Whatever a PRINT left open is now written out without a newline before the ?, so a prompt the listing wrote and the ? the interpreter writes land on one line:

TWO NUMBERS, SEPARATED BY A COMMA? 3, 4
SUM IS 7

That was two lines and a stray ? for the two days between the statement being written and the entry being closed.

examples/reading.sol gains it — the example about the terminal, where asking now comes before reading. Claims go 828 to 830.

The mathematics 3.14 was holding, all eleven at once — 5018395, 2026-08-25

pow, exp, log, sin, cos, tan, asin, acos and atan on float, and float:pi and float:atan2(y, x) on the class. 3.14 is decided, built and moved to COMPLETED.md with its whole argument intact. .sob files are format version 14, unchanged, and bytecode from 0.31.0 still runs — this adds messages, not instructions.

The trigger was basic.sol, and it fired by accident. BASIC was picked for being a different shape from the other ten programs — an interpreter for another language rather than a tool for this one — not for wanting arithmetic. It turned out to want six functions and an exponent operator because they are on the page of ECMA-55 it is measured against, which made it a harder case than the plotter the entry imagined: a plotter that wanted one angle could have been written to want none, and an interpreter cannot decide to want less.

Eleven, not the seven that were wanted. asin, acos and atan2 are here although no program asked, because they fail the same test sqrt failed: asin(x) written by hand is atan(x / sqrt(1 - x*x)), which divides by zero at the ends of its own domain, and atan2 is atan(y/x) with quadrant fixups everybody gets wrong on the axes. pi is the one member that does not fail that test — anybody can type 3.141592653589793 and have the nearest double exactly — and it is in so that a language with sin and cos is not one where the first thing every program does is write out a constant.

The three questions that entry parked are answered, two of them by BASIC. pi is float:pi rather than a third global: infinity and nan are globals because they are values the arithmetic reaches and has no other way to name, while pi is a constant — and pi is a name a program is entitled to want, which is the argument lib/math.sol already makes for binding no global of its own. Angles are radians, following C and the standard; degrees are a multiplication, and a multiplication is not something the machine has to supply. And atan2 takes two coordinates of which neither is the subject of the sentence, so neither is the receiver: float:atan2(y, x) is class-side, the way time:fromSeconds and array:of are.

None of them raise. log(0) is -infinity and log of a negative is nan, following sqrt and division. A language with stricter rules imposes them itself — basic.sol raises for SQR(-1) and LOG(0) because ECMA-55 says to, on top of a Solum that quietly answers nan.

And the size argument the entry worried about turned out to be the small part. Eleven primitives are eleven lines of C. The work was the four things a new message obliges, each held by a test: sent by an example with a checked claim, in the reference’s type table, in the message index, and on the cheatsheet. float answers 26 messages before and 35 after — nine rather than eleven, because pi and atan2 are class-side, so the class answers them and a float does not. Claims go 808 to 828.

basic.sol is finished as a language. Stage three landed the same day: ^ and the six functions that had been raising for two days, each of them one line. All twenty statements of Minimal BASIC and all eleven supplied functions now work, and 2^3^2 is 64 — the standard’s left grouping, which could not be demonstrated until there was an operator to demonstrate it with.

One tidy-up found on the way: the reference’s message index listed atPut twice, once for array and once for dictionary, where every other message listing two types puts them in one row.

BASIC gets its data, and every statement in the standard — 6c25056, 2026-08-25

Stage four of programs/basic.sol: text variables, arrays, DIM, OPTION BASE, DATA/READ/RESTORE, INPUT, DEF FN, RANDOMIZE, and five of the eleven supplied functions. With those, all twenty statements of ECMA-55 Minimal BASIC are implemented, and what is left of the language is six functions: SIN, COS, TAN, ATN, EXP and LOG, which are 3.14 and a decision rather than work.

Twenty, not the nineteen this program has been claiming since it was scoped. OPTION BASE was missing from the count and from the plan, found by running out of statements to implement and going back to the standard to see what was left. It is implemented too, including the standard’s two rules about it — one per program, and before any DIM — because an OPTION BASE written after the array it was meant to shape changes every answer quietly.

Stage four went ahead of stage three because it did not depend on it, and then took most of it anyway. A(1) and ABS(1) are the same shape, and nothing in BASIC’s grammar tells an array reference from a function call — a language with no keywords for its own library has nowhere to put the distinction except the name. So arrays could not be built without the machinery that calls a function, and the five functions that do not need 3.14 came with it. Fortran made the same choice in 1957 for the same reason.

And INPUT found 3.18: a program cannot write to its own output without ending the line. BASIC prompts with ? and reads the answer typed beside it; this prints the ? and reads from the line below, because display and print are the only ways a Solum program has to write and both end the line.

The workaround is worse than the gap, which is the part worth recording. system:writeFile("/dev/stdout", "? ") writes without a newline and looks like the answer. It opens a second stream on the same file, so when the output is a pipe or a file — where the first stream is block-buffered and this one is not — the prompt overtakes everything printed before it:

one          what the program printed, in order
two? four? one
three        what came out of a pipe
five

It works when tried by hand and silently reorders the transcript the moment anything is redirected, which is the shape of both hand-written square roots in 3.14. The entry asks for one primitive and one question: whether it goes on system, beside readLine, or on string, beside display.

It also runs a listing from a file nowsolvm basic.sob programs/basic/sieve.bas — which is part of stage five brought forward, because INPUT reads standard input and so cannot be one of the demonstrations that run on every build. programs/basic/ has the sieve and an adder, and test_cli runs both, the second with its answers piped in.

Two smaller things. A near miss with 3.1: the six blocked functions were nearly written as a loop binding one block per name into a dictionary, which would have stored six blocks that outlived the frame they were written in. They are a list of names tested before the lookup instead, which is shorter anyway. And SIN(0) reported SIN is not an array: an array is named by a single letter — a true sentence about the wrong thing, because the blocked names were not in the function table and so fell down the array branch of the fork. The kind of message that sends somebody looking in the wrong place for an afternoon.

BASIC gets control flow, and a hot loop settles an argument — 16c0b30, 2026-08-25

Stage two of programs/basic.sol: GOTO, IF-THEN, FOR/NEXT, GOSUB/RETURN, ON-GOTO and STOP. That is the whole of Minimal BASIC’s control flow, and it turns the thing from a calculator that reads line numbers into a language you can write a program in — Fibonacci and a times table run on every build, because the only reason to put them in the file is that they work.

A BASIC program is a graph and its edges are line numbers, so all of them are followed at load in three passes. A jump becomes an index into the run order rather than a search of the listing, which matters because the jumps in a BASIC program are its loops and the search would be per iteration. A jump to a line that does not exist is reported before the program prints anything. And FOR finds its NEXT, which is what lets a loop with an empty range skip its body — it already knows where the body ends.

It runs about 420,000 BASIC statements a second, or 384,000 for the same work written with IF and GOTO instead of a counted loop. That is roughly ten times what the scope estimated, and it is the argument for having parsed once at load rather than once per pass.

3.2 never came up, which is worth recording because it sounds like it should have been the whole problem. A language with no non-local return, interpreting one whose defining feature is GOTO: every jump here is an assignment to a program counter and a flag saying the counter has already moved. No frame is entered and nothing is unwound, so a GOTO out of the middle of anything is the same statement as any other. The same goes for 3.5GOSUB and FOR keep explicit stacks in arrays, so nesting costs an array entry and no frames at all.

And the hot loop settled the ifElseIf question, which had been asked in the abstract that morning. lib/control.sol said a primitive would need a program running it per iteration of something; basic.sol grew one the same day, since every IF in a running listing goes through one dispatch. Written with ifElseIf, a 20,000-iteration loop took 0.30s; written as a staircase, 0.246s. Twenty-two per cent of the whole interpreter, for six arms. It went back to the staircase, and the number is in control.sol beside the depth measurement from the morning.

So both edges of that library’s niche are now measured, and the niche is narrow. ifElseIf is out of the recursive dispatches on depth — 60 brackets against 39 — and out of the hot one on speed. What is left to it is the flat, cool, many-armed case: a tokeniser, and disasm.sol reading constant tags. Where a hot dispatch has many arms it wants a dictionary rather than either, because a dictionary asks one question instead of n. That points both ways on whether the VM should take it over, and the entry says so: a program reached for it in a hot path and had to give it up, which is what happened to the four loops before they were built in — but what it gave it up for was a six-arm staircase that reads perfectly well.

Two rules of the dialect are enforced and will look like bugs to anyone who knows a later BASIC. THEN takes a line number and not a statement, so IF X > 0 THEN PRINT "YES" is refused with a message that says where to put the PRINT. And text compares with = and <> only, < on strings having no meaning in Minimal BASIC — refused rather than falling back on the byte order Solum would happily supply. GO TO and GO SUB written as two words are the same statements as one, since spaces are not significant in this dialect.

A BASIC interpreter, and the trigger 3.14 was holding open — 513a280, 2026-08-25

programs/basic.sol is the eleventh program, and the first that is an interpreter for another language rather than a tool for this one. The dialect is ECMA-55 Minimal BASIC (1978), chosen because a published standard settles what counts as finished without the interpreter’s author having a vote. This is stage one of six: LET, PRINT, REM, END and the whole numeric expression grammar, which is enough to run a listing from its lowest line number to its highest.

It fired 3.14 on its first day. That entry — no pow, no log, no exp, no trigonometry — had been waiting since it was written for a program that wants an angle, and had never had one. Six of Minimal BASIC’s eleven supplied functions are SIN, COS, TAN, ATN, EXP and LOG, and ^ needs pow.

What makes it a stronger case than the plotter that entry imagined is that this program cannot decide to want less. A plotter that wanted one angle could have been written to want none. An interpreter is measured against a document it did not write: either PRINT SIN(0) gives 0, or it is not an interpreter for that language. The trigger fired by accident, too — BASIC was picked for being a different shape from the other ten programs, not for wanting arithmetic.

^ raises rather than being stubbed, naming the entry in the message. The obvious stub is repeated multiplication, which is exact for 2^3 and cannot answer 2^0.5 at all — right wherever anybody tests it and silently wrong outside, which is how both of that entry’s hand-written square roots got through. So there is now one decision outstanding, the first since 6.32 was deferred, and stage three cannot start until it is taken. BASIC settles two of the three questions 3.14 parked: its functions take radians, and its ATN takes one argument, so atan2’s missing receiver need not be answered to unblock it.

And a happier finding: line numbers are what make the job fit. SOL_FRAMES_MAX caps recursion at about 254 (3.5), and a tree-walking interpreter for a modern language spends frames in proportion to how deeply its input nests — it would run out of machine before it ran out of program. A line-numbered BASIC never nests: the run loop is a program counter over a sorted table of lines, and GOSUB and FOR will be explicit stacks in arrays, which is heap and not frames. The only recursion is in the expression parser, and it runs once at load rather than once per execution.

All three ways of writing a dispatch turned out to be needed, and the file now says why at each site. 3.2 gives no early return to leave a chain from, so a staircase of ifElse is as deep as it has arms — which is what array:ifElseIf in lib/control.sol exists to fix, and that library states its own price: use it for a flat dispatch and not inside a recursion. Both halves were checked here rather than quoted. The tokeniser is flat, so it uses ifElseIf, and the cost is a third more load time (2,000 lines in 0.43s rather than 0.32s) and no depth at all. primary and evaluate recurse, so they keep their staircases: the deepest listing this reads is 60 brackets, and the same measurement with ifElseIf in primary gives 39. The keyword table is a dictionary, because nineteen keywords through ifElseIf would be nineteen block calls and nineteen string comparisons to recognise STOP, where one hash lookup does — the trade turning on whether the conditions are arbitrary questions or one question asked about different constants.

Two smaller things. The run loop is left four ways and only one of them is its condition, so it carries a boolean whose whole job is to stop it — another site for 3.13, and ifElseIf itself is another, which the library already counted. And PRINT buffers a whole line, because display is the only way this language has to put text on a terminal and it ends the line; that models PRINT exactly and will stop doing so at stage four, where INPUT "NAME"; N$ has to show a prompt and read the answer beside it.

It is the sixth file to use lib/scan.sol and the first that is not a rewrite of a cursor it had already written for itself, and the second program to use lib/control.sol’s ifElseIf — after disasm.sol, which reaches for it in the same place and for the same reason: a flat decision about what a byte starts.

0.31.0 — 2026-08-25

Nothing in the language changed. What changed is that two libraries and every compiler warning are now checked, where before they were not. .sob files are format version 14, unchanged, and bytecode from 0.30.0 still runs.

lib/scan.sol shipped this morning with nothing checking it — verified by a harness written once in a scratch file and then deleted, which is the shape of every claim this repository has since had to go back and check. lib/shell.sol had never had a test of any kind. examples/scanning.sol and examples/commands.sol are 44 claims between them, run and compared on every build, and they take the documented claim count from 764 to 808.

A diagnosis was wrong on the way and is recorded rather than quietly fixed. The obvious reading was that the documentation checker never looks at lib/. It does not, and it barely matters: across all seven library files there are nine lines that print with a comment, because a library is an implementation and not a demonstration. The hole was never where the checker looks — it was that three libraries had nothing to look at.

And 45 shipped files now have to compile without solas saying anything, where before they only had to compile. Both of the compiler’s warnings exist because the failure they describe surfaces a long way from its cause, and nothing in the build failed on either. examples/scanning.sol was written as examples/scan.sol, which includes itself; the warning fired, named the shadowed file exactly, and the file compiled and would have shipped. Every one of the 45 passes today, so the check locks in what is already true — and it was verified by planting a warning and watching it fail.

The roadmap is empty and every idea is waiting on a trigger that has not fired. That is the stable point this release marks, rather than a pause in the middle of something.

A warning nobody fails on is a comment — 2529c55, 2026-08-25

45 shipped files now have to compile without solas saying anything, and until this they only had to compile.

solas has two warnings, and both were added because the failure they describe surfaces a long way from its cause: a file that includes a library of its own name (6.22) and two libraries binding one name (6.21). test_compile.c asked each shipped file one question — does it compile to bytecode the verifier accepts — and never looked at what the compiler said on the way. test_include.c checks that warnings appear, but only for files it writes itself.

Which is not hypothetical. examples/scanning.sol was written this morning as examples/scan.sol, which includes itself. The warning fired, named the shadowed file exactly, and the file compiled and would have shipped — only running it found the problem, which is the thing the warning exists to make unnecessary. It went to a terminal where the output had been redirected away.

Every one of the 45 passes today, so this costs nothing and locks in what is already true — the position -Werror, the sanitizers and cross-platform CI were each in before they were locked in.

Verified by planting a warning and watching it fail, on a freshly built binary, because a check that has never failed is a claim about a check. The plant was instructive beyond its purpose: putting examples/scan.sol back shadowed lib/scan.sol for examples/scanning.sol as well, so the failure reported came from the neighbouring file rather than the planted one. That is 6.22’s hazard reaching one file further than the file that has it.

experiment/ is deliberately outside this. It is parked and expected to fall behind the language, and holding it to a warning check would make it a maintenance burden rather than a proof.

The library shipped this morning had nothing checking it — df65a47, 2026-08-25

examples/scanning.sol and examples/commands.sol, 44 claims between them, run and compared on every build.

lib/scan.sol went out in 0.30.0 verified by a harness written once in a scratch file and then deleted. That is the shape of every claim this repository has since had to go back and check, and it was two hours old. lib/shell.sol had never had a test of any kind.

The diagnosis in between was wrong and is worth recording. The night’s journal said the largest hole was that the documentation checker’s subjects are examples, docs, README.md and index.md — that it never looks at lib/. That is true and nearly irrelevant: across all seven library files there are nine lines that print with a comment, because a library is an implementation and not a demonstration. Pointing the checker at lib/ would have gained nine claims and would not have caught the escapes defect, which lived in a branch no example exercised. The hole was never where the checker looks. It was that three libraries had nothing to look at.

So the fix is an example each, in examples/, which the checker already runs — and the claims are now counted with everything else: 808, up from 764.

Both files are named for the trap they fell into. An example of scan.sol called scan.sol includes itself: a file beside the includer wins, so the include finds the example and does nothing. 6.22 is that entry, and the warning it added said so exactly —

[examples/scan.sol:12:10] solas: warning: this file includes itself, so the
include does nothing -- a file beside the includer wins, and
'./bin/../lib/scan.sol' on the search path is what it shadowed

— to a terminal where it had been redirected to /dev/null. They are scanning.sol and commands.sol now, for the same reason manifest.sol is not called json.sol.

lib/text.sol is the one left, and it is two methods reached by json.sol and html.sol on every build, so it has cover even if it has no file.

0.30.0 — 2026-08-25

One new library file, one four-day-old defect fixed, and nothing else in the language changed. .sob files are format version 14, unchanged, and bytecode from 0.29.0 still runs.

lib/scan.sol is a cursor over text — a position, and the handful of questions you ask at one. Five files here had each written that object for themselves, two of them unable to agree whether the method that moves forward is called step or advance. All five are converted, each checked against a recorded baseline and each byte-identical afterwards.

@include "scan.sol".

digit := { c | c:greaterOrEqual("0"):and({ c:lessOrEqual("9") }) }.
s := scan:on("8080ab").
s:takeWhile(digit):display.           ; 8080
s:rest:display.                       ; ab

It is not a pattern language, and that was the finding behind it: what repeated across those five files was never a pattern, it was a position.

The honest number is 46 recovered against 48 spent. The library pays for itself and no more. What it bought is one implementation instead of five, which is worth having and is not a line count.

json:read could not read a string containing \n, and had not been able to since 2026-08-21 — the escape table was deleted and the two lines that read it were left behind, so every escape but \uXXXX raised. Four days and four releases. It was found by recording a baseline before a refactor rather than by looking for it, and \b and \f work here for the first time.

Two things this release says about the checking, both worth more than the code. The documentation checker’s subjects are examples, docs, README.md and index.md: it does not look at lib/ at all, which is what let the escapes defect stand. And experiment/prove.sh had built one generation with a different search path from the others since the day it was written — invisible until a conversion crossed it, then failing the fixpoint on file names while both compilers agreed about every instruction.

The other four, and what converting them cost — cfd08e7, 2026-08-25

html.sol, experiment/lexer.sol, serve.sol and expect.sol are on the cursor too, so all five files that had written one for themselves now share lib/scan.sol. Every conversion was checked against a recorded baseline and every one is byte-identical: 23 HTML documents through a tree outliner, 23 URL-decode and note-name cases, the self-hosting proof, and the checker’s own 1,161-line report.

The arithmetic, which is not flattering:

file code lines  
lib/json.sol 215 → 197 −18
lib/html.sol 296 → 277 −19
experiment/lexer.sol 169 → 163 −6
programs/serve.sol 129 → 126 −3
programs/expect.sol 534 → 534 0
lib/scan.sol   +48

46 recovered against 48 spent. The library pays for itself and nothing more. What it bought is one implementation instead of five, and two files that could not agree whether the method is step or advance no longer get to choose.

expect.sol returned nothing, and that corrects the survey this all came from. It was counted as four scanning sites. One is cursor-shaped. wordBefore runs backwards from the end of a line, markersIn searches for a substring rather than reading characters, and asCount filters every character rather than stopping at one. A cursor is forward, character-at-a-time, and stops. Counting scanning by eye counted three things that were not this.

And the conversions found a defect in the self-hosting proof. experiment/prove.sh built generation 1 with bin/solas experiment/compile.sol and no -I lib, because the experiment’s files only ever included each other; every other invocation in the script had it. The moment lexer.sol included scan.sol the fixpoint failed — on file names, since a .sob records the file each line came from and lib/scan.sol found two ways is two strings. Both compilers agreed about every instruction. The asymmetry had been in the script since it was written and nothing could see it until something crossed it.

One thing the interface gained by being used: pos is assignable, not only readable, because scanners backtrack — html.sol reads &notanentity; to the ; before deciding it is not an entity and puts the cursor back. That is why there is no separate mark, and the reference says so now.

A cursor five programs had each written for themselves — edd2470, 2026-08-25

lib/scan.sol, and lib/json.sol converted onto it. 5.5 went on the roadmap in the morning and came off the same day.

@include "scan.sol".

digit := { c | c:greaterOrEqual("0"):and({ c:lessOrEqual("9") }) }.
s := scan:on("8080ab").
s:takeWhile(digit):display.           ; 8080
s:rest:display.                       ; ab

The entry’s plan was to convert one file and listen. It said peek, match, skipWhile, takeWhile, takeUntil. Converting json.sol added two more, and neither was guessable from the survey:

And one decision the survey did make plain. The block is never handed nil. All fifteen hand-written versions of this loop open peek:notNil:and({ ... }), which is the cursor’s business and not the caller’s: a predicate here is a question about a character, and running out is not a character.

What it cost and what it paid. scan.sol is 48 lines of code. json.sol went from 215 to 197, so the first conversion returns 18 of the 48. Four files are left — html.sol, expect.sol, serve.sol and experiment/lexer.sol — and whether they follow is now a decision with a number behind it rather than a guess.

The conversion was proved rather than asserted. 38 inputs through json:read with their output recorded before and after, identical at the end. That caught a difference no test covered: hex4 written with take moved before it checked, so a malformed \u00 complained about a character four further on than it used to. It asks before it takes now.

One thing fell out that is worth having on its own: the baseline found that json.sol could not read a newline, which is the entry below.

And one claim was nearly overstated in the other direction. json.sol’s header says one parse is in flight at a time. A cursor made per call looks like it settles that, and it does not: the cursor is still held in a slot on json while the call runs. Nothing can re-enter it — read calls no code the caller wrote — so the limit is real and unreachable, which is what the header says now. scan.sol itself has no such limit, and that was verified: two cursors over two strings, interleaved.

json.sol could not read a newline — a32a7a0, 2026-08-25

json:read answered object does not understand ‘escapes’ for any string containing \n, and had done since 2026-08-21. Four days and four releases.

The table it looks in was deleted by a4dc0c2, the commit that wrote the HTML reader, while things were being moved into text.sol. The two lines that read the table were left behind. So every escape JSON names except \uXXXX\", \\, \/, \b, \f, \n, \r, \t — raised, and \uXXXX kept working because it takes the other branch three lines earlier.

Nothing caught it, and the reason is worth more than the fix. The library test in tests/test_include.c exercises exactly one escape, \u00e9, chosen when the interesting question was whether a code point above ASCII survived. It is the branch that was never broken. The documentation checker never sees lib/ at all — its subjects are examples, docs, README.md and index.md — so the one thing in this repository that reads code and checks what it claims does not look at the library.

Found by writing a baseline before a refactor rather than by looking for it: 38 inputs through json:read with their output recorded, so that 5.5’s conversion of this file could be proved to change nothing. Two of the 38 were already wrong.

\b and \f were missing from the table even before it was deleted, so they work here for the first time. The test now reads a string containing a newline and a string containing all eight, and fails without the fix — checked by reverting the fix and watching it fail, because a test that has never failed is a claim about a test rather than about the code.

0.29.0 — 2026-08-25

Two messages were renamed and two were replaced by one, and both changes break bytecode compiled before them. .sob files are format version 14, unchanged — the format did not move, the names inside it did. Recompiling is the whole of the remedy, and 3.4 is where the project already accepted that cost.

array:at_put is array:atPut. It was the only message of 125 with an underscore in it, and worse than inconsistent: dictionary had answered atPut all along, so one idea had two spellings depending on which type you sent it to.

The counted loop takes its numbers together. #1:toDo(#5, block) and #1:toByDo(#10, #3, block) are gone:

[#1,#5]:loop({ n | n:display }).               ; 1 2 3 4 5
[#1,#10,#3]:loop({ n | n:display }).           ; 1 4 7 10
[#10,#7,#0:sub(#1)]:loop({ n | n:display }).   ; 10 9 8 7

toDo read as todo rather than as a loop, toByDo wedged By into the middle of a name that was already a sentence fragment, and the start value hid in the receiver while the other two numbers sat in arguments. The three numbers a counted loop is made of are now written together and in order, and the step is optional because an array knows its own size.

The language is smaller by three names and answers exactly as much: 123 messages across 219 registrations, down from 125 across 220. Nothing became askable that was not askable before.

And the roadmap has an entry on it again, after being empty of buildable work since 0.26.0. 5.5 is lib/scan.sol: five programs each hand-wrote the same cursor — pos, peek, step or advance, skipSpace — and two of them cannot agree what to call the method that moves forward. It arrived the way the roadmap says entries arrive, from programs rather than from reasoning about the design.

The counted loop takes its numbers together — 90ba128, 2026-08-25

#1:toDo(#5, block) and #1:toByDo(#10, #3, block) are gone. In their place is one message on an array:

[#1,#5]:loop({ n | n:display }).               ; 1 2 3 4 5
[#1,#10,#3]:loop({ n | n:display }).           ; 1 4 7 10
[#10,#7,#0:sub(#1)]:loop({ n | n:display }).   ; 10 9 8 7

Three complaints, and all three were fair. toDo reads as todo and not as a loop. toByDo wedges By into the middle of a name that was already a sentence fragment. And the start value hid in the receiver while the other two numbers sat in arguments, so the three things a counted loop is made of were written in two different places. Now they are one array, in order, and the step is optional because an array knows its own size.

It landed as loop after twenty minutes as loopDo, renamed in ffcf16e before any release carried either. Two reasons, and the second is the better one: nothing else here announces its block in its name — repeat, collect, select, inject and whileTrue all take one without saying so — and loop is far enough from do that the two cannot be misread for each other on the same receiver, which loopDo was not.

What it cost, recorded because it was argued before it was chosen. array already answers do, so [#1,#10]:do and [#1,#10]:loop mean different things on the same receiver — the two elements, and the range between them. And the arity check moved from send time to run time: a wrong-sized array can only be caught by looking, which is why the complaint names what it wanted rather than only what it got.

[#1]:loop({ n | n })              'loop' wants [from, to] or [from, to, step], got 1 element
[#1,#2,#3,#4]:loop({ n | n })     'loop' wants [from, to] or [from, to, step], got 4 elements
[#1,1.5]:loop({ n | n })          'loop' expects an integer for 'to', got float
[#1,#5,#0]:loop({ n | n })        'loop' needs a step other than #0

Everything else is unchanged: inclusive at both ends, an empty range runs the body no times, a negative step counts down, and the overflow check still stops a step near INT64_MAX wrapping past the limit.

Two messages became one, so the language answers 123 across 219 registrations, and integer is down to 36 slots from 38 — which class-and-instance.md asserts in a live example and which the checker caught within a second of the primitive being registered.

Bytecode compiled before this fails with integer does not understand ‘toDo’, for the same reason and with the same remedy as the entry below it.

One idea had two names — aab4526, 2026-08-25

array:at_put is array:atPut. It was the only message of 125 with an underscore in it, and — worse than inconsistent — it was the same message under two spellings, since dictionary had answered atPut all along. The C comment beside the dictionary primitive said so out loud: the value stored, as at_put on an array does. The code knew they were one operation and named them differently anyway.

No alias. Keeping at_put working beside atPut is exactly the second mechanism behind the first that the language exists to refuse, and it would leave the inconsistency in the message index permanently instead of removing it.

It breaks bytecode compiled before it. Message names live in the chunk as strings, so a .sob built yesterday meets a VM that answers does not understand 'at_put'. 3.4 already says there is no compatibility across versions and this is that cost being paid; recompiling is the whole of the fix.

The count moved the right way: 124 distinct messages across the same 220 registrations. Nothing can be asked of the language that could not be asked before — there is one fewer way to spell one of the questions.

That number is stated in four places, and three of them were written yesterday into the README, the site description and the repository’s own description on GitHub, where nothing checks them. tests/test_compile.c catches the one in the reference, and caught this. The other three were found by grep, by remembering.

0.28.0 — 2026-08-25

Nothing in the language changed, and everything about how it is checked did. .sob files are format version 14, unchanged. This is the release where three claims that had been held true by somebody having looked once became claims a machine re-checks on every push — which is the standard this repository already applied to its documents and had never applied to itself.

The suite now runs where it was not written. gcc and clang on Linux, clang on macOS. The README’s no dependencies beyond a C11 compiler and make had been checked by one compiler on one machine, where gcc is a shim for clang. It was false: libm went unlinked, and POSIX declarations were hidden by -std=c11 under glibc. All 762 documentation claims hold on Linux too — every fenced block producing the same output on a different libc and a different instruction set.

And GCC found a bug that was not about portability. frame->ip += READ_SHORT() wrote the instruction pointer twice with no sequence point between them, so the standard did not say which value the addition started from. A compiler that loaded the left operand first would make every forward jump two bytes short. It had been correct under clang for the project’s whole life.

ASan and UBSan run the whole suite on every push, where before they were a pass somebody remembered to make against whatever had just changed. Linux, so that LeakSanitizer works — design.md says the language does not leak, and until now nothing had checked it. It reports nothing.

And it installs. make install, make uninstall, make dist. An installed binary could not previously find its library at all: argv[0] names no directory when a program is found on PATH. There are four tiers on the search path now, with the install location last so a checkout keeps winning over anything installed on the machine. CI installs to a prefix, runs a program by bare name off PATH, uninstalls, and builds from the tarball.

The suite checks 762 claims, unchanged from 0.27.0 — no document gained a claim this release, and that is the point of it.

Install it somewhere, and let it find its library when you do — 254ab14, 2026-08-25

make install, make uninstall and make dist, and the defect that had to be fixed before the first of those meant anything.

An installed binary could not find its library. argv[0] names a directory only when the program was invoked with a path; found on PATH by bare name it says nothing, so the bin/../lib fallback contributed nothing and @include "text.sol" failed with no hint as to why. Running out of the checkout, which is how everything here has always been run, hid it completely.

The search path is four deep now, and the order is the whole design:

   
-I dir what the caller said, first
SOLUM_PATH what the environment said
bin/../lib the library beside the binary — what a checkout has
SOL_LIB_DIR where make install put it

The install location is last on purpose. A checkout has to keep winning over anything installed on the machine, or testing a change means reading the old library and not knowing it.

SOL_LIB_DIR is written into a generated header rather than passed as -D. A binary carrying a path from a previous PREFIX fails silently, and a command-line -D leaves stale objects holding the old value. Measured both ways: changing PREFIX recompiles all fourteen sources and relinks, and repeating the same one produces no output at all.

CI checks both of the claims this adds, because they are exactly the kind that go stale in silence — an installed binary that works, and a tarball that builds. It installs to a prefix, runs a program by bare name off PATH from a directory with no lib/ in it, uninstalls and checks the files are gone, then builds from the tarball make dist produced.

docs/GUIDE.md, docs/REFERENCE.md and the README each listed three places on the search path and now list four.

The sanitizers stop being something somebody remembers — 87050d9, 2026-08-25

ASan and UBSan now run the whole suite on every push. They have been run here before — the changelog carries several passes, some of which found real faults — but by hand, when somebody remembered, aimed at whatever had just changed. That is the standing the portability claim had the day before, and it is not a standing this repository keeps for anything else.

The case for it was made by the compiler warning a day earlier. frame->ip += READ_SHORT() had been right for the project’s whole life because clang happened to evaluate it in the order that made it right. A warning found that one. UBSan is for the ones no warning states.

The flags go in their own SANITIZE variable, not in CFLAGS, and that was measured rather than assumed:

make -Bn build/tests/test_threads
  ... -pthread ...

make -Bn build/tests/test_threads CFLAGS="-std=c11 -g"
  ... (no -pthread)

CFLAGS is ?=, so setting it on the command line replaces the warning flags, and — less visibly — build/tests/test_threads: CFLAGS += -pthread stops applying, linking the one test that needs threads without them and saying nothing about it.

Linux and clang, for a reason. LeakSanitizer does not work on macOS/arm64, and design.md says in its status line that the language does not leak. That is a claim, so it wants somewhere it can be checked. The job takes 1m26s and reports nothing: no leaks, no undefined behaviour, and all 762 documentation claims, whose subprocesses are the instrumented binaries too.

And what the first push found, which was not a sanitizer report

The macOS job hung in make test for 25 minutes and was cancelled by its own job timeout. A re-run passed in 39 seconds, so it is intermittent, and a cancelled job keeps no log — there is nothing on the record saying which test it was standing in.

Fixed in 2808674 in two parts. The Test step now carries its own timeout: a step that times out fails and its log survives, where a job that times out is cancelled and its log does not, and make test names each binary before running it. And session_end in test_line.c — which drives solis through a pty, with 20ms selects and a drain that gives up after two seconds — no longer ends on a waitpid with no deadline. It was the only wait in the suite that could not end.

Whether that is what hung is not established, and the entry says so rather than implying the fix was a diagnosis. It is the only unbounded wait there was, which is reason enough to bound it; the step timeout is what will name the culprit if it was something else. The two blocking waitpid calls in builtins.c are left alone — those are system:run waiting for its child, where waiting until the child is done is the whole contract.

The suite runs where it was not written, and a jump that was right by luck — 9a623fb, 2026-08-24

The README says no dependencies beyond a C11 compiler and make, and until now that had been checked by one compiler on one machine. Apple clang, arm64, macOS — where gcc is a shim for clang, so nothing here had ever been through GCC, glibc, or an x86 register allocator. A claim on the front page held true because somebody looked once is the thing this repository keeps finding in other people’s documents.

.github/workflows/build.yml builds and runs the whole suite three ways: gcc and clang on Linux, clang on macOS. fail-fast is off, because the question a first run answers is what is not portable, and stopping at the first answer turns one run into four.

Two failures were predicted before the run and both happened. No -lm: sqrt, fmod, floor, ceil, round, trunc, log10 and llround all failed to link and not one of them failed to compile, libm being part of libSystem on macOS and a separate library everywhere else. And POSIX declarations hidden by -std=c11, which asks for ISO C and nothing besides — glibc takes that at its word where Apple’s headers show realpath, gmtime_r and strptime regardless. The prediction named the wrong files: strptime failed inside builtins.c, which already declared _POSIX_C_SOURCE and needed _XOPEN_SOURCE.

The third was not predicted, and it is not about portability.

#define READ_SHORT() (frame->ip += 2, sol_read_u16(frame->ip - 2))

case OP_JUMP:  frame->ip += READ_SHORT();
case OP_LOOP:  frame->ip -= READ_SHORT();

READ_SHORT() advances the ip itself, so each of those writes frame->ip twice with no sequence point between, and the standard does not say which value the outer += started from. A compiler that loaded the left operand first would make every forward jump two bytes short. It has always done the right thing under clang, and GCC is the first compiler to have said out loud that it did not have to. OP_EXIT_IF_FALSE, three cases below, was already written the safe way — the offset read into a name first — and now all three are. Fixed in 1b93a7a.

What the run proves, beyond the fixes. The whole documentation suite passes under gcc on Linux: 762 claims, 21 counts, 9 positions, every fenced block producing the same output there as here. The front-page claim is now checked rather than asserted — and it was false at the moment it was written into the repository’s description this morning.

The build is warning-free on all three (9421f53, 8279373). The second of those was a warning the runner could see and a local check structurally could not: the count had been taken from make, and the file was one that only make test builds.

0.27.0 — 2026-08-24

One new debugger command, and three questions answered without touching the language. .sob files are format version 14, unchanged, and nothing a program can say changed with them.

solid gains globals, which lists what a program bound, in the order it bound them. That is the one question a program cannot ask about itself: object:slots lists the root class’s messages and is a reasonable thing to mistake for the global namespace, while the globals themselves are slots on an object with no name in the language, so neither slots nor perform reaches them. A debugger holds that object directly, so the answer was fifteen lines.

Why := is syntax and not a message is now written down in design.md, with pointers from the guide and the reference — the two places a reader is standing when the question occurs to them. It is four operations wearing one spelling and two of them have no receiver to send to; and the compiler being able to see a binding is load-bearing, since that is what the already bound by warning and frame slots are both made of.

Regular expressions were argued three ways: no to a literal, defer an engine to the extension mechanism that does not exist yet, and the half that is actionable today is a cursor library, because what repeats across the 460 lines of scanning in this repository is the cursor and not the pattern. Three arguments against were made wrongly and each was overturned by a measurement rather than by a better argument, which the entry records rather than tidies away.

And the checker stopped confusing a suffix with a substring, at six sites where hello.sol.bak passed as a Solum file and a.md.sol would have been handed to the markdown checker. Nothing in the tree is named that way, so the fix changes no result — which is how it survived unnoticed.

journal.md gains the afternoon. The suite checks 762 claims, up from 756.

A suffix is not a substring — 694b329, 2026-08-24

The defect the regular expression survey turned up, fixed. Six sites in expect.sol asked indexOf(suffix):notNil a question about how a name ends, and got back whether the suffix appeared anywhere. hello.sol.bak passed as a Solum file, draft.md.orig as a document, and a.md.sol would have been handed to the markdown checker — the checker for this repository, quietly checking the wrong things, which is the fault it exists to catch.

Not fixed by adding endsWith to the language. The six sites share one four-line helper in the program that needed it:

string:endsWith := { suffix |
    self:size:greaterOrEqual(suffix:size):and({
        self:copyFrom(self:size:sub(suffix:size):add(#1), self:size)
            :equals(suffix) }) }.

That is the same answer ideas.md reaches at a larger scale for scanning — write it in Solum, in a library, and let a real program decide whether it is worth committing to as an interface.

Nothing in the tree is named .sol.bak today, so the fix changes no result: the same files are checked and the same claims hold. Which is how it went unnoticed for nine programs’ worth of runs, and why this is preventive rather than a repair.

Regular expressions, and where they would go if they came — 1f72916, 2026-08-24

A question about a feature the language does not have, and the answer split three ways rather than settling on one. Written up in ideas.md, with a second entry raised behind it: if the objection to an engine is its size, is that not what extensions are for?

The argument that could not be used was the obvious one. No program here has wanted one is the reading of an absence that design.md ruled out two days earlier, so this had to be settled on shape. It is also false: a survey of every .sol file found about 460 lines of genuine character-class, repetition and alternation scanning, most of it in lib/html.sol, experiment/lexer.sol, programs/expect.sol and lib/json.sol — which has the canonical JSON number expression written out by hand.

But what repeats across those files is the cursor, not the pattern.

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 and the second is a predicate — methods on something holding a position, which all five files hand-roll separately. So the actionable half is a lib/scan.sol, writable today, no change to the VM.

And 3.1 decides its shape before anyone chooses one. A matcher built the combinator way, as a block returning a block, dies with block outlived the frame it was written in. Built from objects it composes today. A cursor holds a position and position is state, so the object spelling is the one it wanted anyway.

Three things were argued wrongly and are recorded that way.

The size objection was the weakest. An engine is ~1,500 lines and would be the third-largest C file here — but an extension answers that completely, and POSIX regex is in libc, so it is zero.

The “second language in a string” objection was aimed at the wrong target. It holds against a literal, which would be invisible to solas and the one thing here that could not be overridden; fill was already kept from growing into a format language for the same reason. It does not hold against a library, because lib/shell.sol already carries an entire foreign grammar in a string, deliberately, with the bargain written into its header.

The termination objection was about Perl, not regex. Catastrophic backtracking needs leftmost-first semantics and backreferences; POSIX ERE has neither. Measured against the system regexec, the classic bad patterns are flat through n=40, and matching is linear — 77ms at 1MB, 2,562ms at 64MB. 3.7’s own table has indexOf over 64MB at 0.27s, so it is the same complexity class, ten times the constant: the existing hole one primitive wider, not a new one.

Verdict: no to a literal, defer the engine, and the finding is the cursor. Regex fails the extensions trigger, because a matcher can be written in Solum and that entry is for things that cannot — but it is close to the ideal throwaway to build the first extension with, since regcomp allocates a regex_t that regfree must take back, making a compiled pattern the smallest possible test of the foreign cell and its release hook.

One defect fell out of the survey and is not about patterns. expect.sol uses indexOf(suffix):notNil where it means endsWith, at six sites, because the language has neither startsWith nor endsWith. notes.solid and hello.sol.bak both pass as .sol, and a.md.sol would be run through the markdown checker. Nothing triggers it today; it is the one thing in this discussion that satisfies the roadmap’s admission rule outright, and it was left unfixed rather than folded into a documentation commit. It is fixed in the entry above.

The debugger lists what a program bound — 60e2f74, 2026-08-24

solid gains globals, and it answers the one question a program cannot ask about itself. object:slots lists the root class’s messages, which is not the global namespace and is a reasonable thing to mistake for it; the globals are slots on an object with no name in the language, so neither slots nor perform reaches them. A debugger holds the root object directly.

(solid) globals
  account          <object 0x10122e250>
  rate             0.05
  -- and 18 built in; `globals all` for those too

Two decisions, and each is about what the listing is for.

It lists what this program bound, not the eighty-odd names the machine arrived with — those are counted and offered rather than printed, since a listing they dominate answers a question nobody asked. The split needs no bookkeeping per slot: a new name goes on the front of the root’s slot list, so a count taken the moment the built-ins finish installing is a permanent boundary, and nothing removes a slot.

And it lists them in the order they were bound, which is the reverse of the order they are stored in — the same front-insertion means reading the list straight through would put the last line of the program first. That is the order slots already answers in, so the two agree.

A method is a slot on a class rather than a global, so integer:double := { ... } is not in the list; integer:slots is where that lives. Documented in REFERENCE.md, and the test drives a real session through the prompt as the others do.

The in-language version of this is still not there and is still not an entry. Reading or listing a namespace by computed name is 2.10’s gap; a debugger answering what is in scope right now is tooling, which is what solid is for.

Why binding is syntax, written down where it gets asked — d935c45, 2026-08-24

A question about the language rather than a change to it. Everything in Solum is a message and every message can be overridden — integer:add := { n | #999 } really does replace addition. := stands outside that, and a reader who notices asks the obvious thing: is it only spelled that way because it reads better, or does it do something a method could not?

It does. It is four operations wearing one spelling, and two of them have no receiver to send to:

What the name is Compiles to Could a method do it?
a parameter or \| a \| temporary OP_SET_LOCAL slot No — the slot is a byte decided while compiling, in a fixed-size frame the verifier bounds-checks, and the name is gone by the time the program runs
a local of an enclosing frame OP_SET_OUTER depth, slot No, the same, with a depth
a global OP_SET_GLOBAL name Almost — a global is an ordinary slot on an ordinary object, but that object has no name in Solum: object is the root class, a different one
a:b := c OP_SET_SLOT name Yes, and the compiler parses it as a send before rewinding over its own OP_SEND

And the reason not to make the last one a message is not readability. The compiler can see bindings, and that is load-bearing: the ‘x’ was already bound by lib/text.sol warning exists only because a binding is something it can recognise, as does deciding at compile time whether a name is a frame slot, which is what makes locals possible at all. Overriding add affects programs that add; overriding bind would affect every assignment in every program, the shipped library included, reentrantly. And you would need a binding to bind the name of the binding method.

One thing the answer turned up is now recorded in 2.10. Reflection cannot write understates it: the globals cannot be read by computed name either, because the object holding them cannot be named. slotAt and perform take a computed name; a global takes only a literal one. No program here has wanted otherwise — nothing in programs/ or lib/ uses perform at all — so it stays a note rather than an entry, with the trigger named.

The account is in design.md, with pointers from GUIDE.md and REFERENCE.md — the two places a reader is standing when the question occurs to them.

0.26.0 — 2026-08-24

Two known limitations close and one silent acceptance stops being silent. .sob files are format version 14, unchanged.

3.15: run and capture take an optional second argument saying where a child’s stdin, stdout and stderr go — an array of alternating name and value, a symbol for a manner and a string for a path. The entry had named two possible shapes and picked neither; what decided it was the half the entry never mentioned, that a child could not be given anything to read either.

The randomness half of 3.14: random:new is seeded by the machine, random:new(#seed) repeats, and the state lives in the object rather than on system. What settled where it should live was measuring the generator bench.sol already carried: correct, and seeded from a clock whose low bits made the first coin flip the parity of the start microsecond.

A block argument is checked when the message is sent, not when the block would have run — fourteen messages where a wrong program was accepted because of the data it happened to meet. false:and(#45) used to answer false and say nothing.

And the checker reads a page as a page. Documented claims go from 729 to 756, the difference being claims that were written down and never read: a fenced block that will not run alone is now run again on the page above it, and one that will not run either way is a failure. A number in a sentence carries a notation saying what it counts, and 20 of them are recounted on every build. 3.16 closed with that, and 3.17 with an index beside an object’s slot list — worth more to sends than to the globals it was written about.

Two of the entries closed on a re-reading rather than on new work. 3.14’s trigger had fired four releases ago and nobody had checked; 3.15’s shape was decided by an argument the entry did not contain. Both are recorded as mis-filings rather than quietly corrected.

A block argument is checked when the message is sent — 858b3f2, 2026-08-24

A wrong program was accepted because of the data it happened to meet. false:and(#45) answered false and said nothing; true:and(#45) failed. One line of source, two behaviours, decided by the receiver. So a mistyped a:and(b) — the braces left off — was correct for exactly as long as a kept coming out false, and became a runtime error the first time it did not.

Raised as a question about and/or, and it was not about and/or. The argument was checked inside the code that calls a block, so a block that was never called was never checked, and every message that might not run what it is given had the same hole:

  hidden when
and · or the receiver settles the answer
ifTrue · ifFalse · ifElse the branch is not taken — including the untaken half of ifElse, where the other half is a real block
whileTrue the condition is false on the first test
repeat · toDo · toByDo the count is zero
do · collect · select · inject · keysAndValuesDo the collection is empty
onError the block did not fail

Fourteen messages. []:collect(#45) answered [] and [#1]:collect(#45) failed, from the same line.

The fix is where the check lives, not what it checks. A block argument is vetted when the message is received, so the complaint is the same on every run of the same text — which is the only kind of complaint a program can be written against. The inlined path pays nothing: a literal block with no parameters and no temporaries compiles to jumps and is never sent at all. The rule is a block value, so a block reached through a name still works, and that is the form these primitives see in the first place.

It caught a line in this repository’s own examples on the first run. blocks.sol demonstrates that an argument is evaluated before the send by passing a group to ifTrue: false:ifTrue(("the group ran anyway":display. nil)). The group ran, printed, answered nil — and ifTrue accepted the nil, because false never reaches its argument. The demonstration was resting on the hole it was standing next to. It answers { nil } now, and the point it was making survives intact.

Two spellings were considered and refused: an eager and(value) beside a short-circuiting andsc(block). It would not have fixed anything — twelve of the fourteen messages have nothing to do with and — and two selectors that differ only in whether side effects happen is a quieter bug than the one being removed. One rule, a block argument is a block, holds across ifTrue, whileTrue, and, do and the rest.

A generator you make, and the seeding was the defect — 08484a0, 2026-08-24

The randomness half of 3.14 closes. There was no random number anywhere in the language — not on system, not on integer, not in the library. There is now, and it is a thing you make:

r := random:new.              ; seeded by the machine
r := random:new(#20260824).   ; seeded by you, and it repeats
r:upTo(#6):print.

upTo(#n) answers #1 to #n — the range an array is indexed by, so xs:at(r:upTo(xs:size)) needs no adjustment. between(#a, #b) takes both ends. fraction is a float, at least 0.0 and less than 1.0, named for what it answers because asFloat is what converting a receiver is called everywhere else here. And seed is an ordinary slot recording what the generator was made with, so a run the machine seeded can be had again by writing the number down.

Where the state lives was the whole of the open question, and the entry had listed four places without picking one. It is in the object, and the reason is the row the entry wrote against system: a generator there gives a VM a history and two runs of one chunk stop being identical. Nothing in embedding.md states that in so many words — one chunk, any number of machines is what it says, and a chunk that carried a generator’s state would not be that. A program that never says random:new is exactly as deterministic as it was before this existed, and a test asserts that across two VMs.

What settled it was measuring the generator already here. bench.sol carried Lehmer’s for four releases, and it was correct: 100,232 heads in 200,000 flips, 21 buckets over 210,000 draws spread from 9,799 to 10,157. The seeding was the defect, and it was invisible:

  before after
the first coin flip, over consecutive seeds 1, 2, 1, 2, …the parity of the start microsecond no pattern
the first resample index of 21, over 2,000 consecutive seeds 3 distinct values of 21 21

Two runs a microsecond apart get consecutive seeds, and a Lehmer generator’s first output moves by the multiplier when its seed moves by one. Neither half of that was fixable in Solum: mixing a seed needs the wrapping multiplication that traps here, and the clock is the only entropy a program can reach, while the machine has /dev/urandom. Add the bias mod n leaves on the way out and there are three ways to get this wrong that a reader cannot see — the argument that made sqrt a primitive, holding more clearly here than it did there.

PCG XSH RR 32/64, whose 64 bits of state are the object’s payload, so an instance allocates nothing and the collector has nothing extra to free. upTo draws again rather than taking a remainder. random itself answers none of the draws — a generator has to be made, since one everything shares is what new exists to avoid.

The trigger had fired four releases ago and nobody noticed. The entry said this waited on a program wanting randomness for what it does rather than for how it measures, and filed bench.sol as the second kind. That misreads it: the program’s product is a confidence interval and the interval is computed by bootstrap resampling, so the randomness is the algorithm. A trigger can be written down wrongly and go on looking unfired, which is worth more than the entry it was attached to. What ideas.md predicted years of commits ago — a thing you make with a seed you can name, not a message on integer — is exactly what got built.

bench.sol uses it now, and examples/random.sol is the twenty-sixth example: every number in it is a claim the build checks, which is the case for a nameable seed written out.

A child’s streams go where they are told — 899eca8, 2026-08-24

3.15 closes. system:run gave the child this program’s stdout and stderr, system:capture kept the child’s stdout, and there was no third thing — no way to discard a child’s stderr, and no way to send either stream to a file. Both messages now take an optional second argument saying where the streams go:

system:run(["make"], ["stderr", 'discard]).
system:capture(argv, ["stderr", 'merge]).
system:run(argv, ["stdout", "build.log", "stderr", 'merge]).

An array of alternating name and value. A value is a manner, as a symbol'share, 'discard, and 'merge for stderr alone — or a path, as a string, and the type is what tells them apart, which keeps a file called discard a file.

The entry named two shapes and picked neither, so the first thing this cost was that decision: a fourth argument to capture, smallest and least general, against an options bag that generalises without new messages. The bag won on an argument the entry did not contain — there was no way to give a child anything to read, either, and stdin was inherited by both messages and unmentioned everywhere. Four optional things is more than positional arguments can carry.

Then the language chose the spelling. A dictionary was the obvious bag and is the wrong one here, because there is an array literal and no dictionary literal: dictionary:new and an atPut is three statements at every call site to say one thing. So it is the array of names and values the entry itself had sketched, keyed by the same strings capture answers with — the same spelling going in as coming out.

bench.sol is what asked, and is the proof. A benchmark harness must run a command many times without its output reaching the report, and capture fenced off stdout while stderr went straight through it. The way round was /bin/sh -c '"$@" 2>/dev/null' sh ... — another fork and another exec on every measurement, of the same order as the thing being measured, which is the one program that cannot pay for it. It passes ["stderr", 'discard] now, and a command that complains no longer writes over its own timings. What said the command failed was always the status, and that is untouched.

Two things in the plumbing were worth the care: the files are opened before the fork, so a bad path is the caller’s error to read rather than a child that silently did nothing; and 'merge follows stdout to where it is now, which is >file 2>&1 and not 2>&1 >file — the two orders a shell distinguishes and the classic way to get this wrong.

The test watches its own stderr, because 'discard is the claim whose failure is invisible: output that should not appear looks exactly like output that appeared somewhere else. It points the test process’s stderr at a file for the length of the call and reads it back empty, then runs three hundred redirected children to say nothing was left open — which under a 256-descriptor limit fails loudly if it is false. Thirteen refusals are checked beside it, "stdout" handed to capture among them: that is refused whatever the value, since keeping stdout is what the message is for.

An object with more than a dozen slots keeps a table beside its list — f4cbfcc, 2026-08-23

3.17 closes. A global was found by walking the root’s slots and comparing interned pointers, linearly, at about 1.35ns a slot. So was every message, down the class object’s slots. An object with more than a dozen slots now keeps an index beside the list — open-addressed on the interned name pointer, which is a name’s identity and stable for the life of the VM.

The list is still the object’s state. It holds definition order, which slots answers with; it is what the collector walks and frees; the table is a lookup index over the same slots and is rebuilt from the list if it ever needs to be. Below the threshold there is no table, so a point with three slots pays nothing.

Measured against the same programs, 21 runs each:

   
a global with 60 ahead of it 2.88×
a global with 16 ahead of it 1.37×
a send to a slot 400 deep 4.89×
a send to add, 35 deep on integer 1.35×
disasm.sol over an 8.7K .sob 1.31×
page.sol over the site 1.20×
evaluator.sol 1.09×
a send to a slot 4 deep 0.88×
reading the most recently bound global 0.89×

The last two rows are the trade and they are real — both intervals sit entirely below 1. A hash is a constant where a walk is a step, so the shallowest lookups pay for the deepest. What makes that the right way round is that the old order was recency: the name a library bound first was the slowest to read and the one the program bound last was the fastest, which is backwards for the case it matters in. Peak RSS is unchanged at 0.94 MB — the tables come to about six kilobytes across the ten built-in classes, and nothing else has enough slots.

Sends were where the time was, and the entry is about globals. The reason the entry did not see it: built-in messages are registered in order and a new slot goes on the front of the list, so add, sub, mul and print — registered first, used most — ended up deepest. add sat 35 slots down a list of 38.

The first version was 30% slower on a shallow send, and finding out why produced the design that shipped. A counter said 2.00 probes a lookup, and the table held slot pointers alone, so each probe followed one to read slot->name — three dependent loads where the list has one. A short linked list is not slow: an object’s slots are allocated together, so the walk reads memory the prefetcher has already fetched. Putting the key in the table beside the slot, in the same sixteen bytes, took that loss from 30% to 12%. Two other guesses measured the wrong way round: a stronger hash was slower, splitmix64’s finaliser being two multiplies on the critical path of every lookup in the language; and doubling the table again made no difference once the key was in it.

And one thing worth leaving behind. Breaking the growth rule deliberately, to check the new test would catch it, hung the suite rather than failing it: a full table makes linear probing spin instead of answering wrongly. The insert loop is bounded now, so the same mistake is an assertion. A wrong answer is a bug; a hang is a bug that takes the test run with it.

A number in a sentence says what it counts, and 3.16 closes — ef5fbd0, 2026-08-23

The third gap, and the one that kept happening. A sentence is neither a comment on a printing line nor a fenced block, so a number in one was outside everything make test proves — and the difficulty is exact: a number in a sentence has no notation saying what it counts.

So it is given one, and it renders as nothing:

[expect.sol](/Solveig/programs/expect.sol) checks 729<!--count claims--> claims

expect.sol recounts each name from the repository as it stands — the programs on disk, the slots a class holds, the messages a value answers, the claims this run checked. A name the table does not know is a failure, so a marker cannot be misspelled into silence, which is the failure mode this entry is about. Counts that are facts about a particular run are deferred rather than compared when the run covered less than everything.

A position needs no marker, because the phrase is already one. Nine programs open with The fifth program here, and programs.md puts them in that order under its headings; nothing had held the two together, and 3.16 named “the fifth program here” as its own example of a number nothing counts.

What recounting found:

  said is
ROADMAP 3.14, on whether float should gain trigonometry float answers 21 messages 26 — five releases out of date, and the count that entry’s whole size argument rests on
REFERENCE.md’s message index 121 messages across 215 registrations 122 across 216
programs.md’s sample output 398 claims 402

The middle one is checked in tests/test_compile.c rather than by expect.sol, because that test already parses every registration in builtins.c to hold the index and the cheatsheet honest — so it is the only place that already knows the number, and asking a second program to recount it would be inventing a second source of truth for one fact.

3.16 is closed and moves to COMPLETED.md, which is the first time anything has left section 3 since 3.9. What remains is not a gap: a sentence can say anything, and no checker reaches that. The entry’s own sharpest example was expect.sol carrying a comment promising the report says how far apart the match was found, which it has never done — corrected in place. A claim on a line that does not print, and a transcript in a fence, are still unchecked and the report says so, which is the difference the entry was about.

A page is read as a page, and a block that will not run is a failure — b0ee07e, 2026-08-23

3.16 had three gaps in it and two are now closed. expect.sol checks 729 claims where it checked 672, and the difference is not new documentation: it is claims that were being written down, printed past, and never read.

What was hiding. A fenced block that would not run alone was counted and reported rather than failed, on the reading that it continues one further up, or shows syntax rather than a program. Both are real. Both are also true of a block with a typo in it. Counting what was inside those blocks settled it: 54 claims in 42 blocks, one claim in thirteen.

And the split says where, which is not where the entry guessed. It proposed telling the two categories apart on the theory that would not compile was the suspicious one, since that is what caught README.md’s opening snippet. It is the other way round. Ten blocks failed to compile and held 2 claims between them — the shell and REPL transcripts, as harmless as they looked. Thirty-one compiled and then failed at run time, and held the other 52.

A page is now read as a page. Each block that runs joins the document’s context, and a block that will not run alone is run again on everything accepted before it — which is what the prose says out loud, since continuing the point above stands 370 lines and ninety blocks after the point in question. That recovers 28 of the 42. The cheaper thing does not work: a fixed window of the nearest blocks recovers 24 of the 54 claims at a depth of five and not one more at twenty, because the distance is not the problem, what is between them is.

Three things had to be got right, and each was got wrong first.

And a block that is not a program says so. The cost the entry named — the convention has to be applied to 42 blocks before it can be enforced on the 43rd — was 14 blocks, because 28 of the 42 were programs all along. The documents were already tagging 31 fences sh and c; a text tag joins them for a REPL session, a syntax exhibit, a sketch of a language that was never built. A reader can see a fence that says text; nobody can see a silence in a count.

With that, a block claiming to be Solum and failing to run is a failure, which was the point of the entry. Confirmed by breaking one deliberately, both ways: a missing . and an undefined name each fail the build.

Eight blocks were broken, in documents that have been read for months:

   
GUIDE.md point:slots asked for slots that page never defined and p:respondsTo('show) for a method nobody wrote; rex:intro wanted an animal and a dog that appear nowhere in the file; m:boundTo(a) wanted a counter from a different document
REFERENCE.md integer:slotAt('poly) where poly occurs exactly once in the document — at the line that uses it; a lines counter used and never initialised; d:atPut on an undeclared d; the same missing animal and dog; and point:slots answering ['x, 'y, 'sum, 'make] when the section above had already given point an asString
class-and-instance.md #45:new(#1):print. ; #1 — a claim about what the language does, which the language stopped doing. In a document whose first paragraph says every snippet here has been run; the outputs are what the VM actually prints
one-hierarchy.md two claims that could never hold — a timestamp and a heap address — now written as the asides they are

The one that had been wrong longest was a claim about the language rather than about a value, and the failures cluster where a document runs one example through a long section.

An error written on the line that raised it is now read as one. The reference writes m:value. ; solvm: nil does not understand 'x' where the guide writes the same thing on two lines, and only the two-line form was recognised — so the one-line form was read as a claim about what that line printed, which it cannot be, and the block went in the not-checked pile with everything else it claimed.

What it costs: make test goes from about seven seconds to about twenty. A page’s context is a second program that grows with the page and is re-run for every block under it. The cheap version — adding up what each block wrote by itself instead of measuring the two together — is wrong in the way that matters, because being one line out is not a failure that shows, it is a claim matched against somebody else’s output.

What is left of 3.16 is prose, which is now the whole entry and has more instances behind it than anything else open. README.md, programs.md and the entry itself all said 589 claims for three releases after it stopped being 589 — and expect.sol carried a comment promising the report says how far apart the match was found when it was not the next line, which it has never done. The fault the program exists to catch, sitting in the program, in the one place it does not look.

0.25.0 — 2026-08-23

A journal release. No code changed.sob files are format version 14, unchanged, and every binary behaves as it did in 0.24.0.

journal.md gains the day, which was three releases long: sqrt into the machine and the whole language on one page, then Solum compiling itself, then four design questions of which one was built.

The postmortem is six items with one shape between them. A square root’s convergence claimed in a changelog and a release tag on the strength of checking how the answer printed rather than what it was. “An explicit-stack parser unlocks the last four files” — unmeasured and wrong — then corrected with a second unmeasured claim that only surfaced because somebody asked whether the two statements matched. A chained ifTrue({...}):ifFalse({...}) written about four hours after documenting that exact trap. A benchmark comparing two loops doing unequal work, thrown away rather than reported. An argument under-sold by measuring a diluted expression, which when isolated produced 3.17 and redirected the question it was asked in service of. And five changelog entries dated a day ahead, in a document whose header says dates are the day the work was done, corrected in the same commit.

Every one is a claim made from reasoning where a two-minute measurement would have refuted it, in a stretch of work whose whole method is measuring.

The second theme is about the tools rather than the hands. The checker cannot catch a claim that stops being checked, and three instances arrived in one day: the front page’s block that failed to compile and was silently skipped; the reference’s four library examples, which stopped compiling when their files moved to experiment/ and took 13 claims out of the count with every remaining claim still holding; and 3.5’s own worked example, which was never a claim at all because neither of its lines prints. 3.16 now has more instances behind it than any other open entry.

What went right is recorded at the same length, because it is the same lesson from the other side: deliberately breaking a rule to check a test would fail caught two tests that would have passed on broken code, and the byte-identity bar found three faults that behaviour tests could not have, all three of which produced files that ran correctly.

The day written down, and five entries dated a day ahead — cf61901, 2026-08-23

journal.md gains the day: three releases in one stretch — sqrt into the machine and the whole language on one page, then Solum compiling itself, then four design questions of which one was built.

The postmortem is six items and they have one shape. The square root’s convergence claimed in a changelog and a tag on the strength of checking how the answer printed. “An explicit-stack parser unlocks the last four files”, said unmeasured and wrong, then corrected with a second unmeasured claim that only came out because somebody asked whether the two matched. A chained ifTrue({...}):ifFalse({...}) written four hours after documenting that exact trap. A benchmark comparing two loops doing unequal work, thrown away rather than reported. An argument under-sold by measuring a diluted expression, which when isolated produced 3.17 and redirected the whole question. And five changelog entries dated 2026-08-24 on the 23rd, in a document whose header says dates are the day the work was done — corrected here.

Every one is a claim made from reasoning where a two-minute measurement would have refuted it, in a session whose method is measuring.

The second theme is about the tools: the checker cannot catch a claim that stops being checked. Three instances in one day — the front page’s block that failed to compile and was skipped, the reference’s four library examples that stopped compiling when their files moved to experiment/ and took 13 claims out of the count with every remaining claim still holding, and 3.5’s own worked example, which was never a claim at all because neither of its lines prints. 3.16 now has the most instances behind it of any open entry.

0.24.0 — 2026-08-23

One library method, one new limitation, and three questions answered without building anything. No language change; .sob files are format version 14, unchanged, and every binary behaves as it did in 0.23.0.

array:ifElseIf is the addition: a chain of alternatives written flat rather than as nested ifElse, which past three or four cases ends in a wall of }) }) }) where a reader has to count brackets to know which branch they are in. Pairs of blocks, first match wins, and an odd number means the last is the else. disasm.sol reads its constant tags with it now.

It cost nothing in the language — control flow is message sending, so a chain of alternatives is something a library can add — and what it does cost was measured before it was written down: 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. So the guidance is specific rather than a preference: for a flat dispatch, not inside a recursive descent.

It also answers the switch/case entry in ideas.md, which is worth reading for what it refused. That entry showed a caseOf written in Solum and deliberately kept out of control.sol: 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 judgement was right and the capability was never in question — what changed is the interface.

3.17 is the new limitation, and it was found by measuring an argument rather than by anything being slow. Global lookup walks the root’s slot list, linearly, at about 1.35ns a slot — and the order is recency, so the name a library bound first is the slowest to read and the one the program bound last is the fastest. At 800 globals a constant is 16× faster than a global. At the 16 to 38 globals a real program here has, a badly placed read costs about 50ns.

Three explorations, recorded and not built. Default values for block parameters, where comparing two proposed syntaxes settled a third — { x := { #0 } | ... }, whose default is a block and therefore evaluated per call, which avoids the shared-mutable-default bug nearly every language with this feature has shipped. Constants, where the speed argument turned out to be right and to point at 3.17 instead, and the memory argument turned out to run backwards: a constant table is per chunk and a block is a chunk, so three blocks sharing a literal hold three copies where the global they would replace is one slot. And forever with break and continue, which has a working library prototype and numbers on both sides of 3.13’s fork — 1.7× for a break, 5.0× when a continue fires every other pass, since a skipped iteration is a raise.

Each of the three is deferred with a trigger, and none of the triggers has fired. What they have in common is that the argument for each was strengthened, weakened or redirected by a measurement rather than by an opinion.

forever, break and continue, explored and measured — 7bb565b, 2026-08-23

A loop with no condition at all, left only by breaking out of it:

{ ... :break ... :continue ... }:forever

Recorded under an early exit from a loop, which had not considered this shape. Nothing is built; the entry now carries a working prototype and numbers on both sides of the fork 3.13 describes.

Two things about it are better than what was written down. break need not be a keyword: as a message on boolean it reads i:greaterThan(#10):break., sitting exactly where ifTrue would and answering nil when the receiver is false — which dissolves the objection that a break keyword would be the language’s first control-flow keyword. And a conditionless loop makes break unambiguous, where bolting an exit onto whileTrue raises the question of what it means when the condition would have stopped the loop anyway.

The other recorded objection survives untouched: break and continue are still Solid’s commands, so the word is taken inside the project’s own toolchain.

It is writable today, entirely in the library, with break raising a marker that forever catches and anything else passing through — a real error still escapes. 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×

About 1.30× of that is what control.sol already records for any library loop — a block call per iteration, which an inlined whileTrue does not pay. The rest is the error machinery, and continue is where it hurts: a raise per skipped iteration, which is backwards, since skipping is meant to be the cheap case.

The trigger has still not fired, and the entry is exact about why, because 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, and no loop here has yet had to thread done:not:ifTrue({ ... }) through its body.

Constants, and the measurement that redirected the question — 1ec00a3, 2026-08-23

Asked whether the language should have constants, either as new assignment syntax or as a @constant directive. Written down in ideas.md as defer, and probably no — and the reason is a measurement rather than a preference.

The speed argument for constants is right. OP_CONST is an array index where OP_GLOBAL is a lookup. Measuring how much turned up 3.17, which is new: global lookup walks a list, linearly, at about 1.35ns a slot, and the order is recency — so the name a library bound first is the slowest to read and the one the program bound last is the fastest. At 800 globals a constant is 16× faster.

But that argues for fixing the lookup, not for adding constants. A hash on the root or an inline cache at the OP_GLOBAL site speeds up every global read in every program; a constant speeds up only the names somebody declared. That is the same reasoning the @define entry gave for making loops primitives rather than macros. And the number is small here: a root holds 15 built-in globals plus what a program binds — 23 in expect.sol, 1 in lib/html.sol — so a badly placed read costs about 50ns.

The memory argument runs backwards, which was the surprise. A constant table is per chunk and a block is a chunk, so three blocks using one literal compile to three chunks with one constant each — three copies of the double, where the global they would replace is one slot read from all three.

Two positions the language already holds, now written down together: the inlined and/or emit a constant true/false rather than read the globals, because a program can rebind them; and [a, b] deliberately sends to the ordinary global array so the two spellings cannot drift apart. Rebindability is a hazard in one place and load-bearing in the other.

Nothing was built. pi needs none of it — two lines in lib/math.sol whenever a program wants one, and none has.

ifElseIf — a chain of alternatives, written flat — b222a76, 2026-08-23

value := [
    { tag:equals(#0) }, { nil },
    { tag:equals(#1) }, { readInteger:value },
    { tag:equals(#2) }, { readFloat:value },
                        { error:raise("unknown tag") }]:ifElseIf.

Pairs of blocks in control.sol: a condition and what to do when it holds, first match wins, and an odd number means the last is the else — a list of pairs with one left over is exactly a list of pairs and a default, so no marker is needed. Lisp calls it cond.

It answers a real complaint about real code. Nested ifElse is what this repository writes for a multi-way dispatch, and past three or four cases it ends in a wall of }) }) }) where the reader has to count brackets to know which branch they are in. disasm.sol had a four-way chain on a constant tag and now reads its tags flat; the scanner in the parked experiment/lexer.sol had ten levels of it, which is what raised the question.

No language change was needed, which is the answer to the question as asked. Control flow here is message sending, so a chain of alternatives is something a library can add — the same reason lib/control.sol could add loops without touching the compiler.

What it costs was measured before it was written down, because a nested ifElse written literally compiles to jumps and this cannot:

  chain ifElseIf
200,000 six-way dispatches 0.145s 0.835s, 5.8×
recursion depth through it 254 levels 84, three frames a level

So the guidance is specific rather than a preference: use it for a flat dispatch and not inside a recursion. In a loop the frames are transient, peaking at three rather than accumulating, and the legibility is free; in a recursive descent it spends a third of the depth 0.23.0 just bought.

It is also the tenth site in this repository to carry a boolean whose only job is to stop a loop, which is one more argument for 3.13.

And it answers the switch/case entry in ideas.md, which is worth reading for what it refused. That entry showed a caseOf written in Solum years of commits ago and deliberately kept out of control.sol: 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 judgement was right and the capability was never in question — what changed is the interface. Flat instead of pairs of pairs, plain blocks closing over what they test instead of one-argument blocks handed the receiver, and the else by position instead of { n | true }.

Default values for block parameters, recorded — 18274ea and 6c89db4, 2026-08-23

Asked: could a block carry a default for a parameter, { x := #0 | body }? Written down in ideas.md with a verdict of defer and a trigger, rather than into the roadmap — no program here has wanted one, and the roadmap’s admission rule is that an entry means a program wanted something and could not have it.

The case for it turned out to be better than the convenience, and it is not about syntax. The language already has defaulted arguments; only C can write them. at(key) and at(key, default), asInteger and asInteger(#n), sorted and sorted(block), timeToRun and timeToRun(#n) — a built-in takes an argument or does not, and in several the extra argument is exactly a default. A block cannot: 'block' takes 1 argument, got 0. That is one of the few places user code cannot do what built-in code does, and the language otherwise works hard to keep those the same thing.

What it would cost is recorded too, because the syntax is the small half. The scanner decides parameters with a copy of the lexer rather than a parser, and would have to skip an arbitrary expression and balance braces to find the |. Arity stops being a number and becomes a range, which the .sob format carries as u16 arity per method — a format version bump. The callee has to learn how many arguments it actually got, which nothing tells it today. And a default is code, so a block gains a generated prologue with jumps.

A second syntax was proposed, and comparing the two settled the spelling. { x:{#0} | ... } is cheaper to scan than { x := #0 | ... }skip_block already exists and balances braces, so the rule is bounded by construction — but it spends :, which in this language always sends a message, on something that is not one. That is the objection that refused ifTrue{...} seen from the other side: one made a send look like syntax, this makes syntax look like a send.

What they suggest between them is { x := { #0 } | ... }, which keeps := meaning bind, keeps the bounded scan, and makes the default code that runs when the argument is missing rather than a value fixed once. That answers one of the three open questions 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. The block spelling makes the right answer the only one writable.

Two questions are left open on purpose: whether a default may see an earlier parameter, and what respondsTo and the arity error should say about a block that takes one argument or two.

0.23.0 — 2026-08-23

Solum compiles Solum. The compiler is written in the language it compiles, it compiles its own source, and the compiler that comes out compiles its own source again to the same bytes. All 47 .sol files in the repository compiled to bytes identical to what solas produces — not “runs the same”, the same file.

The one change to the language is a number. SOL_FRAMES_MAX is 256 rather than 64, so recursion reaches 254 levels rather than 62. .sob files are format version 14, unchanged, and nothing else about the language moved.

That cap had been left alone because raising it looked expensive: SOL_STACK_MAX was derived from it, and a SolVM holds both arrays inline and lives on the C stack, so eight times the frames meant a machine too big to put on a thread. The two did not have to be one number. Sized separately, four times the depth cost 4% more memory — 266,120 bytes to 276,872 — and both ends stay bounds-checked, so call depth exceeded and stack overflow are still ordinary catchable failures.

Three programs that had each written a limit down found it moved: evaluator.sol from 18 brackets to 83, lib/json.sol from 28 levels of nesting to 124, and the Solum compiler from 9 nested blocks to 41 — which is what let it compile its own source at last. 3.5 is rewritten around what moving the cap cost and bought, and every document quoting the old numbers is brought up to date.

Nothing was added to the language to make the compiler possible, which was the rule the exercise ran under from the start. The suggestion that raised it proposed a pattern class and a built-in tokenizer first; neither turned out to be wanted. lexer.sol is 297 lines against solas/src/lexer.c’s 265, and 169 of those are code.

What it found along the way, each recorded where it belongs: writing binary works, NUL included; writing an i64 is easier than reading one, because shiftRight is arithmetic where reconstruction by shifting left overflows; a float has to be taken apart by hand, since nothing reinterprets its bits, and the encoder was checked bit for bit at -0.0, DBL_MAX and infinity; and the four files that would not compile failed on depth rather than on any construct, which is how a constant in vm.h turned out to be the last thing in the way.

And then it was parked. The six files live in experiment/, off the search path and out of make test, because a second compiler has to be taught every construct the first one learns and the proof does not need repeating to stay true. experiment/prove.sh runs it again on demand.

The self-hosting compiler is parked — a5a49ff, 2026-08-23

The proof is finished, so the code stops being maintained. Everything that taught Solum to compile itself has moved to experiment/: lexer.sol, parser.sol, compiler.sol and sob.sol off the search path, compile.sol and emit.sol out of programs/, and all six out of make test.

The reason is the tax rather than the code. A second compiler has to be taught every construct the first one learns, and that 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: all 47 .sol files compiled to bytes identical to solas, and the compiler compiled its own source to a fixpoint. That is written down rather than re-run.

So the experiment is expected to fall behind the language, and the first sign will be a file in there failing to compile. That is the trade, stated in experiment/README.md so nobody reports it as a bug.

experiment/prove.sh runs both halves again on demand — the 47-file comparison and the three generations — and says whether the proof still holds. A script rather than a test, because it is a thing to run when somebody wants to know.

Removing them cost 13 claims, and the checker could not have told you. The reference documented the four libraries with worked examples; with the files off the search path those blocks no longer compile, so they are classified shows syntax rather than a program and skipped — the count went from 677 to 664 with every remaining claim still holding. The sections are gone now and the subtraction is exact, but this is 3.16 again: a block that stops working stops being checked rather than failing.

The frame cap moves, and Solum compiles itself — 6037fdf, 2026-08-23

SOL_FRAMES_MAX is 256 rather than 64, and recursion reaches 254 levels rather than 62.

The reason it had not moved was a number that did not have to be a number. SOL_STACK_MAX was SOL_FRAMES_MAX * 256, on the reasoning that a frame may hold 256 slots since a slot index is a u8. A SolVM holds both arrays inline and lives on the C stack — embed/host.c and every test writes SolVM vm; — so at 64 frames the machine was already 260KB, nearly all of it stack, and raising the cap eightfold would have made a VM too big to put on a thread, where the default stack is often 512KB.

The two are separate now. Frames are 56 bytes each; the stack is sized on its own for how many values a program actually holds live. Both ends are still checked and both failures are still catchable — call depth exceeded at one, stack overflow at the other.

  frames sizeof(SolVM)
before 64 266,120 bytes
after 256 276,872 bytes

Four times the depth for four percent more memory.

And with that, 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, and all 47 .sol files in this repository now compile to exactly the bytes solas produces, up from 42 of 46.

The compiler’s own source was among the files it could not compile, and it failed on depth rather than on any construct — so the last thing between here and self-hosting was a constant in vm.h.

Three programs that had written a limit down found it moved: evaluator.sol from 18 brackets to 83, lib/json.sol from 28 levels of nesting to 124, and the Solum compiler from 9 nested blocks to 41. Every document quoting the old numbers has been brought up to date, and 3.5 is rewritten around what moving the cap cost and bought.

This is still a limit — 254 is a bigger number than 62 and not a different kind of number. Making it dynamic rather than a fixed array is what would remove it, and nothing has wanted that yet.

One thing the checker could not have caught: 3.5’s own worked example said #62:down succeeds and #63:down fails, and neither line prints, so neither was ever a claim. It is written with :print now, so the next time the cap moves the suite will say so.

Which half runs out — e68f1b3, 2026-08-23

A correction, and the measurement that forced it. The entry below said the parser was what ran out of frames and that an explicit stack in it was the answer. That was written from reading the call chain rather than from running anything, and it is wrong.

The compiler is now lib/compiler.sol rather than part of compile.sol — a library, so that a tree nobody parsed can be handed to it directly. That is the only way to ask how much room the compiler has, since on real source the parser always fails first. With the tree built by a loop instead of by parsing:

  9 levels 10 levels
parsing alone passes fails
compiling a hand-built tree passes fails
both together passes fails

They stop at exactly the same depth, about six frames a level each. So fixing the parser alone would buy nothing at all, and 3.5, programs.md and ideas.md now say so instead of what they said this morning.

The honest options are two, and they differ in kind. Both halves carry their own stack — the shape lib/html.sol uses to reach a thousand levels, and twice the work the first account implied. Or the cap moves: built with SOL_FRAMES_MAX at 512 rather than 64, both halves reach 83 levels and fail at 84, which is the same six frames a level with eight times the room. That is the one-line change 3.5 has always named, and it is a decision about the language rather than about this program.

The measurement is kept as a test rather than as a paragraph, so the claim cannot go stale: it builds the deep tree both ways, finds where each stops, and fails if they stop in different places.

Also: compile.sol’s own header still said it did neither @include nor the inlined control flow, four commits after it learned both. Splitting the file is what made that visible.

@include, and the wall at the end of it — aeee2fa, 2026-08-23

The last construct. compile.sol does @include with 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 the repository’s 46 .sol files now compile byte-identically, up from 33, and 0 disagree.

The four that do not are not a missing construct. They are call depth exceeded. The parser recurses about four frames per level of nesting against a budget of 62, so it manages nine levels of nested blocks and fails at ten; solas, recursing on the C stack, is untroubled at thirty. The four files that nest deeper include experiment/lexer.sol, experiment/parser.sol and compile.sol itself.

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 is ever going to get, and it was predicted in ideas.md before a line 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. That is now the next piece of work rather than a note.

Both compilers have to be given the same search path, which is worth knowing rather than working around: the file table records where an included file was found, so the path is part of the output. solas derives its default from where its own binary sits, which nothing in Solum can see, so the test gives both -I lib.

And an aside worth keeping: this walked into ifTrue({ ... }):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.

Control flow compiled to jumps — 057ea62, 2026-08-23

compile.sol now inlines ifTrue, ifFalse, ifElse, and, or, whileTrue and doUntil exactly as solas does, 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 the repository’s 46 .sol 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 at all. 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 a method of its own 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 ordinary lesson about extracting a function from a loop: what the loop had already done for you goes with it.

And the test’s it runs check turned out to be three claims wearing one coat, of which only the third holds. 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 it runs. What is left is the one thing a byte comparison cannot already tell you: that the file gets past the verifier. It also needed its stdin closed, because one of the newly-accepted examples reads a line and the suite sat waiting for it.

Two jump details worth having written down, both of which only a byte comparison catches: a jump’s distance is measured from the end of its whole instruction, and JUMPIF carries the selector after its offset so a non-boolean can be blamed on the message it came from. And a short-circuit answers a constant true or false rather than the global, which a program can rebind — reading it would make the shortcut and the long path disagree about what and answered.

Blocks, and the frames they need — 14001b1, 2026-08-23

The first half of stage 2 of the self-hosting question: compile.sol now does blocks with their parameters and temporaries, groups, slot assignment, frame slot allocation, lexical capture and nested chunks — which is the half of a compiler that is a compiler rather than a translator.

9 of the repository’s 46 .sol files compile byte-identically, up from 3, and 0 disagree. The 37 refusals are all the same thing.

Two mistakes, both caught by the byte comparison and by nothing 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 it ran perfectly well, because nothing reads past what it needs.

And a byte takes the line of the token just consumed, not the line its construct began on. Those coincide for a one-line statement, which is the whole of examples/hello.sol, so stage 1 matched without ever knowing the difference; they part company the moment a send’s arguments run over two lines. parser.sol now records an emit line on every node for this alone.

The refusals are deliberate, and are the design decision worth stating. ifTrue, ifElse, whileTrue, doUntil, and and or are compiled to jumps by solas when written literally. Compiling them as real sends would produce a file that runs correctly and compares differently — and an answer that is right and unequal is the one thing this program must not give, because the whole value of the exercise is that the comparison means something. So it refuses them by name until it can inline them, which is the next piece of work.

A binding turned out to be an expression rather than a statement, which is how the grammar actually works and which a block body refusing t := x:add(a) is what revealed.

Still nothing added to the language.

Solum compiles Solum — 561ecc6, 2026-08-23

compile.sol turns Solum source into the bytes solas produces from it. examples/hello.sol comes out byte-identical, first attempt. Stage 1 of the self-hosting questionemit.sol proved the format could be written, lexer.sol scanned it, and the two new library files close the gap: parser.sol for the grammar and sob.sol for the file, which emit.sol now shares rather than carrying its own copy of.

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 nothing lists which files ought to work, so a construct that starts compiling is counted the moment it does.

The subset is statements, bindings, sends, parentheses, arrays and every literal. Not blocks, temporaries, methods or @include, which is where slot allocation, capture analysis and nested chunks come in, and is stage 2.

Byte-identity earned its keep, which was not obvious when it was chosen over “runs the same”. It forces agreement on what a compiler is otherwise free to decide, and each of these had to be worked out and matched:

The real work was the float encoder. Nothing in Solum reinterprets a float’s bits as an integer, so sob:f64 takes a double apart by arithmetic — sign, the exponent by halving and doubling into [1, 2), then 52 bits of mantissa — and reassembles it as two 32-bit halves so nothing has to reach bit 63, which would overflow on the way exactly as it does when reading. It is readFloat in disasm.sol inverted, and it was checked against the C library at twelve values including -0.0, DBL_MAX and infinity, bit for bit. A byte count would have passed on every one of them, which is the mistake this repository made in 0.21.0 and does not intend to make twice. Stage 0 had recorded a float constant as the one thing not yet writable; it is written.

Still nothing added to the language for any of it. The suite checks 672 claims, up from 667.

Solum scans Solum — ed4d1c6, 2026-08-23

lib/lexer.sol is Solum’s own tokens, scanned by Solum: all nineteen kinds, the shebang, the comments, the escapes, 45. against 45.5, and : against :=. Stage 1 of the self-hosting question, and the half that answers whether the language needed help before it could tokenise anything serious.

It did not, and the file is the evidence. solas/src/lexer.c is 265 lines; experiment/lexer.sol is 297, of which 169 are code. Solum said the same rules in fewer lines of code than the C, using at, copyFrom and comparison — all of which it had before it had a garbage collector. Nothing was added to the language for this. The question came with a suggestion to build a pattern class and a scanner class first; neither turned out to be wanted, and the one place the language shows through is that a fourteen-way character dispatch is a nest of ifElse where C has a switch, which is readability rather than capability.

The test is the corpus. Every .sol file in the repository is scanned by both and compared kind, line, column and text, token for token: 33,034 tokens across 44 files, all identical.

And the corpus was not enough, which is the finding worth keeping. It passed on the first run, so a rule was broken deliberately to check the test could fail — and it 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 runs beside the corpus now: bare exponents, a string with a newline inside it — the one place a token’s line and the scanner’s line differ — and five ways to be wrong, since an error token has a position too and both scanners have to recover identically or everything after it disagrees. With the fixture in place the broken rule is caught.

Scanning a 475-line file takes 62ms including VM start, which is slow next to C and irrelevant at this scale.

REFERENCE.md documents the new file, and the suite checks 667 claims, up from 662.

A .sob written by Solum — 1170ded, 2026-08-23

Could Solas be written in Solum? Asked on 2026-08-23, never discussed here before, and now on the record in ideas.md with a staged answer and the first stage built.

emit.sol is the eleventh program, and it is disasm.sol backwards. No lexer, no parser, no source input — two chunks written out byte by byte and handed to the machine. The back end goes first because it is the half that could have been impossible: scanning characters is ordinary work in any language and two shipped libraries already do it, but a language that cannot write a NUL byte or an i64 cannot emit bytecode at all.

It works, and the assertion is cmp rather than behaves the same. "hi":display. at 94 bytes and #45:print. at 98 both come out identical to what solas produces, both run, and disasm.sol decodes both. Byte-identity is deliberate: a file that ran correctly and differed in its tables would leave the interesting question open, and the interesting question is whether two compilers can be held to one answer.

Three things it found:

No features were added to make this possible, and that is the point. The question came with a suggestion to build a pattern class and a tokenizer first; the entry records why not. A language that compiles itself with help from a tokenizer written in C proves something smaller — and a 3,000-line Solum program is the largest evidence generator this repository will ever have, pressing on the frame limit, on blocks that cannot escape, on the missing early return and on string building all at once. Smoothing its path in advance throws that away before it is collected.

Also fixed: disasm.sol announced this reader was written against version 13 on every file it read perfectly well. The format has been 14 since 0.18.0 and that flip was disasm.sol’s own doing. A reader that cries wolf on correct input teaches you to ignore it.

0.22.0 — 2026-08-23

One new message, one new library file, and one new document. .sob files are format version 14, unchanged.

sqrt is a message a float understands, and it is in the machine rather than the library for a reason worth the release note: it was written in Solum twice, and both versions were wrong and neither said so. Twenty fixed iterations of Newton’s method answered 100000.000156 for sqrt(1e10); the capped loop written to correct that answered 8.67e281 for sqrt(1e300), which is nineteen orders of magnitude, from the fix. Getting it right means scaling by the exponent before iterating, which is asking a script to know how a double is laid out. min, max and between were written correctly the first time and are therefore only math.sol — the line between the two is not importance, it is whether every program would get the same thing wrong.

That answers the arithmetic half of 3.14. Randomness is the half still open and the entry is now about that alone: where the state lives, where the seed comes from, and whether a host can set it.

This release corrects 0.21.0. That entry said the hand-written square root converged at 1e300 and only the formatter was wrong. It had not converged. What was checked against the C library was the digits the formatter produced, never the value they were the digits of — a wrong number can survive careful checking if what you check is how it prints. The 0.21.0 entries and the journal now carry that correction where they made the claim.

CHEATSHEET.md is the new document: the syntax, every type, every message it answers and every global, one line each, for when you know what you want and not what it is called. Two tests hold it to the language — one fails if a message is registered without being listed, and the 64 examples run on every build like every other example here. The suite checks 662 claims, up from 589 at 0.21.0.

And design.md now says what the language is for, which had never been written down: Solum is meant to be a general-purpose language, and the shell-and-text character of the first ten programs is a discovery about what has been built rather than a decision about what it is. The rule that follows is for reading the roadmap — no program here has wanted X is a reason to wait for one before choosing a shape, and never a reason to rule a direction out. It is written down because it was got wrong once, on the trigonometry question, and both documents record the wrong reason as wrong.

The whole language on one page — 4a0ca23, 2026-08-23

CHEATSHEET.md is new: the syntax, every type, every message it answers and every global, one line each, in tables — for when you know what you want and not what it is called. The reference stays the full account of each message; this is the index to your own memory.

Two tests hold it to that. test_every_builtin_message_is_in_the_cheatsheet fails if a message is registered without being listed — the same guard the reference’s index has had, and the reason to have it twice is that a one-page list is exactly the kind of document that quietly falls a release behind. It differs from the index test in how it looks: the index writes bare names in a column and the cheatsheet writes them as they are called, so the name is looked for straight after a backtick or a receiver’s colon and straight before an open paren or the closing backtick. That accepts a table cell and refuses a mention in a sentence, because being used on the page is not being listed on it. The other test is the one that was already there: all 64 examples are run on every build and their answers checked, so the page cannot drift from the language it describes.

Writing it found a third gap in the checker, now recorded under 3.16. A claim on a line that does not itself send print or display is not checked — point:show. ; #3 prints from inside the method, so the expectation is never read — and neither is a second line of output written under the first. Six of the first draft’s 68 claims were in that state. The checker reports those lines rather than hiding them, which is why this is a paragraph in the entry and not a fourth row, but a page of sixty examples is where it becomes easy to hit.

Three of the examples were also wrong in a way that only running them shows: a padded float whose leading spaces a trimmed claim could not express, and two where the file’s own trailing newline printed a phantom blank line. Fixed by running every block and diffing against its comments before committing, which is the same thing the build now does.

What the language is for, and where trigonometry sits — 99826df, 2026-08-23

design.md now states the goal outright: Solum is meant to be a general-purpose language. Not a scripting language, not a shell language — those are shapes it can take, and one of them is the shape it took first. That first shape was a discovery rather than a decision: the ten programs lean towards text and processes because they are the tools this project needed while building the thing that runs them, and written against a different need they would have leaned somewhere else.

The rule that follows is for reading the roadmap: no program here has wanted X is a statement about what has been built, never about what the language is for. It is a good reason to wait for a program before choosing a shape, because the program is what tells you which shape is right. It is not a reason to rule a direction out. The admission rule is unchanged — an entry still means a program wanted something and could not have it — and what changes is the reading of an entry’s absence.

This is written down because it was got wrong once. Asked about trigonometry, the first answer argued it away partly on the grounds that there is no geometry anywhere near this language — a true sentence about ten programs and an empty one about a language. Both documents now record that, the wrong reason included.

3.14 gains the trigonometry answer, which is: not yet, and only for want of a program. The case for eventually building it is the one that made sqrt a primitive and is stronger — a hand-written sine fails the same silent way and fails harder, since the series is the easy half and the difficulty is argument reduction. Reducing modulo 2π needs π to more bits than a double holds, so the obvious reduction loses a digit per octave of the argument and is noise well before 1e16: the same shape as the defect this entry already records, and invisible for the same reason.

Three questions it raises that sqrt did not are written down while they are cheap — where pi lives, given infinity and nan are globals and pi would be the first that is not an IEEE special; radians or degrees, decided once and regretted afterwards; and that atan2 takes two coordinates and has no receiver that is obviously the subject. When a program does want an angle, trigonometry and pow/log/exp should land as one decision rather than a message at a time.

Also: design.md said 0.13.0 in its status section, eight releases stale. Another prose count outside what the checker reads (3.16), found the same way as the last three — by reading the page for another reason.

A square root that is right — 2e438fb, 2026-08-23

sqrt is a message a float understands, and min, max and between are in the new math.sol. That is the arithmetic half of 3.14 answered; randomness is the half still open, and the entry is now about that alone.

The reason sqrt is in the machine and the comparisons are not is the whole of this entry. Both were writable in Solum, and bench.sol had written all of them. min and max came out right the first time, one line each, and there is nothing in them to get wrong. The square root was written twice, and both versions were wrong and said nothing:

Getting it right means scaling by the exponent before iterating, which is asking a script to know how a double is laid out. So this is not the language should be convenient; it is that every program needing a square root here was going to get the same wrong answer privately.

A correction to 0.21.0. That release said the hand-written square root converged at 1e300 and only the formatter was wrong. It had not converged. What was checked against the C library was the digits the formatter produced — which were right, once the formatter was fixed — and never the value they were the digits of. A wrong number can survive careful checking if what you check is how it prints. The 0.21.0 entries now carry that correction, and tests/test_ops.c compares sqrt against the C library at eleven values including 1e40 and 1e300, where the second hand-written version failed.

nan for a negative rather than raising, which is the rule float division already follows — this arithmetic reaches nan and infinity instead of trapping. Float only: #4:asFloat:sqrt, since no arithmetic message here crosses the two types. No pow, log or exp: C has them and each would be a line, but no program here has asked for one, and the ones a program has asked for rather than all of <math.h> is the rule the entry set itself.

math.sol is a library because nothing in it can be got wrong. min, max and between on integer and float, min and max on array, every one of them written out longhand somewhere first — twice in bench.sol, and between three times in lib/json.sol as a surrogate range. json.sol is deliberately not rewritten to use it: a parser would pay a block and a frame per escape sequence for a readability gain, and the measured lesson from lib/control.sol cuts the other way there.

.sob files are format version 14, unchanged. The suite now checks 598 claims, up from 589.

Three stale counts, found by reading. programs.md said the nine files in programs/ and twice the seven, on a page describing ten. Fixed, and recorded as the third instance under 3.16 — enough to say which of that entry’s three options is the one worth building.

0.21.0 — 2026-08-22

A fix release, and the fix is a memory-safety one. 1e150:asString("0.6") answered a 157-character string of which 93 characters were whatever lay behind a 64-byte stack buffer, and a script could print them. An over-read rather than a write, so nothing was corrupted; what leaked was stack, into a value a program can inspect. For a host running a script it did not write, that is the wrong direction for bytes to travel. .sob files are format version 14, unchanged, and the only behaviour that differs is that a large float asked for decimals now answers its digits rather than the right number of the wrong bytes.

It was found by the tenth program, and that is the part worth the note. bench.sol times a command repeatedly and says whether two commands really differ. It needed a square root the language does not have, wrote one, and tested it at 1e300 to see whether it converged. The bug is in the float printer and has nothing to do with square roots — two absences compounding, a program reaching for a function that is missing and the edges of what it wrote landing where the printer had never been.

Corrected after the fact. This section, and the entry below it, said the square root converged and only the formatter was wrong. The square root did not converge: at 1e300 it answered 8.67e281. What was compared against the C library was the digits the formatter produced, never the value they were the digits of. The formatter bug was real and the fix stands; the sentence about the square root was not. See 3.14 and the entry that made sqrt a primitive.

Three roadmap entries, each by the admission rule. 3.14 — there is no sqrt, pow, min, max, and no source of randomness anywhere in the language; this had been deferred in ideas.md with the trigger a program wanting one, and this is that program. 3.15 — a child’s stderr cannot be discarded, and a benchmark harness is the one program that cannot buy its way out through /bin/sh, a shell being another fork and exec of the same order as the thing measured. 3.16 — what 0.20.0’s checker does not check: a fenced block that fails to compile is counted and skipped, and prose is not read at all.

The measurement the tool was built for, first time out: starting the machine at all costs 2.6ms, so about 15% of a 17.7ms run of the documentation checker is fork, exec, loader, and a VM built and thrown away. This repository has quoted timings for six releases, every one taken by hand, once.

The day written down, and what the checker does not check — 0563781, 2026-08-22

journal.md gains the night’s account, including a postmortem of what went wrong: warm numbers reported as if they were the numbers, an “each works” written from reasoning rather than from a run, and a bisect that could not find a cross-file interaction because it looked at one file at a time.

And one outstanding thing that had not been written down anywhere: 3.16. The checker proves 589 claims and two kinds of thing in the same files sit outside it — a fenced block that does not compile, which is counted and skipped and is where the front page’s missing . hid; and prose, which is not read at all, so every count this repository states about itself in a sentence has the standing the examples’ comments had before any of this existed. Odd for this document, being about the repository rather than the language, and it is here because this is meant to be the single list.

A float could print the stack behind it — ad185d3, 2026-08-22

1e150:asString("0.6") answered a 157-character string of which 93 characters were whatever lay behind a stack buffer. A script could print them.

snprintf does not overflow — it truncates, and answers the length it would have written. That length went straight on as the length of the result, beside a 64-byte buffer holding the first 63 characters. Everything downstream then read 157 bytes out of 64. An over-read rather than a write, so nothing was corrupted; what leaked was stack, into a value a program can inspect. For a host running a script it did not write, that is the wrong direction for bytes to travel.

The buffer is now sized for the worst case the format spec permits — 309 digits for DBL_MAX, a sign, a point and up to 40 decimals, so 350 characters — and the length is clamped to the buffer regardless, so a future mis-sizing truncates instead of over-reading.

Found by bench.sol, which needed a square root the language does not have, wrote one, and tested it at 1e300 to see whether it converged — see the correction above: it had not, and that went unnoticed because what was checked was how the answer printed. tests/test_format.c now checks five large floats and the widest thing the spec can ask for, digit for digit against the C library — a length alone would have passed throughout, the length having been right all along.

Program ten wanted arithmetic — 79cf703, 2026-08-22

bench.sol times a command repeatedly and says whether two commands really differ. It is the first program here written to press on a gap rather than to do a job that happened to need one, and it exists because this repository has quoted timings for six releases — 40.5µs to build a machine, 121µs for a request — every one taken by hand, once. A number taken once is a sample of one; the tool’s own first run shows a maximum 47% above the minimum on a quiet machine.

It interleaves two commands with a coin flip deciding the order each round, and reports a bootstrap interval rather than a winner: resample both sets two thousand times, report where the middle 95% of the ratios fell, and say this many runs cannot tell them apart when that interval contains 1. Given the same command twice it answers 1.001, interval 0.985 to 1.015.

Two roadmap entries, both by the admission rule.

3.14 — there is no sqrt, pow, min, max, and no source of randomness anywhere in the language. This had been deferred in ideas.md with the trigger a program wanting one, and this is that program. All four were writable and all four are in the file — the finding is what writing them costs. min and max are one line each. The sqrt was wrong on the first attempt and silent about it: twenty iterations of Newton’s method, right to twelve places at 2 and wrong in the fourth digit at 1e10, because quadratic convergence is what happens after the guess is close and from x itself the first phase is one halving per octave. And the textbook random generator cannot be written in this language at all: a linear congruential generator relies on the multiplication wrapping, and integer arithmetic here traps on overflow. Lehmer’s works, with a multiplier and modulus chosen to stay inside 64 bits.

3.15run shares both of a child’s streams and capture keeps its stdout; there is no way to discard stderr. A benchmark harness is the one program that cannot buy its way out through /bin/sh, a shell being another fork and exec on every measurement, of the same order as the thing measured.

The measurement the tool was built for, first time out: starting the machine at all costs 2.6ms, so about 15% of a 17.7ms run of the documentation checker is fork, exec, loader and a VM built and thrown away.

0.20.0 — 2026-08-22

A testing release. No code changed outside the version string — .sob files are format version 14, unchanged, and every binary behaves exactly as it did in 0.19.0. What changed is that the documentation can no longer be wrong quietly.

Everything this repository writes down about what it prints is now checked on every build. 589 claims across 40 files: 398 in examples/, 189 across seventeen documents, two on the front pages. Before this, examples/ carried about four hundred comments saying what each line printed and the suite compiled every one of them without running any — those comments were true because somebody looked, once. The documents carried two hundred more in the same notation inside ``` fences, and nothing checked those either.

Three things were wrong, and the third is the one worth the release. The guide showed a stack trace in a format that predates 6.27 adding the filename. class-and-instance.md said integer has 24 slots where it has 38, in three places. And the front page’s opening snippet did not compile — the four lines that introduce the language to everybody who arrives were missing the . after a := #45.

That last one had been seen and passed over. A block that fails to compile is classified shows syntax rather than a program and skipped, which is right for the $ ./bin/solis transcript further down the same page and wrong here. The category that keeps the checker honest about what it cannot check is also where a real fault can hide, and that is worth knowing about any such tool.

The checker had five bugs of its own, each found by it doing something visible, and one of them put back a file a commit had deliberately deleted: documentation shows how to delete things, and executing documentation executes that. Blocks from a document now run in a sandbox. The details are in the two entries below.

CHANGELOG.md is the one document skipped: it records what was true at each release, so its snippets describe past states on purpose.

And the checker’s own answer drifted. It reported 589 claims on a tree it had run in before and 588 on a clean one, because a block in the guide read a file a block in the reference wrote — so it failed the first time and passed ever after, off the previous run’s leftovers. Fixed twice over: the sandbox is emptied before anything runs, and the block writes the file it asks about. A checker that agrees with you eventually is worth less than none.

The checker agreed with you eventually — 358a55d, 2026-08-22

589 claims on a warm tree, 588 on a clean one. The count depended on how many times the checker had been run before, which is the one property a checker may not have.

GUIDE.md asks system:modifiedAt("notes.txt") and no block in it creates that file; REFERENCE.md, further down the alphabet, writes one. Both now run in the sandbox 0.20.0 put them in — so on a clean tree the guide’s block failed, and on every run afterwards it passed, off the leftovers of the run before. The sandbox that stopped documentation from reaching the repository had quietly become a way for one run to reach the next.

Two fixes, and both are needed. The sandbox is emptied before anything runs, so a run cannot inherit its own past. And the guide’s block now writes the file it asks about, which reads better anyway: you write a file, then ask when it was written, and the answer is just now.

The lesson is the ordering one. A doc block is checked in isolation, so it must be isolated — and the failure here was invisible precisely because the second run of anything is the one you usually look at.

The last two pages, and one test instead of two — 7f5a3e5, 2026-08-22

The checker took one path; it now takes several, so README.md and index.md join the sweep. They are the first thing anyone reads and were the last two documents nothing checked. 589 claims across 40 files — 398 in examples/, 189 across seventeen documents, and two on the front pages.

And the front page did not compile. Its opening snippet — the four lines that introduce the language to everybody who arrives — was missing the . after a := #45. The checker had seen it and said nothing: a block that fails to compile is classified shows syntax rather than a program and skipped, which is right for the $ ./bin/solis transcript further down and wrong here. So the category that keeps the checker honest about what it cannot check is also where a real fault can hide. Four annotations on those lines were marked as asides with a leading --, and two more in index.md, so what remains is a program that runs.

test_the_examples_do_what_they_claim and test_the_documents_do_what_they_claim are now one case, test_everything_written_down_is_true, because there is no longer a distinction to draw: one invocation, one floor, everything in the repository that says what it prints.

The documentation now has to mean what it says — b381479, 2026-08-22

programs/expect.sol checked the examples’ comments; it now checks the documents’ too. 586 claims on every build — 398 in examples/ and 188 across seventeen documents — in about four seconds.

The guide and the reference carry the same notation inside ``` fences that the examples carry in comments, and nothing checked one of those either. They are the two documents a newcomer actually reads.

Two things were wrong.

The guide showed a stack trace reading [line 1] in block. Traces have named the file since 6.27 — the illustration predates that and was never updated.

class-and-instance.md said integer has 24 slots, in three places. It has 38: messages were added over nine releases and the count was not. That number is safe to state precisely because it is checked now.

A block that does not stand alone is not checked, and not a failure. 40 of 152 blocks continue one further up or show syntax rather than a program. The checker counts them and prints the count — one that silently verified a quarter of its subject would be worse than none, which was the caveat given before any of this was written.

Five bugs in the checker, each found by it doing something visible. A .sol must be compiled where it lies, because @include looks beside the including file — moving examples/include.sol to build/ lost library.sol. Standard input must come from nowhere, or a block documenting readLine waits for a person who is not there; that one hung. Only the first documented error in a block is reachable, since the first stops the program — a page showing four refusals produces one. And documented output cannot be checked in order with the claims: with stderr merged, an unbuffered complaint arrives before a buffered print that ran earlier, which reported a message that was word for word correct.

And the fifth, which is the one worth the entry: the checker executes documentation, and documentation shows how to delete things. system:run(["rm", name]), system:remove("build"), system:writeFile("notes.txt", ...). Most of those name something undefined and fail before reaching the filesystem — but the writeFile has literal arguments, ran, and put back a file that a commit had deliberately deleted, which is how it was noticed. Blocks from a document now run in build/expect-run, so anything they write lands somewhere disposable. A shipped example is not moved: those run from the repository root by convention, and walk.sol and time.sol both stop working anywhere else.

CHANGELOG.md is the one document skipped: it records what was true at each release, so its snippets describe past states on purpose.

Twelve comments across the documents were marked as asides with a leading --, the convention the examples already use — a timestamp, three clock readings, and glosses like ; control flow is / ; ordinary sending, which read as a sentence across two lines rather than as claims about output.

0.19.0 — 2026-08-22

A documentation release. No code changed — the only edit outside docs/ is three comments in embed/host.c retargeted after 6.32 moved. .sob files are format version 14, unchanged, and every binary behaves exactly as it did in 0.18.0.

The roadmap has nothing left to decide. 6.32 — whether a script should run with less than the whole machine — was deferred rather than taken, and moved to ideas.md keeping its number. It was the only entry the roadmap ever held that came from a concern rather than from a program wanting something, and the concern is about a use this language does not have. Four days of reasoning are kept in full; the trigger is somebody running a script they did not write.

lineage.md is new, and is the page to read first if you already write another language: what Solum took from Smalltalk and Self, that Io is its closest living relative and Lua its closest in engineering, and an “if you already know…” section for the five. Linked from the README, the guide’s opening and the tutorial.

And then the question that page invited — what those languages have that this might want. Surveyed in ideas.md, one entry each with a verdict, split from the roadmap by evidence. It produced exactly one roadmap entry, and that one came from this repository’s own programs:

3.13, a loop is left by its condition, or by failing. A whileTrue body cannot end its own loop; a flag ends it at the next test, after the rest of the body runs, and the only exit from inside a body is error:raise caught outside. Nine of 69 loops here carry the workaround and two of them mention it — the seven silent ones being the better evidence, since seven files reaching for one shape without comment is an idiom rather than a complaint.

Deferred with triggers: an early exit from a loop, intercepting a message that was not understood, a set type, and mathematics with a source of randomness — there is no random number source anywhere today. Turned down with reasons: tail calls, coroutines, multiple return values, resuming from an error, more than one parent.

Native C extensions were scoped and filed in ideas.md: yes in principle, and half of it works today, since a primitive is already a C function pointer hung on an object. What is missing is a supported surface, a loader with an ABI, and somewhere for a foreign resource to live — the collector has no finalizer of any kind.

What the relatives have, surveyed — and one roadmap entry from it — 579fcd7, 2026-08-22

lineage.md placed the language among Smalltalk, Self, Io, Lua and Ruby; this is the follow-on question — what those have that this might want. Split by evidence, since the roadmap’s rule is that an entry means a program wanted something and could not have it.

The survey produced exactly one roadmap entry, and it came from this repository’s own programs rather than from the other languages. 3.13: a whileTrue body cannot end its own loop. Setting a flag ends it at the next test, after the rest of the body runs, and the only exit from inside a body is error:raise caught outside — failure machinery doing control flow’s job.

of 69 whileTrue sites  
carry a done boolean whose only job is to stop the loop 6
test an accumulator for the same purpose 3
said anything about it 2

The seven silent ones are the better evidence: a complaint is somebody noticing, and seven files reaching for the same shape without comment is an idiom.

The entry began as a false claim and was corrected before it shipped. It was going to be titled “a loop cannot be left early”, and that is not true — error:raise with an onError outside does leave a loop, including one the compiler has inlined to jumps, and lib/json.sol already uses exactly that for parse failure. What is true is narrower and more interesting.

Also corrected: the two libraries that complained cite 3.2, and what they wanted is smaller than 3.2 — not a return from an enclosing method, but a way out of a loop. Both now cite 3.13, and 3.2 records that two libraries hit it. Section 3’s introduction had never added 3.12 to its list of restrictions that were found rather than chosen; it now lists six.

And a flag is sometimes right, which kills the simple reading: html:closeThrough sets done and then deliberately runs self:pop, because the rest of the body is wanted.

ideas.md takes the other nine, one entry each with a verdict. Deferred with a trigger: an early exit from a loop, intercepting a message that was not understood, a set type, and mathematics with a source of randomness. Turned down with a reason: tail calls, coroutines, multiple return values, resuming from an error, and more than one parent.

Two of the “no”s are worth the reading:

Tail calls look like the answer to 3.5’s 62-frame limit and are not. The two programs that hit that limit are recursive-descent parsers, and a recursive-descent parser never recurses in tail position — parseExpression calls parseTerm and then combines. The case evaporates on inspection.

Coroutines are blocked by something specific: the interpreter re-enters itself on the C stack, so prim_while_true’s loop and prim_array_collect’s index sit between Solum frames with no representation in vm->frames. The frames would move; the C frames interleaved with them cannot. That is the price of re-entrancy, which is also what lets ifTrue and whileTrue be ordinary messages.

Nothing in the survey re-proposes something already decided — cascades, ifTrue{...}, @ifdef, integer widths, a JIT, Go-style concurrency and subclassing integer are all previously argued and left alone.

A page placing the language among its relatives — 96f4649, 2026-08-22

docs/lineage.md: what Solum borrowed and from whom, which living languages sit nearest it, and what will surprise somebody arriving with another language in their hands. Written for a reader who wants to place the language before learning it, which none of the existing pages does — the tutorial teaches, the guide tours, the reference looks things up, and design.md explains the inside.

design.md already summed it up in three words — “Smalltalk lineage, prototype flavour” — and that turned out to be exactly right, so the page unpacks it rather than replacing it. Smalltalk gave the vocabulary and the central idea, that control flow is message sending. Self gave the object model: slots holding state and behaviour alike, no class as a separate kind of thing, new delegating rather than copying.

Two languages the documents had never named. Io is the closest living relative and arrived at nearly this design point independently — worth knowing about if you do not. Lua is the closest in engineering: a small C VM, bytecode, a mark-sweep collector, and a serious intent to be embedded.

The page also carries an If you already know… section for Smalltalk, Self and Io, Ruby, JavaScript and C, since what a newcomer needs is less “here is the family tree” than “here is what will trip you”.

One claim was corrected by running it. The page asserted that comparing an integer to a float is an error rather than false. Arithmetic and ordering do refuse — #1:add(1.0) and #1:lessThan(1.0) both raise — but equals answers false, because whether two values are the same is worth answering across types where which is larger is not. Everything said about Solum is checked against the reference, the roadmap, or the VM; everything said about the other languages is recollection, and the page says so at the bottom rather than leaving a reader to assume otherwise.

Linked from the README, index.md, the guide’s opening and the tutorial’s where-next, since a page nobody finds before the tour is a page that has missed its reader.

Scoped: extensions from a C binary — 9fba95e, 2026-08-22

A question rather than a change: could Solum gain something it cannot express — a database, a graphics surface, a codec — from a C library loaded at run time, rather than by growing the VM? Answered and filed in ideas.md under Deferred, with a trigger. Nothing is built.

The answer is yes, and half of it works today. A primitive is already only a C function pointer hung on an object, sol_object_define_primitive is already public, and system is built from exactly the three calls an extension would make. A host with its own binary can add messages right now; the sketch’s .sol interface file is lib/shell.sol’s shape unchanged.

Three things are missing, and the exploration turned up specifics worth having written down even if this is never picked up:

Two findings from the reading, both checked rather than reported: SolObject.payload is declared, written once as zero, and read by nothing in the entire tree — eight bytes on every object, waiting for roughly this purpose and not sufficient for it. And sol_gc_push_temp overflows at eight deep by calling exit(1) with no diagnostic, which any extension author would eventually meet.

It also names a cost worth knowing before rather than after: design.md says “nothing has to be released… no message hands back anything a program is obliged to close.” A database connection would be the first, and that sentence is the reason an uncatchable stop is cheap.

Trigger: somebody wants a capability Solum cannot express and that is not worth putting in the VM. First move if so: not any of the above, but one throwaway extension with nothing to release, built and loaded, to find out what the path actually wants.

6.32 goes to the idea box, and the roadmap has nothing left to decide — ccd64e9, 2026-08-22

6.32 — whether a script should be able to run with less than the whole machine — moves from ROADMAP.md to ideas.md, deferred rather than taken. It keeps its number, which is cited from about thirty places and is never reused.

Why it was the odd one out. Every other entry the roadmap ever held came from a program wanting something and not having it. This one came from a concern about a use this language does not have: 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 experimental and was never planned for web services — the question was asked because it 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. Neither has happened. Until one does, the honest position is the one embedding.md already takes — a host gets limits, and gets told plainly that nothing here is a sandbox.

Everything the four days added is kept in full, because deciding this later from a blank page would cost far more than keeping it does: the threat model, the two sets of dangerous messages, why system:exit is not one of them, why a capability per message is not fine enough, and the @include complication.

And it left two things behind that were worth having on their own, both built and neither a permission: 6.33, the limits a host may set before a program runs; and the whole embedding interface, which exists because working out what a permission would attach to meant first writing down what a host may rely on — and that write-down found a use-after-free and a false claim of this project’s own.

So the roadmap is down to section 3 and nothing else: no work, and no decision. What is there are the restrictions the language lives under, four of which were found rather than chosen.

0.18.0 — 2026-08-22

.sob format 14. Recompile: files from 0.17.0 and earlier are refused.

$ solvm old.sob
solvm: cannot load 'old.sob': unsupported bytecode version
$ solas program.sol && solvm program.sob      # the remedy, and it costs nothing

Nothing about the language changes — same syntax, same instructions, same semantics, same everything a program can observe. What changed is one byte order inside the file.

A .sob was a little-endian container holding a big-endian instruction stream. The tables used one order and the two-byte operands inside the code section used the other: two conventions arrived at separately, each internally consistent, and never compared until disasm.sol had to decode both in one program and got the operands backwards. That does not read as a misreading — every index comes out 256 times too large, which looks like a corrupt file. 0.17.0 documented the split; this removes it, and “little-endian throughout” is now simply true.

The order lives in two constants now, and used to live in thirteen places. SOL_U16_FIRST_SHIFT and SOL_U16_SECOND_SHIFT in bytecode.h are the whole of it; reading had been single-sourced since the beginning and writing never had — twelve copies of (v >> 8) & 0xff across the compiler and the tests. That collapse is what made the flip itself a two-character edit, and a round-trip test holds the pair to each other so changing one and not the other fails the build.

One thing make test could not have caught. disasm.sol is a reader written in Solum and nothing checks its two decoders against the C — the suite would have stayed green with it reading every operand backwards. What caught it is the thing that program exists for: disassembling a fresh file and comparing against solvm --dump, which agrees over 5,737 instructions across five files including both shipped libraries. Two independent implementations having to be wrong identically is a better check than either alone.

Also: serialize.h’s format table was missing constant tag 3, a boolean — the same gap design.md had, in the one document that was otherwise complete. And 3.4 said a format change had happened once; it has happened three times, and this is the only one that bought consistency rather than a capability.

.sob format 14: little-endian throughout, and now that is true — 8a8dfd4, 2026-08-22

A format change, so every existing .sob is refused and must be recompiled. solvm --version says format 14. Nothing about running a program changes: same language, same instructions, same semantics.

A .sob was a little-endian container holding a big-endian instruction stream. The tables used one order and the two-byte operands inside the code section used the other — two conventions arrived at separately, each internally consistent, and never compared until disasm.sol had to decode both in one program and got the operands backwards. That does not read as a misreading: every index comes out 256 times too large, which looks like a corrupt file.

0.17.0 documented the split. This removes it. The operands are little-endian now, and “little-endian throughout” — which design.md and serialize.h had both been claiming — is simply true.

It cost two characters, because the entry below had already collapsed the order into SOL_U16_FIRST_SHIFT and SOL_U16_SECOND_SHIFT. That was the point of doing that first.

And one thing make test could not have caught. disasm.sol is a reader written in Solum and nothing checks its two decoders against the C — the suite would have stayed green with it reading backwards. What caught it is the thing that program exists for: disassembling a fresh file and comparing against solvm --dump, which agrees over 5,737 instructions across five files at format 14. Two independent implementations having to be wrong identically is a better check than either alone.

Also here: serialize.h’s format table was missing constant tag 3, a boolean — the same gap design.md had, in the one document that was otherwise complete. And 3.4 said a format change had happened once; it has happened three times, and this is the only one that bought consistency rather than a capability.

The bytecode’s byte order now lives in one place — e9c7827, 2026-08-22

No behaviour changes and no format changes. A .sob is still a little-endian container holding a big-endian instruction stream, which 0.17.0 documented after disasm.sol got the operands backwards; this makes that order a thing written down once rather than thirteen times.

Reading was single-sourced from the beginning and writing never was. sol_read_u16 had one definition and a comment explaining why — “so the byte order cannot drift between the emitter, the verifier, and the executor the way the lengths once did” — while the write side had twelve copies of (v >> 8) & 0xff scattered across compiler.c and four test files. One of those, in test_inline.c, was a hand-rolled decode that should have been sol_read_u16 and was not.

#define SOL_U16_FIRST_SHIFT  8      /* the whole of the byte order */
#define SOL_U16_SECOND_SHIFT 0

static inline uint8_t  sol_u16_first(uint16_t v);
static inline uint8_t  sol_u16_second(uint16_t v);
static inline void     sol_write_u16(uint8_t *at, uint16_t v);
static inline uint16_t sol_read_u16(const uint8_t *at);

Named by position rather than by significance, because position is what a caller emitting one byte after another cares about. All thirteen sites go through these now — emit_index, emit_loop, patch_jump, and every test that hand-assembles a chunk or patches a jump offset to check the verifier rejects it.

A round-trip test holds the pair to each other, so changing one shift and not the other fails the build rather than whatever runs next. Verified by doing it.

And this makes the two halves of the format agree on demand. Setting the two shifts to 0 and 8 turns the code stream little-endian, and the whole suite passes end to end — verified, then reverted. What a real flip would still need is a .sob version bump, the code section being stored verbatim, and an edit to the two decoders in disasm.sol. That last one is worth naming: it is a reader written in Solum, and nothing checks it against the C, so a flip would leave it reading backwards quietly. The check that would catch it is the one that program already exists for — disassembling a fresh file and comparing against solvm --dump.

Deferred deliberately: flipping now would spend format version 14 on consistency rather than correctness, and 3.4 makes a version bump a real event. The next time one happens for another reason, this costs two characters.

0.17.0 — 2026-08-22

Two programs that read this project’s own work, and the four faults they found in it.

$ ./bin/solvm programs/expect.sob
21 files with expectations, 398 claims checked
every claim holds

No language change and no API change. .sob files are format version 13, unchanged since 0.11.0, and every binary behaves as it did. What changed is that three documents were wrong and are not, and that about four hundred claims which nothing checked are now checked on every build.

disasm.sol reads a .sob and disassembles it — the first program here to read a binary format, and a second implementation of one this project already had, which is how you find out whether a specification is true. Written from the documents, going to the C only where they ran out. They ran out five times.

the fault now
BYTECODE.md never said what byte an opcode is every row carries it, and a test checks it against the enum
design.md said both “big-endian” and “little-endian throughout” about the same bytes both sections agree, and say which is which
the .sob format table had been missing three sections since version 12 the file table, the file-run table and the slot names are in it, with constant tag 3
the table did not separate the file header from a chunk body it does, so “recursively” means the body

expect.sol runs every example and checks the comments that say what each line prints. Nothing had ever checked one of them: the suite compiled every example and never ran one, so four hundred comments were true because somebody looked, once. All 398 hold. What it turned up instead is that three conventions for those comments had grown up unnoticed, because nothing had ever had to parse them. It runs in make test in a third of a second, and was verified to fail rather than assumed.

And one claim of this project’s own, disproved by being written down. disasm.sol reported <i64 too large to read> for integers with the top bit set, and three places said Solum could write an integer into a .sob it could not read back. Stating that as a roadmap entry meant checking it, and arithmetic reaches what shifting cannot — (b - 256) * 2^56 — so the disassembler reads INT64_MIN correctly and what is left is 3.12, which is much smaller and true. That is the second time in two days a written-down claim of mine failed at the moment it became a promise; the first was sol_vm_intern_chunk in 0.15.0.

programs/ is nine files now, and docs/programs.md says what each does.

3.12, and the claim it disproved by being written — 7a29867, 2026-08-22

disasm.sol reported <i64 too large to read> for any integer constant with its top bit set, and said in its own comments — and in programs.md, and in the entry below — that Solum could write an integer into a .sob that it could not read back.

That was wrong, and giving it a roadmap number is what found out. Writing “here is the limitation” meant stating it exactly, and stating it exactly meant checking it, and it does not hold:

b:shiftLeft(#56)                    ; error, for any b of 128 or more
b:sub(#256):mul(#72057594037927936) ; the same number, every step in range

b - 256 is between -128 and -1, so the product lands between INT64_MIN and -2^56 and nothing overflows on the way. The disassembler reads every i64 now, INT64_MIN and -1 included, and still agrees with solvm --dump everywhere.

What is true is much smaller and is 3.12: no shift can produce a negative integer. There is no unsigned type and shiftLeft traps rather than wrapping, so nothing can put a one in bit 63 — which follows from two decisions worth keeping, and costs a line of arithmetic to work around.

Second time in two days. The other was a claim that a host had to call sol_vm_intern_chunk, written up in a header and a page before being tried; sol_vm_run calls it. Both times the error survived being written into a program’s comments and a document, and neither survived being written as a promise somebody might rely on. That is an argument for the roadmap entry, not against it.

The examples now have to mean what they say — 7ba94a1, 2026-08-22

programs/expect.sol runs every file in examples/ and checks the inline comments that say what each line prints. It is in make test.

21 files with expectations, 398 claims checked
72 lines print without saying what, and are not checked
2 ended with a non-zero status, which two of them do on purpose

every claim holds

Nothing had ever checked one of them. The suite compiles every example and never ran one, so about four hundred comments of the form #2:add(#3):print. ; #5 were true because somebody looked, once, at the time — the same standing the .sob format table had when disasm.sol found it three sections out of date yesterday. They are also the first thing a newcomer reads, which makes them the documentation here with the widest audience and, until now, the least checking.

Every claim that states a value holds. All 398. What the checker turned up instead is that three conventions for these comments had grown up unnoticed, because nothing had ever had to parse them:

; #5                      the value alone
; #7 -- and why           an aside after a dash
; #8 distinct words       an aside with no dash at all

It learned all three rather than declaring two of them wrong, which is the choice worth recording: a checker that insists on a convention its subject never agreed to is measuring itself.

Nine comments were glosses rather than claims — a timestamp that changes every run, a duration at the clock’s floor, ; midnight beside a time, ; T or a space beside a parsed one. Those now open with --, which the checker reads as an aside claiming nothing; and two that abbreviated a time to its interesting half now give it in full with the emphasis as an aside, which reads better against real output anyway.

In tests/test_cli.c, with the other tests that run the binaries as a shell would — about a third of a second for all twenty-one files. Verified to fail: changing one ; #5 to ; #6 fails the build.

Matching is by subsequence rather than line-for-line, because one statement can print many lines; a claim must appear in the output after the one before it. The cost is that a claim could be satisfied by a later coincidental match, and the benefit is that it works on files with loops in them, which is most of them.

A disassembler in Solum, and the three document faults it found — dcecd20, 2026-08-22

programs/disasm.sol reads a .sob file and says what is in it — header, tables, and every instruction with its offset, line, operands and jump targets, recursing into each method and block.

$ ./bin/solvm programs/disasm.sob
build/disasm-sample.sob  --  456 bytes, format version 13

script  -- 1 slots, 10 names, 4 constants, 88 bytes
     0 line 1   block        'block'
     3 line 1   setGlobal    'greet'
    ...
    24 line 3   exitIfFalse  +32 -> 59
    56 line 5   loop         -45 -> 14

The first program here to read a binary format, and the first to read one this project defines. solvm --dump already disassembles, so this is a second implementation — which is the point. It was written from design.md and BYTECODE.md, going to the C only where those ran out, and they ran out five times.

Three faults in the documents, all fixed here.

BYTECODE.md never said what byte an opcode is. It described every instruction — operands, length, stack effect — and tests/test_bytecode.c checked that description against the header in both directions. The mapping from byte to instruction lived only in the order of a C enum, so a reader with the page in front of them could not decode a single instruction. Every row now carries its byte, and a new case in that test checks each against the enum, so an opcode inserted in the middle fails the suite rather than silently making the page wrong.

design.md contradicted itself about byte order, a hundred lines apart. The instruction-set section says a side-table index “is a big-endian u16”, which is right. The .sob section said “little-endian throughout”, which is true of every table in the file and false of the two-byte operands inside the code — and a reader after the file format lands on the second one. Getting it backwards does not look like a misreading, it looks like corruption, every index 256 times too large. Both sections say it now.

The format table was missing three sections and a constant tag. Between the line runs and the methods there are a file table, a run table saying which file each stretch of code came from, and the slot names; and a constant may be tagged 3, a boolean. Those arrived with 6.27 and 6.28, which bumped the format to 12 and then 13 — and the table was not bumped with them. The table also did not separate the file’s header from a chunk’s body, so “then that method’s chunk, recursively” read as though the whole thing recurred; only the body does.

And two findings about the language, neither a defect. No shift can produce a negative integer, there being no unsigned type: shiftLeft(#56) on a byte of 128 or more is a value larger than an i64 holds and the language traps rather than wrapping. And a float has to be decoded by hand, one bit-field at a time, because nothing reinterprets an integer’s bits as a float; readFloat is IEEE-754 binary64 written out in Solum, and 2.5 comes back 2.5.

Corrected the next day. This entry first said an i64 with its top bit set could not be decoded, and that Solum could write an integer into a .sob it could not read back. Both were wrong — arithmetic reaches what shifting cannot, and the disassembler reads INT64_MIN correctly now. See 3.12, which is the entry that disproved the claim by being written.

Not a finding, though the reference reads as though it might be: system:readFile handles a binary file exactly as it should. The Limits table’s “no \0” is about what a literal may contain — a string read from a file holds every byte the file had.

Checked against the oracle. Identical offsets, opcodes, operands and jump targets to solvm --dump over eight files and 7,673 instructions, lib/json.sol and lib/html.sol among them.

0.16.0 — 2026-08-22

A data race, fixed; threads, settled by measuring; and a host can keep its failures to itself.

The serial a machine is stamped with was not atomic, which matters to anybody building VMs on more than one thread and to nobody else. sol_vm_init used a plain next_vm_id++, so two threads could be handed one number — and a chunk they shared would then believe it was already resolved for the second and dispatch against the first’s name table. That is the 0.14.1 use-after-free reappearing inside its own fix, and 0.14.1 and 0.15.0 both carry it.

480,000 machines on 16 threads  
duplicate serials, before 10,319 — one in fifty
after _Atomic 0

Three instructions inside a sol_vm_init that takes 52µs, colliding at 2.1%. A contended increment is nothing like as brief as its instruction count suggests.

A chunk still cannot be shared between threads, which no atomic would fix: running one mutates it, the interned names being cached on the chunk and keyed to one machine at a time. Eight threads and one chunk is a segmentation fault; the same serialised behind a mutex is 0 failures of 2,400. What is now tested and promised is one VM and one chunk per thread, with source text shared freely. That is 3.11.

sol_vm_set_error_reporting(vm, false) stops sol_vm_run writing an uncaught failure to stderr, and stops only that — the result still says what happened and the text is still readable. On unless asked, so the four front ends are unchanged. A host was getting every failure twice: once in its own log and once in a format it did not choose.

And two things got measured rather than guessed. A fresh VM per request is a third of a request — 40.5µs of 121.0µs at -O2, and the ratio holds in a debug build, which makes it a property of the design rather than of the compiler. Compiling the script is 279µs, paid once. Both are in 3.10, which had said nobody had measured it.

The roadmap is the single list again. Three limitations were living only in embedding.md — writing down what a host may rely on means writing down what it may not, and nobody had numbered the second half. They are 3.8, 3.10 and 3.11. Section 3 now separates the restrictions that were chosen from the ones that were found.

No language change. .sob files are format version 13, unchanged since 0.11.0, and solvm, solas, solis and solid behave exactly as they did — each builds one machine on one thread and could not reach the race.

The test suite grows -pthread on one target and only that target.

Threads, settled by measuring — d03810f, 2026-08-22

3.11 said nothing was known about threads and that what would settle it was a test rather than a decision. It said that for about an hour. tests/test_threads.c is the test, and it found two things — only the first of which it was written to look for.

The serial was not atomic. sol_vm_init stamped each machine from a plain next_vm_id++, which is a read-modify-write, so two threads building a machine at once could be handed the same number — and a chunk they shared would then believe it was already resolved for the second and dispatch against the first’s name table. The 0.14.1 use-after-free, reappearing inside its own fix.

   
machines built, 16 threads 480,000
duplicate serials, before 10,319 — a rate of 2.1%
duplicate serials, after _Atomic 0

Three instructions inside a sol_vm_init that takes 52µs, colliding one time in fifty. A contended increment is nothing like as brief as its instruction count suggests, which is the part worth carrying away. _Atomic uint64_t and memory_order_relaxed — relaxed being enough, since nothing is published alongside it and all that is needed is that no two machines get one number.

And a chunk cannot be shared between threads at all, which no atomic would have fixed. Running a chunk mutates it: the interned names are cached on the chunk, keyed to one machine at a time, so two threads running one free and rebuild that table under each other.

eight threads, one chunk, 2,400 runs  
runs concurrent segmentation fault
runs serialised behind a mutex 0 failures

So the fault is entirely in the sharing. A host could put a mutex round sol_vm_run, and that serialises all execution, which is the opposite of why anybody wanted threads.

What is safe and is now tested and promised: one VM and one chunk per thread. Source text is shared freely, because reading text mutates nothing — so threads share the .sol and each compiles its own chunk. Held under a collection on every allocation too, since each machine owns its heap and the collector never leaves it.

Two threads in one VM is not supported and not tested: a machine has one stack, one heap and one frame array, and nothing guards any of them.

The test suite grows a -pthread on one target, and only that target, so a build without pthreads still gets everything else.

A failure can be the host’s, and three gaps got numbers — 4df3c48, 2026-08-22

Two small things, both of them 0.15.0’s leftovers.

sol_vm_set_error_reporting(vm, false) stops sol_vm_run writing an uncaught failure to stderr. On unless asked, so solvm, solas, solis and solid are unchanged and a person at a terminal is still told what went wrong.

The contract written yesterday listed this as a gap rather than fixing it: a host holding error_message and error_trace already, with its own log to put them in, was getting the failure twice and in a format it did not choose. It stops that and stops only that — the result still says what happened and the text is still there to read, which the test checks by capturing the descriptor and asserting both halves.

embed/host.c turns it off and reports failures in its own words, which is what a host is for.

And the roadmap is the single list again. It says so of itself, and it had stopped being true: embedding.md documented four limitations that existed in no other document, because writing down what a host may rely on means writing down what it may not, and nobody numbered the second half.

   
3.8 a host and a script agree a global name and nothing checks that they do
3.10 a VM cannot be reused across runs, globals being one flat namespace nothing unbinds
3.11 nothing is known about threads

3.9 is skipped because it is taken, which is the roadmap’s own convention: a gap in the numbering is a record rather than a mistake. The fourth gap was the stderr one, and it is fixed above rather than numbered.

Section 3’s introduction now separates the restrictions that were chosen from the ones that were found — 3.1 to 3.6 against 3.7, 3.8, 3.10 and 3.11 — because the two ask different questions of a reader.

One claim in 3.11 is checked rather than assumed: the serial counter added in 0.14.1 is the only file-scope mutable state in the library, across all four components. That is the whole of what one-VM-per-thread would have to synchronise, and a reason to expect the answer to be short when somebody tries it.

0.15.0 — 2026-08-22

Embedding is a documented interface, and the order that happened in is the point.

#include "solas/compiler.h"     /* source text -> a chunk */
#include "solum/embed.h"        /* a chunk -> a run */

sol_vm_set_global_text(&vm, "request", body);
if (sol_vm_run(&vm, &chunk) == SOL_OK) {
    char *answer = sol_vm_global_text(&vm, "answer");
    /* ... */  free(answer);
}

solum/embed.h is the whole supported surface a host embeds Solum through — everything else under solum/include is now explicitly the machine’s own business. docs/embedding.md is the contract in prose and tests/test_embed.c has a case for every promise it makes.

Written before deciding permissions, deliberately. A permission is a promise about what a host may rely on, and 6.32 had nothing to attach one to. That entry’s precondition is met now; the decision itself is unchanged and still open.

Four functions, none of them new capabilitysol_vm_global, sol_vm_global_text, sol_vm_set_global, sol_vm_set_global_text, plus sol_vm_error_message and sol_vm_error_trace. Each names two or three calls a host could already have made, which is the whole idea: three internal calls in the right order is not something anybody can rely on.

What is deliberately not promised is stated as plainly as what is, because that is where a permission scheme would have to live: no route for a run’s output except a global name the two sides agree on with nothing checking that they do, no way to silence a failing run’s stderr, no safe reuse of one VM across runs, nothing about threads, and nothing whatever about what a script may reach.

No language change. .sob files are format version 13, unchanged since 0.11.0. solvm, solas, solis and solid behave exactly as they did.

The embedding interface, written down — a46cee0, 2026-08-22

solum/embed.h is the whole supported surface a host embeds Solum through, docs/embedding.md is the contract in prose, and tests/test_embed.c has a case for every promise it makes.

Written before deciding permissions, deliberately. A permission is a promise about what a host may rely on, and 6.32 had nothing to attach one to: the headers made embedding possible and no page claimed it. That is also why the 0.14.1 use-after-free got out — with nothing stated there was nothing to test against, and four shipped binaries could not reach the path.

It caught a mistake in the first hour. This project had twice said a host must call sol_vm_intern_chunk before each run. sol_vm_run calls it, and always did — the defect was inside that function rather than in a call somebody could miss. So the interface is one ordering rule simpler than it had been written up as, and embed/host.c no longer makes a call that did nothing. Corrected in the host, the page and the roadmap.

Four functions, and none of them new capability. Each names two or three calls a host could already have made, which is the point: three internal calls in the right order is not something anybody can rely on.

bool  sol_vm_global(vm, name, &value);       /* a global, or false */
char *sol_vm_global_text(vm, name);          /* rendered, on the heap, caller frees */
void  sol_vm_set_global(vm, name, value);    /* hand a script its input */
void  sol_vm_set_global_text(vm, name, chars);

That closes the gap the host found first — a run’s output went to stdout because display writes there and nothing else existed, and a webserver needs the page as a value. sol_vm_error_message and sol_vm_error_trace are there for the same reason, a host having previously had no way to read a failure it was already being shown on stderr.

And what is deliberately not promised, which is the half a permission scheme would have to live in: no route for a run’s output except a global name the two sides agree on with nothing checking that they do; no way to silence a failing run’s stderr; no safe reuse of one VM across runs, globals being one flat namespace that nothing unbinds; nothing about threads; and nothing whatever about what a script may reach, which is 6.32 and still a decision.

The test file is shaped like a host, not like a test: VMs are built inside called functions, chunks outlive the machines that ran them, and one chunk serves eight. That is the shape 0.14.1 needed and the shape test_a_second_vm_reresolves did not have — it holds both machines as locals of one function, which puts them at different addresses and makes a pointer comparison work. It was never wrong; it was never in the shape that fails.

0.14.1 — 2026-08-22

A use-after-free, found by the first program to embed the machine.

A chunk recorded which VM had interned its names by pointer, and sol_vm_intern_chunk skipped the work when it matched. Free a VM and make another and the second can land at the address the first had — which a host running a script per request does every time, the VM being a local of the function that serves one. The chunk concluded it was already resolved and went on reading the freed machine’s name table.

solvm: undefined name 'lessThan'
solvm: cannot bind 'shiftRight' on boolean

Six of seven requests failed that way, each naming a different built-in and none of it meaning anything.

SolChunk.interned_for is a uint64_t serial now rather than a const SolVM *vm->id, assigned in sol_vm_init from a counter, unique for the life of the process. That is a type change in a public header, so anything reading the field directly needs the one-line edit; nothing in this repository did outside the tests.

Nothing else is affected. solvm, solas, solis and solid each build one VM and run, so none of them could reach it. .sob files are unchanged and still format 13. The language is unchanged.

Why the tests missed it. test_a_second_vm_reresolves is about exactly this hazard and holds both VMs as locals of one function — which puts them at different addresses and makes a pointer comparison work. It was never wrong; it was never in the shape that fails. test_a_reused_address_is_not_the_same_vm builds each VM in a called function and runs one chunk through four of them.

Also in this release: embed/host.c and docs/embedding.md, which are what found it.

A host, and the use-after-free it found on its first run — 12119b0, 2026-08-22

embed/host.c, built with make embed: a C program that holds a SolVM and runs serve.sol through it once per request. Not a component and not in all — a demonstration of the interface 6.32 assumes exists, written to find out whether it does. docs/embedding.md is what it learned, written for a reader.

It found a use-after-free on its first run, and it is fixed. SolChunk.interned_for recorded which VM had resolved a chunk’s names, as a const SolVM *, and sol_vm_intern_chunk skipped the work when it matched. A host serves each request in a function that builds a VM as a local — so every request’s machine landed at the same stack address, the chunk believed it was already resolved, and every run after the first read the freed previous VM’s name table.

==== a search: /search?q=limit
solvm: undefined name 'lessThan'
==== a traversal: /note/..
solvm: undefined name 'truncated'
==== the index: /
solvm: cannot bind 'shiftRight' on boolean

Six of seven requests failed that way, each naming a different built-in and none of it meaning anything. interned_for is a serial now — vm->id, assigned in sol_vm_init from a counter and unique for the life of the process — so an address handed back to a later VM cannot be taken for the same machine.

Why nothing caught it. test_a_second_vm_reresolves exists and is exactly about this, but it holds both VMs as locals of one function, which puts them at different addresses and makes a pointer comparison work. test_a_reused_address_is_not_the_same_vm builds each in a called function, which is what a host does, and runs one chunk through four of them.

.sob files are unaffected: interned_for is a runtime field and the format stays at version 13.

What else the host showed.

6.32 records the conclusion: the interface is worth writing down before deciding what permissions it carries, because a permission is a promise about what a host may rely on and there is no list of that yet.

0.14.0 — 2026-08-22

A program written to be run by a stranger, and the shipped files divide in two.

examples/  25 files, one per concept the guide names
programs/   7 files, each written to do a job

No language change. .sob files are format version 13, unchanged from 0.11.0, and every program that ran under 0.13.0 runs unchanged under this. What moved is where the files are and what the documents say about them.

programs/serve.sol is the seventh program written to do a job and the first whose input does not come from whoever ran it — a CGI-shaped request handler, run as a guest with an allowance. It asked four things of the language and found one about the machine.

3.7, a limit bounds dispatch and not work, is the finding and it corrects 0.13.0. A step is a unit of dispatch, so readFile of 256MB plus an indexOf over all of it is eight instructions — the same eight as for 64MB. Neither limit is undone: a program still cannot loop forever or keep what it makes, which is what they were built for. What they do not bound is the cost of one message. design.md and 6.33’s own entry both said otherwise and now do not.

6.32 has its first concrete argument rather than another paragraph of reasoning: a CGI handler is told what it was asked entirely through system:environment, so a permission scheme with one switch per message must grant it — and has then granted every secret the server process holds. Per-message is not fine enough where the message names something. Still a decision.

The split, which the seven programs had been declaring in their own headers since there was more than one of them. 109 paths rewritten, the test that catches an unregistered file walks both directories, and docs/programs.md says what each of the seven does, how to run it, and what it found.

And the audit ideas.md had been carrying since it was written: does every concept the guide names have a demonstration? The guide came out clean. Four of 121 built-in messages did not — values, modeOf, setMode, setModifiedAt, all covered before the split by two programs that happened to need them, none of which had ever had a demonstration. Each went into the example it belonged in, and the coverage test asks for examples/ alone again.

The audit the split asked for, and a page for the programs — 97dc6ff, 2026-08-22

Two things the reorganisation left owed.

The audit. docs/ideas.md has carried “more examples, chosen by auditing which concepts have none” since the ideas file was written, and the split made the question answerable: does every concept the guide names have a demonstration in examples/, now that the programs are somewhere else?

Run on three axes:

   
guide sections with no Run: pointer 0 — every one of the 22 has an example
built-in messages sent by nothing in examples/ 4 of 121
examples the guide never points at 3walk, time, keys

The four were values, and modeOf, setMode and setModifiedAt. All were covered before the split, by programs/mirror.sol and programs/log.sol — so the split did not lose coverage, it revealed that four messages had never had a demonstration and were being carried by a program that happened to need them.

And the test now asks for the stricter thing. test_every_builtin_message_has_an_example built its corpus from both directories after the move; it is examples/ only again. A message “covered” by appearing incidentally in the middle of two hundred lines of log parsing is not what someone looking it up wants to find. The demonstration has to exist, and a program is free to reach for whatever it needs without that counting.

Guide §18 gained the two paragraphs its Run: line was now promising — readKey, and a file’s mode and time — and points at walk.sol, time.sol and keys.sol, which it had been discussing without naming.

The page. docs/programs.md: what each of the seven does, how to run it, and what it found. Every invocation in it was run. It ends with what the seven have in common, which is four things and no template — a job somebody would want done, running with no arguments on input it carries, a comment where the language was awkward rather than a quiet workaround, and a line in tests/test_compile.c.

The examples divide in two, and now sit that way — 500b6ac, 2026-08-22

examples/ held thirty-two files doing two different jobs. Seven of them move to programs/.

   
examples/ 25 files, each written to show one feature
programs/ 7 files, each written to do a job and use whatever the language turned out to have

The split was already drawn — by the files themselves. Each of the seven opens by saying which it is: “the fourth program here written to do a job rather than to show a feature”, numbered in the order they arrived. The line had been declared and maintained for as long as there had been more than one of them, and it was doing real work in the roadmap, where nearly every entry after the first dozen is attributed to one of these seven wanting something. All the directories do is make it visible in a listing.

So there were no judgment calls. stock.sol is a real program and stays in examples/, because it exists to be the tutorial’s worked example; walk.sol and keys.sol each demonstrate one message. Nothing needed a ruling that the files had not already given.

It was also free. The only sibling @include is include.sollibrary.sol, both demonstrations; the seven reach lib/ through the search path and nothing else. walk.sol’s default root and test_time.c’s stamped file both name examples/, and both point at files that stayed.

109 paths rewritten, including the 43 in CHANGELOG.md and COMPLETED.md. Those are dated records and their wording is untouched — but the file still exists, just elsewhere, and a link that resolves to nothing serves nobody.

The test that catches an unregistered file now walks both directories and compares the total against one list, so a file cannot hide by being moved from one to the other either. index.md gained a table of the seven, having listed five of them and been written when there were thirty files.

And each of the seven lost the second half of its own opening sentence. “The fourth program here written to do a job rather than to show a feature” is now “the fourth program here” — the directory says the rest. log.sol, being the first, keeps the explanation and says why the directory exists.

A program written to be run by a stranger — 293b376, 2026-08-22

programs/serve.sol: a CGI-shaped request handler. It answers /, /search?q=... and /note/<name> from a directory of files, and with no CGI variables set it runs seven requests through itself and prints each response, so it is testable without a socket.

$ PATH_INFO=/search QUERY_STRING=q=limit ./bin/solvm programs/serve.sob
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 221
...

The seventh program here written to do a job, and the first whose input does not come from whoever ran it. That is the whole reason it exists: every other program in examples/ is handed its arguments by the person who started it, and 6.32 is about the case where they are not. It asked four things of the language and one of the machine.

fill is the injection. It is the natural way to build a page, it reads well, and it inserts exactly what it is given. Nothing in the language or in lib/ escapes HTML — lib/html.sol reads entities and cannot write one — so the escaping is in the program, and the safe twin of fill is the one with the worse name.

A template with two kinds of hole cannot be written with fill at all, since it insists the placeholders and the values come to the same number. That check is what makes fill worth trusting, so the answer is not to weaken it — and the marker-and-split habit that replaces it is worse, because a marker is a string and a value can contain one. What is left is an array of pieces joined, which has seams a value cannot add to.

Refusing /note/../../etc/passwd is not string cleaning, and the language helps by having nothing: no path joining, no basename, nothing that normalises ... The tempting wrong answer is unavailable, and what is left is to say which names are names.

A permission per message is not fine enough. A CGI handler is told what it was asked entirely through system:environment, which 6.32 lists among the messages that reveal the machine — correctly. But the program cannot be written without it, so a scheme that can only say yes or no to environment must say yes, and has then also handed over every secret the server process holds. 6.32 now records that: the permission a webserver cannot do without is the one that gives away its secrets.

A limit bounds dispatch, not work — 293b376, 2026-08-22

Running the above the way its own case would — as a guest, with an allowance — found the edge of 6.33, which shipped the day before. New entry 3.7, and a correction to two documents that said otherwise.

A request costs 393 instructions for a note, 465 for the index and 798 for a search, which is the number a host wants. But a step is a unit of dispatch, and a primitive does all of its work between one step and the next:

program steps time
nil:print. 4
readFile of 64MB, then indexOf over all of it 8 0.27s
the same over 256MB 8 1.10s

The count does not follow the size, because the size is not what it counts.

The memory ceiling is the same fact from the other side. It is checked in sol_gc_maybe_collect, so an allocation is measured after it has been made: under --memory=1M the 256MB read completes and the program is stopped at the next instruction holding 268,450,673 live bytes. The overshoot is bounded in time and not in size — the ceiling stops a program carrying on, not going over.

Neither limit is undone. A program still cannot loop forever and cannot keep what it makes, which is what 6.33 was built for. What they do not bound is the cost of one message, which is the number a webserver actually wants. Both ways of fixing that — charging a primitive for what it handles, or refusing an allocation rather than noticing it afterwards — give up something the design currently leans on, so 3.7 records the choice rather than taking it.

docs/design.md said instructions were the one thing a program could not hide from, and that the overshoot was bounded by one instruction. Both are now said accurately. 6.33’s own entry said it bounded a program’s work; it bounds a program that loops.

0.13.0 — 2026-08-21

A program can be given a limit, and the machine can take it back.

$ solvm --steps=100000 loop.sob
solvm: stopped: the step limit of 100000 was reached
  [loop.sol:3] in script
$ echo $?
124

Limits, set by whoever runs a program rather than by the program: --steps=N for how many instructions it may execute and --memory=N for how much it may hold at once, and sol_vm_set_step_limit / sol_vm_set_memory_limit for a program embedding the machine, which is the case that wanted them. Both are off unless asked for, so nothing about running a program from a terminal changes.

The counter lives in the dispatch loop because nowhere else would do. The debug hook could already stop a running program — Solid quits out of one that way — but it is offered when the line or the frame changes, and a loop written literally compiles to jumps, so it saw an inlined loop of three million turns exactly once. Instructions are the one thing a program cannot hide from, and counting them costs less than this release could measure.

A stop is not catchable, which is the whole of what makes it a limit. onError lets it past and ensure does not run its cleanup, because both are ways of running more code and the allowance for running code is what ran out. No message reads or changes either limit. A stopped program exits 124 — neither the 0 of finishing nor the 70 of failing, since it did not fail, it was taken away.

Memory is measured after a collection, so a program is stopped for what it is holding rather than for what it has been through: of two programs making the same garbage under the same ceiling, only the one still keeping it is stopped.

And one decision recorded rather than built: whether a script should be able to run with less than the whole machine, now that system:run means it can reach all of it. That entry is 6.32, and the case behind it — a webserver producing pages, where injection could make untrusted input into code the server runs — is what turned the limits above from a footnote into the half worth building first. Permissions are still open.

.sob files are format version 13, unchanged from 0.11.0.

A program can be given a limit — 88a8ab4, 2026-08-21

6.33, built. A host may say what a program is allowed to spend before it starts it, and take it back when it has spent it.

$ solvm --steps=100000 loop.sob
solvm: stopped: the step limit of 100000 was reached
  [loop.sol:3] in script
$ echo $?
124

From C, which is the case that wanted it:

sol_vm_set_step_limit(&vm, 10000000);
sol_vm_set_memory_limit(&vm, 64 * 1024 * 1024);
if (sol_vm_run(&vm, &chunk) == SOL_STOPPED) { /* neither finished nor asked */ }

Both are off unless asked for, so a program run from a terminal is unchanged.

The counter is in the dispatch loop, and had to be. The debug hook already existed and could already stop a running program — Solid quits out of one that way — but it is offered when the line or the frame changes, and a loop written literally compiles to jumps, so neither moves. Measured with a breakpoint on the loop: an inlined loop over 3,000,000 iterations offered one stop and then ran to completion, where a loop of calls offered one per iteration. What makes --trace bearable makes the program unstoppable, so the count went where every instruction has to pass.

It costs one post-decrement and one compare, and there is no branch asking whether a limit was set: with none the counter starts at UINT64_MAX and reaches zero five hundred years from now. Measured on a five-million-turn inlined loop — around twenty million instructions — 0.74-0.75s with the counter against 0.76-1.04s without. Which is not a speed-up; it is the cost being below the noise of the measurement.

Memory is measured after a collection, which is the whole difficulty of a memory limit. Before a sweep the figure counts everything the program has ever asked for and not had taken back, so a ceiling read off it would stop a program for litter rather than for what it holds. Going over is now a reason to collect, and being over once that has happened is a reason to stop — so of two programs making the same garbage under the same ceiling, only the one still holding it is stopped.

A stop cannot be caught. onError lets it past and ensure does not run its cleanup, because both are ways of running more code and the allowance for running code is what ran out; a handler wrapped around everything would otherwise turn the limit into a suggestion. No message reads or changes either limit, so a program cannot find out what it was given or give itself more.

124 rather than 70, since the program did not fail — it was taken away. Which is what timeout answers, for the same reason.

A correction found while documenting it: REFERENCE.md still said a runtime error could not be caught, which stopped being true when onError landed.

The threat model behind the safe-mode decision, and a second decision — d518aa6, 2026-08-21

6.32 was recorded from the command line’s point of view: a person about to run a script somebody sent them. The case it actually came from is an embedding — a webserver producing pages by running Solum, where the risk is injection, and the one choosing the restriction is the server, protecting itself.

That moves the entry’s conclusions rather than confirming them.

The chooser is a program, not a person. It decides once, at startup, and runs that policy over every request for as long as it is up. So the argument that protection must be on before anybody thinks to ask it for weakens — and another takes over: the restriction has to be settable from C, before the program runs. A --unsafe argument is one front end for it, not the mechanism. If the mechanism is argv parsing, the case that asked for it cannot use it. Which makes this partly a decision to have an embedding interface at all, since no page currently says how to hold a SolVM inside another program.

What is untrusted is the data, not the file. The server wrote the script. So the permission cannot attach to where the code came from, or be decided per file — it is a property of the run.

system:exit came off the dangerous list. 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 its caller. A webserver stays up. Already right, and named in the entry because it is the one an embedding would most expect to be wrong.

6.33, the half that gets forgotten

The entry’s quietest caveat — it is not a sandbox, a restricted script can still loop forever — is an annoyance on a command line and is the whole server in a webserver, with nothing dangerous called and no injection needed. That is 6.33: a running program cannot be stopped from outside.

Some of the mechanism turns out to exist. The VM calls debug_hook when it offers a stop, and a hook may set exiting and had_error to unwind — which is how Solid quits out of a running program. But the offer is gated on the line or the frame changing, and a loop written literally compiles to jumps rather than calls, so neither moves. Measured with a breakpoint on the loop:

loop iterations times the host was offered a stop
{ ... }:whileTrue({ ... }), one line 3,000,000 1
#1:toDo(#5, step) 5 5

The inlined loop is offered once and then runs to completion uninterrupted. It is the same inlining that makes --trace quiet on a three-hundred-thousand-turn loop, seen from the other side: what makes the trace bearable makes the program unstoppable. So a budget cannot be built on the debug hook — it wants a counter in the interpreter loop itself, which is cheap but sits on the hot path.

Memory is the easier half: the collector already compares bytes_allocated against next_gc on every allocation, so a ceiling is one more comparison at that same place, against the live total a sweep leaves behind.

Both remain recorded rather than built.

A decision recorded: restricting what a script may reach — 56408a7, 2026-08-21

6.32, written down rather than built. Raised by noticing what system:run had made possible rather than by anything going wrong.

Everything a program can reach, it can reach. That is right for a script somebody wrote for themselves and wrong for one that arrived from elsewhere, and there is no way to say which this is.

The entry records three things worth deciding before any of it is built.

Which way round the default goes is the whole question. Safe-by-default protects the case that matters — a script you did not write — and breaks every existing use including this repository’s own tests. The deciding question is who is choosing the flag: somebody about to run a script they were sent is exactly the person who will not think to ask for protection, which argues for having it on before they think about it.

“Dangerous” is two sets, not one. The messages that change the machine are the obvious half; the ones that reveal it are the other. Reading ~/.ssh/id_rsa and printing it changes nothing, and environment alone will hand over a token from half the CI systems there are. A mode that only stops writing stops the obvious half — which argues for capabilities rather than a switch, and that is easier to start with than to retrofit.

And it is not a sandbox, which the entry says loudest, because “safe mode” invites more trust than it can earn. A restricted script can still loop forever, allocate until the machine swaps, or fill a disk through a file it is allowed to write. It stops a script reaching for the machine; it does not make a hostile script harmless, and anything needing that wants a container rather than a flag. The same honesty as 3.3, which says the verifier proves a chunk well-formed and not that it stops.

One complication found while writing it: @include reads files and the search path reads the shipped library, so a mode with no reading cannot compile a program that uses lib/json.sol. 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.

0.12.0 — 2026-08-21

A debugger, and a program can run another program.

$ solid report.sol
(solid) break report.sol:5
(solid) continue
report.sol:5  in block
(solid) locals
  amount           #30
  after            #70

Solid is the fourth program, and the last entry the roadmap held. bin/solid runs a program, stops before its first line, and takes commands: step, next, finish, continue, breakpoints, a backtrace, and the locals of a frame by name. It stops where a program breaks, with the frames still standing and the value that caused it still in one — which solis --interactive cannot do, since that begins after the unwind and sees only globals.

The machine knows nothing about debugging: the VM offers a stop before each instruction that begins a new line or enters a new frame, and Solid decides whether that stop is interesting. solum/ gained a function pointer and a branch, and the branch costs nothing measurable — three million loop turns, 0.43s either way.

system:run and system:capture let a program run another program, which a language aimed at scripting an OS could not do until now. They take an array of arguments rather than a command line, and that is the decision in them: a file called ; rm -rf ~ is a name when it is one string in an array, and a sentence when it is text a shell parses. The shell is reachable and spelled out — ["/bin/sh", "-c", "..."] — and lib/shell.sol wraps it, so the convenience is a line away and the hazard is named where it is taken.

capture answers a dictionary of "output" and "status", because grep finding nothing is not grep failing. A missing command answers #127 and a killed one 128 plus the signal, neither raising.

string:trim, wanted within the hour by the first program that read a command’s output: wc -l answers " 100\n" and asInteger will not have it.

.sob files are format version 13, unchanged from 0.11.0 — nothing here touched the format.

The roadmap emptied and then grew again, both in this release. Every entry it held is built, and the two raised since arrived the way it says they should: something was wanted and could not be had. ROADMAP.md now ends with how it emptied rather than what is next, because that is the part that transfers.

A program can run another program — d5826a4, 2026-08-21

6.30, and the first entry raised after the roadmap emptied. It arrived the way the list says entries do: something was wanted and could not be had. A language aimed at scripting an OS that cannot invoke another program is working with one hand.

system:run(["ls", "-l", path]).                  ; the exit status
system:capture(["git", "rev-parse", "HEAD"]).    ; output and status

An array of arguments, not a command line, and that is the whole decision:

system:run(["rm", name]).       ; one argument, whatever `name` holds

A file called ; rm -rf ~ is a name there, because it is one string. Handed to a shell as text, the same name is a sentence. Every scripting language that took the convenient form regrets it, and the regret is a deleted home directory rather than a lint warning. Demonstrated rather than asserted: a directory holding a file of exactly that name survives being listed and measured.

The shell is reachable and spelled out["/bin/sh", "-c", "..."] — and lib/shell.sol wraps it with run, capture, read and line, so the convenience is one line away and the hazard is named in the file that takes it rather than hidden in a primitive.

capture answers a dictionary of "output" and "status", because a command’s output is worth little without knowing whether it worked: grep finding nothing is not grep failing.

Conventions taken from the shell rather than invented. #127 for a command that cannot be run, 128 plus the signal for one that was killed, and neither raises — a script asking whether a tool is installed is asking a question.

capture drains the pipe before waiting for the child, since a program writing more than a pipe holds would otherwise block forever against a parent waiting for it to exit. Tested with 1.3 MB of output.

And string:trim within the hour

6.31, wanted by the first program that read a command’s output. wc -l answers " 100\n", and asInteger is strict about the whole string being a number — rightly, since "12abc" is a mistake rather than twelve. Every command-line tool pads a number and every script that reads one trims it.

Space, tab, newline and carriage return, and nothing else: a string is bytes, and deciding what counts as blank in a text this language cannot otherwise read would be a promise it could not keep.

programs/tools.sol reports on a directory by asking other programs, and settles which of the two to reach for — an array wherever a name comes from outside the program, a string when the shell itself is the point.

Solid — d76b94b, 2026-08-21

6.29, the last entry on the roadmap. bin/solid is the fourth program: it runs a program, stops before its first line, and takes commands.

(solid) break report.sol:5
break at report.sol:5
(solid) continue
report.sol:5  in block
    5      after:lessThan(#0):ifTrue({ error:raise("overdrawn") }).
(solid) locals
  self             <object 0x10122e250>
  amount           #30
  after            #70

Step, next, finish, continue, breakpoints, a backtrace, and the locals of a frame by name — which is what 6.28 was built for, one release before there was anything to spend it on.

The machine knows nothing about debugging. The VM offers a stop before each instruction that begins a new line or enters a new frame, and calls a hook if one is set; Solid decides whether that stop is interesting. Stepping, breakpoints, and what a person means by “over” all live in solid/. solum/ gained a function pointer and a branch — and the branch costs nothing measurable: three million loop turns, 0.43s either way.

It stops where a program breaks, which is the thing solis --interactive cannot do, since that begins after the unwind and sees only globals:

-- division by zero in 'div'
breaks.sol:2  in block
(solid) locals
  a                #100
  b                #0

Nothing resumes from there, but the frames are standing and the value that caused it is in one.

Three bugs, all in deciding when to stop, all found by using it. next stopped twice on one line, because returning from a call lands on the line the call was written on. A breakpoint fired twice per visit, for the same reason — the signal that separates arriving from returning is the frame count dropping. And a breakpoint in a loop fired once: the first fix was too broad, and then the VM’s own gate turned out to be wrong too, since a block whose whole body is one line never changes line or depth. Frames carry an id unique for the life of the VM, and gating on that is what makes a new frame always a new place to be.

That third one is the one to remember: two independent off-by-one judgements about “the same place”, one in each component, both invisible until a loop body happened to be a single line.

The roadmap is empty. Sections 2 and 6 are done and section 3 is the restrictions kept on purpose. ROADMAP.md now ends with how it emptied rather than what is next, because the how is the part that transfers: almost every entry after the first dozen came from writing a program and finding out what it wanted, and the last four came from asking how a program would be debugged.

0.11.0 — 2026-08-21

A frame slot knows what it was called, and the roadmap is down to one entry.

  [locals.sol:7] value(numbers: [#10, #20, #33])
    [locals.sol:4] value(n: #10)
    -> #1

The compiler always knew a temporary was called total — it had to, to resolve the name to slot 2 — and threw it away once the index was emitted. A chunk keeps it now, so solvm --trace names its arguments and anything looking at a frame can say average rather than slot 3.

Another .sob format change, 12 to 13, one release after the last. Files from an earlier build are refused with unsupported bytecode version rather than misread; recompile the .sol. The size cost is small — +0.2% on numbers.sob, +3.4% on page.sob — because a name is stored once per slot rather than once per method chunk, which is what made the file table in 0.10.0 dearer.

The table is indexed by slot rather than filled in the order names arrive, so a slot nobody named still takes a place and index N is slot N. That is the property worth holding: an off-by-one would put the wrong name against the right value, which is worse than no name, and there is a test that names slots out of order and leaves a gap to hold it. Naming arguments in the trace is also how the table was checked — amount: lining up with #30 is visible, where an assertion at a distance is not.

One entry left on the whole roadmap, and it is Solid, the debugger. Everything underneath it is built: the call tree, the file a frame is in, a name for a local, and the interactive half in solis --interactive. What is left is stopping a program while it runs, and a front end to drive that.

A frame slot knows what it was called — f644c9f, 2026-08-21

6.28, and --trace names its arguments:

  [locals.sol:7] value(numbers: [#10, #20, #33])
    [locals.sol:4] value(n: #10)
    -> #1

The compiler always knew — it had to, to resolve total to slot 2 — and threw the name away once the index was emitted. What the runtime had was this:

SETLOCL     2
LOCAL       3

A slot being an index is right: an access is not a lookup. What was missing is the name beside it, for anything looking at a frame rather than running in it.

A table per chunk, in slot order, indexed by slot rather than filled in the order names arrive — so a slot nobody named, like slot 0 which holds the receiver, still takes a place and index N is slot N. A test names slots out of order and leaves a gap, because that is the property worth holding: an off-by-one would put the wrong name against the right value, which is worse than no name.

Naming arguments in the trace is how the table was checked. amount: lining up with #30 says the right name is against the right slot, visibly, rather than asserted at a distance.

The second .sob format change in two releases, 12 to 13. The entry had said this should have ridden along with 6.27 and it did not, so the honest accounting is that a bump costs a recompile, a recompile is cheap and automatic, and nothing here ships bytecode without its source. The size is far cheaper than the file table — +0.2% on numbers.sob, +3.4% on page.sob — because a name is stored once per slot rather than once per method chunk.

One entry left on the roadmap, and it is Solid itself. Everything underneath it is built: the call tree, the file in a trace, and now a name for a local — so a stepper can show average = #180 rather than slot 3 = #180, which was the thing that would have made it most of the work for a fraction of the use.

0.10.0 — 2026-08-21

A stack trace says which file, and the .sob format changes for the first time since 0.1.0.

solvm: index #99 is out of bounds for a string of size 4
  [lib/parse.sol:4] in block
  [main.sol:3] in script

This is the release that breaks bytecode compatibility. Version 11 stood through nine releases; this is 12. A .sob built by an earlier one is refused with unsupported bytecode version rather than misread, and the remedy is to recompile the .sol. solvm --version says which format a build speaks.

What it buys. A .sob is one chunk, and @include compiles a library’s code into the same one — so line numbers came along while the file name did not. The old trace was misleading rather than merely thin: in the case that prompted this, main.sol is three lines long and the trace said [line 4] in block, a line that does not exist in the file anybody would have opened. With four libraries and @include being how a program is meant to be built, that was going to get worse rather than better.

The chunk already carried a line per byte, run-length encoded because neighbouring instructions share a line. It carries a file per byte the same way now, plus a table of paths, and the runs are better here since a method body comes from one file. solvm --trace reads the same table, so the call tree names files too.

The cost, measured: +2.3% on numbers.sob, +15.6% on manifest.sob, the spread being the number of method chunks since each carries its own file table. Sharing one table from the top-level chunk would recover most of it and was not done — every chunk verifying on its own is worth more than two kilobytes.

3.4 stops being hypothetical. It used to say a compatibility policy was worth having before anything was released. Nine releases later the first break has happened, so the entry now records what the policy turned out to be: refuse rather than guess, recompile as the remedy, and --version to answer “will this file run” without trying it.

The debugger has a name. sol-interactive-debugger reads as Solid, and solidus is Latin for firm, whole, sound — a fourth word that looks like the others and is unrelated to them, which is the pattern the other three names already play. It is recorded in 6.29 rather than the README, which lists programs that exist.

Two entries left on the roadmap, both about looking at a running program: a name for a local, and Solid itself.

A trace says which file — 8742f38, 2026-08-21

6.27, and the first .sob format change since 0.1.0.

solvm: index #99 is out of bounds for a string of size 4
  [lib/parse.sol:4] in block
  [main.sol:3] in script

It was misleading rather than merely thin, which is what made it worth the bump. In the example that prompted this, main.sol is three lines long and the old trace said [line 4] in block — a line that does not exist in the file anybody would have gone to look at. Had the file been longer it would have pointed confidently at the wrong line of the wrong one.

A .sob is one chunk: @include compiles a library’s code into the same one, and the line numbers come along while the file name did not.

The fix mirrors what was already there. The chunk carried a line per byte, run-length encoded because neighbouring instructions share a line. It now carries a file per byte the same way, plus a table of paths — and the runs are better here, since a method body comes from one file and is one run. --trace reads the same table, so it names files too, for nothing.

Version 11 stood for nine releases; this is 12. Older files are refused with unsupported bytecode version rather than misread, and the remedy is to recompile. That also makes 3.4 no longer hypothetical — it used to say a policy was worth having before anything was released, and it now records what the policy turned out to be.

The size, measured rather than waved at: +2.3% on numbers.sob, +15.6% on manifest.sob. The spread is the number of method chunks, since each carries its own file table and a program with ninety small methods stores its path ninety times. Sharing one table from the top-level chunk would recover most of it and was not done: every chunk verifying on its own is worth more than two kilobytes.

Two tests caught things. The trace-format assertions from last release failed, which is what they were for. And the serializer’s round-trip test refused a chunk built without a path — the writer was emitting file ids into an empty table, so the loader rejected its own output. A chunk with no file, like one compiled at the prompt, prints a bare [line 1] exactly as before.

The debugger has a name: Solid — 00ac10b, 2026-08-21

sol-interactive-debugger, and it belongs to the family better than an acronym has any right to.

The other names are not abbreviations, they are words: Solveig is Old Norse, sól joined to veig; Solum is Latin twice over, the ground as a noun and “only” as an adverb, which is the design principle rather than a decoration; SolVM is how solum was written before the alphabet split V into two letters. The README makes a point of these being separate words that happen to look alike rather than one word wearing several hats.

Solidus is a fourth — Latin for firm, whole, sound, usually taken back to a root meaning “whole”, the one behind salvus, “safe”, and unrelated to either the ground or the sun however alike they look. A debugger is the tool for finding out whether a program is sound, standing on ground the language calls solum. The pun is in English and the sense is in the Latin, which is the trick the other three names play.

Recorded in 6.29 rather than in the README, which lists programs that exist.

0.9.0 — 2026-08-21

Bits, and the first tools for looking at a program rather than writing one.

solvm --trace=2 report.sob        # the call tree, two deep
solis --interactive report.sol    # run it, then stay at the prompt with what it left

solvm --trace writes the call tree to stderr: a line entering each frame, a line leaving it, indented by depth, named by the selector it was sent as.

  [line 7] <object 0x1027ea980>:describe
    [line 4] <object 0x1027ea980>:double
    -> #42
  -> "x doubled is 42"

Frames rather than sends, since a send is arithmetic as often as it is a call — and the language suits this unusually well. Conditionals and loops written literally compile to jumps, so a whileTrue running three hundred thousand times produces no trace lines at all. --trace=N follows calls N deep, which was added after measuring: page.sol gives 9,284 lines traced fully, 148 at depth 1.

solis --interactive runs a file and stays at the prompt afterwards, failure or not, with everything the program bound still bound. It came from a question — if a traced program fails, could it fall into the REPL instead of exiting? — and the answer is worth more here than it would be in most languages, because a script’s own names are globals and survive the unwind:

-- program failed; its names are here
> tally:print.
[#1, #4, #9, #16]
> { a:withdraw(#500) }:onError({ e | e:message:display }).
not enough

A method the program defined can be called again, so the failing call can be made once more and watched. What is gone is the frames: nothing resumes, and a block’s temporaries go with the stack. A prompt beside the wreck rather than a break in the middle of it.

Bit operationsbitAnd, bitOr, bitXor, bitNot, shiftLeft, shiftRight — and the case for them was already written down. lib/text.sol encoded UTF-8 with div(#64) for a shift and mod(#64) for a mask, carrying a comment saying it did so for want of the real thing. It reads like the RFC now, and is checked against Python’s UTF-8 at every boundary code point.

A shift right keeps the sign, which makes it agree exactly with div by a power of two, since that is floored. A shift left refuses to lose the number, the way mul refuses to overflow.

inc and dec, which are add(#1) and sub(#1) under shorter names — a second spelling, and this language has turned four of those down. What earned them was the count: 76 of the 256 arithmetic sends in the examples and libraries are one or the other. Three in every ten, and that is what having no binary operators costs the commonest arithmetic there is.

Three entries written down rather than built, all about looking at a running program: 6.27, where a trace names lines and not files and so reads as though a library’s failure were in your own file; 6.28, where slots are indices so nothing can show a variable by name; and 6.29.

And an assert turned down, recorded in ideas.md with the reasoning: no to a compile-time switch that strips it, because every hand-rolled check in this repository is validation that must never vanish rather than an assertion that could, and a switch that removes one kind will be pointed at the other.

.sob files are still format version 11, unchanged since 0.1.0.

One bug caught by the collector rather than by a test. --interactive first kept the program’s chunk in run_file’s own frame, so a global still holding a block pointed into a dead stack frame the moment the prompt allocated — which is restriction 3.6 exactly, written down years before it was tripped over. Every ordinary test passed; SOLUM_GC_STRESS=1 aborted. The chunk belongs to main now, and outlives the prompt.

solis --interactive: a prompt beside the wreck — 38b19d1, 2026-08-21

Asked for as a question — if a program being traced fails, could it fall into the REPL rather than exit? — and the answer turned out to be yes, and to be worth more here than it would be in most languages.

$ solis --interactive report.sol
solvm: index #99 is out of bounds for an array of size 4
  [line 7] in script
-- program failed; its names are here
solis 0.8.0 -- ctrl-d to exit
> tally:print.
[#1, #4, #9, #16]

A script’s own names are globals, which is what makes this work. A runtime error unwinds the frames and leaves the globals alone, so the dictionary the program built, the array it was filling and the objects it made are all still there. The check was one line before any code was written:

counter := #41.
nil:boom.            ; the program stops
counter:inc:print.   ; #42 -- it is still there

And a method the program defined can be called again, which is the part that makes it a half-stepper rather than a post-mortem: the failing call can be made once more with the same arguments and watched.

> a:balance:print.
#70
> { a:withdraw(#500) }:onError({ e | e:message:display }).
not enough

What is gone is the frames. Nothing resumes, and a block’s temporaries go with the stack — so this is a prompt beside the wreck rather than a break in the middle of it. Naming a local would need 6.28, which is recorded and not built.

It stays after a program that finishes too, which is the other half: python -i is the precedent, and running something to then poke at what it made is as useful as inspecting a failure.

solis also takes --trace and --trace=N now, the same as solvm. The prompt itself is not traced — what was being watched is the program.

Not -i, deliberately: solis already has -I for the include path, and two flags a shift key apart, one of which takes an argument, is a trap.

One tangent found and left alone: solis cannot read a program from an unseekable file, because sol_read_file uses fseek to size it. cat prog.sol | solis already works by a different route, so it is a curiosity rather than a gap.

solvm --trace, and the rest of debugging written down — a48ac3c, 2026-08-21

The first thing this project has built for looking at a program rather than writing one. It came from asking how a program would be debugged, which is a different route to the roadmap than the usual one — every other entry arrived because a program wanted something and could not have it.

  [line 7] <object 0x1027ea980>:describe
    [line 4] <object 0x1027ea980>:double
    -> #42
  -> "x doubled is 42"

Frames rather than sends, which is what makes it readable: a send is arithmetic as often as it is a call, and a program does hundreds of thousands of those. The name is the selector it was sent as, threaded through from the send site — so a block installed in a slot shows as the method it is rather than as value.

The language turns out to suit this unusually well. Conditionals and loops written literally compile to jumps, so they are not calls and do not appear:

i := #0.
{ i:lessThan(#300000) }:whileTrue({ i := i:inc }).

Three hundred thousand turns, zero lines of trace. What shows up is the calls, which is what was wanted, and it is a property of the inlining rather than anything the tracer does.

--trace=N follows calls N deep, added after measuring: page.sol produces 9,284 lines traced fully, 1,130 at depth 2 and 148 at depth 1. A trace you have to grep is much less use than one that shows the shape.

To stderr, so a program’s own output can still be piped and nothing it prints changes — asserted rather than assumed. Long values are cut at 48 characters, and rendered without sending asString, since a trace that ran the program it was tracing would not be one.

Three entries for the rest, in the order worth doing them:

Bits, and one more, one less — a7ddf8b, 2026-08-21

Both asked for, and both with the evidence already in the tree.

inc and dec are add(#1) and sub(#1) under shorter names — a second spelling, which this language has turned down four times before. What earns them is the count: 76 of the 256 arithmetic sends in the examples and libraries are one or the other. Three in every ten, and the most common arithmetic there is, which is what having no binary operators costs.

count := count:dec.

They answer a new integer rather than changing the receiver, an integer being a value, so the assignment is the idiom. Integers only: counting by ones in a type where a one is not exact is a mistake to make deliberately rather than conveniently. The names are abbreviations, and the objection that this language does not use them turned out to be wrong — add, sub, mul, div and abs are all abbreviated, and inc and dec sit with them.

bitAnd, bitOr, bitXor, bitNot, shiftLeft, shiftRight, and the case for these was written before they existed. lib/text.sol encoded UTF-8 with div(#64) for a shift and mod(#64) for a mask, carrying a comment saying it did so for want of the real thing — a workaround in shipped library code, which is the same signal that got removeLast and indexOf built. It reads like the RFC now:

integer:utf8Tail := { at | #128:bitOr(self:shiftRight(at):bitAnd(#63)):asCharacter }.

Checked against Python’s UTF-8 for every boundary code point, #0 through #1114111.

Two decisions. A shift right keeps the sign, because there is no unsigned integer here and a logical shift would turn every negative into a huge positive — and keeping it makes a shift agree exactly with div by a power of two, which is floored. #-7:shiftRight(#2) and #-7:div(#4) are both #-2, and that agreement is asserted rather than described. A shift left refuses to lose the number, the way mul refuses to overflow.

The index test earned its keep. It went in one release ago; adding eight messages made it fail with 'inc' is a built-in message and the reference's index does not list it, which is exactly the drift it was built to catch. The index is regenerated from the registrations rather than patched by hand.

One test of mine was wrong and the code was right: #-2:shiftLeft(#62) is exactly INT64_MIN and fits, where I had expected it to overflow.

Nothing yet.

0.8.0 — 2026-08-21

A program can deal with a filesystem it has to change, and with a keyboard. The roadmap is empty.

system:makeDirectory(out).                       ; true, or false if it was there
system:writeFile(to, system:readFile(from)).
system:setMode(to, system:modeOf(from)).         ; the executable bit survives
system:setModifiedAt(to, system:modifiedAt(from)).

Five new messagesreadKey, modeOf, setMode, setModifiedAt, and a changed makeDirectory — and every one of them was asked for by a program rather than planned. That is the whole method this release ran on, and it is worth saying plainly because the roadmap had nothing on it when the release started.

programs/mirror.sol copies one directory tree into another. The first program here that writes to the filesystem, and it could not do its job: modifiedAt answered whole seconds, so is the source newer than the copy? was always no within a second of the last run. The filesystem records nanoseconds and time holds nanoseconds; only that message rounded, in the middle of the two. A defect, found by needing it.

It went on to ask for the rest. A copy lost the executable bit, so a backup of anything holding scripts would not run — modeOf and setMode. A copy could not keep the original’s time, so the comparison had to be newer than rather than the same as, and a file replaced with an older copy of itself went unnoticed — setModifiedAt closed that. And makeDirectory refused a directory that was already there, which made make sure this exists a test and a make in every script that writes anywhere.

makeDirectory answers nowtrue if it made one, false if a directory was there — and the argument that decided it was not tidiness: refusing could not be told apart from failing, since mkdir reports the same EEXIST for a directory that is there and a file in the way. One is fine and the other never will be, and now they read differently. A behaviour change, and a test asserting the old contract caught it.

examples/keys.sol reads one key at a time. system:readKey answers one byte without waiting for return, which closed 6.10 — an entry that had been closed once by mistake, when solis grew raw-mode line editing for its own prompt and the work was filed against it. That was the front end reading its own keys; this is the message a program can send.

The prompt lists recent lines with ctrl-h, which is bound where the key was doing nothing anyway: ctrl-h is backspace, and on an empty line there is nothing to delete.

The reference has a contents worth the name and a message index. 56 entries across two levels, and 110 messages each linked to the types that answer it — because what a reference is asked is what has copyFrom? rather than what does a string do?. A test fails the build if a registered message is missing from the index.

.sob files are still format version 11, unchanged since 0.1.0. Everything added here is a primitive or a document.

Nothing is left on the roadmap. Sections 2 and 6 are empty and section 3 is the restrictions kept on purpose, so the document no longer says what to do next — the way to add to it is to write a program and find out what it wants, which is what produced every line of this release.

The reference gets a contents and a message index — 8a5deaf, 2026-08-21

At 2300 lines the reference had a contents listing 13 of its 68 headings, which is a contents in the sense that a signpost pointing at a county is directions.

Two levels now, generated from the headings themselves, so every ## and ### is in it — 56 entries.

And a message index, which is the part that was actually missing. The question a reference gets asked is what has copyFrom? rather than what does a string do?, and the sections answer only the second:

| `copyFrom` | array, string |
| `indexOf`  | array, string |
| `asByte`   | string |

110 messages, each linked to the types that answer it, derived from the registrations in builtins.c rather than written out by hand.

A test keeps it honest. A message registered and missing from the index fails the build — the same bargain that already makes every message appear in an example. Checked by deleting a row and watching it fail, because a check that cannot fail is worse than none:

`readKey` is a built-in message and the reference's index does not list it

It checks presence rather than what is said, since which types answer a message is prose and prose is not something a test can hold to.

One wart the contents exposed: two sections were both called Errors — one about raising and catching, one about what a failure looks like when it is printed. The second is How errors are reported now. Duplicate headings are invisible until something lists them side by side.

system:readKey, and the roadmap is empty — 9ab2d72, 2026-08-21

6.10, closed this time by the thing it asked for. A program can read a key now, not only the prompt:

key := system:readKey.
key:asByte:print.        ; #97 for "a", pressed on its own

The three questions the entry left, answered.

One byte, not a whole key. An arrow is three bytes and a function key can be more, and which is which belongs to the terminal rather than to the language. The byte is the smaller promise: a program that wants arrows assembles them, and one that only wants any key is not made to unpick a sequence it never asked about. A one-character string, so asByte gives the number.

nil at the end of input, which is readLine’s answer and for the same reason.

No echo, because raw mode does not, and showing the key would be a second thing happening.

Raw mode only on a terminal — through a pipe a byte is already a byte, so this reads the same way under solvm program.sob < input, which is what makes the deterministic test possible. ctrl-c still interrupts a program waiting for a key.

What it cannot do, and no byte-level reader can: tell the escape key from the start of a sequence. Escape then tab reads the tab as the byte after the escape. Telling them apart needs a read that gives up after a few milliseconds. examples/keys.sol assembles arrow keys out of the bytes and says this out loud.

A harness bug came out of testing it. session_expect counted turns of its loop against a two-second deadline, so it gave up after a hundred reads however fast they came — which a long line reaches while it is still being echoed, a keystroke at a time. It counts two seconds of silence now. Every test in that file passed beforehand; the first one with a line long enough to notice found it.

The roadmap is empty. Sections 2 and 6 have nothing in them, and what is left is section 3: the restrictions the language keeps on purpose. The way to add to it is to write a program and find out what it wants — which produced everything in this release.

makeDirectory answers instead of refusing — a97f0e6, 2026-08-21

6.25. true if it made one, false if a directory was already there, an error for anything else.

system:makeDirectory("build/out").      ; true  -- made it
system:makeDirectory("build/out").      ; false -- already there

The case was that every script carried the same block. mirror.sol and files.sol both had a version of isDirectory:ifFalse({ makeDirectory }), and both have lost it.

What decided the shape was something the entry had not noticed. It offered a second message or an answer instead of a raise, and the deciding argument turned out not to be tidiness: refusing could not be told apart from failing. mkdir reports EEXIST both for a directory that is already there and for a file sitting at that name, so the two arrived with the same words —

cannot make directory 'perm/already': File exists
cannot make directory 'perm/afile':   File exists

— and the first is fine while the second never will be. A caller wanting to know which had to catch the error and read its text, and got no answer even then.

So the file case is separated out and says what it is, and the ordinary case answers rather than raising, which puts the fact where a caller can use it or ignore it:

cannot make directory 'perm/afile': something that is not a directory is already there

A behaviour change, and the second in two releases: makeDirectory answered nil and raised on an existing directory, and now answers a boolean and does not. A program that caught that error to mean “already there” will stop seeing it — which is the point, since it can now ask instead.

One level still. mkdir -p is a different message and nothing has asked for it.

Section 6 is down to 6.10 — a program still cannot read a keypress — which is the only entry left on the roadmap that is not a limitation kept on purpose.

A copy keeps its mode and its time — 176e1d1, 2026-08-21

6.26: system:modeOf, system:setMode, system:setModifiedAt. Built the day the entry was written, because the thing it fixes is a floor rather than a nicety — a copy that loses the executable bit is a backup that will not run.

source:      -rwxr-xr-x  script.sh
destination: -rwxr-xr-x  script.sh      ; was -rw-r--r--

A mode is an integer, and the alternative was a string of nine letters — "rwxr-xr-x", what ls prints. Turned down because it would be a second representation of a number, with its own parser and its own refusals, where asBase already crosses that gap for every base:

system:modeOf(path):asBase(#8).      ; "755"
"755":asInteger(#8).                 ; #493

Solum has no octal literal, so #493 is what 0755 looks like written down. That reads badly alone, and the pair above is the thing to know.

The file-type bits are masked off, so setMode(to, modeOf(from)) — the whole reason both exist — cannot try to change a file into a directory.

setModifiedAt closed a corner that could not be closed before. A copy is stamped now, so the only question a mirror could ask was is the source newer? — and a source replaced with an older copy of itself is not newer, so it went unnoticed. With the time carried across, a matching pair compares equal, and mirror.sol compares exactly now rather than by “not newer”.

The program that asked for these uses them, which is the part worth having: it can also tell a file whose bytes are right and whose permissions are wrong, and fix that without reading the file again.

A mirroring script, and the defect it found in modifiedAteac07ab, 2026-08-21

programs/mirror.sol copies one directory tree into another and reports what changed. The fifth program here written to do a job, and the first that writes to the filesystem rather than reading it — walk.sol lists a tree, files.sol reads and writes one file, and mirroring is the ordinary job that needs the whole set at once.

It does not delete. A destination file with no counterpart is reported and left alone: a mirror that deletes is a different and much more dangerous tool, and an example is a bad place to hide one.

It could not do its job, and the reason was a defect. modifiedAt answered whole seconds. The test a mirror makes is is the source newer than the copy?, and within one second the answer was always no — so a file edited just after a run was never copied:

1  #1:print.        ; a same-size edit, then: "nothing to do"

The filesystem records nanoseconds and the time type holds nanoseconds. Only this message was rounding, in the middle of the two:

modifiedAt:  1787350321.000000   1787350321.000000     ; the same, it said
stat:        1787350321.201807   1787350321.202166     ; what was actually there

Fixed — st_mtimespec on Apple, st_mtim where POSIX.1-2008 says so, and whole seconds on anything older. It is the second piece of the runtime that differs by platform, after the prompt’s raw mode, and the guard is the same shape.

Three gaps it found besides. Two are new entries:

The third is a corner rather than a gap, and worth knowing: since a copy cannot keep the original’s time, the comparison has to be newer than rather than the same as — so a source file replaced with an older copy of itself goes unnoticed. Every mirroring tool has that corner; this one has it because the time cannot be set rather than because it chose to.

ctrl-h lists the recent lines — b97b43a, 2026-08-21

Asked for after using the prompt: a way to see the last few lines rather than pressing ↑ until the right one turns up.

ctrl-h is already backspace, which is the interesting part. It sends byte 8, and that is what the backspace key sends on many terminals — the editor takes both 8 and 127 for exactly that reason. Binding ctrl-h to something else would break deleting for anybody whose keyboard sends BS rather than DEL.

So it is bound where the key is doing nothing anyway: on an empty line. There is nothing to delete there, so nothing is lost, and the key that was asked for is the key that works.

> [ctrl-h]
  1  #7:mul(#6):print.
  2  "hello":display.
>

The last ten, numbered by their place in the history, oldest of the shown first. An empty history says nothing yet rather than doing nothing, since a key that appears broken is worse than one that says it has nothing to offer.

Two tests, and the second is the one that matters: that ctrl-h with something typed still deletes. The listing is only worth having if it costs nothing.

0.7.0 — 2026-08-21

The prompt became a place you can work.

Up and down through what you have typed, left and right within the line, and history that is still there tomorrow. It came from using solis and wanting it: type something wrong, get an error, and want to press ↑ and fix the typo rather than type the whole line again.

> #1:adx(#2):print.
solvm: integer does not understand 'adx'
> #1:add(#2):print.        ↑, then eleven ←, backspace, d
#3

This closed 6.10, the last entry in section 6, which had been waiting for a program that needed raw terminal mode. The program was solis. The terminal does line editing itself in cooked mode — which is why fgets was enough to begin with, and why backspace already worked — but it does not do history, so an arrow key arrived as the three bytes of its escape sequence and was compiled as though they had been typed. Getting history means taking the editing over.

The keys are the readline ones, written down with the two departures from bash: ctrl-u discards the whole line rather than the part before the cursor, matching the terminal’s own kill character; and ctrl-c and ctrl-z are left to the terminal deliberately, since taking them over would be a surprise.

Solis writes a file now, which it never did before. History goes to $HOME/.solis_history — the most recent 1000 lines, trimmed on the way out, an ordinary text file that can be read, edited or deleted. With no HOME set it keeps nothing. Failing to write it is ignored, on the grounds that a prompt which would not exit because it could not save history is worse than one that quietly forgets.

It degrades rather than depending on anything. Through a pipe, a file, or with TERM=dumb, the prompt reads a line exactly as it did before — which is what keeps solis < script and the test suite working. No readline, no libedit; the build still needs only a C11 compiler and make.

Nothing about the language changed. .sob files are format version 11, unchanged since 0.1.0, and there are no new messages: this is entirely the front end.

The roadmap has nothing left to build. Sections 2 and 6 are both empty. What remains is section 3 — the restrictions the language keeps on purpose — so the document no longer says what to do next. The way to add to it is to write a program and find out what it wants, which is how this entry arrived.

History that outlives the session, and the keys written down — 0ebdd2c, 2026-08-21

What you type at the prompt is kept in $HOME/.solis_history, so ↑ reaches the lines from last time as well as this one. The most recent 1000, trimmed on the way out; with no HOME set, history lasts as long as the session and no longer.

An ordinary text file, one line per entry, readable and editable and deletable like any other. Failing to write it is ignored — a prompt that refused to exit because it could not save history would be worse than one that quietly forgets.

The keys are documented in the reference, with the two departures from bash spelled out: ctrl-u discards the whole line rather than the part before the cursor, matching the terminal’s own kill character; and ctrl-c and ctrl-z are left to the terminal deliberately.

Two mistakes in the test harness, both the same mistake. Raw mode is entered with TCSAFLUSH, which discards input already received, so a key sent before solis is ready is thrown away. The first version of this had it at startup and fixed it by waiting for the prompt. It had the same bug at the end: ctrl-d was sent as soon as the last output appeared, before solis had re-entered raw mode for the next prompt, so it was flushed — and then the test closed the pty, which killed the session instead of ending it.

Nothing had noticed, because nothing had needed a clean exit before. Saving history on the way out does, and the cross-session test failed until session_end waited for the prompt like everything else. A test that hangs up on the program under test is testing a crash, which is worth knowing before it matters rather than after.

History and arrow keys at the prompt — 49b5374, 2026-08-21

6.10, and it closes section 6 entirely. The entry had been waiting for a program that needed raw terminal mode; the program turned out to be solis, from using it — type something wrong, get an error, and want to press up and fix it.

Why it needed the whole entry rather than a small addition. The terminal does line editing itself in cooked mode, which is why fgets was enough to begin with: backspace worked because the tty handled it before solis saw the line. What the tty does not do is history — so an arrow key arrived as the three bytes of its escape sequence and was compiled as though they had been typed. Getting history means taking the editing over, and that is raw mode.

Up and down through history, left and right within the line, home and end, backspace and delete, ctrl-a, ctrl-e, ctrl-u, ctrl-l. ISIG stays on, so ctrl-c still interrupts and ctrl-z still suspends — those belong to the terminal and taking them over would be a surprise.

Two details that came from thinking about the actual use:

It degrades rather than depending on anything. When stdin and stdout are not terminals — a pipe, a file, TERM=dumb — the prompt reads a line exactly as it did before. That is what keeps solis < script and the test suite working, and it is why this is a fallback rather than a dependency: no readline, no libedit, and the build still needs nothing but a C11 compiler and make.

The cursor moves a byte at a time, so a left arrow steps into the middle of a multi-byte character. Same limitation as 2.13 rather than a new one.

Testing it needed a terminal, and two attempts. tests/test_line.c opens one with posix_openpt — POSIX, needing no library, where forkpty lives in different headers on different systems. Both mistakes are worth recording:

A third mistake was mine and not the code’s: the first cursor-movement test failed because I had LEFT and RIGHT swapped in the harness. ANSI C is right and D is left. The editor had been correct the whole time, which is an argument for asserting on program output — #3 came out means the cursor went where it was asked.

Section 6 is empty, and so is section 2. What is left of the roadmap is section 3: the restrictions the language keeps on purpose. The document no longer says what to build next — the way to add to it is to write a program and find out what it wants.

0.6.0 — 2026-08-21

The language has no open design questions left, and the compiler has a second warning.

2.5 is closed — class side versus instance side, the last one — and it is closed by not splitting the objects. The line between the two sides is drawn by the receiver each message requires, which is machinery 1.6 had already built for another reason and which turned out to be the whole of what the split was wanted for. Ten registrations changed; a second object per built-in was not needed.

This is a behaviour change, and the only one in six releases. Every class-side message now requires an object receiver, so an instance can no longer answer for its class:

[#1]:new.            ; was []          -- now: 'new' expects an object, got array
[#1]:of(#2, #3).     ; was [#2, #3]    -- now refused
dictionary:new:new.  ; was a dictionary -- now refused
#45:new.             ; refused, as before, and now respondsTo agrees

A .sob from an earlier release still loads — the format is version 11, unchanged since 0.1.0 — and one that sends a class-side message to an instance will now fail where it used to answer. Nothing in the examples or libraries did.

What it buys is that respondsTo no longer lies. Three messages — new, slots and slotAt — accepted any receiver and then refused a value from inside the primitive, so #45:respondsTo('new) answered true and #45:new failed. It answers false now, and sending and asking agree everywhere.

A warning when two files claim one global name. There is no module system, so an included file binds into the one global namespace and the later binding wins quietly. lib/text.sol proved it the hard way: it bound one object called text, the first program to use it had a variable of that name, and the library broke from a distance with string does not understand 'utf8'.

[prog.sol:3:1] solas: warning: 'text' was already bound by lib/text.sol -- this one wins, and nothing else will say so

Only a claim warns. count := count:add(#1) reads the name before writing it, so it is updating somebody else’s global rather than declaring its own — which files legitimately do across an include. Without that rule the warning fired on a test fixture; with it, the 28 examples, four libraries and every test compile silently.

Section 6 is down to one entry — a single keypress, still waiting for a program that needs one — and section 2 is empty. What is left of the roadmap is the limitations the language keeps on purpose.

A warning when two files claim one name — 595622f, 2026-08-21

6.21, and the second warning the compiler has. There is no module system: an included file binds into the one global namespace, so two files that both use a name did not collide — the later one won, quietly, and which one a program got depended on include order rather than on anything written where the name was used.

The entry said nothing had tripped over it, and that lasted about ten minutes. lib/text.sol bound one object called text, following the reference’s own advice about claiming one name instead of a dozen; the first program to use it had a variable of that name, and the library broke from a distance with string does not understand 'utf8' — a run-time message about a type, for a compile-time collision between two files. That now reads:

[prog.sol:3:1] solas: warning: 'text' was already bound by lib/text.sol -- this one wins, and nothing else will say so
  text := v.
  ^^^^

A claim, not an update, which is the distinction that makes it quiet enough to keep. count := count:add(#1) reads the name before writing it, so it is working on somebody else’s global rather than declaring its own — a thing files legitimately do across an include. The rule is that a name you read in the course of assigning it is one you are updating, and only a claim warns.

That rule was written because the first version fired once across the whole tree, on a test fixture that increments a counter from an included file. With it, 28 examples, four libraries and every test compile without a warning — which is the bar a warning has to clear to be worth having.

The three tiers this leaves, which are the working advice for a library now:

what a library adds how to bind it globals claimed
behaviour on an existing type a method on the class none
a thing with state and its own operations one object, everything on it one
several unrelated names nothing better than several globals several

The top tier is the one worth knowing: integer:asUtf8 and integer:timesCollect need no name of their own, because they extend a class that already has one. That is a send rather than an assignment, so this warning never sees them — there is nothing to collide with.

What it does not fix, and the entry is closed saying so: this is the cheap half of a namespace. There is still no export boundary — every slot on json and html is public and writable, and json:digits := "abc" breaks the parser from outside it — and no declared dependencies. Both need things the language does not have.

Section 6 is down to one entry, 6.10, waiting for a program that needs a single keypress.

2.5 is closed, and the split was not built — b74e720, 2026-08-21

The last open design question. Closed by not splitting the objects, because the thing the split was for turned out to be reachable without it.

What was actually wrong was smaller and worse than the entry said. Three messages — new, slots and slotAt — were registered for any receiver and then refused a value from inside the primitive, so respondsTo said one thing and sending did another. That is exactly what respondsTo has a comment saying it must not do:

#45:respondsTo('new).       ; true
#45:new.                    ; an integer is written #45, and there is nothing for 'new' to make

The same gap let an instance answer for its class:

[#1]:new.                   ; []          -- a fresh empty array
[#1]:of(#2, #3).            ; [#2, #3]
dictionary:new:new.         ; <dictionary>

Every class-side message requires an object receiver nownew, of, fromSeconds, slots, slotAt. Ten registrations changed from any_receiver to instance(..., SOL_OBJ, ...). All four of those sends are refused, respondsTo agrees with sending everywhere, and the teaching errors survive because they were always for the class rather than for a value.

The rule the entry said had nowhere to live now lives in the registration table, where the dispatcher checks it on every send: a slot that takes SOL_OBJ is class side, a slot that takes a value type is instance side. That was the whole of what a behaviour object per built-in would have bought, and it cost ten lines instead of a second object per class with a link that isKindOf and all four reflection messages would have to keep honest.

The two sides are separable from inside the language, and nothing is on neither — there is a test asserting exactly that:

integer:slots:size.                                          ; #30
integer:slots:select({ s | integer:respondsTo(s) }):size.    ; #8
integer:slots:select({ s | #45:respondsTo(s) }):size.        ; #27

Eight and twenty-seven overlap by five: isKindOf, isNil, notNil, perform and respondsTo, reflection that genuinely serves both audiences.

One correction on the way. Looking at this again, the first thing I measured was that 22 of integer’s 30 slots are ones the class will not answer, and I called that noise. It is not — they are the instance side, and a class holding its instances’ messages is the design rather than a defect. The real defect was the three that lied, which is a much narrower thing and the one worth fixing.

The trigger to reopen it: when slots on a built-in class is read by a program rather than printed by an example. Across four programs written to do a job and four libraries, that happens exactly once — integer:slots:size:print, printing a count.

Section 2 is now empty. The language has no open design questions, only deliberate limitations.

0.5.0 — 2026-08-21

An HTML reader, and the frame limit turns out to be about traversal rather than about data.

@include "html.sol".

page := html:read(system:readFile("page.html")).
page:findAll("a"):do({ a | a:attribute("href"):display }).
html:complaints:do({ c | c:display }).      ; and what was wrong with it

lib/html.sol reads HTML into a tree, with programs/page.sol as a program on it. It does not fail. Every other parser here stops at the first problem, which is right when a person wrote the input and can fix it; HTML is generated, served, and wrong, so this one recovers — stray end tags, unclosed elements, implied ends, a bare < in text, a < inside a <script> — and keeps a list of what it recovered from. There is no onError in the library at all: recovery is a branch that appends to a list, not an exception, and building it on error:raise would have meant unwinding past the stack that holds the recovery state.

The finding is about 3.5. The entry asked whether building against a stack sidesteps the 62-frame limit. It does — and then traversal walked straight back into it:

  deepest that works
json.sol, recursive descent 28 levels
html:read, an explicit stack 50,000, no limit found
text, find, findAll — recursive, as first written 28
the same three, with a stack 50,000

A tree built 50,000 deep could not be walked 30 deep. The limit is not a property of the data, it is a property of how you traverse it — and a library can be half-safe without anybody noticing, because the constructor is the part everyone thinks about.

array:removeLast and array:indexOf, and symbols have an order. All three came from workarounds that were already shipped: the HTML library kept a hand-rolled stack because an array could not be popped, and its element sets were delimited strings because an array could not be searched; programs/manifest.sol converted symbol keys to strings and back to sort them. removeLast refuses an empty array rather than answering nil, matching at; indexOf answers nil when absent, so indexOf(v):notNil is includes and there is no second message for it.

The compiler’s first warning. A file that includes a library of its own name finds itself, and since a file is compiled once that include does nothing at all. It was documented and still took a minute to fall into, so the compiler now says so and names what was shadowed. A warning and not an error: shadowing is C’s rule and stays, the file still compiles, the status is unchanged.

No more @ directives. @once, @define and @ifdef were scoped and none belongs: a file is already compiled once, a named constant is a binding, a macro is a block — whose arguments are unevaluated, which is the one thing macros have over functions in C — and respondsTo already answers feature detection at run time. @ stays a namespace with one thing in it, which is a result rather than an oversight.

.sob files are still format version 11, unchanged since 0.1.0. Everything added here is a primitive or a library, so a file built by any earlier release runs on this one.

Three libraries now, and one of them is included by another: text.sol holds integer:asUtf8, wanted by both the JSON and HTML readers. It binds no global at all — the first draft bound one called text, and the first program to use it had a variable of that name, which broke the library from a distance. A namespace only helps if the name is one nobody else wants.

Two papercuts, and both had their workaround already shipped — bc677b0, 2026-08-21

6.23 and 6.19. Neither was blocking anything; what made the case was that the code written around them was in lib/html.sol and programs/manifest.sol, where anyone could read it.

array:removeLast takes the last element off and answers it, which with add makes an array a stack. lib/html.sol had an object carrying its own top index and overwriting with at_put — eight lines, written twice in one file before being factored out. It is a plain array again:

html:push := { e | self:open:add(e). e }.
html:pop  := { self:open:removeLast }.

It refuses an empty array rather than answering nil, matching at’s refusal of an index out of range. Nil would be a second way of saying “nothing” beside the one the language has, and it would turn a mistake into a value that fails somewhere further on. Nothing is made harder: a caller that might be empty asks size, which is the shape a stack’s loop condition already has.

array:indexOf(v) answers a one-based position or nil, exactly like string:indexOf. The HTML library’s element-name sets had been strings searched with the delimiters kept on so that p did not match pre; they are arrays now.

And no includes. indexOf(v):notNil is that question already, so a second message would answer less with more surface. The entry had argued against includes on the grounds that a dictionary answers set membership in O(1) — that still holds; what it missed is that indexOf earns its place by answering where, which a dictionary cannot.

Symbols have an orderlessThan and its three companions, comparing the text. The question was whether it is worth it, since interning is what makes equals a pointer comparison and exactly what makes the addresses say nothing about order, so these are the only symbol operations that look at characters.

It is worth it for the reason anything gets sorted: a tally kept under symbol keys needs a stable order to print in, and symbols are values, so tallying by symbol is the natural thing to write. manifest.sol had been converting keys to strings, sorting those, and converting back with asSymbol to look each one up. Now:

kinds:keys:sorted:do({ kind |
    "  {} {}":fill([kinds:at(kind):asString("4"), kind]):display }).

Nothing was needed for sorted itself: with no block it sends lessThan, so defining one on symbols was the whole of it — the same arrangement that lets a user-defined type order itself.

An HTML reader, and the frame limit turns out to be about traversal — a4dc0c2, 2026-08-21

6.20, written to find out what the language wanted. lib/html.sol reads HTML into a tree; programs/page.sol is a program on it — an outline, a link list, images without alt text. The entry predicted three things it would push on, all three happened, and one answered a question open since 3.5 was written.

Recovery is not error handling. Every other parser here stops at the first problem, which is right when a person wrote the input and can fix it. HTML is generated, served, and wrong, so this one keeps going and keeps a list:

page := html:read("<b>bold</i>").
page:text:display.                              ; bold
; </i> at character 10 closes nothing that is open
; <b> opened at character 1 is never closed

There is no onError anywhere in the library. A stray end tag is not an exception, it is a branch that appends to a list — and building it on error:raise would have meant unwinding past the stack that holds the recovery state.

The stack sidesteps 3.5 completely, and then traversal walked straight back into it. That is the finding:

  deepest that works
json.sol, recursive descent 28 levels
html:read, an explicit stack 50,000, and no limit found
text, find, findAll — recursive, as first written 28
the same three, with a stack 50,000

A tree built 50,000 deep could not be walked 30 deep. The limit is not a property of the data, it is a property of how you traverse it — and a library can be half-safe without anybody noticing, because the constructor is the part everyone thinks about.

asByte was the right size of fix, which this was the first test of. Numeric entities need a code point to become bytes, so the UTF-8 encoder moved to lib/text.sol unchanged and both libraries include it — the first library here included by another library rather than by a program.

6.21 happened, ten minutes after being written down. lib/text.sol first bound a global called text, following the reference’s advice to claim one name instead of a dozen. The first program to use it had a variable called text, and the library broke from a distance with string does not understand 'utf8'. The fix was to bind no global at all: integer:asUtf8 is a method on a built-in class, which needs no name of its own. A namespace only helps if the name is one nobody else wants, and text is about the most wanted name there is.

One new entry. 6.23: an array cannot be popped and cannot be asked whether it holds something. A stack notices both immediately. The workarounds are a top index (which is O(1), so arguably better than the message would have been) and sets kept as delimited strings.

The compiler’s first warning, and no more directives — 2b25dda, 2026-08-21

6.22. A file that includes a library of its own name finds itself, and since a file is compiled once that include does nothing at all — the program compiles cleanly and fails at run time with undefined name.

Documenting it was not enough. The reference already called it occasionally a trap, and it still took about a minute to fall into once lib/ had a second file to collide with. So the compiler says it where the line is:

[greet.sol:1:10] solas: warning: this file includes itself, so the include does nothing -- a file beside the includer wins, and 'lib/greet.sol' on the search path is what it shadowed
  @include "greet.sol".
           ^^^^^^^^^^^

Naming what was shadowed is the useful half: this does nothing says something is wrong, and lib/greet.sol is what it shadowed says what you were expecting to get.

A warning and not an error — shadowing is C’s rule and stays, the file is still valid, the status is unchanged. It is the compiler’s first warning; sol_parser_warning shares the location and the echoed line with sol_parser_error and sets neither had_error nor the panic flag. And only the direct case: a diamond is the ordinary reason include-once exists, a cycle is one it ends on purpose, both stay silent, and both have tests — a warning firing on either would be worse than the trap it was added for.

The rest of the preprocessor, ruled out

@ was reserved for what happens while compiling, and @include is still the only thing in it. Scoped @once, @define and @ifdef and recorded the verdict in ideas.md. None of them belongs, and the reason is the same each time: the job is already done by something that is not a directive.

So @ stays a namespace with one thing in it. That is a result rather than an oversight: if a real compile-time need turns up, the space is ready, and the case for it will be that nothing in the language already does the job.

Renumbered: the self-include entry is 6.22, not 6.18

Caught while checking a link. 6.18 was already takenThere is no date or time, built in 0.3.0 — and the roadmap’s own rule is that numbers are never reused, so that a gap is a record rather than a mistake. The self-include entry was given 6.18 without checking what the highest number in use was, which is the one thing the rule exists to prevent.

It is 6.22 now, everywhere it is referred to, including in the 0.4.0 notes above that were written with the wrong number. The date-and-time 6.18 is untouched.

0.4.0 — 2026-08-21

A byte has a number, and the language reads a format it did not know about.

@include "json.sol".

config := json:read(system:readFile("config.json")).
config:at("server"):at("port"):print.
system:writeFile("config.json", json:write(config)).

asByte and asCharacter. A one-character string answers its number, and a number #0 to #255 answers its string. Two primitives and no new type — both ends already existed. A string is bytes, so they are named for what each answers: "é":asByte is refused rather than answering its first byte, which is what keeps the two exact inverses.

A JSON reader and writer, lib/json.sol, on the search path beside control.sol. It reads \uXXXX in full, surrogate pairs included, and encodes UTF-8 — in Solum, on top of asCharacter, because encoding a code point is arithmetic and arithmetic belongs where the format is known. A document reads, round-trips and writes back with names sorted, so the same document always produces the same text.

--help and --version on solas, solvm and solis. Both go to stdout and leave with 0; usage after a mistake goes to stderr and leaves with 64. --version names the .sob format as well as the release, since that is the number that goes wrong in practice.

.sob files are still format 11, unchanged since 0.1.0. The new messages are primitives rather than opcodes, so a file built by any earlier release runs here, and this is the first release where solvm --version will tell you the number itself.

Two documents. dispatch.md — a dictionary of blocks is a switch statement, 18.8× a chain of comparisons, and the two traps that come of putting closures in a table. one-hierarchy.md — what the single root means from the outside: one method on object answered by every value, and the line between inheriting the behaviour and being an object.

Written by writing programs. lib/json.sol and programs/manifest.sol were written to find out what the language wanted, and the answer moved the roadmap four times: 6.12 got built, 3.5 got a price list for how the dispatch is written, and 6.22, 6.19 and 6.21 are new entries for papercuts a program tripped over. Three programs here now exist to do a job rather than to show a feature.

--help on all three binaries — 6542ec3, 2026-08-21

Each of solas, solvm and solis already had a usage(); it was reachable only by getting the command line wrong. --help and -h now ask for it.

The distinction worth writing down is which stream. Help that was asked for goes to stdout and leaves with 0, so it can be piped or paged. The same words after a mistake go to stderr and leave with 64. Same text, two destinations, and usage() takes the FILE * to say which.

The help itself names every option rather than listing the shape of the command line, since the shape was already there and the options were the part a reader had to find in the source.

A front end’s own flags stay in front of the file, --help included:

solvm program.sob --help --dump -h
#3
  --help
  --dump
  -h

All three reach the program and solvm says nothing, which is the rule that was already there for --dump and is why a script can have a --help of its own. That is the one behaviour here worth protecting, so it is what the new test mostly checks.

--version came with it (ce77764), and names the .sob format as well as the release:

solvm 0.3.0 (.sob format 11)

The format number is the useful half. It is the one that goes wrong in practice — a .sob from a build with a different one is refused rather than misread, and until now there was no way to ask a binary which number it was holding, the answer being in a header. The test checks it against SOLUM_VERSION and SOL_SOB_VERSION rather than against a copy of the text, so a release that bumps either cannot leave the test passing and wrong.

tests/test_cli.c is the first test that runs the binaries rather than linking the library — a main is not something libsol.a holds, and which stream text landed on cannot be asked any other way. make test now builds the binaries first.

A byte has a number: asByte and asCharacter49b0ab1, 2026-08-21

6.12 waited a long time for a program to need it and then got needed by the wrong one. The entry was about taking binary files apart; what actually wanted a byte’s number was textlib/json.sol reading \u0041 and answering "A".

Two primitives, no new type.

"A":asByte:print.            ; #65
#65:asCharacter:display.     ; A

The entry had proposed a byte-buffer type at sixteen bytes a byte. What the program turned out to want was a one-character string in and an integer out, and back — both ends already existed, so this is two functions in builtins.c rather than a type with a representation, a printer, a GC visit and a .sob encoding.

Named for what each answers, which was the decision in it. A string is bytes, so asByte is honest where asCode would have promised a code point:

"é":asByte.
solvm: 'asByte' wants one byte, and this string has 2 -- a character outside ASCII is more than one of them

Refusing is what keeps the two exact inverses, and the test is the range rather than a sample of it: every byte #0 to #255 survives asCharacter:asByte.

The foundation, not the fix — which is the good part. A code point above 127 is more than one byte, so encoding one is arithmetic, and arithmetic belongs where the format is known. The UTF-8 encoder is therefore in lib/json.sol, in Solum, and it reaches all of Unicode:

json:read("\"caf\u00e9\"").          ; café   -- two bytes
json:read("\"\u4e2d\u6587\"").       ; 中文    -- three bytes each
json:read("\"\ud83d\ude00\"").       ; 😀     -- a surrogate pair, four bytes

Solum has no bitwise operators, so the shifts and masks are div and mod and the tag bits go on with add — exact, the bits being disjoint by construction. The library lost a 95-character table of printable ASCII and gained the rest of Unicode, which is the trade this was for. Writing gained the same thing: a control byte goes out as \u00XX now instead of being refused.

#0:asCharacter is the only way to write a NUL — there is no \0 in a literal. Strings are length-counted and already carried one through readFile and writeFile byte-for-byte, so it added a spelling rather than a hazard.

A bug the new test found, twice. onError calls its handler with one argument and arity is strict, so onError({ nil }) fails with ‘onError’ takes 1 argument, got 0 — an arity error in place of the recovery it was written to perform. It was in lib/json.sol on the bad-hex-digit path and in programs/manifest.sol on a non-numeric path segment, and both were invisible because nothing had taken those paths. A handler that ignores the error still has to accept it.

The library is now tested end to end rather than only compiled: what it answers, not just that it runs.

A JSON reader and writer, and the three things it found — 40f2004, 2026-08-21

The roadmap had emptied of anything a program asked for: the two entries left both said, in their own text, that they were waiting for a program to need them. So a program was written to find out.

lib/json.sol is a JSON reader and writer in Solum, on the search path beside control.sol, and programs/manifest.sol is a program on top of it — describe a document, pull a value out by a dotted path, edit it, write it back, read it again and prove the text matches. It claims one global and hangs everything off it, which is what the reference has always said to do instead of the module system the language does not have.

6.12 has its program. The entry was written about binary files; what needed a number for a byte first was text. \u0041 is a code point that has to become "A", and there is no asCode and no asCharacter, so the library carries the printable ASCII range as a literal to index into and refuses the rest:

json:read("\"\u00e9\"").
solvm: \u00e9 is outside what a Solum string can hold at character 8

What is not broken is worth as much: UTF-8 in the text passes through perfectly, because a string is bytes and the parser copies spans of them. It is the format’s escape mechanism that fails, not the encoding — a narrower problem than it looked, and asCode/asCharacter would close it without the byte-buffer type this entry has been asking for.

3.5 got a second data point and a price list. A recursive-descent parser spends the frame budget several times per level, and how the dispatch is written turned out to move the number a long way:

dispatching on the first character levels of nesting before the cap
a dictionary of blocks, which dispatch.md recommends 18
a chain of ifElse 28

Ten levels of document, for one message. Both recommendations are right on their own and they pull against each other exactly where the cases recurse — the jump table is for cases that are leaves. The library takes the chain and the comment says why.

A third, recorded rather than worked on. 6.21: two libraries binding one name do not collide — the second include wins quietly, and swapping the two lines changes the answer with no diagnostic either way. Nothing has tripped over it, unlike 6.22. It is the third entry now pointing at the same absence, so what it records is that there is no module system and the shape of what one would buy — a namespace, which the object idiom approximates; an export boundary, which privacy would have to become a new concept to provide; and declared dependencies, which is exactly what would make 6.22 diagnosable.

Two papercuts, both new entries. 6.22: a file that includes a library of its own name finds itself first and that include quietly does nothing — documented, and still about a minute to fall into once lib/ had a second file to collide with; the example is called manifest.sol for that reason alone, and a warning would cost one comparison. 6.19: a symbol has no lessThan, so a report tallied under symbol keys converts to strings to sort and back to look up.

6.20 is written down rather than built — an HTML parser, as the next program whose findings would not overlap with these. Error recovery, which no parser here has ever had to do; character classification in bulk, which is 6.12 from a second direction; and a tree built against a stack rather than by recursion, which may sidestep 3.5 entirely.

Smaller things settled on the way. A whole float prints as 150, so writing one plainly would hand back an integer on the next read — the number rule needs holding up from both sides for a document to round-trip. Names are written sorted, so the same document always produces the same text and a rewritten file diffs cleanly. And asJson is defined on object, so nil answers "null" through the definition every other type uses — which matters because nil’s class has no global for a method to be bound on, and the single root is what makes that not matter.

6.12 was also made exact on the way. asCode and asCharacter do not exist — the entry proposes those names. What is good about the pair is that it needs no new type: a one-character string in, an integer out, and back. What it would not do is finish the JSON case, because a string is bytes and é is a code point UTF-8 spells in two of them. UTF-8 encoding is ordinary arithmetic once a number can become a byte, so it belongs in the library rather than the VM — the pair is the whole foundation and not the fix. One side effect worth having: #0:asCharacter would be the only way to write a NUL, which no literal spells today, and a string already carries one through readFile and writeFile byte-for-byte.

lib/ is now checked the way examples/ is: every file in it compiles and verifies, and one not listed in the test fails rather than being skipped.

One hierarchy, written down from the outside — 079d353, 2026-08-21

a0b0d41 made every built-in class delegate to object and recorded the decision in class-and-instance.md. That is the why; nothing said what it means for someone writing a program. docs/one-hierarchy.md is the consequence, and the line it draws is:

Delegating to object gives a value the behaviour. It does not give it the storage. One object:describe := { ... } is answered by all eleven kinds of receiver — integer, float, string, symbol, boolean, nil, array, block, dictionary, time and object — and the nearest slot still wins, because the root is the end of the search rather than a special case in it. But #45 is an immediate, so #45:x := #1 says cannot bind ‘x’ on integer, and parent and via refuse it. A value is a kind of object without being one.

Writing that up turned up a loose end. Override on a value class a message that object defines, and the override cannot call the one it displaced:

integer:describe := { "a number, and then ":concat(self:via(object):describe) }.
#45:describe.
solvm: 'via' expects an object, got integer

The check predates the root, from when a value’s chain ended at its own class and there was nothing above to name. Every class delegates to object now, so a value has a chain to walk and the refusal is a leftover rather than a design. slotAt(...):boundTo(self) does the job today — boundTo supplies a receiver instead of searching from one, so it takes a value happily. Recorded as 2.14.

Also linked from the reference’s object section, which stated the hierarchy and not what follows from it.

A dictionary is a switch statement — dc923d0, 2026-08-21

No code, just writing down something the pieces already did.

A block is a value and a dictionary holds values, so a table of blocks under keys dispatches on one:

action := dictionary:new.
action:atPut('red, { "stop" }).
switch := { light | action:at(light, { "not a light" }):value }.

at(key, default) is the whole trick. It was added so a counter could say counts:at(word, #0):add(#1), and it turns out to be exactly what a switch wants — the default case in one message rather than a lookup, a test and a branch. Usually a sign a thing was shaped right rather than shaped for its first use.

It is also 18.8× faster than the predicate caseOf in ideas.md over twenty cases, and the gap grows: a dictionary hashes once whatever the table holds, where a chain of comparisons walks until it matches.

docs/dispatch.md is the new page, and the reason for a page rather than a paragraph is the two traps, both from the blocks being closures. Building the table in a loop and capturing a temporary fails loudly — block outlived the frame it was written in. Capturing a global instead removes the failure and not the mistake: every block reads the same name, so all of them answer whatever the loop left behind. That is the closure-in-a-loop bug every language with closures has, and nothing here protects you from it.

The page also separates the two shapes of the question. Equality on a value is the dictionary’s; conditions, ranges and guards still need predicates tried in turn, which is what the caseOf in ideas.md is for and why it stays there rather than in the library.

One thing corrected on the way: ideas.md carried integer:caseOf := object:slotAt('caseOf) with a note about how neatly slotAt binds a method to another class. Still true of slotAt, and the line has not been needed since the single root — a method on object is found from a number like anything else. The single root took a paragraph of cleverness and made it unnecessary, which is the better outcome.

0.3.0 — 2026-08-21

A program can deal with the machine it is running on. It can look at the filesystem rather than only be told about it, know what day it is, and be run directly as a script.

$ cat report.sol
#!/usr/bin/env solis
system:filesIn("logs"):sorted:do({ name |
    "{}  {}":fill([system:modifiedAt("logs/":concat(name)):asString("%Y-%m-%d"),
                   name]):display }).

$ chmod +x report.sol && ./report.sol

The filesystem. filesIn and isDirectory to walk it; fileSize and modifiedAt to measure without reading; appendFile beside writeFile; makeDirectory, rename and remove to change it; environment to read a variable.

The three that change things take the narrow reading, and the reasoning is in the reference: remove takes a file or an empty directory with no recursive form, makeDirectory makes one level, and rename replaces without asking. Every refusal names the reason the system gave.

A time, as a value type held in nanoseconds since the epoch. system:time for now, system:modifiedAt for a file, time:fromSeconds for any instant, and asTime on a string to read one back. Comparison, secondsSince, plusSeconds, calendar fields, and strftime/strptime formats.

Everything is UTC, which is the decision rather than an omission — a zone is a political fact that changes, where an instant does not. An offset like +01:00 is accepted because an offset is arithmetic; a zone name is not, and will not be.

Scripts. solis takes a file — source or bytecode, decided by looking at the bytes rather than the extension — and a #! on the first line is skipped, so chmod +x works. #!/usr/bin/env solis is the portable form.

.sob stays at format version 11, unchanged since 0.1.0: the new value types were appended and cannot be constants, so the file layout never moved. A .sob built by 0.1.0 runs here, which was checked rather than assumed.

The restrictions in ROADMAP section 3 are unchanged: no non-local return, a capturing block tied to its frame, recursion to about 62 levels, text is bytes.

Verified for the release: clean build with no warnings, make test and SOLUM_GC_STRESS=1 make test both passing, all 26 examples compiled and run, and zero leaks across every test binary.

A time can be read back — 2b941f4, 2026-08-21

"2026-08-20T09:14:02":asTime:year:print.        ; #2026
"20/08/2026":asTime("%d/%m/%Y").

asTime is on string, beside asInteger, asFloat and asSymbol — a conversion from text has always lived there. With no argument it reads ISO-8601, mirroring what asString writes; with one, the format is handed to strptime, the counterpart of the strftime asString(format) uses.

No zone means UTC, there being no other kind here. An offset is accepted because an offset is arithmetic — +01:00 is an exact number of minutes and says nothing about legislation. A zone name is not, and will not be.

A date that does not exist is refused, which almost every date parser gets wrong quietly:

"2026-02-29":asTime.
solvm: 'asTime' cannot read that as a date

The check is a round trip — convert, split back, and see whether the day came out the way it went in. February the 30th converts to March the 2nd without complaining, and a silently wrong date is worse than a refused one. 2024-02-29 is a real day and is accepted; 2026-02-29 is not.

Two things worth recording from building it.

A precision bug of mine, caught by testing a fraction. asSeconds turned int64 nanoseconds into a double and then divided — but a present-day instant is past 1e18, where a double has stopped counting in ones, so 0.25 came back as 0.249999872. Both conversions now split the whole seconds from the fraction, which keeps the seconds exact and asks the double only for the part it can still hold.

timegm was the obvious call and is not standard C; mktime is standard and reads the local zone, which is the one thing this type does not have. The civil-date arithmetic is written out instead — ten lines, exact, and beholden to no zone.

programs/log.sol stops comparing timestamps as text. That worked, because ISO-8601 sorts the same as text and as instants — luck rather than design, true of no other format, and it meant a malformed timestamp went unnoticed because nothing ever looked at one. The report now says over 278 seconds, which text could never have told it, and a bad timestamp is caught with the line number like any other damaged field.

A time — eaa2fa4, 2026-08-21

Roadmap 6.18. A value type for a point in time, held as nanoseconds since 1970-01-01T00:00:00Z.

now := system:time.
now:print.                                   ; 2026-08-21T16:57:41Z
now:year:print.                              ; #2026
now:asString("%Y-%m-%d"):display.            ; 2026-08-21
system:modifiedAt("notes.txt"):asString("%H:%M"):display.

A value, not an object. Two of the same instant are the same time and nothing mutates one, so it belongs beside integer and float — which makes equals exact and a time a dictionary key for free, and means time:new refuses like the other value classes.

Nanoseconds as an integer, not a float of seconds. A point in time being a float invites t:add(1.5), a question with two plausible answers. Integers are exact, nan cannot get in, and int64 nanoseconds reach from 1678 to 2262.

Everything is UTC. Time zones are where every date library goes wrong, and the answer here is not to have them: a zone is a political fact that changes by legislation, twice a year in most places and retroactively in some. An instant is unambiguous; a wall-clock reading is not, and the trailing Z says which of the two you are looking at.

secondsSince, not sub — a time minus a time is not a time, so sub would have answered a different kind of thing from every other sub here. asString(format) hands the format to strftime, whose alphabet everybody knows, rather than inventing a third spec language.

system:time is not system:clock. That one is a stopwatch — monotonic, unspecified epoch, only differences meaningful. This is a calendar. A program asking how long something took wants the first; one asking when it happened wants the second.

Two things building it found that the plan had not.

Nothing could name a particular moment. With only system:time and system:modifiedAt, the only instants a program can have are the current one and a file’s — enough to stamp a log, not enough to say when something is due, or to test any of this against a date somebody knows. time:fromSeconds and asSeconds are the pair that fixes it, and they are also how an instant is written to a file and read back.

Splitting an instant has to floor. C division truncates towards zero, so half a second before the epoch divides to zero seconds and lands on 1970 rather than 1969. Tested, in both directions.

system:modifiedAt is the companion fileSize was waiting for.

Making, moving and removing — 99b971e, 2026-08-21

The other half of the filesystem, and the half that cannot be undone.

system:isDirectory(p):ifFalse({ system:makeDirectory(p) }).
system:rename(old, new).
system:remove(old).
system:fileSize(path).

Three decisions worth having made deliberately, all of them the narrow reading.

remove takes a file or an empty directory, and there is no recursive form. Both, because that is the distinction a script does not want to make — it knows what it is taking away. But deleting a tree is not something to make one message wide: a program that means it can walk with filesIn and remove what it finds, which at least reads like what it does.

makeDirectory makes one directory, not a path of them. mkdir -p is what a script usually wants and does more than its name says — asked for a/b/c it may leave a and a/b behind having failed at c. A directory already there is an error, which makes isDirectory:ifFalse({ makeDirectory }) the way to say “make sure of it”: longer, and it says which of the two you meant.

rename replaces an existing destination without asking, as the system call does and as every mv does, and cannot cross a filesystem. There the answer is read, write, remove — three operations because it is three operations — and the error says so rather than pretending.

Every refusal names the reason the system gave, so a script can tell a missing file from a directory that still has something in it:

solvm: cannot remove 'build': Directory not empty

fileSize and not modifiedAt. Size is unambiguous; a timestamp wants to be a date rather than a number of seconds, and there is no date type here yet — answering an integer now would be an interface a date type would have to change. Recorded as 6.18, which is now the largest thing missing.

Directories, and a script you can run directly — d503612, 2026-08-21

Two things, in the direction of Solum being worth writing a script in.

A program can look, rather than only be told.

system:filesIn("examples"):sorted:first(#3).   ; ["arrays.sob", "arrays.sol", "binding.sol"]
system:isDirectory("examples").                ; true
system:appendFile(log, "another line\n").
system:environment("HOME").                    ; the variable, or nil

readFile needed a path you already had, so a program could be handed something to work on and could never go and find it. filesIn is the missing first step of most file-processing programs — and it was nameable without writing a program to discover it, which is why it was named rather than staged.

Four decisions, all the conservative ones. Names, not paths, because a path would bake in a separator and joining is one concat. Everything but . and .., directories included, because leaving them out would make a recursive walk impossible. In the directory’s order, which is to say none — the rule dictionary:keys already follows. An error if it is not a directory, as a missing file is to readFile.

appendFile is writeFile’s other half. environment answers nil when a variable is not set, that being a legitimate answer rather than a failure.

A .sol file can be marked executable and run.

$ cat hello.sol
#!/usr/bin/env solis
"hello":display.

$ chmod +x hello.sol && ./hello.sol
hello

solis takes a file now — source or bytecode, and it decides which by looking at the bytes rather than the extension, so a script with no extension at all works, which is the usual way of writing one. Arguments after the file are the program’s, as they are for solvm.

The #! is skipped only at the very start of the file, and the newline is left in place so the line after it is line 2 — an error names the line an editor shows rather than one earlier.

One correction worth making: #!/bin/solis $* will not do what it looks like. The kernel passes at most one argument after the interpreter, literally, so the $* arrives as an argument spelled $*. #!/usr/bin/env solis is the portable form, and arguments need no help — they arrive as system:arguments.

examples/walk.sol is the program none of this was possible without: it walks a tree, counts and measures it, and catches call depth exceeded when the tree is deeper than the machine’s frames allow, reporting the partial totals rather than dying. Tried against a forty-deep tree it stops at 31 levels and says so.

The front ends are checked by hand rather than by the suite, which runs in-process and shells out to nothing: source, bytecode, the prompt, arguments, an extensionless script and a chmod +x one were each run.

A calculator, and the frame limit met at last — 4bd7c7e, 2026-08-21

programs/evaluator.sol tokenises, parses and evaluates arithmetic — precedence, brackets, unary minus — and says where it went wrong when the input is bad.

Deliberately a different shape from log.sol, which is line-oriented: read text, split it, tally it. This one recurses, builds a tree of objects, and has to report a position. Written because the last program found nothing the language lacked, and a program that finds nothing is only evidence about programs of its shape.

It reached the recursion limit, which nothing had before. A recursive-descent parser spends about three frames per level of bracket nesting — expression calls term calls factor calls expression again — against the machine’s 62. It manages 18 brackets deep and stops at 19.

And the failure is catchable, which was not obvious. call depth exceeded arrives at onError like any other, is reported like any other, and the program keeps working afterwards. Running out of frames is exactly the sort of failure a machine might not be able to recover from. Recorded against ROADMAP 3.5, because it lowers what raising the cap would buy.

Two smaller things, both written into the example where they bit.

The group-temporary idiom does not compose. ( | t | ... ) twice in one block is a compile error, groups sharing the frame they sit in — which is the documented rule, met for the first time in practice. A constructor block is the answer and is better code anyway.

ifTrue with two arguments is not caught until it runs, which happened twice while writing this. The compiler knows ifTrue well enough to inline one but not what the receiver is, and any object may define an ifTrue of its own taking two — which was checked, and works. So it cannot refuse the wrong count. That is the same ignorance that keeps a counted loop from being inlined, and it is the price of everything being a message rather than an oversight.

One bug of mine, fixed in the writing: reporting a position past the end of the input used the token count where a column was wanted. They agree often enough on short input to look right.

The log analyser survives damaged input — 041467d, 2026-08-21

programs/log.sol assumed its input was well-formed: every line split into exactly six fields and every field parsed. Fed a real log it would stop at the first truncated line. It does not any more, and three of the lines in its own sample are now broken on purpose so that running it shows the recovery:

3 lines could not be read
  line 8: wanted 6 fields, got 4
  line 13: 'four' is not an integer
  line 18: wanted 6 fields, got 4

It is the first program here that could not have been written before 0.2.0, which was the point of writing it.

Three things it found.

parse should say what is wrong with a line rather than let the first message that cannot cope fail on its behalf. f:at(#4) on a short line answers index #4 is out of bounds for an array of size 3 — true, and no use to somebody looking at their log. Checking the field count and raising wanted 6 fields, got 4 costs one line and is the difference between a complaint about the program and a complaint about the input.

The machine says what, the program says where. 'four' is not an integer is a good message and a poor report on its own, because it does not say which line. Only the program is counting lines, so only the program can add that. Neither half is worth much without the other, and the split falls out naturally rather than being arranged.

Surviving a bad line is not the same as surviving a bad file. Fed pure rubbish, every line was skipped correctly and then the summary fell over on entries:at(#1) — there was no first entry to ask the time of. Caught by trying it rather than by thinking about it, which is the argument for trying it.

What did not come up: ensure. Nothing in this program acquires anything that must be given back, which is exactly what 6.17 predicted when it said nothing needed it yet.

0.2.0 — 2026-08-21

A failure can be recovered from. That is the whole of this release: raising one deliberately, catching one, passing on what you did not mean to catch, and cleaning up either way.

{ system:readFile(path) }:onError({ e | "" }).
error:raise("bad input on line 3").
{ working:value }:ensure({ tidyUp:value }).

.sob stays at format version 11 — nothing about the instruction set or the file layout changed, so a .sob built by 0.1.0 runs here.

What it does not include: there is still no taxonomy of failures, so onError catches everything and telling one kind from another means checking e:message. Inventing a hierarchy to go with a catch mechanism would have been inventing it in the wrong order, and the error being an object rather than a string leaves room to say more later without breaking any handler.

The restrictions in ROADMAP section 3 are otherwise unchanged: no non-local return, a capturing block tied to its frame, recursion to about 62 levels, text is bytes.

Verified for the release: clean build with no warnings, make test and SOLUM_GC_STRESS=1 make test both passing, all 23 examples compiled and run, and zero leaks across every test binary.

ensure — cleaning up regardless — e001b8e, 2026-08-21

Roadmap 6.17, written down one commit ago on the grounds that nothing needed it yet.

{ working:value }:ensure({ tidyUp:value }).

Runs the cleanup whether the body finished or not, then goes on doing whatever the body was going to do. Answers the body’s answer.

The difficulty is that a failure has to be set aside for the cleanup to run at all. had_error is what stops the machine, and the dispatch loop tests it after every instruction — so a cleanup started with the flag still up would manage one instruction and stop. The failure is lifted out complete with its message and stack, the VM given fresh buffers for the duration, and the whole thing put back afterwards.

system:exit is set aside the same way, which the entry did not anticipate. It travels by the same flag, and giving back a thing you borrowed is as necessary when a program is stopping as when it is failing. The cleanup runs and the program still leaves with its status.

When both fail, the body’s failure wins — the wrinkle the entry named, and the answer it guessed was right. The first error wins here as it does everywhere.

An uncaught failure that passed through a cleanup keeps its own message and its own stack, so it names where it happened rather than where it was tidied up after.

Unlike onError’s handler the cleanup always runs, so one that is not a block is refused every time rather than only when something fails.

An error can be caught — 29f358f, 2026-08-21

{ nil:frobnicate }:onError({ e | e:message:display }).
text := { system:readFile(path) }:onError({ e | "" }).
error:raise("bad input on line 3").

onError answers the receiver’s answer when nothing went wrong and the handler’s when something did, so it is an expression. A caught error says nothing — which is what the previous commit’s deferred reporting was for.

The error is an object, delegating to a new error global, with its message in a slot. A value rather than a string on purpose: this project rewords its errors freely, so handing a handler the text and nothing else would make matching on it the only way to tell failures apart — an idiom these very habits would keep breaking.

error:raise is the only way to raise, so re-raising is error:raise(e:message). Two spellings — one on the class taking a string, another on an instance taking none — would be one name meaning two things, which is the mistake this language already made once with new. The price is that a re-raised error’s stack points at the re-raise rather than the original failure, which is honest: it is a new raise.

It catches everything, including a misspelled message, as decided. The hazard is real and the way out is one message wide.

system:exit is not caught: it travels the same way but is a stop rather than a failure.

The bug this nearly shipped with

sol_vm_call_block restored the frame count on failure but not the stack pointer. That was invisible for as long as every error unwound to sol_vm_run, which resets the stack on its way out — so nothing between the failure and the top ever had to leave things tidy.

Catching stops the unwind part-way, and everything the unwind was allowed to leave behind is suddenly still there. It showed up as:

xs:add({ error:raise("x") }:onError({ e | e })).
solvm: block does not understand 'add'

— the failed call’s receiver and arguments still on the stack, so the next send found the wrong thing. sol_vm_send had always restored its own stack mark; sol_vm_call_block now does too, which gives every caller the invariant rather than making each catcher clean up.

There is a test for it, and for 20,000 catches in a loop not creeping the stack upward a few slots at a time.

Also recorded: there is no ensure, and roadmap 6.17 says why it was left out rather than guessed at.

An error is text the machine holds — 80818f9, 2026-08-21

Groundwork for catching one, and nothing about the visible behaviour has changed — which is the point of landing it on its own.

sol_vm_runtime_error used to write the message and the stack straight to stderr from wherever the failure was. It builds them into vm->error_text instead, and sol_vm_run writes that out before returning, when nothing has caught it. Nothing catches anything yet.

The reason for the shuffle: a message already on stderr cannot be taken back. A handler has to be able to see an error and decide, and that is impossible while the report happens at the point of failure.

The whole test suite passed untouched, which is the evidence that the behaviour is the same, and a program’s output was diffed byte-for-byte against the previous build to be sure the ordering had not shifted either.

One thing did change, for the better. The first error now wins. Building a message can itself fail — a complaint that names a value renders it, and rendering sends asString — and that used to print twice. The failure that started it is the one worth reporting; the one that followed is a consequence of trying to report it.

system:exit unwinds through the same flag and is not a failure, so it records nothing and says nothing, as before.

0.1.0 — 2026-08-21

The first release. Everything below this heading is in it.

What that means and does not mean:

The tests pass under make test and under SOLUM_GC_STRESS=1 make test, every one of the 22 examples compiles and runs, and leaks reports none across the whole suite.

The counted loops are built in — and not by inlining — c56a3c4, 2026-08-21

Roadmap 6.6, finished. repeat, toDo and toByDo are primitives now.

#3:repeat({ "tick":display }).
{ "tock":display }:repeat(#2).
#1:toByDo(#10, #3, { n | n:display }).       ; 1 4 7 10

The entry asked for inlining and inlining was the wrong answer, which is worth recording because the reasoning was not obvious until it was tried.

Per iteration the Solum version pays a block call for the body, plus a lessThan and an add send for the counter. Inlining removes the block call and keeps the two sends. A primitive removes the two sends and keeps the block call. Over 200,000 iterations:

library (Solum)      0.0601 s
inlined by hand      0.0470 s     -- what the entry asked for
primitive            0.0186 s

3.2× the library version, and 2.5× faster than inlining would have been. The sends cost more than the block call — the opposite of what the entry assumed.

Inlining was also the harder half. A counted loop’s receiver is whatever expression you wrote, and its type is unknown while compiling, so 1.5:repeat({...}) has to go on saying float does not understand ‘repeat’ rather than complaining about the counter. Inlined jumps would need a type-guard instruction carrying the message name — a new opcode, and a .sob version with it. A primitive gets it from dispatch for nothing: repeat is installed for integer receivers, so a float never finds it.

toByDo gained two things it could not have in Solum. A step of #0 is now an error rather than a printed complaint followed by a silent no-op. And a step that would carry the index past INT64_MAX ends the loop instead of wrapping to the bottom and running for ever, in both directions.

The library is nearly empty, which is the record rather than a regret. It opened yesterday with five loops; four have been measured and all four were worth building in. timesCollect is what is left — the one nobody has measured. The search path and @include finding a name it was not told the location of are unchanged, and were always the part that mattered.

Defining any of the four in the library again would be a trap rather than an override: a slot bound on integer shadows the primitive, so the slow version would quietly win.

The script has a frame like everything else — 5f69049, 2026-08-21

.sob format version 11. SolChunk carries a slot_count, and sol_vm_run reserves those slots before the first instruction exactly as push_frame reserves a method’s.

A temporary at the top level of a script works now, and used to be refused:

#1:add(( | t | t := #5. t )):print.        ; #6
( | a, b | a := #2. b := #3. a:mul(b) ):print.    ; #6

The refusal was real and the reason was real: the script’s frame reserved no slots, so a name declared there was emitted as OP_SET_LOCAL against the bottom of the expression stack, where it overwrote whatever the enclosing expression had put there. The verifier refused the result, so solas failed at the file write saying the bytecode was inconsistent, while Solis — which runs what it just compiled without verifying — answered wrongly instead. Refusing it in the compiler reported one mistake once. Giving the frame slots means there is nothing left to refuse.

The whole script is one frame, so two groups in a file share a namespace and cannot both declare t — the same rule two groups inside one block already lived by.

It came out of roadmap 6.6, which is about inlining the counted loops. repeat needs a counter that survives the iteration and there was nowhere to keep one; the entry offered two homes, both bad — new opcodes for stack-slot arithmetic, or a hidden local that works inside a block and not in a script. The third, found by explaining the first two, was that a script’s frame is the only one that reserves nothing, and that this is also why top-level temporaries were refused. One missing field, two problems.

The header’s reserved u16 at offset 6 is where the count went — the top-level chunk is the only one whose frame size is not already carried by the method that owns it, so it is written once rather than on every method’s chunk.

Nothing on disk survives the change, and nothing pretends to: a version 10 file is refused with unsupported bytecode version rather than misread.

The counted loops are still unbuilt, but they are now compiler work with no format change behind them. Whether they are worth building is the question the measurement already answered — repeat costs 1.30x, where doUntil cost 2.29x — and the large win was the one that needed no slots.

doUntil is built in, and compiles to jumps — 413c57b, 2026-08-21

Roadmap 6.6, the half of it that could be had without changing the instruction set.

lines := #0.
{ lines := lines:add(#1) }:doUntil({ lines:greaterOrEqual(#3) }).

The entry sat unbuilt because the wrong construct was measured. repeat costs one block call an iteration and inlining it buys 1.30x, which is not worth a change. doUntil pays for two — its condition is a block as well as its body — plus the done:not send the library version needed. Over 200,000 iterations:

library doUntil   0.0706 s
hand-written flag 0.0395 s
inlined doUntil   0.0309 s

2.29× the library version, and 1.28× the loop it replaces. The second number is the point: writing that loop by hand needs a done flag outside it, and the flag costs two sends an iteration that jumps do not need. doUntil is now the fastest way to write it rather than a convenience paid for.

The wrinkle was the complaint, not the loop. The shape is whileTrue’s with the body in front of the test and the sense inverted, and there is no OP_EXIT_IF_TRUE. Adding one meant a new opcode and a name index on it, since OP_EXIT_IF_FALSE carries none and words its error as whileTrue. Instead OP_CHECK_BOOL — which already carries a name and already refuses a non-boolean — goes in front, so the unnamed instruction can only ever see a boolean. No new opcode, no format change.

A test asserts the inlined and sent forms produce the same first line and that neither says whileTrue.

It left the library. lib/control.sol defined doUntil and no longer does: a definition there would be a trap rather than an override, bypassed exactly where it was most wanted.

repeat and toByDo stay library code, and building this found out why they are a different problem. Nothing survives between iterations of whileTrue or doUntil — the condition is re-evaluated and the boolean consumed. A counted loop has an i and a limit that must live across passes, and there is nowhere for them: the value stack has no instruction that compares or increments a slot in place, and a hidden local works inside a block but not at the top level of a script, which has no frame. That would make the optimisation apply in some places and not others, which is worse than being slow. The roadmap entry records both options.

An array can be sliced — b156bcd, 2026-08-21

Roadmap 6.16, the other thing log.sol wanted — twice.

[#1, #2, #3, #4, #5]:copyFrom(#2, #4).   ; [#2, #3, #4]
[#1, #2, #3]:first(#2).                  ; [#1, #2]
[#1, #2, #3]:last(#2).                   ; [#2, #3]

The example’s hand-rolled firstFew walk is gone; it says :first(#5) now.

copyFrom is the string’s rule, transcribed rather than reinvented. Both ends included, both one-based, the empty slice spelled with to one before from, and out of range an error — following at. Two collections disagreeing about what a slice means would be worse than either rule is good.

first and last clamp, and that is a second rule on purpose. copyFrom names positions, and one outside the array is a program wrong about something. first names a quantity — give me the top five — which a list of three has answered correctly by handing over three. Refusing there would make every ranked report check the size first, which is what these exist to avoid. One rule would have been tidier and wrong. A negative count is refused by both: clamping is for asking for more than there is, not for nonsense.

One thing the change exposed. log.sol’s “busiest paths” ranks by count, and four paths tie at two apiece for three places — so which three appeared depended on the order dictionary:values handed them back. Arbitrary but not random, so it was stable per build and looked fine, and it had quietly changed when the tally became a dictionary. The report breaks ties on the key now, and is the same every run.

An include search path, and a library to find on it — 1a783b2, 2026-08-20

Two halves. @include gained a search path, and lib/control.sol is the first thing that ships on it:

@include "control.sol".

#3:repeat({ "tick":display }).
{ lines := lines:add(#1) }:doUntil({ lines:greaterOrEqual(#3) }).
#1:toByDo(#10, #3, { n | n:display }).       ; 1 4 7 10
#4:timesCollect({ n | n:mul(n) }):print.     ; [#1, #4, #9, #16]

The library was the easy half. Its contents have been sitting in ideas.md working for months. What stopped them being a library was that @include resolved only against the file including it, so a shipped file could be reached only by an absolute path baked into every program or by copying it next to each one. Neither is a standard library.

So: -I dir on solas and solis, then SOLUM_PATH, then the library shipped beside the binarybin/solas looks in bin/../lib. A name not found beside the includer is looked for in each, in order, and the first that has it wins.

That is C’s rule for a quoted include, and for C’s reason: your own files are found without ceremony, and a name you do not have locally comes from the library. It carries C’s cost too — a local file shadows a library one of the same name — which showed up immediately. The first draft of the example was examples/control.sol, which included "control.sol", found itself beside it, and, a file being compiled once, quietly did nothing. It is examples/loops.sol now, and the trap is written down in both the guide and the reference.

What went in, and what did not. repeat, doUntil, toDo, toByDo, timesCollect. Not caseOf, which is also in ideas.md and also works: 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”.

None of it is language. These are methods bound on integer and block by an ordinary Solum file, which is possible only because control flow here is message sending. doUntil earns its place by being the shape whileTrue cannot express — the body before the test — so the flag that needs declaring outside the loop is written once, in the library, rather than in every program.

Eight new tests: the path finding a file, beside-first beating it, the first directory winning, an absolute name searching nothing, the not-found message saying the path was tried, the library compiling and its loops working, and — because a library that announced itself when you included it would be a poor guest — that including it writes nothing at all.

This also changes what roadmap 6.6 is waiting for. Nobody wrote repeat before because writing it out per program was not worth it; it is one @include away now, so if the 30 per cent it costs ever matters, it will be because a program leaned on the library and noticed.

A dictionary — 7e0726d, 2026-08-20

Roadmap 6.15, wanted by programs/log.sol and now used by it.

counts := dictionary:new.
"the fox the dog the":split(" "):do({ word |
    counts:atPut(word, counts:at(word, #0):add(#1))
}).
counts:at("the"):print.          ; #3

dictionary:new, at, at(key, default), atPut, includes, remove, size, keys, values, do, keysAndValuesDo. Open addressing, tombstones for removal, and a rebuild that drops them once they crowd the table.

The entry offered two answers and called the wrong one smaller. It proposed slotAtPut — completing the reflection triple so an object could serve as a dictionary — as the cheap option. Checking killed it. A slot name is interned in the VM’s permanent name table, so keys read from a file would leak a name apiece; and slots are a linked list walked linearly, so an object-as- dictionary would have had exactly the complexity of the array of pairs it was replacing. Not smaller — wrong.

Keys are values. Numbers, strings, symbols, booleans and nil are compared by content, so two keys that look alike are one key. Arrays, blocks, objects and dictionaries are compared by identity, so two that look alike would be two keys — right for equals, useless here, refused rather than surprising anybody. It is the line the language already draws between values and references.

Two things fell out of taking that seriously: -0.0 hashes as 0.0, since the two are equal and the table must not disagree with equals; and nan can be stored and never found again, since it equals nothing at all.

sol_value_equals now exists and prim_equals calls it, so the table and equals cannot come to disagree about what one key being another means.

One bug, and it is the interesting part. Adding a value type touches six places. Five are switches with no default, and -Wswitch named every one at the first build. The sixth — mark_value in the collector — was a chain of if (SOL_IS_...), compiled silently, and swept live dictionaries. It took a segfault at 500 keys and a stack trace showing a freed struct to find. It is a switch now, so the next type cannot slip through the same gap.

tests/test_dict.c has eleven groups: growth past several rehashes, churn until tombstones force a rebuild, a dictionary holding itself, and two hundred freshly allocated keys and values surviving a collection — that last confirmed to fail when the marking is taken out.

A log analyser, and the two things it could not say — de39331, 2026-08-20

programs/log.sol reads an access log and reports on it: totals, a breakdown by status, the busiest paths, the slowest requests, and the failures. It takes a path from system:arguments, or writes a sample into build/ so it runs anywhere.

It is the first program here written to do a job rather than to show a feature, which was the point. Every entry left in the roadmap was waiting for a program to want something. This is what one wanted.

Most of it went in without complaint — split on the file and again on each line, a prototype for an entry, inject for totals, select for the failures, sorted with a block, fill with format specs for the columns. Two things did not.

There is no dictionary, and no way to build one. Counting by key is most of what a log analyser does. An object is a set of named slots and would serve — except a slot name comes from the compiler. perform sends a computed name and slotAt reads a computed slot, but nothing binds one, so an object cannot stand in for a dictionary either. What the example does instead is keep an array of key/count objects and walk it: O(n) a lookup, O(n²) over a file. Fine over eighteen lines and the wrong shape over eighteen thousand. Recorded as roadmap 6.15, with the two ways to answer it — a slotAtPut completing the reflection triple, or a real dictionary type — and the argument that the first is smaller and the second is right.

An array cannot be sliced. No first(#n), no last(#n), no slice, so taking the head of a sorted array is a walk with an index. Every report that ranks anything wants it, and this one wants it twice. Roadmap 6.16; copyFrom on a string is the shape to follow.

Neither was a guess about what might be missing. Both are things the program needed and had to work around, and the workarounds are in the example with comments saying so rather than tidied out of sight.

Worth noting what did not come up: the loop constructs (6.6), a single keypress (6.10) and a byte type (6.12) were all still unwanted at the end of it.

new means one thing — d58918c, 2026-08-20

Breaking. integer:new(#45) and float:new(1.5) used to answer their own argument. They refuse now:

integer:new(#45).
solvm: an integer is written #45, and there is nothing for 'new' to make -- #0 is the empty one

They constructed nothing — return args[0], type-checked. That is the literal spelled longer, and it was the last of the design in the original notes, where you built a mutable integer and then set it:

integer:new(a)
a:set(#45)

Numbers became immutable unboxed values, set never existed, and new outlived the thing it constructed.

The rule that replaces it is mutability. new belongs where something is made, which is where the instances are references, so there is a fresh, distinct one to hand back:

array:new:equals(array:new):print.    ; false -- two arrays
"":equals(""):print.                  ; true  -- one value

Two classes construct, object and array; the other six refuse and say what to write. That rule sorts all eight correctly, and class-and-instance.md had said no rule was available.

They could not simply lose the message. Deleting the registration was tried: every built-in delegates to object, so integer:new inherited object’s and answered an object delegating to integer, which then fails print. Worse than the identity function it replaced, and the same trap that made the other four shadow rather than inherit. So the two joined the refusers.

#45:new(#1) refuses along with it, which removes one of the three symptoms roadmap 2.5 is about. The other two are untouched — integer:slots still lists new beside add, because the slot is still there and slots reports what is there.

What this cost: a documented message, four tests, and the closing line of examples/hello.sol, which used integer:new(#45) as the callback to the original notes. The example closes that loop better now, by showing that both of the notes’ messages went and why.

isNil and notNil10ddf25, 2026-08-20

Roadmap 2.14, the last of the loose ends from 2.8.

nil:isNil:print.                 ; true
"":isNil:print.                  ; false -- empty is not absent

x:equals(nil) said this already and said it awkwardly: a test for absence read as a comparison against a value.

Both, rather than isNil alone with not for the other. The message that actually gets written is the negative one — running out of input is how a loop finishes — and line:isNil:not is worse than the notEquals(nil) it would be replacing. A version with only isNil would have left the single real use of it in the codebase no better off. examples/reading.sol was that use, and it now reads:

line := system:readLine.
{ line:notNil }:whileTrue({ ... }).

On every type, not on nil. The receiver is exactly what is not known: the point of asking is that the answer might be nil, so a message only nil understood could not be sent to find out.

Neither confuses absence with emptiness — "", #0, [] and false all answer notNil, and there is a test that walks every type asserting isNil is the exact complement of notNil and agrees with equals(nil) on all of them.

absence.md, the guide, the reference and three examples now use the new spelling where they used the comparison.

Every concept the guide names has an example — 8a2546c, 2026-08-20

Roadmap 6.9, the example audit. Two new programs, binding.sol and strictness.sol, and nineteen in all.

The audit’s answer was not the one the entry assumed. It supposed the examples were thin, having been written alongside whatever was being built at the time. Measured against every selector registered in builtins.c — the sharper question — exactly one built-in message had never been sent in any example: lessOrEqual.

The real gaps were conceptual. Five of the guide’s nineteen sections pointed at no example, and two of those five were not gaps at all: via was in objects.sol and slotAt/boundTo were in reflect.sol, neither pointed at from the section that teaches them. Those needed a link, not a program.

The three that needed a program got two:

The audit is now a test, for the same reason the instruction set reference is one. tests/test_compile.c reads the registrations out of builtins.c and checks that each selector is sent by some example, with ; comments blanked out first so a message appearing only in an error transcript does not count as covered — and that blanking respects string literals, since files.sol has a ; inside one. A second check walks examples/ and refuses any .sol missing from the list the file verifies, so an example cannot ship unchecked. Both were confirmed to fail when they should.

One thing the audit turned up that was nothing to do with examples: index.md said “Twelve programs” and listed twelve, while seventeen shipped. It lists all nineteen now, and the tutorial’s count is right again too.

The guide contrasts a group with a block — 4001efa, 2026-08-20

Roadmap 6.8. Both are code in brackets, and nothing put them side by side.

m := { x | x:add(#1) }.
(m:value(#42)):print.            ; #43     -- the group ran, and answered
{ m:value(#42) }:print.          ; <block> -- nothing ran
{ m:value(#42) }:value:print.    ; #43     -- now it did

That example came from the roadmap entry. Writing it up turned up a better one, which is the reason the contrast matters rather than a curiosity about brackets. An argument is evaluated before the send, like any other argument — so handing ifTrue a group means the group has already run by the time ifTrue gets to decide anything:

false:ifTrue(("the group ran anyway":display. nil)).
false:ifTrue({ "the block did not":display }).

Only the first prints. Nothing in the compiler knows what ifTrue means; the block simply has not been run, and ifTrue chose not to run it. Every conditional and every loop rests on that, and a reader who has never seen the two side by side has no way to see it.

A third difference explains a restriction the guide already described without saying why: a block makes a frame, a group borrows the one it is in. A group’s temporaries are the enclosing block’s, which is why one may only be declared where a frame already exists — and why declaring a temporary at the top level of a script is refused.

In the guide’s §7, in the reference beside Grouping, and in examples/blocks.sol so the concept has runnable code and not only prose.

The instruction set has a reference, and the tests keep it honest — 8d7c558, 2026-08-20

Roadmap 6.7. docs/BYTECODE.md describes all twenty-one opcodes: operands, instruction length, effect on the stack, and a worked disassembly.

The table in design.md was missing sixOP_JUMP, OP_JUMP_IF_FALSE, OP_EXIT_IF_FALSE, OP_LOOP, OP_CHECK_BOOL and OP_SYMBOL. Every jump plus the two newest, so it described the machine as it was before 4.1. The material existed the whole time; bytecode.h documents each opcode at its definition and the disassembler prints all of them. Nothing tied the document to the header, so nothing said when it stopped being true.

So the new page is checked rather than trusted, by tests/test_bytecode.c, three ways: every opcode in the header appears in the document, every OP_ name in the document still exists in the header, and every instruction length the document gives matches sol_op_length — which is the one place lengths are really written down.

The part that makes this hold up is that the test maintains no list of its own. It reads the enum out of the header, and a C enum with no initialisers numbers from zero upwards, so the order the names are written in is also their value. Nothing to update, nothing to fall behind.

One mistake worth recording, because it is the kind this whole entry is about. Taking any OP_ at the head of a line found twenty-three opcodes instead of twenty-one: the comments in the header wrap, and OP_JUMP_IF_FALSE only in the complaint it makes starts a line too. The two phantom members shifted every value after them, which surfaced as OP_JUMP_IF_FALSE apparently being three bytes long. What tells a member from a mention is what follows it — a comma, or the comment if it is the last one.

All three checks were confirmed to fail when they should, and the three disassembly listings in the page were diffed against real --dump output rather than transcribed. design.md keeps the operand-width rule and points at the new page; it has no table of its own any more.

A block can time itself — 661408d, 2026-08-20

Roadmap 6.5. { ... }:timeToRun answers the seconds the block took, as a float.

{ #20:factorial }:timeToRun:asString(".6"):display.

A float of seconds is what the roadmap called for, and for the reason it gave: it is the only answer that needs no duration type. The block’s own answer is dropped — what was asked for was the time, and { ... }:value is there when the answer is wanted too.

The entry missed something that changed the shape of the thing. The clock has a floor. Here it is a microsecond, by clock_getres and by watching the smallest step between two readings, while one send and one add costs well under a tenth of that. So a single run measures the floor rather than the block:

{ #1:add(#1) }:timeToRun:print.        ; 0, or 0.000001 -- the floor, either way

That is fatal to the entry’s own purpose. It exists because every performance number in this changelog was taken with /usr/bin/time around a whole process, and the numbers it wanted instead are all sub-microsecond. Without a repeat count the message cannot measure a single one of them.

So there is a count too. timeToRun(#n) runs the block n times and answers the total:

total := { #1:add(#1) }:timeToRun(#200000).
total:div(200000.0):asString(".9"):display.      ; 0.000000088 -- or thereabouts

The total rather than the average, because the total is the measurement and the average is a division you can do — and keeping the count in view is what tells you whether the floor was cleared. A count below #1 is refused: the answer would be 0.0 whatever the block.

What is measured includes the cost of calling the block, a frame pushed and popped. That is not overhead to subtract; it is what running the block costs.

This is what roadmap 6.6 has been waiting for. Inlining the loop constructs buys speed rather than expressiveness, so it was never worth doing on a guess — and now the Solum-written version and the inlined whileTrue can be measured against each other first.

Arrays fold, and strings go back together — 72df16b, 2026-08-20

Roadmap 6.14. inject and join.

[#1, #2, #3, #4]:inject(#0, { total, n | total:add(n) }).   ; #10
"a,,b":split(","):join(",").                                ; "a,,b"

The entry set these against each other — a fold answers the gap once, where join is the case that keeps coming up. That was a false choice, and building one showed why. A fold cannot express join well: the separator goes between pieces rather than before each, so folding one needs a flag or a test for the empty accumulation — which is exactly the six lines being replaced. They are not the general and the specific case of one thing; they are two things, and both are built.

inject(start, block) completes the iteration messages. do throws its answers away, collect and select each answer an array, and this answers one value. An empty array answers start without ever calling the block, so a fold is safe to write without asking first whether there is anything to fold. What accumulates need not be the elements’ type.

The cost of not having it was sharper than “a few extra lines”: every reduction had to be a do with an accumulator declared outside it, which works only at the top of a frame. inject is an expression, so a reduction can stand in the middle of one:

[#1, #2, #3, #4, #5, #6]
    :select({ x | x:mod(#2):equals(#0) })
    :inject(#0, { total, n | total:add(n) }).      ; #12

join(separator) is on array rather than string — it is the array that has the pieces. Strict about them: an array holding anything but a string is an error rather than a silent asString on each, since asString and fill are already the messages that render things.

Its separator may be empty where split’s may not, and that asymmetry is deliberate. Nothing can be looked for, since every position in every string contains the empty string — but putting nothing between the pieces is exactly concatenation.

s:split(sep):join(sep) is s, for every string and every separator. That round trip is what split keeping its empty pieces was for, and it is now tested rather than only argued.

One note on the collector, since the project’s habit is to prove these load-bearing: inject holds its accumulated value on the value stack, because sol_gc_push_temp cannot hold an integer or a nil — neither has a header to push. That root is defensive, not load-bearing. Taking it out passes under SOLUM_GC_STRESS=1, because sol_vm_call_block pushes the receiver and arguments before it can allocate, so the value is already rooted wherever a collection can happen. It is kept anyway, and labelled: one stack slot against relying on what another function does with its arguments, across an unbounded number of calls back into the language.

Unrelated and found on the way: test_nesting in tests/test_array.c ran a second chunk over the first without freeing it, leaking 800 bytes. It is a test, not the VM, but it made leaks useless on that binary. Fixed.

A string can be taken apart — 4d35540, 2026-08-20

Roadmap 6.11. split, indexOf and copyFrom.

"a,b,c":split(",").              ; ["a", "b", "c"]
"hello":indexOf("ll").           ; #3
"hello":copyFrom(#2, #4).        ; "ell"

readFile answering a whole file as one string is what made this visible. Counting the lines in a file was a character-at-a-time loop, and it was the least pleasant code in the examples; it is now text:split("\n").

split keeps every piece. There are always occurrences + 1 of them, so a separator at either end or two together leaves an empty string where the missing piece would be:

"a,,b":split(",").       ; ["a", "", "b"]
",a":split(",").         ; ["", "a"]
"abc":split(",").        ; ["abc"]   -- no occurrence, so one piece

That is what makes it predictable: the pieces put back together with the separator between them are the string you started with, whatever it was. Dropping empties would read more kindly on " a b " and would lose the difference between "a,,b" and "a,b" — usually the one thing a program parsing a file needs to keep.

indexOf answers nil when there is no match, not #0, which is what the roadmap called for. Indices start at #1, so #0 would be out-of-band, and more to the point it would be a second spelling of something the language already spells: text:indexOf(","):equals(nil) is the same question an unset slot and the end of input are already asked.

copyFrom includes both ends, both one-based, so copyFrom(#i, #i) is exactly at(#i). The thing the roadmap did not anticipate was needing to say nothing — cutting a string at a mark has no answer for the front half when the mark is the first character. An empty result is spelled with to one before from, and only that far:

"hello":copyFrom(#3, #2).    ; ""
"hello":copyFrom(#4, #2).    ; error: ends at #2, more than one before its start #4

Neither split nor indexOf will look for the empty string: every position in every string contains it, so the answer would be arbitrary.

All three go by the length rather than stopping at the first NUL. That was not free — strstr was the obvious implementation and would have been wrong on exactly the binary files 6.12 is about — and a test reads a file holding a NUL and splits it.

The inverse is missing. There is no join, so putting pieces back together is a walk with do and a flag, which examples/strings.sol now shows in six lines that ought to be one. Underneath it there is no inject or fold either, so every reduction over an array is that same walk. Recorded as roadmap 6.14, and it is the next thing to do.

include is a directive and now looks like one — e215440, 2026-08-20

Roadmap 6.13. @include "library.sol". replaces "library.sol":include.

@include "library.sol".

temperature:cToF(100.0):print.          ; 212

The old spelling was a disguise. It read as a message sent to a string and never was one: no string was pushed, nothing was sent, and the whole thing had vanished before the program ran. It was written that way because the language had no directive syntax and no keyword to spare, and that shape already parsed.

The compiler paid for the disguise three times over — a two-token lookahead in statement to spot one, a special error in primary to refuse the same shape everywhere else it parsed, and a probe function nothing else in the grammar needed. All three are gone. @include is one token, @ and all, so a directive announces itself at its first character.

But the cost that mattered was to the reader. A construct that looks like ordinary syntax and obeys different rules teaches the wrong model: accept "lib.sol":include as a send and you have learned that a send might happen at compile time, which is true of no other send in the language. That is the objection that sank the trailing-block shorthand, and it applies harder here.

@ names a space, not a word. What follows it happens while compiling, and nothing in that space is a message to anything. An unknown directive is refused rather than passed through:

[prog.sol:1:1] solas: unknown directive at '@compile'
  @compile "library.sol".
  ^^^^^^^^

@include is the only member, and may stay the only one. It earns the sigil with one, by marking the single construct in the language that is not run time.

A sigil also costs nothing that a keyword would have cost. No identifier can begin with @, so include is not reserved and any object may still use it as a slot name. One argument for a bare include keyword nearly held — that everything happening at run time in Solum has a colon in it, so a colon-free statement already reads as not-a-send — but it is false: x. is a legal statement, colon-free and entirely a run-time one.

Semantics are untouched: the same splice into the includer’s scope, the same resolution relative to the including file, the same once-per-compilation keying, the same cycle stop. Three new tests cover the new refusals, and the lexer test that used @ as its example of an unexpected character now uses %, @ no longer being one.

One consequence elsewhere. The entry below gives three reasons for putting readFile on system rather than on a string, and the third was that "lib.sol":include would look identical beside "lib.sol":readFile. That collision is now gone. The first two reasons were the load-bearing ones and the decision stands.

A program can read and write files — 63bb836, 2026-08-20

Roadmap 6.4, whole files as strings.

system:writeFile("notes.txt", "apples 3\npears 12\n").
system:readFile("notes.txt"):size:print.         ; #18
system:fileExists("notes.txt"):print.            ; true

They are on system, not on the string naming the file. The roadmap sketched "notes.txt":readFile, which reads better, and three things decided against it: a string knows nothing about files, and putting them there gives every string in the program a message about the filesystem; system is already defined as what belongs to the process rather than to any value, and a file is the world outside; and "lib.sol":include already means something on a string literal — a compile-time directive — so "lib.sol":readFile beside it would be two identical-looking sends that are not the same kind of thing at all.

A missing file is an error, not nil, which the roadmap called the real design work and got right. It is the answer an out-of-range index gets, for the same reason: a program asking for a file it has not got is wrong about something. readLine answering nil at the end of input is not the precedent it looks like — running out of input is how a loop finishes.

system:fileExists(path) is how to ask first, and it answers false for a directory, because that is what readFile says about one too. A fileExists that disagreed with readFile would be a trap rather than a way to look before leaping.

writeFile replaces what is there, creates the file if it is not, and answers nil. It reports failure from fclose as well as fwrite: a buffered write fails when the buffer is flushed, so a full disk announces itself at the close and not at the write that filled it.

Binary already round-trips. A string is bytes, a NUL is a byte like any other, size counts it, and reading a file and writing it back copies it exactly. Taking one apart still does not work, at answering a one-character string rather than a number, and that half of the entry stayed behind as roadmap 6.12 with a number of its own.

Writing this opened a gap worth its own entry. A file arrives as one string and there is no way to split it — no split, no indexOf, no substring — so counting lines is a character-at-a-time loop, which examples/files.sol has and which is the least pleasant code in the examples. That is roadmap 6.11, and it is now the next thing to do: a file you cannot take apart is half of file handling.

tests/test_system.c gains seven cases — the round trip, replacing rather than appending, an empty file being a file, bytes surviving with a NUL among them, a 20,000-byte file, the eight ways a bad call is refused, and fileExists declining a directory.

A program can read its input — 4aefa0c, 2026-08-20

Roadmap 6.3. system:readLine answers one line of standard input without its terminator, or nil when there is no more.

line := system:readLine.
{ line:notEquals(nil) }:whileTrue({
    line:display.
    line := system:readLine
}).

Nil at the end is the one place absence is not treated as a mistake here. Everywhere else the language would rather refuse than answer nothing — an out-of-range index is an error, an unset slot is a miss. Running out of input is different: it is how a loop that reads to the end finishes, not something that went wrong, and a program that has to check for it on every pass anyway loses nothing by checking for nil. An empty line is "" and is not the end, so the two never get confused.

Three details that are only visible when they are wrong, and are tested:

The reader is not shared with Solis’, which keeps the newline because its scanner needs it and appends to a buffer that outlives the call. Different enough that sharing would have meant parameterising both.

At the prompt, readLine reads the next line you type and Solis does not see it — the program and the prompt are reading the same input, and there is no third thing for them to disagree about.

Waiting for a single keypress was split out rather than carried along, and is now roadmap 6.10 with a number of its own. It needs raw terminal mode, which would be the first piece of the runtime that behaves differently by platform, and that deserves its own decision rather than arriving as a footnote to line input.

tests/test_system.c gains five cases, driving stdin from a file: examples/reading.sol numbers what it is given and reports the longest line.

A program can stop, and knows what it was given — e8d4fe8, 2026-08-20

Roadmap 6.2. system is a global holding one object — not a class, since there is one process and it has no instances — and it is where what belongs to the program rather than to any value now lives.

system:exit(#0)          ; stop, with a status
system:arguments         ; an array of strings
system:clock             ; monotonic seconds as a float

exit unwinds rather than leaving from under the machine. Every frame is discarded the way an error discards them, control returns through main, and whatever the C library was holding is flushed on the way out. Calling exit(3) from the primitive would have skipped all of that, and would have made a program’s last line of output depend on whether stdout happened to be a terminal. Nothing after the exit runs, including the rest of a loop it was called inside:

[#1, #2, #3]:do({ n | n:print. n:equals(#2):ifTrue({ system:exit(#3) }) }).
#1
#2

That works because the VM already had a flag every loop tests before continuing — the one an error sets. An exit has to unwind through exactly those loops, so it sets the same flag rather than adding a second test to each of them, and a second flag beside it says which of the two reasons it was. The cost was one enumerator, SOL_EXIT, and one ?: at the bottom of the dispatch loop.

A status is #0 to #255, and anything else is an error rather than a number quietly adjusted to fit. POSIX keeps only the low eight bits, so #256 would otherwise leave with 0 and look like success — the quiet mistake the language refuses everywhere else.

arguments needed no primitive. It is a data slot holding an array, because that is what it is: the same array every time, not a fresh one, so system:arguments:equals(system:arguments) is true. It is the empty array when there were none rather than nil, so a program can walk it without first asking whether it is there. solvm hands it over with sol_vm_set_arguments, which builds the array the way every array of fresh values is built here — nils first, so the backing store grows while nothing new is live.

clock is monotonic seconds as a float, and its epoch is deliberately unspecified: the only useful thing to do with two readings is subtract them, and a wall clock can go backwards in between. It is what 6.5 was waiting for.

Everything after the .sob on the command line now belongs to the program, so solvm’s own flags have to come first — solvm --dump prog.sob rather than solvm prog.sob --dump, which now passes --dump to the program. And system:exit works at the prompt for the same reason it works in a program: Solis runs the same machine, so system:exit(#4) leaves Solis with status 4.

tests/test_system.c covers the eight behaviours — the status arriving, nothing running after, unwinding out of both a do and a whileTrue, the six ways a status is refused, the empty default, the strings arriving in order as one array, the clock being a float that does not go backwards, and system being an ordinary object. examples/system.sol is the runnable version. Clean under GC stress, no leaks.

The trailing-block verdict, argued properly this time — 8b4cf3a, 2026-08-20

Documentation. No code, and that is the decision.

a:equals(b):ifTrue{ dosomething } — a lone block argument dropping its parentheses. ideas.md already said no, and said it badly enough to be worth redoing rather than leaving.

The old entry called it a special case: it works for ifTrue and whileTrue and not for ifElse. That misread the proposal. The rule is a lone block argument may drop its parentheses, and ifElse is not an exception to it but outside it, having two arguments. The rule is uniform, and it reaches most of the language rather than a corner: of the ten messages that take a block, nine take exactly one — and, collect, do, ifFalse, ifTrue, or, select, sorted, whileTrue — and only ifElse does not.

The old entry’s other objection was cost, which is also not it. A block cannot follow a send today, so the grammar has room and nothing becomes ambiguous; it is one branch in the argument parser and one in the inlining probe. Nor does “a second spelling for one thing” distinguish it from [...], which is a second spelling for array:of(...) and was accepted, both being byte-identical sugar.

What the entry now says instead:

It makes a message send look like syntax, exactly where the language works hardest to prove it is not one. Every document here says there is no if, and the parenthesised form is the proof of that at every use site. The objection is use-site specific, which is what makes it awkward — on stock:do{ e | ... } nothing is pretending to be syntax — 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 and Hans’s. A reader meeting ifTrue{ ... } in a snippet has no way to see where the rule stops. The next guess is ifElse{ ... }{ ... }, which is not valid and never will be, and the one after that is that braces attach to selectors generally. The cost is not paid by whoever learns the rule properly from the reference; it is paid by whoever infers it from an example and infers something wider than what is there. The parenthesised form has no edge to fall off.

Finished roadmap entries moved to a document of their own — 1feb449, 2026-08-20

Documentation. No code.

The roadmap was 1062 lines and most of it was done. It is now 365, and COMPLETED.md holds the rest — every finished entry as it was written, which is the case for the work before the work was done: what the problem was, what the options were, and why the shape chosen was the one taken. That is not what a changelog entry says. A changelog says what landed and when; these say why it was worth doing, and deleting them would have thrown the argument away and kept only the outcome.

Sections 1 (blocking real programs), 4 (performance) and 5 (tooling) left the roadmap entire, along with 3.9, the settled table from section 2, and 6.1, which was deleted three commits ago and is restored there rather than lost. What stays behind is what is still live: 2.5 and the loose ends in 2.14, section 3’s limitations, and section 6.

Two entries were split rather than moved. 1.1d — collection is stop-the-world and non-incremental — is not work but a standing restriction, so it moved into section 3 instead, joining 2.13 as an entry filed under a heading that is not its number. And 1.1c was still open when 1.1 was written and is not any more, which the entry now says.

Numbers are never reused, which the new document says at the top and is worth stating: the changelog cites them by number, and one number meaning two things at two times would make every one of those citations ambiguous. So the gaps in the roadmap — no section 1, no 4, no 5 — are a record rather than a mistake, and prose that cites 4.1 or 1.6 still points somewhere exact.

nil, empty, and unset, written down — 6d0c43c, 2026-08-20

Documentation. No code.

The question was whether a fundamental type can be made without a value — mystring := string:nil., or myint := integer:nil.. It cannot, and the reason turned out to be worth a page.

> a := string:nil.
solvm: object does not understand 'nil'

There is one nil and it carries no type. nil names the value rather than a class: every other built-in type has a class object bound to a global, and nil has none, so there is nothing for string:nil to reach. Nor would a typed nil have anywhere to live — a name holds a value and never a type, so mystring is not “a string that is currently empty” but a name bound to nil, indistinguishable from one meant for an integer. Which is why what a value is gets asked of the value: isKindOf(string) is false for nil.

absence.md is new, and holds the whole of it: absence against emptiness ("", #0 and [] are values that answer their type’s messages, where nil answers a short fixed list and errors at everything else); the places a nil arrives without being written — a branch that did not run, a loop’s answer, parent at the root, a temporary before assignment; and the asymmetry that catches people, which is that an unset slot is an error rather than a nil:

> o := object:new.
> o:missing:print.
solvm: object does not understand 'missing'

A temporary is a slot in a frame that exists and holds nil, where a slot that was never bound does not exist — so the lookup walks the prototype chain, finds nothing, and reports the miss like any unknown message, because it is the same thing. A prototype with an optional field therefore binds nil as its default, the same defaulting any prototype slot does.

The last section is why a typed null is not wanted rather than merely missing. It would have to answer a value that claims to be a string and answers no string message — the quiet mistake the language refuses everywhere else — and without a checker reading the program before it runs, it would be caught at the same send an untyped nil is caught at. The version worth something is static, and that is a type system rather than a value.

REFERENCE.md gained the rules in short form, the guide a paragraph in §6 where values and references are settled, and the tutorial and index a link. Two example counts were stale after the include commit added two, and now say fourteen.

A program can be split across files — 8922138, 2026-08-20

Roadmap 6.1, and the first item of section 6 to be built. One line brings another file in:

"library.sol":include.

That file is compiled into this one at that point, as though its text had been written there. Nothing above a few hundred lines wants to live in a single file, and until now there was no way to split one.

Spelled as a send to a string because there was nothing else to spend. An include has to happen while compiling, so it is a directive and not a message — but the language has no directive syntax and no keyword to spare, and "file":include already parses. The compiler recognises the shape before the send is emitted. That is also why it may only stand alone as a statement: anywhere inside an expression there is nowhere for a file to go, and it is a compile error rather than a send that would fail at run time. Sent to anything but a string literal, include stays an ordinary selector anyone may define.

The file is found beside the file including it, not beside the working directory, so a program can be moved as a piece. Source that is not a file — the prompt, or a string handed to the compiler — has nothing to be relative to, and uses the working directory. This works at the prompt, which makes include also the way to load a file into a session.

A file is compiled once per compilation, keyed by realpath so that two spellings of one file are one file. C compiles it every time and leaves each file to guard itself, which needs conditional compilation that Solum has not got; and a second copy could only rebind names already bound and repeat whatever the file did on the way. So two files may each include what they need without arranging between themselves who includes what — and a cycle ends instead of recurring, which is the same rule doing the work.

The namespace stays flat. Globals were one space and remain one: an included file’s names are indistinguishable from the including file’s, and two files binding the same name collide exactly as two := in one file already do. A module system is a much larger change to the object model, and a library that wants a namespace can claim one global and hang the rest off it, an object being a namespace already — examples/library.sol does that.

Compile errors name their file now, which they did not need to when there was only ever one:

[lib/broken.sol:2:6] solas: expected an expression at ':'
  y := :.
       ^
  ... included from lib/middle.sol, line 1
  ... included from prog.sol, line 3

The chain is printed by each level on the way out, so it accumulates without anyone holding a stack. Source compiled without a file still reports [line 2:6], exactly as before.

Under the hood: SolParser gained the path it is reading; sol_compile_source takes one and sol_compile is now a call to it with none; sol_read_file moved out of solas’ main into the compiler, which needs it too; and the escape decoding split out of string_literal into decode_string, since an include needs the text of a file name and emits nothing at all. Includes nest 64 deep.

A .sob is still one chunk with no record of which file a line came from, so a run-time trace gives a line number without saying which file counted it. That is the one thing this leaves behind.

tests/test_include.c covers the nine behaviours — definitions arriving, resolution against the including file, the diamond compiling once, a cycle ending, a missing file, an error inside an included file naming both, an include buried in an expression, the no-file case, and include surviving as an ordinary slot name. examples/library.sol and examples/include.sol are the pair, and both compile in the suite. No leaks.

Assessed a notebook of ideas, and the roadmap has a section 6 — 2a348f0, 2026-08-20

Documentation. No code.

Twenty-three ideas from a notes file, each with a verdict in ideas.md and the ones worth building written up as roadmap section

  1. The list had run out last week; this is what replaced it, and it came from a better place than the old one — notes about what a program would want, rather than a plan written before there were any programs.

The largest finding is how little of it needs the language to change. There is no control-flow syntax, so repeat, doUntil, a stepped for and a switch/case are all library code. They are written out and working in ideas.md:

#3:repeat({ "tick":display }).
{ i := i:add(#1) }:doUntil({ i:greaterOrEqual(#3) }).
#1:toByDo(#10, #3, { n | n:display }).
#2:caseOf([[{ n | n:equals(#2) }, { "two" }], [{ n | true }, { "many" }]]).

forIn is do. do is forEach, collect is map, select is filter. ['red, 'green, 'blue] already is an enum, since symbols compare by pointer. Building the loops in would buy inlining rather than expressiveness, which is 6.6 and not urgent.

What is actually missing is everything around the language. A program has to be split across files, read input, write files and stop with a status, and none of that exists. include (6.1) is the item a real program hits first; a system object with exit, arguments and a clock (6.2) is the smallest thing standing between a script and a program.

Two documentation gaps turned up while checking. design.md’s instruction table is missing six opcodesOP_JUMP, OP_JUMP_IF_FALSE, OP_EXIT_IF_FALSE, OP_LOOP, OP_CHECK_BOOL and OP_SYMBOL, which is every jump and the two newest, so it describes the machine as it was before 4.1. And (group) and {block} are introduced separately and never contrasted, which is where the difference lands:

m := { x | x:add(#1) }.
(m:value(#42)):print.        ; #43
{ m:value(#42) }:print.      ; <block>

Six ideas are recommended against, with the reasoning recorded so it does not have to be re-argued: integer widths and a separate 32-bit float, both of which reintroduce the coercion the language refuses everywhere; a JIT, which is possible and would be larger than the rest of the project combined, with nothing to specialise on until there are inline caches; cascades, which Smalltalk needs because its setters answer the argument and Solum does not, since add answers the array; a trailing-block syntax, which can be made uniform but is a second spelling for two saved characters; and Go-style concurrency, which is a rewrite rather than a feature — one heap, one stop-the-world collector, frames in a fixed array on the VM.

One is deferred rather than refused. $character literals cannot be decided apart from what a string is: today a string is bytes, so a character is either a code point — which is the whole Unicode job — or a byte, in which case $😊 cannot exist and the type buys little. Adding an ASCII-only one now would make the later decision harder.

The last item on the roadmap was already done — ee43086, 2026-08-20

Roadmap 5.2 ended by saying sol_value_print prints <object 0x...> instead of sending print to the object, and wants dispatch from inside the printer or a printOn:-style protocol. That was the one concrete thing left on the list.

It had not been true since f55e105, and the entry had been read off the function’s name rather than off what it is handed. print the message goes through prim_print, which has a VM and does send asString — which is what 5.2 was, and why an object defining asString is shown that way by print, display, fill and array rendering alike.

sol_value_print had exactly one caller: the disassembler, rendering a pooled constant. And a constant is only ever an immutable scalar — check_constants refuses objects, blocks, arrays, strings, delegates and symbols outright — so there was never a receiver there to ask. Passing no VM was correct by construction.

So there was nothing to build, and the fix is to stop the name inviting the misreading a third time: it is now a static print_constant in bytecode.c beside its only caller, named for what it prints, and value.h is one public symbol smaller.

Sections 1, 3, 4 and 5 of the roadmap are now done, and the list has run out. What is left is 2.5 — smaller than it was, since the single root turned out not to be waiting on it — and whatever the first real program written in Solum asks for.

Why a built-in cannot be subclassed — 6d89cac, 2026-08-20

Documentation. No code.

class-and-instance.md says a value type cannot be subclassed and gives the reason in one line of sol_vm_class_of. That leaves the obvious next thought unanswered: surely this is just a missing constructor, and if integer:new answered an object delegating to integer — the way object:new answers one delegating to its receiver — you would have a subclass and could add to it.

It is a reasonable thought, and for user-defined objects it is exactly right: p := point:new and tip := point:new are the same operation, and which one is a subclass is how you use it. So the document now shows why it does not carry over, by building it. On a throwaway copy, integer:new with no argument answering sol_object_new(vm, vm->integer_class):

a := integer:new.
a:isKindOf(integer):print.       ; true
a:tag := #7.                     ; a real object, slots and all

a:add(#1).       solvm: 'add' expects an integer, got object
a:double := { self:mul(#2) }.
a:double.        solvm: 'mul' expects an integer, got object

It inherits every method name and can run none of them — including a method you wrote yourself, the moment it touches anything inherited. integer’s methods are C primitives that read an 8-byte payload an object does not have, so a built-in class hands down an interface and no implementation. It is the same inert object string:new would have produced, which is why those four refuse.

Two independent things stop it and either would suffice: an unboxed value carries no class pointer, and the inherited methods need the exact representation. A behaviour object per built-in would not help, since #45 would still dispatch by type tag.

One hierarchy: every built-in class delegates to objecta0b0d41, 2026-08-20

No .sob change. #45:isKindOf(object) is true now, and “everything is an object” holds of the type graph rather than only of the slogan.

#45:isKindOf(object):print.            ; true
"s":isKindOf(object):print.            ; true
nil:isKindOf(object):print.            ; true
integer:parent:equals(object):print.   ; true
object:parent:print.                   ; nil   -- the chain ends here

This was believed to need the class-side/instance-side split first, on the grounds that a built-in inheriting object’s new would answer a plain object rather than a value. Two earlier commits had already removed that and nobody noticed: 7ac6be6 gave float its own newinteger and array have theirs — so those shadow object’s; and 1.6 gave every primitive a receiver requirement, so via and parent, the only two messages integer does not already define, are refused for any receiver that is not an object. Roadmap 2.5 is corrected.

So the change is eight lines setting each class’s prototype, plus the one thing that really was in the way.

Four classes cannot make their instances, and now say so. string, symbol, block and boolean have no new of their own and would have inherited object’s, which answers a fresh object delegating to the receiver — for string, an object that refuses every message a string understands. Inert rather than wrong, and no use to anybody. They shadow it:

string:new.
solvm: a string is written as a literal, not made with 'new' -- "" is the empty one

symbol:new.
solvm: a symbol is written 'name, or made from a string with asSymbol -- not with 'new'

block:new.
solvm: a block is written { ... } and compiled -- there is nothing for 'new' to make

boolean:new.
solvm: there are only two booleans, true and false -- 'new' makes neither

The rule underneath, stated once: new means “make an object delegating to me”, and these four have instances that are not objects delegating to them. That asymmetry is inherent to unboxing rather than a wart, so it is said where each class is defined and object:new stays general. Nothing was built to succeed instead, because there is nothing better for them to do — "" is already the empty string, asSymbol already names its direction, a block comes from the compiler, and there are exactly two booleans. The error is all such a class has to offer here, so it teaches.

Nothing leaked onto the values. #45:parent and #45:via(...) are refused by the receiver check — the work 1.6 did for an unrelated reason, three commits before anyone thought about a root.

The cost is on the miss path only: a send that hits is unchanged, and a lookup that fails now walks object’s thirteen slots before giving up, which measured about 10% over 200,000 failed lookups. That is the path that ends in does not understand.

Three tests in tests/test_object.c: every value and every class answering isKindOf(object) with the chain ending at object:parent, the messages that must stay refused for a value, and the four refusals beside the constructors that still construct.

Extending a built-in, and a single root that was not blocked — 0a17b99, 2026-08-20

Documentation. No code.

Adding methods to a built-in class now has a section in the reference. It needs no new rule — a class is an object and a slot holding a block is a method, so integer:double := { self:mul(#2) } is the same binding as everything else, and every built-in takes them. Written down because nothing said so outside the README’s opening example, along with the two things worth knowing before overriding a message that already exists: the primitive you displace is gone and via cannot reach it, and building the text with fill inside an asString override recurses until the call-depth cap, since fill renders its values by sending asString.

And a correction, from an experiment. This entry and roadmap 2.5 have both been saying that a single root — the built-in classes delegating to object — waits on the class-side/instance-side split, because float inheriting object’s new would answer a plain object rather than a float.

That stopped being true and nobody noticed. 7ac6be6 gave float its own new, so it shadows object’s; integer and array have theirs. And 1.6 gave every primitive a receiver requirement, so the two messages integer would actually inherit — via and parent, the only two it does not already define — are refused for a non-object receiver before they run.

So it was tried, on a throwaway copy: eight lines setting each built-in class’s proto, and the whole test suite passes untouched. Every isKindOf(object) becomes true, integer:parent answers object, #45:add(#1) is still #46, and #45:parent and #45:via(...) are refused by the receiver check — three commits before anyone thought about a root.

What is left in the way is one message. string, symbol, block and boolean have no new of their own and would inherit object’s, which answers an object delegating to the class — inert rather than wrong, since it errors on every message a string understands, but still bad. So the open question is not how do we build a metaclass level but what should new do on a class that cannot construct anything, which is where the document’s closing section already arrives from the other end.

The two are separable. The split is still worth doing for slots and for #45:new(#1); it is not what the root is waiting on. Nothing is committed here but the writing — the experiment stayed in a scratch tree.

Wrote down the class-side question — bb5f077, 2026-08-20

Documentation. No code.

class-and-instance.md is the long version of roadmap 2.5, the one design question still open. It was a paragraph that said integer holds new and print in one object and that separating them “needs a metaclass level”, which is true as far as it goes and leaves out the two things that actually decide the question.

The first is that this is only a problem for the built-ins. A user-defined object has one side and delegation, which is coherent: point:make and point:sum sit together, and an instance seeing both is prototypes working as described. The built-ins are welded by one line — sol_vm_class_of has to hand an unboxed #45 some object to dispatch to, and the only candidate is the object the global integer names.

The second is that metaclasses are the Smalltalk answer to a question this language does not ask. design.md says whether an object is a class or an instance is how it is used, not what it is; a metaclass tower would import a class-based concept to fix something only the built-ins have. The document proposes a smaller shape instead — one behaviour object per built-in, holding the instance side, with sol_vm_class_of returning it — and works through what that fixes, what it would make 1.6’s receiver check redundant for, and the one wrinkle worth designing carefully, which is keeping #45:isKindOf(integer) true when #45 no longer dispatches to the object integer names.

A closing section asks the same question one level down — given that there is a class side, what belongs on it? new turns out to be three operations sharing a spelling: identity on integer and float, an allocation on array, an allocation and a delegation on object. Not even a uniform protocol, since the arities disagree, so nothing generic could send it anyway.

None of the four missing classes wants one. A string is immutable, so unlike an array there is nothing to allocate and fill; symbol:new("foo") is asSymbol under a worse name; a block cannot be constructed at run time at all; and there are exactly two booleans.

The interesting direction is the opposite: integer:new and float:new are the identity function, and they are a vestige of the abandoned design. The original sketch had integer:new(a) followed by a:set(#45) — a mutable integer object you construct and then fill. Numbers became immutable unboxed values, set never existed, and new outlived the model it was for.

And it records a trigger rather than a recommendation to do it now: the symptoms are cosmetic — #45:new(#1) answers, slots mixes the sides — but the single root is not, and integer:isKindOf(object) being false is what should set this going.

The roadmap entry itself was also wrong on a detail and is corrected in 7beb07e: it claimed integer has new where float does not, which 7ac6be6 half fixed. new is on integer, float, array and object; string, symbol, block and boolean have no class side at all.

Compile errors point at the column — 0e48e5d, 2026-08-20

Roadmap 5.4. No .sob change and no change to the language.

[line 2:9] solas: expected '.' between statements at ','
  b := #2 , .
          ^

A line number left the reader scanning the line. The error now names the column, echoes the line, and underlines the offending token — a caret per character, so a misplaced name is underlined rather than merely pointed at.

A token records where it began, not where the scanner stopped. That is the change that matters beyond the printing: a string may span lines, and it used to be reported at whatever line it ran out on rather than at its opening quote.

Error tokens changed shape. error_token used to put its complaint in start — the field that otherwise points into the source — so an error was the one kind of token that could not be pointed at. The complaint moved to a message field, and now start and length locate the offending characters for every token, which is why unterminated string can underline the string it means.

Two details that are easy to get wrong, both pinned by tests:

Runtime errors stay at line granularity, and that is a size question rather than an oversight. A chunk records a line per byte of bytecode; a column would be a second table in every .sob, carried always and read only when something has already gone wrong. Worth revisiting if a debugger ever wants it, and recorded in the roadmap so it stays a decision.

Three tests in tests/test_lexer.c — that every token carries a column locating its first character, that a multi-line string is placed where it opens, and that an error token points into the source — and four in tests/test_compile.c, for the reported position, the caret landing under the token rather than beside it, the tab pad, and the windowing.

Solis reads until the input could compile — edccb90, 2026-08-20

Roadmap 5.1. No .sob change and no change to the language.

> integer:double := {
..     self:mul(#2)
.. }.
> #21:double:print.
#42

A line was never a unit of anything in this language — . separates statements and a newline is ordinary whitespace — so reading one at a time was the REPL imposing a rule the language does not have. A method body spanning three lines used to produce three unrelated errors. Solis now reads until what has been typed could compile, with .. for the continuation prompt.

Two things say the input could still be finished: an unclosed bracket, and an unclosed string. Both outlive a line, so the state carries across them. Counting brackets naively would have been wrong twice over, and neither case is theoretical:

A backslash claims the character after it, so "\"" does not close the string — the same rule the lexer scans by. And a stray closer does not take the depth below zero: a mistyped ) is a mistake for the compiler to report, not a reason to wait for input that could never balance it.

The 1024-byte cap is gone rather than reported, which the entry had asked for as a minimum. The buffer grows, and a line is read in pieces until its newline arrives. That cap caused the confusing session the roadmap recorded — a generated 255-element array literal looked like it had failed to compile when it had merely been severed mid-token, and the tail arrived as if it were the next line. A 5000-byte line now arrives whole, and a test checks the next line is still the next line.

Deciding when input is finished moved to solis/src/input.c so it could be tested, which also gave Solis the cmd/ and src/ split the other two components already had — it was the only one with its entry point in src/. tests/test_solis.c is new: eleven finished forms and seven unfinished ones, the state carrying across lines, a comment hiding a brace only to the end of its own line, a stray closer leaving the depth at zero, and the buffer growing past where the old one stopped.

Not done, and not obviously wanted: a way to abandon a half-typed submission. Ctrl-D at a continuation prompt leaves, and typing the closing bracket gets a compile error, which are two workable ways out. A blank line would be the usual third, but a blank line inside a method body is ordinary formatting here.

The verifier computes stack heights — bf2fffd, 2026-08-20

No .sob change and no change a program can see. Roadmap 3.9, the last item that had real substance left in it.

The machine is a stack machine, so every instruction runs at a definite height: SEND 'add' (1 args) always has exactly two values beneath it, whatever the program computed to get there. Nothing computed that, which left one operand unguardable at load. argc is a byte the file supplies, and whether that many arguments are really present depends on the height — so no structural check could tell a real count from a corrupted one. Fuzzing the loop work found the shape it takes: a send claiming 227 arguments on a stack one deep, reading its receiver from below the frame.

The verifier walks control flow from the entry now, following each branch and carrying the height. The rule that makes it work is the JVM’s: the paths into a point must agree. An instruction reached from two places at two different heights has no height, and that is exactly what corruption looks like.

This came last of the four checks rather than first because it needed the other three. Every opcode’s length has to be known, and every branch target has to be established as an instruction boundary, before a walk that follows jumps can trust where it lands.

Measured over 1,750 single-byte corruptions of one .sob, under ASan and UBSan:

  before after
refused at load 1031 1066
failed part-way through a run 236 208
ran to completion 483 476

The last row is the one worth having. Those seven were corrupt files that passed every check and that the runtime never objected to — they ran, on an inconsistent stack, and produced output. Twenty-eight more moved from failing mid-run to being refused at the door. Load costs about 5% more, paid once.

The runtime check stays, which is where this departs from what the roadmap expected — it had guessed the analysis would let the runtime checks go. It does not, because the two cover different populations rather than one being redundant: the verifier runs when a .sob is loaded, Solis runs what it just compiled without verifying — deliberately, since verifying every REPL line to catch the compiler’s own bugs is the wrong shape — and the C API will run any chunk it is handed. One comparison per send is a cheap floor to keep under all of that. The two tests that used to assert “the verifier lets this through, the send catches it” now assert both ends catch it.

Code no path reaches is never given a height, and is not required to have one: it cannot run. Its operands are still checked by the structural pass, and a jump into it would make it reachable, at which point it is checked like anything else. There is a test pinning that, so it stays a decision rather than a gap.

Five tests in tests/test_serialize.c for the shapes it rejects — branches disagreeing at a join, POP/RETURN/SET_SLOT with nothing beneath them, a back edge arriving one value higher than it left — and one for what it must keep accepting, an inlined loop with and/or and a conditional in it. tests/test_compile.c hands all twelve examples and 29 accepted forms to the verifier, so whatever Solas accepts, the verifier accepts still has a test behind it.

A tutorial, and a site to read it on — 61162cb, 2026-08-20

Documentation. No code, no behaviour change.

TUTORIAL.md is new, and is the third shape the documentation wanted. REFERENCE.md is for looking a message up and GUIDE.md surveys the concepts in order; neither has you writing anything. The tutorial builds one program — a stock report — from an empty file to a working thing, introducing each idea at the moment it is needed rather than because it comes next in a list. By the end it has used objects and slots, methods and self, blocks, parameters and temporaries, arrays, do/collect/select/sorted, format specs, fill, an object rendering itself, delegation, and via — without ever presenting them as a syllabus.

Two moments in it are load-bearing rather than decorative. The asFloat in self:price:mul(self:qty:asFloat) is introduced by removing it and showing the error, because strict arithmetic is easier to accept once you have seen what it refuses. And the last step overrides one method on a delegating object and then shows that the inherited maker, the report row, and isKindOf all keep working — which is the argument for prototypes made by demonstration instead of assertion.

examples/stock.sol is the finished program, so the tutorial’s claims and a runnable file cannot drift apart. tests/test_compile.c compiles all twelve examples now.

The site is at https://hansolovkarlsson.github.io/Solveig/, built from the markdown already in the repository. There is no generated copy of any document, so a page cannot fall out of step with the file it came from: editing docs/GUIDE.md is editing the Guide page. Three plugins do it — optional-front-matter renders files that have none, which is all of them, since they are read on GitHub too; relative-links rewrites [x](/Solveig/docs/GUIDE.html) to the page it becomes; titles-from-headings takes each title from the first heading.

The first build failed, and the reason is worth keeping. Jekyll runs Liquid over every markdown page, and Liquid’s syntax is {{ }} — while Solum’s fill writes placeholders as {} and escapes a literal brace as {{. So every document that explains fill is a Liquid syntax error, and the sentence that broke it was this changelog’s own description of the escape. Not a typo in one file: a landmine under every document this project will write about templates.

Wrapping the passages in {% raw %} would have fixed the build and broken the files, which are read unrendered on GitHub where the tags would show as literal clutter. The fix is to stop pretending these are templates — they interpolate nothing — so render_with_liquid: false turns Liquid off for pages while leaving layouts alone. That needs Jekyll 4, and the built-in Pages build pins 3.10, so the site is built by a workflow in .github/workflows/pages.yml instead. Two things came with that: the build logs are visible, and the failure above was reported by the Pages API as building for ten minutes after the run had already failed in thirty-five seconds.

_layouts/default.html and assets/css/solveig.css are the whole of the presentation — one layout, one stylesheet, no framework, light and dark both defined explicitly. examples/ is deliberately not excluded from the build, so the links the guide and tutorial make to .sol files resolve to the files themselves. The Gemfile is read by the workflow and by nothing else: make still needs a C11 compiler and nothing more.

A guide, and examples for the concepts that had none — e2ff82c, 2026-08-20

Documentation and examples. No code, no behaviour change.

GUIDE.md is new: a tour of every concept in the language in an order that builds, each section pointing at a runnable example. REFERENCE.md is organised for looking a message up, which is the wrong shape for meeting the language, and design.md answers “why” rather than “what” — so there was nowhere to send someone who wanted to learn it. Seventeen sections, from message sending through to the restrictions worth carrying around.

fetched-methods.md is new: the long explanation of what slotAt gives you, why a fetched method cannot be called as it stands, and what boundTo is for — including the honest answer that most code should reach for { c:bump } instead, and that it earns its place when the method is chosen at run time. It has the comparison against perform and the two things binding deliberately does not do.

Three examples for concepts that had none:

Every snippet in both documents and all three examples was run, and the outputs shown are what the VM prints. That includes the errors quoted in comments, which is where a claim usually goes stale: two were wrong when first written — decimals are for floats is really decimals mean nothing for an integer, and a brace escape was shown through display, which does not have escapes, rather than through fill, which does.

tests/test_compile.c compiles all eleven examples now and hands each to the verifier, so the new ones are covered by the same invariant as the old.

boundTo: calling the method you fetched — be19104, 2026-08-20

No .sob change — a primitive, not an opcode. Roadmap 2.14, the last item that was ahead of the verifier work.

m := point:slotAt('sum).
m:value.                 ; solvm: nil does not understand 'x'

m:boundTo(p):value:print.        ; #7

A slot holding a block is a method, so slotAt is the only way to hold one as a value — and what comes back is unbound, because self is supplied by a send rather than carried by the block. A method written at the top level has self nil, so calling a fetched one asked nil for the receiver’s slots.

It answers a block rather than calling one, which was the decision here. The alternative was valueWith(receiver, ...), running immediately with the receiver as the first argument. Answering a block follows via, which answers a delegating view rather than doing the send — binding and calling are two things, and keeping them two has three consequences worth having:

Any value may be the receiver, since self may be.

Two things it deliberately does not do. It does not lift the frame restriction: the home frame comes across unchanged, so a block that reads it is no freer for being bound — binding chooses a receiver, not a lifetime (3.1). And it does not survive a send. Installing a bound block in a slot makes an ordinary method, and a send supplies its own receiver, which is what makes an installed block a method at all:

b:show := m:boundTo(a).
b:show.                  ; self is b -- the send wins, not the binding

That last one is the one place binding looks like it ought to win and does not, so there is a test holding it there.

No temp root, and the reasoning is worth recording because the rule elsewhere has been the opposite. sol_block_new allocates and so may collect, but the receiver of the send and its argument are both still on the value stack — the dispatch loop drops them after the primitive returns — and the stack is a root. Under SOLUM_GC_STRESS=1 a collection happens between entering the primitive and the new block being registered, so a hundred bindings in a loop under ASan is what would catch that reasoning being wrong. It is clean.

Seven tests in tests/test_reflect.c, and examples/reflect.sol grew a section. Every snippet in the reference was run.

Dispatch by pointer, and a hash over the side tables — 1bc0e56, 2026-08-20

No .sob change, and no change a program can see: same bytes out of the compiler, same answers out of the VM. Roadmap 4.3, which was the last item in section 4.

A send used to strcmp. sol_object_lookup walks a proto chain comparing slot names, and every send did that character by character. Now every slot name and every selector goes through one table on the VM, which answers the same address for the same characters, so the walk compares pointers.

The hash has to be paid somewhere, and the trick is where: a chunk’s name table is resolved through the table once, before the chunk first runs, so it is paid per name per chunk rather than per send. The dispatch loop reads a pointer that is already resolved.

  before after
3M sends in a loop 1.36s 0.74s
1M sends to a user-defined method 0.51s 0.29s
1M sends four levels up a proto chain 0.38s 0.21s

The obvious place to put this was the symbol table, and it was the wrong place. 'foo is already interned, and the roadmap had been assuming symbols would serve. But that table is weak on purpose — 5a15fc9 measured a program interning twenty thousand names taking over a minute with a strong table and running instantly with a weak one, because every collection had to mark every symbol ever interned. Slot names are pointed at by objects and by chunks, which have no way to announce they are done with one, so they would have had to be marked — reintroducing exactly the cost the weak table exists to avoid. So these are a second table, strong, immortal for the life of the VM and freed with it. A symbol is a value a program can drop; a name is the VM’s own atom. Same job, different lifetime, and the lifetime is the reason.

Two lookups now, deliberately named apart. sol_object_lookup compares spelling and is what C callers and tests hold literals for; sol_object_lookup_interned compares pointers. Handing the second one a string that never went through the table would answer NULL rather than fail — an equal string that is not the string — so -DSOLUM_CHECK_INTERNED compiles in an assertion that it did. That is the SOLUM_GC_STRESS bargain: too expensive to leave on, too useful never to run. The whole suite passes under it, and a test pins the silent-NULL shape so it stays known rather than surprising.

The side tables no longer scan

The other half of 4.3, and the half that had begun to hurt. 4.2 raised the tables from 256 entries to 65536 without touching the linear scan that filled them, so interning was quadratic:

  before after
10,000 distinct names and constants 0.43s 0.01s
20,000 1.44s 0.02s
40,000 6.17s 0.04s

A chunk keeps a hash index over each side table. Below sixteen entries there is no index at all and the scan stands — which is where a scan was always cheaper, and is what keeps this from costing memory: a method body, a block, or a REPL line never builds one. That mattered. The first version indexed every table from the first entry and pushed 60,000 REPL lines from 1.9 MB to 2.2 MB, because a two-name chunk was allocating two 64-slot indexes; with the threshold it is 1.9 MB again, unchanged.

Hashing a constant has to fold exactly what same_constant folds, which compares bits rather than values so that -0.0 stays distinct from 0.0 and a NaN still finds itself. The hash reads the same bits, and a test walks both.

The emitted bytecode is byte-identical: every example, and a 20,000-name program, compile to the same .sob as before. Verified past the unit tests by the suite under ASan and UBSan with SOLUM_GC_STRESS=1, the suite again with -DSOLUM_CHECK_INTERNED, and 1,750 single-byte corruptions of a .sob through the loader, which fills the index as it appends.

tests/test_names.c is new: the table, slots sharing one name, the two lookups agreeing on a chain, a chunk resolving, a chunk re-resolving when a second VM runs it, interning either side of the threshold, the constant-hash corners, and a slot’s name outliving a collection of its neighbours.

Inlined and and orde226a8, 2026-08-20

.sob goes to version 10.

x := #3.
x:greaterThan(#0):and({ x:lessThan(#10) }).    ; jumps now, no block, no frame
x:lessThan(#0):or({ x:equals(#3) }).

The last two selectors that short-circuited through a real block. Conditionals and loops were inlined in 54e2ae1 and 0fd9a75; these finish roadmap 4.1, and the jumps were all in place — but they needed one thing the other four did not.

A new opcode, OP_CHECK_BOOL. ifTrue answers nil on the path it does not take and anything at all on the path it does. and answers a boolean either way, and on the long path that boolean is whatever the block said — so the block’s answer is both the reply and something that has to be checked. Neither existing test does that: OP_JUMP_IF_FALSE and OP_EXIT_IF_FALSE both consume the value they branch on. The new one examines the top of the stack and leaves it there, carrying the message name so the complaint is the one the send would have made.

        and:                            or:
          JUMP_IF_FALSE -> false          JUMP_IF_FALSE -> run
          <body>                          CONST true
          CHECK_BOOL                      JUMP -> end
          JUMP -> end                   run:
        false:                            <body>
          CONST false                     CHECK_BOOL
        end:                            end:

The shortcut answers a constant, not the global true or false. Those are ordinary globals and a program can rebind them; reading one would let the short path and the long path disagree about what and answers. A test rebinds both and requires the shortcut to keep answering booleans.

  before after
a two-million-pass loop, mostly and/or 2.31s 1.83s
recursion through an and/or block 31 62

The depth is again worth more than the seconds, and for the reason the earlier entries gave: the block was costing a frame, and the jumps do not. Recursion that runs inside an and now reaches as far as recursion that does not.

Everything else is the shape already established. The restrictions are unchanged — the block must be written on the spot with no parameters and no temporaries, or it falls back to an ordinary send, which still means true:and({ a | a }) is an arity error rather than being quietly made to work. Both forms raise the non-boolean-answer complaint from one function in vm.c, so the inlined form and the primitive cannot word it differently; the primitive’s own message moved there rather than being copied.

Checked three ways past the unit tests: 1,750 single-byte corruptions of a .sob using both messages, run under ASan and UBSan, none of which crashed the loader; the suite under SOLUM_GC_STRESS=1 with both sanitizers; and a hand-built chunk reaching OP_CHECK_BOOL with an empty stack, which passes verification — the verifier still does not compute stack heights (3.9) — and is refused at run time rather than read below the frame.

The six accepted forms this adds to the compiler are in tests/test_compile.c, which now checks 29 of them against the verifier: whatever Solas accepts, the verifier accepts.

A temporary needs a frame, and the compiler says so — a57632c, 2026-08-19

( | t | ... ) declares temporaries of the frame the group sits in. Inside a block or a method there is a frame, and it worked. At the top level of a script there is none — the script’s chunk reserves no slots — and the compiler emitted OP_SET_LOCAL 0 anyway, writing over the bottom of the expression stack.

#1:add(( | t | t := #5. t )):print.

The receiver #1 was sitting in that slot. t := #5 overwrote it, and the answer came back #10 instead of #6 — no error, just arithmetic on the wrong number. Roadmap 1.7.

Refused in the compiler, at the | where the mistake is:

[line 1] solas: a temporary needs a frame, so declare it inside a block at '|'

Both front ends now say that, which they did not before. Compiled, the verifier had always caught it, so sol_chunk_save refused to write the file and reported bytecode is internally inconsistent — true, and useless, since the problem was three tokens of source. Solis never verifies, because it runs what it just compiled and trusts its own compiler, so there the wrong answer simply appeared.

That trust is the larger half. Solis is right to hold it — verifying every REPL line to catch the compiler’s own bugs is the wrong shape — but nothing was checking it was earned. tests/test_compile.c now checks it: every shipped example and 23 accepted forms are compiled and handed to sol_chunk_verify, so whatever Solas accepts, the verifier accepts has a test behind it instead of being an assumption. Anything the compiler learns to accept belongs in that list.

Recovery needed care too. Reporting and returning left the parser on the |, so it resumed inside the group, cleared the panic flag at the . between the group’s statements, and complained again about the ) — two messages for one mistake, where every other error here produces exactly one. The refusal now steps over the declaration list, and a test counts the messages.

Found by auditing REFERENCE.md against the implementation: the reference said declarations may open any group, and they could not.

Side-table operands are two bytes, and constants intern — 9b81fd3, 2026-08-19

A chunk could hold 256 constants and 256 names, because the operands that index them were one byte each. A literal-heavy program stopped compiling well before it stopped making sense: sorting two thousand numbers was not possible without generating them at run time. Roadmap 4.2.

x0 := #1000. x1 := #1001. ... x399 := #1399.
[line 1] solas: too many constants in one chunk at '#1256'    ; was

Every index into a side table is now a big-endian u16. That covers the constant pool, the name table, and the nested-method table — OP_CONST, OP_GLOBAL, OP_SET_GLOBAL, OP_BLOCK, OP_STRING, OP_SYMBOL, OP_SET_SLOT, OP_SEND, and the selector OP_JUMP_IF_FALSE carries. The ceiling is 65536.

Not a CONST_LONG-style pair, which is what the roadmap had pencilled in. The rule 4.1 arrived at was that an opcode should mean something — OP_LOOP is its own instruction because a backward jump is a different thing, OP_EXIT_IF_FALSE because it complains differently. A CONST_LONG means what OP_CONST means and differs only in operand width, and it would not have come alone: nine instructions carry an index, so it would have been nine more opcodes across the length table, the verifier, the disassembler, and the dispatch loop. That is four more copies of exactly the agreement 4.1 collapsed into one.

So width belongs to the operand, under one rule. An index into a side table is a u16; a frame slot, a nesting depth, an argument count stays a u8, because those are bounded by the machine rather than by the source — a frame of more than 255 slots is refused before it runs. Jump offsets were u16 already, so sixteen bits is now the only width the format has, and sol_read_u16 is the one place it is decoded.

The constant pool interns, which it never did. #1 written three times was three slots and is now one; the name table has always worked this way. The loader appends to both instead, for the reason the names already had: a file refers to these tables by position, so folding a duplicate on load would shift every index after it. Constants are compared by their bits rather than by ==, which keeps -0.0 distinct from 0.0 and stops a NaN folding onto itself.

Interning paid for much of the widening:

  before after
constants and names per chunk 256 65536
the eight examples, total .sob bytes 9934 10250
arrays.sol top-level constants 41 12
a tight two-million-pass loop 0.251s 0.252s
the same loop with a conditional in it 0.457s 0.455s
a million sends of a user-defined method 0.159s 0.158s

arrays.sol came out 3.9% smaller. Run time did not move; the extra byte is read by the same helper the jumps already used.

The verifier checks both bytes of every index, so an index of 256 into a table of one entry is caught rather than read as slot 0 — a test asserts exactly that. A 400-constant, 400-name program is compiled, verified, run, written to a file, loaded back, and run again, checking the value bound to the last name: an index that lost its high byte anywhere on that path would answer wrongly rather than crash.

.sob goes to version 9. Files written by an earlier build are refused, as usual.

One thing the old cap was hiding: both tables intern by walking themselves, which costs nothing at 256 entries and is quadratic at 65536 — 16000 distinct names and constants compile in 0.87s, 32000 in 3.52s. The scan was always this shape; the cap meant it could never be reached. Noted in the roadmap against 4.3, which wants the same hash table for dispatch.

What is left at 255 is the argument count, and through it an array literal. That one is not an operand-width problem: a longer literal needs array:new and repeated add rather than a wider argc.

Re-fuzzed: 3302 single-byte corruptions of a .sob, zero sanitizer reports, 35 semantic timeouts of the kind 3.3 describes.

A class object no longer answers its instances’ messages — ab5dd96, 2026-08-19

Two crashes, both reachable from three words of ordinary source, both fixed by one check. Roadmap 1.5 and 1.6.

array:add(#1).      ; was: abort
array:print.        ; was: segmentation fault
array:size.         ; was: #0, read from whatever `array` is not

array is an object whose slots are the messages an array understands, and it answers them itself. prim_array_add then did SOL_AS_ARRAY(self) on the class object, because a primitive reached through a class had always been entitled to assume its receiver’s type. That holds for every instance and fails for the one object that is not one. array:print was the same bug wearing the renderer: rendering asks an object for asString, found the one meant for arrays, and went round again — C recursion, so the call-depth cap never saw it.

Each primitive now records the receiver it needs, and the dispatcher checks before entering it. One check in one place rather than 64 copies of the same if, and both dispatch sites go through it, so perform and the renderer are covered along with OP_SEND.

array:add(#1).
solvm: 'add' expects an array, got object

The requirement is per message, not per class, because a class object is the genuine receiver of some of them — array:of, array:new, integer:new, float:new, and reflection, which reads either side. The installation lists now say which is which, one message at a time:

instance(vm->array_class, SOL_ARRAY, "add", prim_array_add);
any_receiver(vm->array_class, "of", prim_array_of);

That is 2.5 answered in the small. Splitting the two sides into separate objects still wants a metaclass level and is still open; what had to be settled first was which side each message is on.

respondsTo asks the same question the dispatcher does, so it cannot claim a message that sending would refuse: array:respondsTo('add) is now false, and array:respondsTo('of) true. Binding a block over a primitive clears the requirement along with it, so a class can be given messages of its own:

array:describe := { "arrays, in a list" }.
array:describe:display.        ; arrays, in a list

One thing had to move in the renderer. A class object nested inside something being printed — [array]:print — would otherwise have raised the new error from inside a print, which is not the renderer’s business. It now asks only an object that can answer, and shows one that cannot as its address, exactly as it already showed an object with no asString at all.

What is left of 1.5 is that render’s depth counter restarts when the recursion leaves through sol_value_render. That is still wrong in principle and is now unreachable: closing the loop needs a primitive that renders a receiver it did not check, and there is no longer one. Left alone rather than carrying state on the VM for a case nothing can produce.

One comparison per primitive send, which costs 4.0% on the tight loop from the entry below and 2.1% on the same loop with a conditional in it — the first is nearly all sends, so it is close to the worst case.

Both crashes were found by fuzzing the inlined loops (4.1) and are older than that work. The same sweep — 3205 single-byte corruptions under ASan and UBSan — now reports nothing at all, where it had reported these two. Thirty-four runs still time out, which is the spin 3.3 describes and the expected answer. tests/test_class_side.c covers every built-in class.

Inlined loops — 0fd9a75, 2026-08-19

.sob goes to version 8.

whileTrue written literally now compiles to jumps too. There is no block and no frame; the condition is re-run in place, and a backward jump closes the loop:

0005 GLOBAL      0 'i'
0007 CONST       1 '#5'
0009 SEND        1 'lessThan' (1 args)
0012 EXITIFF    13 -> 28
0015 GLOBAL      0 'i'
0017 CONST       2 '#1'
0019 SEND        2 'add' (1 args)
0022 SETGLOB     0 'i'
0024 POP
0025 LOOP       23 -> 5
0028 NIL
  before 4.1 inlined conditionals and now loops
Recursion, plain 30 62 62
Recursion through a loop body 20 30 62
A tight 2,000,000-pass loop 0.53s 0.52s 0.44s
The same loop with a conditional in it 1.44s 1.13s 1.06s

All three builds were timed together on one machine, so the columns compare; the 1.60s in the entry below was measured on another day.

The depth is the result worth having. A level of that second row used to cost three frames — the method, the ifTrue branch, and the whileTrue body — and now costs one, so recursion that happens to run inside a loop reaches exactly as far as recursion that does not. The seconds are worth less than they look: 15% off a loop that does nothing but count.

whileTrue is the awkward one to inline, because its condition is the receiver. By the time the selector has been read, an ordinary compile has already emitted an OP_BLOCK for it. So the compiler now reads ahead over the whole { ... }:whileTrue({ ... }) before compiling any of it. The parser stays single-pass in the sense that matters: it never revisits a token it has already emitted for.

The same two restrictions as the conditionals, and now on the receiver as well — both blocks must be written on the spot with no parameters and no temporaries. whileTrue calls each with no arguments, so a parameter is an arity error that inlining would quietly fix, and a temporary belongs to a frame that inlining would take away. Anything else is an ordinary send. examples/blocks.sol runs the same loop both ways and prints both answers.

Two opcodes, not one. OP_LOOP jumps backward, and is deliberately separate from OP_JUMP so that forward remains the default and the one instruction that can move the ip towards zero is easy to find. OP_EXIT_IF_FALSE tests the condition, and is separate from OP_JUMP_IF_FALSE because the two complain differently: for ifTrue the boolean is the receiver, so a non-boolean does not understand the message; for whileTrue it is what a block answered, which is a different sentence.

{ #1 }:whileTrue({ #2 }).
solvm: whileTrue expects the condition block to answer a boolean, got integer

Both sentences now come from one function, so the inlined form and the send cannot drift apart — the failure 5.3 records, avoided in advance this time. A test captures stderr from both and compares them.

What a backward jump costs the verifier, which was the open question: a verified chunk can now run forever. It is not a new capability. { true }:whileTrue({}) is a legal program, and before this a corrupted file could already spin through a loop built from real sends — the earlier fuzz runs recorded exactly that, as semantic timeouts rather than memory faults. So the verifier does not try to prevent it. It checks that every branch target, forward or backward, lands on the start of an instruction inside the chunk, and stops there. There are tests for a backward target one byte into an instruction, for one before the start of the chunk, and one asserting that a loop jumping to itself is accepted — a spin is a bad program, not a broken VM.

Fuzzed: 3205 single-byte corruptions of a loop-bearing .sob, run under ASan and UBSan. Two sanitizer reports, neither from the jumps and both reproducible from ordinary source — 1.5 and 1.6 in the roadmap. Thirty-four runs timed out, which is the spin, and is the expected answer rather than a fault. The same sweep against the previous commit, 4276 variants, found the argument count fixed below and nothing else.

Instruction lengths are down to one table, sol_op_length, read by the emitter, the verifier, the disassembler, and the tests. There had been four copies, and two of them disagreeing is precisely how a jump comes to land mid-instruction.

Also fixed here, because the fuzzing found it: a send with a corrupted argument count read below the frame. OP_SEND carries argc in a byte, and nothing checked that many arguments were on the stack — a sub claiming 227 of them on a stack one deep read the receiver from 3.6 KB below. Whether a count is honest depends on the stack height at that instruction, which the verifier does not compute (3.9), so the send now refuses to reach below its own frame. Not a new fault: the same fuzzing against the previous commit reproduces it, and the regression test is a stack-buffer-overflow without the check.

Found here and deliberately not fixed here: array:print crashes the VM.

array:print.        ; segmentation fault

Three words of ordinary source, and the REPL goes the same way. Rendering an object asks it for asString; on the class objects array and block that finds the one they define for their instances, which renders the same value again, and the depth render carries restarts at zero each time round. Bisected to f55e105, which is where rendering began asking — it has nothing to do with jumps. Written up as 1.5 with the fix it wants, which is its own commit.

The second report is the same shape by a different route, and also from source:

array:add(#1).      ; abort

array is an object whose slots are the messages an array understands, so sending one to array itself finds it, and prim_array_add then reads the class object as if it were an array. Written up as 1.6. Both wait on a decision rather than on work — 2.5 is the design question under them — so neither is fixed here.

Inlined conditionals — 54e2ae1, 2026-08-19

.sob goes to version 7.

ifTrue, ifFalse, and ifElse written literally now compile to jumps — no block allocated, no frame entered:

0000 CONST       0 '#1'
0002 CONST       1 '#2'
0004 SEND        0 'lessThan' (1 args)
0007 JUMP_IF_FALSE    5 -> 16 (ifElse)
0011 STRING      2 'yes'
0013 JUMP        2 -> 18
0016 STRING      3 'no'
  before after
Recursion depth 30 62
2,000,000 conditionals 1.60s 1.12s

They are still ordinary messages on a boolean, still reachable through perform or with a block held in a variable. Inlining applies only when every argument is a block written on the spot with no parameters and no temporaries — a block with parameters is an arity error when ifElse calls it with none, and inlining would quietly make it work; a block’s temporaries belong to its own frame, and inlining would declare them in the enclosing one where they could collide. Everything else falls back to a real send, and there are tests that the two forms agree on every combination.

The verifier changed, as 4.1 predicted it would have to. Execution is no longer linear, so it records where each instruction begins and checks every branch target lands on one, in range. A crafted target one byte into a send would otherwise have its operands executed as opcodes; there is a test for exactly that. Offsets are unsigned and so forward-only, which is also why verified bytecode cannot loop through a jump — 1798 corrupted variants of a jump-bearing file gave no sanitizer report and no timeout.

The remaining cost in that loop is whileTrue, still a send with a block call per iteration. It needs a backward jump, and is now first on the list.

Sorting — 113745f, 2026-08-19

[#3, #1, #2]:sorted:print.                            ; [#1, #2, #3]
[#1, #3, #2]:sorted({ a, b | b:lessThan(a) }):print.  ; [#3, #2, #1]

sorted answers a new array, like collect and select; nothing sorts in place. With no block the order comes from sending lessThan, so a type that defines one sorts itself, the way fill honours an overridden asString instead of going around it. Mixed types are an error rather than an arbitrary order — lessThan has no coercion to fall back on.

Stable, and tested as such: sorting twice orders by two keys, minor first.

Merge sort, chosen for two reasons past the O(n log n). It is stable. And it cannot be walked off the end by a comparison that contradicts itself — a program is free to write { a, b | true }, and the indices are bounded by the halves rather than by what the comparison claims. A quicksort partition trusting the comparison would not be. There is a test that a self-contradicting comparison loses no element.

The comparison calls back into the VM, so it can allocate and collect mid-merge. Removing the root on the result array gives heap-use-after-free in merge_sort under stress. What makes it safe is that a value is copied into the scratch array and never moved, so until the copy back it is still in the rooted result — an invariant of how merging works, now written down where the next person will need it.

Checked against a reference sort on 2000 runtime-generated values, and under ASan with GC stress on every comparison.

No .sob change: sorted is a primitive, so the format stays at version 6.

Reflection — a7310a7, 2026-08-19

point:slots:print.               ; ['x, 'y, 'show]
p:isKindOf(point):print.         ; true
p:respondsTo('show):print.       ; true
p:perform('show):display.        ; (3, 4)

Five messages, on every type: slots, slotAt, respondsTo, isKindOf, perform. Names are given as symbols, which is what symbols were wanted for.

slots answers own slots in definition order — the slot list is kept newest first, so it is filled backwards. Inherited names are not yours; parent:slots asks about those. The rest search the chain as a send does. A value answers for the class it dispatches to, so #45:isKindOf(integer) holds, and since the built-in classes are objects whose slots hold primitives, integer:slots lists what an integer understands.

Installed in a loop over every class rather than nine times over. That is not brevity: a message that answers what an object understands is wrong the moment one class quietly lacks it.

A fetched method is unbound, and this is documented rather than papered over. slotAt answers the plain block; self comes from a send, so m:value runs with self nil. Fetching is for passing a method around; to call one, send it. Binding a receiver to a fetched block is now item 3 in the suggested order.

Building the slots array interns a symbol per slot, and interning allocates — so the half-built array is a temp root. Removing it gives heap-use-after-free at builtins.c:1562 under stress, which is what the new test in tests/test_reflect.c guards.

No .sob change: these are all primitives, so the format stays at version 6.

Symbols — 5a15fc9, 2026-08-19

.sob goes to version 6.

a := 'foo.
"foo":asSymbol:equals('foo)      ; true  -- the very same symbol

state := 'running.
state:equals('running):ifElse({ "go" }, { "stop" }):display.

An interned name. Two symbols spelling the same thing are the same symbol, so equality is a pointer comparison rather than a walk over characters — which is the whole reason to have them apart from strings, a name being compared far more often than it is read. A symbol never equals a string; asString gives its name.

The intern table is weak, and that mattered more than memory. Measured by disabling the pruning:

  20,000 interned names
strong table did not finish in 60 seconds
weak table instant, 1.7 MB

With a strong table every collection has to mark every symbol ever interned, so the work grows with the total rather than the live set. Pruning runs between marking and sweeping, so the table never names a cell the sweep is about to free — and there is a test that a kept symbol survives a collection and that re-interning afterwards finds the same one back.

This also gives 4.3 its mechanism: interned names are exactly what selector dispatch wants instead of a strcmp per send.

asUppercase and asLowercase91d413c, 2026-08-19

#255:asBase(#16):asUppercase     ; "FF"    -- uppercase hex at last
"Hello, World!":asLowercase      ; "hello, world!"

ASCII letters only, and by explicit range rather than toupper, which follows the C locale: under a Turkish locale toupper('i') is a dotted capital I, so the same program would answer differently on two machines. Predictability is worth more than the locales this cannot serve anyway.

Every other byte passes through untouched, so "café":asUppercase is "CAFé" rather than mangled.

A string with nothing to change answers itself. Strings are immutable, so nothing can tell the difference, and it saves an allocation.

This closes the gap integer bases left — asBase writes lowercase digits, and a case message is a more general answer than an uppercase variant of it would have been.

Also records what the language thinks text is (roadmap 2.13): a string is bytes, size counts bytes, at answers a byte, and "café":size is 5. Real Unicode is a different piece of work, not a larger version of this one.

Integer bases — f4b909d, 2026-08-19

#255:asBase(#16)                    ; "ff"
#255:asBase(#2)                     ; "11111111"
#255:asBase(#16):asString("08")     ; "000000ff"
"ff":asInteger(#16)                 ; #255

A message, not a letter in the format spec. The roadmap had sketched #255:asString("x"), which is exactly what the spec was designed without — a letter that looks like printf’s conversion character and invites a reader to try f and d. A number covers every base from 2 to 36 where a letter covers one, and padding still comes from the spec by chaining.

Digit grouping in format specs — 95074c9, 2026-08-19

#1234567:asString(",")       ; "1,234,567"
1234.5:asString(",10.2")     ; "  1,234.50"
#-1234567:asString(",")      ; "-1,234,567"

, groups whole-number digits in threes, and only those — a sign, a fraction, and an exponent all pass through untouched, so 1234567.891 becomes 1,234,567.89 and 1e20 stays 1e+20.

Two extensions were considered and deliberately not built, both recorded in the roadmap: forcing exponent form ("10.2e"), which the renderer already does on magnitude, and integer bases ("x"). Both reintroduce something that looks like the conversion letter the spec was designed without, and invite a reader to try letters that do not exist.

Format specs — 3524c70, 2026-08-19

asString takes an optional spec:

[align] ['0'] [width] ['.' decimals]

45.8:asString("6.2")     ; " 45.80"
45.8:asString("08.2")    ; "00045.80"
#-45:asString("06")      ; "-00045"
"ab":asString(">6")      ; "    ab"

row := { n, v | "{}{}":fill([n:asString("<8"), v:asString("8.2")]) }.
row:value("apples", 3.5).     ; apples      3.50
row:value("pears", 12.25).    ; pears      12.25

Deliberately smaller than printf:

Put on asString rather than a separate format message, so one message answers “the text of this value” and there is no second one to drift from it. No argument means what it always meant, so display, fill, and array rendering are untouched.

format is now fill4a70ef0, 2026-08-19

Breaking: "...":format([...]) is now "...":fill([...]).

"you have {} apples":fill([#3]):display.    ; you have 3 apples

The behaviour is unchanged. The name was wrong: the placeholders are blanks and the message fills them, whereas format belongs to formatting a single value against a spec — where the value is the receiver, not the template.

"...":fill(...) is a template acting on values; 45.8:asString("5.2") is a value being formatted. Two jobs, and format was the wrong word for the first.

Not replace, which string:replace(old, new) will want.

Formatting a single value is recorded as an open decision (roadmap 2.12). The shape is settled — a spec argument to the existing asString, so one message answers “the text of this value” and there is no second one to drift from it — but the spec language itself is not.

The virtual machine is bin/solvmefbdf2c, 2026-08-19

Breaking: the command changed. ./bin/solum program.sob is now ./bin/solvm program.sob.

The machine has been called SolVM in prose since the project was named, while the program on disk was still solum. Now they agree.

Its own messages agree too — a runtime error reads solvm: rather than solum:, as do the fatal allocation failures in the runtime library.

The sources stay under solum/, and the include paths and SOLUM_* macros with them. solum and SOLVM are the same word in two hands, so the directory keeps the modern spelling and the program the older one. Renaming the tree as well would touch every #include in the project for no gain a reader would feel.

An object is rendered by asking it — f55e105, 2026-08-19

point:asString := { "point({}, {})":format([self:x, self:y]) }.

p:print.                     ; point(3, 4)
[p, q]:print.                ; [point(3, 4), point(0, 0)]
"at {}":format([p]):display. ; at point(3, 4)

One definition serves print, display, format, and an enclosing array, because the renderer sends asString rather than reaching for a pointer.

The seam had to move: sol_value_render now takes a VM, which may be null. The disassembler passes null — its constants are never objects — and falls back to the address, which is also what an object without its own asString shows.

The recursion this invites is cut at the source: object’s default asString writes the address directly instead of calling the renderer back. An asString a user writes to render itself still recurses, but through real frames, so it stops at the call-depth cap like any other runaway recursion.

Fixed: error recovery could loop forever — f55e105

synchronise checked whether the previous token was a . before advancing, so a statement that failed without consuming anything — primary reports an unexpected token without taking it — was retried forever when the token before it happened to be a ..

b := { #1. | q | q }. produced three million identical error lines in three seconds. Recovery now advances before testing, so it always consumes at least one token.

Pre-existing, and found by a typo in a test rather than by looking for it. Six malformed inputs that used to hang are now regression tests.

String escapes, and displayc04cdca, 2026-08-19

q := "she said \"hi\"".
q:print.                                     ; "she said \"hi\"" -- literal form
q:display.                                   ; she said "hi"      -- the text
"you have {} apples":format([#3]):display.   ; you have 3 apples

\", \\, \n, \t, \r. An unknown escape is an error rather than a literal backslash, so a typo is caught where it is written. There is no \0: the chunk’s text table is NUL-terminated in memory and one would truncate the string.

The scanner only learns that a backslash claims the next character, so that \" does not end the string. Which escapes are legal is decided once, in the compiler, where they are decoded.

Rendering puts the escapes back, or a string holding a quote would render as text that no longer reads as one string. A rendered string now compiles back to the same string, the same round-trip floats hold to.

display was the gap escapes exposed. print shows the literal form, which is right for reading a value back but wrong for output — a formatted string could only be shown wearing quotes, and a string with newlines could not be written as lines at all. display sends asString and writes those characters raw. Every type answers it.

Float exponents, and text that reads back — c8cef1b, 2026-08-19

a := 1.5e-3.  b := 1e308.        ; exponents scan now
1234567.0:print.                 ; 1234567   -- was 1.23457e+06
1.0:div(3.0):print.              ; 0.3333333333333333
infinity:print.  nan:print.

This was a correctness bug, not only a cosmetic one. %g gives six significant digits, so 1234567.0 printed as 1.23457e+06 — a different number — and asString baked that into a string. Printing could quietly show the wrong value.

The fix caught a drift it was meant to prevent: prim_float_as_string had its own snprintf("%g") instead of using the renderer, so print and asString disagreed about the same value until it was routed through.

Tested by rendering fifteen awkward doubles, feeding the text back in as source, and requiring the result to be bit-identical.

. is required between statements — be13b07, 2026-08-19

Breaking, though nothing in the repository changed: every example and test already wrote the dots.

. separates statements rather than terminating them — required between two, optional after the last:

a := #1
b := #2          ; solas: expected '.' between statements at 'b'

a := #1. b := #2 ; fine, the last needs none

This is what groups and blocks already enforced. The top level accepted its absence anywhere, which meant a missing separator could never be reported, and the same code stopped compiling merely by being moved into a method body.

Groups and blocks now name the missing separator as well, where they used to complain about the closing bracket and send the reader looking in the wrong place.

It does not catch everything. A line beginning with : continues the expression above it, so total := #10 followed by :add(#5). is genuinely one statement with no separator missing. Only a newline-sensitive rule would see two, and this is not that language. There is a test pinning the behaviour so it stays a known limit rather than a surprise.

Formatted output — ca1369b, 2026-08-19

"you have {} apples and {} pears":format([#3, #4]).

{} takes the next value and renders it by sending it asString, so a type that overrides asString is honoured rather than bypassed:

point:asString := { "point(":concat(self:x:asString):concat(")") }.
"the answer is {}":format([p]).        ; "the answer is point(7)"

This needed sol_vm_send, a way for a primitive to call back into the language. That also unblocks a better default print (5.2), which wants to send print to an object rather than showing its address.

The remaining operations — 7ac6be6, 2026-08-19

x:greaterThan(#0):and({ x:lessThan(#10) }).   ; short-circuit
"abc":lessThan("abd").                        ; strings order
#-5:abs.  #5:negated.  #1:notEquals(#2).
[#1, "a", [#2]]:asString.                     ; "[#1, \"a\", [#2]]"

Also records formatted output (2.11) as an open decision: building a sentence is currently a chain of concat and asString, workable for two pieces and unreadable for five.

Conversions — 246ae8e, 2026-08-19

"you have ":concat(#45:asString):concat(" apples").   ; "you have 45 apples"
#7:asFloat:div(#2:asFloat).                           ; 3.5
2.7:floor. 2.7:ceiling. 2.7:rounded. 2.7:truncated.   ; #2 #3 #3 #2
"45":asInteger.  "2.5":asFloat.

This also fills the gap floored division left: #7:div(#2) is #3, and #7:asFloat:div(#2:asFloat) is 3.5.

via: calling the method you override — a5aa9e0, 2026-08-19

animal:intro := { "I am ":concat(self:name) }.
dog:intro := { self:via(animal):intro:concat("!") }.

rex := dog:new. rex:name := "rex".
rex:intro.        ; "I am rex!"

Before this, an override could reach the ancestor’s code but not with the right receiver — naming the ancestor sends to it, so self inside became the ancestor and rex:intro answered "I am animal!". An overriding method could therefore only extend one that never consulted self.

self:via(ancestor) answers a delegating view: a send to it begins the lookup at the ancestor and runs what it finds with self still the receiver.

User-defined objects — d27176f, 2026-08-19

point := object:new.
point:x := #0.                          ; a default every instance sees
point:sum := { self:x:add(self:y) }.    ; a method: a slot holding a block
point:make := { a, b | | p | p := self:new. p:x := a. p:y := b. p }.

p := point:make(#3, #4).
p:sum:print.                            ; #7

One primitive — object:new, answering a fresh object that delegates to the receiver. That was the whole gap: slot assignment, proto-chain lookup, and block-in-a-slot-is-a-method already existed, so this needed a primitive rather than a mechanism.

The built-in classes deliberately do not delegate to object: float inheriting its new would answer a plain object rather than a float. That leaves two hierarchies that do not meet, which is the class-side/instance-side question in the roadmap.

Division — 9ad8039, 2026-08-19

div and mod, on integers and floats.

#7:div(#2):print.     ; #3
#-7:div(#2):print.    ; #-4   floored, not truncated
#-7:mod(#2):print.    ; #1    the divisor's sign, not the dividend's

Also recorded two gaps found while checking the above: float literals have no exponent notation (1e308 does not scan), and print emits float text the scanner cannot read back (1e+256, inf).

Strings — e454192, 2026-08-19

s := "hello".
s:concat(", world"):print.        ; "hello, world"
"hi":equals("hi"):print.          ; true
["ada", "grace"]:collect({ n | n:concat("!") }).

SolString and the string class: print, size, equals, concat, at.

Left open, each independent: escape sequences, interning, ordering, and conversions to and from numbers.

collect and select — b9b9702, 2026-08-19

[#1, #2, #3, #4, #5]:collect({ x | x:mul(x) }).      ; [#1, #4, #9, #16, #25]
[#1, #2, #3, #4, #5]:select({ x | x:greaterThan(#2) }).  ; [#3, #4, #5]

Both answer a new array and leave the receiver alone, so they chain into a pipeline that reads left to right.

These are the first primitives to need a temporary root, and it turned out to be load-bearing rather than cautious. They allocate a result array and then call a block per element, and a block can allocate; between calls the result is reachable only from a C local. Removing the root and running under SOLUM_GC_STRESS=1 with ASan turns the loop into a heap-use-after-free in sol_array_add — the result is swept while it is still being filled.

select appends each element before testing it and winds the count back on rejection, so the element is never held only in a C local across a block call.

select is strict about its block answering a boolean, as whileTrue is.

Array literals — 63749ee, 2026-08-19

xs := [#1, #2, #3].
n := [[#1, #2], [#3]].
e := [].

[...] is sugar for array:of(...) in the strict sense: the two forms compile to byte-identical .sob files, and a test asserts it rather than trusting the claim. Two lexer tokens and one compiler branch — no new opcode, no verifier change, nothing the VM has to learn.

Because the desugaring is real rather than a lookalike, the array it sends to is the ordinary global; rebinding that name moves both spellings together. They cannot drift apart, which is the point.

A literal is a construction, not a pooled constant, so every evaluation answers a fresh array — two calls to a method containing one do not share it. Capped at 255 elements by OP_SEND’s one-byte argument count.

Rejected: making [...] immutable so it could be pooled. Pooling would only ever apply where every element is itself a compile-time constant — [a, b] must be built at run time regardless — so the price is a rule the reader has to re-check at every use site, for a saving that most literals would not get. Two spellings mean one thing, which is the principle this was weighed against.

The project is named Solveig — 7db2b27, 2026-08-19

The repository had no name distinct from its parts: “Solum” was serving as the project, the virtual machine, and the language at once.

Solveig now names the project. The language stays Solum, and the programs stay Solas, SolVM, and Solis. Old Norse Sólveig, from sól “sun” and veig, usually read as “strength” – the Norse cousin of the sol- root the rest of the family already shares. The README carries the longer note, including why SolVM and solum are the same word: classical Latin wrote V where we now write U, so Roman inscriptions give SOLVM.

Documentation only. No code, no file names, and no behaviour changed.

Arrays — 1d8c573, 2026-08-19

Nothing in the language could hold more than one value, so no program could accumulate a result.

a := array:of(#10, #20, #30).
a:at(#1):print.              ; #10 -- indices are one-based

b := array:new.
b:add(#1):add(#2):add(#3).   ; add answers the array, so it chains
b:do({ e | sum := sum:add(e) }).

Still to come: the [...] literal sugar, and collect/select.

Roadmap audit — 470c6d3, 2026-08-18

No code change. Audited the roadmap against the source and against everything raised in review, and added the two real gaps it was missing:

Also corrected a comment in the compiler claiming there were no blocks yet.

The collector owns compiled code — 104a5e0, 2026-08-18

Solis no longer retains every line’s chunk. Over 60,000 REPL lines, peak resident set went from 25.5 MB growing linearly to 1.9 MB flat.

Ownership is dual rather than wholesale, because Solas has no VM to own a chunk on its behalf. A chunk from sol_chunk_init is caller-owned and freed by hand; one from sol_code_new belongs to a SolCode cell the collector sweeps. sol_chunk_add_method propagates ownership as each subtree is added, so a caller cannot forget to.

A garbage collector — 29d011a, 2026-08-18

Mark–sweep, non-moving, stop-the-world. Objects and blocks are reclaimed while a program runs; before this nothing was freed until the VM exited.

The motivating case — a block literal allocated once per loop iteration — over two million allocations:

  Peak RSS
before 98 MB, growing linearly
after 1.5 MB, flat

Code is still owned by the chunk that compiled it, so Solis continues to retain every line’s chunk; that is roadmap 1.1b.

:= became one operator — 7029d27, 2026-08-18

Breaking: method definitions changed shape, and .sob went to version 4.

:= used to mean two different things depending on what stood to its left. In a := #45:add(#32) it evaluated the right-hand side; in integer:fun() := #45:add(#32) it did not — that was a definition form the compiler pattern-matched, whose right-hand side was compiled to run later, freshly, on every call.

Now there is one rule: obj:name := value evaluates and binds, exactly as a := value does.

integer:double := { self:mul(#2) }.
integer:poly := { a, b | self:mul(a):add(b) }.
integer:quadruple := { | d | d := self:double. d:double }.

Method temporaries must be declared — 343d776, 2026-08-18

Breaking: bodies that relied on implicit locals need | ... |.

Fixes a real defect. Assignment inside a method used to declare a local for any new name, so a global could not be updated from a method at all, and because the local was declared before its own initializer was compiled, counter := counter:add(#1) read the fresh nil local and failed with “nil does not understand ‘add’”.

Documented what verification does not promise — be7fdca, 2026-08-18

Verification guarantees a loaded chunk is safe to execute; it does not guarantee the program terminates, and it should not. Established by fuzzing rather than assumed: every hang seen while corrupting a .sob mapped to a constant payload or code byte, never to a name, count, or length the loader parses, and a control run over a program with no loop produced zero hangs.

Blocks, booleans, and message-based control flow — 284d015, 2026-08-18

.sob went to version 3.

#5:lessThan(#10):ifElse({ #100:print }, { #200:print }).
{ i:lessThan(#5) }:whileTrue({ i := i:add(#1) }).

Methods, call frames, and locals — dd31244, 2026-08-18

.sob went to version 2.

Methods could be written in Solum source rather than only as C primitives, and the VM grew call frames. A frame’s slots point into the value stack at the receiver, so nothing is copied to make a call. Solis began retaining every line’s chunk, because a class holds only a pointer to a method the chunk owns.

The .sob bytecode file format — 2b2bea2, 2026-08-18

Solas writes bytecode to a file and Solum loads and runs it. Little-endian and host-independent; floats survive bit-identical; line numbers are run-length encoded.

A .sob file is treated as untrusted input: the loader bounds-checks every read and rejects a count that could not fit in the bytes remaining, and what survives is verified before it can execute — every instruction fits, every operand indexes something real, and the final instruction stops the machine so the dispatch loop cannot run off the buffer.

Initial commit — 52f2f01, 2026-08-18

Solas (compiler), Solum (VM), and Solis (REPL) as three components sharing one static library, with solum/include/solum/bytecode.h as the single contract between compiler and VM.

Design decisions taken here: a name is a binding rather than an object and values are immutable; # is a type tag, so #45 is an integer and a bare 45 is a float; arithmetic is strict and integer overflow traps rather than wrapping.