Skip to content

Latest commit

 

History

History
2547 lines (2127 loc) · 137 KB

File metadata and controls

2547 lines (2127 loc) · 137 KB

The programs

The twenty-two files in programs/: what each one does, how to run it, and what it found. examples/ is the other directory — one file per concept the guide names, each written to show a feature. These were written to do a job.

Two used to be here and are not. emit.sol and compile.sol taught Solum to compile itself, which it now does — its own source, to a fixpoint. They live in experiment/ with the four libraries they used, off the search path and out of make test, because keeping them in step with solas taxed every change to the real compiler and the proof does not need repeating. experiment/README.md has the account.

That distinction is the reason for the split, and it is not cosmetic. A program written to show a feature is written after the feature and to suit it, so it can never report that the feature was awkward. These can, and did: nearly every entry the roadmap gained after the first dozen came from one of these fifteen wanting something the language did not have.

What this page is not is a description of what Solum is for. These fifteen lean towards text and processes because they are the tools this project needed while building itself, and the language is meant to be general — design.md says so, and says what happened the once that was forgotten.

Each is a single .sol file with its reasoning in its own comments. This page is the map; the file is the argument.

At a glance

does run it
log reads an access log and reports on it solvm log.sob [logfile]
evaluator tokenises, parses and evaluates arithmetic solvm evaluator.sob
manifest reads JSON, describes it, queries it, writes it back solvm manifest.sob [file.json] [path]
page reads HTML and reports on it solvm page.sob [file.html] [tag]
mirror copies one directory tree into another solvm mirror.sob [src] [dst] [dry]
tools reports on a directory by running other programs solvm tools.sob [directory]
serve answers one HTTP request PATH_INFO=/ solvm serve.sob
disasm reads a .sob file and says what is in it solvm disasm.sob [file.sob] [brief]
expect checks the examples and the documents against their own claims solvm expect.sob [dir or file]...
bench times a command, and says whether two of them really differ solvm bench.sob [runs] [cmd] [-- cmd]
basic runs a BASIC listing solvm basic.sob
edit edits a file on the screen, in the manner of vi solvm edit.sob [file]
sola compiles SolaBasic to a .sob solvm sola.sob [prog.bas] [out.sob]
check_syntax reads a grammar, then checks a file against it solvm check_syntax.sob [grammar.bnf] [source]
pascal compiles ISO 7185 Pascal to a .sob solvm pascal.sob [prog.pas] [out.sob]
sed runs a sed script over the lines of its input solvm sed.sob [-n] script [file...]
tail the end of a file, without reading the rest of it; -f follows solvm tail.sob [-n N] [-f] [file...]
sha256sum the SHA-256 of a file, and the check of a list of them solvm sha256sum.sob [-bctwz] [file...]
awk runs an awk program over the records of its input solvm awk.sob [-F sep] program [file...]
diff the shortest set of changes that turns one file into another solvm diff.sob [-u] [-q] old new
sort lines in order, spilling to disk when they do not fit solvm sort.sob [-rnufbs] [-k F,F] [file...]
gzip inflates a gzip stream back into the bytes it was made from solvm gzip.sob [-dcktl] [file...]

Every one runs with no arguments at all, on input it supplies itself. That is deliberate — a program you have to feed before it will say anything is a program you will not run.

Compile first. All of them:

