The Mills · Data & statistics

halifax

A programmable RPN calculator on the runtime stack — mills/halifax

A terminal showing the labelled stack rows of an RPN calculator above a traced cascade, each nested row stepped one indent further right

Summary

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.

Why Every Feature Was Free

Nothing in the list above was built for the calculator. Each one is a property of the language showing through:

What you seeBecause Shoddy is
UNDO and REDO across every entrypurely 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 fileshomoiconic at the token level — code is ordinary data: a definition is its tokens, and its tokens are text
{ 12500 13100 11900 } MEANa language with first-class lists, and stats was already in the tree
money exact at the cent, splits that conservemoney — exact cents, no drift, and MSPLIT loses no penny
+ that adds money, matrices and stringsone Cell variant for every value, so choosing what + does is a single Select Case
TRACE — the stack after every tokenthe surface language is stack code; see the runtime stack
nothing keyable crashes iterrors are values, and the line is the transaction — the state always survives

A Session

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.

Defining Words

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 line may be written over several

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:

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.

A user word takes exactly one cell

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.

TRACE

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
MarkerMeans
|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.

The Tape

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.

Running It

Run it from mills/halifax/. The wrapper is ./build.sh, or ./build.ps1 on Windows, with the same subcommands:

CommandWhat it does
./build.sh runThe calculator, at a prompt.
./build.sh testThe headless suite. No terminal, no display, no network.
./build.sh demoFeeds files/demo.halifax to the prompt, ending on a traced cascade.
./build.sh buildWeaves 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:

WordWhat 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.
RESETStarts again: empty stack, no history, no tape, only the words you started with.
QUITLeaves. Nothing is saved for you. Only as a whole line — under a . prompt it is body text of the line you are still writing.
EXITA 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.

How It's Built

Two files, and the split is the reason the mill is testable at all:

FileWhat's in it
halifax-core.shoddyPure. 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.shoddyThe 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.

How It's Tested

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:

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.

The Machines It Uses

MachineWhy
reckonerThe whole calculator: tokenizer, dictionary, registers, modes, definitions, combinators, UNDO, REDO, TRACE, the tape, HELP and WORDS. RckEval is the one door the shell needs.
seedalgALGSIMPLIFY, ALGEXPAND, ALGFACTOR, ALGSOLVE and ALGDESOLVE — symbolic algebra, staying at the text boundary.
seedboolHEX, 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.
seedbuiltinLEN, 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.
seedbuzzerBUZZPLAY, BUZZFREQ, BUZZLENGTH — an MML tune typed at the prompt.
seedclockSTAMP, STAMPDATE, STAMPTIME, TICKS, ELAPSED and FORMATDURATION.
seedcsvCSVLOAD, CSVREAD, CSVSAVE, CSVCOLUMN, CSVWHERE, CSVPAIRS and the rest of the table.
seeddictDPUT, DGET, DHAS, DKEYS — a keyed store on the stack, beside the numbered registers.
seedengTrigonometry, hyperbolics, complex numbers, statistics, curve fitting and distributions, number theory, polynomial arithmetic, sequences, unit conversion and physical constants.
seedfileREADLINES, WRITELINES, APPENDLINE and FILEEXISTS.
seedfinRates, time value of money, growth, statistics, curve fitting, budgeting, loan amortization, NPV/IRR/payback, margin, break-even, payroll and depreciation.
seedhtmlHTMLPARSE, HTMLTEXT, HTMLPRETTY, HTMLLOAD and HTMLSAVE — the tolerant reader, sharing seedxml's tree shape.
seedhttpsHTTPSGET, HTTPSTATUS, HTTPHEADER, HTTPBODY — a fetch with headers, and the reply taken apart.
seedisamISAMOPEN, ISAMINSERT, ISAMGETOR, ISAMRANGE — an indexed table opened from a path, a schema and the name of its key, through RckSeedIsam.
seedjsonJSONPARSE, JSONTEXT, JSONPRETTY, JSONLOAD and JSONSAVE — an object landing as a dict.
seedkeysKEYNAME, KEYGLYPH, KEYIS — what a physical key code stands for.
seedlinDeterminants, inverses, systems, echelon forms, rank, the LU/QR/Cholesky factorisations, characteristic polynomials, eigenvalues and the vector words.
seedmathHYPOT, DIST, CLAMP, LERP, REMAP, ROUNDTO and the extra logarithms.
seedmatrixMatrices as cells, multiplied with the same * that multiplies two numbers.
seedmipMIPPROBLEM, 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.
seedgeoGEODIST, GEOBEARING, GEODEST, GEOROUTELEN, GEOBOUNDS, GEOAREA, GEOSUNRISE and the rest — two coordinates and a question, which is the shape of a calculator line.
seedmoneyMONEY, MSPLIT, MFMT: exact cents, and a split that conserves the penny.
seednetNETGET, NETREQUEST — fetching a URL, when the mill was started with --allow-net.
seedneuralNEURALNEW, NEURALPREDICT, NEURALSCALERFIT and NEURALSCALERAPPLY — a net and its scaler as dicts.
seedplotterPLOTHISTOGRAM, PLOTBAR, PLOTPIE, PLOTSAVE — a chart of whatever numbers the session has to hand.
seedrandomRANDOM, RANDOMRANGE, RANDOMINT, SHUFFLE, SAMPLE and SEED.
seedrecioRECOPEN, RECGET, RECPUT, RECAPPEND — record files opened by name, the first resource a session can hold open across lines.
seedregexRXTEST, 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.
seedscribblerSCRIBOPEN, SCRIBFILL, SCRIBTEXT, SCRIBSAVE — a drawing window opened from the prompt and kept open across lines.
seedseqRANGE, SORTL, REVERSE, CONCAT and the list plumbing the combinators are used with.
seedshakerSHAKE, UNSHAKE, UNSHAKEOR, SHAKEOK and SHAKEMOD — reversible obfuscation over lists of numbers.
seedsimplexLPPROBLEM and LPSOLVE — a linear program and its solution, each a dict.
seedsinqSORTBY, 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.
seedsparseSPARSE, SPENTS, SPNNZ, SPCOLDOT, SPADDROW and the rest — matrices that are mostly zeros, carried as a dict so the storage itself is readable.
seedstatsMEAN, MEDIAN, STDDEV, QUANTILE, CORREL and the distributions — a list to a statistic in one line.
seedstrSTRCAT, SPLIT, JOIN, TOFIXED — strings are cells, so they are on the same stack as the numbers.
seedturtleTURTLEFORWARD, TURTLERIGHT, TURTLEBLIT — LOGO turtle graphics from the prompt.
seedvt100VTCLEARSCREEN, VTCURSORPOS, VTEVALKEY — terminal escape sequences built as strings, to write to a file or print.
seedxmlXMLPARSE, XMLTEXT, XMLPRETTY, XMLLOAD and XMLSAVE — a node as a tagged list, attributes as a dict.
seqContains reads the shell-word tables, DropN takes the last rows for the tape pane, and Append builds the shown lines.
strJoin and Split turn a list of rows into a file and back; Trim takes the carriage return off a line written on another platform.