Solveig

Completed roadmap items

The case for each piece of work as it was argued before the work was done — the problem, what the options were, and why the shape chosen was the one taken. This is where a roadmap entry goes when it is finished, rather than being deleted.

What actually landed, and when, is in CHANGELOG.md, which names the commit for each. What is still outstanding is in ROADMAP.md. What was considered and turned down is in ideas.md.

The numbers are the original ones and are never reused. The changelog cites them, and a number that meant two things at two times would make every one of those citations ambiguous — so the gaps in the roadmap are themselves a record of what has gone.

Entries that were removed before this document existed are not here; their reasoning went to the changelog at the time, and the verdicts are in Settled below.


1. Blocking real programs

1.1 Garbage collection — done

Mark-sweep over objects, blocks, and compiled code. SOLUM_GC_STRESS=1 collects on every allocation; running the suite under it with ASan is what makes a missing root a caught bug rather than a latent one.

How it works is in design.md; what changed is in CHANGELOG.md. Two results worth keeping in view, both measured against the commit before each change:

  Before After
A block literal allocated per loop iteration, 2M iterations 98 MB, linear 1.5 MB, flat
60,000 REPL lines 25.5 MB, linear 1.9 MB, flat

1.1a the collector and 1.1b GC-owned code are done, and so is 1.1c, which was still open when this entry was written:

1.1c Temporary roots inside primitives — done

The mechanism exists and Solis uses it; no primitive allocates yet, so nothing applies it. Arrays are what changed that – see 1.2a.

1.1d is not work but a standing restriction — collection is stop-the-world and non-incremental — so it stayed behind, in ROADMAP.md.

1.2 Arrays — done

SolArray and the array class: new, of, size, at, atPut, add, do, collect, select, print, equals, plus the [...] literal. Indices are one-based, an index must be an integer, and out of bounds is an error. Arrays are references, like objects.

Arrays come before strings deliberately, for a reason that only became clear once the collector existed: an array holds SolValues, so the tracer gains a real outgoing edge. A string holds bytes and has none. Arrays therefore exercise the collector in a way strings cannot, and they are the first thing that can hold a reference the collector must not lose.

Reference semantics, like objects: a := b makes two names for one array, and mutating through either is visible to both – the established split, numbers are values and objects are references, so it needed no new rule.

No .sob change. Arrays are mutable, so a literal is a construction rather than a pooled constant, and check_constants rejects one outright.

Three details worth keeping in view:

1.2b [...] literal sugar — done