for f in programs/*.sol; do ./bin/solas "$f"; done

or one at a time, which is what each file's own header shows:

./bin/solas programs/log.sol && ./bin/solvm programs/log.sob

solas writes the .sob beside the source, so programs/log.sol becomes programs/log.sob. Bytecode is gitignored and is not shipped.


log — an access log, read and reported on

The first of these, and the reason the directory exists.

Reads a log of six space-separated fields and reports the totals, the busiest paths, the status breakdown and the slowest requests.

./bin/solvm programs/log.sob                      # a built-in sample
./bin/solvm programs/log.sob path/to/access.log   # your own

It expects its input to be damaged, because real input is. Three lines of the built-in sample are broken in different ways and the report says so and carries on rather than stopping at the first.

That half was written after onError existed and found two things. Surviving a bad line is not the same as surviving a bad file — a file of pure rubbish leaves nothing to report on, and the summary fell over on the empty array the first time it met one. And the division of labour that makes a report readable is that the machine says what went wrong, naming the offending text, while the program says where, because only the program is counting lines.

What it asked for and got: a dictionary and array slicing. Both were built because this program could not be finished without them.

evaluator — a calculator

Tokenises, parses and evaluates arithmetic, with parentheses, unary minus, and an error message that names the position.

./bin/solvm programs/evaluator.sob
2 + 3 * 4 = 14
(2 + 3) * 4 = 20
1 + -- expected a number at 4
1 + + 2 -- expected a number at 5, got '+'
2 $ 3 -- unexpected '$' at 3
1 / 0 -- division by zero in 'div'

Deliberately a different shape from log.sol, which is line-oriented — read text, split it, tally it. This one recurses, builds a tree of objects, and has to say something useful when its input is wrong.

What it found: the frame limit, and that it is catchable. A recursive-descent parser spends about three frames per level of bracket nesting, so it manages 83 brackets deep — 18 when it was written, against a cap of 64 frames rather than 256. Running out of frames arrives at onError like any other failure and the program carries on after it, which is what makes the limit a limit rather than a crash.

manifest — a JSON file, described, queried and written back

Reads a JSON document, prints its shape, pulls out a value by dotted path, and writes it back out.

./bin/solvm programs/manifest.sob                            # a built-in sample
./bin/solvm programs/manifest.sob path/to/file.json          # your own
./bin/solvm programs/manifest.sob file.json server.port      # one value from it

The parser is lib/json.sol, on the search path, so the program says @include "json.sol". and not where it lives. Splitting it that way was the point: a JSON reader is library code, and the program above it is what finds out whether the library is any good.

It is called manifest.sol and not json.sol because a file that includes a library of its own name finds itself on the search path first — and, a file being compiled once, that include quietly does nothing. That is 6.22, and it took about a minute to fall into.

What it found: that a byte had no number — asByte and asCharacter exist because of this program and lib/json.sol — and a price on how the value dispatch is written. Dispatching on the first character through a dictionary of blocks costs one more frame per level than a chain of ifElse, which is 18 levels of JSON nesting against 28.

page — an HTML file, read and reported on

Reads an HTML document and reports its title, outline, links, images without alt text, and everything wrong with the markup.

./bin/solvm programs/page.sob                          # a built-in sample
./bin/solvm programs/page.sob path/to/page.html        # your own
./bin/solvm programs/page.sob page.html img            # dump one kind of element

The parser is lib/html.sol, on the search path.

It cannot fail on bad input, because bad input is the normal case. log.sol skips a bad line and lib/json.sol refuses a bad document — both right for their format. HTML is generated, served, and wrong, so a reader that stops is no use. The parser recovers and keeps a list, and the program prints that list as part of the report rather than as an error.

What it found: that the frame limit is about traversal, not about data. The tree is built against a stack rather than by recursion, which reads a document nested 50,000 deep — and then walking it back down recursed and capped at 28 again, so text, find and findAll are written with a stack too. Also that an array could not be popped or asked what it holds, which a stack notices immediately; removeLast and indexOf are 6.23.

mirror — one directory tree copied into another

Lists both trees, compares them, and copies what differs — carrying each file's mode and modification time across.

./bin/solvm programs/mirror.sob                              # built-in sample trees
./bin/solvm programs/mirror.sob source destination           # your own
./bin/solvm programs/mirror.sob source destination dry       # say, do not do

It does not delete. A destination file with no counterpart in the source is reported and left alone, because a mirror that deletes is a different and much more dangerous tool and an example is a bad place to hide one.

The first of these to write to the filesystem rather than read it, and the ordinary job that needs the whole set at once — list, test, measure, make, copy.

What it found, and it is the richest of the seven:

  • modifiedAt answered whole seconds, so within one second "is the source newer?" was always no and a file edited just after a run was never copied. The filesystem records nanoseconds and time holds nanoseconds; only that message was rounding, in the middle of them.
  • A copy could not keep the original's time, so the test had to be newer than rather than the same as — which misses a source replaced with an older copy of itself. setModifiedAt was built for this.
  • The executable bit was lost, because a copy here is readFile then writeFile and neither carries a mode, so a backup of anything holding scripts was not runnable. modeOf and setMode were built for this.

The last two are 6.26, which this program is the whole case for.

And one thing that is not a gap: a whole-file copy is fine at this size and not at every size. readFile answers the file as one string, so a mirror of something large holds it in memory twice. Worth knowing where the edge is rather than discovering it.

tools — a directory reported on by running other programs

Reports how many files a directory holds, how big it is, what kinds, and what version control has to say — by asking du, find, git and friends, each of which already exists and is better than anything worth writing here.

./bin/solvm programs/tools.sob             # the current directory
./bin/solvm programs/tools.sob path/to/it  # another one
  size    49M
  .sol    105 files
  .c      47 files

  git     bb05f65
  changed 10 files

The first of these to do most of its job by asking something else to do it, which is what a scripting language is for.

What it shows: why capture answers the status beside the output. A command that is not installed answers 127 with nothing to say, and one that failed may still have printed something — so output alone cannot be believed, and the status is what says whether to. It also uses lib/shell.sol, which is on the search path.

serve — one HTTP request, answered

A CGI-shaped request handler: /, /search?q=... and /note/<name>, served out of a directory of files.

./bin/solvm programs/serve.sob                                     # seven requests, self-run
PATH_INFO=/search QUERY_STRING=q=limit ./bin/solvm programs/serve.sob
PATH_INFO=/note/limits ./bin/solvm programs/serve.sob
PATH_INFO=/ ./bin/solvm --steps=100000 --memory=8M programs/serve.sob

With no CGI variables set it runs seven requests through itself and prints each response, so it is testable without a socket. The last four of those are the attacks: a reflected script tag, a name that is only dots, a traversal spelled with escapes, and a name carrying the quote that would break out of an href.

The first of these whose input does not come from whoever ran it. Every other program here is handed its arguments by the person who started it; this one is handed a path and a query string by a stranger, which is the case 6.32 is about.

What it found:

  • fill is the injection. It is the natural way to build a page and inserts exactly what it is given, and nothing in the language or lib/ escapes HTML. The safe twin of fill is the one with the worse name.
  • A template with two kinds of hole cannot use fill at all, since it insists the placeholders and the values come to the same number. The marker-and-split habit that replaces it is worse, because a marker is a string and a value can contain one. What is left is an array of pieces joined.
  • A permission per message is not fine enough. A CGI handler is told what it was asked entirely through system:environment, so a scheme that can only say yes or no to that message must say yes — and has then handed over every secret the server process holds.
  • A limit bounds dispatch, not work. Running it as a guest with an allowance found that readFile of 256MB and a scan of all of it is eight instructions, the same eight as for 64MB. That is 3.7, and it corrected two documents that said otherwise.

disasm — a .sob file, read and disassembled

Reads a compiled Solum file and prints its header, tables and instructions — offsets, lines, opcodes, operands and jump targets — recursing into every method and block.

./bin/solvm programs/disasm.sob                    # compiles itself a sample
./bin/solvm programs/disasm.sob path/to/file.sob   # your own
./bin/solvm programs/disasm.sob file.sob brief     # header and tables only

The first to read a binary format, and the first to read one this project defines. solvm --dump already disassembles, so this is a second implementation — which is the point, because a second implementation is how you find out whether a specification is true. It was written from design.md and BYTECODE.md, going to the C only where those ran out. They ran out five times.

What it found in the documents, all three now fixed:

  • BYTECODE.md never said what byte an opcode is. It described every instruction and the test suite checked that description against the header in both directions — and the mapping from byte to instruction lived only in the order of a C enum, so a reader with the page in front of them could not decode one instruction. The page carries the numbers now and tests/test_bytecode.c checks them.

  • design.md contradicted itself about byte order. Its instruction-set section said a side-table index "is a big-endian u16", correctly. Its .sob section said "little-endian throughout", which was true of every table in the file and false of the operands inside the code. A reader after the file format lands on the second. Getting it backwards does not look like a misreading, it looks like corruption — every index 256 times too large.

    Settled rather than documented, in the end. The order was collapsed into one pair of shifts and then flipped: as of .sob format 14 the operands are little-endian too, and the sentence is simply true. This program's own two decoders had to be edited by hand, nothing checking them against the C — and comparing its output against solvm --dump is what confirmed both sides had moved.

  • The format table was missing three sections and a constant tag — the file table, the file-run table and the slot names, plus tag 3 for a boolean. They arrived with 6.27 and 6.28, which bumped the format to 12 and then 13; the table was not bumped with them. It also did not separate the file's header from a chunk's body, so "then that method's chunk, recursively" read as though the whole thing recurred.

And two about the language, neither a defect:

  • No shift can produce a negative integer, there being no unsigned type: shiftLeft(#56) on a byte of 128 or more is a value larger than an i64 holds, and the language traps rather than wrapping. The trap is right.

    This was first written up as "Solum can write an integer into a .sob that it cannot read back", and that was wrong. Arithmetic reaches what shifting cannot — (b - 256) * 2^56 is the same number by a route where every step fits — and the disassembler reads INT64_MIN correctly. The claim was made on the strength of one route failing and disproved by trying to write it down as 3.12.

  • A float has to be decoded by hand, one bit-field at a time, because nothing reinterprets an integer's bits as a float. readFloat is IEEE-754 binary64 written out in Solum. It works — 2.5 comes back 2.5.

Checked against the oracle: identical offsets, opcodes, operands and jump targets to solvm --dump over eight files and 7,673 instructions, including lib/json.sol and lib/html.sol.

The neighbour that could not be a program

The obvious next thing to write here is the other question about a .sob — not what instructions are in it but what does it export, which is what anybody asks of a library they did not write. It is not in this directory and cannot be, and the reason is worth recording on the page about what belongs here.

The globals are slots on an object with no name in the language, so neither slots nor perform reaches them: a program cannot list what it has bound, let alone what a file it loaded bound. Whatever answers that question has to hold the root object, which is C. It shipped as solid --exports — a mode of the debugger, because the debugger is the other thing here that holds the root, and its globals command is the same question asked from a prompt.

disasm could have faked it and would have been wrong, which is the more useful half. Collecting every SETGLOB out of the top-level chunk is a dozen lines from where this program already stands — and lib/text.sob binds no global at all, hanging asUtf8 on integer instead, so that report would be confidently empty for a library with two messages in it. The surface has to be read by loading the file, not by reading it. See 6.38.

expect — the examples and the documents, checked against what they claim

Runs every file in examples/ and checks the inline comments that say what each line prints.

./bin/solvm programs/expect.sob                      # all of examples/
./bin/solvm programs/expect.sob examples/numbers.sol # one file
./bin/solvm programs/expect.sob programs             # another directory

Over examples/ alone that is 30 files and 588 claims:

30 files with expectations, 588 claims checked
88 lines print without saying what, and are not checked
GRAMMAR.md and solum.bnf agree on 35 productions, and 2 are prose
289 changelog entries name a commit, 10 name none
2811 links in 142 files, 1407 of them naming a heading, against 1608 headings
2239 name a path, and every one of those is there too
1 heading sits inside a fenced block, and is not an anchor
17 programs say where they come in the order, and are there
3 runs ended with a non-zero status, which is what a documented error does

every claim holds

Six of those lines are about the repository rather than about examples/, and they are printed whatever this is pointed at: the grammar, the changelog's hashes, the links, and where each program says it comes in the order are facts about the tree, not about the run.

The two numbers in the sentence above the block are recounted on every build; the block itself is a transcript, and a transcript is the one thing here nothing can check. That is the shape of the remaining gap, in miniature — and it had gone stale again by the time the links went in, still showing 21 files and 402 claims from three releases before.

The narrowest customer of the eleven — this repository — and a real job all the same. examples/ carries about four hundred comments of the form #2:add(#3):print. ; #5, and until this existed nothing checked one of them. The suite compiles every example and never ran one, so those comments were true because somebody looked, once, at the time — the same standing the .sob format table had when disasm found it three sections out of date. They are also the first thing a newcomer reads.

It is in make test now, in tests/test_documents.c1066 claims on every build, and it fails the build if one stops holding. It was in test_cli.c until 2026-09-03, with the other tests that run the binaries as a shell would, and it moved when the cost was broken down: 55 seconds, which was two thirds of the whole suite. This entry said about sixteen for as long as nothing measured it.

And it holds one document against a file rather than against a run. GRAMMAR.md opens by saying it is the same grammar as solum.bnf in a form a person reads, and that is the largest claim on the page — everything else there is one production, and that sentence is all of them at once. Every production is now compared character for character, once runs of whitespace are collapsed, so a rule that gains an alternative in one file and not the other fails rather than drifting. Twenty-three agree and two are prose, string and comment being written any character but a quote where the notation says ! '"'; those two are counted and reported rather than skipped quietly, so the excusing is visible.

Aligning the two to make the comparison possible found primary listing its alternatives in a different order in each file. Harmless — there is no token two of them could both match — and exactly the drift the check exists for, since the next reordering might not be.

And it checks the SolaBasic documents, which are in another language entirely. SOLABASIC.md, its reference and its cheatsheet carry seventy ```basic blocks, and a fenced block naming a language was the one thing this checker skipped — so a repository that defines a dialect, ships a compiler for it and writes three documents about it was checking all three by eye.

The expectation is a text block under the basic one, not a comment on the line, and the reason is BASIC's own output: PRINT 42 writes a space where a minus would go, then the digits, then a trailing space. Leading spaces are what a print zone and TAB are made of and a comment cannot show them; a trailing space in a comment is invisible and the first editor to touch the file would strip it. Inside a fence the leading ones survive. So trailing whitespace is ignored on both sides and leading whitespace is not — the trailing space after a number is held instead by programs/sola/*.out, which test_cli compares byte for byte.

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

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

And it checks the documentation too. The guide and the reference carry the same notation inside ``` fences, and nothing checked those either — they are the two documents a newcomer actually reads. 476 claims across twenty-nine documents, and two more on README.md and `index.md` — the front pages, which were the last two things nothing checked.

A block from a document runs in a scratch directory, because this executes documentation and documentation shows how to delete things — one block with literal arguments put back a file a commit had deliberately removed. A shipped example is not moved: those run from the repository root by convention.

That half found two things. The guide showed a stack trace reading [line 1] in block, a format that predates 6.27 adding the filename — the illustration was never updated when the format changed. And class-and-instance.md said integer has 24 slots, three times, where it has 37: messages were added and the count was not. That number is safe to state now precisely because it is checked.

A block that does not run is a failure, and that is a change: it used to be a count. The reading was that such a block continues one further up or shows syntax rather than a program — both true, and both also true of a block with a typo in it. Counting what was in them found 54 claims in 42 blocks, one claim in thirteen, none of them checked and none of them reported.

Two things emptied the category. A page is read as a page: a block that will not run alone is run again on everything the document established before it, so the reference's continuing the point above reaches a point 370 lines back. That accounts for 28 of the 42. And a block that is not a program says so, with a word after its fence — a text tag for a session or a sketch, sh for a shell transcript, c for C, the last two of which the documents were already writing. That accounts for 14. The remaining 8 were broken.

The escape hatch is deliberately the visible one: a reader can see a fence that says text, and cannot see a silence in a count.

And it recounts what the prose says about this repository. A sentence is neither a comment on a printing line nor a fenced block, and a number in one has no notation saying what it counts — so it is given one, which renders as nothing and leaves the sentence as it was:

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

Each name is recounted from the repository as it stands. A name the table does not know is a failure, so a marker cannot be misspelled into silence. A program's position needs no marker, because the phrase is already one: nine of the eleven open with The fifth program here, and the headings on this page put them in that order.

That found float answering 26 messages where ROADMAP 3.14 said 21 and rested an argument on it, the reference's index saying 121 messages across 215 registrations where it is 122 across 216, and the sample output above, which had been showing 398 claims for a while.

CHANGELOG.md is the one document whose blocks are skipped — it records what was true at each release, so its snippets describe past states on purpose.

Its headings are read, though, for the commit hash each one names. An entry cannot carry its own hash, so it goes in saying pending and a follow-up commit substitutes the real one — and until ROADMAP 3.21 nothing asked whether that had worked. Once it had not: an entry carried a literal %s where its hash belonged for two days, through every make test, and was found by a person reading the page. Everything backticked after a heading's last em dash must now be seven hexadecimal characters or the literal pending. It does not ask git whether the commit exists, which would couple this program to a repository — it reads files and runs programs today, and a tarball with no .git in it checks clean.

And its links are read, along with every other document's. A markdown link that names a heading — [3.5](ROADMAP.md#35-recursion-is-limited-to-about-254-levels) — is the one cross-reference nothing verified, in a repository whose filing system is moving a heading from one file to another when an entry closes. Every heading in docs/, the two pages at the root and every .sol header is turned into the anchor GitHub would give it, and every link carrying a # is either in that set or it is a finding. A link with no fragment is counted and not checked, because a missing file is a different question.

And site.sh asks the same questions of the pages that are actually published, which is a different thing and is not in make test: the suite is offline and dependency-free, and a check that fails on a train is not a check. It fetches every page GitHub Pages serves and compares three things against the source at origin/main — not against the working tree, since the site renders what was pushed and a local file would report every unpushed edit as a fault.

It has found two fault classes that read correctly as markdown and publish wrong. A paragraph wrapped so that ``` began a line is a code fence, and cost 263 of CHANGELOG.md's headings for ten days. And a link whose text wraps across a line loses the site's baseurl when Jekyll rewrites its `.md` target to `.html` — eleven of those were writing `/docs/X.html` where the page lives at `/Solveig/docs/X.html`, and every one was a 404. Neither is visible to `expect.sol`, because in both cases the markdown is correct.

Fenced blocks are tracked while doing it, and that is the load-bearing part. A heading inside a fence is not a heading on the page, so the number of those is reported: it is 1, and it goes to 12 the moment a paragraph wraps so that ``` begins a line — which is what CHANGELOG.md had done for ten days, along with an inline code span wrapped so that `` began one, which kramdown reads as raw HTML. Between them, 64 of that page's 327 headings reached the published site. Neither was found by this check, and the entry for it in ideas.md says so: what found them was counting the headings the site renders against the headings in the file, which needs the network and is not here.

What it found, once every block was actually being run: the guide asking point:slots for slots that page never defined, and p:perform('show) for a method nobody had written; the reference binding integer:slotAt('poly) where poly appears nowhere else in the document; a lines counter used and never initialised; a point:slots answer that had gone stale when the section above it gave point an asString; and, in class-and-instance.md, #45:new(#1):print. ; #1 — a claim about what the language does that the language stopped doing, in a document whose own opening line says every snippet here has been run.

Every claim that states a value now holds — all 729. What the first pass turned up instead was that the examples used three conventions for these comments and nobody had noticed, because nobody had had to parse them:

; #5                      the value alone
; #7 -- and why           an aside after a dash
; #8 distinct words       an aside with no dash at all

The checker learned all three rather than declaring two of them wrong, which is the choice worth recording: a checker that insists on a convention its subject never agreed to is measuring itself. Nine comments were glosses rather than claims — a timestamp that changes every run, a duration at the clock's floor, ; midnight beside a time — and those now open with --, which the checker reads as an aside claiming nothing.

What it deliberately does not do is demand line-for-line agreement. A claim must appear in the output after the one before it, because one statement can print many lines. The cost is that a claim could be satisfied by a later coincidental match; the benefit is that it works on files with loops in them, which is most of them.


bench — how long does it take, and is the difference real?

Runs a command many times and reports the shape of what came back; given two commands, says whether the difference between them survives the noise.

./bin/solvm programs/bench.sob                       # 20 runs of `solvm --version`
./bin/solvm programs/bench.sob 20 ls -l              # one command
./bin/solvm programs/bench.sob 40 cmd a -- cmd b     # two, interleaved
A:  ./bin/solvm nothing.sob
  runs     40
  min         2.372 ms
  median      2.617 ms
  p90         2.852 ms
  max         3.477 ms
  mean        2.656 ms  +/- 0.233

B:  ./bin/solvm expect.sob index.md
  runs     40
  min        16.755 ms
  median     17.683 ms
  p90        18.219 ms
  max        18.735 ms
  mean       17.695 ms  +/- 0.462

A / B    0.148 times, 95% interval 0.145 to 0.151
         A is faster

Written to press on a gap rather than to do a job that happened to need one, which makes it the odd one out here. Every program before it was a job first. This one starts from a fact about the repository: it has been quoting timings for six releases — 40.5µs to build a machine, 121µs for a request, 279µs to compile a chunk — and every one of them was taken by hand, once. A number taken once is a sample of one, and the run above shows why that matters: the maximum is 47% above the minimum on a quiet machine.

Two commands are interleaved, and a coin decides which goes first each round. A machine drifts over the course of a minute — something else starts, the CPU warms and throttles — so timing all of A and then all of B measures the minute. Strict alternation is better and is still a pattern: anything on the machine with a period of two runs lines up with it exactly.

The interval is a bootstrap, and it is what makes the answer honest. Timings are skewed — bounded below by the work and unbounded above by whatever else happened — so the tests that assume a normal distribution do not apply. Instead it resamples both sets two thousand times and reports where the middle 95% of the ratios fell. If that interval contains 1, the right answer is this many runs cannot tell them apart, and it says so rather than reporting a winner. Given the same command twice it answers 1.001, interval 0.985 to 1.015 — which is the test the tool has to pass before any of its other answers are worth reading.

What it found is 3.14 and 3.15. There was no sqrt, no min, no max and no randomness in the language, so this file carried all four. Writing them is easy; getting them right is not, and the square root here was wrong twice, each time silently: first as twenty fixed iterations, right to twelve places at 2 and wrong in the fourth digit at 1e10, and then — in the version written to fix that — as a capped loop that answered 8.67e281 for sqrt(1e300). All four are the language's now: sqrt is a message a float understands, min, max and between are in math.sol, and the generator this file carried is random:new — which was built because measuring the one here found what was wrong with it. The generator was fine; the seeding was not, and neither half of that was fixable in Solum. Two runs a microsecond apart got consecutive seeds, and the first coin flip was then exactly the parity of the start time while the first resample index of 21 took three values out of 21.

And testing that square root at 1e300 found a bug in the VM. Formatting a float with a fixed number of decimals wrote into a 64-byte buffer and then used snprintf's answer — the length it would have written — as the length of the result, so 1e150:asString("0.6") returned 157 characters of which 93 were the stack behind the buffer. Fixed, with the buffer sized for the worst case the format spec allows and the length clamped to it regardless.

The two faults hid each other. The 1e300 test was read as showing a formatter bug and a square root that had converged, because the digits were checked against the C library and matched. They were the right digits of the wrong number. A test that compares how an answer prints is not a test of the answer.

basic — an interpreter for another language

Reads a BASIC listing and runs it. The dialect is ECMA-55 Minimal BASIC (1978), chosen because a published standard means what counts as finished is settled by somebody other than the author of the interpreter — twenty statements and eleven supplied functions, and no room to declare victory early.

./bin/solvm programs/basic.sob
HELLO, WORLD
 14 
 20 
 2.5 
-7 
 42 
 0 
A              B              C
 1  2  3 
COUNT:  99 

Those spaces are the standard rather than an accident. A number is written as a sign character — a minus, or a space when it is not negative — then the digits, then a trailing space, which is why BASIC output has its airy look and why a negative number lines up under a positive one.

The whole language is here — all twenty statements and all eleven supplied functions — and it runs about 420,000 BASIC statements a second. LET, PRINT, REM, END and the expression grammar; GOTO, IF-THEN, FOR/NEXT, GOSUB/RETURN, ON-GOTO, STOP; then text, arrays, DIM, OPTION BASE, DATA/READ/RESTORE, INPUT, DEF FN, RANDOMIZE and the functions. It also takes a listing of its own:

./bin/solvm programs/basic.sob programs/basic/wave.bas

Given a file it runs only that — the demonstrations it carries are skipped, because a tool asked to run your listing should not print its own first. A listing that failed leaves a non-zero status, so it composes with a shell, and says why on standard error, so a redirect keeps the two apart.

And it has a prompt, which is the interface BASIC actually had:

./bin/solvm programs/basic.sob --repl
./bin/solvm programs/basic.sob --repl programs/basic/sieve.bas

One rule and six commands. A line beginning with a number goes into the program, a line that does not happens now, and a number on its own deletes that line — which is how a line is removed when the only editor you have is the line you type again. LIST, RUN, NEW, LOAD, SAVE and BYE are the rest. programs/basic/session.in is a recorded session and session.out the transcript it must still produce.

PRINT shows six significant digits with no nought before the point, which is what BASIC shows and what Solum does not — 1/3 is 0.3333333333333333 in Solum and .333333 here. A million comes out as 1E+06, which looks like a defect and is the standard: seven digits to the left of the point is more than six significant digits can describe.

Four of the listings in programs/basic/ carry a recorded transcript compared byte for byte on every build. That is what the claims in comments cannot be — programs/ is not one of the documentation checker's subjects, and the output of a BASIC program is exactly where a comment goes stale unnoticed: print zones, six digits and the trailing space after every number are invisible to a reader and all load-bearing.

It is checked against a suite somebody else wrote. The NBS Minimal BASIC Test Programs are 208 programs written at the National Bureau of Standards in 1980 to test an implementation against ANSI X3.60-1978, the standard ECMA-55 mirrors — a US government work, public domain, and the only test of this interpreter not written by its author. programs/basic/conformance.sh fetches and runs them; it is not part of make test, because it needs the network and because the suite is written for a person to read rather than for a machine to score.

It found seven defects, and none of them had been caught by the eighty-three claims in the fileDATA being raw text rather than tokens, a datum having no type until a READ takes it, DEF needing no parameter, NEXT having to search the loop stack, FOR having to nest dynamically through GOSUB, DIM being a declaration rather than a statement, and exceptions that report and carry on. Those claims check what the author of the interpreter thought to check, which is exactly what an external suite is for.

One thing here is not the standard, and it is written down in the file rather than left to be found. ECMA-55 makes a space insignificant outside a string, so FORI=1TO10 and PRI NT are both legal BASIC; here they are not. Fixing it means a tokeniser that knows where it is in the grammar — FORI is FOR I only because a statement begins with a keyword — which is a different design rather than a missing branch.

A BASIC program is a graph rather than a sequence, and its edges are line numbers, so all of them are followed in three passes at load: a jump becomes an array index instead of a search of the listing, a jump to a line that does not exist is reported before the program prints anything, and a FOR finds its NEXT. That last one is what lets a loop with an empty range skip its body — it already knows where the body ends.

The first of these to be an interpreter for another language rather than a tool for this one. It holds a second language's whole state — a variable table, a program counter, a listing — and what makes it different from the other programs here is that it is judged against a specification: either a listing gives the answer the standard says, or the interpreter is wrong.

What it found: the trigger 3.14 had been holding open. That entry held pow, log, exp and trigonometry, waiting for a program that wants an angle. Six of Minimal BASIC's eleven supplied functions are SIN, COS, TAN, ATN, EXP and LOG, and ^ needs pow — so this was not a program that would like an angle, it was one that could not be finished without them, and could not decide to want fewer: they are in the standard it is measured against. For two days ^ raised rather than being stubbed with repeated multiplication, an operator right for 2^3 and quietly wrong for 2^0.5 being the same silent failure that entry already recorded twice. Then the decision was taken and all eleven landed at once.

It also found 3.18: INPUT has to show a ? and read the answer beside it, and there was no way to write to standard output without ending the line. system:write closed that the same day, and it is half of what edit needed before it could draw anything at all.

And a happier finding, about line numbers. They are usually a joke, and here they are what makes the job possible. SOL_FRAMES_MAX caps recursion at about 254 (3.5), and a tree-walking interpreter for a modern language spends frames in proportion to how deeply its source nests — so it would run out of machine before it ran out of program. A line-numbered BASIC never nests: the run loop is a program counter over a sorted table of lines, and GOSUB and FOR are explicit stacks in arrays, which is heap rather than frames. The only recursion is in the expression parser, and it runs once at load rather than once per execution.


edit — a file on the screen

The twelfth, and the first that draws. Every other program here writes a line at a time and reads a line at a time; this one owns the screen, puts the cursor where it wants it, and redraws the whole of what you are looking at between one keystroke and the next.

A modal editor in the manner of vi, in about fourteen hundred lines — two fifths of them comment, which is where its arguments are.

./bin/solvm programs/edit.sob                 # a buffer it writes for itself
./bin/solvm programs/edit.sob notes.txt       # your own file

h j k l and the arrows move, w and b by word, 0 and $ to the ends of a line, gg and G to the ends of the file, ctrl-f and ctrl-b by a screen. i a I A o O begin insert, x deletes a character, r replaces one, ~ swaps its case, J joins two lines, e goes to the end of a word and fx tx Fx Tx find a character on the line. d, y and c take a motion — dw, ce, d$, dj, dG, d'a, and dd/yy/cc for whole lines — p and P put back what they took, ma marks a place and 'a and `a go to it, u and ctrl-r undo and redo, . does the last change again, and a count repeats: 3j, 2dd, d2w, 10G, 3p, 3.. /pattern and ?pattern search forwards and back, n and N do it again, and :s/find/replace/ changes what they find — /g for every match on the line, :%s for every line in the file. :w, :w name, :q, :q!, :wq and a bare number to go to that line. Escape leaves insert mode, with the caveat below.

It was written to find one thing, and the thing was written down first. ideas.md predicted, before this file existed, that an editor would want the terminal's size and find nothing to ask. That is exactly what happened, in the first hour, and it is 6.34: system:terminalSize now, closed the same day it was raised.

What made it an entry was a measurement rather than the absence. The number was always reachable — stty size through a shell — at 7.0ms an ask, which is a fork, an exec and a pipe per keystroke if a program measures each time it draws. So the editor measured once at startup, and every window resized after that was a window it drew wrong until it was restarted. One ioctl is about a microsecond, so it now measures on every frame and the resize notification the language has not got stops being something anybody needs.

And it confirmed a warning that had only ever been theoretical — then got it fixed. examples/keys.sol had said since the day readKey landed that a byte-level reader cannot tell the escape key from the start of an escape sequence, since telling them apart needs a read that gives up after a few milliseconds. Nothing had ever bound that key, so the warning stood untested. A modal editor binds it to the most frequent action there is, and escape stopped taking effect until the next key arrived.

That is 6.35: system:keyWaiting(0.05), is a byte coming? — and nothing follows an escape that fast except a machine. The editor asks, and leaves insert mode there and then. The warning was right, it was written by a program that was not annoyed by it, and it waited for one that was.

Three smaller things it found, none of them worth an entry:

  • An array cannot have an element put into the middle or taken out of it. add appends and removeLast pops, so o and dd rebuild the array around the change. That is one pass over the lines per line inserted, which for a file anybody edits is nothing, and it is why the two are one method each here rather than one call each.
  • system:write flushes, which is what makes one frame one call. A redraw that arrived in pieces would be a redraw you can watch happening.
  • A tab is one byte and eight columns, and everything that positions a cursor has to hold both numbers at once. That is not the language's doing — every editor ever written has this — but it is where most of the arithmetic in this file went.

Searching came a day later, and most of it is a library. lib/re.sol is regular expressions — POSIX basic, which is what vi means, with groups and back-references since 2026-09-01 — and the editor is /, ?, n, N on top of it. What the program added to the library was the part a matcher cannot know: a file here is not one string. It is an array of lines and the cursor is a row and a column, so a search is a walk over lines, and ^ and $ mean the ends of a line without anybody having decided that they should. Both directions wrap and say when they did, because a search that comes back round to where it started looks exactly like one that found something new. And a pattern that will not compile puts its complaint on the bottom line rather than ending the editor.

And substitution came with it. :s/find/replace/ is the search plus replaceAllIn, with & in a replacement standing for what was matched and the delimiter being whatever character follows the s — so :s#/usr/bin#/usr/local/bin# needs no escaping. It is deliberately not /find/replace/: /src/lib is a good search for a pattern with a slash in it, so a bare /a/b/ would mean deciding that some searches are silently substitutions. vi put substitution on the colon line for that reason.

The report is counted rather than compared17 substitutions on 9 lines — because the number of lines whose text ended up different is a smaller number and a wrong one: replacing a with a changes nothing and is still a substitution. And :%s is the first thing here that can change a hundred lines at once with still no undo, which is what the count and :q! are for.

And then it was made to be vi rather than to look like it. The notation is [count] operator [count] motion, where any of the three may be absent — and an editor that implements that as a table of keys is a pile of special cases, one row per pair. So there are two dictionaries and one dispatcher: a motion answers a place and moves nothing, an action does something, and the dispatcher decides which a key is and whether an operator is waiting for a place to work over. dw, 3dw, d3w, d$, dj, dG, y'a, 2yy and 3p are then all the same code. Adding e or f later is one line in the motion table and no change anywhere else, which is the test of whether the grammar was implemented or imitated.

The motions are the ones the cursor uses: an operator runs wordForward and puts the cursor back, so dw and w cannot disagree about where a word ends. That is also where the one bug of the rewrite lived — a cursor may not stand past the last character of a line and a range end must be able to, which is what dw on the last word of a file needs and what the clamp did not know.

A place carries how it should be read: whole lines or a piece of text, and whether the character it lands on is inside the range. dj is two whole lines, d$ includes the last character, dw does not include the first character of the next word. And an exclusive motion that ends in the first column ends at the end of the line before instead — the real vi rule, and what stops dw on the last word of a line from dragging the next line up into it.

One unnamed register, holding either lines or a piece of text, and which of the two decides what p doesyy p copies a line below this one, yw p copies a word after the cursor. A mark is a row and a column, and the row moves when the text does: insertLine and removeLine shift the marks below them, and a mark on a line that is deleted is dropped rather than left pointing at whatever moved into its place.

And undo is one array copy per change, which is the finding worth having out of this program's third day. A change is remembered by keeping the whole buffer, which sounds extravagant and is not: a line is a string, a string cannot be changed, so a copy of the array of lines shares every line with the buffer it came from. The copy is one pointer per line and the text is never copied at all.

Measured, because the claim is exactly the kind that is believed and wrong: ten thousand lines of ten characters and ten thousand lines of a thousand characters snapshot in 0.095ms and 0.078ms — one measurement twice. A hundred times the text costs nothing, which is what sharing looks like from outside.

That is why this is a stack of buffers rather than a list of inverse operations. How to undo a delete is the design a mutable-string language is pushed towards, and it is a second implementation of every command — one to do it and one to undo it, with the second exercised only when something has already gone wrong. The price here is array slots instead: a hundred states of a ten-thousand-line file runs under --memory=16M and not under 15M.

Every change goes through three methodssetLineAt, insertLine, removeLine — so a command cannot forget to be undoable. And a change is one keystroke, except in insert mode, where everything typed between i and escape is one: that boundary is drawn in the dispatcher rather than in the commands, which is what makes u after a typed paragraph useful rather than infuriating.

And . repeats the keys rather than a description of them. The other way is to remember what was done — an operator, a motion, a count, some inserted text — and do it again, which is a second description of every command that can change the text and a second place for them to disagree. Keys are what the editor already understands, so feeding them back in is the same path they took the first time and . cannot drift from what it repeats.

What counts as a change is what undo already decided. remember is called by the three methods that alter the text, so it is the one place that knows whether a command changed anything; it sets a flag for . on the way past. A command that only moves the cursor records nothing, and neither does yy — a yank is not a change, which is vi's rule and falls out here rather than being written down. Colon commands are left out on purpose: :s/a/b/ changes the text and . does not repeat it, here or in vi, because a colon command takes a line of its own syntax and can name a range.

It found one bug, and it was the first command able to. . dispatches keys of its own, and the count was being cleared after an action ran rather than before — so the 3 of a replayed 3x joined the count still pending and x3. deleted the whole line. An action that runs other commands has to start from a clean state, and nothing before . had ever run one.

And then it was measured on a file worth the name, which nothing here had done: 50,000 lines, 2.3 MB. Loading, moving to the end, searching, editing and undoing are all 0.03–0.05 s — and :%s/alpha/ALPHA/g across the whole file took 7.7 seconds, which is not slow, it is a hang.

Two things were wrong and both were the program's rather than the language's. The matcher tried a match at every position of every line, where a pattern beginning with a plain literal can ask indexOf — a primitive, scanning in C — where the next candidate is. And the editor walked every line twice, once to count the matches for its report and once to replace them. The library answers both in one walk now, the way capture answers "output" and "status".

7.7 s to 2.4 s, with the same 32,818 lines changed and all 136 behaviour checks unmoved. The remaining cost is the matcher itself, which is Solum, and that is the honest floor: string:indexOf scans those 50,000 lines in 0.007 s, and the same scan written as a loop here takes 0.85 s. A library's speed lives at the boundary with the primitives, and the way to be fast is to hand the scanning back across it.

And its behaviour is pinned by a hundred and eighty-one scripted sessions. programs/edit/checks.sol writes a file, feeds the editor a string of keys through a pipe and compares what was written against what those keys should have done. It runs in make test and takes under a second, and it exists because four days of building this produced one defect per feature — the clamp that let dw leave the last character of a file, the count cleared after an action rather than before, c$ eating the space in front of the cursor. Every one of those is a line in that file now.

What it does not do: no U — vi's undo every change on this line is a different mechanism, not a level of this one. No ; and , to repeat an f, no named registers, no line ranges beyond %, and J joins without inserting the space vi inserts — that rule has exceptions in it, and a rule with exceptions should be wanted by somebody before it is written — :1,5s/a/b/ is a parser this has not got. Each of those is more of the same rather than more of the language, and this program was written to ask the language a question.


sola — a compiler for another language

Reads a SolaBasic program and writes a .sob. The file it produces is run by solvm with nothing of this program present, which is the difference between it and basic — that one interprets another language, this one compiles one.

./bin/solvm programs/sola.sob                        # the demonstration
./bin/solvm programs/sola.sob prog.bas [prog.sob]    # a file
i = 1
i = 2
i = 3
i = 4
i = 5
counted to 5

All eight stages of SOLABASIC.md, and every one of them held against a real QuickBASIC 4.5 rather than only against transcripts this compiler recorded of itself. Stage 3 — GOTO and labels — went first, because it is the claim the whole design rests on and the document says to reach it in week one rather than week six. Stage 2 is the structured half, stage 4 is procedures, and SOLABASIC-REFERENCE.md is what the three of them add up to for somebody writing the language rather than reading about it.

The claim is that GOTO cannot be written in Solum. There is no control-flow syntax here — a loop is a message send — so a translator from BASIC to Solum source would have to compile every statement into a block, put the blocks in an array, and dispatch on a label variable. That is a full send per statement, which is roughly what basic.sol already pays as a tree-walker: a slower interpreter wearing a compiler's name. In bytecode, GOTO is OP_JUMP and OP_LOOP.

And the verifier cooperates, which had to be checked rather than assumed. SolVM requires the paths into an instruction to agree on stack height. Every SolaBasic statement compiles at depth 0 and ends with a POP, so a label is a depth-0 merge point by construction and an arbitrary jump between statements needs no analysis at all. That was measured before the compiler was written, by hand-assembling a chunk with a backward jump to an arbitrary earlier offset, a forward jump over dead code, and a conditional between them — and by breaking it two ways to check the test could fail:

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

The second is the one that matters, because it is what a compiler that got clever would emit. The depth-0 discipline is load-bearing rather than tidy.

The opcode is not known when the jump is emitted, which is the whole of the back end here. Forward is OP_JUMP and backward is OP_LOOP — two opcodes, because the machine has no signed offset and the verifier relies on everything else moving forwards — and which one a GOTO is depends on where its label turns out to be. Both are three bytes, so three zero bytes go down as a placeholder and a fixup list remembers where. Nothing moves afterwards, so no offset already computed can be invalidated by a later patch, which is the trap in every backpatching scheme that emits a short jump and grows it.

A label is a string of characters, not a number. That is CB80's rule taken over whole, and it is what lets an old listing through unaltered: labels need not ascend, need not be present, and nothing here ever sorts one.

100 PRINT "at one hundred"
GOTO 50
30 PRINT "never"
50 PRINT "at fifty, which is written after one hundred"
END

And it is 45 times faster than the tree-walker. The same counting loop — 200,000 iterations, two statements each — is 1.54s under basic.sol and 0.034s compiled, both including VM start. SOLABASIC.md predicted "roughly an order of magnitude" and was too modest; the entry that says so is in that document's own change log, which is what it is for. It is still a much faster interpreter rather than compiled — SolVM has no arithmetic instruction, so a SolaBasic + is one OP_SEND and not an add.

Stage 2 needed nothing the back end did not already have, and that is the finding rather than the feature. A GOTO needs a hole punched in the code and filled in when its label turns up; IF, SELECT CASE, FOR, DO and WHILE need exactly the same hole, filled in when their closing line turns up instead. So the whole of stage 2 is one stack of open blocks, each frame holding the holes it still owes an answer to. The structured half of the language is the unstructured half with a stack on top — and doing stage 3 first is what made that visible rather than lucky.

The blocks are a stack and the statements stay flat, which is the decision worth arguing about. A parser building a tree is the other way and it is the wrong way here: BASIC's blocks are not written as nesting, they are an opening line and a closing line, and half the errors worth reporting are the two failing to match. A stack has the mismatch in its hand —

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

— where a tree would have refused to parse and had less to say about why. It is also what makes EXIT FOR reach the right loop: the innermost FOR, not the innermost block, so an EXIT FOR inside an IF inside the loop is a search down the stack rather than a walk over a tree.

And a jump still goes wherever it likes. GOTO out of a loop from inside an IF works, and a label placed just before NEXT is how BASIC spells what a later language calls continue — both in escape.bas, because the two halves meeting is the thing worth a transcript.

Stage 4's expensive item cost less than billed, because of how it is represented. QBasic passes by reference — assigning to a parameter assigns to the caller's variable — and SOLABASIC.md called that the most expensive thing in the language. It is a one-element array: a variable ever passed by reference is kept in one always, so the call hands the array over and the callee's atPut reaches the caller's storage. No wrapping at the call site, no copying back, no temporary to keep alive across it, and nothing to get wrong when the call is recursive. The part that was as billed is deciding which parameters — a fixed point, because a parameter is by reference when its procedure assigns to it or hands it on to something that does, and that chains.

A procedure is a block, and it never captures its home frame — every name it uses is its own slot or a global — so 3.1 never bites. 3.5 does: a call is a real frame, recursion stops around 254 levels, and the trace names the BASIC procedure and the BASIC line when it happens. SOLABASIC.md predicted that before the compiler existed.

Stage 1 is the type system, and types have to be settled before a byte is emitted. A conversion is an instruction acting on the top of the stack, so widening an Integer has to happen after it is pushed and before the value beside it — by which time it is far too late to discover it was needed. The tree is typed in a pass of its own and emitting is a second walk that already knows where the conversions go. There is no boolean type, as the language definition says: a comparison is -1 or 0 used as a number, so NOT, AND and OR are bit operations and still read correctly.

A supplied function is emitted where it is called, because there is nowhere to put a library — the .sob is the whole program and none of lib/ is in it. So SGN is a scratch slot and two conditional jumps, LEFT$ clamps with two comparisons before copyFrom is allowed near it, and LTRIM$ is a loop. All of it the same jumps a SELECT CASE compiles to.

And 3.1 caught the compiler. A helper here built the block that emits a one-send builtin and stored it in a table — and that block read the helper's parameters, so it captured a frame that had already returned. 3.1 says a block that reads its home frame cannot outlive it, and the machine said exactly that: block outlived the frame it was written in. The table holds a symbol and a selector now.

PRINT's rules were brought forward out of stage 6, because stage 7 is a comparison against a real QuickBASIC and it cannot compare anything while every line differs in its spacing. A number is a sign character, the digits, and a trailing space; , moves to the next zone of 14 and ; moves nowhere; a separator at the end of a line holds it open; the margin is 80.

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

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

Stage 5's by-reference was free, which is the opposite of what a scalar cost. A Solum array is a reference, so Sort(n(), 6) hands the array over and the callee's atPut writes the caller's storage because it is the same array — no box, no analysis, nothing to keep alive across the call. And the bounds being constant means most of the index arithmetic happens while compiling: a(i) is i - low + 1, and a second dimension multiplies by a stride the compiler already knows.

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

And it is compared against a real QuickBASIC, which is the only check here that can find something nobody thought of — everything else this compiler is held to is a transcript recorded by its own author. Twenty programs match byte for byte, five differ exactly where the language definition says they should, and the comparison has found four real defects that the transcripts did not — one of which a transcript had recorded as correct.

And PRINT USING was built the other way round, which is what having an oracle is for: twenty-one formats went through QuickBASIC 4.5 first, and the formatter was written to reproduce what came back rather than to reproduce what somebody remembered. Every case matched on the first comparison but one, and that one was this compiler disagreeing with itself — PRINT USING writing an exponent with E where plain PRINT already wrote D. oracle.sh runs a corpus in two halves: oracle/agree/, which must produce the same bytes under both, and oracle/differ/, which must not and says at the head of each file why. That turns the divergence list from prose into something that can fail — a program in differ/ that starts agreeing means the divergence has gone and SOLABASIC.md is now wrong about it.

The verdict is not in. The harness needs a QuickBASIC and this repository has no dependencies beyond a C11 compiler and make; it keeps that by saying what it needs rather than fetching it. Both of its paths were exercised with SolaBasic standing in as its own oracle — every agree/ matched and every differ/ was reported as having lost its divergence, which is exactly what that arrangement should produce — so the mechanism is not taken on trust either.

What is not here is what the language definition marked not yet from the start: random-access files, ON ERROR, TYPE, REDIM, OPTION EXPLICIT, and : between statements on one line.

check_syntax — a grammar, and a file held against it

Reads a grammar written in Wirth's EBNF, then reads a second file and says where it stops agreeing with it. The grammar is the program: hand it pascal.bnf and it checks Pascal, hand it solum.bnf and it checks Solum.

./bin/solvm programs/check_syntax.sob                              # the demonstration
./bin/solvm programs/check_syntax.sob grammar.bnf source.pas       # a file
./bin/solvm programs/check_syntax.sob grammar.bnf source.pas tokens  # the token stream
./bin/solvm programs/check_syntax.sob grammar.bnf                  # the grammar alone
programs/check_syntax/missing-semicolon.pas:13:3: syntax error: expected ';', 'else' or 'end', found 'n', reading <if-statement>
    13 |   n := n + 1;
       |   ^
programs/check_syntax/missing-semicolon.pas: 1 error

Two dialects, because "a file written in BNF" means the older one at least as often. Wirth's notation — expression = term { "|" term } . — is the one the Pascal report uses and the one that describes itself. The older shape — <expression> ::= <term> | <expression> "+" <term> — has angle brackets, ::=, no terminator and one production per line. Both are read by the same reader: a production ends where the next one starts, which is a name followed by a definition symbol, so the . is optional rather than required.

A grammar has two halves and has to say where the seam is. Pascal's syntax is written over tokens and says nothing about how characters become them; Wirth's report gives the lexical rules in the same notation, so both live in one file with %syntax naming the line between. That seam is declared rather than guessed, because identifier and expression look alike and a checker that guesses wrong reports a correct file as broken — which is the worst thing this program could do.

Three extensions, and no more. Wirth's notation cannot describe a lexer: it has no range, no negation, and no way to write a tab. So "a" .. "z" is a range, ! factor is one character provided that does not match, and "\n" is what it looks like. All three are refused in a syntactic rule, where they would be asking a question about characters in a place that has only tokens.

Where the error is reported from is the whole difficulty. A backtracking matcher fails at the top, at position one, with everything it tried rolled back — myprog.pas:1: does not parse is a sentence about the program that printed it. So the position is the furthest token any terminal ever failed at, recorded as the match goes and never rolled back, and the message lists what was wanted there. The innermost rule that had already consumed something is named too, which is what turns reading <multiplying-operator> into reading <if-statement>.

The reserved words are derived, not declared. begin tokenises as an identifier, so x := begin would otherwise parse. Every word-shaped literal in the syntactic half is reserved against the token kind it would tokenise as, which recovers Pascal's 35 keywords from pascal.bnf without a list anywhere.

Solum, against itself

solum.bnf is the whole of this language — thirteen syntactic rules and nine token rules — and GRAMMAR.md is the same grammar written for a person to read.

It was taken from solas/src/lexer.c and solas/src/compiler.c rather than from the documentation. The only grammar written down anywhere was a sketch, at the top of solas/include/solas/parser.h, which says of itself that it goes only "as far as docs/design.md pins it down" — it has no blocks, no arrays, no symbols, no temporaries and no slot assignment.

Fifty-six of the fifty-seven .sol files in this repository check clean, and the fifty-seventh is a depth limit rather than a disagreement — see below. Every example and every library file is swept on each test run, which is what stops the grammar from quietly narrowing: none of those thirty-eight files was written with it in mind.

Three things the grammar makes visible that prose does not.

There are no reserved words, and the tool reports this by having none to report. It reserves every word-shaped literal a syntactic rule mentions, and this grammar mentions none: nil, true, object and self are ordinary identifiers that happen to be bound. A test asserts the absence.

. separates rather than terminates, uniformly — required between two statements, optional after the last, in a file, a block and a group alike.

:= may follow a send that took no arguments and not one that took some. That is how a slot is bound, and o:at(#1) := #2 is not a way of storing into a collection. The grammar says so structurally, by putting both possibilities inside send rather than after the chain, and solas and this checker refuse the same file at the same column.

What it found

Every diagnostic it has about grammars came from a grammar being wrong in a way that blamed the wrong file. That is why the checking half is as large as the matching half.

letter and digit are not tokens the first Pascal file read as a stream of them
symbol = "." | ".." never produces .. ordered choice inside a rule is not longest match across rules
<expr> ::= <expr> "+" <term> left recursion, which a PEG cannot do at all

The first is the sharpest. letter and digit are lexical rules and they are not tokens — they are what the token rules are made of — and nothing about their shape says so. Both they and identifier match T; longest-match ties go to the rule declared first; letter is declared first. The report was 130 syntax errors in a file with nothing wrong with it. There is a %fragment directive to say what was meant, and a warning — a token kind no syntactic rule can match — for when somebody forgets it.

The third would otherwise arrive as call depth exceeded against the subject file: a sentence about Pascal when the mistake is in the BNF. It is found by reading the grammar, before anything is matched.

The one measurement that had to be taken twice. Line and column are computed from a byte offset by counting newlines from the start of the file, on the argument that a run wants four of them — one per message — and that carrying a line through every token and every node is a field on everything for a saving nobody would notice. That argument is right about errors and was wrong about the token dump, which wants one per token. On the largest file here — programs/sola.sol, 4,778 lines and 31,887 tokens — it took seventeen and a half minutes to list the tokens of a file it checks in under four seconds. Tokens arrive in order, so the dump carries the line instead and makes one pass over the source: 1,052 seconds became 3.6, which is 270 times. A design note that says how often something is wanted is a claim about every caller, including the one written afterwards.

What it will not do is revisit a choice. This is a PEG: a | b tries b only if a failed, and a choice that succeeded is not reconsidered when the rule containing it fails later. That costs nothing on an LL(1) grammar, which Wirth's Pascal is and most published grammars are. Where it costs something — an alternative that is a proper prefix of a later one — the case is exactly detectable and is reported as a grammar warning rather than left to mis-parse quietly. The rest is stated in the file's header, because a limitation a program does not admit to is one its user discovers as a wrong answer.

The depth limit is gone, and the numbers are why it went. The matcher was a tree walk — one Solum frame per node of the grammar, against a machine with 254 — and the limits were measured through real grammars rather than guessed: 19 levels of nested begin … if and 28 nested parentheses against pascal.bnf, 13 nested blocks against solum.bnf. A grammar rule is not one frame: one level of a language's own nesting costs about four rule references and a reference costs two frames, so the multiplier is the grammar.

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

So the matcher is an explicit stack machine. The grammar compiles once to a flat instruction list — Call, Ret, Choice, Commit, and terminals, which is LPeg's instruction set — and the stack lives in Solum arrays rather than in the machine's frames. Backtracking is a stack entry instead of an unwind: popping to a choice point discards every call made since it, which is exactly what recursion was doing for free. 2,000 levels of nesting now check in both languages, and what bounds depth is memory.

a | b Choice L1 ; <a> ; Commit L2 ; L1: <b> ; L2:
[ a ] Choice L1 ; <a> ; Commit L1 ; L1:
{ a } L1: Choice L2 ; <a> ; LoopCommit L1 ; L2:
! a Choice L1 ; <a> ; FailTwice ; L1: Any

It cost 38% of the running timeprograms/sola.sol went from 3.79 seconds to 5.25 — and two attempts to get that back are worth 3.7% between them. Reordering the dispatch staircase by frequency bought 2.4%, and spelling out the hottest comparison rather than calling it bought 1.3%. Both were predicted to be worth much more. The loop's cost is the instruction fetch and the sends inside an arm, not the comparisons that choose the arm, and an interpreter written in this language pays for its dispatch and cannot get it back by hand.

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

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

pascal — a compiler for a language with a standard

Reads ISO 7185 Standard Pascal and writes a .sob. The second compiler here, and the first with a real one to disagree with: fpc -Miso, run beside it by oracle.sh.

./bin/solvm programs/pascal.sob                        # the demonstration
./bin/solvm programs/pascal.sob prog.pas [out.sob]     # a file
./programs/pas/oracle.sh                               # against a real Pascal
    22    12    85
     3     2
    -3     3

All eight stages, and PASCAL.md says what is deliberately not here and why. The program heading, var, const, type, assignment, expressions, write and writeln with field widths, begin/end, if, while, repeat, for in both directions, case, goto with labels, enumerations, subranges, and ord, chr, succ, pred, odd, abs and sqr; and procedure, function, value and var parameters, recursion, forward, nested procedures with uplevel access, arrays, records, with, sets, reading standard input, pointers, and the standard's required functions. Twenty-one programs produce the same bytes as fpc -Miso, and three more must not — each of those exercises a divergence the document records, so the divergence list is something that can fail.

A type has two kinds, and that is most of stage 2. run is what the machine is holding — an integer, a float, a one-character string, a boolean — and kind is what Pascal thinks it is. An enumeration is an integer at run time and a Colour at compile time; a subrange of char is a character at run time and a 1 .. 20 at compile time. Every check is on kind and every instruction emitted is chosen by run.

Where SOLABASIC.md is a language definition, PASCAL.md is a conformance statement, and that is the whole difference. sola.sol had to draw its own boundary because no standard for a QBasic exists. Pascal has one, so what the page draws instead is the mapping onto SolVM's value model, the divergences, and the stages.

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

What it found

And the verifier says internally inconsistent and not which slot. Two mistakes produced that and nothing else: a jump offset measured from the wrong place — OP_JUMP_IF_FALSE is five bytes where OP_JUMP is three, because it carries the selector it was inlined from — and a scratch slot handed out one past the end of the frame. Both were found by bisecting a working program down to the construct that broke, which is the only tool that message leaves you.

mod is free and div is not, which is the reverse of SolaBasic. ISO says i mod j is non-negative for positive j — a floored remainder, and SolVM's is floored, so mod is one instruction. ISO's div truncates toward nought where SolVM floors, so it compiles through abs and a sign. SolaBasic wanted exactly the opposite of both, and got them from the same machine.

Booleans are jumps, not sends. The machine's and and or take blocks, being short-circuit; Pascal's are ordinary operators. OP_JUMP_IF_FALSE and a boolean constant do it in four instructions with no block allocated — and the standard permits the short-circuit that falls out, because evaluation order for these is the implementation's.

A field width is a compile-time string. writeln(i:6) emits the constant ">6" and one asString, so a write is a GLOBAL system, the value, one send and a SEND write. No runtime formatter and no prelude — which is the other thing sola.sol needed and this does not.

The oracle earned its place twice on the first day. A program in differ/ was called MaxInt, and a program's own name is an identifier in scope — so maxint meant the program and fpc asked for a . where the ) was. And a claim written into this compiler's header before it was checked — that fpc answers -1 for -3 mod 2 where ISO wants a non-negative result — was wrong: Pascal's sign belongs to the whole term, so -3 mod 2 is -(3 mod 2), and asked with a variable holding -3 both answer 1. A compiler for a language whose grammar it has just read is exactly the place to misread precedence.

repeat needs both jumps, and written the way it reads it runs once. OP_JUMP_IF_FALSE only goes forward and OP_LOOP is unconditional, so looping while a condition is false cannot be one instruction: the false case jumps over an exit and into the loop back. The obvious spelling — jump over the loop when false — inverts the loop, and a repeat ... until i >= 3 runs its body exactly once and looks almost right.

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

A method's line runs have to cover every byte of it, and forgetting to close the last one is a file the verifier calls internally inconsistent — with the disassembler showing every instruction at line 0, which is the only visible sign of what is wrong. That is the third distinct mistake to produce that one message.

Stage 4 settled two predictions written before it was started, and both held. A nested procedure is a block made inside its parent's activation and kept in a slot of that frame, so OP_BLOCK captures the right frame and OP_OUTER depth slot reaches the right variables. The machine needed nothing added — that instruction takes a depth and a slot, which is a static link by another name. And 3.1 turns out to be Pascal's own scoping rule rather than a limitation on it: a nested procedure may not be called after its parent returns, and a capturing block may not outlive its home, and those are the same sentence.

The blocks it emits are the first in this repository to set the capture flag. sola.sol has never emitted one, SolaBasic having no nested procedures — and its header says so, which is how the prediction was made.

An array and a record are the same thing at run time, and the whole difference is what the compiler knows. Both are a Solum array; a record's field is an index worked out while compiling, and an array's subscript is the Pascal index less its lower bound, folded the same way. Neither is a dictionary and neither carries its shape. Making one is a loop, so the emitted code grows with how deeply a type nests rather than with how big it is — a program may declare a thousand of something and the compiler knows the number.

And assigning a whole array or record copies it, which the standard says and the machine does not: a Solum array is a reference, so without the copy two names would mean one thing. The copy is as deep as the type goes, because a record of arrays is still one value in Pascal.

A set is an array of booleans, and the plan said bit-words. That plan met 3.12: 1 shiftLeft 63 overflows, because SolVM's integers are signed and there is no unsigned type to borrow, so a 64-bit word would have to be a 63-bit word or have its top bit special-cased everywhere. A boolean each makes membership one index — the operation a program writes most — and costs a set of char 256 booleans rather than four integers. Everything else is a loop over the span either way, so the bits would have bought only memory.

Reading is on standard input only, and that is a decision rather than a gap. ISO leaves the binding between a name in a program heading and a file on disk to the implementation, so a program that opens an external file has no answer the oracle could compare against — and a divergence nobody can check is a divergence nobody should write. file of T is out for the same reason: its representation on disk is the implementation's too.

And two of the three bugs in it were the same bug. JUMP_IF_FALSE is the only conditional jump the machine has, so leave when this is true has to be spelled leave when its negation is false — and readln written without the not stops at the first character that is not a line marker, which is the one it is standing on. The other was c:indexOf(" \t\n\r") where " \t\n\r":indexOf(c) was meant, so nothing was ever whitespace and the first token was the whole file.

Pointers made the var parameter grow up, and that is the finding of stage 7. A reference began as a one-element cell — sola.sol's answer, and enough for BASIC, where the only thing that can be passed by reference is a whole variable. Pascal's Insert(t^.left, k) is the idiom a tree is built with, and the storage it names is element two of the record t points at. No cell can alias that. So a reference is a container and an index, which names either exactly — and a whole variable carries its pair from the moment it is declared, so passing one costs nothing at the call. The restriction stage 5 had written down as stage 8 turned out to be a representation that was one case too narrow.

sed — a stream editor

Runs a sed script over every line of its input: addresses pick the lines, and s, p, d, q, =, y, a, i, c and { } say what to do with them. -n, -e and -f are there, and the input is the files named or standard input.

./bin/solvm programs/sed.sob                        # it demonstrates itself
./bin/solvm programs/sed.sob -n '/^ERROR/p' log.txt
./bin/solvm programs/sed.sob 's/  */ /g' < notes.txt
$ sed -n '/warn/,/error/p'

