feat(v4.0.0): POST passes and is part of the boot; U* and U/MOD

The boot is now nucleus, forth79.4th, POST, prompt, on both products.

- forth79.4th: U* and U/MOD, the capsule's first colon definitions.  They
  are in the FORTH-79 Required Word Set and neither v3 nor v4 had them.
- post79.4th: 550 cases, 126 of the 130 required words.  443 are v3's with
  v3's result.  The rest follow three rulings (2026-10-05): address-
  dependent cases are checked for count, not value; where v3 departs from
  FORTH-79 the standard's result is expected; words v3 has no case for get
  cases written by hand.  v4/tools/post79_rules.py holds each exception
  with its reason and docs/v4.0.0/POST79.md lists them all.
- every case starts from an empty stack, DECIMAL and FORTH DEFINITIONS
- the boot requires POST's tally line with fail=0

Verified: tests=550 pass=550 fail=0 and identical PARITY lines on hosted
amd64, aarch64 and riscv64 (make -C v4 hosted-check) and on bare metal,
clean qemu with STARFORTH_V4=1, on the same three (logs/20261005-1619xx,
-1621xx, -1625xx).  A U/MOD broken on purpose fails five cases and stops
the boot.  make -C v4 test passes.

Not shown: all words but those two are still assembled, so POST has so far
tested the assembled words.  Nothing was typed at a bare-metal prompt.
Open: PAD 42 OVER ! faults on v4 (D-1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rajames
2026-10-05 16:28:01 -04:00
co-authored by Claude Opus 5.5
parent 5d6043e37e
commit 2930349bbf
13 changed files with 1331 additions and 416 deletions
+9 -12
View File
@@ -129,27 +129,24 @@ $(BINDIR)/boot-$(1).txt: $(BINDIR)/starforth4-$(1)
endef
$(foreach i,$(HOSTED_ISAS),$(eval $(call HOSTED_RULE,$(i))))
# `make post` builds the amd64 system with POST in its boot
# (V4_POST_AT_BOOT=1, system/boot.c) and boots it: the POST capsule,
# ../capsules/v4/post79.4th, is loaded after forth79.4th and must end with a
# clean tally. `make post79` writes that capsule again from v3's cases
# (tools/mkpost.py); it needs the hosted v3 binary.
# `make post` boots the amd64 system alone, which is the quick check after a
# change to the vocabulary: nucleus, forth79.4th, POST, prompt. `make
# post79` writes the POST capsule, ../capsules/v4/post79.4th, again from
# v3's cases and tools/post79_rules.py (tools/mkpost.py); it needs the
# hosted v3 binary.
.PHONY: post post79
$(BINDIR)/starforth4-post: $(HERE)/tools/hosted.c $(BINDIR)/v4_image_64.c $(CAPSULE_DIR_C) $(ENGINE_SRCS) $(SYSTEM_SRCS) $(wildcard $(HERE)/include/v4/*.h) $(HERE)/Makefile
$(CC_amd64) $(CFLAGS) -D_POSIX_C_SOURCE=200809L -I$(HERE)/include -DV4_CELL_BITS=64 $(HOST_DEFS) -c $(HERE)/tools/hosted.c -o $(BINDIR)/hosted-post.o
$(CC_amd64) $(SYSTEM_CFLAGS) -DV4_POST_AT_BOOT=1 -static $(BINDIR)/hosted-post.o $(BINDIR)/v4_image_64.c $(CAPSULE_DIR_C) $(ENGINE_SRCS) $(SYSTEM_SRCS) -o $@
post: $(BINDIR)/starforth4-post
@$(BINDIR)/starforth4-post < /dev/null
post: $(BINDIR)/starforth4-amd64
@$(BINDIR)/starforth4-amd64 < /dev/null
post79:
python3 $(HERE)/tools/mkpost.py
# `make hosted` builds the three. `make hosted-check` boots each with no
# input and requires that all three print the same lines, ending in
# PARITY:OK and the prompt: the same hashes on every ISA.
# PARITY:OK, POST: PASSED and the prompt: the same hashes on every ISA.
.PHONY: hosted hosted-check
hosted: $(foreach i,$(HOSTED_ISAS),$(BINDIR)/starforth4-$(i))
hosted-check: $(foreach i,$(HOSTED_ISAS),$(BINDIR)/boot-$(i).txt)
@grep -q '^PARITY:OK$$' $(BINDIR)/boot-amd64.txt || { echo "hosted-check: no PARITY:OK"; exit 1; }
@grep -q '^POST: PASSED$$' $(BINDIR)/boot-amd64.txt || { echo "hosted-check: no POST: PASSED"; exit 1; }
@cmp $(BINDIR)/boot-amd64.txt $(BINDIR)/boot-aarch64.txt
@cmp $(BINDIR)/boot-amd64.txt $(BINDIR)/boot-riscv64.txt
@echo "hosted-check: amd64, aarch64 and riscv64 boot identically"
+32 -37
View File
@@ -55,58 +55,53 @@ and fails unless their output is identical and ends in `PARITY:OK`.
A capsule line the node does not answer ` ok` to ends the boot, naming the
capsule, block and line, with `PARITY:FAIL` and `POST: FAILED`.
State, 2026-10-05, after the POST work began: all six builds load
`v4:forth79.4th` and reach `ok>` with the same lines:
State, 2026-10-05: all six builds start the nucleus, load `v4:forth79.4th`,
pass POST and reach `ok>`, with the same lines:
```
PARITY:V4_NUCLEUS words=295 image_hash=0x3ed5d5fc83f0b026
PARITY:V4_CAPSULE name=v4:forth79.4th capsule_id=0xa4b74bdc7deaf204 capsule_hash=0xa4b74bdc7deaf204 dict_hash=0x118de9d37e682ea4
PARITY:V4_CAPSULE name=v4:forth79.4th capsule_id=0x4055641ee17d176b capsule_hash=0x4055641ee17d176b dict_hash=0x1ec33b48cc76a0b5
PARITY:V4_POST tests=550 pass=550 fail=0
PARITY:V4_CAPSULE name=v4:post79.4th capsule_id=0xb987dbb4388990bd capsule_hash=0xb987dbb4388990bd dict_hash=0x671fdd7aebf0f8d8
PARITY:OK
POST: PASSED
ok>
```
Bare-metal logs: `logs/20261005-160144/amd64/`, `logs/20261005-160234/aarch64/`,
`logs/20261005-160400/riscv64/`. Each run was ended once the prompt was in the
Bare-metal logs: `logs/20261005-161924/amd64/`, `logs/20261005-162142/aarch64/`,
`logs/20261005-162524/riscv64/`. Each run was ended once the prompt was in the
log; nothing was typed at a bare-metal prompt, so keyboard input there is not
yet verified. The capsules were unsigned (no signing key on this machine).
`forth79.4th` holds no definitions yet: every word is still in the nucleus.
**What this does not yet show.** `forth79.4th` holds two definitions, `U*`
and `U/MOD`. Every other word is still in the assembled nucleus, so POST is
so far a test of the assembled words. Moving them to the capsule, a group at
a time with POST after each, is the next step (`NUCLEUS.md` section 8).
## POST
`capsules/v4/post79.4th` is v3's POST cases for the FORTH-79 Required Word
Set, written by `v4/tools/mkpost.py` (`make -C v4 post79`): 537 cases in
blocks 7000–7112, covering 111 of the 130 required words. Each case carries
what the hosted v3 binary did with the same line — error or not, the stack,
the length and checksum of what was printed — and v4 must do the same.
`capsules/v4/post79.4th`: 550 cases in blocks 7000 up, covering 126 of the
130 words of the FORTH-79 Required Word Set. 443 are v3's cases with what
the hosted v3 binary did as the expected result. The other 107, and the 27
v3 cases left out, are listed with reasons in `docs/v4.0.0/POST79.md`.
**POST does not pass yet, so it is not in the boot.** `make -C v4 post` builds
the amd64 system with it (`-DV4_POST_AT_BOOT=1`) and boots it:
- `make -C v4 post` boots the amd64 system: the quick check after a change.
- `make -C v4 hosted-check` boots all three hosted ISAs and compares them.
- `make -C v4 post79` writes the capsule again (`tools/mkpost.py`,
`tools/post79_rules.py`); it needs the hosted v3 binary.
```
PARITY:V4_POST tests=537 pass=439 fail=98
PARITY:FAIL
POST: FAILED
```
POST is FORTH: a harness of FORTH-79 words and two nucleus hooks, `(CATCH)`
and `(EMIT-HOOK)`. It forgets itself when it has finished. The boot passes
only on seeing POST's tally line with `fail=0` (`v4/system/boot.c`). A colon
definition broken on purpose (`U/MOD` without its last `SWAP`) fails five
cases and the boot stops with `POST: FAILED`.
The 98 are not yet sorted into v4 defects and differences that need a
ruling. A first reading finds four kinds:
Not tested: `KEY`, `EXPECT`, `QUERY` (the keyboard) and `QUIT` (it returns
without ` ok`).
| Kind | About | Examples |
|---|---|---|
| The case prints or leaves a memory address, which differs between the two machines | 40 | `HERE`, `PAD`, `1 BLOCK`, `BL WORD`, `CONTEXT @ .`, `FIND` |
| v3 departs from FORTH-79 and v4 follows the standard | 35 | `PICK` and `ROLL` count from 1; `<# # #S #>` take a double number; `LEAVE` ends the loop at the next `LOOP`; `>R` `R>` only inside a definition |
| The case assumes v3's byte address units, where a v4 address unit is a cell (D-1) | 8 | `42 , HERE 8 - @`, `PAD 42 OVER !` |
| Not yet understood | 15 | vocabulary cases, `LIST`'s layout, `16 BASE ! 255 .`, `321 EMIT`, cases v3 ends in an error and v4 does not |
Open: `PAD 42 OVER !` faults on v4, because `PAD` is a byte address and `!`
takes a cell address (D-1). FORTH-79 expects it to work. The v3 case for it
is left out until that is ruled.
Required words with no v3 case, and so no POST case: `U*` `U/MOD` `?`
`EXECUTE` `KEY` `EXPECT` `QUERY` `U.` `CONVERT` `'` `."` `LITERAL` `STATE`
`COMPILE` `[COMPILE]` `(` `QUIT` `BLK` `79-STANDARD`. `U*` and `U/MOD` are
in neither v3 nor v4.
Twelve v3 cases are left out: three read the keyboard, two forget a word of
the system, two store to the search order, and five leave v3 unable to
continue in one session (`mkpost.py` prints the list).
The boot passes POST only on seeing the tally line with `fail=0`
(`v4/system/boot.c`); that no line was refused is not enough.
The Required Word Set list in `tools/mkpost.py` was written from memory of
the standard and has not been checked against the document.
+12 -8
View File
@@ -11,11 +11,11 @@
#include "starkernel/capsule_blocks.h"
/* The capsules, in the order they are loaded. */
/* POST is not yet part of the boot: v4:post79.4th does not pass yet
* (v4/README.md). Build with -DV4_POST_AT_BOOT=1 to load it and to require
* its clean tally; `make -C v4 post` does. */
/* POST is part of the boot: v4:post79.4th is loaded after the vocabulary it
* tests and must end with a clean tally. -DV4_POST_AT_BOOT=0 leaves it
* out, for work on a vocabulary that does not pass yet. */
#ifndef V4_POST_AT_BOOT
#define V4_POST_AT_BOOT 0
#define V4_POST_AT_BOOT 1
#endif
static const char *const boot_capsules[] = {
"v4:forth79.4th",
@@ -136,14 +136,18 @@ static int text_at(const char *line, unsigned len, unsigned at, const char *want
static void post_watch(char c)
{
static const char clean[] = " fail=0";
unsigned i;
if (c != '\n') {
if (post_len < sizeof post_line) post_line[post_len++] = c;
return;
}
if (text_at(post_line, post_len, 0, "PARITY:V4_POST ")) {
post_seen = 1;
post_clean = post_len >= sizeof clean - 1 && text_at(post_line, post_len, post_len - (unsigned)(sizeof clean - 1), clean);
}
/* the node's prompt comes before it on the line, so look along the line */
for (i = 0; i < post_len; i++)
if (text_at(post_line, post_len, i, "PARITY:V4_POST ")) {
post_seen = 1;
post_clean = post_len >= sizeof clean - 1 && text_at(post_line, post_len, post_len - (unsigned)(sizeof clean - 1), clean);
break;
}
post_len = 0;
}
+139 -23
View File
@@ -36,6 +36,9 @@ import re
import subprocess
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import post79_rules as rules # noqa: E402
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
MODULE_DIR = os.path.join(ROOT, "v3", "src", "test_runner", "modules")
OUTPUT = os.path.join(ROOT, "capsules", "v4", "post79.4th")
@@ -83,15 +86,15 @@ HARNESS = r"""
( edit. docs/v4.0.0/NUCLEUS.md section 6. )
VARIABLE T# VARIABLE TPASS VARIABLE TFAIL VARIABLE TOPEN
VARIABLE TBAD VARIABLE TWANT VARIABLE TLEN VARIABLE TSUM
VARIABLE TDEP VARIABLE TBASE
VARIABLE TDEP
CREATE TBUF 64 ALLOT CREATE TSTK 16 ALLOT CREATE TNAME 8 ALLOT
0 T# ! 0 TPASS ! 0 TFAIL ! 0 TOPEN ! 10 TBASE !
0 T# ! 0 TPASS ! 0 TFAIL ! 0 TOPEN !
: TB ( -- baddr ) TBUF 4 * ;
: TN ( -- baddr ) TNAME 4 * ;
( T-EMIT is given every character a case prints: the first 255 )
( are kept, all are counted and summed. )
: T-EMIT ( c -- )
DUP TSUM @ 31 * + 16777215 AND TSUM !
255 AND DUP TSUM @ 31 * + 16777215 AND TSUM !
TLEN @ 255 < IF TB TLEN @ + C! ELSE DROP THEN 1 TLEN +! ;
=====
: T-CLEAR ( i*x -- ) BEGIN DEPTH WHILE DROP REPEAT ;
@@ -107,34 +110,38 @@ CREATE TBUF 64 ALLOT CREATE TSTK 16 ALLOT CREATE TNAME 8 ALLOT
TBAD @ IF 1 TFAIL +!
." POST FAIL: " TN COUNT TYPE T-SHOW CR
ELSE 1 TPASS +! THEN
0 TOPEN ! 0 (CATCH) ! TBASE @ BASE !
0 TOPEN ! 0 (CATCH) !
THEN ;
=====
( T{ name begin a case: clean stack, errors caught )
( T{ name begin a case: clean stack, decimal, FORTH )
: T{ ( i*x -- ) T-CLOSE T-CLEAR
DECIMAL [COMPILE] FORTH DEFINITIONS
BL WORD COUNT 31 MIN DUP TN C! TN 1+ SWAP CMOVE
1 T# +! 1 TOPEN ! 0 TBAD ! 0 TWANT ! 0 TLEN ! 0 TSUM !
0 TDEP ! 1 (CATCH) ! ;
( T| ... a line of the case, what it prints captured )
: T| ' T-EMIT (EMIT-HOOK) ! ; IMMEDIATE
: T-END ( -- ) 0 (EMIT-HOOK) ! BASE @ TBASE ! DECIMAL ;
: T-END ( -- ) 0 (EMIT-HOOK) ! DECIMAL ;
( -> ... the case is over: set its stack aside, top first )
: -> ( i*x -- ) T-END 0
BEGIN DEPTH 1 > WHILE
DUP 16 < IF SWAP OVER TSTK + ! ELSE SWAP DROP 1 TBAD ! THEN
1+ REPEAT
16 MIN TDEP ! ;
=====
( ->ERR the case is over, and must have ended in an error )
: ->ERR ( i*x -- ) T-END -1 TWANT ! T-CLEAR ;
=====
( ... }T the stack the case must have left, in order )
: }T ( i*x -- )
DEPTH TDEP @ = 0= IF 1 TBAD ! T-CLEAR EXIT THEN
0 BEGIN DEPTH 1 > WHILE
SWAP OVER TSTK + @ = 0= IF 1 TBAD ! THEN 1+ REPEAT DROP ;
( n }TD only how many values the case must have left )
: }TD ( n -- ) TDEP @ = 0= IF 1 TBAD ! THEN ;
( len sum T= what the case must have printed )
: T= ( len sum -- ) TSUM @ = 0= IF 1 TBAD ! THEN
TLEN @ = 0= IF 1 TBAD ! THEN ;
=====
( the tally; a failure is an error, which ends the boot )
: T-REPORT ( -- ) T-CLOSE 0 (CATCH) ! DECIMAL
." PARITY:V4_POST tests=" T# @ T. ." pass=" TPASS @ T.
@@ -312,13 +319,13 @@ def run_v3(v3, cases):
None if v3 did not get through it, else (error, stack, output)."""
script = [": T| ; IMMEDIATE", ": TCLR BEGIN DEPTH WHILE DROP REPEAT ;"]
for n, case in enumerate(cases):
script.append('TCLR ." [[B%d]]"' % n)
script.append('TCLR DECIMAL FORTH DEFINITIONS ." [[B%d]]"' % n)
script.extend(case["lines"])
script.append('." [[E%d]]" BASE @ DECIMAL .S BASE ! ." [[S%d]]"' % (n, n))
script.append("BYE")
done = subprocess.run([v3, "--log-none"], input="\n".join(script) + "\n", capture_output=True,
text=True, errors="replace", timeout=600)
text = ANSI.sub("", done.stdout)
done = subprocess.run([v3, "--log-none"], input=("\n".join(script) + "\n").encode("latin-1"),
capture_output=True, timeout=600)
text = ANSI.sub("", done.stdout.decode("latin-1")) # a byte is a character
results = []
for n, case in enumerate(cases):
begin, mid, end = "[[B%d]] ok\nok> " % n, "ok> [[E%d]]" % n, "[[S%d]]" % n
@@ -362,6 +369,7 @@ def main():
ap = argparse.ArgumentParser()
ap.add_argument("--v3", default=os.path.join(ROOT, "lfs", "amd64", "starforth"))
ap.add_argument("--report", default=None)
ap.add_argument("--dump", default=None, help="write what v3 did with every case")
args = ap.parse_args()
required, left_out, cases, seen = set(REQUIRED), [], [], {}
@@ -410,21 +418,60 @@ def main():
current.extend(part.strip("\n").split("\n"))
flush()
disagreements = []
disagreements, applied = [], {"address": 0, "standard": 0, "extra": 0}
def expectation(error, stack, output, mode="full"):
"""The lines that end a case."""
if error:
return ["->ERR"]
if mode == "depth":
return ["-> %d }TD" % len(stack)]
if mode == "depth+output":
return pack("", ["->", str(len(stack)), "}TD", str(len(output)), str(checksum(output)), "T="])
return pack("", ["->"] + [str(v) for v in stack] + ["}T", str(len(output)), str(checksum(output)), "T="])
def emit(label, lines, ending):
block = ["T{ " + label] + lines + ending
if len(current) + len(block) > 16:
flush()
current.extend(block)
dump = []
for case, (error, stack, output) in zip(cases, results):
label = case["label"]
dump.append("%s\n input: %s\n v3: %s stack=%s out=%r\n"
% (label, " ".join(case["text"].split()), "ERROR" if error else "ok", stack, output))
if error != case["should_error"]:
disagreements.append((case["label"], "v3's table says %s, v3 %s"
disagreements.append((label, "v3's table says %s, v3 %s"
% ("error" if case["should_error"] else "no error",
"raised one" if error else "raised none")))
lines = ["T{ " + case["label"]] + case["lines"]
if error:
lines.append("->ERR")
if label in rules.STANDARD:
rule = rules.STANDARD[label]
if rule is None: # the case is dropped
applied["standard"] += 1
left_out.append((label, rules.DROPPED[label]))
continue
lines = pack("T|", atoms(rule.get("text", case["text"])))
assert lines, label
if "depth" in rule:
emit(label, lines, ["-> %d }TD" % rule["depth"]])
else:
emit(label, lines, expectation(rule.get("error", False), rule.get("stack", []), rule.get("out", "")))
applied["standard"] += 1
elif label in rules.ADDRESS:
emit(label, case["lines"], expectation(error, stack, output, rules.ADDRESS[label]))
applied["address"] += 1
else:
tail = ["->"] + [str(v) for v in stack] + ["}T", str(len(output)), str(checksum(output)), "T="]
lines.extend(pack("", tail))
if len(current) + len(lines) > 16:
flush()
current.extend(lines)
emit(label, case["lines"], expectation(error, stack, output))
for rule in rules.EXTRA:
lines = pack("T|", atoms(rule["text"]))
assert lines, rule
emit(rule["label"], lines, expectation(rule.get("error", False), rule.get("stack", []), rule.get("out", "")))
applied["extra"] += 1
unknown = [k for k in list(rules.STANDARD) + list(rules.ADDRESS) if k not in {c["label"] for c in cases}]
assert not unknown, "rules for cases that do not exist: %s" % unknown
if args.dump:
open(args.dump, "w").write("\n".join(dump))
flush()
blocks.append(["T-REPORT", "FORGET T# DECIMAL"])
@@ -436,9 +483,15 @@ def main():
assert len(line) <= LINE_MAX, line
out.write(line + "\n")
tested = sorted({c["word"] for c in cases})
tested = sorted({c["word"] for c in cases} | {r["label"].rsplit(".", 1)[0] for r in rules.EXTRA})
untested = [w for w in REQUIRED if w not in set(tested)]
report = ["POST cases: %d, in %d blocks (%d to %d)" % (len(cases), len(blocks), FIRST_BLOCK, FIRST_BLOCK + len(blocks) - 1),
dropped = sum(1 for v in rules.STANDARD.values() if v is None)
total = len(cases) - dropped + len(rules.EXTRA)
report = ["POST cases: %d, in %d blocks (%d to %d)" % (total, len(blocks), FIRST_BLOCK, FIRST_BLOCK + len(blocks) - 1),
" from v3 unchanged: %d" % (len(cases) - applied["address"] - applied["standard"]),
" from v3, address-dependent (post79_rules.ADDRESS): %d" % applied["address"],
" rewritten or dropped for FORTH-79 (post79_rules.STANDARD): %d, of which dropped %d" % (applied["standard"], dropped),
" written by hand (post79_rules.EXTRA): %d" % applied["extra"],
"Required words with at least one case: %d of %d" % (len(tested), len(REQUIRED)),
"Required words with no case: " + " ".join(untested), "",
"Left out (%d):" % len(left_out)]
@@ -446,10 +499,73 @@ def main():
report += ["", "v3 did not do what its own table expects (%d):" % len(disagreements)]
report += [" %-32s %s" % item for item in disagreements]
text = "\n".join(report) + "\n"
write_doc(cases, results, left_out, disagreements, untested, total, applied, dropped)
if args.report:
open(args.report, "w").write(text)
sys.stdout.write(text)
DOC = os.path.join(ROOT, "docs", "v4.0.0", "POST79.md")
def code(text):
return "`" + " ".join(text.split()).replace("|", "\\|") + "`"
def expects(rule):
if rule.get("error"):
return "an error"
if "depth" in rule:
return "%d value(s) left; output not compared" % rule["depth"]
return "stack `%s`, prints %s" % (" ".join(str(v) for v in rule.get("stack", [])) or "empty",
"`%s`" % rule["out"].replace("\n", "\\n") if rule.get("out") else "nothing")
def write_doc(cases, results, left_out, disagreements, untested, total, applied, dropped):
by_label = {c["label"]: (c, r) for c, r in zip(cases, results)}
out = ["# POST for the FORTH-79 Required Word Set: where it departs from v3", "",
"Written by `v4/tools/mkpost.py` from `v4/tools/post79_rules.py`; do not edit.",
"Design: `NUCLEUS.md` section 6. The capsule is `capsules/v4/post79.4th`.", "",
"POST runs %d cases. %d are v3's, with what the hosted v3 binary did as the" % (total, len(cases) - applied["address"] - applied["standard"]),
"expected result. This file lists every other case, and why.", "",
"## 1. Rewritten for FORTH-79 (%d)" % (applied["standard"] - dropped), "",
"v3 departs from the standard, or the case is written for v3's machine. v4",
"follows the standard (standing ruling), so the expected result is the",
"standard's and not v3's.", "",
"| Case | v3's line | What v3 did | As POST runs it | Must do | Why |", "|---|---|---|---|---|---|"]
for label, rule in rules.STANDARD.items():
if rule is None:
continue
case, (error, stack, output) = by_label[label]
did = "error" if error else "stack `%s`, printed `%s`" % (" ".join(map(str, stack)) or "empty", output.replace("\n", "\\n")[:40])
ran = code(rule["text"]) if "text" in rule else "unchanged"
out.append("| `%s` | %s | %s | %s | %s | %s |" % (label, code(case["text"]), did, ran, expects(rule), rule["why"]))
out += ["", "## 2. Address-dependent (%d)" % applied["address"], "",
"The case leaves or prints a memory address, which is a different number on",
"the two machines. The number of values left is checked, and what is",
"printed unless an address is printed; the values are not.", "",
"| Case | Line | Checked |", "|---|---|---|"]
for label, mode in rules.ADDRESS.items():
out.append("| `%s` | %s | %s |" % (label, code(by_label[label][0]["text"]),
"values left, and output" if mode == "depth+output" else "values left only"))
out += ["", "## 3. Written by hand (%d)" % len(rules.EXTRA), "",
"For required words v3 has no case for. The expected results are the",
"standard's; no v3 run stands behind them.", "",
"| Case | Line | Must do |", "|---|---|---|"]
for rule in rules.EXTRA:
out.append("| `%s` | %s | %s |" % (rule["label"], code(rule["text"]), expects(rule)))
out += ["", "## 4. v3 cases left out (%d)" % len(left_out), "", "| Case | Why |", "|---|---|"]
out += ["| `%s` | %s |" % item for item in left_out]
out += ["", "## 5. Required words POST does not test", "",
" ".join("`%s`" % w for w in untested) + ".", "",
"`KEY`, `EXPECT` and `QUERY` wait for the keyboard, which a boot cannot type",
"at. `QUIT` returns to the terminal without `ok`, which the capsule loader",
"takes for a refused line.", "",
"## 6. Where v3 did not do what its own table expects (%d)" % len(disagreements), "",
"POST expects what v3 did, unless section 1 says otherwise.", "", "| Case | |", "|---|---|"]
out += ["| `%s` | %s |" % item for item in disagreements]
open(DOC, "w").write("\n".join(out) + "\n")
if __name__ == "__main__":
main()
+163
View File
@@ -0,0 +1,163 @@
"""post79_rules.py -- where POST does not take v3's word for it.
mkpost.py takes every case from v3 and, by default, what v3 did with it.
This file holds the exceptions, each with its reason. Ruled by Captain Bob,
2026-10-05 (docs/v4.0.0/NUCLEUS.md 6.5):
ADDRESS the case leaves or prints a memory address, which is not the
same number on the two machines. "depth+output": the number of
values left and what was printed are checked, the values are
not. "depth": only the number of values, because an address is
printed.
STANDARD v3 departs from FORTH-79, or the case is written for v3's
machine; v4 follows the standard. The entry gives the case as
POST runs it: `text` if the line had to change, then what it
must do -- `error`, or `stack` and `out`, or `depth` alone where
an address is printed. None drops the case. `why` is the
reason, and is what docs/v4.0.0/POST79.md lists.
EXTRA cases written by hand, for required words v3 has no case for.
"""
D1 = "v3's address unit is a byte; a v4 address unit is a cell (D-1)"
PIC = "FORTH-79: <# # #S #> work on a double number; v3 takes a single"
RS = "FORTH-79: >R R> R@ are for use inside a definition; v4 refuses them at the prompt"
PICK = "FORTH-79: PICK and ROLL count from 1; v3 counts from 0"
LEAVE = "FORTH-79: LEAVE ends the loop at the next LOOP; v3 jumps out at once"
V3WORD = "v3's WORD does not take the next word from the input, as FORTH-79's does; see the WORD cases written by hand"
V3ERR = "v3 raises an error FORTH-79 does not ask for"
VOC = "FORTH-79: a word defined in another vocabulary is not found from FORTH; v3 finds it"
ADDRESS = {
"-TRAILING.basic": "depth+output", "-TRAILING.all_spaces": "depth+output", "-TRAILING.no_spaces": "depth+output",
"HERE.stability": "depth+output", "PAD.stability": "depth+output",
"UPDATE.basic": "depth+output", "UPDATE.multiple": "depth+output", "SAVE-BUFFERS.dirty_blocks": "depth+output",
"EMPTY-BUFFERS.after_use": "depth+output", "EMPTY-BUFFERS.dirty_blocks": "depth+output",
"BUFFER.flush_dirty": "depth+output",
"BLOCK.basic": "depth", "BUFFER.basic": "depth",
"VOCABULARY.create_and_switch": "depth", "CONTEXT.basic": "depth", "CONTEXT.initial": "depth",
"CURRENT.basic": "depth", "CURRENT.after_def": "depth",
# the standard leaves the layout of a listing to the system
"LIST.basic": "depth", "SCR.after_list": "depth",
}
STANDARD = {
"PICK.pick_0": dict(error=True, why=PICK + "; 0 PICK is an error"),
"PICK.pick_1": dict(stack=[1, 2, 3], out="3 \n", why=PICK),
"PICK.pick_2": dict(stack=[1, 2, 3], out="2 \n", why=PICK),
"ROLL.roll_1": dict(out="3 2 1 \n", why=PICK),
">R.basic": dict(text=": RS1 42 >R R@ . R> . CR ; RS1", out="42 42 \n", why=RS),
">R.zero": dict(text=": RS2 0 >R R@ . R> . CR ; RS2", out="0 0 \n", why=RS),
">R.negative": dict(text=": RS3 -123 >R R@ . R> . CR ; RS3", out="-123 -123 \n", why=RS),
">R.multiple": dict(text=": RS4 1 2 >R >R R@ . R> . R@ . R> . CR ; RS4", out="1 1 2 2 \n", why=RS),
"R>.basic": dict(text=": RS5 42 >R R> . CR ; RS5", out="42 \n", why=RS),
"R>.lifo_order": dict(text=": RS6 1 2 >R >R R> . R> . CR ; RS6", out="1 2 \n", why=RS),
"R@.basic": dict(text=": RS7 42 >R R@ . R> DROP CR ; RS7", out="42 \n", why=RS),
"R@.non_destructive": dict(text=": RS8 99 >R R@ R@ = . R> DROP CR ; RS8", out="-1 \n", why=RS),
",.basic": dict(text="42 , HERE 1 - @ . CR", out="42 \n", why=D1),
",.negative": dict(text="-999 , HERE 1 - @ . CR", out="-999 \n", why=D1),
"+!.basic": dict(out="15 \n", why=V3ERR + ": a store to HERE"),
"+!.by_zero": dict(out="42 \n", why=V3ERR + ": a store to HERE"),
"+!.negative": dict(out="7 \n", why=V3ERR + ": a store to HERE"),
"+!.accumulate": dict(out="6 \n", why=V3ERR + ": a store to HERE"),
"HERE.after_comma~2": dict(stack=[0], why="the case divides by zero unless HERE moved by one cell; on v3 it had moved by 8"),
"MOVE.basic": dict(text="65 HERE ! 66 HERE 1+ ! HERE HERE 16 + 2 MOVE HERE 16 + @ . HERE 17 + @ . CR",
out="65 66 \n", why="FORTH-79: MOVE moves cells; and " + D1),
"PAD.usable": None, # see OPEN below
"BASE.base_store": dict(out="255 \n", why="255 read in base 16 and printed in base 16 is 255; v3 printed 467"),
"<#.basic": dict(out="42\n", why="v3 prints 774763251095801167872 for 42 S>D <# #S #>"),
"<#.empty": dict(text="0 0 <# #> TYPE CR", out="\n", why=PIC),
"<#.negative": dict(text="-42 DUP ABS 0 <# #S ROT SIGN #> TYPE CR", out="-42\n", why=PIC),
"#.single_digit": dict(text="15 0 <# # #> TYPE CR", out="5\n", why=PIC),
"#.multiple": dict(text="15 0 <# # # #> TYPE CR", out="15\n", why=PIC),
"#.zero_pad": dict(text="5 0 <# # # #> TYPE CR", out="05\n", why=PIC),
"#S.basic": dict(text="42 0 <# #S #> TYPE CR", out="42\n", why=PIC),
"#S.zero": dict(text="0 0 <# #S #> TYPE CR", out="0\n", why=PIC),
"#S.large": dict(text="1234567890 0 <# #S #> TYPE CR", out="1234567890\n", why=PIC),
"SIGN.negative": dict(text="-42 DUP ABS 0 <# #S ROT SIGN #> TYPE CR", out="-42\n", why=PIC),
"SIGN.positive": dict(text="42 DUP ABS 0 <# #S ROT SIGN #> TYPE CR", out="42\n", why=PIC),
"SIGN.zero": dict(text="0 DUP ABS 0 <# #S ROT SIGN #> TYPE CR", out="0\n", why=PIC),
"#>.normal": dict(text="42 0 <# #S #> TYPE CR", out="42\n", why=PIC),
"#>.empty": dict(text="0 0 <# #> TYPE CR", out="\n", why=PIC),
"#>.stack_effect": dict(text="42 0 <# #S #> SWAP DROP . CR", out="2 \n", why=PIC),
"HOLD.basic": dict(text="42 0 <# 46 HOLD #S #> TYPE CR", out="42.\n", why=PIC),
"COUNT.basic": dict(text="4 PAD C! PAD COUNT . PAD - . CR", out="4 1 \n", why=D1 + "; and S\" is not FORTH-79"),
"COUNT.empty": dict(text="0 PAD C! PAD COUNT . PAD - . CR", out="0 1 \n", why=D1),
"COUNT.max_length": dict(text="255 PAD C! PAD COUNT . PAD - . CR", out="255 1 \n", why=D1),
"COUNT.basic~2": dict(text="4 PAD C! PAD COUNT . PAD - . CR", out="4 1 \n", why=D1),
"COUNT.empty~2": dict(text="0 PAD C! PAD COUNT . PAD - . CR", out="0 1 \n", why=D1),
"COUNT.max_length~2": dict(text="255 PAD C! PAD COUNT . PAD - . CR", out="255 1 \n", why=D1),
"CMOVE.basic": dict(text="65 PAD C! 66 PAD 1+ C! PAD PAD 10 + 2 CMOVE PAD 10 + C@ . PAD 11 + C@ . CR",
out="65 66 \n", why=D1 + "; and the v3 case leaves a value on the return stack"),
"CMOVE.bounds": None, # moves 1000 bytes over the system; v3 refuses by a bounds rule FORTH-79 has not got
"TYPE.basic_string": dict(text="72 PAD C! 105 PAD 1+ C! PAD 2 TYPE CR", out="Hi\n", why=D1 + "; and " + RS),
"TYPE.numbers": dict(text="49 PAD C! 50 PAD 1+ C! PAD 2 TYPE CR", out="12\n", why=D1 + "; and " + RS),
">IN.initial": dict(out="9 \n", why="FORTH-79: >IN is the offset into the input, 9 here; v3 prints an address"),
"WORD.empty_input": None, "WORD.space_delim": None, "WORD.newline_delim": None, "WORD.tab_delim": None,
"WORD.comma_delim": None, "WORD.skip_leading": None, "WORD.single_char": None, "WORD.long_word": None,
"WORD.zero_delim": None, "WORD.high_ascii": None, "WORD.count_format": None, "WORD.count_value": None,
"WORD.multi_delim": None,
"UPDATE.no_block": dict(why=V3ERR),
"SCR.after_load": dict(out="1 \n", why="v3 prints nothing after 1 LOAD; SCR is still 1 from the LIST before"),
"FIND.existing": dict(depth=0, why="FORTH-79: FIND takes the next word, DUP here, and leaves its address, which is printed"),
"FIND.user_word": dict(depth=0, why="the address FIND leaves is printed"),
"FIND.empty": dict(stack=[0], why="FORTH-79: FIND leaves 0 when there is no word; v3 raises an error"),
"VOCABULARY.cross_vocab_access": dict(error=True, why=VOC),
"VOCABULARY.duplicate": dict(why=V3ERR + ": defining a name again"),
"IF.true": dict(out="42 \n", why="v3 runs an older TEST1 from another vocabulary and leaves the flag"),
"IF.false": dict(out="24 \n", why="v3 runs an older TEST2 from another vocabulary and leaves the flag"),
"LEAVE.basic": dict(out="4 \n", why=LEAVE),
"LEAVE.at_start": dict(out="1 \n", why=LEAVE),
"LEAVE.qdloop": dict(out="3 \n", why=LEAVE),
}
# Dropped, and why. mkpost.py prints these with the cases it leaves out.
DROPPED = {
"PAD.usable": "OPEN: PAD 42 OVER ! faults on v4 -- PAD is a byte address and ! takes a cell address (D-1)",
"CMOVE.bounds": "moves 1000 bytes over the system; v3 refuses by a bounds rule FORTH-79 has not got",
}
for _name in [k for k, v in STANDARD.items() if v is None and k.startswith("WORD.")]:
DROPPED[_name] = V3WORD
EXTRA = [
dict(label="U*.small", text="3 4 U* . . CR", out="0 12 \n"),
dict(label="U*.zero", text="0 5 U* . . CR", out="0 0 \n"),
dict(label="U*.full", text="-1 -1 U* 2+ . 1 = . CR", out="0 -1 \n"), # (2^N-1)^2: high cell 2^N-2, low cell 1
dict(label="U/MOD.small", text="7 0 2 U/MOD . . CR", out="3 1 \n"),
dict(label="U/MOD.exact", text="12 0 4 U/MOD . . CR", out="3 0 \n"),
dict(label="U/MOD.double", text="0 1 2 U/MOD 2* . . CR", out="0 0 \n"), # 2^N / 2: quotient 2^(N-1), remainder 0
dict(label="U/MOD.inverse", text="-1 -1 U* -1 U/MOD 1+ . . CR", out="0 0 \n"),
dict(label="U/MOD.carry", text="-1 -2 -1 U/MOD 1+ . 2+ . CR", out="0 0 \n"),
dict(label="U/MOD.by_zero", text="1 0 0 U/MOD", error=True),
dict(label="?.basic", text="VARIABLE Q1 42 Q1 ! Q1 ? CR", out="42 \n"),
dict(label="EXECUTE.found", text=": E1 7 ; FIND E1 EXECUTE . CR", out="7 \n"),
dict(label="U..small", text="42 U. CR", out="42 \n"),
dict(label="U..unsigned", text="-1 U. CR", out="18446744073709551615 \n"),
dict(label="CONVERT.digits", text=": C1 0 0 BL WORD CONVERT DROP ; C1 123 . . CR", out="0 123 \n"),
dict(label="'.found", text="' DUP 0= . CR", out="0 \n"),
dict(label='.".in_definition', text=': Q3 ." hi there" ; Q3 CR', out="hi there\n"),
dict(label="LITERAL.basic", text=": Q4 [ 5 ] LITERAL ; Q4 . CR", out="5 \n"),
dict(label="STATE.interpreting", text="STATE @ . CR", out="0 \n"),
dict(label="STATE.compiling", text=": Q5 STATE @ 0= . ; IMMEDIATE : Q6 Q5 ; CR", out="0 \n"),
dict(label="COMPILE.basic", text=": Q7 COMPILE DUP ; IMMEDIATE : Q8 Q7 ; 3 Q8 . . CR", out="3 3 \n"),
dict(label="[COMPILE].basic", text=": Q9 [COMPILE] IF ; IMMEDIATE : Q10 Q9 1 ELSE 2 THEN ; 0 Q10 . -1 Q10 . CR", out="2 1 \n"),
dict(label="(.comment", text="1 ( 2 ) 3 . . CR", out="3 1 \n"),
dict(label="BLK.terminal", text="BLK @ . CR", out="0 \n"),
dict(label="79-STANDARD.present", text="79-STANDARD"),
dict(label="WORD.next_word", text=": W1 BL WORD COUNT TYPE ; W1 HELLO", out="HELLO"),
dict(label="WORD.delimiter", text=": W2 44 WORD COUNT TYPE ; W2 A B,", out="A B"),
dict(label="WORD.count", text=": W3 BL WORD C@ . ; W3 ABC", out="3 "),
dict(label="WORD.skips_leading", text=": W4 BL WORD COUNT TYPE ; W4 X", out="X"),
]