[#1, #2] compiles to the bytecode for array:of(#1, #2) – literally, not merely equivalently: the two forms produce byte-identical .sob files, and a test asserts it. Two lexer tokens and one compiler branch, no new opcode.

The desugaring is real rather than a lookalike, which has one visible consequence worth knowing: the array it sends to is the ordinary global, so rebinding that name moves both spellings together. They cannot drift apart, which is the point. Capped at 255 elements by OP_SEND’s argument count, which stayed one byte when the index operands widened (4.2).

1.2a Temporary roots, finally needed — done

sol_gc_push_temp / sol_gc_pop_temp exist and Solis uses them. Arrays are what force them into primitives, and precisely which ones is worth being exact about:

select appends each element to the result before testing it, winding the count back when the block rejects it. Otherwise the element would live only in a C local while the block ran, and a block that replaced it in the source would leave nothing pointing at it.

1.3 Strings — done

SolString and the string class: print, size, equals, concat, at. Immutable, and therefore a value rather than a reference – equals compares characters, where an array compares identity. One-based at, answering a one-character string since there is no character type. Strict concat: joining a string to a number is an error, not a conversion.

It needed no .sob change after all. A literal’s bytes ride in the chunk’s interned text table, alongside selectors and global names, and OP_STRING builds the string from them at run time. That table was already serialised, so only the opcode set changed. A literal whose bytes match a selector shares one entry, harmlessly.

What is left, small and separable:

1.4 User-defined objects — done

A global object, whose new answers a fresh object delegating to the receiver. That was the whole gap: slot assignment, proto-chain lookup, and block-in-a-slot-is-a-method all already existed, so classes needed one primitive rather than a mechanism.

There is no separate notion of a class. An object created from object can be given slots; an object created from that delegates to it and finds them. Whether a given object is a class or an instance is how it is used, not what it is. Assigning a slot on an instance always makes the instance’s own, so it shadows the prototype rather than writing through.

Left open:


The next two were the only urgent items in this document, and they were one bug wearing two hats. Both are fixed.

1.5 array:print crashed the VM — done

array:print.        ; was: segmentation fault
block:print.        ; was: segmentation fault

Rendering an object asks it for asString. On the class objects array and block, lookup started at the object itself and found the asString those classes define for their instances, which renders — and rendered the same value again. render does carry a depth, but the count restarted at zero every time the recursion left through sol_value_render, and it is C recursion, so SOL_FRAMES_MAX never saw it: a primitive called from sol_vm_send pushes no VM frame.

It fell out of 1.6 as predicted. The receiver check refuses asString to a class object, so the cycle cannot start; there is no longer a route into prim_rendered_as_string that does not hold an array or a block. Every other route back into the renderer goes through a block a user wrote, and a block costs a frame, so the call-depth cap bounds it as it bounds any other runaway recursion.

One thing did have to move. A class object nested inside something being rendered – [array]:print – would have raised 1.6’s error from inside a print, which is not the renderer’s business. So the renderer 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.

The depth that restarts 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 as it is rather than carrying state on the VM for a case nothing can produce.

1.6 A class object answered its instances’ messages — done

array:add(#1).      ; was: abort
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.

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 as well as 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 take any receiver, as does reflection, which reads either side. The installation lists 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 — for each message, rather than by splitting the two sides into separate objects, which still wants a metaclass level.

respondsTo asks the same question the dispatcher does, so it cannot claim a message that sending would refuse: array:respondsTo('add) is false and array:respondsTo('of) is true. Binding a block over a primitive clears the requirement along with it, so a class can be given messages of its own.

Costs one comparison per primitive send: 4.0% on a loop that is nearly all sends, 2.1% on a more ordinary one.

Found by fuzzing the loop work (4.1) and present as far back as the array primitives. tests/test_class_side.c covers every built-in class, and the fuzz sweep that found the two crashes – 3205 corrupted variants – now reports nothing.

1.7 A temporary declared in a top-level group — done

( | t | ... ) declares temporaries of the frame the group sits in. Inside a block or a method that is a frame, and it worked. At the top level of a script there is no frame – 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, so t := #5 overwrote it and the answer was #10 instead of #6. Silently: no error, just arithmetic on the wrong number.

Refused in the compiler, which of the three ways out was the smallest correct one and forecloses neither of the others:

[line 1] solas: a temporary needs a frame, so declare it inside a block at '|'

It reports at the |, where the mistake is, and both front ends now say the same thing – which they did not before. Compiled, the verifier had always caught it, so sol_chunk_save refused to write the file and said 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 was the larger half, and the reason this is worth more than ten lines of parser. 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 that it was earned. tests/test_compile.c now does: every shipped example and every accepted form in a growing list is compiled and handed to sol_chunk_verify, so whatever Solas accepts, the verifier accepts is a property with a test behind it rather than an assumption. Anything the compiler learns to accept belongs in that list.

The refusal also had to recover properly. Reporting and returning left the parser on the |, so recovery resumed inside the group, cleared the panic flag at the . between its statements, and complained a second time about the ). Every other error in this compiler produces exactly one message, and a test asserts this one does too.

Found by auditing REFERENCE.md against the implementation, not by fuzzing: the reference claimed declarations may open any group, and they could not.


2. Language decisions

The two entries this section still holds live are in ROADMAP.md: 2.5, class side versus instance side, and 2.14, the loose ends the decided items left.

Settled

The numbers stay because the changelog cites them. Each row is the verdict; the entry named is where the reasoning lives.

  Question Decided Entry
2.1 Division Floored, answering an integer. Integers trap on zero, floats answer an infinity; quo/rem stay free for the truncating pair 9ad8039
2.2 Statement terminator . separates rather than terminates: required between two statements, optional after the last be13b07
2.3 Array indexing base One-based. An index is an ordinal, not an offset, so at(#0) is out of bounds and caught 1d8c573
2.4 Array literal syntax [...], pure sugar – byte-identical to array:of(...), with no new opcode 63749ee
2.6 Float exponents 1e3, 1E+3, 1.5e-3. A bare e is left alone; # is exact and takes no exponent c8cef1b
2.7 Symbols 'foo, interned, compared by pointer. The intern table is weak 5a15fc9
2.8 Missing operations Conversions, short-circuiting and/or over blocks, notEquals as the negation of equals, string ordering, negated/abs, sorting, isNil/notNil 7ac6be6, 246ae8e, 113745f, 10ddf25
2.9 Calling the method you override self:via(ancestor), the ancestor named rather than inferred a5aa9e0
2.10 Reflection slots, slotAt, respondsTo, isKindOf, perform, named by symbol. Reads only a7310a7
2.11 Filling a template {} placeholders and fill, matched exactly, each value rendered by sending asString ca1369b, 4a70ef0
2.12 Formatting a single value A spec argument to asString. No conversion letter, no sign mode; bases are a message, not a letter 3524c70, 95074c9, f4b909d
2.13 Case and text ASCII only, by explicit range rather than toupper. Still live, and in the roadmap’s section 3 91d413c

3. Known limitations

The limitations themselves are still live and are in ROADMAP.md. These were limitations until they stopped being ones.

3.22 A file is read whole, or not at all — done

system:readFile(path, from, count) answers count bytes from the one-based position from, and the entry above it is no longer true.

What it was. readFile answered the whole file and there was no other way to read one: no handle, no line at a time, no seek. Two gigabytes was the hard stop, a string’s length being a signed 32-bit count, refused by name rather than truncated. Below that the peak cost was twice the file, the bytes being read into a buffer and then copied into the string. fileSize, answering without reading, was the single concession.

The entry said it was right for everything here, and that was true when it was written. The editor loads a file to edit it, solas loads a source to compile it, expect.sol loads a document to check it — all three want the whole thing, and a handle would have been ceremony around one call.

The trigger, and the four seconds in which it fired

The entry named its own trigger: a program with a file that does not fit. Nothing here had one, and the largest input measured was a 50,000-line scan held entire without complaint.

Two measurements changed that, a day apart.

sed.sol on 2026-08-30 was the first program here able to price the entry, because it does identical work by two routes — readLine streams standard input, and a named file is read whole and split. Same script, same bytes, 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

About 4.7 times the file, where the entry said twice. Twice was right for readFile alone; a program working line by line then holds a string object per line, and the entry did not cover that shape. sed closed by saying the trigger had still not fired, which was true and was the sentence worth going back to.

On 2026-08-31 it took four seconds to fire. A sparse file is 3 GB of holes and 8 KB of disk:

/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 nothing 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, and it stopped being either.

What it is, and the one thing it does differently

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

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; the string that comes back says its own size, so nothing is hidden by giving back what was there. A whole-file read that comes up short is still a failure, because its length came from ftello a moment earlier and anything less means a fault.

That distinction was decided by a comment already in the code, which is worth recording because it turned a matter of taste into one of fact. The old function carried:

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 be argued, and ferror remains the check that catches the directory either way.

#0 is not a position and is refused, as 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 grow or shrink between the seek and the read.

What is still true: a string holds at most two gigabytes, so count is bounded even though the file no longer is.

What the second caller found, which the first could not

tail was written to check this call and reported that it wanted nothing. That holds; what it could not see is the price of having no handle, because it reads a file once or twice per invocation.

sha256sum.sol, on 2026-08-31, streams a whole file through this call and pays it 16,384 times a megabyte. Measured at -O2:

   
a ranged read, any size from 1 byte to 64 KB ~30 us
fileSize, fileExists, modifiedAt 0.65 us

The cost is the open and not the bytes, and having no handle means every call opens the file again. So a streaming caller’s chunk size stops being an arbitrary constant: hashing a megabyte 64 bytes at a time is 62% slower than 64 kilobytes at a time, and the curve is flat from about four kilobytes.

It is a price rather than a defect and it does not reopen the decision. The 30 microseconds is fopen’s — plain C doing the same open, read and close measures 28 on the same machine — so a handle would move the cost rather than remove it, and would buy back exactly the lifetime this entry refused. What it argued for was a sentence, and REFERENCE.md now carries it: a caller reading in pieces is choosing how often to pay for an open, and a poll loop should ask fileSize first, which is what tail -f already does and why following an idle file costs no measurable CPU.

The order this was built in, which the scoping got wrong first

The plan was to write tail first and let it ask, because that is the rule — a program asks and a page does not. The question what was tail for? is what exposed it: the evidence arrived before the program did, in the four seconds above, and a tail written on the whole-file read cannot call the thing it is meant to be asking about. It would have re-proved a measured wall and said nothing about the shape.

So the shape question — is this call usable? — is the one a caller answers, and a caller has to come after the call exists. ideas.md keeps both recommendations rather than replacing the first, which is what that section already does for predictions.

3.21 A changelog hash is written by hand and nothing checks it — done

The second entry here about this repository’s own verification rather than about the language, after 3.16, and the same shape as the third gap that one closed: a fact stated by hand, in a place nothing reads, that goes wrong quietly.

Every entry in CHANGELOG.md names the commit it landed in, and a commit cannot carry its own hash — so the entry goes in saying pending and a follow-up commit substitutes the real one. The substitution failed once and the failure survived two days: the PRINT USING entry of 2026-08-26 carried a literal %s where its hash belonged, through every make test in between, because nothing asked whether a hash looked like one. It was found by a person reading the page while cutting 0.35.0.

The rule is what an entry’s heading looks like. A heading is

### PRINT USING, measured before it was written — `078bd92`, 2026-08-26

and everything backticked after the last em dash is a commit: seven hexadecimal characters, or the literal pending while the follow-up is not in yet. %s is neither, and neither is anything else a substitution can leave behind. The last em dash rather than the first, because a title may contain one of its own; everything backticked rather than the first thing, because two entries are not shaped like the rest — one names two commits joined by and, and one names a commit and no date. Both are right as they are, and a rule that read only the first token would have been a rule those two had to be rewritten to satisfy.

The weaker guard, deliberately. The entry named a stronger version: ask git whether the hash names a commit that exists, which catches a well-formed hash that is simply wrong. That wants a repository to ask, and expect.sol does not have one — it reads files and runs programs, and a tarball with no .git in it checks clean. The weaker guard catches the failure that actually happened and keeps that property, which is the trade the entry had already argued for and which building it did not change.

What it cannot see, said out loud. A heading that loses the em dash and everything after it is indistinguishable from a section inside an entry, and five headings are genuinely that. So both numbers are reported — how many entries name a commit, and how many headings name none — rather than passed over in silence, and the second one moving is visible to whoever reads a run. A floor in test_documents catches the other direction, a guard that quietly stops finding hashes to check, exactly as the floors beside it do for claims, counts, positions, SolaBasic blocks and grammar productions.

It also reports a pending still waiting, which is the state the release cut is looking for and previously had to look for by eye.

3.20 Five shipped libraries published everything they had — done

exports shipped with one library using it. This closed the other five, and four of them now say what they publish:

library publishes keeps
scan the fifteen the reference documents src, the text a cursor is a position in
pattern the ten the reference documents thirteen — one matcher taken apart
sob file, version eight — a byte writer and the buffer it fills
html read, complaints four dozen — one HTML parser taken apart
html:element eleven add and at were meant to be among the kept; see below

shell drew none, and that is the answer rather than an omission. It has four slots and all four are the API. A line listing everything hides nothing, and a boundary that hides nothing is the decoration this document rejects elsewhere.

Two objects, not one, in html. It binds a parser and the node prototype a read answers, and they need separate lines — with the inner one drawn first, since html’s own boundary would otherwise put element outside it and refuse the very next statement.

What drawing the lines found.

html:element publishes eleven names rather than nine, for the reason json:quote was published there: the parser builds the tree from outside an element. html:newElement is a method on html, and a new node delegates to html:element rather than to html, so both the position it stamps (at) and the way it hangs a child on a parent (add) arrive from outside. They were public in fact long before anything said so.

And one library was reaching into another’s internals. html sliced a cursor’s own text — self:cur:src:copyFrom(start, self:cur:pos:sub(#1)) — where scan:since(start) says the same thing and had existed the whole time. The two are the same slice; since guards pos == start, which the test above it had already ruled out. Nothing had stopped it, so nothing had noticed. That is the boundary paying for itself: not by preventing a bug, but by finding a place where an API had been bypassed and reimplemented.

The semantics changed to make any of it worth doing. 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, which is the half that matters. Boundaries are inherited now: an object under one is its export list, whether it drew the line or got it from its prototype. 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, and without it every library would be forbidden from filling in what it hands out. Only downward: reaching up into a prototype by name is still refused.

Cost. The assignment path settles self:x := ... with two comparisons before consulting anything, that being nearly every assignment a method makes. Against the previous build, a send-only loop differs by about 2% in one ordering and is indistinguishable in the other, and a real program is indistinguishable — which is to say it is at the noise floor rather than clearly below it.

3.19 A program cannot write to standard error — done

system:writeError(text) is the answer, landed the day after the entry was written and the day it was raised. The case is kept below as it stood.

display, print and system:write all go to standard output, and nothing goes to standard error. So a program has no way to separate what it produced from what went wrong with producing it.

The machine has the stream the language does not. solvm writes its own diagnostics to standard error, and a test holds it to that — a mistake goes to stderr. A Solum program running on that machine cannot do the same thing.

The program, and why the earlier ones do not count

basic.sol run over a .bas file reports a bad listing on standard output:

$ solvm basic.sob broken.bas > out.txt
$ cat out.txt
line 10: there is no line 999

The error is in the output file, and 2>/dev/null does not suppress it. The status is right — that was fixed the same day — and the stream is not, so a shell can tell the run failed but cannot separate the failure from the results.

Two programs here already mention stderr and neither is this. bench.sol discards a child’s stderr so a noisy command cannot write over its report, and expect.sol keeps a child’s stderr apart from its stdout so a complaint cannot accidentally satisfy an expectation. Both are about reading somebody else’s stderr, which 3.15 settled. This is the first program that wants to write one.

Both workarounds, measured

   
system:writeFile("/dev/stderr", text) Works on this machine. It is a path that only exists on Unix, writeFile opens and truncates a file per call, and the whole thing is spelled as writing a file — which is what it is, and not what was meant.
system:run(["sh", "-c", "echo ... 1>&2"]) Also works, and costs a process. 0.5 seconds for a hundred diagnostics — five milliseconds a line to write a line.

Neither is worse than the gap the way 3.18’s was: they are ugly rather than wrong. That makes this a smaller entry than that one, and it is why it is written down rather than worked around.

What it would take, and the question to answer first

One primitive, and the question is the same shape as 3.18’s and has a different answer available:

   
system:writeError(text) Its own message, beside write. Says what it does, and the pair reads as the two streams a process has.
system:write(text, 'error) One message with a destination. Fewer names, and it makes the common case carry an argument it never wants.

What should not happen is a second display. display and print are about rendering a value, they serve every type, and a variant of each that goes somewhere else would be exactly the second mechanism behind the first that this language exists to refuse. Whatever this becomes, it belongs beside write on system — which is where 3.18 put the first half for the same reason.

What was decided

Its own message, not a destination on write. Of the two shapes the entry offered, system:write(text, 'error) would make the common case carry an argument it never wants, and every call site would have to say which stream it meant even though almost all of them mean the same one. Two names read as the two streams a process has.

And 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 exists to refuse. There is one way to reach standard error and it is spelled as writing, not as displaying.

It flushes, which C does not require — stderr is unbuffered — but the whole point of both write and this is text that arrives when it is written, and that is not worth depending on a platform for.

What it fixed, in the program that asked

solvm basic.sob broken.bas now puts the program’s output on one stream and the complaint on the other:

$ 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 was fixed by the same message. Its nothing on standard input complaint had always gone to standard output, mixed in with the numbered lines that are its actual result. Nobody had noticed, because until this there was nowhere else to put it.

One test had to be rewritten, and the way it failed is the entry in miniature. It compared the merged streams — 2>&1 — and broke the moment they were separated, both because the two now carry different things and because merging them puts them in the wrong order: stdout is block-buffered down a pipe and stderr is not. It asserts each stream on its own now.

3.18 A program cannot write without ending the line — done

system:write(text) is the answer, landed the same day this was written. It takes a string, adds nothing to it, and writes to the same stdout the rest of the machine writes to. What the entry argued is kept below as it stood.

display and print are the only ways a Solum program has to put text on its own output, and both end the line. So a prompt cannot sit beside the answer to it, a counter cannot be overwritten in place, and a line cannot be built from pieces that are decided as it goes.

Unlike everything else in this section, this one is not kept on purpose. The restrictions around it were chosen, or found and then judged worth living with. This is work with a clear shape and a program already waiting on it.

The program is basic.sol, and the statement is INPUT. BASIC prompts with ? and reads the answer typed after it on the same line; this interpreter prints the ? and reads the answer from the line below, which is not what a BASIC program looks like:

TWO NUMBERS, SEPARATED BY A COMMA
?
SUM IS 7

The workaround exists and is worse than the gap

system:writeFile("/dev/stdout", "? ") writes without a newline and looks like the answer. It is not, and the way it fails is the reason this entry is worth writing down rather than leaving as folklore.

writeFile opens its own stream on the file. display writes through the one the process started with. When the output is a terminal that stream is line-buffered and the two happen to interleave correctly; when it is a pipe or a file it is block-buffered, and everything written with display is still sitting in a buffer while the writeFile goes straight out. Measured:

one          $ what the program printed, in order
two? four? one
three        $ what came out of a pipe
five

The prompt arrives before the three lines written before it. It works when tried by hand and silently reorders the transcript the moment anything is redirected, which is the same shape as the two hand-written square roots in 3.14: plausible under the test anybody runs, wrong under the conditions nobody thinks to try.

What it would take, and the one question it raises

One primitive that writes a string to standard output and adds nothing. The question is not how but where it goes, and it is a real one, because this language has kept its output on display and print — messages every object answers, on the object being written.

   
system:write(text) Puts it beside readLine, which is the symmetry: the two halves of a terminal. Against it: everything else that writes is a message on the value, and this would be the first that is not.
string:write Keeps the receiver as the thing being written, next to display. Against it: it reads as a third of a pair whose other two members serve every type, and a write that only strings answer is not that.

Either would be one line of C. Neither should be added on the way past something else — a language whose whole claim is that there is no second mechanism should not gain a third way to print without deciding which of the three is the one.

What was decided, and the one thing the entry did not think of

It went on system, beside readLine. The entry offered that or string:write and called the question real, which it was. What settled it: 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 family. It is about a destination, and the destination is where readLine already lives. The two are the two halves of one terminal.

A string and not any value, following writeFile, which is the neighbour it most resembles. system:write(#42:asString) says which of the two renderings it wants, so there is no second rule to remember about how a value becomes text.

It flushes, which the entry did not think about and which is the whole point. Text not followed by a newline 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. So the primitive flushes and the ordering holds:

one
two? three
four? five

That is the same program whose broken output is recorded above, now through a pipe and in the order it was written.

basic.sol’s INPUT is what asked, and it 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 this being closed.

3.14 The mathematics that is not here — done

The title is the problem as it stood, and it stood for a long time. All of it landed on 2026-08-25, as one decision: pow, exp, log, sin, cos, tan, asin, acos, atan on float, and float:pi and float:atan2(y, x) on the class. float went from 26 messages to 35. What the entry argued about for weeks is kept below in full, because the argument is the part worth having.

No pow, no log, no exp, no trigonometry and no pi. C has all of them and each would be a line, and none is here because no program in this repository has asked for one — the ones a program has asked for rather than all of <math.h> being the rule this entry set for itself.

This entry was larger twice and is now this. It read there is no square root, no minimum, and no randomness, and both of those halves have since been answered. What each cost is recorded here, because the cost is the useful part.

The square root, and why it is a primitive

sqrt is a message a float understands and min, max and between are in math.sol. Both were writable in Solum; the line between them is not importance. min and max were written correctly the first time, in one line each, and there is nothing in them to get wrong. sqrt was written twice, and both versions were wrong and silent about it:

   
twenty fixed iterations Right to twelve places at 2, and sqrt(1e10) answered 100000.000156. Newton converges quadratically once the guess is near; from x itself the approach is one halving per octave, so seventeen of the twenty iterations went before the good part began.
iterate until it stops moving, capped at sixty steps Written to fix the first, and worse. The cap is needed, because in floating point Newton can settle into an oscillation between two adjacent values rather than a fixed point — but a value above about 1e21 has not finished halving in sixty steps, so the loop returns x divided by 2^60. sqrt(1e300) answered 8.67e281 rather than 1e150. Nineteen orders of magnitude, silently.

Getting it right means scaling by the exponent before iterating, which is asking a script to know how a double is laid out. The C library already knows and is correctly rounded, so the answer is one line of C and no lines of Solum.

The second version was checked and passed. Its 1e300 answer was printed and read, and what the reading found was a bug in the formatter — the over-read fixed in 0.21.0 — because 8.67e281 rendered at six decimal places is 157 characters out of a 64-byte buffer. The formatter was fixed, the digits were compared against the C library, they matched, and the value they were the digits of was never compared to anything. A wrong number can survive being looked at carefully if what you look at is how it is printed.

And randomness, which was the open half until it was measured

random:new is the answer, and the question was where the state lives. It lives in an object you make, seeded by the machine or by a number you name — never on system, because a generator there would give a VM a history and two runs of one chunk would stop being identical. That is not a promise embedding.md makes in so many words, and the wording of this entry used to say it was: what that page promises is one chunk, any number of machines, which a chunk carrying a generator’s state would not be. A program that never says random:new is exactly as deterministic as it was before any of this existed.

What settled it was measuring the generator that was already here. bench.sol carried Lehmer’s — multiplier 16807, modulus 2^31-1, chosen so the product could not exceed a signed 64-bit integer, because integer arithmetic traps on overflow rather than wrapping and so the generator everybody knows cannot be written in this language at all. It was correct, and in bulk it was fine: 100,232 heads in 200,000 flips, and 21 buckets over 210,000 draws spread from 9,799 to 10,157.

The seeding was the defect, and it was invisible. The clock was the only entropy a Solum program could reach, so two runs a microsecond apart got consecutive seeds — and a Lehmer generator’s first output moves by the multiplier when its seed moves by one:

  before after
the first coin flip, over consecutive seeds 1, 2, 1, 2, 1, 2, …the parity of the start time no pattern
the first resample index of 21, over 2,000 consecutive seeds 3 distinct values of 21 21

Neither shows in the output, and neither was fixable in Solum: mixing a seed properly needs the wrapping multiplication that traps here, and there is no entropy but the clock for a program — the machine has /dev/urandom sitting right there, which is the asymmetry the whole entry turned on. Add the modulo bias that mod n leaves on the way out and there are three ways to get this wrong that a reader cannot see, which is the argument that made sqrt a primitive, holding more clearly here than it did there.

The generator is PCG XSH RR 32/64, its 64 bits of state are the object’s payload so an instance allocates nothing, upTo draws again rather than taking a remainder, and the seed is recorded in an ordinary slot so a run the machine seeded can be had back by writing the number down.

What the trigger taught, since the entry named one and it was the wrong one. It said this waited for a program wanting randomness for the work rather than for how it measures, and counted bench.sol as the second kind. That was a misreading of that program: its product is the confidence interval, and the interval is computed by bootstrap resampling. The randomness is the algorithm, not the instrumentation. A trigger can be written down wrongly and go on looking unfired, which is worth more than the entry it was attached to.

What is left

pow, log and exp are still not here either, and were not added with sqrt. C has them and they would each be a line, but no program in this repository has asked for one, and the ones a program has asked for rather than all of <math.h> is the rule this entry set for itself.

Nor is there any trigonometry, or a pi. Asked about directly, so the answer belongs here rather than in a conversation.

The case for building it is the one that made sqrt a primitive, and it is stronger rather than weaker. A hand-written sine fails the same silent way and fails harder: the series is the easy half, and the difficulty is argument reduction. Reducing x modulo 2π needs π to far more bits than a double holds, so the obvious x:sub(twoPi:mul(x:div(twoPi):rounded)) loses a digit of the answer for every octave of the argument and is returning noise well before 1e16. That is the same shape as the defect this entry already records — plausible output, catastrophically wrong in a range nobody thinks to test, silent throughout. If a thing every program would get wrong the same way belongs in the machine is the rule, trigonometry meets it more clearly than sqrt did.

What it was waiting for was a program, and the program has arrived. For most of this entry’s life no file here had ever wanted an angle. The first draft of this paragraph gave a second reason — that the twenty-two programs are text and process work, so geometry is not what this language is for — and that reason is wrong and is worth leaving recorded as wrong. The programs are the tools this project needed while building itself; they describe what has been written, not what may be. Solum is meant to be a general-purpose language, which design.md now states outright, and no entry in this document should be read as ruling a direction out because nothing has gone that way yet.

So the trigger was ordinary: a program that wants an angle — a plotter, a simulation, anything with coordinates or a waveform. When one arrived, the sensible thing would be to land trigonometry and pow/log/exp as a single decision rather than a message at a time, since arriving one convenience at a time is exactly what the rule above exists to prevent.

The program that arrived, and why it is a harder case than a plotter

basic.sol is an interpreter for ECMA-55 Minimal BASIC (1978). Six of that standard’s eleven supplied functions are SIN, COS, TAN, ATN, EXP and LOG, and its ^ operator needs pow.

The difference from a plotter is that this program cannot decide to want less. A plotter that wanted one angle could be written to want none — plot something else, or take the coordinates ready-made. An interpreter is measured against a document it did not write. Either PRINT SIN(0) gives 0 or the interpreter is not an interpreter for that language, and no amount of taste about what belongs in a small language changes what is on page 27 of the standard. That is the strongest form the trigger could have taken, and it took it by accident: the program was chosen for being a different shape from the other ten, not for wanting arithmetic.

^ is where it bites first, and it is the same failure this entry already records twice. The obvious stub is repeated multiplication, which is exact for 2^3 and cannot answer 2^0.5 at all; the next one is exp(y * log x), which needs two of the six missing functions. So basic.sol raises on ^ and names this entry, rather than shipping an operator that is right in the cases anybody tests and silently wrong outside them — which is precisely how both hand-written square roots got through.

What is decided by BASIC rather than by us, of the three questions below: the standard’s functions take radians, and its ATN takes one argument, so atan2’s missing receiver need not be answered to unblock this program. Only where pi lives is still open, and Minimal BASIC has no PI at all — so even that can wait for the second program.

Stage three of basic.sol is the supplied functions, and it cannot start until this is decided.

Three questions it raises that sqrt did not, worth having answered before a program forces them:

   
where pi lives infinity and nan are globals, so pi would be the third — and the first that is not an IEEE special. float:pi as a class-side slot is the alternative, and the two read very differently on the page.
radians or degrees Decided once and regretted afterwards, in every language that has chosen. C gives radians; the places a person types an angle by hand usually want degrees.
atan2 belongs to neither argument It takes two coordinates and there is no receiver that is obviously the subject, so y:atan2(x) reads badly in a language where the receiver is what the sentence is about.

And a note on size, since it is the one argument that is about the shape of the language rather than about the maths: float answers 35 messages today, and sin, cos, tan, asin, acos, atan and atan2 would be a third again. That is a reason to add them deliberately and together, not a reason to refuse.

How it was decided, and what the deciding was actually about

The program was basic.sol, and it fired the trigger by accident. BASIC was chosen for being a different shape from the other ten programs here — an interpreter for another language rather than a tool for this one — and not for wanting arithmetic. It turned out to want six functions and an exponent operator, because they are on the page of ECMA-55 Minimal BASIC it is measured against.

That made it a harder case than the plotter this entry imagined. 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. There was no version of the program that wanted less.

Eleven, not the seven that were wanted. asin, acos, atan2 and pi are here although no program has asked for one, and the reason is this entry’s own rule rather than generosity. Written by hand, asin(x) is atan(x / sqrt(1 - x*x)), which divides by zero at the ends of its own domain; atan2 is atan(y/x) with quadrant fixups everybody gets wrong on the axes. Both fail the same test sqrt failed. pi is the one member that does not — anybody can type 3.141592653589793 and have the nearest double exactly. It is here 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 above were answered, two of them by BASIC.

   
where pi lives float:pi, on the class, not a third global. infinity and nan are globals because they are values this arithmetic reaches and has no other way to name. pi is a constant — and pi is a name a program is entitled to want, which is the argument math.sol already makes for binding no global of its own.
radians or degrees Radians, following C and following the standard basic.sol implements. No degree variants: a conversion is a multiplication, and a multiplication is not something the machine has to supply. That is the same line this entry drew between sqrt and min.
atan2 belongs to neither argument So neither argument is the receiver. float:atan2(y, x) is class-side, the way time:fromSeconds and array:of are, and then the arguments are in the order the name has always had them.

None of them raise. sqrt answers nan for a negative and division reaches infinity, so log(0) is -infinity and log of a negative is nan. A language with stricter rules imposes them itself, and basic.sol demonstrates exactly that: it raises for SQR(-1) and LOG(0) because ECMA-55 says to, on top of a Solum that quietly answers nan. The stricter rule belongs to the language being interpreted.

And the size argument this 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: it must be sent by an example with a checked claim, be in the reference’s type table, be in the message index, and be on the cheatsheet. That is where the afternoon went, and it is the right place for it to go.

3.15 A child’s streams cannot be redirected — done

system:run gave the child this program’s stdout and stderr; system:capture kept the child’s stdout and answered it with the status. There was no third thing, and in particular no way to discard a child’s stderr or to send either stream to a file.

Both messages now take an optional second argument saying where the child’s streams go — an array of alternating name and value:

system:run(["make"], ["stderr", 'discard]).
system:capture(argv, ["stderr", 'merge]).
system:run(argv, ["stdout", "build.log", "stderr", 'merge]).

A value is either a manner, as a symbol'share, 'discard, and 'merge for stderr alone — or a path, as a string. The type is what tells them apart, which is what keeps a file called discard a file.

The shape was the whole question, and the language answered it

The entry named two candidates and picked neither: a fourth argument to capture, which is the smallest thing that works and the least general, or an options bag, which generalises without new messages at the cost of a shape nothing else here uses.

The bag won, and then the language chose how to spell it. The argument for the bag is that there are four things a caller might want to say and positional arguments cannot carry four optional ones — and the fourth is the one this entry never mentioned: there was no way to give a child anything to read, either. stdin was inherited by both messages and unmentioned by the roadmap, which is what settled it. But the bag could not be a dictionary, because this language has an array literal and no dictionary literal — a dictionary here would have cost three statements at every call site to say one thing:

opts := dictionary:new.
opts:atPut("stderr", 'discard).
system:capture(argv, opts).

So it is an array of alternating name and value, which is the notation the entry itself had sketched before either question was asked. The names are the strings capture already answers with, so a stream is spelled the same going in as coming out.

What it cost, and what it caught

Thirty lines of plumbing around code that already forked, and the rest was refusals. Every one of them reports rather than guesses: a stream named twice, a name that is not a stream, 'merge on anything but stderr, a manner that does not exist, a value that is neither a symbol nor a string, and "stdout" handed to capture — which is refused whatever the value, since keeping stdout is what that message is for.

Two decisions in the plumbing are worth the words they took:

bench.sol is the proof, because it is what asked. It had used capture so that a timed command could not write over the report, and stderr went straight through that and did exactly what capture was there to prevent. The way round was /bin/sh -c '"$@" 2>/dev/null' sh ... — a second fork and a second exec on every measurement, of the same order as the thing being measured, which is the one program that cannot pay it. It now passes ["stderr", 'discard] and its report is clean:

$ ./bin/solvm programs/bench.sob 8 /bin/sh -c 'echo noise 1>&2; true'
  runs     8
  min         4.594 ms
  median      5.278 ms

The noise goes and the failure does not: what says a command failed is the status, which 'discard leaves alone.

The test that mattered watches its own stderr. 'discard is the claim whose failure is invisible — output that should not appear looks exactly like output that appeared somewhere else — so the test points the test process’s stderr at a file for the length of the call and reads it back empty. Three hundred redirected children in a loop afterwards say nothing was left open, which under a 256-descriptor limit is a claim that fails loudly if it is false.

3.17 A global is found by walking a list — done

OP_GLOBAL resolved a name by walking the root object’s slots and comparing interned pointers, and every send resolved its message the same way, down the class object’s slots. Linear, and exactly linear: about 1.35ns a slot walked, so a name with 800 globals ahead of it cost 16× what pushing a constant costs.

An object with more than a dozen slots now keeps a table beside its list. Not instead of it: the list is still the object’s state — it holds definition order, which slots answers with, and it is what the collector walks and frees. The table is a lookup index over the same slots, open-addressed on the interned name pointer, which is the identity of a name and stable for the life of the VM. Below the threshold there is no table at all, so a point with three slots pays nothing, in memory or in a branch.

What it was worth, and what it cost

Against the same programs, bin/solvm before and after, 21 runs each through bench.sol:

   
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×
manifest.sol 1.06×, and the interval crosses 1
log.sol unchanged
a send to a slot 4 deep 0.88× — 12% slower
reading the most recently bound global 0.89× — 11% slower

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 ones. What makes that the right way round is that the shallow case was never the one that mattered: the old order was recency, so 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 — a library’s constant, read in somebody’s loop. Every real program measured is faster or unchanged.

Memory is not the cost it looks like. Peak RSS over disasm.sol is 0.94 MB before and after: the tables are two seats a slot at sixteen bytes, so the ten built-in classes come to about six kilobytes between them, and nothing else in the repository has enough slots to get one.

What the measuring found, which was not what the entry expected

The entry is about globals. Sends turned out to be where the time was, and for a reason the entry did not see: the 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. Padding integer and timing the walk gave 1.1ns a slot on the send path, the same as on the global path, which is what made it clear the fix belonged in sol_object_lookup_interned rather than at the OP_GLOBAL site.

The first version was 30% slower on a shallow send, and finding out why is what produced the design that shipped. A counter said 2.00 probes a lookup; the table held slot pointers alone, so each probe had to follow 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 the shallow-send loss from 30% to 12%.

Two things measured the opposite of the obvious guess. A stronger hash was slower — splitmix64’s finaliser is two multiplies on the critical path of every lookup in the language, and it bought fewer probes than it cost. And a table twice the size made no difference once the key moved into it, so the density stayed at two seats a slot rather than four.

One thing worth leaving behind

Breaking the growth rule deliberately, to check that the test would catch it, hung the suite instead of failing it: a full table makes linear probing spin rather than answer 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.


3.16 What the checker does not check — done

The odd one out of the whole list: about this repository’s own verification rather than about the language. It was here because ROADMAP.md claims to be the single list of what is outstanding, and a gap in what make test proves is outstanding.

It named three gaps. Two were closed by reading a page as a page and making an unrunnable block a failure; the third was closed by giving a number in a sentence a notation saying what it counts.

A block that would not run

It 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 — README.md’s opening snippet, the four lines that introduce the language to everybody who arrives, was missing the . after a := #45 and had been seen and skipped on every run.

Counting what was inside those blocks settled it: 54 claims in 42 blocks, against 672 checked. 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 the README. It is the opposite:

  blocks claims inside them
would not compile 10 2
compiled, then failed at run time 31 52

The compile failures were the shell and REPL transcripts, as harmless as they looked. A name defined in the block above is not a fragment showing syntax; it is a program with its first half on the previous page.

So a page is 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 accounts for 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 right, and each was wrong first.

And a block that is not a program says so, with a word after its fence. 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. 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, confirmed by breaking one both ways.

A number in a sentence

“Prose is not checked at all” was the second gap, and the difficulty is exact: a number in a sentence has no notation saying what it counts. So it is given one. The comment renders as nothing and the reader sees the sentence:

[expect.sol](/Solveig/programs/expect.sol) checks 1066<!--count claims--> claims

expect.sol recounts each of them from the repository as it stands — the programs on disk, the slots a class holds, the claims this run checked — and a name the table does not know is a failure, so a marker cannot be misspelled into silence, which is the failure mode the entry is about. The 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. The two are now held together.

What it found, all of it by recounting rather than by reading:

  said is
ROADMAP 3.14, on whether float should gain trigonometry float answers 21 messages 35 — the count that entry’s whole size argument rests on, five releases out of date
REFERENCE.md’s message index 121 messages across 215 registrations 122 across 216
programs.md’s sample output 21 files, 398 claims 22 files, 588 claims
README.md, programs.md and the entry itself 589 claims 1066

What is left, which is not a gap

A sentence can say anything, and no checker reaches that. The entry’s own example is the sharpest one: expect.sol carried a comment promising that 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. That is now corrected in place, and what replaced it says the checker does not detect a coincidental match.

A claim on a line that does not itself print is still not checked, and the report says so — it counts those lines and prints the count. The same goes for a transcript in a fence: programs.md shows what expect.sol prints, and nothing can hold a transcript to anything. Both are visible in the output rather than hidden in it, which is the difference this entry was about.

What it costs: make test went 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.


3.9 The verifier does not know the stack height — done

OP_SEND carries argc in a byte the file supplies, and whether that many arguments are really on the stack depends on the height at that instruction. Nothing computed it, so nothing structural could tell a real count from a corrupted one. Fuzzing the loop work found the shape: 227 arguments on a stack one deep, reading the receiver from below the frame.

The verifier computes the height at every instruction now, by walking control flow from the entry and following each branch. The rule 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 what corruption looks like. This came last of the four rather than first because it needed the other three: every opcode’s length is known, and every branch target is already established to be an instruction boundary, so the walk can only land where an instruction begins.

Measured over 1,750 single-byte corruptions of one .sob:

  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. About 5% on load, paid once.

The runtime check stays, which is the one place this departs from what the entry above expected. The two cover different populations rather than one being redundant: the verifier runs when a .sob is loaded, and 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 — while the C API will run any chunk it is handed. One comparison per send is a cheap floor to keep under all of that.

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.


3.23 Nothing checks the pages that are actually published — done

The third entry here about this repository’s verification rather than about the language, after 3.16 and 3.21, and the first one whose evidence is a fault that shipped rather than one that was argued about.

expect.sol reads the markdown. GitHub Pages renders it. Nothing compares the two, and on 2026-09-01 the difference was 263 headings.

What happened. Two lines in CHANGELOG.md were markdown that renders as something other than what it says — a paragraph wrapped so that ``` began a line, which is a code fence, and an inline code span wrapped so that <if-statement> began one, which kramdown reads as a raw HTML block and which stops rendering to the end of the file. 64 of that page’s 327 headings reached the site, from 0.20.0 on 2026-08-22 until they were found — ten days and twenty releases. Every check in this repository passed on every one of those days, because every check reads the file.

The comparison that found it is two lines of arithmetic. Fetch each published page, count the <h1><h6> it rendered, count the headings in the source outside fenced blocks, and compare. That is what it takes: the fault class is “a heading stopped being a heading”, so counting headings is not a proxy for it, it is the thing itself.

And its stronger form costs one more pass over the same fetch. Collect the id= attributes the renderer actually emitted and hold every internal #link on the site against them — 1,236 links across 30 pages. This is the same question expect.sol now answers locally, asked of the artefact a reader touches rather than the file it was built from, and the two can disagree exactly when this entry’s fault class strikes.

Measured on 2026-09-01, after the fixes: 30 pages, 0 headings missing, 0 dead anchors, in 6 seconds and 3.5 seconds respectively — almost all of it latency, and one pass would serve both.


It cannot go in make test. The suite is offline and dependency-free and must stay so; a check that fails on a train is not a check. The shape it wants is the one the oracles already have — oracle.sh, tail/follow.sh, sha256sum/vectors.sh — a script run on demand, outside the build, that needs something the machine happens to have.

And it has an obvious moment: a release. releasing.md already says Open the page about the release body, for a fault that cannot be found by reading the markdown because the markdown is correct. This is the same instruction about the same class of fault, aimed at the site instead, and what the document checker does not cover is where it would be named.

The one real design problem: what it compares against. The site renders origin/main, not the working tree. Comparing a published page against a local file reports every unpushed edit as a fault, which would make the check noise within a day. It has to read the source from git show origin/main:docs/X.md, and say so when the two are the same commit. The throwaway that found the fault did not do this, and was right only because it happened to run just after a push.

Two shapes, and the cost is not the fetching.

site.sh, beside the other oracles: curl, grep -c, git show. Perhaps sixty lines, no new dependency, and it can be written this afternoon.

A Solum program is the answer this repository would normally prefer — a program asks, not a document — and it is the more expensive one, for a reason that has nothing to do with the network. extensions/net has no TLS, so the fetching would be system:capture over curl either way. The real cost is that the anchor rule — GitHub’s lower-case-and-strip — lives inside expect.sol, and a second implementation of it would be two copies of one function, which is the trigger this repository built replace on. Doing it in Solum means moving that rule to lib/ first. That is a good change and it is a different one.

What it does not cover, and this is the part to be careful about. It checks that the markdown renders as what it says. It says nothing about whether the prose is true. Two live claims were found on the same day and neither is in its reach:

Both want the marked-count mechanism extended past docs/, which is a separate and cheaper entry than this one and is not being argued for here. Naming them together is how this entry avoids being sold on evidence it does not actually cover.


The calls, and none of them is mine.

  1. Does it go in at all? The case is a fault that shipped for ten days, not a trigger that might fire. Recommendation: yes.
  2. site.sh now, or Solum after the anchor rule moves to lib/? Recommendation: the script, and let a second customer for the anchor rule decide the library.
  3. Headings only, or headings and anchors? Recommendation: both — one fetch, and the anchor half is what a reader actually experiences.
  4. Named in releasing.md, or left as a thing to run? Recommendation: named, under what the document checker does not cover.

Built the same day it was scoped, on the four calls above answered as recommended: site.sh, beside the other oracles, headings and anchors over one fetch, named in releasing.md.

Writing it found a third fault class, and it is the one that makes the case. Not headings this time: a markdown link whose text wraps across a line loses the site’s 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; the eleventh is in README.md, which the site does not publish.

They were found by reading the rendered hrefs while writing the fetch loop, before the check itself ran, which is the same way the morning’s fault turned up: the artefact says things the source cannot.

And the check the entry proposed would have missed them. The throwaway that argued this entry only checked links whose target it had already fetched, so a link with a wrong base resolved to nothing and was silently skipped — it reported 0 dead on a site with ten 404s in it. The shipped version reports a site-absolute link that does not start with the baseurl as a fault in its own right, which is the difference between checking that a link’s anchor exists and checking that its address does.

What has actually fired, and what each branch really catches. The first draft of this paragraph said the heading branch had been reproduced by pointing SOURCE_REF at an older commit. It had not — that was written before it was run, and running it produced extra rather than lost, because the site is healthy and a healthy site cannot make this branch fire in the direction that matters.

4. Performance

Nothing here was urgent — the VM is written for clarity first — and all of it is now done. Kept rather than deleted because each entry records what was measured and why the shape chosen was the one taken; the detail is in the changelog.

4.1 Conditionals and loops are real calls — done

ifTrue, ifFalse, ifElse, whileTrue, and, and or written literally compile to jumps: no block allocated, no frame entered. They are still ordinary messages, and still reachable as such through perform or with a block held in a variable.

Inlining applies only when every block involved is written right there with no parameters and no temporaries. Both restrictions are about meaning, not convenience: 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, so inlining would declare them in the enclosing one where they could collide with a name already there. Anything else falls back to a real send, and there are tests that the two forms agree.

whileTrue is the awkward one, 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 reads ahead over the whole { ... }:whileTrue( { ... }) before compiling any of it, and the parser stays single-pass in the sense that matters – it never revisits a token it has already emitted for.

Measured at each step, all three builds timed together on one machine so the columns are comparable:

  before 4.1 conditionals and loops
recursion, plain 30 62 62
recursion through a loop body 20 30 62
a tight two-million-pass loop 0.53s 0.52s 0.44s
the same loop with a conditional in it 1.44s 1.13s 1.06s

The depth is the real result. Each 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, and 4.1’s own entry measured its 1.60s on another day; these were all taken today.

The verifier changed as predicted, twice. It records where each instruction starts and checks every branch target lands on one, in range. The backward jump is its own opcode, OP_LOOP, so that “forward” stays the default and the one instruction that can move the ip towards zero is easy to find.

What a backward jump costs is that verified bytecode can now run forever. That is not a new capability and the verifier does not try to prevent it: { true }:whileTrue({}) is a legal program, and before this a corrupted file could already spin through a loop built from real sends. Landing on an instruction, inside the chunk, remains the whole promise. Termination never was.

The loop’s test is a second opcode, OP_EXIT_IF_FALSE, rather than a reuse of OP_JUMP_IF_FALSE, and only 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. Both sentences now come from one function, so the inlined form and the send cannot drift apart – the failure 5.3 records, in advance this time.

Instruction lengths are also down to one table now, sol_op_length, which the emitter, the verifier, the disassembler, and the tests all read. Four copies of that table and a jump landing mid-instruction is what disagreement looks like.

and and or came last, and needed one thing the conditionals did not. They answer a boolean on both paths, and on the long path the boolean is whatever the block said – so the block’s answer is the reply and has to be checked. That is neither of the existing tests: OP_JUMP_IF_FALSE and OP_EXIT_IF_FALSE both consume the value they branch on. OP_CHECK_BOOL examines the top of the stack and leaves it, naming the message so the complaint is the one the send would have made. .sob went to version 10.

The short-circuit answer is a constant rather than the global true or false. Those are ordinary globals a program can rebind, and reading one would let the two paths disagree about what and answers.

  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 the better number, and for the same reason as above: the block was costing a frame that the jumps do not.

4.2 One-byte operands — done

OP_CONST, OP_SEND, and the name operands each carried a single byte, so a chunk could hold 256 constants and 256 names, and a literal-heavy program stopped compiling well before it stopped making sense. Those operands are two bytes now and the ceiling is 65536.

Not the CONST_LONG-style pair this entry expected, and the reason is the rule 4.1 arrived at: an opcode should mean something. OP_LOOP is its own instruction because a backward jump is a different thing from a forward one; OP_EXIT_IF_FALSE is its own because it complains differently. A CONST_LONG means exactly what OP_CONST means and differs only in how wide its operand is – and it would not have come alone. Nine instructions carry a side-table index, so it would have been nine more opcodes, in the length table, the verifier, the disassembler, and the dispatch loop: four more copies of the agreement 4.1 spent its time collapsing into one.

So the width belongs to the operand rather than to the opcode, and there is one rule for it. An index into a side table – a constant, a name, a nested method – is a u16, because those tables grow with the program. 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 also interns, which it never did – #1 written three times was three slots and is now one. The loader still appends rather than interning, for the reason the name table already did: a file refers to both 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 from folding onto itself.

Interning paid for much of the widening. Across the eight examples the .sob files grew 3.2% in total, and arrays.sol shrank by 3.9% – its top-level constant pool went from 41 entries to 12. Run time did not move: three benchmarks, all inside ±1%, which is the noise on this machine. The second byte costs a read the jumps were already doing.

  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

Raising the ceiling exposed something the old one had been hiding. Both tables intern by walking themselves, which costs nothing at 256 entries and is quadratic at 65536:

distinct names and constants compile
1000 0.02s
4000 0.08s
16000 0.87s
32000 3.52s

The scan was always this shape – the name table has done a strcmp per entry since the beginning – but a cap of 256 meant it could never be reached. Nothing anyone writes by hand is near 16000 distinct literals, and a generator can be, so the cap and the algorithm no longer match. A hash on the way in is the fix, and it is the same table 4.3 wants for dispatch: intern once, compare pointers after.

What is left at 255 is the argument count, and through it an array literal (1.2b). That one is not an operand-width problem: a longer literal needs a different construction – array:new and repeated add – rather than a wider argc.

4.3 Dispatch does a string compare per send — done

A selector is compared by pointer now. Every slot name and every selector goes through one table on the VM which answers the same address for the same characters, and a chunk’s name table is resolved through it once before the chunk runs – so the hash is paid per name per chunk, and a send 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 table is deliberately not the weak symbol table behind 'foo, which was the obvious place to put it. The two hold their contents differently for a reason: a symbol is a value a program can drop, so that table is weak and a symbol nothing mentions can die – the measured result in 5a15fc9. A name is pointed at by slots and by chunks, neither of which can announce that they are done with one, so these live as long as the VM and are freed with it. Sharing the weak table would have meant marking every slot name on every collection, which is the cost that table exists to avoid.

sol_object_lookup still compares spelling, because C callers and tests hold ordinary literals; the dispatch loop uses sol_object_lookup_interned. Handing the second one a name that never went through the table would answer NULL rather than fail, so -DSOLUM_CHECK_INTERNED compiles in an assertion that it did – the same bargain as SOLUM_GC_STRESS, a check too expensive to leave on and too useful never to run. The suite passes under it.

4.3a The side tables’ linear scan — done

The other half, and the one that had begun to hurt: 4.2 raised the tables from 256 entries to 65536 without touching the linear scan that filled them, so compiling many distinct literals 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, and the emitted bytecode is byte-identical – the same entry lands at the same position, only faster to find. Below sixteen entries there is no index at all and the scan stands, which is where it was always cheaper anyway and is why a method body, a block, or a REPL line costs nothing extra: measured, 60,000 REPL lines still peak at 1.9 MB.

4.4 A one-byte string is allocated per character read — done

The machine keeps one string for each of the 256 byte values and answers it rather than allocating. It is a table on the VM, filled on first use, and the whole change is four lines of behaviour spread over four files.

The case came from measuring against another language, which nothing here had done. Nine matched programs against CPython 3.14 put Solveig within nine percent overall – ahead on arithmetic and allocation, behind on dispatch – and the worst of the nine was a character scan at 2.13 times CPython’s time. Taking the read out of the loop and running the rest is what named the cause:

  with s:at(i) without it so the reads cost
Solveig 1.32 s 0.87 s 0.45 s
CPython 0.62 s 0.52 s 0.10 s

Half a second of allocation and collection over nine million characters, for bytes the machine already had. CPython keeps its 256 single-byte strings as singletons, so s[i] returns a pointer; string:at ended in sol_string_new and returned a fresh cell.

And the loop’s other half was the same fault seen from the side. "o" in the condition is a literal, and OP_STRING builds a literal fresh on every evaluation – so a scan that compares each character against a constant made two strings a pass, and only one of them was the one being looked for. That is 1.3’s last open bullet, which said interning would fix it and would need a weak table so interned strings can still die.

It needs no weak table, and the reason is the count. Symbols are weak because a program can intern a million names, so a table that kept them would be a leak with a table around it. There are 256 byte values and there will never be more: the whole table is about six kilobytes held for the life of the machine, and holding it is exactly what makes the second read free. The general case is still open for the reason 1.3 gave – a long literal has to be able to die – and is now the only part of it that is.

The test is in sol_string_new rather than at string:at, so that one byte is the machine’s copy as a property of the machine and not as something two primitives remember. Every way a one-byte string can be made goes through that function: at, asCharacter, copyFrom(#i, #i), a split that yields a single character, an extension calling the same entry point, and OP_STRING. A string is immutable and compared by value, so which copy a program holds is not a question it can ask – which is what makes this a cost removed rather than a decision about semantics.

Filled on first use rather than at startup, and 3.10 is why: a third of a request goes on building a machine, so 512 allocations added to sol_vm_init would be paid by every request to buy bytes most programs never read. Measured on hello-world, VM build is unmoved – 1.004 times, 95% interval 0.973 to 1.026, which is the tool saying it cannot tell them apart.

  before after
9.0M characters scanned and compared 1.371 s 0.758 s
the same loop with the read taken out (the literal alone)   1.49× faster
against CPython 3.14, startup removed 2.13× 1.22×
join/split, dictionary, object allocation, VM build   unmoved, four ways

The nine-benchmark geometric mean against CPython went from 1.09 to 1.02.

The table is a GC root and the collector marks all 256, which is the one place this is unlike the weak symbol table beside it. sol_vm_init zeroes it before the first allocation for the same reason the class pointers are zeroed there – that allocation may collect, and under SOLUM_GC_STRESS it does. Indexed by the byte itself, so \0 has an entry like the other 255 and byte 200 is not a negative index; neither is reachable from source, so tests/test_string.c makes both from C, walks all 256, collects twice, and asks again.

4.5 A global is a hash lookup, and a receiver check is a call — done

Two changes to the dispatch loop, both found by profiling rather than by reading, and neither of them a design question. 1.04× to 1.28× across the benchmark suite and 1.065× on a real program; against CPython 3.14 the suite’s geometric mean went 1.02 to 0.885, ahead on five of nine.

What asked was 4.4’s question repeated: where does the rest of it go? The profile that mattered was of basic.sol interpreting 39,000 BASIC statements — a real program with objects, methods and dictionaries — rather than of a loop written to be timed. Of ~1550 samples: the dispatch loop 55%, name lookup 13%, the per-send receiver check 12.6%, push_frame 4%.

The receiver check was a call across a translation unit. sol_slot_accepts is three predictable branches — is there a primitive, does it take any receiver, does the type match — and it lived in object.c while the dispatch loop lived in vm.c, so every send paid a call for them and nothing could inline it. It is a static inline in object.h now. That is the whole change, and it is 1.4% to 6.5% depending on how send-heavy the program is.

The global was a hash lookup, and that is bigger. OP_GLOBAL looked the name up on the root object every time and OP_SET_GLOBAL did it again to write. The measurement is the same loop written two ways, differing in nothing but where the counter lives:

   
i and sum as globals 579 ms
the same loop with them as block temporaries 461 ms
  1.255×, and an array index is what the second one does instead

The profile agrees from the other side: sol_object_lookup_interned falls from 218 samples to 25 between those two programs, so nearly all of it was variable access rather than send dispatch. Every top-level script pays this, and so does every line typed at the REPL.

A chunk now remembers where each global was found. One SolSlot * per name, beside the interned name and emptied by the same rule — interned_for, the VM serial — because a slot pointer belongs to one machine’s root exactly as an interned name belongs to its name table. Two caches, one invalidation, and no new thing to keep in step.

Why remembering the address is sound is a fact about this language rather than a general one. A slot is malloc‘d on its own and linked, so growing an object’s index moves pointers to slots and never the slots; and nothing removes a slot — no slotAtPut, no unbinding, which 3.10 records as a problem and this reads as a guarantee. A miss is not remembered, so a name that is not bound yet is looked up again rather than recorded as absent, which is what lets a method written before its global exists work once it does.

And the two changes together were slower than either alone, which is the part worth keeping. Written with both slow paths inline in the switch, every benchmark improved except deep recursion — which touches no global at all and lost 8.5%. Neither half did that on its own: inlined the check alone, fib was 1.011; the cache alone, 0.996; both, 0.922, with an interval nowhere near 1. Nothing had been added to that program’s path, so the cost was instruction cache — a bigger switch body, and the hot cases falling differently across it.

Moving find_global and bind_global out of line, leaving only the remembered path in the loop, fixed it and improved everything else at the same time:

  both inline slow paths out of line
fib 0.922 1.003
loop 1.251 1.284
float 1.212 1.276
array 1.184 1.237
strloop 1.152 1.228

A dispatch loop is a cache-resident thing, and code that runs once per site does not belong in it. That is the lesson, and it was invisible to reasoning about instruction counts — every version does the same work.

The invalidation is tested, and the test was vacuous at first. Deleting the free of the table made no difference, because the calloc beside it ran anyway and handed back a fresh one; the test passed against code that had lost the property it names. It only bites against a cache that genuinely survives a change of machine, and that is what tests/test_vm.c now breaks against — twenty machines in turn, each binding a different value, the second very likely landing on the first’s freed address, which is 4.3’s serial doing the job it was made for. A test that passes against the broken version tests nothing, and the only way to know is to break it on purpose.

4.6 The extension ABI is whatever is not staticdone

bin/solvm exported 146 sol_* functions where extend.h named 23 and the two bundles here used 13 between them. It exports 29 now, and they are the ones the header says.

The surface was inferred rather than chosen, and the Makefile’s own comment records the previous step of the same problem: four binaries exporting four different accidental sets, 100, 118, 133 and 118, fixed by whole-archive linking into one set of 139. That made them agree. It did not make them chosen — the rule was still “everything in libsol.a with external linkage”, which is the parser, the lexer, the REPL’s line editor and the bytecode reader as well as the machine.

So an extension could bind to any of them, and an ordinary refactor would break it silently. No error names the cause; dlopen says a symbol is not found and nothing says why it used to be. That is not hypothetical: 4.5 made sol_slot_accepts a static inline, which took it off the table, 147 to 146. It was checked by hand against extend.h and nothing was owed. Nothing in the build checked, and nothing would have said so had the answer gone the other way.

SOL_API marks the surface at each declaration, everything is compiled -fvisibility=hidden, and the exceptions are the list. Declared where the function is rather than in a file beside the linker, because the Makefile objects to hand-kept lists in three places and would be right to — a list there goes stale, and a marker on the declaration cannot. used as well as the visibility, since the two answer different questions: whether a symbol may be seen from outside, and whether it may be discarded for having no caller inside. Every one of these has no caller inside; that is what makes it an ABI.

Not on the bundle rules, deliberately. sol_extension_init is the bundle’s symbol rather than the machine’s, and hiding it would mean every extension source anywhere needed a new annotation to keep working.

Six were promoted on review, out of 70 that the header did not name and that were not obviously the compiler or the line editor:

   
sol_dict_new, sol_dict_put, sol_dict_get the gap extensions.md had already recorded — the language’s shape for an answer with fields, which net had to work around by answering an object
sol_type_name rule 1 asks a primitive to say what it was given, and withheld the function for saying it
sol_value_equals, sol_vm_class_of comparing and classifying a value without reimplementing either

The rest stay hidden, including the whole of the host API: a host links the library statically, and visibility costs a static caller nothing.

Both directions are tested, and both were broken on purpose to check. test_the_promised_surface_is_exported carried a comment saying it was weaker than it looked and had never caught anything — true then, because the lookup succeeded whether or not the link was right. It is load-bearing now: a hidden symbol is absent from the dynamic table however many callers it has in the test file, so dropping SOL_API from one fails it. The other direction had never been testable at all and is test_the_surface_stops_where_it_says — take -fvisibility=hidden out of the Makefile and it names sol_chunk_init, sol_compile, sol_parser_init and sol_lexer_init as reachable.

And a bundle proved it end to end. A separate .so, compiled with nothing but -Wl,-undefined,dynamic_lookup, loaded by the shipped solvm, building a dictionary and naming a type through the new surface:

<dictionary "kind": "integer", "same": true>

What this unblocks and does not take: -flto is 5–29% across the benchmark suite and used to delete the whole ABI, because internalising what nothing in the program references is exactly what it is for. That is now a decision about LTO’s own costs rather than a thing that silently breaks extensions — and it is still deferred, in ideas.md.


5. Tooling and ergonomics

5.1a Error recovery could loop forever — fixed

synchronise tested whether the previous token was a . before advancing, so a statement that failed without consuming anythingprimary 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. Found by a typo in a test, not by looking for it.

5.1 Solis is line-at-a-time, and lines are capped — done

Solis reads until the input could compile, then compiles and runs it. A line was never a unit of anything in this language – . separates statements and a newline is ordinary whitespace – so a method body may now span as many lines as it likes, 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 both cases are real rather than theoretical: a brace inside a string is not a bracket, and fill templates are made of braces; a ; comment runs to the end of its line, so anything in one is text. A stray closer does not take the depth below zero, or a mistyped ) would leave the prompt waiting for input that could never balance it.

The 1024-byte cap is gone rather than reported. The buffer grows, and a line is read in pieces until its newline arrives, so nothing is cut. That cap was the cause of the confusing session this entry recorded: a generated 255-element array literal looked like it failed to compile when it had merely been severed mid-token, and its tail arrived as if it were the next line. A 5000-byte line now arrives whole, and there is a test that the next line is still the next line.

Deciding this is in solis/src/input.c rather than in the loop, so it can be tested – which also gave Solis the cmd/ and src/ split the other two components already had.

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.

5.2 print on an object — done

An object is rendered by asking it: the renderer sends asString, so one that defines its own is shown that way by print, by display, by fill, and inside an enclosing array – one definition serving all four.

The seam did have to move. sol_value_render now takes a VM, which may be null; the disassembler passes null, its constants never being objects, and falls back to the address.

The recursion this invites is broken at the source: object’s default asString writes the address directly rather than 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 rather than smashing the C stack.

That was true of an ordinary object and false of a class object, which was 1.5: array:print smashed the C stack, because lookup on array starts at the object itself and found the asString that class defines for its instances. Fixed by 1.6’s receiver check, which refuses it. The renderer now asks only an object that can answer and shows one that cannot as its address, so a class object nested in an array renders rather than raising.

Still missing: nothing asks an object for a literal form distinct from its display form, the way #45 prints as #45 but displays as 45. Objects have one representation, which is probably right.

This entry also used to end by saying sol_value_print prints <object 0x...> instead of sending print to the object, and wants dispatch from inside the printer. That was read off the function’s name rather than off what it is handed, and it had not been true since f55e105. print the message goes through prim_print, which has a VM and does send asString. The function had exactly one caller — the disassembler, rendering a pooled constant — and a constant is only ever an immutable scalar, since check_constants refuses objects, blocks, arrays, strings, delegates and symbols outright. There was never a receiver there to ask.

It is now a static print_constant in bytecode.c beside its only caller, named for what it prints, so the name cannot suggest the gap again.

5.3 Float text round-tripping — done

A float now renders as the shortest decimal that reads back as the same bits, and that text compiles.

This was worse than “does not round-trip”. %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.

Shortest is not always clearest, so where a number has few enough whole digits the renderer asks for enough precision to keep %g in fixed notation: 1000 rather than 1e+03. More digits can never stop it round-tripping.

Infinity and not-a-number are written by name, and infinity and nan are now globals so those two read back. -infinity has no literal form; asFloat parses it, since strtod accepts the word.

The fix also caught a drift it was meant to prevent: prim_float_as_string had its own snprintf("%g") rather than going through the renderer, so print and asString disagreed about the same value until it was routed through.

5.4 No source position beyond the line — done for compile errors

A compile error names the line and the column, and shows the line with the offending token underlined:

[line 2:9] solas: expected '.' between statements at ','
  b := #2 , .
          ^

A token now records where it began rather than where the scanner stopped, which is what places a string spanning several lines at its opening quote instead of wherever it ran out. Error tokens changed shape for this: the complaint moved to a message field so that start and length point into the source for every kind of token, and an unterminated string can be underlined like anything else.

Two details that are easy to get wrong and are 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. And a long line is windowed around the token rather than spilled whole – which matters more since 5.1, because Solis will now read a line of any length.

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, so a column would be a second table in every .sob, carried always and printed only when something has already gone wrong. Worth revisiting if a debugger ever wants it.


5.5 Five programs each wrote the same cursor — done

lib/scan.sol — a position and the questions you ask at one. Raised on 2026-08-25 out of a survey done for a different reason, and closed the same day.

The case. lib/json.sol, lib/html.sol and experiment/lexer.sol each defined pos, peek and a step — two calling it step and the third advance — and programs/expect.sol and programs/serve.sol did the same scanning inline without naming a cursor at all. Roughly 460 lines of it. Nothing was blocked, which is why it went in section 5 rather than section 1: the cost was that a fix to the cursor was a fix in five places, and nobody would make the other four.

The entry said: write it, convert json.sol only, and let that say whether the interface is right before anything else moves. That is what happened, and the conversion changed the interface twice.

   
since(start) takeWhile describes a run of one kind of character. JSON’s number is a sign, then digits, then perhaps a fraction and an exponent, and the caller wants all of it. Not in the entry’s list; the conversion demanded it.
take(#n) \uXXXX wants exactly four characters. Also not in the list.
The block is never handed nil Every hand-written version opened peek:notNil:and({ ... }). That is the cursor’s business: a predicate is a question about a character, and running out is not a character.

What it cost and what it paid. All five are converted now, and the honest total is that the library roughly pays for itself and no more:

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. Anyone expecting a library to shrink the repository should read that and stop expecting it. What was actually bought is that there is one implementation of a cursor instead of five, and the two files that could not agree whether the method is called step or advance no longer have the choice.

expect.sol returned nothing, and that is the finding that corrects the survey. The survey counted four scanning sites in it. Only one — commentAt — is cursor-shaped. wordBefore runs backwards from the end of a line; markersIn searches for a substring rather than reading characters; asCount filters every character rather than stopping at one. A cursor is forward, character-at-a-time, and stops. Counting a program’s scanning by eye counted three things that were not this.

The conversion was proved rather than asserted. 38 inputs through json:read, output recorded before and after, identical at the end. It caught one difference no test covered: hex4 built on take moved before it checked, so a malformed \u00 complained about a character four further on than it used to. The check is asked before the take now.

And the baseline found a defect that had nothing to do with any of this: json:read had been unable to read a string containing \n since 2026-08-21, because the escape table was deleted and the two lines reading it were left behind. Four days, four releases. Two of the 38 inputs were already wrong before the refactor started.

The one thing this did not answer is whether endsWith belongs on string for everyone, deferred on 2026-08-24. scan.sol did not need it, so it is still open and still has no program behind it.

What the conversions turned up that was not about cursors

Two defects, neither of them in the code being changed.

json.sol could not read a newline, and had not been able to since 2026-08-21 — found by recording a baseline before the refactor rather than by looking for it. Two of the 38 inputs were wrong before a line was touched.

experiment/prove.sh built one generation with a different search path from the others. bin/solas experiment/compile.sol had no -I lib, because the experiment’s files only ever included each other; every other invocation had it. The moment lexer.sol included scan.sol the fixpoint failed — and failed on file names, because a .sob records the file each line came from and lib/scan.sol found two ways is two different strings. Both compilers agreed about every instruction. The asymmetry had been there since the proof was written and could not be seen until something crossed it.

6. Beyond the language

The rest of this section is live, and is in ROADMAP.md.

6.45 A pipe cannot be taken in bounded pieces — done

What 6.43 left behind when it closed on 2026-09-03, and it is the half that whole-pipe reading is no use for. Raised by sort.sol rather than by diff, on 2026-09-02, and recorded inside 6.43 at the time because it arrived as a second customer for what looked like one want.

The two routes in are both wrong for it, in opposite directions. readLine is fast and lossy: it drops the terminator and folds \r\n, so a file written on another system sorts as different lines. readKey is exact and is a byte at a time. Measured over 628,890 bytes: 0.0075 s by line against 0.1498 s by byte, 84 MB/s against 4.2, or 238 nanoseconds a byte — stdin-cost.sh reruns both over the same bytes in one pass, and the figures vary a few per cent rather than an order of magnitude.

And the third route is the whole read, which is the opposite of what is wanted. sort spills to disk past -S precisely so that memory stays bounded however large the input is; reading the pipe whole first defeats the thing the option exists for. So the answer is a middle — neither the whole of it nor a byte of it — and no customer alone could have shown that. diff wanted the whole and got it; sort wants the middle and has neither.

The third customer was written on 2026-09-04, and it sharpened the entry by disagreeing with it. gzip.sol is the first program here whose input has no lines at all: a gzip stream of ideas.md holds 0x0a bytes, 0x0d bytes and NULs, every one of them data. readLine is not lossy there, it is meaningless — so the half of the prediction that said it would have exactly one route in is right.

What was wrong is the implication that one route is not enough. The route works. system:readFile("/dev/stdin") reads a pipe whole since 6.43 closed, ... | solvm gzip.sob -d is what that program’s own sweep runs, and nothing about it is blocked for want of anything here.

It is a customer for the reason sort is, which is memory. Measured with --memory=N, binary-searched the way an instruction count is: 185,364 bytes of output want 5,563,386 bytes held, and 392,567 want 13,116,403 — thirty bytes for every byte produced, where the format asks for a 32 KB window however large the stream is. Four copies of the data are alive at once, of which a bounded read would retire two.

So the entry has two customers and one argument rather than two, and the honest reading is that its shape was settled by sort alone. What the second adds is that the want is not peculiar to sorting: any program that produces more than it can hold meets it, and inflate is the second of those written here.

decision — what shape it takes is not settled, and the reason to say so rather than to guess is that the obvious spelling is already taken. A range on a stream is refused by readFile, deliberately: a range means positions, and a caller asking twice for the same range expects the same bytes where a stream would answer whatever had not been consumed. So a bounded read of a pipe is a different question from a bounded read of a file, and giving them one name would be the mistake new made. What it wants is a message that says take up to this many bytes, and tell me how many arrived, which is a small primitive and a name this document should not pick on its own.

Closed on 2026-09-04 as system:readUpTo(#n) — up to n bytes of standard input, exactly as they were sent, and nil at the end.

The decision above was the name, and it took four goes. Three were dropped and the three were dropped on evidence rather than on taste, which is the part worth keeping.

readSome was the first proposal and the weakest: it says the answer may be partial and nothing more.

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 it collides with the 4 KB window in solum/src/stdin.c that the message reads out of, so readBuffer(#65536) would be asking the buffer for sixteen times what the buffer holds.

readPart collides with the other half of the very distinction this entry existed to protect: tests/test_system.c calls the range test test_a_range_reads_part_of_a_file, and sha256sum.sol already calls the value it gets back from readFile(path, at, count) a part. The file side had the noun.

readPiece was free, was committed, and was renamed within the hour, because a free noun is not the same as the right one. piece says the answer is part of something; it says nothing about n, which is the whole of what a caller has to know.

readUpTo is the one taken, and the argument for it 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 exactly what this message’s argument is — readUpTo(#4096) answers a string of one to 4,096 bytes the way upTo(#6) answers an integer of one to six. The name is not new vocabulary, it is the vocabulary already there, and the objection raised against it first — a preposition, which nothing else in system: has — was wrong on the facts.

The contract is read(2)’s and not fread’s, which was the second call and the smaller one. 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, so this is the primitive of the two. It is also what keeps an interactive stream working, where waiting for a full buffer would stall.

Two things fell out that were not asked about and are worth recording. #0 is refused rather than answered with "", which keeps a non-nil answer from ever being empty and so keeps nil unambiguously the end — the signal readLine and readKey already give, and the reason notNil exists (2.14). 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 — which sort.sol states as a reader here is a path and an integer.

No new GC root, and that is a claim rather than an omission: the only allocation is the string that is returned, which leaves by the same path readKey’s does, and the bytes it is built from live in the static window the collector never sees. A hundred calls under SOLUM_GC_STRESS arrive intact, which is the test that says so.

It is the third reader through the one window 6.36 built, so readLine, readKey and readUpTo interleave without losing a byte between them.

And the thing it was built for, measured. Two scripts counting the newlines in the same stream, one holding the whole of it and one holding a piece, both answering what wc -l does. The ceiling is 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. That is the whole of what sort asked for on 2026-09-02 — -S bounds what it holds, and reading the pipe whole first defeated the option — and what gzip -d wanted two days later for the same reason.

Both customers were converted on 2026-09-04, the day after this closed, and the paragraph that stood here said neither had been. What it predicted was a bounded read retires two copies of four for gzip -d, and that is the half worth scoring:

  before after  
gzip -d, 187,655 bytes out 6,615,294 4,528,936 31.5% less held
gzip -d, 397,342 bytes out 13,121,439 8,942,620 31.8% less held
sort, 584,997 bytes in 28,846,431 15,402,663 1.87x fewer instructions

The prediction was right about which copies and understated what they cost. Two of four is what went. That a before-and-after on one file cannot actually show — so it was shown by holding the output still and varying the input, across seven streams that all produce 187,655 bytes: the before figure climbs 6,615,294 → 8,710,802 as the input goes 65,881 → 187,693, and the after figure does not move at all, 58 KB of scatter with no trend. A gigabyte through the pipe holds the same 4,096 bytes of it that a kilobyte does.

And these numbers are ceilings with a coarse grain, which is worth stating beside the table above as well. The smallest --memory=N a run survives is where the collector’s heap threshold next lands, not what the program holds: it steps, by 2,095,509 bytes in one jump across that range, and five compression levels of one file — 200 KB of boxed integers between the extremes — give 6,615,294 to the byte, all five. Honest for a comparison run both ways on the same input, which is what every figure here is; not honest read as what the program holds. sort was not predicted to get faster and did: the byte-at-a-time reader was spending 23 instructions a byte, and the pipe route now costs what the named file costs, to within 0.03%.

And it cost 1,206 instructions in gzip — 0.003% — but only after the guard in nextByte was written twice. The first version called the refill test on every byte and cost 1.8%, on the named-file route as well, where nothing ever refills.

What the conversion turned up is a hole in a check, not in the language. programs/gzip/sweep.sh named its input file on the command line in all 66 of its cases, so the pipe route — the one this entry exists for — was tested by nothing. oracle.sh has run every case both ways since sed, and the shape worth naming is that sweep.sh is a different script: the rule was correct where it was written and absent from the file written next to it. Both sweeps run both routes now, and both were proved to fail rather than assumed to.

6.43 A program cannot read standard input whole, and the call that looks as though it can answers ""done

Raised on 2026-09-02 by diff.sol, which is the first program here that has to reproduce another tool’s bytes from a pipe. Two halves, and the second was a defect rather than an absence. Both closed on 2026-09-03 in one change, which is the part worth keeping: they read as two jobs and were one.

readFile asks whether the stream is seekable rather than assuming a size. A pipe grows a buffer as the bytes arrive, so readFile("/dev/stdin") answers the same string from < big.txt and from cat big.txt | — 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.

The only reason a program could not read a pipe whole was that the call which should have done it returned early, which nothing had noticed because the early return was indistinguishable from a correct answer about an empty file. The title’s first clause was a consequence of its second, and fixing the second was the whole of the work.

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. That would be one message meaning two things, which is a mistake this language has made once already with new. A redirect is seekable, so the same range works there.

What was left is 6.45, which took a number of its own rather than staying inside a closed entry: a pipe in bounded pieces, which is sort’s want and not diff’s. A whole read is the opposite of it, so none of the above helped — which this entry had already said, and is worth having believed.

The case as it was argued follows.

A second customer arrived the same day, with a reason this entry did not have. sort.sol does not care about the newline at the end – its output always ends with one – and wants the opposite of a whole read: a pipe taken in bounded pieces, so that memory stays inside -S however large the input is. readKey does that at 238 nanoseconds a byte; readLine does it at a twentieth of the price and changes the answer, because folding \r\n makes a file written on another system sort as different lines. So what is missing is a middle – neither the whole-file read nor a byte at a time – and one customer alone could not have shown that.

And a third would sharpen it again. gzip -d is on the survey and would be the first program here whose input has no lines at all: a 73,572-byte gzip stream of ideas.md holds 264 0x0a bytes, 254 0x0d and 273 NUL, every one of them data. readLine would not be lossy there, it would be meaningless – so that program would have exactly one route in, and ... | gunzip is the ordinary way it is used. Measured on 2026-09-02 while deciding what to write next, and written down here rather than in the argument for writing it.

The want. readFile reads a file whole and there is no equivalent for standard input. The two ways in are readLine, which answers a line without its terminator and folds \r\n into one – so it cannot say whether the last line ended with a newline, and silently rewrites a file written on another system – and readKey, which is exact and is a byte at a time. Measured over 628,890 bytes: 0.0075 s by line and 0.1498 s by byte, 84 MB/s against 4.2, or 238 nanoseconds a byte. diff pays it, because a diff that cannot tell ...c from ...c\n is wrong rather than slow.

sh programs/stdin-cost.sh is the measurement, kept rather than thrown away because this entry states its numbers. It reruns both routes over the same bytes in one pass and demonstrates the defect below at the end of it. A run varies a few per cent – 20 to 22 times, 240 to 260 nanoseconds – and the figures above are the first run’s; what does not vary is the order of magnitude.

The defect, as it stood. The obvious workaround is readFile("/dev/stdin"), and it worked from a redirect. From a pipe it answered the empty string – not the contents, and not an error:

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

prim_system_read_file sizes the file with fseeko(SEEK_END) and ftello. On a pipe the seek fails and size keeps its initial 0, which is indistinguishable from an empty file and takes the want == 0 path that answers "" before any read is attempted. The function already refuses a directory and already checks a negative size; a failed seek is the case between them that nothing looks at.

This is the shape that a path with a NUL in it has, found on 2026-08-31: a silent wrong answer rather than a missing feature, and found the same way – by a program with a reason to try what nobody had tried. The two halves are one entry because the want is what makes the defect worth fixing: if readFile read a pipe, there would be nothing here to ask for.

6.42 A second producer of .sob has no contract to build against — done

The first program written against this language by somebody who did not write the language. Phoenix is a compiler-generator, outside this repository, and it emits .sob directly — not Solum source for solas to compile. The stated plan is that it will emit bytecode for other source languages too, which is what makes this an entry rather than a courtesy: the moment a second producer exists, the .sob format stops being a detail between solas and solvm and becomes an interface.

It has already cost its author time twice, and both were documentation rather than defects: display’s newline, and a comment convention that looks like a claim about the language. Both are fixed. What is not fixed is that there is no one place that says what a producer must get right.

What a producer needs and where it is

.sob itself is well served and that is worth saying first. BYTECODE.md gives every opcode with its byte, held to the header by three tests; serialize.h gives every field; disasm.sol reads the format independently and experiment/emit.sol has already written one by hand, byte-identical to solas. A producer targeting the format alone is in good hands.

What it has not got is everything the compiler enforces that the grammar cannot. Held against solas’s own diagnostics, in four groups:

  where it is written down
self is only meaningful inside a block, and cannot be assigned nowhere
a duplicate temporary, or one shadowing a parameter nowhere
chunk limits: constants, names, blocks, arguments, parameters, array elements, dictionary pairs, names in a frame nowhere
a conditional or loop body too large to jump over nowhere
comparisons do not chain; only - and ~ open a unary inside @expr REFERENCE.md
a directive must stand alone; a file that includes itself REFERENCE.md

The grammar cannot carry the first four, which is not a defect in the grammar. solum.bnf says of itself that there are no reserved words, and that is true: self is an ordinary identifier that happens to be bound. A front end built from the grammar alone will accept self := #1 and { | t, t | t }, and both are errors. That was measured on 2026-09-01 — the grammar and solas agree on 94 of 94 shipped files and on 15 constructs, and disagree only where a scope rule is involved.

And the verifier says one sentence for thirty-five faults

sol_chunk_verify returns SOL_SER_MALFORMED from 35 distinct conditions, and every one of them reports bytecode is internally inconsistent: a jump past the end, a stack height that does not balance, slot_count < arity + 1, a name index out of range, a line run overrunning the code, a chunk not ending in HALT or RETURN.

For solas that is the right bar and the entry that built it said so: a corrupted opcode arrives as a message rather than a crash. For an outside producer it is the difference between a five-minute fix and an afternoon bisecting a binary — and these are exactly a code generator’s bugs, not exotic ones. emit.sol is the only independent producer this repository has ever had, and its account records the verifier catching a fault as a virtue, without recording what it took to find out which fault.

Two shapes, and they are not the same size

A documentwriting a producer, collecting the rules above with the chunk limits and their values. Perhaps two pages, no code, and it can be written from solas’s diagnostics in an afternoon because they are already exhaustive. It is the whole answer for a producer emitting source, and half the answer for one emitting bytecode.

Splitting MALFORMED — a result code, or a message, that names which of the thirty-five it was. This is the half a bytecode producer actually needs, and it is a change to shipped C with a compatibility question attached: the codes are part of what solvm reports and something may be reading them.

A conformance corpus is the third thing and it depends on the second. A suite of deliberately malformed .sob files, each asserting a specific diagnosis, cannot be written while every diagnosis is the same sentence. The corpus is the forcing function for the split and not a separate job.

The calls

  1. Document first, or split first? Documented on 2026-09-01: PRODUCING.md, linked from BYTECODE.md and GRAMMAR.md, with every rule triggered before it was written down and every limit found by generating programs until solas refused.

    Writing it found two defects, which is the argument for having written it rather than described it. 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 the dictionary limit is 127 pairs, not the 254 its message implies, because a literal lowers to dictionary:of and the ceiling is the argument list’s.

  2. How far does the split go? Done on 2026-09-01, and further than the recommendation. Splitting by who is at fault was the plan; what the code turned out to want was a sentence at every site, because the sites are already one condition each and grouping them would have thrown away the part a producer uses. Thirty-two sentences, and a single-byte fuzz over one small .sob now yields twelve distinct diagnoses where it yielded one.

    The enum did not move, which was the compatibility question and is now answered by not needing an answer: SOL_SER_MALFORMED still means what it meant, and 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, so no caller and no test had to change. Nothing is stored between calls, so two threads verifying two chunks do not tread on each other.

  3. Is a stability promise owed? Answered on 2026-09-01: format 15 will refuse 14 and everything before it, and 14 already refuses 15 — the check is an equality rather than a floor, so a newer file is as unreadable as an older one. No compatibility window, none planned, and a rebuild rather than a migration is always the answer.

    It needed no code, which is the point worth recording: the behaviour was already exact-match in both directions and was verified by handing solvm files claiming 13 and 15. 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. It is in BYTECODE.md and PRODUCING.md now.

  4. Does the corpus live here or there? Here, and not as files. Built on 2026-09-01: seventeen cases in test_serialize.c, thirteen distinct diagnoses, each constructing a chunk with one fault and asserting the sentence rather than the code — so a diagnosis changing, or two merging back into one, fails the build.

    A directory of malformed .sob files was the shape the entry imagined and is the wrong one. 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 our broken files; it needs its own diagnosed, which is what the split gives it. What the corpus is for is keeping the sentences still, and that is done where the chunks can be built directly.

Not built. The scoping is this entry; building is a separate instruction.


Closed on 2026-09-01, the day it was opened, and by four pieces of work in the order the entry recommended and mostly not the shape it recommended. PRODUCING.md says what a grammar cannot carry and what the limits are; the verifier says which of thirty-two conditions it refused on; seventeen cases pin thirteen of those diagnoses; and the format version is a stated promise rather than an implementation detail.

Three of its 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 files, and the version promise needed no code at all. Each correction is recorded beside the call it corrected, which is the use of writing the calls down separately in the first place.

And the entry paid for itself twice before it closed. Writing the document found SOL_MAX_LOCALS at 256 against a u8 slot_count, so solas was emitting bytecode its own verifier refused. Splitting the diagnosis is what would have named that fault instead of hiding it.

6.41 A path that stops existing is an error rather than an answer — done

tail -f dies on a log rotation. Not fails to follow — dies, with tail: cannot measure 'x.log' and exit status 1, the moment the path stops existing:

$ ./bin/solvm tail.sob -f x.log &
$ mv x.log x.log.1
one
tail: cannot measure 'x.log'      # exit 1

tail.sol polls system:fileSize once per file per interval, and fileSize raises when the path is gone. The file it says it cannot measure is one that existed a second earlier and will exist again a second later, which is the whole of what a rotation is.

The tool on the machine waits, on both flags and in both cases. Driven through the same rotation, and through a removal-then-recreate, /usr/bin/tail carried on every time — where it goes on reading is the table in 6.39, and it is not the same for the two flags; what matters here is that neither of them stops. tail.sol exited 1 on all four. It is the comparison oracle.sh exists for, and this is a case that harness was never pointed at, because making a rotation needs a second process and every check it runs is one command against one input.

Two of the four path messages already answer rather than raise. system:fileExists and system:isDirectory both swallow a failed stat and answer false — a path that is not there is not an error to them, it is the answer. system:fileSize and system:modifiedAt raise. The four are asked about the same thing in the same breath and disagree about what absence is.

The decision, taken and built on 2026-09-01: fileSize and modifiedAt answer nil for a path that is not there. A real failure — a permission denied, an I/O error — goes on raising, so this narrows what raises rather than removing it. Nil is what getenv answers for a name that is not set and what terminalSize answers off a terminal, so the language already spells there is none this way.

Nil does not fix tail on its own, and that is the point of writing it down separately. With fileSize answering nil the poll loop reaches now:lessThan(sizes:at(i)) with a nil now and raises ‘lessThan’ expects integer, got nil — further from the cause than the message it replaces. What nil buys is that the program can now say what a vanished path means to it, which is a decision tail has to make and the VM cannot make for it. Fixing the message and fixing the program are one unit of work, not two.

The workaround exists and is a race. system:fileExists(path):ifTrue({ system:fileSize(path) }) is what mirror.sol already writes, and there are thirty fileExists calls across the tree standing in front of a read or a stat. Two stat calls where one would do, and a window between them in which the file can go — narrow, and exactly as wide as the event being guarded against.

What it costs, counted rather than estimated. Two assertions move: system:fileSize("build/tests/no-such-thing") in test_system.c and system:modifiedAt("no-such-file") in test_time.c sit in refused tables and would go to an answers-nil one. Of the call sites, two raise on a nil and one improves: tail’s lessThan and greaterThan raise, which is 6.41’s own subject; mirror’s fileSize(from):equals(fileSize(to)) answers false, since equals is total across types — so the fileExists guard in front of it becomes removable rather than broken. The rest hand the answer to fill, which prints nil where a number was: manifest, pascal, page and examples/files.sol, all of them measuring a file they have just written. The reference’s system table gains a word on two rows. Nothing in lib/ calls either message.

Built the same day it was scoped, which is not the usual rhythm here and is worth saying why: the scoping had nothing left to decide once the two questions in it were answered, because the decision was which of two shipped messages is wrong rather than what to add.

system:fileSize and system:modifiedAt answer nil for ENOENT and ENOTDIR and go on raising for everything else. tail:follow holds nil in its size array to mean gone when last looked at, and reads from the beginning what comes back — no notice, because a file that returns is a different file rather than the same one cut short.

Two scenarios went into follow.sh, and both would have ended the run with cannot measure before this: a rename with a new file at the path, and a removal with the path created again later. Both compare against the oracle’s -F rather than its -f, because this tail polls a path and has no open file to keep — see 6.39 for what that difference is, and for the throwaway measurement of it that this harness caught being wrong.

No GC proof is owed. Both changes answer SOL_NIL_VAL on a branch that used to raise; nothing is allocated, so there is no root to prove load-bearing.

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.

6.39 A program cannot tell whether two paths are the same file — done

Nothing answers a file’s identity. system:fileSize and system:modifiedAt are the whole of what can be asked about a path, and two different files can agree on both — which is exactly the case that matters, since a log and the log that replaced it are usually the same size for a moment and can share a second.

tail.sol wanted it on 2026-08-31 and could not have it. tail -F follows a file across a rotation: the path stays, the file behind it is replaced, and the program has to notice and start again on the new one. There is no way to notice. -F is named in that file as the one thing it cannot write, rather than approximated with a heuristic on size and time that would be wrong exactly when a rotation happens.

One customer, one flag, and the entry says so. This is not a gap anything else has hit. mirror.sol copies a tree into a directory inside itself without trouble — it lists the source before it writes anything, so it never walks into its own output — which is the other program that would plausibly have wanted this and does not.

Two shapes, and they are not the same size.

system:sameFile(a, b) answers a boolean and invents nothing: two stat calls and a comparison of device and inode, entirely inside the primitive. It answers the question -F asks — is the file at this path still the one I was watching? — only if the program can re-ask it against a path, which is not what -F needs: the old file may be gone by the time the question is asked, and a boolean about two paths cannot be held.

system:fileId(path) answers something a program can keep — a string, most likely, since device and inode together do not fit an integer on every platform and a string is the type this language reaches for when the value is opaque. A follower holds the id it started with and compares each time round. That is what -F actually needs, and it is a value whose only operation is equals, which is a small thing to add and a real one to have to explain.

The trigger is a second customer, and a plausible one exists without being here yet: any program that must not write a file onto itself, a watcher of any kind, or a lock that has to survive a rename. Until one of those arrives this has a single caller and a workaround of do not offer -F, which is what the program does.

Measured on 2026-09-01, and two of the entry’s assumptions moved.

The string is right, and now for a reason rather than a guess. dev_t here is a signed four-byte integer and ino_t an unsigned eight; on Linux both are unsigned eight. So the pair does not fit an int64 on Linux, and on this machine a device number can be negative/dev/null has one. Whatever formats the id has to be sign-correct, which is a detail that would have been found by a crash rather than by reading.

What -f and -F actually do, and the correction that took three goes.

  -f -F
renamed away, new file at the path goes on reading the renamed file follows the new file
removed, then recreated later prints nothing more, keeps running follows the new file

-f follows the descriptor and -F follows the name, which is exactly what the man page says. An earlier version of this entry said the opposite, on a throwaway that ran both flags in one script and reproduced its own wrong answer four times. What caught it was follow.sh — the harness runs the two sides under one set of conditions instead of two — and what settled it was lsof on the running process, which shows tail -f holding the renamed file open after a rotation. Direct evidence beat a reproducible experiment, and the experiment was reproducible because it was reproducibly wrong.

The rule that would have saved the hour is the one this repository already has: a comparison whose two sides did not run under the same conditions is not a comparison. It was applied to the checks and not to the throwaway that measured the oracle.

Neither dies, which is the finding the table was really for: across a rename and across a removal, both flags of the tool on the machine carry on, and tail.sol exited 1 on either until 6.41.

So this tail’s -f is the oracle’s -F, and cannot be anything else: it polls a path and has no open file to keep, so following the name is the only behaviour available to it. That is written down in the program rather than left to be inferred.

And 6.41 came first, which the same hour found, and it is now done. Following a rotation has two halves — surviving a path that is not there, and noticing the file behind it changed — and only the second is this entry. The first is a defect in fileSize today, needs no new kind of value, and tail -f exits 1 without it. Building fileId first would put a message with one caller into a language where that caller dies before it can call it.

And the gap it leaves is silent, which the entry did not say and should have. Measured on 2026-09-01, after 6.41 made tail -f survive a rotation at all — a log replaced inside one poll interval by a file of the same size:

   
/usr/bin/tail -F AAAA BBBB CCCC
tail.sol -f AAAA CCCC

BBBB was written to the log and never appears. The poll sees five bytes, then five bytes, then ten, and prints from offset six — so a whole line is skipped, with no error, no notice and no exit status. This is not “the wrong offset”, it is losing a line and saying nothing, which is the failure a log follower exists to not have.

Size is the only discriminator available, and it is wrong exactly when the replacement matches. A fresh log is usually empty, so shrink-detection catches the common case by luck; this is the case luck does not cover.

The trigger never fired, and it was built anyway — on instruction, on 2026-09-01, with one customer and one flag exactly as the entry had said for two days. Recording that is the use of having written a trigger down; the entry does not get to quietly become right about its own admission rule.

What changed was not the trigger but the case. The entry said -F prints from the wrong offset. Driving it said something worse: a line written to the log never appears, and nothing reports it. A gap that loses data silently is a different argument from a gap that misprints, and it was available to be measured on the day the entry was written.

6.41 settled one of its open questions for free. A follower asks for the id every poll, and during a rotation the path is not there — which used to raise. fileSize and modifiedAt now answer nil for an absent path, so fileId had a precedent to follow rather than a decision to make: nil for absent, raising for a path that cannot be looked at. Building the two in the wrong order would have put a message with one caller into a language where that caller died before it could call it.


Built on 2026-09-01. system:fileId(path) answers device and inode as a string — "16777234:231399178" — with only equals promised of it. The shape is the one this entry argued for two days earlier and did not have to revisit: a value a program can keep, since a boolean about two paths cannot be held across the moment one of them stops existing.

The format is readable rather than opaque, which was the one call the entry left open. An id turns up in a trace and in Solid; this repository has taken the legible form every other time it had the choice, and opacity in a string is a convention either way — the reference says only equals is promised rather than pretending the type enforces it.

Four properties, each a different question, and all four are asserted: the same path twice gives one id; a hard link gives 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, since identity is the file’s and not the path’s; and a replacement of exactly the same size gives a different one, which is the case nothing else here could see.

No -F flag was added. This tail polls a path and has no open file to keep, so it was already -F-shaped; a second flag would have been a second name for one behaviour. -f simply stopped losing data. follow.sh gained the scenario — a five-byte log replaced by a five-byte log — and it goes on comparing against the oracle’s -F.

No temporary root, and the comment says why: the text is built on the C stack and the one allocation is the answer, with nothing live to lose across it. Fifty ids under gc_stress come back equal, which is what stands in for removing a root that is not there.

And an implicit include was found by adding it. intmax_t was arriving through some other header on this machine; <stdint.h> is now included explicitly, because a headline of no dependencies, portable C11 is not compatible with relying on what a platform happens to pull in.

6.40 A program cannot ask whether a stream is a terminal — done

system:isTerminal(which), where which is 'input, 'output or 'error. One primitive over isatty, three symbols, no state.

Two programs asked, which is what promoted it from a note. Every program in programs/ runs with no arguments on input it carries, and ... | tail and ... | sha256sum are real invocations with the same empty command line. tail.sol hit that collision on 2026-08-31 and wrote it down as a note with a second program as the trigger rather than as an entry; sha256sum.sol was the second the same afternoon.

Three symbols and not three messages. isTerminalInput, isTerminalOutput and isTerminalError would be three names for one question, and the argument is the thing that varies. 'input rather than 'stdin because the language spells them out everywhere else it can — readLine, write, writeError; run’s options array is the one place the C names appear, and there they are keys a child process cares about.

It was built to retire a paragraph and it fixed a defect instead

The workaround was described as exact, in this entry and in both programs, and it was wrong. keyWaiting(0.0) answers is there a byte right now and is documented as true at the end of input, and the argument ran: a terminal nobody is typing at says false, a pipe with data says true, a pipe at its end says true. Three cases, each of them 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. So with no arguments:

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

printed the demonstration and threw the input away. So did sha256sum. Both now ask isTerminal('input) and both read the pipe.

Nothing was going to catch that. A pipeline written in a test or typed at a prompt has its first byte ready before the program starts, so the case is invisible everywhere it would normally be looked for; it needs a slow writer, which is a thing nobody constructs on purpose. It was found by building the message and then asking what the old spelling had actually been answering — the question you only ask when you are replacing something.

The lesson is about the shape of the claim rather than about terminals. 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 the states of the thing actually are — a pipe has four, not three, and the fourth has no data and no end.

And the output half was already answerable by accident

6.34’s system:terminalSize calls ioctl on standard output and answers nil when that fails, so terminalSize:notNil had been isatty(1) since it shipped — at the price of building a dictionary and throwing it away. Found while writing the entry rather than assumed, and checked through a pseudo-terminal both ways. tests/test_system.c keeps the two agreeing, so the accident is now a documented equivalence rather than a thing to be rediscovered.

What is tested

Three tests, and the first is the one that could be wrong quietly: a pseudo-terminal is put on exactly one of the three descriptors at a time, with 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 of the mapping fails it. A test that only ever saw one stream answer would pass with two of them exchanged.

The primitive allocates nothing and answers a boolean, so there is no GC root and none was added.

6.38 Nothing says what a compiled file exports — done

solid --exports, a mode of the debugger that runs a .sob or loads a .so and then says what is in the machine that was not there before.

The problem, and the three things that nearly solved it

Somebody has a compiled file and wants to know what may be sent to it. That is the first question anyone asks of a library they did not write, and the answer was read the source, which is not available for a .sob shipped without one and does not exist for a .so at all.

Three things already reached part of the way, and it is worth writing down which half each of them was missing, because that is what decided the shape:

  reaches misses
solvm --dump, disasm.sol the instructions it is a disassembly. SETGLOB and SETSLOT are in there and nothing collects them
solid’s globals the names, and a .so’s too, since it takes --extension= names only, no messages, and it is a prompt rather than a command
slots, exports, respondsTo the whole surface, exactly you have to know the name before you can ask

The third row is the constraint, and it was already documented. globals is the one question a program cannot ask itself: the globals are slots on an object with no name in the language, so neither slots nor perform reaches them — 2.10 in ROADMAP.md and design.md. Once a name is in hand the language answers everything else; what it cannot answer is what the names are.

So this could not have been programs/exports.sol, sitting beside disasm.sol where by every other measure it belonged. Whatever answers it must hold the root object directly, and two things in this tree do: a host, and Solid.

Static or dynamic, and the throwaway that settled it

The safe design is to read the file: walk the top-level chunk, collect every OP_SET_GLOBAL, and never run a line. No side effects, nothing to bound, and it works on a file that would fail if executed.

It is also wrong, and a hundred-line throwaway is what showed it.

So the file is put into a machine and the machine is measured before and after — the root’s slots for what was bound, and every built-in class’s slot count for what was extended. Both file kinds end in the same place, which is why one mechanism reads both and why naming a bundle and a file on one command line gives two reports rather than one heap.

The cost is that the file runs, and it is stated rather than hidden, in the help text and in the reference. A library binds its names and stops, because that is the whole of what a library does. A program does whatever it was written to do first — programs/tools.sob shells out to du and git before the report appears, and extensions/net/client.sob spends six seconds failing to reach a server. A file that does not finish reports what it had bound by then, says that is what it is, and leaves with 70.

Why a mode of the debugger and not a fifth binary

The four names mean something, and a fifth would need one — plus a paragraph in the README explaining it — for a capability Solid already has. Solid is the tool that takes a thing apart; it holds the root object, which is the whole requirement; and it already takes --extension=, so the .so half needed no new machinery at all. globals at the prompt becomes the one-line cousin of this rather than a rival to it.

Not solas, and that one is a rule rather than a preference: a compiler that loaded native code in order to read a file would put dlopen into the compiler, which extensions.md refuses by name. Not solvm, which is the thing that runs a program.

The boundary is the answer, so it is honoured

An exports boundary is the file’s own statement of what may be sent to it, which is exactly the question. Names it keeps private are counted rather than listed, and --exports=all lists them, marked. An object that never drew one has every name listed, which is the truth about that object — not drawing a line is not a weaker line.

What it found, including the finding that was wrong

One hazard in its own implementation, closed rather than survived. The report holds an SolObject * across the run, and a file may rebind the name an extension bound — probe := #1. — at which point the object measured before has no root left and the collector may take it. It passed under ASan with SOLUM_GC_STRESS=1 on the first attempt, which proves only that the collector did not get to it that time. The name is looked up again now and the two pointers compared before either is read: a comparison, never a dereference. Equal means the slot still holds it, and holding it is what keeps it alive. There is a test, and it runs under the sanitisers.

And one finding that shrank to nothing on the second look. The throwaway reported that lib/json.sob carried a scan with no exports boundary where lib/json.sol says it should have one, and sixteen .sob files in the tree differ from a fresh compile. That was written up as a defect before checking one thing: .gitignore has *.sob, and make install copies lib/*.sol. Nothing in lib/ is tracked, built by make, or installed. They are hand-compiled leftovers on one machine and the correct response is rm. Kept here because the lesson transfers: a diff against the working tree is not a diff against what is published, and git ls-files was available the whole time.

6.37 indexOf cannot say where to start — done

indexOf(s, #from), a second arity on the message that was already there.

Two shipped files had written the workaround, which is the number this repository has taken to mean build it6.19 and 6.23 were papercuts of the same shape. A second search in the same string could only be written by copying what was left of it:

lib/pattern.sol     text:copyFrom(from, text:size):indexOf(leader)
programs/expect.sol rest:copyFrom(at:add(marker:size), rest:size):indexOf("-->")

A second arity rather than a second message, because it is the same question. at(key) and at(key, default) set that shape and run, sorted, asString, random:new and timeToRun all follow it. The message count does not move: 138 before and after.

#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. Further out is a mistake and says so, as is a float.

What it was worth, measured, including where it was worth nothing

The honest part first. On the workload that started this — a global substitution over 50,000 short lines — it is 2.35s to 2.25s, four per cent, which is nothing. A short line makes a short copy, and copying a short line is cheap.

Where it matters is a long line, because the copy is quadratic in the length of one. Searching a single 80,000-character line for a pattern with a common first character and no match:

   
copying the tail at every candidate 0.14 s
searching from a position 0.05 s

Minified JSON, generated code and a log line are all one long line, and the editor will meet one. A megabyte on one line would have been twenty seconds and is now under a second.

And the second customer got shorter rather than faster. programs/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. It walks an index over one string now: four lines shorter, one variable fewer, and the copies gone. That is the better argument of the two — a primitive that lets a program say what it means removes the bookkeeping that saying it another way required.

6.36 readLine and readKey did not share an input buffer — done

A program that called readLine and then readKey lost input, silently.

printf 'one\nXY\n' | solvm program.sob     # readLine → "one";  readKey → nil

readLine read through stdio, which reads a block ahead into the C library’s buffer. readKey — and keyWaiting — read the file descriptor underneath it. The bytes that arrived in the same block as the line were held where read could not see them, and nothing said so.

It was found by reading the code rather than by being bitten, an hour after 6.35 had been built on top of it. The comment above readKey claimed the two were “kept from disagreeing by flushing what stdio holds before going underneath it” — and there was no such flush, and there cannot portably be one: fflush on an input stream is undefined in C. The comment described an intention. Every other entry on that list came from somebody wanting something and not getting it; this one came from somebody being told they already had it.

What it is now

solum/src/stdin.c, a 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 reader 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 whatever is held, which is what lets a test replace stdin between cases and start clean — and is the one place the process-wide thing and the per-VM thing touch.

The terminal modes moved there too, out of builtins.c. A byte is read in non-canonical mode so a keypress needs no newline; keyWaiting sets the same mode for the length of its question. readKey is four lines now.

Three things that came with it

keyWaiting had to learn about the window, or one buffer would have been worse than two: it would have answered 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 through fgets and a script asking for a key got whatever was left. Both of Solis’ readers take from the window, and its sol_input_read_line lost its FILE * parameter — it reads standard input, which is the only thing it was ever given.

A line may hold a NUL. 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. That was not the point of the change and is the best thing in it.

What it does not change

Reading ahead still reads ahead — up to four kilobytes from a pipe or a file. That matters only when another process wants the same input: a program that reads a line and then hands standard input to a child with run may find the child short of what the window holds. It was true of stdio’s buffer before and is true of this one now, with the difference that it is this repository’s behaviour to describe rather than the C library’s to discover. A terminal is unaffected: it delivers a line at a time, so nothing is taken that was not asked for.

6.35 A read that gives up — done

system:keyWaiting(seconds), answering true or false: is there a byte to read, waiting up to that long for one to arrive.

The oldest known gap in this language, and the one with the longest paper trail. 6.10 closed with a paragraph headed what it cannot do: tell the escape key from the start of an escape sequence, because an arrow is three bytes and readKey answers one and blocks. examples/keys.sol said the same thing on the day it was written, and ended “worth knowing before writing anything that binds the escape key on its own”. Nothing bound it. The warning sat there, correct and untested, until programs/edit.sol bound it to the most frequent action a modal editor has and found that escape did nothing until the next key arrived.

A question, not a second reader. The obvious shape is readKey(seconds) answering the byte or nil, and it has a collision in it: nil already means the end of input, which is how every read loop in this language finishes. Adding nothing yet to the same answer leaves a program unable to tell there is nobody there from they have not typed yet — the first final, the second normal, and a loop that confuses them either spins for ever or stops early. So readKey is untouched and this answers a boolean. Two messages, each with one meaning.

True at the end of input, where the readKey after it answers nil. That is the coherent pair: there is something to read, and what is there is the end.

Seconds as a float, like every other duration here, and 0.0 is a question about right now. A negative wait is refused rather than taken for wait for ever, which is what poll would make of it — a hidden mode reached by a sign is not an interface.

What it cost to get right, which was not the poll

Fifteen lines of poll and a boolean, and then a bug that no test in this repository could have caught, because every test here reads through a pipe.

A terminal in canonical mode holds what is typed until a newline. readKey sets non-canonical mode for the length of one read and puts it back, so between two reads the terminal is canonical again — and a poll there is told that nothing has been typed however much has. The arrow keys this message exists to recognise stopped working the moment it was used, their [ and B sitting in the driver’s line buffer where poll cannot see them. A pipe has no line discipline, so all 118 of the editor’s behaviour checks and every C test passed either way.

It was found on a pseudo-terminal, by driving the editor through one and pressing an arrow. The fix is the same raw-mode dance readKey does, around a call that reads nothing:

tcsetattr → poll → tcsetattr back

and the test that pins it makes its own pseudo-terminal, writes [B with no newline, and asks. Without the dance that test fails and every other test in the suite passes.

What it did not know about, for one hour

readLine’s buffer. This read the file descriptor, like readKey, and neither could see bytes the C library had already read ahead — so keyWaiting would answer nothing is coming while a byte sat in stdio.

That is 6.36, opened an hour after this landed by reading the code beside it, and closed the same day. There is one window over standard input now, and keyWaiting was taught about it rather than left underneath it: it answers true for a held byte without asking the system anything, which is the first line of sol_stdin_waiting.

Left recorded rather than deleted, because it is the reason the two entries had to land together. One buffer would have been worse than two had this half been forgotten — a poll that cannot see what the reader is already holding does not fail, it answers nothing is coming, confidently and wrongly.

6.34 A program cannot ask how big the terminal is — done

system:terminalSize, answering a dictionary of "rows" and "columns", or nil when the output is not a terminal.

The program that wanted it was edit.sol, and it wanted it in its first hour, exactly as ideas.md predicted it would before the editor was written. That prediction is the only one of the four on that list that was made about an absence already confirmed rather than guessed at, which is why it is the one that closed the same day.

What the workaround was, and what it cost. There is no environment variable to read — COLUMNS and LINES are shell locals and are not exported, so a program sees neither — so the only route to the number was to run a program that asks the kernel and read what it printed:

asked each ask
stty size through /bin/sh 7.0 ms
stty size with no shell 2.3 ms
system:environment, which does not answer the question 0.0002 ms
the ioctl this message is about 0.001 ms

7ms is a fork, an exec and a pipe per keystroke if the size is asked for on every redraw, so the editor asked once at startup instead — and a window resized after that was a window the editor drew wrong until it was restarted. That is the whole finding: not that the number was unreachable, but that reaching it cost enough to change the design around it.

And the obvious second answer is worse. tput lines down a pipe answers the terminfo default rather than failing — 24, confidently, whatever the screen is — which is a wrong number that looks like a right one.

The four decisions in it

One message answering both numbers, rather than rows and columns. Two asks can straddle a resize and compose a screen that never existed: an old width with a new height. One ask cannot, and there is no version of this where two messages are the safer pair.

A dictionary, the way capture answers "output" and "status". An array would be two integers in an order the reader has to remember, and rows and columns are precisely the pair that everybody remembers backwards.

Nil rather than 24 by 80, which is what readLine, readKey and environment all answer for absence. A default is a lie a program cannot see through, and what to do without a terminal is the program’s decision and not the language’s: an editor picks a size, a pager gives up, a report ignores the question. edit.sol picks 24 by 80 — in its own file, where a reader can see it.

The output’s size, not the input’s. That is where the drawing goes, and it means a program whose standard input is a script and whose output is a terminal still gets a true answer — which is what makes a full-screen program testable at all. One whose output is a file gets nil, which is the truth about the file.

What it deliberately does not do

There is no notification that the size changed. A resize is a signal, SIGWINCH, and the language has no signals and gains none here. It does not need them at this price: one system call per redraw is about a microsecond, so a program that draws can measure every time it draws and never be wrong for longer than one frame. edit.sol does exactly that, in three lines with the reason written beside them. The cheap ask is what makes the missing signal not matter, and if the ask had stayed at 7ms this entry would have had to answer a much larger question.

Testing it

The suite makes its own terminal: posix_openpt, grantpt, unlockpt, a TIOCSWINSZ of a size the test chose, and dup2 over standard output for the length of one run. openpty would have been shorter and wants -lutil on Linux, which the front page’s no dependencies beyond a C11 compiler and make does not allow. So the size is arranged rather than inherited, and the test says 31 by 101 because a real terminal never is.

The three allocations — the dictionary and its two key strings — are one root and a GC-stress case. Removing the root and running under ASan reports a heap-use-after-free inside sol_dict_put, which is the proof that it is load-bearing rather than decorative.

6.1 There is no way to split a program across files — done

Nothing above a few hundred lines fits in one file, and there is no include. This is the item a real program hits first.

The mechanism is easy — Solas reads the named file and compiles it in — and the question is the namespace. Globals are one flat space, so textual inclusion is consistent with what exists: names collide, and the second definition wins, exactly as two := in one file already do. A module system with its own namespace is a much larger change to the object model and would want the class-side question (2.5) settled first, since a module is a thing with two sides.

Start textual. Record the collision rule, and whether a file included twice is compiled twice.

Built as 8922138, and textual it stayed: "library.sol":include. compiles that file in at that point, the namespace stays flat, and a file included twice is compiled once — a second copy could only rebind names already bound and repeat whatever the file did on the way, and C’s alternative needs conditional compilation that Solum has not got. The rules are in REFERENCE.md.

The spelling did not survive. It was "library.sol":include. here, and that shape was a disguise: it read as a send to a string and never was one. It is @include "library.sol". now — see 6.13 for what the disguise cost and why the sigil was worth introducing.

6.2 A system object — done

system:exit(code) is the difference between a script and a program: there is currently no way to say stop, and here is why. Alongside it, the two other things a program asks the world for: its arguments, and the time.

Small, self-contained, and the natural home for anything else that is about the process rather than about a value.

Built as e8d4fe8. All three, and the shape they took: system is one object bound to a global rather than a class, since there is one process and it has no instances. exit unwinds rather than calling exit(3), so every frame is discarded the way an error discards them and whatever the C library was holding is flushed on the way out; a status is #0 to #255 and anything else is refused, POSIX keeping only the low eight bits. arguments turned out to want no primitive at all — it is a data slot holding an array, which is what it is. clock is monotonic seconds as a float, the epoch left unspecified because only differences mean anything. The rules are in REFERENCE.md.

6.3 Reading input — done

system:readLine, answering a string or nil at end of input. A few lines of C, portable, and enough for anything that reads a file line by line or prompts.

Built as 4aefa0c. system:readLine answers a line without its terminator, or nil at the end – the one place absence is not treated as a mistake here, since running out of input is how a loop that reads to the end finishes rather than something that went wrong. An empty line is "" and is not the end, so the two never get confused. \r\n counts as one terminator and a last line with no newline of its own still counts as a line.

The half of this entry about waiting for a single key stayed behind, under a number of its own: 6.10, which is still open. solis grew raw-mode line editing for its own prompt in 6.24, which needed the same machinery and is not the same thing: that is the front end reading keys, where 6.10 is a message a program can send.

6.4 File handling — done

Whole-file first, which covers most of what a script does:

Errors are the design work rather than the reading: the language has no exceptions, so a missing file has to be a runtime error like any other, or answer nil and make every caller check. Given how strict everything else is, an error is the consistent choice, and a system:fileExists gives the caller a way to ask first.

Built as 63bb836, and on system rather than on the string naming the file — system:readFile(path), system:writeFile(path, text), system:fileExists(path). "notes.txt":readFile reads better and is what this entry sketched, but a string knows nothing about files, system is already where what belongs to the world outside the program lives, and — at the time — "lib.sol":include already meant something quite different on a string literal.

That third reason has since dissolved: 6.13 made an include @include "lib.sol", which looks like nothing else, so there is no longer a collision to avoid. The decision stands on the first two reasons, which were the load-bearing ones.

The error question this entry called the real work went the way it predicted: a missing file is an error, the same answer an out-of-range index gets, and fileExists is how to ask first. It answers false for a directory, since 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.

The binary half stayed behind, under a number of its own: 6.12. And a gap this opened is 6.11 — a file arrives as one string and there is no way to take it apart.


6.5 Measuring from inside the language — done

Every performance number in the changelog was taken with /usr/bin/time around a whole process. Timing a block from inside Solum would be better, and was a few lines once 6.2 had provided system:clock:

{ #20:factorial }:timeToRun:print.

The design question the entry named was what it answers, and a float of seconds was the obvious choice for the reason it gave: it is the only answer that needs no duration type. It also subtracts and compares like any other number, and asString(".3") already formats it.

Built as 661408d. The block’s own answer is dropped — what was asked for was the time, and a message answering both would have to answer an array or an object, which is worse to take apart than writing { ... }:value when the answer is wanted too.

The entry missed something, and it changed the shape. The clock has a floor. On the machine this was written on it is a microsecond — clock_getres says so and so does 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: 0 most times, one whole microsecond when the two readings fall either side of a tick.

That is fatal to the entry’s own purpose. The numbers it wanted to take from inside the language, rather than with /usr/bin/time around a process, are all sub-microsecond. Without a repeat count the message cannot measure any of them.

So timeToRun(#n) as well, running the block n times and answering the total. The total rather than the average, because the total is the measurement and the average is a division the caller can do — and keeping the count in view is what says whether the floor was cleared. A count below #1 is refused: the answer would be 0.0 whatever the block, which tells you nothing and is more likely a mistaken count than an intention.

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 also what 6.6 was waiting for. Inlining the loop constructs buys speed rather than expressiveness, and now the Solum-written version and the inlined whileTrue can be measured against each other before anything is built.


6.7 The instruction set has no complete reference — done

design.md had a table of the instruction set that was missing six opcodesOP_JUMP, OP_JUMP_IF_FALSE, OP_EXIT_IF_FALSE, OP_LOOP, OP_CHECK_BOOL and OP_SYMBOL. That is every jump and the two newest, so the table described the machine as it was before 4.1.

The disassembler printed all of them and bytecode.h documented each one at its definition, so the material existed and the document had fallen behind. The entry asked for a reference page generated from, or at least checked against, the header — the same problem the examples solved by being compiled in the test suite.

Built as 8d7c558. BYTECODE.md describes all twenty-one opcodes: operands, instruction length, effect on the stack, and why the three jump instructions carry a name index they never push. design.md keeps the operand-width rule and points at it, having no table of its own any more.

Checked rather than generated, and three ways, by tests/test_bytecode.c:

None of it needs a list of opcodes maintained in the test. The names come out of the enum in the order they are written, which is also their value, since a C enum with no initialisers numbers from zero upwards — so the header alone gives name and value both, and the check has nothing of its own to fall behind in.

Writing that parser was where the one real mistake was. Taking any OP_ at the head of a line gave twenty-three opcodes rather than twenty-one: the comments wrap, and OP_JUMP_IF_FALSE only in the complaint it makes begins a line too. Two phantom members shifted every value after them, which showed up as OP_JUMP_IF_FALSE apparently being three bytes long. What separates a member from a mention is what follows it — a comma, or the comment when it is the last one.

All three checks were then confirmed to fail when they should: a renamed opcode, a removed one, and a wrong length each stop the suite with a message naming the file and the opcode. The three disassembly listings in the page were diffed against real --dump output rather than transcribed.


6.8 (group) and {block} are not contrasted anywhere — done

Both are code in brackets; one evaluates now and one is a value. The tutorial introduced each separately and never put them side by side, which is where the difference actually lands.

Built as 4001efa: a subsection at the end of the guide’s §7, a short one in the reference beside Grouping, and a section in examples/blocks.sol so the concept has runnable code and not only prose.

The entry supplied the example and it is the one used:

m := { x | x:add(#1) }.
(m:value(#42)):print.            ; #43
{ m:value(#42) }:print.          ; <block>

Writing it up turned up a better one, though, and it 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 in the language rests on that one fact, and a reader who has not seen a group and a block side by side has no way to see it.

The third difference is frames, and it explains a restriction §3 already describes 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 a group may declare them only somewhere that already has a frame, and why declaring one at the top level of a script is refused.


6.9 The examples do not cover everything — done

Seventeen examples, chosen by what was being built at the time rather than by what a reader needs. The entry asked for an audit: list every concept the guide names, find which have no example, and fill the gaps rather than adding more of what is covered.

The audit was run two ways, and the answer was not the one the entry assumed.

Against the guide, five of its nineteen sections pointed at no example: §2 names and binding, §3 statements and groups and temporaries, §11 overriding and via, §14 fetching a method, and §16 errors and strictness.

Against the built-in messages — every selector registered in builtins.c, which is the sharper question — exactly one had never been sent in any example: lessOrEqual. Coverage was far better than “chosen by what was being built at the time” suggests. The gaps were conceptual rather than material, and two of the 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.

Built as 8a2546c:

Every section but §19, which is prose about what is left, now points at something runnable, and all sixty-five built-in messages are sent by at least one example.

And the audit is now a test, for the same reason 6.7’s reference is: tests/test_compile.c reads the registrations out of builtins.c and checks each selector is sent by some example, with ; comments blanked out first so a message that only appears in an error transcript does not count as covered. That blanking respects string literals, because 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. Commenting out the single lessOrEqual send makes the first one name it.

One thing the audit turned up that was not about examples at all: index.md said “Twelve programs” and listed twelve, while seventeen shipped. It lists all nineteen now.


6.11 A string cannot be split — done

readFile answers a whole file as one string, which is what made this visible: there was no split, no indexOf, and no substring. at(#i) answers a one-character string, so breaking a file into lines was a character-at-a-time loop — examples/files.sol had one, and it was the least pleasant code in the examples.

The shape was not in doubt, only how much of it to build. split(separator) answering an array of strings covers most of what a script does to a file. indexOf and a substring message are the more general pair, and each raised the same question: what to answer when there is no match. Nil, or #0 as an out-of-band index — and the first is more in keeping, since #0 is not a valid index here and would be a second way of saying “nothing” beside the one the language already has.

Built as 4d35540, all three: split(s), indexOf(s), copyFrom(#a, #b).

split keeps every piece. There are always occurrences + 1 of them, so a separator at either end or two together gives an empty string where the missing piece would be. That is what makes the answer predictable: the pieces put back together with the separator between them are the string you started with, whatever it was. Dropping empties reads more kindly on " a b " and loses the difference between "a,,b" and "a,b" — which a program parsing a file is usually the one thing that matters to it. No occurrence gives one piece, the whole string, which keeps the rule rather than making a special case of it.

indexOf answers nil, as the entry expected. The argument for it got stronger on the way: #0 would not merely be out-of-band, it would be a second spelling of a thing the language already spells, and text:indexOf(","):equals(nil) is the same question that an unset slot and the end of input are already asked.

copyFrom includes both ends and both are one-based, so copyFrom(#i, #i) is exactly at(#i). The one thing this entry 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. So an empty result is spelled with to one before from, and only that far — anything further apart is a mistake rather than a wider empty. from may be one past the end, which is where the empty tail is.

Neither split nor indexOf will look for the empty string. Every position in every string contains it, so the answer would be arbitrary; refusing says so where the mistake was made.

All three go by the length rather than stopping at the first NUL, which was not free — strstr was the obvious implementation and would have been wrong on exactly the files 6.12 is about. A test reads a file holding a NUL and splits it.

The inverse was left out and is 6.14: there is no join, so putting pieces back is still a walk with do, and underneath that there is no inject or fold either.


6.13 include was spelled as a message — done

6.1 built the include and spelled it "library.sol":include., for the honest reason that the language had no directive syntax and no keyword to spare, and that shape already parsed.

It was a disguise, and the compiler paid for it in three places. statement copied the lexer and looked two tokens ahead to spot one before the string had been consumed. primary carried a special error — an include must stand alone as a statement — because the shape parsed everywhere and worked in one place. And include_follows existed at all, a probe nothing else in the grammar needed.

The cost that mattered was not in the compiler though. A construct that looks like ordinary syntax and obeys different rules teaches the wrong model: a reader who accepts "lib.sol":include as a send has learned that a send might happen at compile time, and that is not true of any other send in the language. It is the objection that sank the trailing-block shorthand in ideas.md, and it applies harder here — the shorthand would at least still have been a message.

One argument for keeping it nearly held: that in Solum everything happening at run time has a colon in it, so a colon-free statement already reads as not-a-send and no sigil is needed. It is false. x. is a legal statement, colon-free and entirely a run-time one.

So @, and a distinct token rather than a keyword:

@include "library.sol".

The token is @include, @ and all, which is why this costs nothing anywhere else. There is no lookahead — a directive announces itself at its first character. There is no reserved word — no identifier can begin with @, so include stays an ordinary name any object may use for a slot. The probe and the special-case error are both gone, and primary now only has to say that a directive belongs on its own.

@ names a space rather than one word: what follows it happens while compiling, and nothing in it is a message. An unknown directive is refused rather than passed through, since a name in the compiler’s own space that the compiler does not know is a mistake and not something that might come to mean something later. @include is the only member so far, and the space may never have a second one — it earns its keep with one, by marking the single construct in the language that is not run time.

Built as e215440. 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 by where the file turns out to be on disk, the same cycle stop. —

6.14 An array of strings cannot be joined — done

6.11 built split and left its inverse out. Putting the pieces back was a walk with do and a flag for whether the separator goes in front — six lines to say something that ought to be one.

The entry asked a larger question first, and it was the right one to ask: there was no inject or fold either, so every reduction over an array was that same walk with an accumulator declared outside it. join was one instance of a gap, not the gap.

Built as 72df16b, and both. The entry set them against each other — a fold answers the gap once, where join is the case that keeps coming up — but 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 the very six lines being replaced. They are not the general and the specific case of one thing. They are two things.

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 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. And unlike do it is an expression, so a reduction can stand in the middle of one rather than only at the top of a frame where an accumulator could be declared — which was the real cost of not having it.

join(separator) is on array rather than on string, because 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 and a second quiet route to the same place is worth refusing.

Its separator may be empty, where split’s may not, and the asymmetry is not an oversight. Nothing can be looked for — 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 testable rather than merely argued.

One note on the collector. inject holds its accumulated value on the value stack for the length of the fold, since sol_gc_push_temp cannot hold an integer or a nil — neither has a header to push. That root is defensive rather than load-bearing, and taking it out passes under SOLUM_GC_STRESS=1: 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 costs one stack slot and buys not having to rely on what another function does with its arguments, across an unbounded number of calls back into the language.


6.12 Taking a binary file apart — done

Reading and writing binary files always worked: a string is bytes, a NUL is a byte like any other, and split, indexOf and copyFrom all go by the length rather than stopping at the first NUL, so a binary file could be cut up by a marker. What was missing was a number for a byte — at answered a one-character string, and there was nothing to do arithmetic on.

The entry proposed a byte-buffer type, and said an array of integers would work at sixteen bytes a byte. It also said to build it when a program needed it rather than on the chance that one might, and that turned out to be the load- bearing sentence: when a program finally needed it, it wanted something much smaller.

The program was not the one expected. This entry was written about binary files. What needed a byte’s number first was textlib/json.sol has to read \u0041 and answer "A", and write a control byte back out as \u00XX. Neither direction existed, so the library carried the printable ASCII range as a string literal to index into and refused everything else, é included.

What was built is two primitives and no new type:

"A":asByte:print.            ; #65
#65:asCharacter:display.     ; A

asByte takes a one-character string and answers an integer; asCharacter takes an integer #0 to #255 and answers a one-character string. Both ends already existed, which is why 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 a decision rather than a shrug. A string is bytes (2.13), 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 pair exact inverses, and the test is the whole range rather than a sample of it: every byte #0 to #255 survives asCharacter:asByte.

It is a foundation and not a fix, which is the good part. A code point above 127 is more than one byte, so #233:asCharacter is Latin-1 rather than the two bytes UTF-8 spells é with. Encoding a code point is arithmetic once a number can become a byte — and arithmetic belongs where the format is known. So the UTF-8 encoder lives in lib/json.sol, in Solum, and 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, and it reads about as well as the C would.

One thing came free. There is no \0 in a string literal, so #0:asCharacter is the only way to write a NUL. Strings are length-counted rather than NUL-terminated and already carried one through readFile and writeFile byte-for-byte, so this added a spelling rather than a hazard.

What is still not here is the byte-buffer type the entry opened with, and nothing is asking for it. A byte from a string is a number now, which is what every use met so far actually wanted. If a program turns up that needs a large mutable buffer, that is a new entry with its own case, not this one reopened.

2.5 Class side versus instance side — closed

The last open question in the language, and it is closed by not splitting the objects — because the thing the split was for turned out to be reachable without it.

integer holds both new and add in one object. The complaint was that nothing separates the two audiences, and the fix on offer was a behaviour object per built-in, with a class-to-behaviour link that isKindOf and all four reflection messages would have to follow.

What was actually wrong was smaller and worse. 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, which is precisely what respondsTo is documented not to do:

#45:respondsTo('new).       ; true
#45:new.                    ; an integer is written #45, and there is nothing for 'new' to make

And the same shape let an instance answer a class-side message:

[#1]:new.                   ; []          -- a fresh empty array
[#1]:of(#2, #3).            ; [#2, #3]
dictionary:new:new.         ; <dictionary>

The fix is that every class-side message requires an object receivernew, of, fromSeconds, and the reflection that needs an object to look inside. Ten registrations changed from any_receiver to instance(..., SOL_OBJ, ...). All four of those sends are refused now, and respondsTo agrees with sending everywhere.

The teaching errors survive, because they were always for the class:

integer:new.
solvm: an integer is written #45, and there is nothing for 'new' to make -- #0 is the empty one

The line between the two sides is drawn by the receiver each slot requires, rather than by which object holds the slot. That is the rule this entry said had nowhere to live, and it lives in the registration table now, where the compiler checks it: a slot that takes SOL_OBJ is class side, a slot that takes a value type is instance side.

The two sides are separable from inside the language, and every slot belongs to at least one:

integer:slots:size.                                            ; #30
integer:slots:select({ s | integer:respondsTo(s) }):size.      ; #8   -- class side
integer:slots:select({ s | #45:respondsTo(s) }):size.          ; #27  -- instance side

Eight and twenty-seven overlap by five, and the five are isKindOf, isNil, notNil, perform and respondsTo — reflection that genuinely serves both audiences, which is the right answer rather than an oversight. Nothing is on neither side, and there is a test asserting exactly that.

What the split would still buy, and why it is not worth it. integer:slots answers 30, listing both audiences. That is honest — they are its own slots — and it is now explicable in one sentence with a filter to demonstrate it. The split would make it 8 by moving 22 elsewhere, at the cost of a second object per built-in and a link every reflection path must keep honest.

The trigger to reopen this: when slots on a built-in class is read by a program rather than printed by an example. In four programs written to do a job and four libraries, reflection on a built-in class appears exactly once — integer:slots:size:print in examples/reflect.sol, printing a count. Until something reads that list and has to care which audience it is looking at, the filter is enough.

6.23 An array cannot be popped, or asked what it holds — done

Both found by lib/html.sol, which keeps a stack of open elements — the thing parsing a nesting format wants, and the thing an array did not quite serve. Both workarounds were written and shipped before the messages were, which is what made the case for them.

removeLast takes the last element off and answers it. The workaround had been an object carrying its own top index, overwriting with atPut rather than shrinking — eight lines, written twice in one file before being factored out. The library 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, which was the decision in it. at already refuses an index out of range, and nil would be a second way of saying “nothing” beside the one the language has — worse, it would turn a mistake into a value that fails somewhere further on. A caller that might be empty asks size first, which is the shape a stack’s loop condition already has, so nothing is made harder by the strictness.

indexOf answers a one-based position or nil, exactly like string:indexOf. The library’s element-name sets had been strings searched with the delimiters kept on so that p did not match pre — the trick every shell script uses, for the same reason. They are arrays now.

And no includes, which the entry had been unsure about. indexOf(v):notNil is that question, so a second message would answer less with more surface: one that says where is worth more than one that says only whether. The entry’s argument against includes — that a dictionary answers set membership in O(1) and an array cannot — survives; what it missed is that indexOf earns its place by answering something a dictionary cannot.

Equality is sol_value_equals, the one the language uses everywhere: by content for values, by identity for arrays, blocks, objects and dictionaries.

["a", "b", "c"]:indexOf("b").    ; #2
[[#1]]:indexOf([#1]).            ; nil  -- an equal-looking array is a different one

6.30 A program cannot run another program — done

system:run(argv) and system:capture(argv), and lib/shell.sol over them.

The first entry raised after the list emptied, and it came the way the list says entries come: something was wanted and could not be had. A language aimed at scripting an OS that cannot invoke another program is doing the job with one hand — most of what a shell script does is arrange other programs.

An array of arguments, not a command line, which 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 it 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", "..."], which says what it is doing where it is done. lib/shell.sol wraps that 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" rather than the text alone, because a command’s output is worth little without knowing whether it worked: grep finding nothing is not grep failing, and only the status separates them.

Conventions taken from the shell rather than invented. A command that cannot be run answers #127; one killed by a signal answers 128 plus the signal. Neither raises — a script asking whether a tool is installed is asking a question, not making a mistake.

Reading before waiting. capture drains the pipe and only then waits for the child, because a program writing more than a pipe holds would block forever against a parent waiting for it to exit. Tested with 1.3 MB of output.

6.31 Text from another program arrives padded — done

string:trim, wanted within an hour of 6.30 by the first program that read a command’s output.

wc -l answers " 100\n". asInteger is strict about the whole string being a number — rightly, since "12abc" is a mistake rather than twelve — so the padding has to come off first. Every command-line tool pads a number, and every script that reads one trims it.

Space, tab, newline and carriage return: the four a terminal produces. Nothing else, because 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. A string with nothing to remove answers itself.

6.33 A running program cannot be stopped from outside — done

sol_vm_set_step_limit and sol_vm_set_memory_limit, set by whoever embeds the machine before the program runs, and solvm --steps=N and --memory=N as the front end for them. Reaching either answers SOL_STOPPED and exits with 124.

The case. A program that did not stop could not be made to. No time limit, no instruction budget, no ceiling on what it could hold; a host that started one had given away the thread and the heap until the program felt like giving them back. That had always been true and had never mattered, because the caller was a person with a terminal and a ctrl-c. It mattered as soon as the caller might be a webserver, where a request that never finishes is a worker that never returns, and enough of them is the whole server — with nothing dangerous called and no injection needed. It is the half of 6.32 that gets forgotten, because permissions are what people ask for and limits are what the case actually needed.

Why not the debug hook, which already existed and could already stop a running program — Solid quits out of one that way. Because the hook is offered when the line or the frame changes, and a loop written literally compiles to jumps: it enters no frame and returns to no caller, so neither moves. Measured before anything was written, with a breakpoint on the loop:

loop iterations times the hook was offered a stop
{ ... }:whileTrue({ ... }), one line 3,000,000 1
#1:toDo(#5, step) 5 5

The inlined loop was offered once and then ran to completion. It is the same inlining that makes --trace quiet on a long loop, seen from the other side: what makes the trace bearable makes the program unstoppable. So the counter went in the dispatch loop, which is the one place every instruction goes through — a limit checked anywhere else is a limit with a way around it.

A step rather than a second, because a unit of work does not vary with the machine, its load, or what else is running. A limit chosen once means the same thing everywhere, which a wall-clock timeout does not.

Cost, and why there is no test for whether a limit was set. With none, the counter starts at UINT64_MAX, so the unlimited case runs the same post-decrement and compare as the limited one and reaches zero five hundred years from now. One branch either way rather than two.

Measured on a five-million-turn inlined loop, which is around twenty million instructions: 0.74-0.75s with the counter and 0.76-1.04s without it. That is not a speed-up, it is the measurement saying the cost is below its own noise – but it does put a ceiling on it, which is what the number was wanted for.

Memory is measured after a collection, which is the whole difficulty. bytes_allocated before a sweep counts everything the program has ever asked for and not yet had taken back, most of which may be unreachable; a ceiling read off that figure stops a program for litter rather than for what it is holding, and a loop building one small string at a time would trip it however small the strings were. So going over is a reason to collect, and being over once that has happened is a reason to stop. Two programs make the same amount of garbage under the same ceiling and only the one still holding it is stopped, which is a test.

A stop is not catchable, and that is the point rather than an omission. It travels by had_error, as an exit does, so it unwinds through every loop that already tests that flag — but onError lets it past and ensure does not run its cleanup. A handler is code; running a handler is spending the allowance that just ran out; and a handler wrapped around everything, which is the shape people write, would turn the limit into a suggestion. ensure is the sharper case, because it works by setting the failure aside precisely so that more code may run, and a program could otherwise put its work in a cleanup and carry on. What that costs is small here, because nothing in this language has to be released: a file is read or written whole, and no message hands back anything a program is obliged to close.

And nothing inside the language can reach either limit — no message sets, clears or reads one. A program cannot find out what it was given and cannot give itself more, which is the whole of what makes them limits rather than suggestions.

124 rather than 70, because the program did not fail. It was taken away, and a host reading that as an ordinary failure would go looking for a bug that is not there. It is what timeout answers, for the same reason.

What it does not do. It bounds a program that loops – which is what the entry was written about – and not what it reaches for: a stopped program may already have deleted the files it was going to delete. That is 6.32, which is still a decision.

And it does not bound the cost of one message, which this entry originally claimed it did by saying it bounded a program’s work. A step is a unit of dispatch, so a primitive that reads a file or scans a string spends one of them however large the file is: readFile of 256MB plus an indexOf over all of it is eight instructions, and under a 1MB ceiling that read completes before the program is stopped holding 256 times its allowance. Found by writing serve.sol and running it as a guest with an allowance, and recorded as 3.7.

6.29 A stepper — Soliddone

bin/solid, the fourth program. It runs a program, stops before its first line, and takes commands: step, next, finish, continue, breakpoints, a backtrace, and the locals of any frame by name.

(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

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/, and solum/ gained one function pointer and a branch.

The branch costs nothing measurable. Three million loop turns: 0.43s with the check, 0.43s without.

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 — the unwind is decided by the time the error is reported — but the frames are still standing and the value that caused it is in one. sol_vm_runtime_error calls the hook after building the trace and before returning, guarded against re-entry.

Three bugs, all in deciding when to stop, and all found by using it:

The third of those is the one worth remembering: 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.

A line breakpoint matches any frame at that line, which includes the frame that defines a block when the block’s literal ends there. That is right and it made two tests wrong before it made them careful — the test programs now use a line only the body can be on.

6.28 Local variables have no names at run time — done

A chunk records what each frame slot was called, so --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 it away once the index was emitted. The disassembly is what the runtime had left:

SETLOCL     2
LOCAL       3

A slot is an index at run time and that is the right thing: an access is not a lookup. What was missing is the name beside it, for anything looking at a running frame rather than running in it.

A table per chunk, in slot order. Written straight rather than run-length encoded, since neighbouring slots share nothing, and indexed by slot rather than filled in the order names arrive — a slot nobody named, like slot 0 which holds the receiver, still takes a place, so index N is slot N. There is a test that names slots out of order and leaves a gap, because that is the property worth holding: an off-by-one here would put the wrong name against the right value, which is worse than no name at all.

--trace naming arguments is how the table was checked. If amount: lines up with #30 then the right name is against the right slot, and that is visible 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: a bump costs a recompile, a recompile is cheap and automatic, and nothing here ships bytecode without its source. Two bumps cost two recompiles.

The size, against version 12: +0.2% on numbers.sob, +3.4% on page.sob. Far cheaper than the file table, because a name is stored once per slot rather than once per method chunk.

What it unblocks is Solid. A stepper showing slot 3 = #180 would have been most of the work for a fraction of the use; it can show average = #180.

6.27 A stack trace does not say which file — done

A trace names the file as well as the line:

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 a format change. The example above has a three-line main.sol, 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 main.sol been longer it would have pointed confidently at the wrong line of the wrong file.

A .sob is one chunk: @include compiles a library’s code into the same one, and the line numbers come with it while the file name does not.

The fix mirrors what was already there. The chunk carried a line per byte, run-length encoded in the file because neighbouring instructions share a line. Now it carries a file per byte the same way, plus a table of the paths — and the runs are even 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 free.

What it cost. The first .sob format change since 0.1.0: version 11 stood for nine releases and this is 12. Files from an earlier build are refused with unsupported bytecode version rather than misread, and the remedy is to recompile.

The size, measured across the examples:

  v11 v12  
hello.sob 279 315 +12.9%
numbers.sob 1,633 1,671 +2.3%
mirror.sob 4,580 5,100 +11.4%
manifest.sob 15,176 17,547 +15.6%
page.sob 18,694 21,005 +12.4%

The spread is the number of method chunks: each carries its own file table, so 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 a property worth more than two kilobytes, and .sob files are tens of kilobytes to begin with.

A chunk with no file still says just the line. The prompt compiles from text rather than from a file, so it has no path to give, and prints [line 1] in script exactly as before. That case was found by a test rather than by thinking about it: the writer emitted file ids into an empty table, and the loader refused its own output.

6.25 makeDirectory refuses one that is already there — done

It answers whether it made one instead: true if it did, false if a directory was already there, and 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 }), because “make sure this exists” is what a script wants nine times in ten and refusing made it a test and a make.

What decided the shape was a thing the entry had not noticed. It offered two options — a second message, or answering instead of raising — and the argument for the second turned out to be stronger than “one message is tidier”: 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 a caller who wanted to know had to catch the error and read its text, and even then got the same words for two situations that are not the same news:

cannot make directory 'perm/already': File exists
cannot make directory 'perm/afile':   File exists

The first is fine and the second never will be. So the file case is separated out and says what it is:

cannot make directory 'perm/afile': something that is not a directory is already there

Answering true or false puts the ordinary fact where a caller can use it or ignore it, and leaves errors for the things that are actually wrong: no permission, no parent, or something else in the way.

One level still. mkdir -p is a different message and nothing has asked for it; this entry was only ever about the case where the work is already done.

6.26 A file’s mode and time cannot be read or set — done

system:modeOf, system:setMode, system:setModifiedAt.

The case was one program. programs/mirror.sol copies a tree with readFile and writeFile, which carry bytes and nothing else, so what arrived was:

source:      -rwxr-xr-x  script.sh
destination: -rw-r--r--  script.sh

A backup of anything holding scripts was not runnable. For a language aimed at scripting an OS that is a floor rather than a nicety, which is why this went ahead of the two entries beside it.

A mode is an integer, and the alternative considered was a string of nine letters — "rwxr-xr-x" — which is what ls prints and what a person recognises. It was turned down because it would be a second representation of a number, needing 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 on its own and is why the pair above is the thing to know.

The file-type bits are masked off. What comes back is permissions alone, so setMode(to, modeOf(from)) — the whole reason both exist — cannot try to change a file into a directory. A mode outside #0 to #4095 is refused rather than partly applied, which is what chmod would do with bits it does not recognise.

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 file 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 the older replacement is seen:

1 files to copy
    notes

Only the modification time. The access time is left alone with UTIME_OMIT, because nothing has wanted it and setting it silently would be a second thing happening. Times before 1970 split by flooring rather than truncating, and there is a test that reads one back.

What the program gained besides: it can now tell a file whose bytes are right and whose permissions are wrong, and fix that without reading the file again — which is the shape every real mirroring tool has.

6.10 Waiting for a single key — done

system:readKey, answering one byte without waiting for return.

Closed once by mistake and reopened. The first time, solis grew raw-mode line editing for its own prompt — the same machinery, and not the same thing, since a program still could not read a key. That work is 6.24. This is the message.

The entry left three questions and they are 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. Answering 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. It comes back as a one-character string, so asByte gives the number and the value is a value like any other.

nil at the end of input, which is readLine’s answer and for the same reason: running out of input is how a loop that reads finishes.

No echo. Raw mode does not, and a program that wants the key shown prints it. Showing it would be a second thing happening.

Raw mode only when standard input is a terminal. Through a pipe or a file a byte is already a byte, so this reads the same way under solvm program.sob < input — which is what makes it testable without a terminal, and the pipe test is the deterministic one. ISIG stays on, so ctrl-c interrupts a program that is waiting for a key rather than handing it the byte.

What it cannot do, and no byte-level reader can: tell the escape key from the start of a sequence. Pressing escape and then tab reads the tab as the byte after the escape. A terminal tells them apart by waiting a few milliseconds and giving up, which needs a read with a timeout. examples/keys.sol demonstrates the assembly and says this out loud.

The raw-mode dance is written twice, here and in solis/src/line.c, and that is deliberate: the prompt holds raw mode across a whole line of editing where this holds it for one byte, and an interface with both lifetimes in it would have been larger than the twelve lines it saved.

A harness bug came out of testing it. session_expect in test_line.c counted turns of its loop against a two-second deadline, so it gave up after a hundred reads however fast they arrived — 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 was passing beforehand; the one with a line long enough to notice was the one that found it.

6.24 The prompt has no history — done

This entry was filed under 6.10 and should not have been. 6.10 is waiting for a single key, and it is the program-facing half of 6.3: system:readLine lets a Solum program read a line, and 6.10 is the message that would let it read a keypress. What was built here is raw terminal mode inside solis, for its own prompt. It needed the same machinery and it is not the same thing — a program still cannot read a key, and system:readKey does not exist. 6.10 is back on the roadmap, where it was before being closed by mistake, and this is the work under a number of its own.

It came from using the prompt: type something wrong, get an error, and want to press up and fix it rather than type the line again. From using the prompt: type something wrong, get an error, and want to press up and fix it. The terminal does line editing itself in its usual 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 if they had been typed.

Getting history means taking the editing over, which is what made this a piece of work rather than a small addition.

What it does. 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. Ctrl-d ends the session on an empty line and deletes forwards otherwise, which is what it means everywhere else.

Three details worth the words:

It degrades rather than depending on anything. sol_line_editing_available asks whether stdin and stdout are terminals and whether TERM is something better than dumb; when they are not, the prompt reads a line exactly as it did before. That is what keeps solis < script, solis program.sol 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.

Bytes, not characters. The cursor moves a byte at a time, so a multi-byte character is split by a left arrow. That is the same limitation the language has — 2.13 — rather than a new one, and it will be answered when that one is.

The redraw is the whole line, every time. Wasteful in principle, invisible in practice at these lengths, and it is the version that cannot drift: tracking what the terminal already shows would be a second model of the same thing, which is the usual source of a display that disagrees with the buffer.

Testing it needed a terminal, so tests/test_line.c opens one with posix_openpt — POSIX, and needing no library, where forkpty lives in different headers on different systems and would have changed the Makefile. Two things that took a second attempt:

6.19 A symbol cannot be ordered — done

lessThan, greaterThan, lessOrEqual and greaterOrEqual on symbols, comparing the text.

The question the entry raised was whether that is worth it, since interning is what makes equals on two symbols a pointer comparison — and it is exactly what makes their addresses say nothing about their order. So these four are the only symbol operations that have to look at the characters.

It is worth it, and the reason is the one that gets anything sorted: a tally kept under symbol keys needs a stable order to print in. Symbols are values and make good dictionary keys, so tallying by symbol is the natural thing to write, and then the report could not be printed the same way twice. programs/manifest.sol had the workaround in it — collect the keys to strings, sort those, convert back with asSymbol to look each one up — and writing that is what made the case:

kinds:keys:sorted:do({ kind |
    "  {} {}":fill([kinds:at(kind):asString("4"), kind]):display }).

Nothing new was needed for sorted itself: with no block it sends lessThan, so defining one on symbols is all it took. That is the same arrangement that lets a user-defined type order itself.

6.20 An HTML parser — done

Written to find out what the language wanted, which is how the last three entries here got their case. It is lib/html.sol, with programs/page.sol as a program on top of it, and the entry predicted three things it would push on. All three happened, and one of them answered a question that had been open since 3.5 was written.

1. Error recovery, which nothing here had ever done. Every other parser in this project reports the first problem and stops — solas, evaluator.sol, json.sol — and each is right to, because their input is written by somebody who can fix it. HTML is generated, served, and wrong. A reader that stops is no use, so this one recovers and keeps a list:

@include "html.sol".

page := html:read("<b>bold</i>").
page:text:display.                              ; bold
html:complaints:do({ c | c:display }).
; </i> at character 10 closes nothing that is open
; <b> opened at character 1 is never closed

The shape that made it work is that recovery is not error handling. There is no onError anywhere in the library: a stray end tag is not an exception to recover from, it is an ordinary branch that appends to a list and carries on. Trying to build it on error:raise would have meant unwinding past the very stack that holds the recovery state.

2. A tree built against a stack, and 3.5 does not reach it. The entry asked whether building against a stack of open elements would sidestep the frame limit. It does, completely:

  deepest that works
json.sol, recursive descent 28 levels
html:read, an explicit stack 50,000 levels, and no limit found

The catch was on the way back down. The tree built 50,000 deep could not be walked 30 deep, because text, find and findAll were written the obvious recursive way and spend a frame per level:

  before after
text, find, findAll 28 levels 50,000

They are written with an explicit stack now too. The lesson is sharper than “use a stack”: 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 — the constructor was the part everyone thought about.

3. Character work in bulk, which asByte handled. Numeric entities (&#233;, &#x1F600;) need a code point to become bytes, which is 6.12 from a second direction and the first test of whether that pair was the right size of fix. It was: the encoder moved to lib/text.sol unchanged and both libraries include it. That file is the first library here included by another library rather than by a program.

What it cost that was not predicted. An array cannot be popped and cannot be asked whether it holds something — 6.23. And lib/text.sol first bound a global called text, which the first program to use it shadowed with a variable of its own, breaking the library from a distance with string does not understand 'utf8'. That is 6.21 happening within ten minutes of being written down. 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.

6.21 Two libraries binding one name collide silently — done

Top-level rebinding is legal and 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 failure was not a message but a different answer.

The entry said nothing had tripped over it. That lasted about ten minutes. lib/text.sol was written to hold what the JSON and HTML readers both needed, and bound one object called text — following the advice in the reference about claiming one name instead of a dozen. The first program to use it had a variable called text, and the library broke from a distance:

solvm: string does not understand 'utf8'

A run-time message about a type, for a compile-time collision between two files.

The compiler says it now, at the line where it happens:

[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 warning and not an error, for the reason the self-include warning is one: rebinding is legal and sometimes meant — a program may want to replace something a library bound — so saying so without forbidding it is the right bargain.

A claim, not an update. count := count:add(#1) reads the name before writing it, so it is working on somebody else’s global rather than declaring its own, and files legitimately do that 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 removed the one false positive in the whole tree — a test fixture that increments a counter from an included file — and it is what makes the warning quiet enough to keep: the 28 examples, four libraries and every test compile without one.

What it does not fix. This is the cheap half of a namespace and nothing else. 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. Those need things the language does not have: slots cannot be removed and slots lists everything, so privacy would be a new concept rather than a use of existing ones.

The three tiers that came out of it, which are the working advice now:

what a library adds how to bind it globals claimed
behaviour on an existing type a method on the class nonecontrol.sol, text.sol
a thing with state and its own operations one object, everything on it onejson, html
several unrelated names nothing better than several globals several

The top tier is the discovery. integer:asUtf8 and integer:timesCollect need no name of their own, because they extend a class that already has one — a send rather than an assignment, so this warning never sees them and there is nothing to collide with.

6.22 A file that includes a library of its own name silently does nothing — done

The search path looks beside the includer first, and a file is compiled once. So @include "json.sol" written in a file called json.sol finds itself, has already started, and contributes nothing. The program compiled cleanly and failed at run time with undefined name 'json', a long way from the line that caused it.

It was documented before it was diagnosed, and that turned out not to be enough: the reference called it occasionally a trap, and it still took about a minute to fall into once lib/ had a second file to collide with. The example built on the JSON library is called manifest.sol for that reason and no other.

The compiler is holding both halves of the question — it knows the file it is compiling and the file the include resolved to — so it says so:

[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".
           ^^^^^^^^^^^

A warning and not an error, which was the decision in it. Shadowing is C’s rule and worth keeping, the file is still valid, and the status is unchanged — so this is a note about something that will not do what it looks like, not a refusal. It is the first warning the compiler has; sol_parser_warning shares the location and the echoed line with sol_parser_error and sets neither had_error nor the panic flag.

Naming what was shadowed is the useful half. “This does nothing” tells you something is wrong; 'lib/greet.sol' on the search path is what it shadowed tells you what you were expecting to get. When nothing of that name is on the path there is nothing to name, and the warning says the first half alone.

Only the direct case. Two files that include each other are a cycle that include-once ends on purpose, and a file reached twice by different routes is the ordinary reason include-once exists. Both are silent, and there are tests for both — a warning that fired on either would be worse than the trap it was added for.

6.15 There is no dictionary, and no way to build one — done

Found by writing programs/log.sol, the first program here written to do a job rather than to show a feature. Counting by key is most of what a log analyser does, and the language could not express it: the tally was an array of key/count objects walked from the top, O(n) a lookup and O(n²) over a file.

The entry weighed two answers and called the first one smaller: slotAtPut, completing the reflection triple against slotAt and perform, so that an object could serve as a dictionary; or a real dictionary type.

Checking made the choice, and it was not the one the entry expected. slotAtPut would not have worked at all:

It was not the smaller option. It was the wrong one: prettier syntax for the same algorithm, plus a leak. So: the real type.

Built as 7e0726d. dictionary:new, at, at(key, default), atPut, includes, remove, size, keys, values, do, keysAndValuesDo. Open addressing, one allocation for the entries, tombstones for removal, and a rebuild that drops them once they crowd the table.

Keys are values. Integers, floats, 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, and refused rather than surprising anybody. That is the same line the language already draws between values and references, so it needed no new idea.

Two consequences fell out of taking it seriously. -0.0 has to hash as 0.0, since 0.0:equals(-0.0) is true and the table would otherwise disagree with equals about what one key being another means. And nan can be stored and never found, since it equals nothing including itself — IEEE showing through rather than a decision.

sol_value_equals now exists, in object.c, and prim_equals calls it. A dictionary asks the same question of its keys that equals asks, and two definitions could have drifted.

What went wrong, and why it was allowed to

A dictionary is the first type whose keys are edges as well as its values, and adding a value type touches six places. Five are switches over SolValueType with no default, so -Wswitch named them all at the first build: sol_type_name, sol_vm_class_of, the renderer, the serializer’s constant writer, and its check.

The sixth was mark_value in the collector, and it was a chain of if (SOL_IS_...). It compiled without a word and swept live dictionaries instead, which took a segfault at 500 keys and a stack trace to find — entries=0x2, capacity=388, a struct that had been freed and reused.

It is now a switch, with the comment saying why. The check was worth having at the sixth site too, and the only reason it was missing is that nobody had added a type since the check became the habit.

tests/test_dict.c has eleven groups, including 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 one fails if the marking is removed, which was confirmed rather than assumed.


6.16 An array cannot be sliced — done

The other thing programs/log.sol wanted, and it wanted it twice. There was no first(#n), no last(#n) and no slice, so taking the head of a sorted array was a walk with an index that the example carried as a firstFew helper.

Built as b156bcd: copyFrom(#a, #b), first(#n), last(#n). firstFew is gone from log.sol, which now says :first(#5).

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 only that far, from allowed one past the end, and anything outside that an error — following at. Two collections disagreeing about what a slice means would be worse than either rule is good, and the string got there first.

first and last clamp, and that is a second rule on purpose. The entry did not ask the question; writing it did. copyFrom names positions, and a position outside the array is a program wrong about something. first names a quantity — give me the top five — and a list of three has answered that correctly by handing over three. Refusing there would make every ranked report check the size first, which is the whole of what these exist to avoid.

One rule would have been tidier and wrong. A negative count is refused by both, since clamping is for asking for more than there is rather than for asking for nonsense.

All three answer a new array and leave the receiver alone, like collect, select and sorted, and they share the elements rather than copying them — an array holds references, so a slice of an array of arrays sees the same inner arrays. There is a test for that surviving a collection.

And a report that was not repeatable

Replacing firstFew exposed something else. 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 happened to hand them back. That order is arbitrary but not random, so the output was stable per build and looked fine; it had quietly changed when the tally became a dictionary.

Arbitrary is not good enough for something a person reads twice, so the report now breaks ties on the key, and the comparison block says why. The example is the same every run, and it demonstrates a two-key sort into the bargain.


6.6 The loop constructs are library code, and pay for it — done

All four, and the entry’s premise was wrong about three of them. It asked for inlining. doUntil got inlining, because it deserved it. repeat, toDo and toByDo became primitives instead, which is both cheaper to build and faster to run than the inlining the entry wanted.

The entry sat unbuilt for a long time because the gain looked modest — 1.30x for repeat, measured once timeToRun(#n) existed. The mistake was measuring the wrong one. repeat pays for one block call an iteration; doUntil pays for two, its condition being a block as well as its body, plus the done:not send the library version needs. Measured properly, over 200,000 iterations:

library doUntil   0.0706 s
hand-written flag 0.0395 s
inlined doUntil   0.0309 s

2.29x the library version, and 1.28x the loop it replaces. That second number is the one worth having: writing the loop out yourself needs a done flag outside it, and that flag costs two sends an iteration the jumps do not need. So doUntil is not a convenience you pay for — it is now the fastest way to write that loop.

Built as 413c57b, in two pieces. A primitive on block, so the message exists whether or not it is written literally, and an inliner beside inline_while.

The wrinkle was the complaint, not the loop. The shape is whileTrue’s with the body moved in front of the test and the sense inverted, and there is no OP_EXIT_IF_TRUE. Adding one would have meant a new opcode — and a name index on it, since OP_EXIT_IF_FALSE carries none and words its error as whileTrue, which is the wrong message for a program that wrote doUntil.

OP_CHECK_BOOL already carries a name and already refuses a non-boolean, so it goes in front:

top:  body / POP / condition / CHECK_BOOL 'doUntil'
      EXIT_IF_FALSE -> again        ; false: go round
      JUMP          -> end          ; true: leave
again: LOOP -> top
end:  NIL

By the time OP_EXIT_IF_FALSE sees the value it can only be a boolean, so its wording is unreachable. No new opcode, no .sob version change, and the invariant holds: a test asserts the inlined and sent forms produce the same first line, and that neither says whileTrue.

It came out of the library. lib/control.sol defined doUntil and does not any more — a definition there would be a trap rather than an override, since the compiler splices the loop in when both blocks are written on the spot and would bypass it exactly where it was most wanted. The file says so where the definition used to be.


6.6, continued — the counted loops, and why not inlining

Recorded separately because the answer contradicts what the entry asked for.

repeat, toDo and toByDo were to be inlined the way whileTrue and doUntil are. Two things came out of trying.

Inlining them faithfully needs an instruction that does not exist. A counted loop’s receiver is whatever expression you wrote, and its type is not known while compiling. 1.5:repeat({ ... }) has to go on saying float does not understand ‘repeat’ — the rule that an inlined message complains exactly as the sent one does. Inlined jumps would reach the counter comparison first and complain about that instead, so getting it right needs a type-guard instruction carrying the message name, the way OP_JUMP_IF_FALSE carries one for ifElse. That is a new opcode and a .sob version with it.

And it would have been the slower answer anyway. Per iteration the Solum version pays a block call for the body, a lessThan send 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. Measured 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, which is the opposite of what the entry assumed and is why it is worth writing down.

The receiver check comes free with the primitive: repeat is installed for SOL_INT receivers, so a float never finds it and dispatch says so in the words it always used. The thing inlining would have needed a new opcode for is what dispatch already does.

toByDo gained two things it could not have as Solum. A step of #0 is an error rather than a printed complaint the library could only follow with 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 — there is a test for both directions.

The library is nearly empty now, and that is the record rather than a regret: it opened with five loops, four were measured, and all four were worth building in. timesCollect is what is left, being 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.


6.17 There is no ensuredone

Written down when onError landed, on the grounds that nothing needed it yet: the things ensure usually protects are handles and locks, and there are neither. Built the next thing anyway, because the entry named the one wrinkle it would have and that wrinkle turned out to be the whole of it.

Built as e001b8e. { body }:ensure({ cleanUp }) runs the cleanup whether the body finished or not, then goes on doing whatever the body was going to do. It answers the body’s answer; the cleanup’s is discarded, the cleanup not being what the expression is about.

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 its stack, the VM given fresh empty buffers for the duration, and the whole thing put back afterwards. The texts are moved rather than copied, so anything the cleanup reports lands somewhere else and is thrown away.

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 — more so. The cleanup runs and the program still leaves with its status.

When both fail, the body’s failure wins. That was the wrinkle the entry named, and the answer it guessed was right: the first error wins here as it does everywhere, and the second is usually a consequence of the first.

An uncaught failure that passed through a cleanup keeps its own message and its own stack, so it still names where it happened rather than where it was tidied up after. There is a test for that, and one for twenty thousand cleanups in a loop leaving the stack and the collector’s temporaries where they were.

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 — which is a difference in what the two messages promise rather than an inconsistency.


6.18 There is no date or time — done

Written down when system:fileSize landed without a matching modifiedAt, because a timestamp wants to be a date rather than a number of seconds and answering an integer then would have been an interface a date type had to change. Built the next thing, on the lines the entry set out.

Built as eaa2fa4. A value type, SOL_TIME, held as nanoseconds since 1970-01-01T00:00:00Z.

A value, not an object, which the entry called and which the rule that sorted out new confirms: two of the same instant are the same time, nothing mutates one, so it belongs beside integer and float rather than being slots. That makes equals exact, makes 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. The language is strict about integers and floats, and 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. The entry named time zones as where every date library goes wrong, and the answer 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.

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 in the language, and would have invited t:sub(#5). The name carries the direction and the unit.

asString(format) hands the format to strftime. The entry worried about inventing a second spec language; the answer was to invent neither. The number-formatting spec is about width and digits and has nothing to say about a Tuesday, and strftime’s alphabet is the one everybody already knows.

What the entry did not plan, and building it found

Nothing could name a particular moment. With only system:time and system:modifiedAt, the sole instants a program can have are the current one and a file’s — which is enough to stamp a log, and not enough to say when something is due, or to test any of this against a date somebody knows. Two tests in, that was obvious. time:fromSeconds(f) and asSeconds are the pair that fixes it, and they are also how an instant gets 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. There is a test for the day before the epoch and for the sliver before it.

system:modifiedAt is the companion fileSize was waiting for, and could not have been written until this existed.