bob     17  warn
carol   93  ok
dave     5  error
erin    68  warn

Half of sed, and it is a coherent half. The hold space, branching, the multi-line commands and r/w are not here — those are what make sed a stream language rather than a filter, and they want a pattern space that is a two-line window and a program counter that can jump. The regular expressions are lib/re.sol's, which is POSIX basic: groups, back-references, \+, \?, \| and \{n,m\} included. Until 2026-09-01 it was the subset vi searches with and \( was a literal parenthesis, which made a valid script answer the inverse of sed's on both counts.

It is held against the sed on the machine. programs/oracle.sh runs 57 cases under both and requires the same bytes. Three cases must differ, each carrying the reason at the top of its own file — and two more were in that pile until re.sol gave sed groups, at which point the harness said so: AGREES -- the divergence has gone, and the file still claims it. A case that moves from differ/ to agree/ is what closing a gap looks like from the corpus's side. Every case runs twice — with the input named as a file and with it arriving on standard input — because those are different code paths underneath and a stream editor that answered two ways about the same bytes would be wrong where nothing else could see it. This is the argument sola's oracle makes for QuickBASIC and pascal's makes for fpc, for no money at all: this oracle was already installed.

What it found, and the file's own tail has each with its measurement:

  • A defect in pattern.sol, on the oracle's first run, in the substituter rather than the matcher. pattern:on("o*"):replaceAllIn("aoc", "-") answered -a--c- where every sed answers -a-c-: an empty match at the position where the previous match ended was being taken as a match, when it is the same position seen twice. The library's own example is the one case that cannot show it — in s/x*/-/g over abc the star never matches a character, so no match has an end for a later empty one to land on, and the rule that was missing and the rule that was present agree on every position. Fixed, in substitutionIn and in countIn, with the cases added to examples/matching.sol.
  • substitutionIn takes a boolean where sed has three answers — the first match, all of them, or the Nth. A boolean is the two ends of a range with a middle.
  • What reading a file whole costs a line-oriented program. The same script over the same bytes: 2.5 MB peak by pipe whatever the size, against 5.3 MB for a 618 KB file and 32.3 MB for a 6.4 MB one. That is about 4.7 times the file rather than the twice 3.22 states, because a program working line by line holds a string per line as well. The entry's trigger — a file that does not fit — still has not fired.
  • system:readLine cannot say whether the last line had a newline, which is the one bit a stream editor must not lose. A named file keeps it and a pipe cannot, so three oracle cases declare the difference and bound it: the pipe's answer must be the file's plus exactly one newline.

