feat(v4): word accounts at call -- the word ID in the header, the kernel's records, the Stadium charged per tick (#189)

ENGINE step 4 Phase 2 (plan docs/v4.0.0/plans/2026-11-13-engine-step4-phase2-word-accounts.md). Rulings,
Captain Bob 2026-11-13: "ID field in the header"; "Per heartbeat tick"; the ACL "All of it in Phase 4".

- The word ID, at xt-4 before the flags (ENGINE.md 3b). The system's words are numbered as they are
  assembled, by a new directive, #id, (and #ids, the count), in the C assembler (text.c) and in the
  machine ROM's (capsules/v4/rom), which counts in the system's own (NEXT-ID). (HEADER) numbers later
  words; FORGET gives back the IDs it frees, always the newest; COLD resets to (BOOT-ID); (SEAL) records
  it. All 313 system headers carry the cell.
- The kernel's records (v4/mesh.h): (TELL-DEFINED) and (TELL-FORGOTTEN), requests -19/-20 on port 0,
  which the kernel puts in (WORD-DEFINED)/(WORD-FORGOTTEN) when it seals a node, and reads the system's
  own words once. A bit per address marks a word's xt (calls into the ROM or headerless code are not
  words); at call, the bit test and a count. v4_mesh_charge hands the owner the calls since.
- The Stadium: stadium_words_charge -- N calls as N of stadium_word_dispatch's dispatches, shared by calls
  when the reservoir is short; sk_v4.c charges every node's words at the tick beside its opcodes, and
  stadium_word_forget before an ID is used again.
- Tests first: test_text.c, test_host_dict.c, test_host_quit.c (numbering, FORGET, COLD),
  test_mesh_boot.c (44 checks), kernel/tests/test_stadium_words.c; qemu_session.py: Hera's words
  admitted after POST.
- amd64: Stadium words: promotions=141 after POST, CONSERVED; POST 539/539; word_count=377;
  dict_hash 0xa854ba15b437d288 (the new cell); the boot 142 s against Phase 1's 153 s.
- v4 test, test-32: all pass; v4-checks: 80 programs under the sanitizers, 0 failures; the hosted
  builds alike. docs/reference/v4-dictionary.tsv: the two new words; FABRIC-3 XLVIII.24.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RzZgrf7V7Zk2iczBXDRbm
