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
@@ -130,6 +130,19 @@ void stadium_word_dispatch(VMUuid vm_id, uint32_t word_id, uint64_t heartbeat_ti
*/
void stadium_word_forget(VMUuid vm_id, uint32_t word_id);
/*
* stadium_words_charge - a v4 node's words, charged once a heartbeat tick (#189, plan
* 2026-11-13-engine-step4-phase2, Task 4; Captain Bob, 2026-11-13: "Per heartbeat tick"). For each of
* the `count` words, calls[k] calls of word ids[k] since the last charge are calls[k] of
* stadium_word_dispatch()'s dispatches at `heartbeat_ticks`: a resident word cools by the ticks since,
* once; a word not resident is admitted as Option B admits it; each call pulls
* STADIUM_WORD_HEAT_QUANTUM, never leaving the reservoir below a third. When the calls want more than
* that leaves, it is shared among the words in proportion to their calls -- not taken by whichever
* word is charged first (as stadium_ops_charge). Conservation holds.
*/
void stadium_words_charge(VMUuid vm_id, const uint32_t *ids, const uint64_t *calls, uint32_t count,
uint64_t heartbeat_ticks);
/*
* stadium_words_resident_heat - Sum of heat held by vm_id's own
* word-execution residents only (item 4.1's cells, tracked in this file's
+45
View File
@@ -72,6 +72,32 @@ static v4_fabric_node sk_v4_nucleus, sk_v4_hera;
static v4_image sk_v4_image;
static v4_cell sk_v4_keyed[SK_V4_PLACES]; /* by place: the number its Stadium quota is keyed by; 0: none */
/* The words' accounts (#189, Phase 2; v4/mesh.h): each node's records, kept by word ID, and the bit for each
* address that is a word's xt -- from the kernel's heap at the boot -- and a tick's charge gathered. */
static uint32_t sk_v4_charge_ids[V4_MESH_WORD_IDS];
static uint64_t sk_v4_charge_calls[V4_MESH_WORD_IDS];
static uint32_t sk_v4_charge_n;
static void sk_v4_charge_one(void *ctx, v4_cell id, v4_uheat_t calls)
{
(void)ctx;
if (sk_v4_charge_n < V4_MESH_WORD_IDS) {
sk_v4_charge_ids[sk_v4_charge_n] = (uint32_t)id;
sk_v4_charge_calls[sk_v4_charge_n] = (uint64_t)calls;
sk_v4_charge_n++;
}
}
/* a word has gone: its ID will be used again, so the Stadium forgets its patron first (v3's coherence) */
static void sk_v4_forgot(void *self, unsigned place, v4_cell id)
{
(void)self;
#ifdef STARFORTH_ENABLE_VM
if (place < SK_V4_PLACES && sk_v4_keyed[place] != 0)
stadium_word_forget(stadium_node_key((uint32_t)sk_v4_keyed[place]), (uint32_t)id);
#else
(void)place; (void)id;
#endif
}
static v4_fabric_node *sk_v4_room(void *self)
{
v4_fabric_node *x = (v4_fabric_node *)kmalloc(sizeof *x);
@@ -142,6 +168,11 @@ void sk_v4_charge_opcodes(void)
sk_v4_keyed[k] = number;
}
stadium_ops_charge(stadium_node_key((uint32_t)number), x->h.op, now);
/* and its words, by their calls since (#189, Phase 2: "Per heartbeat tick") */
sk_v4_charge_n = 0;
v4_mesh_charge(&sk_v4_mesh, k, sk_v4_charge_one, 0);
if (sk_v4_charge_n)
stadium_words_charge(stadium_node_key((uint32_t)number), sk_v4_charge_ids, sk_v4_charge_calls, sk_v4_charge_n, now);
}
#endif
}
@@ -264,6 +295,20 @@ void sk_v4_boot(void (*chain)(void), const BootDisk *boot_disk)
sk_v4_mesh.nucleus = &sk_v4_nucleus; sk_v4_mesh.hera = &sk_v4_hera;
sk_v4_mesh.room = sk_v4_room; sk_v4_mesh.unroom = sk_v4_unroom;
sk_v4_mesh.born = 0; sk_v4_mesh.gone = sk_v4_gone; sk_v4_mesh.parity = sk_v4_parity;
sk_v4_mesh.accounts = (v4_mesh_account *)kmalloc((size_t)SK_V4_PLACES * V4_MESH_WORD_IDS * sizeof(v4_mesh_account));
sk_v4_mesh.is_word = (unsigned char *)kmalloc((size_t)SK_V4_PLACES * (V4_NODE_WORDS / 8u));
if (!sk_v4_mesh.accounts || !sk_v4_mesh.is_word) { /* no records kept: the words are not charged */
if (sk_v4_mesh.accounts) kfree(sk_v4_mesh.accounts);
if (sk_v4_mesh.is_word) kfree(sk_v4_mesh.is_word);
sk_v4_mesh.accounts = 0; sk_v4_mesh.is_word = 0;
console_println("StarForth v4: no room for the words' accounts; words are not charged");
} else { /* none kept yet: all zero */
unsigned char *z = (unsigned char *)sk_v4_mesh.accounts;
size_t i, n = (size_t)SK_V4_PLACES * V4_MESH_WORD_IDS * sizeof(v4_mesh_account);
for (i = 0; i < n; i++) z[i] = 0;
for (i = 0; i < (size_t)SK_V4_PLACES * (V4_NODE_WORDS / 8u); i++) sk_v4_mesh.is_word[i] = 0;
}
sk_v4_mesh.forgot = sk_v4_forgot;
sk_v4_mesh.out = sk_v4_out; sk_v4_mesh.key = sk_v4_key;
sk_v4_mesh.words = sk_v4_words; sk_v4_mesh.word_count = sizeof sk_v4_words / sizeof sk_v4_words[0];
sk_v4_mesh.serve = sk_v4_serve;
+68
View File
@@ -263,6 +263,74 @@ void stadium_word_dispatch(VMUuid vm_id, uint32_t word_id, uint64_t heartbeat_ti
}
}
void stadium_words_charge(VMUuid vm_id, const uint32_t *ids, const uint64_t *calls, uint32_t count,
uint64_t heartbeat_ticks) {
int slot;
uint32_t k;
uint64_t total = 0, want, pullable, avail, floor = Q48_ONE / 3;
unsigned shift = 0;
const uint64_t q = (uint64_t)STADIUM_WORD_HEAT_QUANTUM;
if (!words_initialized || !ids || !calls) return;
slot = stadium_quota_slot_for_vm(vm_id);
if (slot < 0) return;
/* First: every resident word charged now cools by the ticks since its own last, once, as its first
* dispatch would; and the calls in all. */
for (k = 0; k < count; k++) {
size_t cell;
if (calls[k] == 0 || ids[k] == WORD_ID_INVALID || ids[k] >= DICTIONARY_SIZE) continue;
total = (total + calls[k] < total) ? UINT64_MAX : total + calls[k];
cell = resolve_resident_cell(slot, ids[k]);
if (cell != STADIUM_CELL_NONE) {
StadiumPatronHeader *h = &stadium_cells()[cell].header;
uint64_t elapsed = heartbeat_ticks - word_slots[slot][ids[k]].last_decay_tick;
if (elapsed > 0) {
uint64_t cooled = ((h->heat * (uint64_t)STADIUM_WORD_COOL_RATE_Q48) >> 16) * elapsed;
if (cooled > h->heat) cooled = h->heat;
if (cooled > 0) { h->heat -= cooled; stadium_reservoir_push(vm_id, cooled); }
word_slots[slot][ids[k]].last_decay_tick = heartbeat_ticks;
}
}
}
if (total == 0) return;
/* Then: a quantum a call, or, when they want more than the floor leaves, a share by calls. */
avail = stadium_reservoir_peek(vm_id);
pullable = avail > floor ? avail - floor : 0;
want = total > UINT64_MAX / q ? UINT64_MAX : total * q;
while ((total >> shift) >= ((uint64_t)1 << 32)) shift++; /* pullable * share fits 64 bits */
for (k = 0; k < count; k++) {
uint64_t give;
size_t cell;
if (calls[k] == 0 || ids[k] == WORD_ID_INVALID || ids[k] >= DICTIONARY_SIZE) continue;
give = want <= pullable ? calls[k] * q
: (total >> shift) ? pullable * (calls[k] >> shift) / (total >> shift) : 0;
give = word_dispatch_pull(vm_id, give);
cell = resolve_resident_cell(slot, ids[k]);
if (cell != STADIUM_CELL_NONE) { stadium_cells()[cell].header.heat += give; continue; }
{
StadiumPatronHeader candidate;
uint8_t *raw = (uint8_t *)&candidate;
size_t i, idx;
for (i = 0; i < sizeof(candidate); i++) raw[i] = 0;
candidate.identity = (uint64_t)ids[k];
candidate.heat = give;
candidate.ttl = 0;
candidate.link = 0;
candidate.contains = STADIUM_CONTAINS_NONE;
candidate.mass = 1;
candidate.flags = 0;
candidate.behaviour = (uint8_t)STADIUM_BEHAVIOUR_COOL;
idx = stadium_admit(vm_id, &candidate);
if (idx == STADIUM_CELL_NONE) { stadium_reservoir_push(vm_id, give); continue; }
word_slots[slot][ids[k]].cell_index = idx;
word_slots[slot][ids[k]].last_decay_tick = heartbeat_ticks;
stat_promotions[slot]++;
}
}
}
void stadium_word_forget(VMUuid vm_id, uint32_t word_id) {
int slot;
size_t cell;
+3 -1
View File
@@ -19,9 +19,11 @@ CC ?= cc
CFLAGS := -std=c99 -Wall -Wextra -Werror -O2 -g -I$(HERE) -I$(ROOT)/kernel/include \
-I$(ROOT)/kernel/include/starkernel -I$(ROOT)/v3/include
TESTS := test_gpt test_usb_config test_sk_v4_stop test_sk_v4_slots test_sk_v4_mint test_sk_v4_identity test_heartbeat_export test_ssm_jacquard test_physics_l2 test_stadium_node test_stadium_ops
TESTS := test_gpt test_usb_config test_sk_v4_stop test_sk_v4_slots test_sk_v4_mint test_sk_v4_identity test_heartbeat_export test_ssm_jacquard test_physics_l2 test_stadium_node test_stadium_ops test_stadium_words
SRC_test_stadium_ops := $(ROOT)/kernel/src/vm/stadium_ops.c $(ROOT)/kernel/src/vm/stadium.c $(ROOT)/kernel/src/vm/session.c $(ROOT)/kernel/src/capsule/vm_uuid.c
EXTRA_test_stadium_ops := -D__STARKERNEL__
SRC_test_stadium_words := $(ROOT)/kernel/src/vm/stadium_words.c $(ROOT)/kernel/src/vm/stadium.c $(ROOT)/kernel/src/vm/session.c $(ROOT)/kernel/src/capsule/vm_uuid.c
EXTRA_test_stadium_words := -D__STARKERNEL__
SRC_test_stadium_node := $(ROOT)/kernel/src/vm/stadium.c $(ROOT)/kernel/src/vm/session.c $(ROOT)/kernel/src/capsule/vm_uuid.c
EXTRA_test_stadium_node := -D__STARKERNEL__
SRC_test_physics_l2 := $(ROOT)/v3/src/physics_execution_hooks.c
+90
View File
@@ -0,0 +1,90 @@
/* SPDX-License-Identifier: LicenseRef-Starship-1.0
* Copyright (c) 2023-2026 Robert A. James
*/
/* test_stadium_words.c -- stadium_words_charge on the host (#189, plan 2026-11-13-engine-step4-phase2,
* Task 4): a v4 node's words charged to the Stadium once a heartbeat tick (Captain Bob, 2026-11-13: "Per
* heartbeat tick"). N calls of a word are N of stadium_word_dispatch's dispatches -- the same quantum,
* the same floor left in the reservoir, the same cooling by the ticks since -- and when the words
* called want more than there is, what there is is shared by their calls, not taken by whichever is
* charged first. Conservation holds throughout.
*/
#include "harness.h"
#include "starkernel/vm/stadium.h"
#include "starkernel/vm/stadium_words.h"
#include "starkernel/session.h"
#include "starkernel/vm_uuid.h"
#include "starkernel/kmalloc.h"
#include "starkernel/q48_16.h"
#include "starforth_config.h"
#include <stdarg.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
void *kmalloc(size_t size) { return calloc(1, size); }
void kfree(void *ptr) { free(ptr); }
kmalloc_stats_t kmalloc_get_stats(void)
{
kmalloc_stats_t s;
memset(&s, 0, sizeof s);
s.free_bytes = 512u * 1024u * 1024u;
return s;
}
void log_message(int level, const char *fmt, ...) { (void)level; (void)fmt; }
void sk_hal_panic(const char *message) { printf("PANIC: %s\n", message); exit(2); }
int blk_flush(uint32_t block_num) { (void)block_num; return 0; }
int main(void)
{
VMUuid a = stadium_node_key(10);
uint64_t q = (uint64_t)STADIUM_WORD_HEAT_QUANTUM, r0, pullable;
uint32_t ids[3];
uint64_t calls[3];
CHECK(stadium_boot_init_for(a) == 0 && session_boot_init() == 0 && stadium_birth_node(a, "Hera") == 0,
"a Stadium with the node born in it");
stadium_words_init();
/* the same as that many dispatches: word 7 charged five calls, word 8 dispatched five times */
ids[0] = 7; calls[0] = 5;
r0 = stadium_reservoir_peek(a);
stadium_words_charge(a, ids, calls, 1, 2);
CHECK(stadium_reservoir_peek(a) == r0 - 5u * q && stadium_words_resident_heat(a) == 5u * q,
"five calls of word 7 pull five quanta, and it holds them: %llu", (unsigned long long)(r0 - stadium_reservoir_peek(a)));
{
unsigned k;
r0 = stadium_reservoir_peek(a);
for (k = 0; k < 5; k++) stadium_word_dispatch(a, 8, 2);
CHECK(stadium_reservoir_peek(a) == r0 - 5u * q && stadium_words_resident_heat(a) == 10u * q,
"the same as five dispatches of a word");
}
CHECK(stadium_conserved(a), "conservation holds");
/* a resident word cools by the ticks since, once, then takes its calls: as its dispatches would */
{
uint64_t before = stadium_words_resident_heat(a);
ids[0] = 7; calls[0] = 2;
stadium_words_charge(a, ids, calls, 1, 12);
ids[0] = 8; calls[0] = 1;
stadium_word_dispatch(a, 8, 12); stadium_word_dispatch(a, 8, 12);
CHECK(stadium_words_resident_heat(a) < before + 4u * q, "ten ticks on, both cooled before they took their calls");
CHECK(stadium_conserved(a), "conservation holds");
}
/* no calls, nothing */
ids[0] = 9; calls[0] = 0;
r0 = stadium_reservoir_peek(a);
stadium_words_charge(a, ids, calls, 1, 3);
CHECK(stadium_reservoir_peek(a) == r0, "a word with no calls is not charged");
/* more wanted than there is: shared by calls, the floor left */
pullable = stadium_reservoir_peek(a) - Q48_ONE / 3;
ids[0] = 20; calls[0] = 3 * (pullable / q);
ids[1] = 21; calls[1] = 1 * (pullable / q);
stadium_words_charge(a, ids, calls, 2, 4);
CHECK(stadium_reservoir_peek(a) >= Q48_ONE / 3 && stadium_reservoir_peek(a) - Q48_ONE / 3 < 4u,
"the reservoir is left at its third");
CHECK(stadium_conserved(a), "conservation holds when the calls want more than there is");
return DONE();
}