tail — the end of a file, without reading the rest of it

The last ten lines, or the last N, or the last N bytes, or everything from line N onward — of the files named or of standard input.

./bin/solvm programs/tail.sob                       # it demonstrates itself
./bin/solvm programs/tail.sob -n 50 big.log
./bin/solvm programs/tail.sob -c 4096 big.log
... | ./bin/solvm programs/tail.sob -n 3
$ tail -n 3 huge.log        # a 3 GB file, in 5 ms, holding 2 MB

line 18
line 19
line 20

The first program here written to check a call rather than to ask for one, and it is the wrong way round on purpose. 3.22 was closed before this was written, because the evidence for closing it arrived without a program: a sparse file is 3 GB and 8 KB of disk, and on one of those fileSize answered and readFile refused. A tail on the whole-file read would have re-proved that and said nothing about the shape of the fix, since it could not have called it. So the range was built and this is its first caller.

What it found is nothing, and that is the result. The range wanted no extra argument, no convenience and no different rule at the edges. Held against /usr/bin/tail: 29 corpus cases run both by file and by pipe, seven more by hand for the several-file headings, and the same commands on a 3 GB file — every one byte-identical.

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

Three things it did report, and the file's own tail has each:

  • The predicted price of a range was real and split paid it. 3.22 said a record spanning two chunks becomes the caller's problem. It does — and split counts a chunk's newlines while join puts back exactly what it removed, so the offset of the last few lines inside a chunk is arithmetic rather than a second search. Twelve lines, and one integer carried across a boundary.
  • Clamping earned itself, in two of the four places this reads — both of the two that stream. A refusal would have made every chunk ask fileSize and take a minimum first, re-deriving a number the call already had, with a race in the gap.
  • No arguments means two things here, which no other program had at the time: the house rule says demonstrate, and ... | tail says read standard input. keyWaiting(0.0) was made to separate them, and that was wrong — a pipe that is open, empty and not yet finished answers false just as an idle terminal does, so a slow pipeline got the demonstration. sha256sum hit the same collision, which promoted the note to 6.40, and building system:isTerminal is what found the defect.

