feat(v4.0.0): hosted golden model, single node

First code for StarForth v4 (JUSTIFICATION.md section 10, step 1): one node
of the 32-instruction core as a C99 model, with cell width as a build
parameter.

- Node: P, A, B, F18 circular stacks (10 and 9 deep, D-2), word-addressed
  memory (D-1), 5% guard bands on every bounded list.
- Instruction word: six 5-bit slots in 32 bits at every cell width.
- Executor: all 32 opcodes of DECOMPOSITION.md 1.3. Cell arithmetic wraps
  explicitly; no signed overflow or implementation-defined shift.
- Heat: per-opcode and per-call-target counters and the anti-clock, driven
  by instruction retirement (1.4, D-6 interim).
- Slot packer and runner for tests, and a reference unsigned multiply in
  plain C99 with no 128-bit type.

Tests run at 32- and 64-bit cells, and under ASan and UBSan. They cover
every opcode and execute the first section 4 definitions (NIP SWAP OR
NEGATE ROT 0< 0= 2DUP - U<) against the C operation each stands for.

UM* as written in section 4 is exact only while u1 <= 2^(n-2). Two known
failing cases are pinned in test_foundation.c until it is rewritten.

DECOMPOSITION.md: record D-9, the instruction word is 32 bits at every
cell width (ruled 2026-10-02).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rajames
2026-10-02 14:38:27 -04:00
co-authored by Claude Opus 5.5
parent 1f98034311
commit 067f317c47
33 changed files with 3635 additions and 3 deletions
+114
View File
@@ -0,0 +1,114 @@
/* node.h -- the v4 node: registers, stacks, and word-addressed memory.
*
* DECOMPOSITION.md section 1.1:
*
* | Cell | 32 bits (mesh node). Host node's width is a build
* | parameter; see D-5. |
* | Addressing | Word-addressed (D-1). |
* | Registers | T (top of data stack), S (second), R (top of return
* | stack), P (program counter), A and B (address
* | registers). |
* | Stacks | F18 circular hardware stacks, not visible to code
* | (D-2) ... data stack 10 deep, return stack 9 deep. |
* | Instruction | Six 5-bit slots, plus 2 spare bits. |
*
* T, S and R are not fields here: they live inside v4_dstack and v4_rstack,
* because on the F18 they are the topmost slots of the circular stacks rather
* than registers that happen to shadow them, and the golden model has to model
* the mechanism and not just the values. P, A and B are genuinely separate
* registers and live here.
*
* MEMORY SIZE IS A BUILD PARAMETER, not a constant of the design.
* JUSTIFICATION.md section 10 names cell width, node count and node memory as
* the three parameters of the golden model, and no word count is fixed anywhere
* in DECOMPOSITION.md -- D-4, which is where a node memory map would be pinned
* down, is deferred to step 2. The default here is a round number that suits
* a host; the datapoint worth knowing is JUSTIFICATION.md section 4, that a
* GA144 node had 64 words of RAM and 64 of ROM. v4 keeps the same split --
* dictionary and interpreter live in the compiler capsule on the host and are
* streamed to nodes, so node memory is not sized by capsule size.
*/
#ifndef V4_NODE_H
#define V4_NODE_H
#include "v4/cell.h"
#include "v4/guard.h"
#include "v4/stack.h"
/* Words of node memory. Overridable at build time. */
#ifndef V4_NODE_WORDS
#define V4_NODE_WORDS 1024u
#endif
/* 5% safety boundary at each end of node memory, per guard.h. Note the
* arithmetic: at 1024 words that is 52 words either side, 5.2% of memory
* spent on a boundary. That is the cost of the convention on a resource that
* is scarce by construction, and it is worth naming rather than discovering
* later -- on a real mesh node the memory is 32-bit words on silicon with no
* second chance. A deployment that cannot afford it sets V4_GUARD_PCT to 0 at
* build time, which is why the percentage is a macro and not baked in. */
#define V4_MEM_BOUND V4_GUARD_ELEMS(V4_NODE_WORDS)
typedef struct {
v4_cell p; /* P -- program counter */
v4_cell a; /* A -- address register */
v4_cell b; /* B -- address register */
v4_dstack ds; /* holds T and S */
v4_rstack rs; /* holds R */
v4_cell mem_guard_head[V4_MEM_BOUND];
v4_cell mem[V4_NODE_WORDS];
v4_cell mem_guard_tail[V4_MEM_BOUND];
} v4_node;
/* Zero P, A and B, empty the stacks, and clear memory. Installs every
* boundary (memory and both stacks) and leaves the node in a defined state. */
void v4_node_reset(v4_node *n);
/* 1 if every boundary in the node is intact -- memory and both stacks -- else
* 0. This is the single call a caller needs to detect that an index went
* wrong somewhere; see guard.h for what it catches that AddressSanitizer
* cannot. */
int v4_node_guards_intact(const v4_node *n);
/* Read and write one word. D-1: word-addressed, so `addr` counts words and
* CELLS is a no-op. `addr` is a v4_cell because A, B and P are all v4_cells
* and a narrower parameter would put a conversion between the register and the
* access on every one of the eight memory opcodes.
*
* PRECONDITION: 0 <= addr < V4_NODE_WORDS.
*
* Out-of-range addressing is not defined by DECOMPOSITION.md. D-1 fixes word
* addressing and says nothing about an address outside the node's memory; a
* F18 has no such case because its address space is the memory. Whether a
* conforming model should wrap, saturate, or fault is a real open question and
* is raised as a gap rather than decided here, because each answer changes the
* ISA's observable behaviour and inventing one silently would bake a guess into
* every later test.
*
* WHAT THE BOUNDARY HERE DOES AND DOES NOT COVER, since it is easy to
* overclaim and the distinction decides whether out-of-range addresses are
* actually protected:
*
* - It DOES cover a linear index error. An access to mem[-1] or
* mem[V4_NODE_WORDS] lands in the 5% band, because the band is immediately
* adjacent (tests/test_node.c asserts the adjacency with offsetof rather
* than assuming it). That access is inside the struct, so AddressSanitizer
* stays silent about it and v4_node_guards_intact() is what reports it.
*
* - It does NOT cover an out-of-range *word address*. A v4_cell address of
* -1 casts to unsigned 0xFFFFFFFF, which is 16 GB past mem at 32-bit cells
* -- far outside the struct and outside the band. So a P, A or B that runs
* off the end of memory is caught by neither the band nor, in a production
* build without sanitizers, by anything else here. It is a precondition
* violation and the range check that would catch it is the open question
* above, not the band.
*
* The band is worth its 10.2% for the first case regardless. The second case
* is a hole, and it is closed by ruling on out-of-range addressing, not by
* widening the band. */
v4_cell v4_node_load(const v4_node *n, v4_cell addr);
void v4_node_store(v4_node *n, v4_cell addr, v4_cell value);
#endif /* V4_NODE_H */