This commit is contained in:
Claude
2026-11-13 19:12:44 +00:00
parent d64ad4a1c3
commit e8e7ec8b9f
44 changed files with 1060 additions and 349 deletions
@@ -0,0 +1,137 @@
# ENGINE Step 4, Phase 2: word accounts at `call` -- Plan
> **For agentic workers:** steps use checkbox (`- [ ]`) syntax. One QEMU at a time, in the foreground,
> every boot under `timeout 570` (`.claude/CLAUDE.md`, `.claude/memory/rules.md`); every log and CSV
> is committed. Test first: each change starts with a failing test.
**Goal (#189):** the kernel keeps a record for each word on each node, joined to the word by the word
ID in its header (`ENGINE.md` 3b, ruled 2026-10-05: "A is good"). Each `call` to a word counts on its
record (`execution_heat`), and the Stadium's word layer is fed from the records, so that the Stadium
holds a node's words as it holds v3's (`V3-PARITY.md` §1 row 3, in part).
**Parent plan:** `2026-10-20-engine-step4-node-in-the-vms-place.md`, Phase 2; after Phase 1
(`2026-11-13-engine-step4-phase1-v4-in-the-kernels-boot.md`, accepted 2026-11-13, protocol PASS on
046ce0be). Q4 (RULED 2026-10-20, as recommended): the kernel keeps, per node and word ID, a record with
`DictEntry`'s account fields; `stadium_word_dispatch` takes a VM ID and a word ID.
## Rulings (Captain Bob, 2026-11-13)
1. **The word ID is a field in the header** -- not a kernel table keyed by address, and not every call
target. (Matches 3b: "its word ID in its header".)
2. **Charged per heartbeat tick**, as the opcodes are (Phase 1, Q2): a call only counts; once a tick
the words called since are charged to the Stadium.
3. **The ACL is all Phase 4**: no ACL fields or countdown in Phase 2.
## The design
**An entry gains one cell, its word ID, before its flags.** Today (`dict.f18`):
name | xt-3 flags | xt-2 the name's address | xt-1 the link | xt the code
becomes
name | xt-4 word ID | xt-3 flags | xt-2 the name's address | xt-1 the link | xt the code
Every offset from the xt stays as it is; only the name moves one cell further from the code (`NAME>`,
`TRAVERSE`, `(NEED)`: 12 bytes become 16).
**Who numbers the words.**
- The system's words are numbered by the assembler, in the order they are assembled, from 0, with a
new directive `#id,`: it lays down the next number and counts on. The 313 assembled headers
(`... #, 0 #, HDR-NFA #, HDR-LINK ...`) gain `#id,` before their flags. The count is stored in
a new variable, `(NEXT-ID)`.
- A word defined later is numbered by `(HEADER)`, from `(NEXT-ID)`.
- FORGET takes away the newest entries, so the IDs it frees are the highest: it sets `(NEXT-ID)`
back to the first gone entry's ID, and they are used again, as v3 recycles its word IDs. COLD sets
it back to the system's count.
- The same numbering on every node and at either cell width; no kernel needed (the hosted product
numbers alike). `dict_hash` changes once, for the new cell; `word_count` does not.
**How the kernel knows a called address is a word.** Many calls are to ROM routines and headerless
code (189 of the system's definitions have no header), whose cell at xt-4 is not an ID. So the kernel
keeps, per node, one bit for each address of its memory: set for a word's xt. Its records are kept by
word ID. Both come from what the node tells it (3b, already in the system and unwired):
`(WORD-DEFINED)` (`dict.f18`), at `(HEADER)`, gives the xt; `(WORD-FORGOTTEN)`, at FORGET and COLD,
gives the word address from which entries went. The kernel attaches them with requests on port 0
(`kernel_take`). The system's own words, already in a node when it is sealed, are read once by
walking the dictionary from `LATEST`.
**At `call`** (Part A's hook, `v4_exec_state.on_call`): if the target's bit is set, the record of the
ID at target-4 counts one: `execution_heat`, and its calls since the last charge. A bit test and an
increment: nothing else at `call`.
**At the heartbeat tick** (where the opcodes are charged, `sk_v4_charge_opcodes`): for each node, the
words called since are charged to the Stadium under the node's key -- new
`stadium_words_charge(vm, word_id, calls, tick)`, which is `stadium_word_dispatch` for that many calls
at once: cooling by the ticks since, a quantum a call, the reservoir's third left, and when the calls
want more than there is, a share in proportion to the calls (as `stadium_ops_charge`, #189).
**When words go:** FORGET and COLD drop the records and bits of the entries gone, and
`stadium_word_forget` each (v3's coherence on recycling). A kill already evicts every resident of the
node's quota (`stadium_return_quota`); the records go with the node.
## Tasks
### Task 1: `#id,` in the assembler
- [x] Test first (`v4/tests/test_text.c`): `#id,` lays down 0, 1, 2 in order; a symbol gives the count.
- [x] `v4/src/text.c`: the directive and the counter (reset per text).
### Task 2: the header's word-ID cell
- [x] Tests first: the host dictionary tests (`test_host_dict.c`): a word's ID at xt-4; a word defined
at the prompt gets the next ID; FORGET gives its IDs back; COLD resets; `NAME>` and `TRAVERSE`
across the new cell.
- [x] `capsules/v4/sys/*.f18`: `#id,` in the 313 headers; `dict.f18`: `(NEED)`, `(HEADER)`, `NAME>`,
`TRAVERSE`, `(NEXT-ID)`; `system.f18`: FORGET and COLD; `map-32.f18`/`map-64.f18` and `mkmap.c`:
`(NEXT-ID)`'s address.
- [x] `make -C v4 test test-32`; the POST expectations and the hosted products updated for the new
`dict_hash` (word_count 375 unchanged).
### Task 3: the kernel's records
- [x] Test first (`v4/tests/test_mesh_boot.c`, and a kernel host test): after the boot every system
word has a record and a bit; `: SQ DUP * ;` makes one more; `3 SQ` counts SQ once; FORGET SQ
drops it; a call to a headerless routine counts nothing.
- [x] `v4/system/mesh.c`: the requests behind `(WORD-DEFINED)`/`(WORD-FORGOTTEN)`, the walk at seal,
the bits and records per place, the hook at `call`. Records sized by `(NEXT-ID)`, in the owner's
memory.
### Task 4: the Stadium charge per tick
- [x] Test first (`kernel/tests/test_stadium_words.c`): `stadium_words_charge` for N calls equals N
dispatches when there is enough; shares by calls when there is not; conservation.
- [x] `kernel/src/vm/stadium_words.c`: `stadium_words_charge`; `kernel/src/v4/sk_v4.c`: charge every
node's words at the tick, beside its opcodes; `stadium_word_forget` on FORGET.
- [x] `scripts/qemu_session.py`: after POST, "Stadium words: N resident" with N > 0, and conservation
CONSERVED; a word defined at the prompt and called becomes resident.
### Task 5: acceptance
- [x] The boot's time before and after (Review focus 3), in the commit message.
- [ ] The full protocol; `docs/v4.0.0/ENGINE.md`, this plan, `FABRIC-3.md` XLVIII.24.
## As built (2026-11-13)
- Tasks 1-4 done, test first: `test_text.c` (`#id,`, `#ids`, the header's ID), `test_host_dict.c` and
`test_host_quit.c` (the layout, numbering, FORGET giving IDs back, COLD), `test_mesh_boot.c` (44
checks: records, calls at the prompt, in definitions and through `EXECUTE`, the charge, FORGET,
every word on Hera kept, a born node's own), `kernel/tests/test_stadium_words.c` (the charge equal
to that many dispatches; cooling; the floor; conservation).
- The ROM's assembler counts in the system's `(NEXT-ID)`, zero when the machine starts, so no ROM
variable was added.
- The telling words are `(TELL-DEFINED)` and `(TELL-FORGOTTEN)`, requests −19 and −20; the kernel
puts them in the hook variables when it seals a node, so the hosted product, which keeps no
records, never tells.
- amd64: `Stadium words: promotions=141` after POST, CONSERVED; `word_count=377`; the boot 142 s
(Phase 1: 153 s).
## Review focus
1. **A headerless call is not a word:** only an xt the node told of has its bit.
2. **Recycling:** an ID freed by FORGET and used again starts from nothing in the Stadium
(`stadium_word_forget` before the reuse).
3. **Cost at `call`:** a bit test and an increment; the boot's time under TCG recorded before and after.
4. **The 32-bit build:** the same IDs and the same counts at 32-bit cells.
5. **The Stadium's bound:** its word layer holds word IDs below `DICTIONARY_SIZE` (1024); the system
has 375 words. An ID above it is counted on its record but not admitted; reported if reached.
## What this phase does not do
The ACL (Phase 4, ruled). Decay, the rolling window, pipelining, inference, L8 on the records
(Phase 7). The transition metrics (Phase 7, with the loops that read them).