-f is here, and it is where the prediction was half wrong. keyWaiting could not stand in for a wait, exactly as predicted — twenty asks of keyWaiting(0.5) take 10.02 s against an idle terminal and 56 microseconds against a finished pipe, so a follow loop built on it spins in every script and pipeline. But the prediction that the price of forking /bin/sleep would be the finding was wrong: a fork measured 2.23 ms, which at a one-second poll is 0.22%, and nothing like the stty-per-keystroke case it was reasoned from. So system:sleep had to be argued for on something else, and was: waiting is one call to the kernel, and a program should not start a process to do it.

Everything else -f needed was already there — fileSize notices growth without reading, and the ranged read collects exactly the new bytes. Following an idle file for five seconds costs 0.00 s of CPU, which is what /usr/bin/tail costs.

And it is checked, which the scoping said it could not be. tail/follow.sh is the answer to an oracle cannot check a program that does not stop: give it a deadline. Six scenarios — start both tails, feed the files on a schedule, stop them, compare. It earned itself on the fourth: BSD tail puts a blank line before the first heading when following and not when it is not, which nothing but a check that runs the real thing would have found.

Following across a rotation used to be the one thing it could not do, and both halves of that closed on 2026-09-01. It has to survive the path going away, which it did not — fileSize raised and the program exited 1 on any mv (6.41). And it has to notice that the file at a path is a different file, which needs an identity: fileSize and modifiedAt were all that could be asked, and both coincide across a rotation. system:fileId answers it, and the gap it closed was a lost line rather than a misprint — a replacement of the same size read as unchanged.

