Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
830 lines
60 KiB
Markdown
830 lines
60 KiB
Markdown
# FABRIC-3.8.md — Real concurrency: finishing what the reshuffle started
|
||
|
||
**Status: OPEN — Stages A, B and FR (VM frames) done (§6); C–I not started. §3.11 ruled
|
||
(coolest VM with pending work, by physical comparison; no scheduler).** Each stage below needs Captain
|
||
Bob's explicit go, gets its own commit, and gets its own three-architecture acceptance (`clean
|
||
qemu`, amd64 → aarch64 → riscv64, foreground, visible framebuffer, one at a time).
|
||
|
||
**Provenance.** Opened 2026-09-23. The multiuser/multitasking DoE (`FABRIC-3.md` §XXXIII–§XXXV)
|
||
was launched on amd64 (`12345 3 S" amd64" DOE`, `logs/20260923-125936/`) and stopped by
|
||
direct instruction about 49 minutes in, with zero trials complete, once it was established that
|
||
the harness measures a **cooperative, serialized round-robin**, not concurrency (log and CSV
|
||
kept). Captain Bob: *"that was the point of this branch"* — the `v2.1.0` Tripod/kernel reshuffle
|
||
(`FABRIC-3.5.md`/`-3.6.md`, now on `master`) was meant to deliver real concurrent execution.
|
||
This document records why it doesn't yet, and the design that finishes it.
|
||
|
||
**The governing objective, Captain Bob's words:** *"the object: doe contains zero dependence on
|
||
special execution states."* Concurrency must be a property of the OS for **every** VM. The DoE
|
||
may only use ordinary words (birth a VM, send it a message, kill it). No DoE-specific
|
||
registration, no DoE-specific dispatch path, no special mode. If the DoE needs something the
|
||
OS doesn't give every VM, that's an OS defect to fix here, not a harness workaround.
|
||
|
||
Successor-in-topic to `FABRIC-3.5.md`/`FABRIC-3.6.md` (both CLOSED/ARCHIVAL at `v2.1.0`), whose
|
||
close headers say further fleet work gets its own document. `FABRIC-3.md` stays open and
|
||
authoritative for bare-metal boot; this doesn't supersede it.
|
||
|
||
---
|
||
|
||
## 1. What the reshuffle ruled, and what actually landed
|
||
|
||
**Ruled** (`FABRIC-3.5.md`):
|
||
- §XXXII: *kernel-Hermes publishes eligibility; it does not dispatch.* The switcher is **the sole
|
||
mover of control**. The MSG-TICK idle pump is retired.
|
||
- §XLIII: *the target drains its own queue, in its own context, at its own outermost
|
||
checkpoint.* Nobody `VM-EXEC`s into anybody.
|
||
- §XV.3: nobody owns turn order. It's compudynamic.
|
||
|
||
**Landed** (`FABRIC-3.6.md` Phases 0–5, `v2.1.0`): the message allocator and heat ledger,
|
||
per-VM pending queues, the drain function, channels, chunking, per-VM native stacks (§XXVIII
|
||
Stage 1), the switch primitive (Stage 2), the timer-driven switch signal (Stage 3), and WIREBIND
|
||
participation (Stage 4).
|
||
|
||
**Not landed, traced 2026-09-23 against this branch (`e40cafc`, same code as `origin/master`
|
||
for every file below):**
|
||
|
||
| # | Gap | Evidence |
|
||
|---|---|---|
|
||
| G1 | **Kernel-Hermes never publishes eligibility.** `sk_hermes_send_one()` doesn't call `sk_vm_switch_signal_mark_work()`. `has_work` is never set, so the switcher never switches. | `kernel_hermes.c:485–537` — zero `mark_work` references in the file. The only caller left is the `SWITCH-MARK-WORK` word (`mama_forth_words.c:867`), which had one caller: FORTH `MSG-SEND`, deleted with `messaging.4th` in Phase 4. |
|
||
| G2 | **A switched-to VM does nothing.** First entry lands in the Stage-2 placeholder, which yields straight back forever. | `switch.c:100–105`, `sk_vm_switch_entry()`. Its own comment: "no production behavior defined yet." |
|
||
| G3 | **Delivery to passive VMs is still the pump.** Task 3.9 put the kernel-Hermes drain into `sk_repl_idle()`'s registry walk: Hera calls `vm_interpret()` on each live VM, **on Hera's own physical stack**, once per idle beat. | `repl.c:909–928`. §XXXII ruled this pump retired. |
|
||
| G4 | **Only the Tripod and WIREBIND user VMs are switch participants.** VMs born through `capsule_birth_baby()` (`BIRTH`, `CAPSULE-BIRTH`, `WORKER-BIRTH`, `UNATTENDED-BIRTH`) never register. | `kernel_main.c:1261/1270/1280`, `capsule_wirebind.c:316` — the only `sk_vm_switch_signal_register()` call sites. |
|
||
| G5 | **The stack-ownership gate is a single global.** `g_vm_interpret_depth` counts *all* `vm_interpret()` nesting across all VMs, and the gate is `depth <= 1`. Once VMs genuinely interleave on their own stacks, that global stops meaning "this VM owns this stack at its outermost frame." Same for `g_log_attrib_vm`. | `vm_core.c:1164–1178`, `:1183–1220`. |
|
||
| G6 | **Kill vs. parked VMs.** `capsule_vm_kill()` refuses any `VM_STATE_SWITCHED_OUT` VM, and the ordinary kill path never unregisters from the switcher (only `capsule_vm_force_reap()` does). A worker that finished and parked could never be killed; one killed some other way would leave a stale switch slot. | `capsule_birth.c:412–` (refusal at the function's `SWITCHED_OUT` check); `capsule_birth.c:371` (the only unregister in that file, in the reap path). |
|
||
| G7 | **Switches only happen inside word dispatch.** The checkpoint lives in `execute_colon_word()`. A VM sitting at an idle point (Hera at her REPL, blocked in `sk_console_readline()`) never takes a pending switch. | `vm_core.c:913–990`. `sk_repl_idle()` never calls `sk_vm_switch_signal_take_pending()`. |
|
||
| G8 | **Shared file-scope state.** `RANDOM`/`SEED` share one `g_prng_state` across every VM. Under interleaving, one VM's draws perturb another's stream, and reproducibility of any seeded sequence becomes timing-dependent. | `starforth_words.c:71`, `:657`. The Isabelle sweep memory lists more of this class (control-flow compile stack, vocabulary globals). |
|
||
|
||
**Latent hazard created by combining G1 with G3.** Wiring G1 alone would wake the switcher for
|
||
every message recipient, WIREBIND `~user` VMs included, and those are already participants. The
|
||
pump (G3) runs their relayed console commands via `vm_interpret()` on **Hera's** stack at
|
||
global depth 1, so the gate (G5) passes. The first pending switch would then save Hera's stack
|
||
pointer under the `~user` VM's `native_stack_saved_sp`. That's `FABRIC-3.md` §XXVIII.1's
|
||
stack-ownership corruption, reborn on the live console path. **G1 must not land before G5 and
|
||
G3 are fixed** (§4).
|
||
|
||
**Why the DoE couldn't see any of this.** `multiuser-doe.4th`'s `MU-TOUCH-ALL` calls `VM-EXEC` into
|
||
`mu0…muN` in index order. `VM-EXEC` runs `vm_interpret()` synchronously on Hera's stack at
|
||
depth 2, where the gate defers anyway. So the "concurrency" factor (2/4/8) counted resident
|
||
VMs; execution was strictly serialized. That's `FABRIC-3.md` §XIX's own named null hypothesis.
|
||
|
||
---
|
||
|
||
## 2. Design principles
|
||
|
||
1. **One mover.** Only `sk_vm_context_switch()`, driven by the switch signal, moves control
|
||
between VMs. Nothing else runs a VM's code on another VM's stack. (§XXXII)
|
||
2. **Work runs on its owner's stack.** A VM's payloads and words execute only while that VM
|
||
physically owns the CPU stack it's standing on. This is the property the depth gate was
|
||
approximating.
|
||
3. **Kernel-Hermes publishes facts.** It enqueues, and marks the recipient as having work. It never
|
||
dispatches or orders anything. (§XXXII, §XV.3)
|
||
4. **Generic, not DoE-shaped.** Every VM born through `capsule_birth_baby()` is a participant,
|
||
drained the same way, killable the same way. The DoE gets concurrency by using `BIRTH` and a
|
||
message send, exactly like any future program would.
|
||
5. ~~**Nothing new decides turn order.** The existing readiness/threshold signal stays as it is. Its
|
||
hardcoded `50` threshold remains the flagged seam (`FABRIC-3.5.md` §XV.3), not something this
|
||
pass tunes.~~ **CORRECTED 2026-09-23, Captain Bob: *"the timer is NOT the source of
|
||
preemption, compudynamics is."*** Today's switch decision is timer-driven:
|
||
`heartbeat_tick()` (`heartbeat.c:179`, ISR context) calls `sk_vm_switch_signal_tick()`, which
|
||
adds one unit of readiness per timer tick to every non-current participant and requests a
|
||
switch once readiness passes a fixed `50` (`capsule_vm_switch_signal.c:58`, `:191–210`).
|
||
That's a time-slice scheduler with a threshold, the opposite of `FABRIC-3.5.md` §XV.3 ("turn
|
||
order is compudynamic"). **Principle 5 is replaced by:** *the decision to preempt, and whom
|
||
to run, must come from the compudynamics (the physics the fleet already computes), not from
|
||
elapsed timer ticks.* §3.11 holds the design question; nothing is decided there yet. Stage A
|
||
(the ownership gate, §3.1) is unaffected: it governs *where* a switch may safely happen,
|
||
not *whether* one should.
|
||
|
||
---
|
||
|
||
## 3. Mechanism
|
||
|
||
### 3.1 The ownership gate becomes per-VM (G5)
|
||
|
||
Replace the global depth test with the real invariant:
|
||
|
||
> A checkpoint may switch or drain only when **`sk_vm_switch_current_vm() == vm`** (this VM
|
||
> physically owns the stack) **and `vm->interpret_depth == 1`** (at its own outermost
|
||
> interpret frame).
|
||
|
||
- New per-VM field `interpret_depth`, incremented and decremented in `vm_interpret()` beside the
|
||
existing global. The global stays for log attribution until §3.7 retires it.
|
||
- `sk_vm_switch_current_vm()` (`switch.c:68–72`) already tracks the physical owner, seeded at boot
|
||
for Hera and updated on every switch. It becomes load-bearing here, which is what it was built for.
|
||
- A nested `VM-EXEC` into another VM fails the first clause (that VM isn't the stack owner), so the
|
||
existing defer-if-nested behavior is preserved exactly, now by construction.
|
||
- **Also gate on `vm->mode != MODE_COMPILE`**: never switch or drain mid-definition (G8's compile
|
||
statics).
|
||
|
||
### 3.2 The trampoline becomes every participant's idle loop (G2)
|
||
|
||
`sk_vm_switch_entry()` stops being a pure bounce. On each entry or resume:
|
||
|
||
1. If this VM has a pending message: set `vm->hermes_draining = 1`, drain **one** message
|
||
(`sk_hermes_drain_checkpoint()`, ruled one per checkpoint, `FABRIC-3.5.md` §XLVI 3.0e), clear
|
||
the flag.
|
||
2. Otherwise, mark this VM **parked-idle** (§3.5) and yield back to `native_switch_back_to`.
|
||
|
||
The payload runs via `vm_interpret()` at this VM's depth 1, on its own stack. So its own word
|
||
checkpoints can preempt it (§3.1 passes), and preemption now happens inside real work, not just
|
||
at message boundaries.
|
||
|
||
### 3.3 One drain at a time per VM (nested drain)
|
||
|
||
`FABRIC-3.5.md` §XLIII.5's "recursive drain prevented for free" assumed every checkpoint sits under
|
||
some outer `vm_interpret()`. With the trampoline as a base, a payload runs at depth 1, and the
|
||
existing in-word drain (`vm_core.c:923–925`) would then drain a *second* message **inside** the
|
||
first's execution. **Rule:** a per-VM `hermes_draining` flag. No drain site drains while it's
|
||
set. The in-word drain site stays: it's how Hera and any VM busy running a long word still
|
||
receive mail. It just can't nest.
|
||
|
||
### 3.4 Eligibility is published, and the pump stops draining participants (G1 + G3, one commit)
|
||
|
||
- `sk_hermes_send_one()` calls `sk_vm_switch_signal_mark_work(to)` after a successful enqueue.
|
||
That's the §XXXII ruling, one line.
|
||
- `sk_repl_idle()`'s registry walk (`repl.c:909–920`) **skips switch participants.** Their queues
|
||
are drained only by themselves (§3.2). Non-participants, if any remain, keep today's behavior.
|
||
Once §3.6 makes every born VM a participant, the loop becomes dead code, and it's stripped in
|
||
its own commit per this series' strip discipline.
|
||
- These two changes **must land together**. Either alone is either inert (mark without trampoline
|
||
behavior) or the §1 latent hazard.
|
||
|
||
### 3.5 Parked-idle is a killable state (G6)
|
||
|
||
`VM_STATE_SWITCHED_OUT` currently means "a live frame is parked here, don't free," and that's
|
||
correct mid-work. A VM that yielded from its trampoline's idle branch (§3.2 step 2) has nothing
|
||
borrowed on its stack and nothing in progress. **New per-VM flag `parked_idle`**, set only on
|
||
that branch and cleared on every switch-in. `capsule_vm_kill()` allows a `SWITCHED_OUT` VM whose
|
||
`parked_idle` is set. The same function gains the `sk_vm_switch_signal_unregister()` call its
|
||
reap sibling already has (`capsule_birth.c:371`). A plain flag, not a new `VMState`, following
|
||
the `pending_reap` precedent (`FABRIC-3.md` §XXX: `VM_STATE_DEAD` is treated as idempotent
|
||
success and would swallow it).
|
||
|
||
### 3.6 Every born VM participates (G4)
|
||
|
||
`capsule_birth_baby()` registers the new VM with the switch signal at its `VM_STATE_LIVE`
|
||
completion point. That's the same choke point task 3.2 already uses to subscribe every VM to the
|
||
common channel (`FABRIC-3.6.md` task 3.2). Registration happens **after** birth completes, never
|
||
before, the same rule `kernel_main.c`'s Tripod block states. `WORKER-BIRTH`/`UNATTENDED-BIRTH`/
|
||
`BIRTH` gain nothing of their own. `kernel_main.c`'s explicit Artemis/Hestia calls and
|
||
`capsule_wirebind.c:316` become duplicates. Remove or guard them (registering twice would
|
||
double-allocate a slot), in the same commit.
|
||
|
||
**Birth itself stays synchronous.** `capsule_birth_baby()` runs the capsule's code to completion
|
||
before returning. Any workload a capsule self-executes at load (every `workload-N.4th` does,
|
||
`FABRIC-3.md` §XXXIII.4) therefore still runs serially at birth. That's addressed in §3.9 as a DoE
|
||
ruling, not an OS change.
|
||
|
||
### 3.7 An idle point is a switch point (G7)
|
||
|
||
`sk_repl_idle()` runs on Hera's own stack at depth 0. That's a clean switch point by §3.1's
|
||
invariant. It checks `sk_vm_switch_signal_take_pending()` and performs the switch, exactly as the
|
||
word-dispatch checkpoint does. Without this, concurrency stops whenever Hera sits at her prompt,
|
||
which is exactly when a human has launched background work and is waiting.
|
||
|
||
**`sk_repl_headless_wait()` gets the same treatment**, since it's also an idle loop on Hera's
|
||
stack. Neither may switch while `g_mama_interpreting`/`g_idle_pump_active` is set (the existing
|
||
reentrancy guards stay until proven dead).
|
||
|
||
### 3.8 A generic send word (needed by any program, not just the DoE)
|
||
|
||
There's no general FORTH-callable kernel-Hermes send today, only the fixed-purpose
|
||
`KH-BLK-ATTACH-SEND`/`KH-ELEVATE-SEND` (`repl.c:385–488`), each hardwired to send to Hera.
|
||
**New word `SEND ( paddr plen name-c name-u -- ok? )`** (name is Bob's call, see §5): resolves the
|
||
target by registry name, sends a copy of the payload via `sk_hermes_send_one()` (so G1's
|
||
eligibility mark fires), and returns 0/1. `from` is the calling VM, derived in C, never taken from
|
||
the stack, the same anti-spoof rule `KH-ELEVATE-SEND` established. Payload bound is one block
|
||
(1024), per `FABRIC-3.5.md` §XLV.3. Registered for every VM. A plain unconditional primitive:
|
||
restricting it, if ever wanted, is `ACL.4th`, never a C check.
|
||
|
||
### 3.9 The DoE, rebuilt on ordinary words (after §3.1–§3.8 land)
|
||
|
||
Using only `BIRTH`-family words, `SEND`, and `KILL`:
|
||
|
||
1. **Birth** N workers (serial, as all birth is).
|
||
2. **Send** each worker its workload command as a message. Workers now run **concurrently**,
|
||
each on its own stack, preempted by the switch signal.
|
||
3. Each workload payload ends by `SEND`ing a completion line back to Hera (e.g. `" <n> MU-DONE"`),
|
||
which runs in Hera's own context when she drains it and increments a counter.
|
||
4. Hera **waits with an ordinary loop** (`BEGIN … MU-DONE-COUNT @ N = UNTIL`). Her word checkpoints
|
||
drain her mail and let the switcher preempt her. No special wait state.
|
||
5. Check errors, `KILL` each worker (legal now they're parked-idle, §3.5), and print and persist
|
||
the trial marker as today.
|
||
|
||
**DoE ruling needed (§5):** workload capsules self-execute at load, so the heavy work happens
|
||
serially at birth. For the concurrent phase to be the measured phase, workers should be born from
|
||
**definitions-only** capsules, with the workload arriving as the first message.
|
||
|
||
### 3.10 Shared state that interleaving exposes (G8), stated rather than fixed
|
||
|
||
- `g_prng_state`: the DoE's Fisher-Yates shuffle runs before any worker exists, so the **run
|
||
order** stays seed-deterministic across ISAs. Any `RANDOM` a workload calls mid-trial is not.
|
||
Whether that matters depends on which workloads draw from it (not yet checked).
|
||
- The compile-time control-flow and vocabulary statics named by the Isabelle sweep: protected for
|
||
switches by §3.1's `MODE_COMPILE` gate. A payload that *itself* compiles (`:` … `;`) is protected
|
||
as long as it can't be preempted mid-definition, and the gate guarantees that.
|
||
- **Not fixed in this pass.** Making these per-VM is real work in vendored shared source
|
||
(`src/word_source/`), which is `FABRIC-4.md`'s declared scope.
|
||
|
||
### 3.11 Preemption comes from the compudynamics, not the timer (OPEN, Captain Bob's ruling of principle)
|
||
|
||
**Ruled:** the timer isn't the source of preemption; the compudynamics is. **Not yet
|
||
designed:** which physical quantity decides "switch now, to whom." Material already in the tree,
|
||
listed so the design starts from what exists (traced, not proposed):
|
||
|
||
- **VM-physics heat** (`execution_heat_q48`, `capsule_vm_physics.c`; FORTH `VM-HEAT`). Moves
|
||
when a VM does real work (`vm_physics_touch()`) and is conserved fleet-wide (fleet K).
|
||
`FABRIC-3.md` §XIX already argued the turn-attractor from this: *the coolest live VM with
|
||
pending work goes next*, the same least-dense judgment `stadium_admit()` uses for eviction,
|
||
applied to "whose turn."
|
||
- **Stadium heat/reservoir** (per-VM quota conservation, `stadium_conserved()`). A message's heat
|
||
is the Stadium cell it occupies (`FABRIC-3.5.md` §XL.4), so pending mail already has a physical
|
||
weight.
|
||
- **Loop #7's adaptive heartbeat** (`HeartbeatState`, per VM). This project's "one and only
|
||
clock" (memory `project_acl_ttl_overhead_final`), and adaptive rather than a fixed period.
|
||
Distinct from the hardware timer ISR (`heartbeat_ticks()`), which is what currently drives
|
||
readiness.
|
||
|
||
**The shape this implies, for ruling (not decided):** the switch decision moves out of ISR
|
||
readiness counting and into the ownership-gated checkpoint (§3.1), where it compares physics.
|
||
Switch when some VM with pending work (`has_work`) is compudynamically favored over the current
|
||
one (e.g. cooler per §XIX), and never merely because N timer ticks have elapsed. The timer ISR
|
||
would then keep only what physics needs from it, and nothing that decides turns. The fixed `50`
|
||
threshold and the per-tick readiness accrual go away.
|
||
|
||
**Clarified 2026-09-23, Captain Bob:** *"this is kind of like a mixture of preemptive and
|
||
cooperative multitasking… It's the compudynamics that drives the whole thing. Not necessarily a
|
||
preemptive timer. Not necessarily cooperatively, as in 'when I finish my task, I move on'. But
|
||
it's a mixture of both."* Read as:
|
||
|
||
- **Where a switch may happen is cooperative in shape.** Only at a VM's own clean boundary, the
|
||
ownership-gated word checkpoint (§3.1): stack owned, own outermost frame, not compiling. Never
|
||
at an arbitrary instruction, never because an interrupt fired.
|
||
- **Whether, and to whom, is preemptive in effect.** A running VM doesn't hold the CPU until it
|
||
finishes. At any such boundary it can be displaced mid-task when the compudynamics favors
|
||
another VM with pending work.
|
||
- **Running out of work parks a VM.** That's the plain cooperative case (§3.2's idle loop).
|
||
- **Neither a clock nor task completion decides. The physics does.**
|
||
|
||
**Constraint, Captain Bob 2026-09-23:** *"make sure nothing inside of a given execution state or
|
||
VM controls this. There are no special words saying I'm giving up control or anything like
|
||
that. This is strictly all operating system and strictly all compudynamically driven."* No yield
|
||
word, no `PAUSE`, no "I'm done" word, anywhere. Parking happens in the kernel's trampoline when
|
||
the OS sees an empty queue (§3.2), and every switch decision is kernel-side.
|
||
|
||
**Also a Stage C input:** the trampoline's point between two messages is a clean switch point
|
||
(own stack, depth 0). The compudynamic decision has to be consulted there too, or a VM whose queue
|
||
holds only checkpoint-free payloads (primitive words, no colon calls) never yields. Noted in
|
||
`sk_vm_switch_entry()`'s own comment; not built in Stage B.
|
||
|
||
**RULED 2026-09-23, Captain Bob:** *"VM frames first, then coolest VM with work goes next"* —
|
||
and, immediately after: ***"no scheduler allowed."*** So the switch decision at Stage C is a
|
||
**physical comparison made at the ownership-gated checkpoint**. Among VMs with pending work, the
|
||
one with the lowest heat is favored over the one running, the same least-dense judgment
|
||
`stadium_admit()` already makes for eviction and the §XIX turn-attractor argument. It is **not**
|
||
a scheduler: no run queue, no priority list, no ordering data structure, no component that owns
|
||
or computes an order, nothing that runs on a timer. The comparison is re-evaluated only at the
|
||
moments the physics already exposes (checkpoints, the trampoline's between-message point, idle
|
||
points), and the heat it reads is the heat the fleet already conserves. The per-tick readiness
|
||
accrual and the fixed `50` threshold are removed at Stage C, not tuned.
|
||
|
||
~~**This is a design question for Captain Bob before Stage C**~~ (settled above) (Stage C is the first stage where
|
||
anything actually switches). Stages A and B don't depend on it.
|
||
|
||
### 3.12 VM frames: per-VM execution state that travels with the VM (OPEN, raised by Captain Bob)
|
||
|
||
**Captain Bob, 2026-09-23:** *"I'm thinking we may need VM frames for state."* Once VMs genuinely
|
||
interleave, any execution context that isn't already inside the `VM` struct or on the VM's own
|
||
native stack is shared across VMs, and it goes wrong at the first switch that lands inside it.
|
||
The Stage B probe hit an instance of this class on its first run (§6, the amd64 alignment finding
|
||
is the same family: state the first-entry frame should have carried and didn't).
|
||
|
||
**Known members of that class today, traced:**
|
||
|
||
| State | Where | Why it breaks under interleaving |
|
||
|---|---|---|
|
||
| `g_log_attrib_vm` | `vm_core.c` (save/restore in `vm_interpret()`) | Save/restore assumes last-in-first-out nesting. Hera switches out mid-interpret; Hestia's `vm_interpret()` saves `prev=Hera`; Hestia switches back mid-payload → attribution stays "Hestia" while Hera runs. `(LOG-APPEND-RAW)` takes its source from this, so persisted log records name the wrong VM (defeating `FABRIC-3.md` §XXVII's anti-spoof rule). |
|
||
| `g_vm_interpret_depth` | `vm_core.c` | Same LIFO assumption; superseded by per-VM `interpret_depth` (Stage A) and retired in Stage I. |
|
||
| `g_switch_entry_vm` | `switch.c` | Read once at first entry; safe only while first entries can't overlap. |
|
||
| `g_prng_state` | `starforth_words.c:71` | One `RANDOM` stream for every VM (G8). |
|
||
| Compile-time control-flow and vocabulary statics | vendored `src/word_source/` (Isabelle sweep memory) | Shared across VMs; kept safe for switches by the Stage A `MODE_COMPILE` gate, not by ownership. |
|
||
|
||
**Shape, for ruling (not decided):** a `VMFrame` struct in its own header (per
|
||
`feedback_dedicated_headers_not_inline_fields`: related state gets a header, not loose `VM`
|
||
fields), embedded in `VM`, holding the per-VM execution context that is global today. The
|
||
switch primitive then needs to swap nothing extra: whatever lives in the frame moves with the VM
|
||
by construction. Log attribution becomes "the frame of `sk_vm_switch_current_vm()`" rather than a
|
||
saved global. The vendored-source statics are `FABRIC-4.md`'s scope, but the frame is where they'd
|
||
go. **Should land before or with Stage C**, since Stage C is the first stage where switches
|
||
happen for real.
|
||
|
||
**RULED 2026-09-23: VM frames land first, as Stage FR, before Stage C.** Concrete design:
|
||
|
||
- **`include/starkernel/vm/vm_frame.h`**, new: `typedef struct VMFrame { int interpret_depth;
|
||
int hermes_draining; int parked_idle; struct VM *attrib_vm; } VMFrame;`. `VM` gets one field,
|
||
`VMFrame frame;`. The three loose fields Stages A and B added (`interpret_depth`,
|
||
`hermes_draining`, `parked_idle`) **move into it**, since they are exactly this cluster. All
|
||
their uses are renamed; behavior is unchanged.
|
||
- **Stack-scoped log attribution.** `attrib_vm` lives in the frame of the VM that **owns the
|
||
physical stack** (`sk_vm_switch_current_vm()`), and means "the innermost VM interpreting on
|
||
this stack." `vm_interpret(vm, …)` saves and restores `owner->frame.attrib_vm` instead of the
|
||
global. A nested `VM-EXEC` into Artemis on Hera's stack still attributes to Artemis, exactly
|
||
as today. `vm_log_attributed_vm()` returns `current->frame.attrib_vm` (or `current` itself when
|
||
no interpret is live on its stack). Because each stack carries its own chain, a switch can
|
||
never leave attribution pointing at the VM that just switched out. Before the switch
|
||
mechanism is seeded at boot (no current VM), the existing global path is kept as the fallback.
|
||
- **Not in the frame this stage:** `g_prng_state` (vendored `starforth_words.c`, shared with
|
||
the hosted build, open ruling §5 item 5), the compile-time statics (covered by the Stage A
|
||
`MODE_COMPILE` gate; `FABRIC-4.md` scope), and `g_vm_interpret_depth` (retired in Stage I).
|
||
|
||
**REVISED 2026-09-23, Captain Bob: *"give frames IDs and call by ID"*; chose *"frames stack per
|
||
VM, each with an ID."*** This supersedes the single-struct shape above (written, never booted):
|
||
|
||
- **`VMFrameEntry { VMFrameID id; VMUuid vm_id; uint32_t kind; }`**. `id` is a system-wide
|
||
monotonic 64-bit frame ID (0 = none, never reused). `vm_id` is the VM executing at that level,
|
||
**held as an ID, never a pointer**, so nothing can dangle after a VM dies. `kind` is
|
||
`VM_FRAME_INTERPRET` or `VM_FRAME_DRAIN`.
|
||
- **`VMFrameStack { entries, count, cap, max }`**, one per VM, embedded as `VM.frames`. It holds the
|
||
nested interpret levels running **on that VM's own native stack** (a nested `VM-EXEC` into
|
||
Artemis on Hera's stack is a frame on *Hera's* stack whose `vm_id` is Artemis). Growable like
|
||
`VMCallState`, but grown by allocate-copy-free rather than `sf_realloc` (which orphans the old
|
||
block). Before the switch mechanism is seeded, a boot-time stack in `vm_core.c` stands in.
|
||
- **`vm_interpret()` pushes one frame and pops it by ID.** It keeps its own frame's ID and, at
|
||
exit, checks the top is that ID before popping. A mismatch is logged as frame corruption.
|
||
That's the "call by ID" integrity check across switches.
|
||
- **Everything the loose fields held is now derived from the stack:**
|
||
- ownership gate (§3.1): `current == vm`, the stack holds exactly one frame, and its `vm_id` is
|
||
`vm`;
|
||
- drain guard (§3.3): a `VM_FRAME_DRAIN` frame for `vm` is already on the current stack (the
|
||
drain pushes its interpret as `DRAIN` kind);
|
||
- parked-idle (§3.5): `VM_STATE_SWITCHED_OUT` **and the top frame is `VM_FRAME_IDLE`**. The
|
||
trampoline pushes that frame just around its idle yield and pops it on resume, *before*
|
||
draining. An empty stack alone is not enough: Hera parked at an idle point (Stage F) has zero
|
||
interpret frames but her whole REPL call chain live on her stack. The frame records *where*
|
||
the VM is parked, which the flag used to;
|
||
- log attribution: the top frame's `vm_id` on the current owner's stack, **resolved by ID**
|
||
through the VM registry. An empty stack means NULL, which maps to `HADES`.
|
||
- **Failure rules.** A frame push that can't grow **fails closed**: log it, set `vm->error`,
|
||
don't interpret. Interpreting without a frame would silently break the gate and attribution.
|
||
An initial capacity is allocated at birth so the hot path almost never allocates. A
|
||
**pop-by-ID mismatch** means corruption (every stack is LIFO, since resume restores the owner as
|
||
current): log both IDs, count it, and remove *our own* ID wherever it sits rather than popping
|
||
someone else's frame. No code holds a `VMFrameEntry *` across an interpret body (growth
|
||
copies), only the ID. The frame-ID counter has a single writer: `vm_interpret()`, mainline
|
||
only, with no interrupt-context caller (checked by grep).
|
||
|
||
**Evidence, named in advance (probe, reverted after capture).** Two temporary C words, `SB-GO`
|
||
(switch to Hestia) and `SB-BACK` (switch back to whoever switched in), plus `SB-ATTRIB` (record
|
||
`vm_log_attributed_vm()`'s name). These are *probe-only* control words, never committed; they
|
||
exist to force the interleaving that Stage C will produce naturally. Hestia is queued a
|
||
message `SB-ATTRIB SB-BACK SB-ATTRIB`, then Hera runs `SB-ATTRIB SB-GO SB-ATTRIB SB-GO`. The
|
||
recorded sequence must be **Hera, Hestia, Hera, Hestia**. The third entry is the discriminator:
|
||
Hera resuming while Hestia is still parked mid-payload. **Baseline first:** the same probe on
|
||
the pre-frame code must *fail* on that third entry (reading Hestia), proving the test detects
|
||
the defect before the fix is claimed.
|
||
|
||
### 3.13 Stage C design: heat under self-driven execution, and the switch decision (FOR REVIEW, 2026-09-23)
|
||
|
||
§3.11 ruled *coolest VM with pending work goes next, no scheduler*. Before designing the
|
||
comparison, I traced (read-only) what that heat actually does once a VM runs its own mail on its
|
||
own stack. **The finding blocks Stage C as written.** Everything below is traced unless marked as
|
||
a proposal.
|
||
|
||
#### 3.13.1 What the trace found
|
||
|
||
**G9: when a VM runs its own mail, VM-physics heat doesn't move.**
|
||
|
||
- `vm_physics_touch()` (`capsule_vm_physics.c:284`) is the only thing that moves
|
||
`execution_heat_q48` between live VMs (apart from `retire`, which moves heat to the root on
|
||
kill). Its only callers are Hera's three orchestration verbs: `VM-STEP`, `VM-EXEC` and
|
||
`VM-CALL` (`mama_forth_words.c:723/811/946`). Each one touches the *target* at dispatch.
|
||
- `sk_hermes_drain_checkpoint()` and the trampoline (`switch.c`) call nothing in physics. A
|
||
VM that runs a kernel-Hermes payload on its own stack, which is the whole point of Stages
|
||
C–H, leaves every heat value exactly where it was.
|
||
- Starting state (`vm_physics_init:188`): Hera holds all of `Q48_ONE` (0x10000 raw) and every
|
||
other VM starts at 0.
|
||
- **Consequence:** once the DoE is rebuilt on `SEND` (§3.9), nothing ever calls `VM-EXEC`. Every
|
||
worker stays at 0 and Hera stays at `Q48_ONE` forever. "Coolest with work" becomes a permanent
|
||
tie among all workers, and whatever breaks that tie is the scheduler in all but name. That
|
||
violates *"no scheduler allowed."*
|
||
|
||
**The transfer law barely moves heat at switch granularity** (`vm_physics_touch`, lines
|
||
301–343):
|
||
|
||
- The formula is `amount = elapsed_beats × slope >> 16`, with slope seeded at `65536/3` = 21845
|
||
raw.
|
||
- `elapsed_beats` counts fleet beats since *this VM's own last touch*. Every touch resets it
|
||
(`last_active_tick = now`), and a VM's first-ever touch moves nothing (`touched == 0`).
|
||
- Worked out: elapsed 1, 2 or 3 moves **0**. 3 × 21845 = 65535, and 65535 >> 16 = 0. Elapsed 4
|
||
moves 1 raw unit (1/65536 of the fleet). Elapsed 100 moves 33 raw units, about 0.05%.
|
||
- The truncated remainder is thrown away, not carried. So touching often moves *nothing*, which
|
||
rules out the obvious "touch the running VM at each of its beats": elapsed would always be 1.
|
||
- The seed's own comment (lines 138–144) already says the slope is **"UNVALIDATED after the
|
||
ns-to-tick restatement… a real re-tune is DoE work (item 5.1)."**
|
||
|
||
**What a beat is, and that every VM produces them** (checked because Bob ruled out the timer):
|
||
|
||
- The fleet beat (`fleet_heartbeat_tick_count`) advances once per `vm_heartbeat_run_cycle()` →
|
||
`vm_tick()` → `vm_physics_heartbeat_tick()`.
|
||
- That runs every `HEARTBEAT_CHECK_FREQUENCY` (256, Kconfig) words of **a VM's own execution**.
|
||
The per-VM `check_counter` is bumped at both `vm_core.c:909` (each colon word) and `:1286`
|
||
(each word parsed by `vm_interpret`).
|
||
- `heartbeat_enabled = 1` and `worker = NULL` are set for every VM in `vm_init`
|
||
(`vm_bootstrap.c:305/308`). The pthread worker path is hosted-only.
|
||
- **So the beat is execution-paced, produced by whichever VM is running, and not the timer.**
|
||
The *timer* ISR is a different function, `heartbeat_tick()` in `heartbeat.c`. Today it calls
|
||
`sk_vm_switch_signal_tick()` (`heartbeat.c:179`), which is the readiness path Bob ruled out.
|
||
In this doc, "beat" always means the execution-paced one.
|
||
- Each VM also keeps its own beat count, `vm->heartbeat.tick_count` (`vm_runtime.c:118`), so
|
||
the beats a VM produced during one turn can be read directly.
|
||
|
||
**Stadium heat doesn't answer "whose turn" either.**
|
||
|
||
- A message's Stadium heat is pulled from the **sender's** reservoir at `sk_hermes_alloc()`, and
|
||
the cell's owner is the sender. On release it goes back to the **sender** (`stadium_evict()`).
|
||
- The recipient's Stadium state never changes when it receives or drains.
|
||
- `sk_hermes_decay()` is only called from the boot self-tests (`kernel_main.c:969/1006/1033`).
|
||
- So Stadium heat measures a VM's *outstanding sent mail*. It says nothing about how much CPU a
|
||
recipient has had. It's listed for completeness, not proposed.
|
||
|
||
**Stage C reverses a recorded decision.** `capsule_vm_switch_signal.h:28–34` deliberately did
|
||
*not* use VM-physics heat for switching, on timescale grounds: word-level dispatch over millions
|
||
of executions versus instant-by-instant readiness. Bob's §3.11 ruling overrides it. The
|
||
truncation above is that timescale gap in concrete form, and §3.13.2 is how it would be closed.
|
||
|
||
**The existing law measures age, not work** (derived from the code, worth stating because it
|
||
changes what "coolest" means):
|
||
|
||
- A touch pulls in proportion to beats *since the last touch*.
|
||
- Over a VM's life, the sum of its pulls is its age in beats × slope, however often or rarely it
|
||
actually ran. How often it ran only changes *when* it gets charged.
|
||
- It loses heat when others are touched, in proportion to its share.
|
||
- So under the current law, "coolest" ranks VMs by charge timing and share, not by CPU used.
|
||
|
||
#### 3.13.2 Proposal: charge a VM for the beats it produced (ruling C-1)
|
||
|
||
**Proposed: charge the running VM once at each of its own beats, keeping a carry.**
|
||
|
||
- At every beat a VM produces while it is the switch-current VM, it is charged one beat's worth:
|
||
`slope` raw units, plus the carry left over from its previous beats.
|
||
- The whole units are pulled from the rest of the live fleet in proportion to each VM's heat,
|
||
through the existing conservative `vm_physics_transfer()`. Nothing is created, and fleet K is
|
||
still conserved.
|
||
- The fraction below one unit stays in a per-VM carry. That carry is what makes a per-beat charge
|
||
work where a per-beat *touch* can't (see row (d)).
|
||
- It would be one new entry point in `capsule_vm_physics.c`, e.g. `vm_physics_charge(vm_id)`,
|
||
called from the beat path (`vm_physics_heartbeat_tick()`'s caller) for the running VM.
|
||
- It is kernel-side and invisible to anything a VM executes.
|
||
- Result: **the VM that has done the most work is the hottest,** and its heat rises *while it
|
||
runs*. That matches the plain meaning of "coolest VM with work goes next."
|
||
|
||
**Why per beat and not at switch-out (corrected before review).** An earlier draft of this
|
||
section charged only at switch-out. That design is **purely cooperative**, which contradicts
|
||
§3.11:
|
||
|
||
- During a turn nothing would change. The running VM's stored heat stays where it was at
|
||
switch-in, and the waiting VMs are untouched.
|
||
- So every decision taken during the turn repeats the answer from switch-in. The running VM is
|
||
never displaced, and a long payload keeps the CPU until it finishes.
|
||
- Charging per beat is what makes the running VM's own execution displace it, which is the
|
||
preemptive effect.
|
||
|
||
**Worked example.** Two workers start at 0 with equal work.
|
||
|
||
- The running worker gains 21845/65536 of a unit per beat, so a whole unit moves every 3 beats.
|
||
- At that point the other worker is strictly cooler and takes over at the next beat decision.
|
||
- They alternate every ~3–6 beats. The turn length comes out of the physics; nobody chose it.
|
||
|
||
| Option | Moves heat under self-driven execution? | Preemptive in effect? | Side effects |
|
||
|---|---|---|---|
|
||
| **(a) Charge at each of the running VM's own beats, with carry** (proposed) | Yes, in proportion to work | **Yes**, because heat rises during the turn | New entry point. Proposed: records **no** `fleet_window` samples (see "Frozen slope" below). |
|
||
| (a′) Charge at switch-out by `tick_count` delta | Yes, in proportion to work | **No**, unless decisions compare *projected* heat: stored heat plus the charge for the beats produced so far this turn. With projection it is equivalent to (a), but without any mid-turn transfer. | Same as (a). |
|
||
| (b) `vm_physics_touch()` on switch-in (the `VM-EXEC` analogue) | Yes, but it charges *time away*, not work (see "age, not work"), and needs ≥4 beats away before anything moves | No | Writes `fleet_window` samples, which changes slope adaptation and comparability with existing DoE heat columns. |
|
||
| (d) `vm_physics_touch()` at each beat | **No.** Elapsed is always 1 and the remainder is discarded, so 0 moves, forever | — | Rejected on the arithmetic. Row (a) differs only by keeping the carry and not resetting the elapsed count. |
|
||
|
||
**Truncation (ruling C-2).** With the carry, nothing is lost to rounding under (a). The other
|
||
choices:
|
||
|
||
- (i) no carry: then (a) moves nothing, the same as (d);
|
||
- (iii) refit the slope at beat scale. That is DoE item 5.1 and **out of scope** here.
|
||
|
||
The carry applies to the new charge only. `vm_physics_touch()` is untouched, so `VM-EXEC`
|
||
transfers stay byte-for-byte as today.
|
||
|
||
**Frozen slope (part of C-1).**
|
||
|
||
- With no samples recorded by the charge, and no `VM-EXEC` in the rebuilt DoE, the adaptive
|
||
`fleet_transfer_slope_q48` **never updates under self-driven load**. It stays at the
|
||
unvalidated seed.
|
||
- Recording samples would let it adapt. It would also change what the existing heat columns mean
|
||
compared with past campaigns.
|
||
- Bob should choose this knowingly.
|
||
|
||
**Two laws on one ledger.** Artemis, and any participant that Hera also `VM-EXEC`s, is both
|
||
*touched* (the dispatch law) and *charged* (the work law). Both go through `vm_physics_transfer()`
|
||
on the same heat, so conservation holds, but the VM's heat then mixes two meanings. Proposed:
|
||
accept this and document it. `VM-EXEC` into a participant becomes rare once §3.8's `SEND` exists.
|
||
|
||
**Where the charge starts.** Each VM's charge baseline is its beats at the moment it becomes
|
||
switch-current. Hera's is seeded at `sk_vm_switch_set_current()`, so her first charge doesn't
|
||
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.
|
||
|
||
**Hera's reservoir mass (ruling C-4, open, arithmetic shown).**
|
||
|
||
- Hera starts with all of `Q48_ONE` as the fleet's structural root (`vm_physics_init`'s "cold mass
|
||
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).
|
||
- 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.
|
||
- Options for Bob:
|
||
- (α) accept it;
|
||
- (β) compare the heat each VM has been *charged* (its turn heat) rather than its absolute
|
||
heat, so the root's structural mass doesn't count against it;
|
||
- (γ) something else Bob rules.
|
||
- I have no recommendation yet. (β) is a different physical quantity, and Bob should choose it
|
||
knowingly.
|
||
|
||
#### 3.13.3 Proposal: the decision (ruling C-3 on ties)
|
||
|
||
**Eligible** means the VM is a registered participant, is not the one running, is not dead or
|
||
stillborn, and **either** of the following holds:
|
||
|
||
- its kernel-Hermes pending count is > 0, read directly from the queue
|
||
(`sk_hermes_pending_count`), not from a `has_work` flag. The queue *is* the eligibility, so
|
||
`SWITCH-MARK-WORK` and G1's missing mark become unnecessary;
|
||
- it is switched out, its **frame stack is non-empty, and its top frame is not
|
||
`VM_FRAME_IDLE`**. That means it was displaced mid-task, or it is Hera with her REPL chain live.
|
||
- Without this clause, a VM preempted in the middle of a payload never gets picked again. This
|
||
is what the frame stacks were for.
|
||
- A never-entered VM has an empty stack, so it is not eligible unless it has mail.
|
||
- Hera parked at an idle point pushes an IDLE frame (§3.12, and §3.13.5 below), so she is not
|
||
eligible there unless she has mail or input.
|
||
|
||
**Switch when** some eligible VM is **strictly** cooler than the one running. If it is equal, the
|
||
running VM stays: a tie with the incumbent is "no reason to move," not a coin toss.
|
||
|
||
**Ties among several equally cool candidates (ruling C-3).**
|
||
|
||
- **Proposed:** the candidate that has been *waiting longest*, measured by one single-writer
|
||
event count.
|
||
- Each message is stamped with it when it is admitted (a new `SkHermesMessage` field).
|
||
- Each VM is stamped with it when it is displaced mid-task.
|
||
- A candidate's wait stamp is the minimum of its oldest pending mail and its displacement.
|
||
- Every stamp is unique, so the result is unique.
|
||
- This is the arrival order of the mail itself, the kernel's FIFO. It is a fact about the
|
||
messages, not a ranking of VMs.
|
||
- **Bob to rule** whether arrival order counts as "an ordering" under *"no scheduler allowed."*
|
||
- Rejected outright: slot index, UUID and registry order. Each of those is a fixed order of VMs.
|
||
|
||
**Testable "not a scheduler" criterion.**
|
||
|
||
- The decision is a pure function of physical state: heat, pending mail, frames and wait stamps.
|
||
It keeps no queue of its own and remembers nothing between decisions.
|
||
- It must return the same VM whichever order the participant table is walked.
|
||
- This is Stage C-b's evidence (below).
|
||
|
||
**When the decision is made** (only at moments the physics exposes, never on a clock):
|
||
|
||
1. **The running VM's own beat.** The beat sets a per-VM "decide" flag. The next *owned*
|
||
checkpoint consumes it (`sk_vm_owns_outermost_frame`). There are two checkpoint sites:
|
||
- the existing one in `execute_colon_word`;
|
||
- the between-words point in `vm_interpret_kind`'s loop, which needs a new gated checkpoint
|
||
so that a payload made only of primitives can be displaced.
|
||
2. **Mail arrival** to a VM that would then win.
|
||
3. **The trampoline's between-message point**, as already noted in `sk_vm_switch_entry()`.
|
||
4. **Idle points** (Stage F).
|
||
|
||
**Why not every word.** Under (a), heat changes only at beats, and eligibility changes only when
|
||
mail arrives or a queue empties. Between those events every decision would repeat the last one,
|
||
so deciding every word is pure overhead. Deciding at the beat means a turn lasts at least one beat
|
||
unless the VM runs out of work first.
|
||
|
||
**Flagged honestly:** that minimum is the existing Loop #7 cadence constant
|
||
(`HEARTBEAT_CHECK_FREQUENCY`). It is not new, but it does act as a floor on turn length.
|
||
|
||
**The switch target on an empty queue.**
|
||
|
||
- Today the trampoline yields to whoever switched in (`native_switch_back_to`).
|
||
- **Proposed:** it asks the decision instead, and falls back to `native_switch_back_to` only when
|
||
no VM is eligible.
|
||
|
||
#### 3.13.4 What removing the timer path changes
|
||
|
||
- `heartbeat.c:179`'s `sk_vm_switch_signal_tick()` call is removed. The ISR returns to its
|
||
pre-§XXVIII minimal shape, so the timer no longer decides anything.
|
||
- These are removed:
|
||
- the readiness accrual;
|
||
- `SK_SWITCH_READINESS_THRESHOLD` (50);
|
||
- `has_work`;
|
||
- `take_pending`.
|
||
- `note_switch_performed` is kept only as the counter.
|
||
- **Effect on the DoE CSV** (`doe_log.c:205–230`):
|
||
- `switch_count` and `switch_current_slot` survive.
|
||
- `switch_ticks_since` loses its meaning. Proposed: it becomes beats since the last switch.
|
||
- The three `*_readiness` columns die.
|
||
- Found while tracing, *not fixed*: those columns and the heat columns still look up
|
||
`"Hermes"`, which no longer exists as a VM since the reshuffle, so those values are always 0
|
||
today.
|
||
|
||
#### 3.13.5 The relay needs the idle point in the same stage (traced)
|
||
|
||
Stage C-b's own evidence, the WIREBIND relay delivered by a real switch, **can't be reached
|
||
without §3.7's idle point** (Stage F):
|
||
|
||
- The console line is forwarded from C. `sk_hermes_send_one(vm->stadium_vm_id, paired.vm_id,
|
||
CONSOLE_CMD, …)` at `repl.c:1510`, then `goto out`. Hera interprets nothing and goes straight
|
||
back to polling.
|
||
- Today the `~user` VM's mail is delivered by the idle pump (`repl.c:~909–928`), which drains
|
||
every live VM *on Hera's stack*. That is G3.
|
||
- Once the pump skips participants (C-b), the only places a switch can happen are owned word
|
||
checkpoints and beats. Hera at her poll loop reaches neither: she is running C, not words.
|
||
- So nothing switches, the `~user` VM's `CONSOLE-CMD-EVENT` sits in its queue, and the relay
|
||
breaks.
|
||
|
||
**Proposed:** fold §3.7's idle point into C-b.
|
||
|
||
- The pump loop's own location (the idle point, Hera between lines) becomes a decision point.
|
||
Hera pushes an IDLE frame, consults the decision, and switches to the coolest eligible VM
|
||
instead of draining participants on her stack. The pump then shrinks to Hera's own queue.
|
||
- Stage F then only has to show that work proceeds with the console idle.
|
||
- The alternative is to keep the pump draining participants until F. That keeps G3's
|
||
borrowed-stack delivery alive through C-b, so C-b's relay evidence would prove nothing about
|
||
switching.
|
||
|
||
**Hera's eligibility at the idle point.** Keyboard or serial input pending is Hera's mail. At
|
||
the idle point she is eligible when input is pending (the REPL's own input poll), exactly as a
|
||
worker is eligible when its kernel-Hermes queue is non-empty. **Ruling C-5**, below.
|
||
|
||
#### 3.13.6 Stage C, split
|
||
|
||
This splits the single C row in §4 in two:
|
||
|
||
- **C-a** is physics only: heat starts moving and nothing switches yet.
|
||
- **C-b** is the decision: pump skip, eligibility read from the queue, the ISR path removed, and
|
||
the idle point (§3.13.5).
|
||
|
||
C-a is observable on its own, and doing it first proves the heat signal is alive before anything
|
||
depends on it. The §4 table below is updated to show this split, **pending rulings C-1 to C-4.**
|
||
|
||
---
|
||
|
||
## 4. Staged plan
|
||
|
||
Each stage is its own commit and its own three-arch acceptance, and each names its evidence
|
||
**before** it runs (`FABRIC-3.5.md` §XXXV.6). A green boot proves nothing here: a switch storm and a
|
||
healthy idle produce the same serial log (`FABRIC-3.md` §XXVIII.1).
|
||
|
||
| Stage | Change | Evidence that proves it (named in advance) |
|
||
|---|---|---|
|
||
| **A** | §3.1 per-VM ownership gate (+ `MODE_COMPILE` gate). Nothing else. | Byte-identical behavior: `dict_hash` triple unchanged, identical boot, WIREBIND mint→attach→`USE`→relay (the `FABRIC-3.6.md` Phase 4 acceptance sequence) still `1 2 + .` → `3`. |
|
||
| **B** | §3.2 trampoline idle loop + §3.3 `hermes_draining` + §3.5 parked-idle flag. Still inert (nothing marks work yet). | Same as A: nothing can reach the new trampoline branch yet, so behavior is identical. A synthetic self-test (kernel_main.c, the §XXVIII Stage 2 `SWITCH-TEST` pattern: write, capture, revert) switches into **Hestia** (not Artemis, whom the Zuse attach flow `VM-EXEC`s; a parked VM refuses `VM-EXEC`) with **two** messages queued, the first calling a colon word so the in-word drain checkpoint is actually reached. The ordering witness `SB-SEQ` must equal 12 (one-at-a-time drain), not 21 (nested). She must end parked-idle at depth 0 with her context saved on her own stack, current VM back to Hera, and the ledger and `stadium_conserved()` restored. |
|
||
| **FR** | §3.12 VM frames: `VMFrame` header; Stage A/B fields move into it; stack-scoped log attribution. | Probe `SB-ATTRIB`/`SB-GO`/`SB-BACK` (reverted): attribution sequence Hera, Hestia, **Hera**, Hestia on all three ISAs, and the **same probe fails on the pre-frame baseline** (third entry reads Hestia). Then formal acceptance: `dict_hash` unchanged, relay works. |
|
||
| ~~**C**~~ | ~~§3.4 eligibility marked + pump skips participants (one commit).~~ Split into C-a/C-b by §3.13 (pending rulings C-1–C-4). | (see C-a, C-b) |
|
||
| **C-a** | §3.13.2 the running VM is charged at each of its own beats, with carry. Physics only; switching unchanged. | Temporary probe (reverted), which switches Hestia in with the Stage FR probe's `SB-GO`/`SB-BACK` words, because C-a itself switches nothing. Hestia runs a colon-word payload that produces ≥ 8 beats. Her `VM-HEAT` is sampled **at a beat during the turn** and again after it, and must rise from 0 by `beats × slope` (whole units, remainder kept in carry). The rest of the live fleet must fall by the same total. `vm_physics_conserved()` holds, and the `fleet_window` sample count is unchanged. **Baseline first:** the same probe on current code shows heat unchanged (proves G9). Then acceptance: `dict_hash` unchanged and the relay works. |
|
||
| **C-b** | §3.13.3 decision at owned checkpoints, beats, the trampoline and **the idle point** (§3.13.5, which absorbs Stage F's §3.7). Eligibility comes from the queue or a non-IDLE top frame. The pump skips participants. The ISR switch path and readiness are removed (§3.13.4). | The WIREBIND relay still works, now delivered by a real switch: `switch_count` increments across the relay and the `~user` VM's own drain line appears. Ledger and `stadium_conserved()` hold. `heartbeat_tick()` contains no switch call (source). **Walk-order probe** (temporary): every decision is also computed with the participant table walked in reverse; disagreements = 0. No `native_stack_saved_sp` ever inside Hera's stack range (temporary probe). No switch storm (log growth at idle `ok>`, QMP `send-key` computes a value). |
|
||
| **D** | §3.5 kill of parked-idle + unregister in `capsule_vm_kill()`. | Birth, message, idle-park, `KILL` succeeds; the switch-signal slot count returns to its prior value. |
|
||
| **E** | §3.6 generic registration in `capsule_birth_baby()`, duplicate call sites removed. | The Tripod and WIREBIND VMs still register exactly once (slot count), and a `BIRTH`-ed VM appears as a participant. |
|
||
| **F** | §3.7 idle point is a switch point. *(If C-5 is ruled yes, the mechanism lands in C-b and F keeps only this evidence.)* | With Hera idle at `ok>`, a message sent to a worker **gets processed without typing anything**: the worker's output appears while the console is idle. |
|
||
| **G** | §3.8 `SEND`. | Two workers, each sent a payload that loops printing a tagged marker (`A` / `B`) N times: **the markers interleave** in the serial log. This is the one-line proof of concurrency, and it's the test this whole document exists to pass. |
|
||
| **H** | §3.9 DoE driver rebuilt (capsule change only). | A single trial at cfg 4 (8 workers): `switch_current_slot` in the per-beat DoE rows moves across **multiple worker slots within one trial**, and `MU-TRIAL … fail=0`. Then Bob's call on relaunching the campaign. |
|
||
| **I** | Strip dead code: the pump loop once every VM is a participant, `SWITCH-MARK-WORK` if nothing calls it, the global depth counter once attribution no longer needs it. | Per `FABRIC-3.5.md` §XXII's strip discipline: reachability shown three ways, not by grep alone. |
|
||
|
||
**Stop conditions** (re-plan, don't push on): `dict_hash` diverges across ISAs; the ledger or
|
||
`stadium_conserved()` goes false; any evidence of a stack saved under the wrong VM; a switch storm
|
||
(serial log frozen with QEMU pinned, checked by log line-count growth at an idle prompt and by
|
||
QMP `send-key` computing a value, per `FABRIC-3.6.md` task 1.5).
|
||
|
||
---
|
||
|
||
## 5. Rulings needed from Captain Bob before code
|
||
|
||
1. **Go/no-go on the staged plan (§4)**, one stage at a time.
|
||
2. **Workload capsules (§3.9):** keep self-execution at birth (the heavy phase stays serial), or
|
||
born definitions-only with the workload as the first message (recommended, so the measured
|
||
phase is the concurrent one). If the latter: new `-def` capsule variants, or strip the tail
|
||
call from the existing `workload-N.4th` files (check their other users first, per `FABRIC-3.5.md`
|
||
§XXII.2's three reachability routes)?
|
||
3. **The send word's name** (§3.8): `SEND`, `HERMES-SEND`, `KH-SEND`, something else.
|
||
4. **The multiuser factor.** The DoE as built varies concurrency × workload mode and runs once
|
||
per ISA. `FABRIC-3.md` §XXXIII.2 planned identity origin as a fixed arm, never built. Should
|
||
identities (`UNATTENDED-BIRTH` capsule identities, or WIREBIND thumbdrives) become a crossed
|
||
factor alongside concurrency and ISA, now that "users" is meant to be as important as the other
|
||
two? This decides the DoE's shape, not the OS work, and can wait until Stage H.
|
||
5. **§3.10's shared PRNG:** accept it for this pass (run order is still deterministic), or put
|
||
per-VM `RANDOM` state in scope.
|
||
|
||
6. **C-1 (§3.13.2):** how heat is charged under self-driven execution.
|
||
- Proposed (a): the running VM is charged at each of its own beats. Switch-out-only charging
|
||
is purely cooperative, unless decisions compare projected heat (a′).
|
||
- Also: charge records no `fleet_window` samples, which means the slope stays frozen at its
|
||
unvalidated seed under self-driven load. Or record them and let it adapt.
|
||
7. **C-2 (§3.13.2):** a per-VM carry on the new charge only. Without it, (a) moves nothing.
|
||
8. **C-3 (§3.13.3):** ties. The incumbent stays on a tie with itself. Among several candidates:
|
||
whether "waiting longest," by a single-writer arrival stamp, is acceptable under *"no scheduler
|
||
allowed,"* or whether Bob wants something else. Slot, UUID and registry order are rejected.
|
||
9. **C-4 (§3.13.2):** Hera's reservoir mass means the console gets no turn under worker load.
|
||
Options are (α) accept it, (β) compare charged heat instead of absolute heat, or (γ) something
|
||
else. No recommendation.
|
||
10. **C-5 (§3.13.5):** fold the idle point (Stage F's §3.7) into C-b, since the relay can't be
|
||
delivered by a switch without it. Also: Hera's pending console input counts as her mail for
|
||
eligibility.
|
||
|
||
**Nothing in this document authorizes an edit.** Per Captain Bob's Law, no stage starts without an
|
||
explicit instruction.
|
||
|
||
---
|
||
|
||
## 6. Execution log
|
||
|
||
- [x] **Stage A — per-VM ownership gate (§3.1).** 2026-09-23. `include/vm.h`: new
|
||
`VM.interpret_depth`. `vm_core.c`: `vm_interpret()` raises and lowers it beside the global
|
||
counter; new `sk_vm_owns_outermost_frame(vm)` (`sk_vm_switch_current_vm() == vm &&
|
||
vm->interpret_depth == 1 && vm->mode != MODE_COMPILE`) replaces `sk_vm_at_outermost_interpret()`
|
||
at both checkpoint sites in `execute_colon_word()` (drain and switch). The global counter and
|
||
the old function stay until Stage I.
|
||
**Evidence, as named in §4:** clean build on all three ISAs with no new warnings (only
|
||
the pre-existing `mkcapsule`/linker ones), hosted build clean. Foreground, visible-framebuffer
|
||
boots, with `disk/artemis.img` + `zuse-thumb-ident.img` + `bob-thumb-ident.img` reset together
|
||
before each: `logs/20260923-140120/amd64/`, `logs/20260923-140654/aarch64/`,
|
||
`logs/20260923-141303/riscv64/`. All reach `[zuse@Hera] ok>` with zero `UNKNOWN WORD`, zero
|
||
self-test FAIL/fault/panic, and a `dict_hash` set **identical across all three and unchanged
|
||
from the pre-Stage-A boot** (`logs/20260923-110505/amd64/`): M7.1a `0x3933d76939ef66c6`, Hera
|
||
`0x27ba08011349842b`, Hestia `0xdd1aa879b3a6c5df`, Artemis `0xe9d0408b1119d51f`. The full live
|
||
WIREBIND sequence on each ISA: hot-attach blank `bob` via QMP → `MINT` (`-1`,
|
||
restricted) → detach/reattach → `WIREBIND: rajames attached` → `S" rajames" USE` →
|
||
`1 2 + .` → `3`, delivered via `Kernel-Hermes CONSOLE-CMD-EVENT` with
|
||
`stadium_conserved(self)=true`. Clean QMP `quit` each time, no leaked processes.
|
||
note: this relay still runs through the idle pump on Hera's stack (G3, unchanged until
|
||
Stage C). With the new gate, checkpoints inside that relayed payload now correctly **fail**
|
||
the ownership test (Hera owns the stack, not `rajames~user`). Before Stage A they passed on
|
||
the global depth count, which was the latent §1 hazard.
|
||
- [x] **Stage B — trampoline idle loop, one drain at a time, parked-idle (§3.2/§3.3/§3.5).**
|
||
2026-09-23. `include/vm.h`: `VM.hermes_draining`, `VM.parked_idle`. `kernel_hermes.c`:
|
||
`sk_hermes_drain_checkpoint()` refuses while `hermes_draining` is set, and sets and clears it
|
||
around its `vm_interpret()` (set only once a message is in hand; single exit). `switch.c`:
|
||
`sk_vm_switch_entry()` is now each participant's kernel-side idle loop. It drains one of its
|
||
own messages per pass, driven by the drain's return value, not a separate pending probe
|
||
(so a non-consuming drain can't spin with no checkpoint to break it). When nothing is
|
||
drained, it sets `parked_idle` and yields to `native_switch_back_to`; a failed yield panics.
|
||
Inert in normal operation: nothing marks work yet (Stage C), so no real boot enters it.
|
||
**Finding, fixed in this stage: amd64 first-entry stack misalignment.** The first probe boot
|
||
(`logs/20260923-142526/amd64/`, kept) took **#GP (vector 13)** the moment Hestia's trampoline
|
||
called into `vm_interpret()`. `sk_vm_switch_prime` (amd64 `switch.S`) built its frame at
|
||
`top − 56`, so the entry function ran with `rsp == top` (16-aligned). SysV needs
|
||
`rsp ≡ 8 (mod 16)` at entry, so every compiler-emitted aligned SSE spill faulted. Latent since
|
||
§XXVIII Stage 2, because the old placeholder never called anything that spilled. Fixed:
|
||
frame at `top − 64` with a zeroed pad slot. aarch64 and riscv64 return through the link
|
||
register and already land 16-aligned; no change there.
|
||
**Evidence, as named in §4:** a temporary probe (kernel_main.c, written, captured,
|
||
**reverted**; `git diff` afterward shows only `vm.h`, `kernel_hermes.c`, `switch.c` and amd64
|
||
`switch.S`) queued two messages to Hestia, the first calling a colon word, and switched
|
||
Hera→Hestia. `STAGE-B PROBE: PASS` on all three: `logs/20260923-143121/amd64/`,
|
||
`-143435/aarch64/`, `-143838/riscv64/`. Sends ok, switch ok, 0 pending, `parked_idle=1`,
|
||
depth 0, context saved on her own stack, current VM back to Hera, **`SB-SEQ = 12`** (one at a
|
||
time; a nested drain gives 21), ledger `held` restored, `stadium_conserved(hera)` true. The
|
||
probe's printed line reads `seq=ld …` because this kernel's freestanding `snprintf` has no
|
||
`%lld` and shifted the arguments after it; the PASS test compares values directly and is
|
||
unaffected. Formal acceptance with the probe gone, coupled images reset before each:
|
||
`logs/20260923-144234/amd64/`, `-144701/aarch64/`, `-145224/riscv64/`. All reach
|
||
`[zuse@Hera] ok>` with zero `UNKNOWN WORD`/failures/faults, the `dict_hash` set identical
|
||
across ISAs and unchanged from Stage A, and the full mint → WIREBIND → `USE` → `1 2 + .` →
|
||
`3` relay with `stadium_conserved(self)=true`.
|
||
- [x] **Stage FR — VM frames: a frame stack per VM, each frame with an ID (§3.12).** 2026-09-23.
|
||
Captain Bob: *"give frames IDs and call by ID"*, *"frames stack per VM, each with an ID."*
|
||
New `include/starkernel/vm/vm_frame.h` / `src/starkernel/vm/vm_frame.c`: `VMFrameEntry`
|
||
`{ VMFrameID id; VMUuid vm_id; kind }` (kinds `INTERPRET`/`DRAIN`/`IDLE`); `VMFrameStack`
|
||
embedded as `VM.frames`, reserved at birth (`vm_init_with_host`), freed in `vm_cleanup`,
|
||
grown by allocate-copy-free. `vm_interpret()` → `vm_interpret_kind()`: pushes one frame onto
|
||
the running stack's owner (`vm_frame_current_stack()`; a boot stack before seeding), holds
|
||
only its ID, **fails closed** if the push can't grow, and **pops by ID** at exit (a mismatch
|
||
is printed as `VM FRAME CORRUPTION` and only our own frame is removed). The Stage A/B loose
|
||
fields are gone, all derived now: the ownership gate is "the owner's stack holds exactly one
|
||
frame and it's `vm`'s"; the drain guard is "a `DRAIN` frame for `vm` is on the running
|
||
stack"; parked-idle is a `VM_FRAME_IDLE` frame the trampoline pushes around its yield and
|
||
pops before draining; log attribution is the top frame's `vm_id`, **resolved by ID** through
|
||
the VM registry (empty stack → NULL → `HADES`). The attribution global is removed.
|
||
**Evidence, as named in §4:**
|
||
- **Baseline (pre-frame code, probe only):** `logs/20260923-150901/amd64/` →
|
||
`attribution=HSSAS want=HSHAS FAIL`. The old global named Hestia while Hera resumed,
|
||
which is exactly the defect.
|
||
- **With frames, probe on all three ISAs:** `logs/20260923-152109/amd64/`,
|
||
`-152424/aarch64/`, `-152828/riscv64/` → `attribution=HSHAS hestia_idle_top=1
|
||
hera_frames=0 PASS`. That covers interleaved attribution, nested `VM-EXEC` into Artemis
|
||
still attributed to Artemis, Hestia parked with an `IDLE` top frame, and Hera's frames
|
||
back to zero. The probe (`SB-GO`/`SB-BACK`/`SB-ATTRIB`, probe-only control words) was
|
||
reverted (`git checkout -- src/starkernel/kernel_main.c`).
|
||
- **Formal acceptance without the probe, coupled images reset before each:**
|
||
`logs/20260923-153227/amd64/`, `-153644/aarch64/`, `-154208/riscv64/`. All reach
|
||
`[zuse@Hera] ok>`, with zero `UNKNOWN WORD`/failures/faults, **zero `VM FRAME CORRUPTION`
|
||
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.
|