feat(v4): a hook at call, for whoever runs the node

Plan 2026-10-20 (ENGINE step 4), Task A1, for #189 -- Captain Bob,
2026-11-12: the physics blocks alpha.5; order A, 1, 2, then 7.

- exec.h/exec.c: v4_call_hook, es->on_call/on_call_ctx; called at `call`
  once the return address is pushed, before P moves. NULL: as before.
- heat.h/heat.c: the per-call-target table (4096 slots, no capsule word
  counted), its freeze mask and v4_heat_call_* go (ENGINE.md step 3: "The
  per-call-target count goes"); the kernel will keep a word's accounts.
- tests: test_call_hook_sees_every_call, seen to fail first (no on_call);
  test_heat's per-call tests go with the table.
- the plan records today's rulings (order; HEAT-OP: both, a full array).

Suite (logs/20261112-225951/v4-tests): test, test-32, sanitize, 0
failures; hosted-check, the three alike. amd64 boot: POST 538/538,
dict_hash 0x8c92508f748f99cf, unchanged -- nothing sets the hook yet.

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-12 23:01:52 +00:00
parent f38761ff66
commit 3c97534c8b
14 changed files with 110 additions and 169 deletions
@@ -55,6 +55,13 @@ minted, and the boot recognises it. Today it does not: the drive QEMU attaches b
`disk/thumbdrives/zuse-thumb-ident.img`, is all zeros (bleached, `e56974e0`), and the v4 boot reads
no thumbdrive and prints its prompt with no identity. See "The identity gate" below.
**RULED 2026-11-12 (Captain Bob), #189** -- v4 has no Compudynamics beyond its heat counters, and it
"blocks alpha.5": the physics comes first. **Order: Part A, Phase 1, Phase 2, then Phase 7**; Phases
3 to 5 follow after alpha.5. And **HEAT-OP**: "Add HEAT-OP. If it's a huge performance hit, we'll
revert it" -- both: `HEAT-OP[0..31]` and `ANTICLOCK` readable as registers now, a full 33-cell array
(`DECOMPOSITION.md` §7), and fed to the Stadium in Phase 1, measured, kept or reverted on the
numbers.
**Q1. What "Hera, Hermes, Artemis are kernel" means in v4.**
*Recommend:* each is a function of the kernel, not a VM or node with a capsule of its own:
- **Hera** — process management: the registry, births, `NODE-KILL`/`SLEEP`/`WAKE` and who may ask
@@ -160,11 +167,11 @@ acceptance. Only Part A is written in tasks below; it does not depend on Q1 to Q
**Interfaces:** Produces `typedef void (*v4_call_hook)(void *ctx, v4_node *n, v4_cell target);` and, in `v4_exec_state`, `v4_call_hook on_call; void *on_call_ctx;`. Called at `V4_OP_CALL` after the return address is pushed, before `n->p` moves. NULL = no hook.
- [ ] **Step 1: Failing test** `test_call_hook_sees_every_call`: a programme calling three routines, one twice; the hook records the four targets in order; with `on_call = NULL` the result is the same.
- [ ] **Step 2:** `make -C v4 test` — FAIL (no `on_call`).
- [ ] **Step 3:** Add the hook; remove `v4_heat_on_call`, `call[]`, the freeze mask and `v4_heat_call_*`, and their tests.
- [ ] **Step 4:** Suite passes at both widths and under sanitizers; `hosted-check` unchanged.
- [ ] **Step 5:** Commit `feat(v4): a hook at call, for whoever runs the node`. (No boot change: nothing sets the hook on bare metal yet. One amd64 boot to confirm POST 538/538 and the same `dict_hash`.)
- [x] **Step 1: Failing test** `test_call_hook_sees_every_call`: a programme calling three routines, one twice; the hook records the four targets in order; with `on_call = NULL` the result is the same.
- [x] **Step 2:** `make -C v4 test` — FAIL (no `on_call`).
- [x] **Step 3:** Add the hook; remove `v4_heat_on_call`, `call[]`, the freeze mask and `v4_heat_call_*`, and their tests.
- [x] **Step 4:** Suite passes at both widths and under sanitizers; `hosted-check` unchanged.
- [x] **Step 5:** Commit `feat(v4): a hook at call, for whoever runs the node`. (No boot change: nothing sets the hook on bare metal yet. One amd64 boot to confirm POST 538/538 and the same `dict_hash`.)
### Task A2: Words that are checked are called
Binary file not shown.
+5
View File
@@ -0,0 +1,5 @@
# Task A1's v4 suite, 2026-11-12 (#189)
test_exec-before.log: test_exec.c against the engine before the hook: does not compile (no on_call).
suite-test-test32-sanitize.log: make -C v4 test test-32 sanitize hosted-check; stopped by the session's 30-minute background limit inside san-64-test_host_depth.c, every program before it 0 failures.
suite-sanitize-rest.log: the sanitizer runs from san-64-test_host_depth.c on (39 programs, 810885 checks, 0 failures); its hosted-check stopped at qemu-aarch64 not installed.
hosted-check.log: hosted-check rerun with qemu-user installed: the three boot identically, depthsweep 230 runs 0 failures.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+10 -1
View File
@@ -11,7 +11,10 @@
*
* Section 1.4: "Every retired instruction increments that opcode's heat counter
* and advances the anti-clock; every `call` increments the heat counter of its
* target. None of this costs an instruction."
* target. None of this costs an instruction." A call's target is counted by
* whoever runs the node, through the hook below (ENGINE.md step 3; plan
* 2026-10-20, Task A1, #189): the kernel keeps a word's accounts. The engine's
* own per-call-target table, a stand-in, is gone.
*
* The node owns P, A, B, the stacks and memory; heat and the anti-clock are
* separate physics state (section 7 names them as registers).
@@ -37,8 +40,14 @@
#include "v4/iword.h"
#include "v4/node.h"
/* Called at `call` once the return address is pushed and before P moves to the
* target (so P is still the return address). NULL: no hook. */
typedef void (*v4_call_hook)(void *ctx, v4_node *n, v4_cell target);
typedef struct {
v4_uheat_t anticlock;
v4_call_hook on_call; /* reset leaves NULL */
void *on_call_ctx;
/* A node blocked at its port in the middle of an instruction word
* (node.h) goes on, when it has been served, from the slot after the
+9 -45
View File
@@ -8,27 +8,21 @@
* the anti-clock; every `call` increments the heat counter of its target. None
* of this costs an instruction."
*
* D-6 is deferred to step 2, but the note immediately adds: "The golden model
* implements per-opcode and per-call-target heat in the meantime." The
* counters here are therefore a two-dimensional heat structure (op × target)
* on a per-call basis, with counts stored in 64-bit (because heat is
* accumulated and must not roll over at 32-bit). The "per-call-target"
* counter is indexed by the address of the called target -- on a word-addressed
* node, that is a cell value equal to the address `a` passed to `call a`.
* The per-opcode counters are here. A call's target is counted by whoever runs
* the node, through exec.h's hook at `call` (ENGINE.md step 3: "The
* per-call-target count goes"; plan 2026-10-20, Task A1, #189) -- the kernel
* keeps a word's accounts. The 4096-slot per-call-target table and its freeze
* mask, the golden model's stand-in "in the meantime" (D-6), were here until
* 2026-11-12; a target at 4096 or above was not counted, so no capsule word was.
*
* The anti-clock is a virtual tick driven by execution -- one tick per retired
* instruction -- and the rest of §7 refers to it via `ANTICLOCK`. It is not a
* wall-clock time, it is a function of the execution stream, as specified.
*
* The MM names in §7: `HEAT-OP[0..31]` (R), `HEAT-CALL[...]` (R/W) with a
* freeze bit (D-6). D-4 (memory map) is deferred, so the model does not
* materialize those MM addresses yet. This header defines the backing storage
* that a later MM layer will expose; it keeps the counts separate from the node
* itself because heat is physics data, not the instruction word stream, and it
* needs to be accessible to assertions such as K conservation tests later.
* The MM names in §7: `HEAT-OP[0..31]` (R) are these counters; `HEAT-CALL[...]`
* is the kernel's word accounts (above). D-4 (memory map) is deferred.
*
* Counts are 64-bit. Even for a short run a call-heavy word can heat up many
* times, and the golden model must run POST and remain monotonic and
* Counts are 64-bit. Even for a short run an opcode can retire many times, and the golden model must run POST and remain monotonic and
* comparable. They are stored as `v4_uheat_t` (which is uint64_t by cell-width
* parameterisation) rather than cell-sized so that a 32-bit node's heat is not
* width-truncated in the model. (JUSTIFICATION.md §5 keeps heat arithmetic
@@ -40,20 +34,8 @@
#include "v4/cell.h"
#include "v4/opcode.h"
#define V4_HEAT_MAX_CALL_TARGETS 4096u
typedef struct {
v4_uheat_t op[V4_OPCODE_COUNT]; /* HEAT-OP[0..31] */
/* Per-call-target heat. Indexed by target address. The table is fixed-size
* for the golden model; the actual hardware table size is deferred with D-6,
* but "in the meantime" the model must have a per-call-target structure.
* 4096 entries is small (32KB if using 64-bit per entry), enough to cover a
* typical test program's call targets without needing to allocate on-the-fly
* -- and since the node has boundaries, over-indexing is caught. */
v4_uheat_t call[V4_HEAT_MAX_CALL_TARGETS];
v4_ucell call_freeze_mask[V4_HEAT_MAX_CALL_TARGETS / (sizeof(v4_ucell) * 8u)];
/* freeze bits: one per call-target entry (D-6). Stored as a bitmask. */
} v4_heat;
void v4_heat_reset(v4_heat *h);
@@ -61,22 +43,4 @@ void v4_heat_reset(v4_heat *h);
/* Every retired instruction: +1 to the opcode's heat and +1 to ANTICLOCK. */
void v4_heat_on_retire(v4_heat *h, unsigned op, v4_uheat_t *anticlock);
/* Every call: +1 to the call-target's heat. The target is the address passed
* to call (word address). If the target is outside the table range, the model
* must not silently wrap -- a boundary-style check is the conservative choice
* here, because the table size is a model parameter. */
void v4_heat_on_call(v4_heat *h, v4_cell target);
/* Read/write helpers for per-call-target heat (R/W). Freeze bits are set per
* target when D-6's freeze bit is asserted; writes to a frozen target should be
* ignored in a real implementation, but the MM layer is not defined yet. The
* golden model stores freeze bits and the accessor can enforce them later. */
v4_uheat_t v4_heat_call_get(const v4_heat *h, v4_cell target);
int v4_heat_call_set(v4_heat *h, v4_cell target, v4_uheat_t val);
/* Freeze control (D-6). */
void v4_heat_call_freeze(v4_heat *h, v4_cell target);
void v4_heat_call_unfreeze(v4_heat *h, v4_cell target);
int v4_heat_call_is_frozen(const v4_heat *h, v4_cell target);
#endif /* V4_HEAT_H */
+3 -1
View File
@@ -98,6 +98,8 @@ static int stack_faulted(v4_node *n, unsigned op)
void v4_exec_reset(v4_exec_state *es)
{
es->anticlock = 0;
es->on_call = 0;
es->on_call_ctx = 0;
es->resume = 0;
es->resume_iw = 0;
es->resume_slot = 0;
@@ -133,7 +135,7 @@ void v4_exec_op(v4_node *n, v4_exec_state *es, v4_heat *h,
case V4_OP_CALL:
x = v4_iword_branch(n->p, w, slot);
v4_rstack_push(rs, n->p);
v4_heat_on_call(h, x);
if (es->on_call) es->on_call(es->on_call_ctx, n, x);
n->p = x;
break;
case V4_OP_UNEXT:
-67
View File
@@ -10,8 +10,6 @@ void v4_heat_reset(v4_heat *h)
* the bare-metal kernel can link it as it is. */
unsigned i;
for (i = 0; i < sizeof h->op / sizeof h->op[0]; i++) h->op[i] = 0;
for (i = 0; i < sizeof h->call / sizeof h->call[0]; i++) h->call[i] = 0;
for (i = 0; i < sizeof h->call_freeze_mask / sizeof h->call_freeze_mask[0]; i++) h->call_freeze_mask[i] = 0;
}
void v4_heat_on_retire(v4_heat *h, unsigned op, v4_uheat_t *anticlock)
@@ -21,68 +19,3 @@ void v4_heat_on_retire(v4_heat *h, unsigned op, v4_uheat_t *anticlock)
}
if (anticlock) (*anticlock)++;
}
static unsigned call_index(v4_cell target)
{
/* The per-call-target heat table is the golden model's stand-in until D-6 rules which
* heat structures exist in hardware (DECOMPOSITION.md D-6). It is indexed by the call's
* target address itself, V4_HEAT_MAX_CALL_TARGETS slots. A target at or past that has
* no slot: it is not counted, and (unsigned)-1 tells the callers so -- they then record
* nothing and read 0, rather than counting it in another target's slot. ENGINE.md step 4,
* Part A1, plans to remove the table with v4_heat_on_call (corrected 2026-11-12, #181:
* this comment read as an unfinished question). */
unsigned idx = (unsigned)(v4_ucell)target;
if (idx >= V4_HEAT_MAX_CALL_TARGETS) return (unsigned)-1;
return idx;
}
void v4_heat_on_call(v4_heat *h, v4_cell target)
{
unsigned idx = call_index(target);
if (idx == (unsigned)-1) return;
if (v4_heat_call_is_frozen(h, target)) return;
h->call[idx]++;
}
v4_uheat_t v4_heat_call_get(const v4_heat *h, v4_cell target)
{
unsigned idx = call_index(target);
if (idx == (unsigned)-1) return 0;
return h->call[idx];
}
int v4_heat_call_set(v4_heat *h, v4_cell target, v4_uheat_t val)
{
unsigned idx = call_index(target);
if (idx == (unsigned)-1) return 0;
if (v4_heat_call_is_frozen(h, target)) return 0;
h->call[idx] = val;
return 1;
}
void v4_heat_call_freeze(v4_heat *h, v4_cell target)
{
unsigned idx = call_index(target);
if (idx == (unsigned)-1) return;
unsigned word = idx / (8u * sizeof(v4_ucell));
unsigned bit = idx % (8u * sizeof(v4_ucell));
h->call_freeze_mask[word] |= ((v4_ucell)1u << bit);
}
void v4_heat_call_unfreeze(v4_heat *h, v4_cell target)
{
unsigned idx = call_index(target);
if (idx == (unsigned)-1) return;
unsigned word = idx / (8u * sizeof(v4_ucell));
unsigned bit = idx % (8u * sizeof(v4_ucell));
h->call_freeze_mask[word] &= ~((v4_ucell)1u << bit);
}
int v4_heat_call_is_frozen(const v4_heat *h, v4_cell target)
{
unsigned idx = call_index(target);
if (idx == (unsigned)-1) return 0;
unsigned word = idx / (8u * sizeof(v4_ucell));
unsigned bit = idx % (8u * sizeof(v4_ucell));
return (h->call_freeze_mask[word] & ((v4_ucell)1u << bit)) != 0;
}
+54 -1
View File
@@ -207,7 +207,7 @@ static void test_branches(void)
fresh(); rpush(7); n.mem[10] = bword(0, V4_OP_CALL, 200); n.p = 10;
(void)v4_exec_step_word(&n, &es, &h);
CHECK(n.p == 200 && n.rs.r == 11, "call pushes the return address");
CHECK(v4_heat_call_get(&h, 200) == 1 && h.op[V4_OP_CALL] == 1, "call heats its target");
CHECK(h.op[V4_OP_CALL] == 1, "call is counted as an opcode (its target: the hook, below)");
(void)v4_rstack_pop(&n.rs);
CHECK(n.rs.r == 7, "call pushes exactly one");
@@ -570,6 +570,58 @@ static void test_address_faults(void)
"and they are not faults: only a running programme faults");
}
/* ENGINE.md step 3 (plan 2026-10-20, Task A1): a hook at `call`, for whoever runs the node -- the
* kernel keeps a word's accounts there (#189). It sees every call, in order, once the return address
* is pushed and before P moves; with no hook the node runs exactly as before. */
static v4_cell seen[8];
static unsigned nseen, hook_bad;
static void record_call(void *ctx, v4_node *nd, v4_cell target)
{
if (ctx != (void *)&seen || nd != &n) hook_bad++;
if (nd->p != nd->rs.r) hook_bad++; /* P not moved yet: still the return address */
if (nseen < 8) seen[nseen] = target;
nseen++;
}
static void call_programme(void) /* 100, 200, 100 again, 300; then a halt loop */
{
fresh();
n.mem[10] = bword(0, V4_OP_CALL, 100);
n.mem[11] = bword(0, V4_OP_CALL, 200);
n.mem[12] = bword(0, V4_OP_CALL, 100);
n.mem[13] = bword(0, V4_OP_CALL, 300);
n.mem[14] = bword(0, V4_OP_JUMP, 14);
n.mem[100] = word6(V4_OP_SEMI, NOP, NOP, NOP, NOP, NOP);
n.mem[200] = word6(V4_OP_SEMI, NOP, NOP, NOP, NOP, NOP);
n.mem[300] = word6(V4_OP_SEMI, NOP, NOP, NOP, NOP, NOP);
n.p = 10;
}
static void test_call_hook_sees_every_call(void)
{
unsigned i, retired_hooked = 0, retired_plain = 0;
v4_cell p_hooked, p_plain;
unsigned depth_hooked, depth_plain;
call_programme();
es.on_call = record_call; es.on_call_ctx = (void *)&seen;
nseen = 0; hook_bad = 0;
for (i = 0; i < 9; i++) retired_hooked += v4_exec_step_word(&n, &es, &h);
p_hooked = n.p; depth_hooked = n.rs.depth;
CHECK(nseen == 4, "the hook sees four calls (%u)", nseen);
CHECK(nseen >= 4 && seen[0] == 100 && seen[1] == 200 && seen[2] == 100 && seen[3] == 300,
"the hook sees 100, 200, 100, 300, in order");
CHECK(hook_bad == 0, "the hook is called with its context and the node, before P moves (%u wrong)", hook_bad);
call_programme(); /* the same with no hook */
for (i = 0; i < 9; i++) retired_plain += v4_exec_step_word(&n, &es, &h);
p_plain = n.p; depth_plain = n.rs.depth;
CHECK(p_plain == p_hooked && depth_plain == depth_hooked && retired_plain == retired_hooked,
"with no hook the node runs the same (P %ld/%ld, depth %u/%u, retired %u/%u)",
(long)p_plain, (long)p_hooked, depth_plain, depth_hooked, retired_plain, retired_hooked);
CHECK(es.on_call == 0, "v4_exec_reset leaves no hook");
}
int main(void)
{
printf("v4 exec tests: V4_CELL_BITS=%d\n", V4_CELL_BITS);
@@ -581,6 +633,7 @@ int main(void)
test_memory();
test_return_and_ex();
test_branches();
test_call_hook_sees_every_call();
test_unext();
test_retirement();
#if V4_CELL_BITS == 64
+2 -49
View File
@@ -1,7 +1,8 @@
/* SPDX-License-Identifier: LicenseRef-Starship-1.0
* Copyright (c) 2023-2026 Robert A. James
*/
/* test_heat.c -- heat and anti-clock. */
/* test_heat.c -- heat and anti-clock. A call's target is counted through exec.h's hook
* (test_exec.c), not here: the per-call-target table is gone (#189). */
#include "v4/heat.h"
#include <stdint.h>
#include <stdio.h>
@@ -30,11 +31,6 @@ static void test_reset(void)
for (unsigned i = 0; i < V4_OPCODE_COUNT; i++) {
CHECK(h.op[i] == 0, "op %u heat non-zero after reset", i);
}
for (unsigned i = 0; i < V4_HEAT_MAX_CALL_TARGETS; i++) {
CHECK(h.call[i] == 0, "call target %u heat non-zero after reset", i);
CHECK(!v4_heat_call_is_frozen(&h, (v4_cell)i),
"call target %u frozen after reset", i);
}
v4_uheat_t ac = 999;
v4_heat_on_retire(&h, V4_OP_NOP, &ac);
CHECK(ac == 1000, "anticlock not advanced");
@@ -57,55 +53,12 @@ static void test_retire_advances_anticlock_and_opcode(void)
}
}
static void test_call_advances_target(void)
{
v4_heat h;
v4_heat_reset(&h);
v4_heat_on_call(&h, 10);
v4_heat_on_call(&h, 10);
v4_heat_on_call(&h, 5);
CHECK(v4_heat_call_get(&h, 10) == 2, "target 10");
CHECK(v4_heat_call_get(&h, 5) == 1, "target 5");
CHECK(v4_heat_call_get(&h, 1) == 0, "target 1");
}
static void test_freeze(void)
{
v4_heat h;
v4_heat_reset(&h);
v4_heat_on_call(&h, 7);
v4_heat_call_freeze(&h, 7);
CHECK(v4_heat_call_is_frozen(&h, 7), "frozen");
v4_heat_on_call(&h, 7);
CHECK(v4_heat_call_get(&h, 7) == 1, "still 1");
CHECK(v4_heat_call_set(&h, 7, 100) == 0, "set returns 0 when frozen");
CHECK(v4_heat_call_get(&h, 7) == 1, "unchanged");
v4_heat_call_unfreeze(&h, 7);
CHECK(!v4_heat_call_is_frozen(&h, 7), "unfrozen");
CHECK(v4_heat_call_set(&h, 7, 42) == 1, "set allowed");
CHECK(v4_heat_call_get(&h, 7) == 42, "changed");
}
static void test_out_of_range_ignored(void)
{
v4_heat h;
v4_heat_reset(&h);
v4_heat_on_call(&h, (v4_cell)V4_HEAT_MAX_CALL_TARGETS + 5);
CHECK(v4_heat_call_get(&h, (v4_cell)V4_HEAT_MAX_CALL_TARGETS + 5) == 0,
"out of range call ignored");
CHECK(v4_heat_call_set(&h, (v4_cell)-1, 123) == 0, "set out of range");
CHECK(v4_heat_call_is_frozen(&h, (v4_cell)-1) == 0, "frozen check");
}
int main(void)
{
printf("v4 heat tests: V4_CELL_BITS=%d\n", V4_CELL_BITS);
test_reset();
test_retire_advances_anticlock_and_opcode();
test_call_advances_target();
test_freeze();
test_out_of_range_ignored();
printf(" %d checks, %d failures\n", checks, failures);
return failures ? 1 : 0;