A programmable RPN calculator on the runtime stack — mills/halifax
A calculator at a prompt. It speaks RPN — Reverse Polish Notation, where you type the numbers first and the operation after. Numbers go on a stack — a pile of values that grows and shrinks from the top — and the stack has no fixed depth. Words work on the numbers. Everything you would want from a programmable calculator is here, because each piece was already in the tree:
*,bin/mill run mills/halifax/halifax.shoddy
Named after Halifax Piece Hall (1779) — the region's actual wool-trading floor, where merchants totted up the day's business by hand. halifax is a thin program over thick machines. cuttle is the stack, reckoner is the engine, and seven reckoner seeds are the vocabulary. What halifax adds is an assembled dictionary, a prompt, and the five words that name a file.
Nothing in the list above was built for the calculator. Each one is a property of the language showing through:
| What you see | Because Shoddy is |
|---|---|
| UNDO and REDO across every entry | purely functional — a value never changes, a new one is made instead. A state is a value, so history is just a list of them |
| programs as diffable text files | homoiconic at the token level — code is ordinary data: a definition is its tokens, and its tokens are text |
{ 12500 13100 11900 } MEAN | a language with first-class lists, and stats was already in the tree |
| money exact at the cent, splits that conserve | money — exact cents, no drift, and MSPLIT loses no penny |
+ that adds money, matrices and strings | one Cell variant for every value, so choosing what + does is a single Select Case |
| TRACE — the stack after every token | the surface language is stack code; see the runtime stack |
| nothing keyable crashes it | errors are values, and the line is the transaction — the state always survives |
This session is the contract, not an illustration. test.shoddy
reproduces it byte for byte. A single character of drift fails the suite.
shoddy halifax — RPN. Numbers go on the stack; words work on them.
WORDS lists, HELP <word> explains, TRACE follows, UNDO backs out, QUIT leaves.
[ empty ]
> 3 4 + 5 *
x: 35
> 2 DUP *
y: 35
x: 4
> DROP 1250.75 MONEY 3 MSPLIT
y: 35
x: { $416.92 $416.92 $416.91 }
> UNDO
y: 35
x: 4
> REDO
y: 35
x: { $416.92 $416.92 $416.91 }
> UNDO
y: 35
x: 4
> DEG 90 SIN
z: 35
y: 4
x: 1
> 1 0 /
?: / cannot divide by zero
z: 35
y: 4
x: 1
> 7 8 9
[ depth 6 ]
t: 1
z: 7
y: 8
x: 9
Read it downward. x is the top of the stack, and it sits
nearest the prompt — that is why the rows print deepest first. An empty stack
says [ empty ] rather than printing nothing. Past four levels the
render stops naming rows and counts them instead, because x y z t
are the four names a stack machine has. The three money shares add back to
exactly $1,250.75. And 1 0 / leaves the stack untouched. The
1 and the 0 it pushed are gone with the line,
because the line is the transaction — a line that fails
changes nothing.
Type : NAME … ; at the prompt, and the word is in the
dictionary beside the built-in ones. WORDS lists it,
HELP explains it, VIEW prints it back, and
SAVE writes it to a file you can read.
> : VAT DUP 0.2 * + ;
ok: VAT defined
[ empty ]
> 250 VAT
x: 300
> SAVE "mine.halifax"
saved 1 word
mine.halifax then contains exactly what you typed:
: VAT DUP 0.2 * + ;
That is the whole of the file format. There is no serialization step — no converting to a special storage form — and no metadata. A definition is its tokens. Saving writes them down, and loading reads them back through the same evaluator that would have run them typed. A definition that names a word the dictionary has not got is refused when it is defined, not when it is run. So a file that will not load says so before it has loaded any of it.
A definition that has not reached its ; by the end of the line
is not wrong. It is unfinished, and the prompt says so by changing to
. and waiting for the rest. The same goes for a { … }
list and a [ … ] program.
> : VAT DUP
. 0.2 * + ;
ok: VAT defined
[ empty ]
> 250 VAT
x: 300
What you get is the line you would have typed on one row. It is joined with
a single space, evaluated once, and recorded on the tape once, as the
whole line. Nothing runs until the closing mark arrives. So a first line that
appended to a file or drew a random number does it once, not once per attempt.
SAVE is unaffected either way, since it writes one line per word
however the word was written.
Two things follow from a continuation line being the tail of what you are still writing, rather than a line of its own:
QUIT and EXIT are body text there.
Typed under a . prompt they do not leave. They go into the
definition you are writing, and they refuse when that word is run — exactly as
: VAT DUP QUIT ; typed on one line does. There is no way to leave
by accident half way through a definition.Only a construct that another line could still close waits. A stray
}, a : with no name, a list left open inside a
definition that has already closed, and an unterminated string are all handed
straight to the engine. Each refuses at once, worded exactly as it always was.
Ending the input while a line is unfinished — the end of a redirected script —
closes the session quietly and throws the part-built line away. A
file that ends that way is a different matter: LOAD
refuses it whole, because a file is a finished thing and a half-written
definition in one is a mistake in it.
This is the one thing about programming this calculator that surprises
people. It is the engine's rule rather than the mill's. A definition never
declares how many arguments it takes. Its body runs on a fresh stack
holding the single cell the word was applied to — a cell is one value on the
stack — and whatever it leaves is pushed back. So VAT above
works. A two-argument HYP written the obvious way does not: it
would underflow, reaching for a cell that is not its own.
A second argument arrives through a register — a named slot a value can be
stored in. The same mechanism gets a value into a MAP
program:
> 4 "B" STO
ok: B
[ empty ]
> : HYP DUP * "B" RCL DUP * + SQRT ;
ok: HYP defined
[ empty ]
> 3 HYP
x: 5
Banked before, recalled inside. The rule exists because token lists have no closures — a closure is a piece of code that carries the variables that surrounded it, and these tokens carry nothing. Giving a user word the caller's whole stack would make every definition's behaviour depend on what happened to be underneath it when it was called.
One line per token: the token, a marker, and the stack that token left.
NOTRACE turns it off.
> TRACE
trace on
x: 300
> 250 VAT
250 | 300 250
VAT » DUP 0.2 * +
DUP | 250 250
0.2 | 250 250 0.2
* | 250 50
+ | 300
VAT « 300 300
y: 300
x: 300
| Marker | Means |
|---|---|
| | the token ran; what follows is the stack it left |
» | a sub-evaluation is being entered; what follows is the tokens about to run |
« | it has returned; what follows is the caller's stack with the answer merged in |
The indent is not decoration. A user word's body, a
{ … } literal and every pass of a combinator — a word like
MAP that runs a program for you — all run on a fresh stack. So
the cells on an indented row are a different stack from the ones on
the row above. Read the cascade above again with that in mind.
VAT is applied with 300 250 on the caller's stack,
and its body sees only the 250 — its own one cell. Printed flat,
those rows would look like a stack that lost its lower half and then got it
back.
Every sub-evaluation is announced, because they all pass through one place
in the engine. A combinator emits one » per pass and a
single « when it merges. So a MAP over three
elements reads as three runs of one program:
> { 1 2 3 } [ 2 * ] MAP
{ » 1 2 3
1 | 1
2 | 1 2
3 | 1 2 3
{ « { 1 2 3 }
[ | { 1 2 3 } [ 2 * ]
MAP » 2 *
2 | 1 2
* | 2
MAP » 2 *
2 | 2 2
* | 4
MAP » 2 *
2 | 3 2
* | 6
MAP « { 2 4 6 }
x: { 2 4 6 }
The { 1 2 3 } literal traces too, because it is a
sub-evaluation like any other: three cells collected on a stack of their own
and pushed back as one list. The [ 2 * ] gets an ordinary
| row, because it is a push rather than a run.
Every transaction is recorded as it was shown: the line you typed, whatever
the calculator said, and the stack as it was rendered. It keeps the last 500
rows and drops the oldest. TAPE pushes the row count,
CLEARTAPE throws it away, and TAPESAVE "file" writes
it out verbatim. That is why a saved tape reads back as the session it
was:
> 250 VAT
x: 300
> SAVE "mine.halifax"
saved 2 words
x: 300
It records refusals too. A tape that showed only the lines that worked would be a record of a session that did not happen.
Run it from mills/halifax/. The wrapper is
./build.sh, or ./build.ps1 on Windows, with the same
subcommands:
| Command | What it does |
|---|---|
./build.sh run | The calculator, at a prompt. |
./build.sh test | The headless suite. No terminal, no display, no network. |
./build.sh demo | Feeds files/demo.halifax to the prompt, ending on a traced cascade. |
./build.sh build | Weaves the program into bin/. |
halifax is also the mill behind the Shoddy Reckoner's calculator. The pure core is woven as a machine DLL — a compiled library another program can load — and the app's session loop calls its words natively. That is Mode N hosting, with no console anywhere.
The shell owns a few words rather than the dictionary. Each one must be the whole line:
| Word | What it does |
|---|---|
SAVE "file" | Writes the words you have defined, as : NAME … ; text. |
LOAD "file" | Reads such a file back. One bad line refuses the whole file, and so does a file that ends part way through a definition. |
TAPESAVE "file" | Writes the tape, exactly as it was shown. |
RESET | Starts again: empty stack, no history, no tape, only the words you started with. |
QUIT | Leaves. Nothing is saved for you. Only as a whole line — under a . prompt it is body text of the line you are still writing. |
EXIT | A second spelling of QUIT. Same effect, same refusal if it turns up mid-line. |
They are still registered in the dictionary, for their metadata
alone. So WORDS lists them under -- shell -- and
HELP SAVE answers. A mill whose file words were invisible to the
two words people explore with would have hidden a third of itself. Typed
anywhere but as the whole line, they refuse and say so — which is what
3 4 SAVE "x" gets.
Paths are relative to the directory you ran from. If a file called
halifaxrc is there, it is loaded before the first prompt — a
place to keep the words you always want. A missing halifaxrc is
silence, not an error.
Two files, and the split is the reason the mill is testable at all:
| File | What's in it |
|---|---|
halifax-core.shoddy | Pure. Assembles the state from reckoner and its seven seeds, reads a typed line to work out whether the shell has any business in it, and produces the lines to show. Nothing here reads a file, writes one or prints. |
halifax.shoddy | The shell: a prompt, four files, and nothing else. Every effect the mill performs is in this one file, and there are six of them. |
The stack render belongs to the engine rather than to either file, and that is deliberate. The tape records the rendered rows, so a second render in the mill would let the tape and the screen disagree about what happened. What the core owns are the lines around the stack — the banner, the prompt, the tape pane. Each is written as a line-list producer, so the full-screen face planned for v2 can reuse them unchanged.
The loop is one self-recursive Def — a definition that calls
itself to repeat — which is worth knowing if you copy this shape. A tail call
is a call made as a definition's very last step. The compiler turns a
self tail call into a jump, but a mutual one into a real call. So a
loop written as HxLoop → HxTurn → HxLoop would grow the
stack by a frame or two per line entered. That is fine for an afternoon, and
not fine for a redirected script of a hundred thousand lines. So
QUIT is decided by a pure test before the turn, rather
than by the turn handing back a stop. The price is tokenizing the line
twice.
Every file word uses a guarded builtin: TryReadFile and
TryWriteFile, never ReadFile or
WriteFile. An unwritable path is an ordinary thing to answer at a
prompt. An aborted mill is not.
test.shoddy Includes halifax-core.shoddy and
not halifax.shoddy, so nothing in the suite can read or
write a file even by accident. The centrepiece is the three sessions on this
page, asserted whole rather than sampled. Every line the mill
would print is compared byte for byte, and the first differing row is named on
failure. A render that drifts is a docs page that lies, and there is no other
way to notice.
Around that centrepiece, the suite checks:
MAP cascade above,HxRead on every shape of line a shell word can be typed
in.Then the part that matters most: nothing keyable aborts the
mill. The suite feeds it eighty-five lines of nonsense — every word
with an empty stack, wrong cell kinds, unterminated strings and brackets, a
definition of itself, a definition of a number, every shell word misused.
After each one it asserts that 3 4 + still answers 7. Survival is
proved by the next line and not by the error, because "it did not die" is the
actual contract and only a subsequent line can show it.
What is left untested is the loop and the four file words. They are the shell's, and they are effects.
| Machine | Why | |
|---|---|---|
| reckoner | The whole calculator: tokenizer, dictionary, registers, modes, definitions, combinators, UNDO, REDO, TRACE, the tape, HELP and WORDS. RckEval is the one door the shell needs. | |
| seedalg | ALGSIMPLIFY, ALGEXPAND, ALGFACTOR, ALGSOLVE and ALGDESOLVE — symbolic algebra, staying at the text boundary. | |
| seedbool | HEX, BIN, BITAND, BITOR, SHL, ROTL, SIGNED, ADDW, LOWBIT, CARD, GRAY, MINTERMS, NAND, XOR and the rest of the table. Its width words read the core's WIDTH mode. | |
| seedbuiltin | LEN, MID, VAL, NTH, CALL, ERROR and the rest of the runtime's own builtins — the seed that bridges no machine, folded first so everything after it can rely on them. | |
| seedbuzzer | BUZZPLAY, BUZZFREQ, BUZZLENGTH — an MML tune typed at the prompt. | |
| seedclock | STAMP, STAMPDATE, STAMPTIME, TICKS, ELAPSED and FORMATDURATION. | |
| seedcsv | CSVLOAD, CSVREAD, CSVSAVE, CSVCOLUMN, CSVWHERE, CSVPAIRS and the rest of the table. | |
| seeddict | DPUT, DGET, DHAS, DKEYS — a keyed store on the stack, beside the numbered registers. | |
| seedeng | Trigonometry, hyperbolics, complex numbers, statistics, curve fitting and distributions, number theory, polynomial arithmetic, sequences, unit conversion and physical constants. | |
| seedfile | READLINES, WRITELINES, APPENDLINE and FILEEXISTS. | |
| seedfin | Rates, time value of money, growth, statistics, curve fitting, budgeting, loan amortization, NPV/IRR/payback, margin, break-even, payroll and depreciation. | |
| seedhtml | HTMLPARSE, HTMLTEXT, HTMLPRETTY, HTMLLOAD and HTMLSAVE — the tolerant reader, sharing seedxml's tree shape. | |
| seedhttps | HTTPSGET, HTTPSTATUS, HTTPHEADER, HTTPBODY — a fetch with headers, and the reply taken apart. | |
| seedisam | ISAMOPEN, ISAMINSERT, ISAMGETOR, ISAMRANGE — an indexed table opened from a path, a schema and the name of its key, through RckSeedIsam. | |
| seedjson | JSONPARSE, JSONTEXT, JSONPRETTY, JSONLOAD and JSONSAVE — an object landing as a dict. | |
| seedkeys | KEYNAME, KEYGLYPH, KEYIS — what a physical key code stands for. | |
| seedlin | Determinants, inverses, systems, echelon forms, rank, the LU/QR/Cholesky factorisations, characteristic polynomials, eigenvalues and the vector words. | |
| seedmath | HYPOT, DIST, CLAMP, LERP, REMAP, ROUNDTO and the extra logarithms. | |
| seedmatrix | Matrices as cells, multiplied with the same * that multiplies two numbers. | |
| seedmip | MIPPROBLEM, MIPSOLVE, MIPFIXED, MPSMIP, MPSMIPLOAD — answers that have to be whole numbers, and the pinned linear program that is the honest way to a shadow price once they are. | |
| seedgeo | GEODIST, GEOBEARING, GEODEST, GEOROUTELEN, GEOBOUNDS, GEOAREA, GEOSUNRISE and the rest — two coordinates and a question, which is the shape of a calculator line. | |
| seedmoney | MONEY, MSPLIT, MFMT: exact cents, and a split that conserves the penny. | |
| seednet | NETGET, NETREQUEST — fetching a URL, when the mill was started with --allow-net. | |
| seedneural | NEURALNEW, NEURALPREDICT, NEURALSCALERFIT and NEURALSCALERAPPLY — a net and its scaler as dicts. | |
| seedplotter | PLOTHISTOGRAM, PLOTBAR, PLOTPIE, PLOTSAVE — a chart of whatever numbers the session has to hand. | |
| seedrandom | RANDOM, RANDOMRANGE, RANDOMINT, SHUFFLE, SAMPLE and SEED. | |
| seedrecio | RECOPEN, RECGET, RECPUT, RECAPPEND — record files opened by name, the first resource a session can hold open across lines. | |
| seedregex | RXTEST, RXFIND, RXFINDALL, RXGROUPS, RXREPLACE, RXSPLIT, RXQUOTE, RXWHY — a stated rule about a string on the stack, and RXWHY to find out what is wrong with a pattern without needing a subject to try it on. | |
| seedscribbler | SCRIBOPEN, SCRIBFILL, SCRIBTEXT, SCRIBSAVE — a drawing window opened from the prompt and kept open across lines. | |
| seedseq | RANGE, SORTL, REVERSE, CONCAT and the list plumbing the combinators are used with. | |
| seedshaker | SHAKE, UNSHAKE, UNSHAKEOR, SHAKEOK and SHAKEMOD — reversible obfuscation over lists of numbers. | |
| seedsimplex | LPPROBLEM and LPSOLVE — a linear program and its solution, each a dict. | |
| seedsinq | SORTBY, GROUPBY, MINBY, FINDFIRST, TAKEWHILE and the set words. Eight of the thirteen take a PROGRAM for the key — { 1 2 3 4 } [ 2 MOD ] SORTBY — and the program sees the whole session, so a word defined a line earlier can be the key. Folded last, so it is the last heading WORDS prints. | |
| seedsparse | SPARSE, SPENTS, SPNNZ, SPCOLDOT, SPADDROW and the rest — matrices that are mostly zeros, carried as a dict so the storage itself is readable. | |
| seedstats | MEAN, MEDIAN, STDDEV, QUANTILE, CORREL and the distributions — a list to a statistic in one line. | |
| seedstr | STRCAT, SPLIT, JOIN, TOFIXED — strings are cells, so they are on the same stack as the numbers. | |
| seedturtle | TURTLEFORWARD, TURTLERIGHT, TURTLEBLIT — LOGO turtle graphics from the prompt. | |
| seedvt100 | VTCLEARSCREEN, VTCURSORPOS, VTEVALKEY — terminal escape sequences built as strings, to write to a file or print. | |
| seedxml | XMLPARSE, XMLTEXT, XMLPRETTY, XMLLOAD and XMLSAVE — a node as a tagged list, attributes as a dict. | |
| seq | Contains reads the shell-word tables, DropN takes the last rows for the tape pane, and Append builds the shown lines. | |
| str | Join and Split turn a list of rows into a file and back; Trim takes the carriage return off a line written on another platform. |