Stage C-a: work law — running VM charged at each of its own beats, with carry (FABRIC-3.8 §3.13.2)

vm_physics_charge(): per-beat slope into a per-VM carry, whole units pulled
from the rest of the live fleet in proportion to heat (conservative
vm_physics_transfer), rounding shortfall kept in the carry, no fleet_window
sample. Called from vm_tick() only for the switch-current VM. Closes G9:
a VM running its own mail on its own stack now heats up while it works.

Probe (reverted): baseline Hestia heat 0->0->0 over 71 beats (FAIL, proves
G9); with charge 0->11->23 at beats 15->51->86, identical on amd64/aarch64/
riscv64, conserved, PASS. Acceptance: dict_hash unchanged, WIREBIND relay 3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Robert Allan James
2026-09-23 16:51:09 -04:00
co-authored by Claude Opus 5.5
parent 29447abb7d
commit 7c3736841d
19 changed files with 64261 additions and 6 deletions
+57 -5
View File
@@ -529,10 +529,13 @@ switch-current. Hera's is seeded at `sk_vm_switch_set_current()`, so her first c
cover every beat since boot. The clamp to `others_total` would make that harmless anyway, but it
should be defined rather than left to the clamp.
**What a beat counts.** A beat is 256 of *either* colon-word entries or words parsed at top level
by `vm_interpret` (the two `check_counter` sites). Primitives executed inside colon bodies are
**not** counted. So the "words" figures below are beat counts × 256 in that sense, not total
primitive executions.
**What a beat counts** (corrected at the start of C-a, from reading `vm_core.c:909` in context).
- A beat is every 256 words a VM executes. That counts each word dispatched inside a colon body,
primitives included (the `check_counter` bump in `execute_colon_word`'s inner loop), plus each
word parsed at top level by `vm_interpret` (`:1286`).
- The earlier draft said primitives inside colon bodies weren't counted. That was wrong.
- The "words" figures below are therefore beat counts × 256 executed words.
**Hera's reservoir mass (ruling C-4, open, arithmetic shown).**
@@ -540,7 +543,7 @@ primitive executions.
added to a closed system").
- At slope 1/3 raw per beat, the workers together have to produce about `65536 × 3 × (N/(N+1))`
beats before Hera cools to a worker's level. For N = 8 that is ≈175k beats, ≈45M
colon-entries/top-level words (see "What a beat counts" above).
executed words (see "What a beat counts" above).
- Until then, Hera is never the coolest while any worker has work, **so the console gets no turn
under load.** This doesn't block Stage C's relay evidence, because the relay runs while the
workers are idle. It does block Stage F/H usability.
@@ -827,3 +830,52 @@ explicit instruction.
and zero refused interprets**. The `dict_hash` set is identical across ISAs and unchanged
from Stages A/B. The full mint → WIREBIND → `USE` → `1 2 + .` → `3` relay works (the path
with a nested frame on Hera's stack). Clean build on all three plus hosted, no new warnings.
- [x] **Stage C-a — the work law: the running VM is charged at each of its own beats, with carry
(§3.13.2).** 2026-09-23. Captain Bob: *"commit it and start stage C-a"*, which accepts
§3.13.2's proposals C-1(a) and C-2 as written: charge at each beat, keep a carry, record no
`fleet_window` samples. C-3, C-4 and C-5 are C-b's rulings and are still open.
Changes:
- `capsule_vm_physics.c` gets a new `vm_physics_charge(vm_id)`. It adds one beat's slope
to a new per-VM `charge_carry`, then pulls the whole units from the rest of the live
fleet in proportion to each VM's heat, through `vm_physics_transfer()`.
- Clamped to what the others hold. That shortfall is dropped, as in touch, so the carry
can't grow without bound while one VM runs alone.
- Units lost to the proportional split's rounding go back into the carry.
- No `fleet_window` sample and no `last_active_tick` change, so `vm_physics_touch()` and
`VM-EXEC` behave byte-for-byte as before.
- `vm_runtime.c`'s `vm_tick()` calls the charge right after `vm_physics_heartbeat_tick()`,
only when the VM producing the beat is `sk_vm_switch_current_vm()`. A VM running nested
inside someone else's `VM-EXEC` stays on the dispatch law.
- §3.13.2 "What a beat counts" is corrected. `vm_core.c:909` is in `execute_colon_word`'s
inner loop, so primitives inside colon bodies *are* counted.
**Evidence, as named in §4.** A temporary probe (`kernel_main.c`: `SB-GO`/`SB-BACK` plus
`SB-HEAT`, which records Hestia's heat, the rest of the fleet's heat, her beat count and
`vm_physics_conserved()`; reverted with `git checkout`). It sends Hestia `: SBW ; : SBL 3000
0 DO SBW LOOP ; SB-HEAT SBL SB-HEAT SBL SB-HEAT SB-BACK`, and Hera runs `SB-GO SB-GO`.
- **Baseline (pre-change code, probe only):** `logs/20260923-160929/amd64/` gives
`hestia_heat 0 → 0 → 0` over beats 15 → 51 → 86, and **FAIL**. Hestia ran 71 beats of
her own mail on her own stack and gained nothing, which **proves G9**.
- **With the charge, all three ISAs:** `logs/20260923-161448/amd64/`,
`-161959/aarch64/` and `-162543/riscv64/` give, byte-identically, `hestia_heat 0 → 11 →
23` at beats 15 → 51 → 86, `rest 65536 → 65525 → 65513`, conserved at every sample, and
**PASS**. The heat rises **mid-turn** at each in-turn sample.
- Exact to the arithmetic: switched in at beat 15, so ⌊36·21845/65536⌋ = 11 and
⌊71·21845/65536⌋ = 23. Every unit Hestia gained came out of the rest of the fleet (all
from Hera; Artemis held 0).
- `fleet_window_warm=NO` and `slope 21845` are unchanged. By source, the charge never
writes `fleet_window`.
**Formal acceptance with the probe gone,** coupled images reset before each:
`logs/20260923-163215/amd64/`, `-163757/aarch64/`, `-164359/riscv64/`.
- All reach `[zuse@Hera] ok>` with zero `UNKNOWN WORD`, failures, faults, `VM FRAME
CORRUPTION` and refused interprets.
- The `dict_hash` set is identical across ISAs and unchanged from Stages A/B/FR.
- The full mint → WIREBIND → `USE` → `1 2 + .` → `3` relay works, with
`stadium_conserved(self)=true`.
- Clean build on all three plus hosted, no new warnings.
**Consequence to watch in C-b:** heat now moves at boot too. Hera is switch-current from
her Stage 3 registration onward and is charged for her own beats. She holds nearly all the
heat, so her charges pull almost nothing; that is the C-4 arithmetic playing out as
expected. The per-beat DoE heat columns will read differently from pre-C-a runs.
+1 -1
View File
@@ -1,5 +1,5 @@
# Capsule Block Manifest — Auto-generated
<!-- Generated by mkcapsule --manifest 2026-09-23T19:41:33Z -->
<!-- Generated by mkcapsule --manifest 2026-09-23T20:43:24Z -->
<!-- DO NOT EDIT — re-run mkcapsule --manifest to refresh. -->
<!-- Hand-written justifications and immutability notes live -->
<!-- in MANIFEST.md alongside this auto-generated index. -->
+19
View File
@@ -92,6 +92,25 @@ void vm_physics_retire(VMUuid vm_id);
*/
void vm_physics_touch(VMUuid vm_id);
/**
* vm_physics_charge - Charge the running VM for one beat of its own work
*
* FABRIC-3.8.md §3.13.2 (Stage C-a, 2026-09-23). The work law, beside
* vm_physics_touch()'s dispatch law: called once per beat a VM produces
* while it is the switch-current VM (vm_tick()), so a VM running its own
* kernel-Hermes mail on its own stack heats up as it works -- before this,
* nothing did (G9). Adds one beat's slope to a per-VM carry, and pulls the
* carry's whole units from the rest of the live fleet in proportion to
* each VM's heat, through the same conservative transfer as touch. The
* fraction below one unit, and any whole units lost to the proportional
* split's own rounding, stay in the carry instead of being discarded.
* Records NO fleet_window sample, so the slope estimator and the existing
* DoE heat columns keep their meaning (§3.13.2 "Frozen slope").
*
* @param vm_id Registry VM ID of the running VM
*/
void vm_physics_charge(VMUuid vm_id);
/**
* vm_physics_tick - Heartbeat-gated inference pass
*
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -63,6 +63,10 @@ typedef struct {
* overloading last_active_tick == 0, which is a
* legitimate tick value a first touch can land on */
int is_live;
uint64_t charge_carry; /* vm_physics_charge()'s owed transfer, Q48.16 --
* FABRIC-3.8.md §3.13.2: kept, never truncated
* away, so a one-beat charge (slope < 1 unit)
* still moves heat once enough beats accrue */
} VMPhysics;
typedef struct vm_physics_node {
@@ -203,6 +207,7 @@ void vm_physics_init(VMUuid vm_id)
node->physics.last_active_tick = 0;
node->physics.touched = 0;
node->physics.is_live = 1;
node->physics.charge_carry = 0;
return;
}
@@ -214,6 +219,7 @@ void vm_physics_init(VMUuid vm_id)
node->physics.last_active_tick = 0;
node->physics.touched = 0;
node->physics.is_live = 1;
node->physics.charge_carry = 0;
node->next = vm_physics_head;
vm_physics_head = node;
}
@@ -357,6 +363,52 @@ void vm_physics_touch(VMUuid vm_id)
fleet_window.is_warm = (fleet_window.count >= VM_FLEET_WINDOW_DEPTH);
}
/* FABRIC-3.8.md §3.13.2 (Stage C-a): see the header. Same fan-out shape as
* vm_physics_touch() above, but charged per beat of the running VM's own
* work rather than per dispatch, and with the sub-unit remainder carried
* instead of truncated away -- touch's elapsed_ticks * slope >> 16 is 0 for
* any single beat, which is why touch can't simply be called per beat. */
void vm_physics_charge(VMUuid vm_id)
{
vm_physics_node_t *target = vm_physics_find(vm_id);
vm_physics_node_t *n;
uint64_t amount, others_total = 0, moved_total, moved = 0;
if (!target || !target->physics.is_live) return;
target->physics.charge_carry += fleet_transfer_slope_q48;
amount = target->physics.charge_carry >> 16;
if (amount == 0) return;
target->physics.charge_carry &= 0xFFFFULL;
for (n = vm_physics_head; n; n = n->next) {
if (n != target && n->physics.is_live) {
others_total += n->physics.execution_heat_q48;
}
}
/* Clamped to what the rest of the fleet holds, exactly like touch: a
* VM already holding all the heat has nothing left to pull, and that
* shortfall is dropped, not owed -- otherwise the carry would grow
* without bound while one VM runs alone. */
moved_total = (amount > others_total) ? others_total : amount;
if (moved_total == 0) return;
for (n = vm_physics_head; n; n = n->next) {
if (n != target && n->physics.is_live) {
uint64_t share = (moved_total * n->physics.execution_heat_q48) / others_total;
vm_physics_transfer(&n->physics, &target->physics, share);
moved += share;
}
}
/* The proportional split rounds each share down; what it failed to
* move is still owed, so it goes back into the carry rather than
* vanishing. Conservation is unaffected either way -- only what
* vm_physics_transfer() actually moved ever changed any heat. */
target->physics.charge_carry += (moved_total - moved) << 16;
}
/**
* l8_regime_modulation_q16 - Read-only Q16 multiplier from Hera's own L8
*
+12
View File
@@ -51,6 +51,7 @@
#include "../include/ssm_jacquard.h"
#include "vm_internal.h"
#include "starkernel/capsule_vm_physics.h"
#include "starkernel/vm/switch.h" /* sk_vm_switch_current_vm() -- FABRIC-3.8.md §3.13.2 */
#include "starkernel/timer.h"
#include <errno.h>
@@ -140,6 +141,17 @@ void vm_tick(VM* vm)
* (VM-FLEET-ATTRACTOR-DESIGN-20260705.md rev k). */
vm_physics_heartbeat_tick(vm_monotonic_ns(vm));
/* FABRIC-3.8.md §3.13.2 (Stage C-a): the work law. This beat was
* produced by `vm`'s own execution; if `vm` is the VM physically
* running (switch-current), charge it one beat. A VM whose words run
* nested inside someone else's VM-EXEC is not switch-current and is
* not charged here -- that execution is already accounted by
* vm_physics_touch()'s dispatch law. Kernel-side only: nothing a VM
* executes can ask for, skip, or give back a charge. */
if (vm == sk_vm_switch_current_vm()) {
vm_physics_charge(vm->stadium_vm_id);
}
/* Plugin 2: System State Monitoring (Future) */
/* vm_tick_system_monitor(vm); */