There is no -F flag, and that is deliberate rather than pending. This tail polls a path and has no open file to keep, so it is -F-shaped and cannot be otherwise; BSD's -f follows the descriptor and goes on reading the renamed file, which is a behaviour this cannot have. A second flag would be a second name for one thing, so -f simply stopped losing data, and follow.sh compares it against the oracle's -F.

sha256sum — a digest, and the first inner loop that is arithmetic

The SHA-256 of each file named, or of standard input; and with -c, a list of digests checked against the files it names.

./bin/solvm programs/sha256sum.sob                    # it demonstrates itself
./bin/solvm programs/sha256sum.sob file.tar
./bin/solvm programs/sha256sum.sob -c SHA256SUMS
... | ./bin/solvm programs/sha256sum.sob
$ sha256sum h.txt
5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03  h.txt

-b writes * before the name, -t is the default, -z ends the line with a NUL, -w complains about lines of a list that are not checksum lines. That is the whole of sha256sum [-bctwz], which is the whole of the usage line of the tool on this machine.

It was written to produce one number. Every other program here is a parser or a filter and spends its time in split, indexOf or a syscall; the survey that chose it put this first because sixty-four rounds of shifts, masks and additions per sixty-four bytes is the cheapest way to ask what does this interpreter cost per arithmetic operation when there is nothing else going on.

208 bytecode instructions per byte, at 4.3 nanoseconds each. Measured with --steps=N, which stops a program after N instructions — so the smallest N that lets a run finish is that run's exact count, and a binary search finds it. It is 13,302 instructions per 64-byte block, flat from ten blocks to a hundred, and a ten megabytes take 9.30 s at -O2: 234 million instructions a second. The cost of one instruction had never been stated here — performance.md has whole-program times and the ratios between them, and nothing below that.

on a megabyte
/sbin/sha256sum ~1800 MB/s — C, and the M2's SHA instructions
shasum -a 256 ~320 MB/s — Perl, calling a C library
this program, -O2 1.08 MB/s
this program, the -g build make gives you 0.22 MB/s

The Perl one is the interesting row: an interpreter too, and about three hundred times faster for not interpreting the hash. The last row is 4.9x, against the 1.9x to 4.1x the nine benchmarks show for that flag — which is what a program that is nothing but arithmetic inside the dispatch loop should be expected to do.

And it did not need a new integer type, which was the other half of the prediction. SHA-256 is defined on mod-2³² arithmetic and this language has one signed 64-bit integer that traps rather than wrapping, so it is the first thing here to want byte, word and long — and it does not, because a 64-bit integer holds the sum of five 32-bit values with fifty-nine bits to spare. The cost of refusing them is twenty-three bitAnds in one program. 3.12 never comes close either: the largest shift here moves a value under 2³² left by thirty places.

