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 path — readLine 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 yet —
sort 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.
A link whose text wraps is a 404, and make test catches it now — e27650e, 2026-09-02
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 condition – a 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
- 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 versionwith 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.
A link that names a heading, and the two faults found on the way — 1610eb4 and 5456f4e, 2026-09-01
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 trigger — a
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 here — performance.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 -f — f30bc5d, 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
them — of 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 endsWith —
server.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
- 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_body — the 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 at — no 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 with — 30db427, 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 2 — was
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
prose — string 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’s
— Call, 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 parameter — g%() 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
written — Lexical 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.
PRINT USING, measured before it was written — 078bd92, 2026-08-26
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.
DIM SHAREDon a plain variable did nothing — the flag was recorded only for arrays, so every procedure quietly got a local of its own.- A procedure zero-initialised the module’s shared variables, replacing what
the module had put there. A
SUBthat assigns survived by luck of ordering; aFUNCTIONthat only reads saw nought. - A
FUNCTIONof no arguments called without brackets was read as a variable — the same defect the oracle found inRND, one level up. And the name of a procedure holds the procedure, so the walker deciding which names want a nought was overwriting the block with0.
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 now —
solvm 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.5 —
GOSUB 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 ¬anentity; 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:
since(#start)—takeWhiledescribes a run of one kind of character. JSON’s number is a sign, then digits, then perhaps a fraction and an exponent, and what the caller wants at the end is all of it.posis the mark, and there is nomarkmessage because it would do nothing readingposdoes not.take(#n)—\uXXXXwants exactly four characters, which no predicate describes.
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.
- The context cannot satisfy the block’s claims. How much it writes on its own is known before the block is appended, and only what came after is read.
- A complaint is read wherever it lands. With stderr merged the streams
interleave by buffering rather than by source, so taking the context’s line
count off the front does not take the context’s lines off the front. It
left
solvm: undefined name 'animal'in the part being skipped, and nine blocks were accepted as having run when they had produced nothing. - The program has to say it reached the end.
system:exitunwinds, which is documented behaviour with a block of its own on page 801 of the reference — and that block joined the context, after which the page’s context was a program that exited before reaching anything, for ninety blocks. A line printing a word nothing else prints is appended, and if it does not come back the context does not grow.
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 question —
emit.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:
- Names are interned when the instruction mentioning them is emitted, so the name table’s order is the order the code refers to things.
- Constants are shared by value and type —
#45and45are two entries. Keying them by text alone yields a program that pushes an integer where a float was written: it runs, it is wrong, and only a byte comparison notices. That mutation is what the test was checked against. - Line runs count bytes rather than instructions.
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:
- Writing an i64 is easier than reading one, which is not the direction
anybody would guess.
disasm.solcarries careful arithmetic because reconstructing the top byte by shifting it into bit 63 overflows and integer arithmetic traps here; going out,shiftRightis arithmetic, so masking after it is right for negatives as readily as positives. - The verifier catches a bad emitter. A
.sobis untrusted input, so one corrupted opcode byte gets bytecode is internally inconsistent and exit 65 instead of a crash — a whole class of back-end bug arrives as a message. - A float constant is the one thing not yet writable. Nothing reinterprets
a float’s bits as an integer, so
readFloatindisasm.soltakes a double apart field by field and the emitter needs that inverted. Laborious, not blocked, and nothing compiled so far has a float literal.
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:
- Twenty fixed iterations of Newton’s method — right to twelve places at 2, and
sqrt(1e10)answered100000.000156. - Then, written to fix that, a loop running until the answer stopped moving with
a cap of sixty steps. The cap is needed, because in floating point Newton can
oscillate between two adjacent values rather than settle. But a value above
about 1e21 has not finished halving in sixty steps, so the loop returns
xdivided by 2^60:sqrt(1e300)answered8.67e281rather than1e150. Nineteen orders of magnitude, from the corrected version, silently.
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
sqrta 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.15 — run 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:
- The surface is unsupported, not absent.
embed.hsays it is the whole supported surface and that everything else undersolum/includemay change without notice — andSolPrimitiveis inobject.h, outside it. - There is no ABI version.
SOLUM_VERSIONis printed and compared to nothing; the only version check in the project is.sob’s. And there is a concrete build blocker:libsol.ais static, nothing is-fPIC, nothing is exported, so a loaded bundle could not resolvesol_*back intosolvmat all as built. - The collector has no finalizer of any kind, so a foreign handle would be
never freed and never counted against
--memory. The shape that answers it is a foreign cell carrying its own release function — and that shape dissolves an otherwise ugly interaction, because the whole heap is freed at VM teardown, so a release hook fires even for a program a limit stopped uncatchably.
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
.sobit 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 capability — sol_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.
- The allowance really is per run.
sol_vm_runresetssteps_remainingfromstep_limit, written for a server handing one machine a request and then another, and never before given a second run to prove it. Two deliberately starved requests stop and the ones on either side do not. - The numbers to set a limit from: a request costs 393 to 798 instructions and about 15KB live.
- There is no route for the answer, which is the gap that matters. A
script’s output goes to stdout because
displaywrites there and nothing else exists; a webserver needs the page as a value.sol_object_lookuponvm->rootandsol_value_renderdo it, but a host must assemble them, know the value dies with the VM, and agree a global name with the script by convention with nothing checking that they do. - A fresh VM per request is the only safe choice, since globals are one flat namespace and nothing unbinds them — and it costs rebuilding the interned names and the built-in classes every time.
- 3.6
has its second case.
sol_chunk_freeaftersol_vm_free, never before. The entry said the hazard bites code mixing caller-owned chunks with a long-lived VM, “which today is the test suite”; it is a host as well, and a host is where it turns up in somebody else’s program.
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 | 3 — walk, 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.
valueswent intoexamples/dictionaries.sol, whose own prose already said “keys and values answer arrays” in a paragraph that then sent onlykeys.modeOf,setMode,setModifiedAtwent intoexamples/files.sol, under a new section on what a file is besides its contents. Writing it turned up thatplusSecondswants a float, which the example now says.
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.sol → library.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 operations — bitAnd, 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:
- 6.27 — a trace names
lines and not files, so a failure inside a library reads as though it were in
the file you are looking at. Misleading rather than merely thin, and worse
with four libraries. A
.sobformat change, and it improves every error rather than only a debugging session. - 6.28 — slots are
indices, so anything inspecting a running program shows
slot 3and notcount. The same kind of side table; it should ride along with 6.27. - 6.29 — much the largest, wants 6.28 first, and
worth weighing against
solis, which already does the interactive half of what a debugger is for.
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 messages — readKey, 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 now — true 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 modifiedAt — eac07ab, 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:
- 6.25 —
makeDirectoryis an error when the directory is already there, so make sure this exists is a test and a make. Every script that writes anywhere will carry the same three-line block this one does. - 6.26 — a copy
loses the executable bit, because
readFileandwriteFilecarry bytes and nothing else.-rwxr-xr-xin,-rw-r--r--out. For a language aimed at scripting an OS that is a floor rather than a nicety: a backup of anything holding scripts will not run.
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:
- A half-typed line survives browsing. Step away with up and what you were writing is kept, so down brings it back rather than an empty line.
- The same line twice running is one entry, because re-running something to watch it fail again should not mean pressing up twice to get past it.
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:
- Keys sent before solis starts are lost, because raw mode is entered with
TCSAFLUSH, which discards input already received. The test waits for the prompt — written from inside the reader, after the mode change — before sending anything. - Assert on what ran, not on what the screen shows. The first version
matched painted text, and since the editor paints every keystroke, looking for
keptafter typing"kept":display.matched the painting rather than the running and drifted a keystroke out of step. The values are chosen now so the output never appears in the input: nothing in#100:add(#5):print.spells#105.
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 now — new, 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 order — lessThan 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.
@onceis already the behaviour, unconditionally. C needs it because C compiles an included file every time it is reached; Solum compiles it once, keyed by where it lands on disk. There is nothing for the directive to switch on.@definehas three jobs: a named constant, which is a binding holding a value rather than a name standing for text; a function-like macro, which is a block — and the one thing macros have over functions in C, arguments that are not evaluated before the call, is exactly what a block is; and compile-time computation to save run-time cost, where the answer here has consistently been to measure and make it a primitive, which speeds up every caller rather than the ones who remembered.@ifdefis the arguable one. Platform differences are absorbed by the VM in C. Debug builds can readsystem:environment. Feature detection is the real use, andrespondsToalready answers it at run time with no second language:"A":respondsTo('asByte)istruehere andfalseon an older build. What conditional compilation would cost is out of proportion — a second language with its own scoping, and text on the screen that is no longer the program.
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 taken — There 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 asCharacter — 49b0ab1, 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 text —
lib/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 language is settled enough to write programs in. It has been stable
for the whole of the work leading here, and everything added has been
additive except two deliberate breaks, both before this line:
@includegained its@, andnewcame off the classes that construct nothing. .sobfiles are version 11 and are not portable across versions. A file from an older build is refused withunsupported bytecode versionrather than misread, and there is no compatibility promise between releases — recompile the.sol. See ROADMAP 3.4.- 0.1 rather than 1.0 because the restrictions in ROADMAP section 3 are real and deliberate: no non-local return, capturing blocks tied to their frame, recursion to about 62 levels, text is bytes, and no way to recover from an error. Each is documented where a program would meet it.
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 binary — bin/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 notNil — 10ddf25, 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:
- binding.sol for names and binding, and for statements, groups and temporaries — the plumbing every other example uses without stopping to look at it.
- strictness.sol for errors and strictness: every refusal with its real error text and what to write instead. It ends by failing on purpose, three frames deep, because a stack trace is the one thing in that section no working program can show you.
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 six — OP_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:
- A last line carrying no newline of its own still counts as a line.
\r\nis one terminator, so a file written on another system reads the same as one written here.- A line of any length comes back whole. It is read in 256-byte chunks and grown, which is the bug Solis had before 5.1 — a long line severed mid-token, its tail arriving as if it were the next line.
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
- 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 opcodes — OP_JUMP, OP_JUMP_IF_FALSE,
OP_EXIT_IF_FALSE, OP_LOOP, OP_CHECK_BOOL and OP_SYMBOL, which is every
jump and the 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 object — a0b0d41, 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 new — integer 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:
- The pad before the caret is built from the line’s own characters, so a tab in the source is a tab in the pad and the two line up whatever width the terminal gives it. Spaces would drift the moment anyone indented with tabs.
- A long line is windowed around the token rather than spilled whole. That
matters more since
5.1: Solis will now read a line of any length, so an error in a 5000-character line would otherwise have printed all of it.
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 brace inside a string is not a bracket.
filltemplates are made of braces, so"{}":fill([#1])would have hung the prompt waiting for a close. - A
;comment runs to the end of its line, so a{inside one is text. It counts again on the next line.
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:
- numbers.sol — the two numeric types and why the literal says which, strict
arithmetic, trapping overflow, floored division and what it does to
mod, the narrowing messages, bases, and floats that read back. - format.sol —
printagainstdisplayagainstasString, the format spec, digit grouping,fill, and an object rendered by asking it. It builds a column of figures, which is what the spec was designed for. - values.sol — values against references, head-on: what
equalsmeans for each, whata := bdoes, and why the split falls where it does. Mutability is what makes identity matter; if a thing cannot change, equality can be about contents instead.
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:
valuegoes on meaning exactly what it meant, arity included. The arguments are the block’s own and the receiver is not among them, som:boundTo(#10):value(#3, #7)has no ambiguity about which is which, wherevalueWith(p)on a one-argument block would have been an arity error that reads like a one-argument call.- A bound method is a value. It can be passed around, and bound once and called many times.
- The original is untouched, so one fetched method binds to each of several
receivers in turn:
[p, q]:collect({ e | m:boundTo(e):value }).
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 or — de226a8, 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 asLowercase — 91d413c, 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.
- The most negative integer converts like any other. Its magnitude is taken unsigned, so there is no negation to overflow.
asInteger(#n)reads it back, and every base round-trips — there is a test walking 2 through 36 over a set of values includingINT64_MIN + 1.- Strict: no
0xprefix, no leading whitespace, and a digit outside the base is an error rather than a truncated parse.strtollwould have accepted the first two. - Digits above nine are lowercase. Uppercase hex wants
asUppercaseon strings, which is a more general thing to have than a second base message, and is now the noted gap.
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.
- Fixed at
,. A separator that varies by locale is a much larger door to open than a report column is worth. - Cannot be combined with zero fill. The leading zeros would not themselves
be grouped — Python renders that as
001,234.50— which reads as a mistake, so it is refused rather than produced. - The flags have one order, so there is one way to write a given spec.
- Grouping belongs to numbers; asking a string, boolean, nil, array, or object for it is an error.
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:
- No conversion letter. The receiver knows its own type, so there is nothing that could contradict it.
- No sign mode. A leading space for a positive number falls out of the width, numbers aligning right — which removed a whole mode from the design.
- Numbers align right and text aligns left;
<>^override. - Decimals belong to floats. Asking an integer, string, boolean, or array for them is an error rather than a no-op.
- Zero fill must align right — padding a number on the left with zeros would change what it says — and goes after any sign.
- A value wider than the width is never cut. Losing digits would be worse than a ragged column.
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 fill — 4a70ef0, 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/solvm — efbdf2c, 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 display — c04cdca, 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.
- A float now renders as the shortest decimal that reads back as the same bits, found by trying increasing precision until the text parses back identically.
- Shortest is not always clearest, so where a number has few enough whole digits
the renderer keeps
%gin fixed notation:1000rather than1e+03. More digits can never stop it round-tripping. - Exponent notation scans:
1e3,1E+3,1.5e-3. A bareeis left alone rather than claimed, so1eis a float and an identifier — which the statement rule then rejects, a clearer failure than a malformed number.#is exact, so an integer takes no exponent. - Infinity and not-a-number are written by name, and
infinityandnanare now globals, so those two read back.-infinityhas no literal form;asFloatparses it.
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)"
- Both directions of mismatch are errors. Too few values and too many are each reported with the counts. Filling a gap with blanks, or dropping extras, would turn a mistake into output that looks deliberate.
{{writes a literal brace.}is never special and needs no escape, so}}is two of them — unlike Python, where}closes a placeholder that can carry content. Here a placeholder is exactly{}, so one escape rule is enough.- Kept as its own message rather than an argument to
print, soprintgoes on meaning one thing and the text can be used without printing it.
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]]"
andandortake a block, so the answer can be settled without running it — the same shape asifTrue, and the reason they cannot simply take booleans. Strict about what the block answers, aswhileTrueis.notEqualsis defined as the negation ofequals, so it inherits whatever equality means for each type: by value for strings, by identity for arrays.- Strings order by characters, shorter first when one is a prefix — what
sorting will want.
lessOrEqualandgreaterOrEqualon numbers and strings. negatedandabs, trapping on the most negative integer, which has no positive counterpart — the same edge that guardsINT64_MIN div #-1.float:new, for symmetry withinteger:new.- Rendering moved into one place, a text buffer in
value.c, soprintand a composite’sasStringproduce the same text by construction rather than by two implementations agreeing.
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.
asStringanswers plain text;printshows the literal form.#45:asStringis"45", not"#45"— the point of it is building text, and “you have #45 apples” would be wrong. Two jobs, kept apart, as Smalltalk separates displayString from printString.- Narrowing names its direction. There is no
asIntegeron a float:floor,ceiling,rounded, andtruncatedeach say what they do, so there is no default to remember. Each can fail, since most floats have no integer counterpart — infinity, not-a-number, and anything out of range are errors. - Parsing is strict at both ends. The whole string must be a number and
nothing else, so
"12abc",""," 45"and"45 "are all errors. The leading-space case needed an explicit check, sincestrtollskips whitespace of its own accord and the two ends would otherwise have behaved differently. - Widening an integer past 2^53 loses precision silently, which is what binary64 is; erroring would be unlike every other language.
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.
- The ancestor is named rather than inferred. A
superkeyword would have to resolve against the object where the running method was defined, which is bookkeeping no frame carried. Naming it needs none of that, stays correct however deep the receiver is, and cannot find the method again and recurse. parentreads the delegation link so a chain can be walked. Read-only: the link stays an internal pointer, so nothing a program writes can corrupt dispatch.- Dispatch needed one change — a delegate receiver rewrites its own stack slot to
the real receiver before lookup, after which every existing path reads
selfcorrectly without knowing delegates exist.
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.
- There is no separate notion of a class. An object given slots, and an object created from that, differ only in how you use them.
- Assigning on an instance always makes the instance’s own slot, so it shadows the prototype rather than writing through — one instance cannot change all of them.
- Delegation chains, and the nearest slot wins, so overriding works at any depth.
- Equality is identity: two objects with the same slots are still two objects.
- The default
printis overridable, since aprintslot on the prototype is found before the primitive.
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
- Answering an integer was forced rather than chosen. A float result would let two integers leave their type silently, which is the coercion the language refuses everywhere else. A fractional answer needs an explicit conversion — which does not exist yet, and is now the most-missed missing operation.
- Floored, for what it does to
mod. A floored remainder always lands in[0, n)for positiven; a truncated one takes the dividend’s sign and needs correcting at every use site.quo/remstay free for the truncating pair. - Division by zero splits along a line the language already had. Integers trap, having no infinity; floats answer one, since float multiplication already overflows to infinity where integer multiplication traps.
INT64_MIN div #-1is guarded separately — the one division that overflows, and undefined behaviour in C rather than merely wrong, raising SIGFPE on x86.
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.
- Immutable, and therefore a value rather than a reference.
equalscompares characters, where an array compares identity. That completes the split the language already had: numbers and strings are values, objects and blocks and arrays are references. - One-based
at, answering a one-character string since there is no character type. Strictconcat: joining a string to a number is an error, not a conversion. - Printed as it would be written —
"hello", nothello— the way#45prints as#45. - No
.sobchange was needed after all. A literal’s bytes ride in the chunk’s interned text table beside selectors and global names, andOP_STRINGbuilds the string at run time — which is also how the compiler emits one without having a VM to allocate in. Only the opcode set changed, so the format went to version 5. - A string holds bytes, not values, so it has no outgoing edges for the collector — the difference from an array that made arrays the better first heap type.
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) }).
- A
SolArrayheap type joins the collector, and every element is a tracing edge — the reason arrays were built before strings, whose bytes are not. - One-based indices: an index is an ordinal, not an offset — there is no pointer
arithmetic here and no address for it to be a displacement from, so what makes
zero-based natural in C does not apply. It also matches the Smalltalk lineage
the object model already came from.
at(#0)is out of bounds and therefore caught rather than silently off by one. - Strictness carried through: an index must be an integer, and out of range is an error rather than nil.
- Arrays are references, like objects.
equalsis identity; comparing contents is a different question and will get its own name. dobounds the count once and re-reads the backing store each pass, since the block may grow the array and move the store underneath it.- Printing is depth-limited, because
a:add(a)is legal. - No
.sobchange: an array is built at run time, never pooled as a constant.
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:
- Recursion is limited to about 30 levels (3.5).
SOL_FRAMES_MAXis 64 and each recursion level costs two frames in the idiomatic form – the method’s block, and theifElsebranch block carrying the recursive call. Measured: 30 succeeds, 31 reports “call depth exceeded”. Low enough for ordinary code to hit. - A caller-owned chunk must outlive blocks defined in it (3.6). Freeing one while a reachable block points into it leaves that block undefined to call. The collector is safe – a block caches its owning cell, so tracing never touches a freed chunk – but nothing detects the call.
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.
- Added
sol_gc_push_temp/sol_gc_pop_tempfor cells held only in a C local across an allocation. Solis uses them to protect a fresh code cell while it compiles into it. - A chunk’s constants are traced, since they will hold heap values as soon as strings exist.
- A block caches its owning cell rather than reading it back through
block->code->chunk. A caller-owned chunk can be freed while blocks pointing into it are still on the heap — calling such a block was always wrong, but the tracer must not fault merely for walking past one. Stress mode under ASan found this as a use-after-free inmark_code.
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 |
- Both heap types now begin with a shared
SolGCHeader, so one list threads the whole heap and one sweep loop walks it. A new heap type joins by embedding the header rather than adding another list. - Marking uses an explicit worklist, not recursion, so a graph deeper than the C stack traces without overflowing it — a 200,000-link proto chain is a test.
- Collection happens before an allocation, so the new cell cannot be swept.
sol_vm_initnulls the roots before its first allocation for the same reason. SOLUM_GC_STRESS=1collects on every allocation. The whole suite passes under it with ASan and UBSan.
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 }.
- A slot holds a value. A slot holding a block is a method: sending its name
runs the block with the receiver as
self. A slot holding anything else answers that value, so methods and data slots are no longer different kinds of thing. - Because
:=evaluates, a method can be computed:integer:double := maker:value(). - Blocks gained parameters:
{ a, b | ... }. A leading|still means temporaries. - Capture now chains. With no separate notion of a method, every frame is a
block’s, so
OP_OUTERcarries a depth and the runtime walks the lexical chain, checking liveness at each hop. selfis no longer resolved lexically at compile time — which block ends up invoked as a method is not knowable there. It compiles to slot 0 of the frame being entered; the VM captures the receiver into a block at creation, and a send to a slot holding it overrides slot 0.- The method-definition special form left the compiler, and about 100 lines with it.
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’”.
- Only parameters and names declared with
| a, b |are locals. Everything else is a global, read or written. - Only the script’s top level may create a global, so an undeclared name inside a method or block must already exist — a typo is reported instead of quietly becoming a variable that looks local.
- Declarations may open any group or block body. A duplicate name in one frame is a compile error.
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) }).
{ ... }makes a block: code as a value, deferred rather than run.- Control flow is ordinary message sending.
ifTrue,ifElse, andwhileTrueare plain primitives receiving an unevaluated block, so the language has no control-flow syntax and a user can add control structures the same way. - The interpreter became re-entrant so a primitive can invoke a block.
- Added a boolean type with
true/falseandnot, andequals,lessThan,greaterThanon numbers. Comparisons are as strict as arithmetic;equalsis the exception, answering false across types rather than erroring. - Capture is lexical, with two cheap measures instead of heap promotion: a block that does not touch its home frame may escape freely, and one that does records a frame id so calling it after that frame returned is reported.
- With conditionals, recursion can terminate — the language became Turing-complete.
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.