Four things it found that nobody predicted:

  • A third of the program was a method call. rotr written the obvious way — a method on the hash object — hashes a megabyte in 1.36 s; written out in the sixty-four rounds, 1.10; written out in the message schedule too, 0.92. 1.48x, with identical arithmetic in all three and the same digest out of all three, so what the method cost was a frame and a return, ten times a round — 32% of the readable version's running time. Worth holding against the inline cache entry, which measured lookup at 9.7%. This is the call itself.
  • @expr has no bit operators, so the one file here that is nothing but shifts, xors and masks is the one file that cannot use the notation at all — & and | are already the short-circuiting logical pair. One program is one program, and it is written down because a notation introduced for "a formula you are transcribing" met a formula it could not transcribe.
  • 3.2 turned up in the parsing, not the hashing. Every loop in the block function runs a fixed number of times, which is what a specified algorithm looks like; the flag-carried early exit is in the code deciding whether a line of a checksum list is a checksum line.
  • The isatty trigger fired, and the message it asked for found a defect in both programs that asked. tail recorded keyWaiting(0.0) as an exact workaround with a note; this was the second program, for the identical collision; and building 6.40 showed the workaround was not exact — a pipe that is open, empty and not yet finished answers false just as an idle terminal does, so both programs threw away a slow pipeline's input.

The subset is bounded by a corpus, not by a sentence. [-bctwz] is the whole of the oracle's usage line and not the whole of the oracle, which also answers to ten long options it never mentions — --tag, --quiet and --status having no short form at all. Three of them are in differ/, along with an unknown option, so what is deliberately absent fails a check rather than sitting in prose.

And the second caller found the price the first could not. A range with no handle means no open is held, so every call opens the file again: a ranged read costs about 30 us whatever its size, against 0.65 us for the stat behind fileSize — forty-five times. tail reads once or twice per invocation and cannot see that; this streams, and pays it 16,384 times a megabyte, so the chunk size is what amortises an open. 64 bytes is 62% slower than 64 KB on a megabyte and the curve is flat from about 4 KB. It is a price and not a defect — plain C measures 28 us for the same open, read and close — so it argues for a sentence in the reference rather than for a handle, and 3.22 now carries both.

It is the second caller of the ranged read, going the other way. tail seeks backwards to a place it computed; this walks forwards from byte one in 64 KB pieces and never looks back, so nothing here ever holds a file — which fell out of the pipe rather than being designed, since standard input arrives a byte at a time and the state had to survive between bytes. A shape that serves both a seek and a scan with no state between calls is 3.22's argument holding up under a use it did not have in mind.

Three checks, and one of them is the kind sed and tail could not have.

what it runs
sh programs/oracle.sh sha256sum 21 cases that must agree, each both as a named file and down a pipe, and 3 that must not
sh programs/sha256sum/vectors.sh the published FIPS 180-4 and NIST digests, and the bytes a .case file cannot carry
sh programs/sha256sum/check.sh -c against the oracle on a directory of files: 18 agreeing, 3 not

The vectors are the point: an oracle can be wrong in the same direction as anything derived from it, and SHA-256 has answers printed in a standard before this language existed. Everything agrees, including a million times a.

The oracle earned itself twice inside ten minutes. Once against this program: -c dropped every empty piece of the split and carried a comment saying "a blank line is not a malformed line in either tool", which had not been tried — /sbin/sha256sum -c -w numbers a blank line and counts it. And once against itself: in -c mode it reports a missing file with two spaces, having kept the separator that stood in front of the name, which check.sh records as a divergence rather than copying.

programs/oracle.sh grew two things for this program, in the way it grew for its second caller: an oracle that is not in /usr/bin is now looked up on the PATH, and pipenames: says that a program naming its input has two routes differing by the name — the pipe's output must be the file's with the path replaced by a dash, which is a full check rather than a waiver.

awk — a language for lines, and the first customer of the extended dialect

The pattern-action language: a program is a list of pattern { action } pairs, each action running for every input record the pattern accepts.

./bin/solvm programs/awk.sob                      # it demonstrates itself
./bin/solvm programs/awk.sob '{ print $2 }' data.txt
./bin/solvm programs/awk.sob -F: '{ print $1 }' /etc/passwd
./bin/solvm programs/awk.sob -f prog.awk data.txt
$ awk '$2 > 40 { print $1, $2 }' scores.txt
alice 42
carol 93

The nineteenth program here, and the first to want re:ere. sed and the editor both want POSIX basic regular expressions; lib/re.sol's extended half had no caller until this one. It wants it by standard rather than by taste, which is a stronger thing for a library to be held to: POSIX says what /a|ab/ matches, so a divergence is a defect rather than a preference.

It was written after the library rather than before it, which was the argument of the scoping on 2026-09-01: awk's largest demand already had two customers, so writing awk to justify the engine would have been the wrong order. ideas.md carries the prediction that entry made, written before this file existed.

What it has

BEGIN and END, expression and regular-expression patterns, ranges (NR==2, NR==4), print and printf with redirection to a file or a pipe, fields and NF and $NF, FS as a blank, a character or an ERE, OFS ORS NR FNR FILENAME SUBSEP RSTART RLENGTH, arrays with in and delete, if while do for for (k in a) break continue next exit, user functions with locals and arrays by reference, getline in its plain and getline var forms, and the built-ins: length substr index split sub gsub match sprintf sin cos atan2 exp log sqrt int rand srand tolower toupper system close.

The three things the scoping predicted, and what each turned out to be

predicted what it was
full ERE re:ere, already built. The prediction was right and the work was done in advance
a lenient numeric read nine lines here. asInteger is strict on purpose and awk needs "3abc" + 0 to be 3, so the numeric prefix is scanned. Wanted nothing new
%e and %g written here, where a format belongs. fill takes {} and no conversion at all, deliberately, so that nothing in its spec starts looking like a format language

A fourth thing pressed harder than any of them, and the prediction missed it. 3.2, no non-local return, was wanted three separate times in this file: by the expression evaluator, by the statement executor, and by the parser's primary. An interpreter dispatching on a tag is exactly the shape that wants to answer and leave, and without it every later branch has to be guarded against having already finished. next, exit, break, continue and return are five flags where one mechanism would do.

And the frame limit shaped the parser, as ideas.md said it would. awk has twelve levels of binary precedence and the textbook shape spends three frames a level, which would run out at six or seven parentheses; precedence climbing costs three frames for the whole chain.

What it does not have, and says so

RS is a newline and setting it is accepted and ignored — the only thing in the file that is not refused where it is not implemented, and it is named in the header for that reason: it is the shape that cost sed a defect the same day. Splitting on another character is two lines; paragraph mode also changes what FS does, and half of RS would be worse than none.

Everything else that is not there refuses: a conversion printf does not know, a built-in that is not written, an argument list of the wrong length.

Held against the awk on the machine

programs/oracle.sh runs the corpus in programs/awk/ under both. What must differ is one thing and POSIX says so: the order for (k in a) visits an array is undefined, so the two disagree and neither is wrong.

diff — two files, and the first program here that computes

The shortest set of changes that turns one file into the other, in the normal format or unified.

./bin/solvm programs/diff.sob                     # it demonstrates itself
./bin/solvm programs/diff.sob old.txt new.txt
./bin/solvm programs/diff.sob -u old.txt new.txt
cat new.txt | ./bin/solvm programs/diff.sob old.txt -
$ diff old.txt new.txt
2c2
< jumps over
---
> vaults over

The twentieth program here, and the first that computes a relationship rather than recognising a structure. Everything before it reads one input and reports on what is in it. This one holds two and answers a question neither contains: which lines to remove and which to add, in the fewest edits there are.

The algorithm is Myers' greedy forward pass — for each edit distance in turn, how far along each diagonal a path of exactly that many edits can reach — and the answer is minimal because the distance was counted upwards rather than searched for.

What the prediction got right, and what it did not

ideas.md named four findings before the program existed. One held.

predicted what happened
3.5 on the recursion No. The recursion belongs to the linear-space variant, and the iterative one is what gets written when the limit is known first
a two-dimensional array No. Myers keeps one array of diagonals and a ragged trace; what bites is that diagonals run from -max and Solum arrays are one-based
quadratic memory Half. Quadratic in the edits, not in the files — so two large files differing in one line cost one band
the output format is the hard part Yes, and it was the whole of it

Four format faults, none of them findable by reading the algorithm: a count of one is written @@ -1 +1 @@; hunks merge at a gap of exactly twice the context and split at one more; \ No newline at end of file follows a context line too; and an empty range is written at the line it follows except at the start of a file that has lines, where it is written at line 1 rather than 0.

The last of those was found by a random sweep against the tool, which disagreed 44 times in 1,050 runs before it and none after. programs/diff/sweep.sh is that sweep, kept rather than thrown away because its numbers are quoted here; sweep.sh 400 minimal runs the other question it was used for, whether the tool's own answer is ever above the minimum.

Held against the diff on the machine

programs/oracle.sh runs the corpus in programs/diff/ under both — and diff is what generalised that harness twice: it is the first program here to take two inputs, and the first whose exit status is documented behaviour rather than a 0 or 1 nobody had checked. Both are in the harness now, for every program it runs.

Beyond the corpus, a random sweep of 2,400 runs over six option forms and files of up to forty lines: zero disagreements — and that sentence was written an hour before the first pair of real files, which disagreed.

Two answers can both be right, and on real files they often are. Where a line inside an inserted block equals the line at the seam, the insertion can be placed as one run or split around that line for the same number of edits. The tool splits and this program does not. programs/diff/apply.sh counts it over sixty pairs of real files at two revisions of this repository: 48 byte-identical to the tool, 12 not, and in every one of the 12 the two answers cost the same.

So the check that matters is not byte equality. apply.sh writes our unified diff, hands it to patch(1), and compares the result with the second file: 60 of 60 reproduced it exactly. That is the property — is this the diff from A to B — where the oracle answers is this the tool's diff.

Neither the corpus nor the sweep could have found this. The sweep mutates one line at a time and the shape needed is a block inserted whole; and it does not reduce — no window of thirty lines either side reproduces it, because the tool's algorithm makes a global choice. It cannot be a corpus case, which is what apply.sh is for.

The one divergence a generator did find is -i, where the tool disagrees with itself: on input holding no uppercase at all it picks a different one of two equally minimal answers than it picks without the flag, and this program picks the same one either way.

Two findings about standard input that nothing predicted

  • Reading a pipe byte-for-byte costs 20x. readLine drops the terminator and folds \r\n, so it cannot say whether the last line ended with a newline and silently rewrites a file written on another system. readKey is exact and is a byte at a time: 4.2 MB/s against 84, or 238 ns a byte. Paid, because a diff that cannot tell those two files apart is wrong rather than slow.
  • readFile on a pipe answers "". Not the contents and not an error -- the size comes from a seek that a pipe refuses, and a failed seek is indistinguishable from an empty file. A silent wrong answer, and the one finding here that is a defect rather than a limitation.

sort — lines in order, and a file bigger than the memory to hold it

Byte order, keys, and an external merge sort that writes runs to disk when the input will not fit in the budget.

./bin/solvm programs/sort.sob                     # it demonstrates itself
./bin/solvm programs/sort.sob -n scores.txt
./bin/solvm programs/sort.sob -t: -k2,2n /etc/passwd
./bin/solvm programs/sort.sob -S 4096 huge.txt    # spill at four kilobytes
$ sort -t: -k2,2n fruit.txt
fig:1
banana:2
pear:3

The twenty-first program here, and the first that does not have to hold its input: past -S bytes it sorts what is in hand, writes it out as a run, and merges the runs at the end.

Byte order, alwaysLC_ALL=C is what the tool has to be run under to agree, and that is the language rather than the program: a string here is bytes and lessThan compares them. oracle.sh sets it for every tool now.

The predicted gap was not there, and the reason is the finding

ideas.md predicted that sort would want a positioned write, since an external merge sort writes runs to temporary files and writeFile replaces where appendFile appends.

An external merge sort never writes into the middle of a file. A run is produced whole and then only ever read; the output is produced in order, so it appends. The entry called this the mirror of the ranged read and a mirror is the wrong figure: the ranged read exists because a program wants part of a file it did not write, and nothing wants to write part of a file it is producing, because a producer knows what comes next.

What the k-way merge did want was k independent positions in k files at once — the thing a language with file handles has to think about — and readFile(path, from, count) has none of that. A reader here is a path and an integer. The half of the program the entry worried about is the half that needed nothing.

The generator missed both real defects

programs/sort/sweep.sh runs generated inputs and then this repository's own files, under twenty-three option forms. It was written before any claim was made about it, because diff had learned the week's lesson the expensive way.

Both defects it found came from the real half:

  • -n must reject a leading +. The tool reads -1 as minus one and +5 as zero. Found in this repository's README, on a line beginning +0.2% to +3.4%. The generated alphabet had a minus because somebody thought of one, and no plus because nobody did.
  • -f folds to upper case, not lower. Visible only beside punctuation: folding down puts [ below a, folding up puts it above Z. Three README files here begin lines with **[.

Both are corpus cases now, so the next reader does not find them twice.

The check that was too slow to finish

The sweep found one more thing by not finishing. Its first run at full width went two hours and fourteen minutes without completing even the generated half, and the reason was in this program: the k-way merge picked its winner by a linear scan over every run's head, so the cost was lines x runs. At -S 16 over docs/CHANGELOG.md -- 14,707 lines in 788,815 bytes -- that is some forty-nine thousand runs and a comparison per run per line.

The scan carried a comment saying a heap would matter at a few hundred runs and that the scan was the trade this repository keeps making until something measures otherwise. Something measured otherwise the same day, and the comment had named its own falsifying condition well enough to recognise it when it arrived.

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

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

A check too slow to finish is a defect report nobody reads as one. It sat in the background reporting nothing while it was the only thing that had found anything.

And a claim it nearly published

sorted is a stable merge sort, and the program was going to report that nothing said so — an implementation detail and a promise being the same line of code. REFERENCE.md has said it all along, in prose under the sorting examples rather than in the message table. The absence was asserted from a grep that found the table row and stopped.

What is true is smaller: this is the first program here that depends on that guarantee, and until now the sentence in the reference had no customer.

Two more things

-n is the second customer for a lenient numeric read, which awk predicted and never had one for. It cost nine lines, which is why it is a paragraph rather than an entry.

And the second customer for 6.43, with a reason the entry does not have. diff wants a pipe read whole; a sort that spills wants the opposite — a pipe read in bounded pieces, so memory stays inside -S however large the input is. The entry is about the absence of a middle, and the second customer is what shows the middle is what is missing.

The middle arrived, and the pipe now costs what the name costs

readUpTo shipped in 0.43.0 and the byte-at-a-time loop is four lines that read like the file branch beside them. 584,997 bytes in 11,350 lines, counted exactly with --steps:

instructions
through a pipe, a byte at a time 28,846,431
through a pipe, readUpTo 15,402,663
the same file named on the command line 15,398,455

The two ways in cost the same now, to within 0.03%, where before a program with two routes had two performance stories. 23 instructions a byte is what the byte-at-a-time reader was spending; 4.7 MB through a pipe went from 1.50 s to 0.81 s, -O2 and best of five — the instruction counts hold under either build, the seconds do not.

And nothing here had a line long enough to cross a read. The files the piped half draws from are 130 KB and up and their lines top out at 1,694 bytes, this program's own corpus at 566 — so the branch that fills a second time because a piece arrived with no newline in it was reached by nothing. The sweep generates an input with 30,000- and 4,097-byte lines now: a fill that reads once per call instead of looping is caught by 23 of 23 option forms on it and by 0 of 23 on docs/programs.md down the same pipe. tail had already had the idea — chunk-longline.case, one line longer than a chunk, so a record spans two reads — and nothing had carried it here.

The question about readChunk, and the two defects under it

The conversion left the pipe route faster than the named file — 20% in wall clock, on identical instruction counts — which does not happen for any reason that is about pipes. The reader drained its buffer with copyFrom, so every line copied whatever was behind it, and a larger read was not a cheaper one: 65,536 cost 1.03 s where 8,192 cost 0.86 s. The pipe was only quicker because readUpTo answers out of a 4,096-byte window whatever is asked for, so it had been reading in small pieces by accident.

The answer was not to tune the constant. Asking why a bigger read was slower found two quadratics — fill rescanned the whole buffer for a newline on every read, and next copied the whole tail per line — both of them quadratic in the length of a line longer than one read. A reader holds the fields of the piece last read now, plus the fragments of the line still being read, joined only when its newline arrives. One line and nothing else, through a pipe:

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

And readChunk stopped being a question rather than getting a better value: the spread across 4,096 to 262,144 went from 17% with the largest read worst to 2% with the largest read best. The win is in bytes copied, not instructions run — one percent of the instructions against 14% of the wall clock — which is why --steps could not see any of it. An instruction count is not a cost model, and this is the first measurement here where the two disagree by more than a rounding.

gzip — inflate, and the window that was not the cost

DEFLATE decompression: a bit reader, canonical Huffman decoded a bit at a time, and a 32 KB window that back-references copy out of. gzip without -d compresses, and that is a second program and a harder one.

./bin/solvm programs/gzip.sob                     # it demonstrates itself
./bin/solvm programs/gzip.sob -dc notes.txt.gz    # to standard output
./bin/solvm programs/gzip.sob -d notes.txt.gz     # writes notes.txt
./bin/solvm programs/gzip.sob -t archive.gz       # check it and say nothing
./bin/solvm programs/gzip.sob -l archive.gz       # the sizes and the ratio
cat archive.gz | ./bin/solvm programs/gzip.sob -d
$ gzip -l notes.txt.gz
  compressed uncompressed  ratio uncompressed_name
          38           18 -99.9% notes.txt

The twenty-second program here, and the first whose oracle produced every input it is held against. Elsewhere a corpus holds a file and both tools are asked what they make of it; here /usr/bin/gzip compresses and this program decompresses, so a disagreement cannot be a difference of opinion about what the input meant. programs/gzip/sweep.sh runs 66 such round trips — chosen shapes, generated text at three levels, and this repository's own files — and every one comes back to the byte.

The window is 5% of it, and the prediction was about the window

ideas.md wrote this program down as the array-heavy workload with a definitive oracle, on the grounds that a SolValue is a tag and a union, so a 32 KB window is 32,768 tagged values and every access is a send. Predicted finding: the cost of that, in a number.

The number is 220 instructions per byte of output and 1.32 MB/s, on docs/REFERENCE.md — 65,177 bytes in, 185,364 out, 40,775,088 instructions counted exactly with --steps. Where they go:

the Huffman decode, a bit at a time 28.8 M 70.7%
CRC-32 over the output 4.63 M 11.4%
the output array back into a string 4.26 M 10.5%
the window 1.97 M 4.8%
the input string into an array 1.04 M 2.6%
the two fixed tables, once 0.06 M 0.1%

And it is not that the window is little used. 172,699 of the 185,364 bytes came out of it — 93.2% — against 12,665 written from a literal. Nearly every byte is out:add(out:at(at)), that pair of sends costs about eleven instructions, and boxing is not the problem.

What costs is the bits: 59,710 symbols at about 483 instructions each, 521,162 bits at about 55. The expensive thing is the one that happens most often, not the one that looks heaviest — a 32 KB array looks like the cost and a bit looks like nothing.

So the question the survey said this program would settle — whether packed numeric arrays are needed — has an answer, and it is no: the boxing is 5% and the interpretation is 70%.

The ratio is in the tool and in no specification

gzip -l's ratio column is not 100 * (uncompressed - compressed) / uncompressed. It is integer arithmetic with a floor at -99.9%, and eighteen bytes in a twenty-seven byte file is -44.5% where the obvious formula says -50.0%. This program printed the obvious one until it was held against the tool.

RFC 1952 does not contain it, because it is not part of the format. It is a property of the program that prints the listing, and there is no way to know it but to run that program — which is the argument for an oracle in one line: a standard cannot be wrong about what it does not specify.

What it wanted from the language, which was nothing

No roadmap entry came out of it. It found nothing is an outcome ideas.md keeps available deliberately, and it is worth something only because the prediction was written first.

It is a customer for 6.45, which names it by name — but not for the reason the entry gives. The entry says its input has no lines, so readLine would be meaningless and it would have exactly one route in. The route is there and it works: readFile("/dev/stdin") reads a pipe whole since 6.43 closed. What makes it a customer is memory, which is sort's reason and not a new one — thirty bytes held for every byte produced, measured with --memory, where the format asks for a 32 KB window however large the stream is.

It was converted the next day, and the input is gone

Standard input arrives in 4,096-byte pieces now, each replacing the last. Nothing here ever looks backwards — the window a back-reference reads from is the output — so a piece that has been read is not kept. Smallest --memory=N that lets the run finish:

bytes out before after
187,655 6,615,294 4,528,936 35.3× → 24.1× held per byte out
397,342 13,121,439 8,942,620 33.0× → 22.5×

That is the input gone rather than a discount on it — and a before-and-after on one file cannot show that, so it was shown by holding the output still and varying the input. Seven streams, all producing the same 187,655 bytes, each two members with the first k stored and the rest deflated:

compressed in before after
65,881 6,615,294 4,528,936
104,642 6,618,344 4,586,890
143,963 8,713,853 4,538,086
187,693 8,710,802 4,580,790

The before column climbs 2.1 MB across that range and the after column does not move — 58 KB of scatter with no trend, over an input that nearly trebles. A gigabyte through the pipe holds the same 4,096 bytes of it a kilobyte does. The two copies still there are of the output, which wants a ring buffer and an incremental write and is this program's own business. It cost 1,206 instructions, 0.003%.

And that before column is why --memory=N is a ceiling and not a reading. It steps rather than climbing: 6.62 MB for the first four rows and 8.71 for the last three, one jump of 2,095,509 and nothing between. Five compression levels of the same file — 200 KB of boxed integers between the extremes — give 6,615,294 to the byte, all five. The smallest --memory a run survives is where the collector's heap threshold next lands, and the step is about a third of the figure. Honest for a comparison run both ways on the same input, which is what every such number here is; not honest quoted as what the program holds.

And it found that the sweep was running every case down one route

sweep.sh named its input on the command line in all 66 cases, so the pipe — the route 6.45 exists for — was checked by nothing. oracle.sh has run both routes since sed; sweep.sh is a different script written for a program the shared harness does not fit, and the rule did not travel with it. A check that was right where it was written and absent from the file written next to it is a different failure from a check got wrong. It runs both ways now, 131 cases, and the pipe cases were proved to fail rather than assumed to: a reader that stops at the first short answer is caught by 64 of them and by none of the file cases.

One of those cases would have hung rather than failed, which a review pass found by running it. The truncated-stream case wants gzip -t to refuse half a stream; the defect it is for — a reader that answers past the end instead of refusing — produces a run that never ends, 900 million instructions and still going. It carries a --steps deadline now and wants exit 1 rather than merely non-zero, since 124 is what the deadline leaves and it means did not stop.

Adding one

There is no template and there should not be. What the twenty-two have in common is only this:

  1. It does a job somebody would want done, rather than exercising a feature. The job is what makes the language answer honestly.

  2. It runs with no arguments, on input it carries, and takes real input when given it.

  3. Where the language was awkward, the comment says so rather than working around it quietly. That comment is the whole point — it is the draft of a roadmap entry, and several became one.

  4. It is registered in tests/test_compile.c, which verifies every shipped .sol and fails if one is added without being listed.

  5. If something on the machine already does the job, it is held against that. Five are: sola against QuickBASIC, pascal against fpc, and sed, tail and sha256sum against the tools of those names. And where a standard prints the answers, hold it against those too: an oracle can be wrong in the same direction as anything derived from it. programs/sha256sum/vectors.sh does that with FIPS 180-4, and basic's conformance.sh did it first with the NBS suite — the difference being that a digest can be compared by a machine and the NBS programs are written for a person to read. programs/oracle.sh is the harness: a corpus of cases that must produce the same bytes and a second corpus that must not, each of the second saying why. Checks of this kind — against an oracle or against a standard — are the only ones here that can find what nobody thought to look for: sed's first run found a defect in lib/pattern.sol that its own documented example could not have shown, and the NBS suite found seven in basic.sol that eighty-three author-written claims had missed. See method.md.

    A program that never stops can still be checked: give it a deadline. programs/tail/follow.sh starts both tails, feeds the files on a schedule, stops them and compares.

What it does not have to do is demonstrate a message. That is examples/'s job, and a test holds it to it: every built-in message must be sent by something in examples/, so a program is free to reach for whatever it needs without that counting as coverage.