/* StarForth — Steady-State Virtual Machine Runtime Copyright (c) 2023–2025 Robert A. James All rights reserved. This file is part of the StarForth project. Licensed under the StarForth License, Version 1.0 (the "License"); you may not use this file except in compliance with the License. */ /** * capsule_vm_physics.c - Dynamic VM Fleet Physics Implementation * * See capsule_vm_physics.h and * docs/working/architecture/VM-PHYSICS-DYNAMIC-FLEET-DESIGN-20260705.md * for the full design rationale. * * Concurrency: no locking. The only interrupt-driven kernel path * (heartbeat_tick, from the timer ISR) touches only its own isolated * TimeTrustState and never calls into mama_word_*, vm_interpret, or any * capsule/dictionary state. HEARTBEAT_THREAD_ENABLED=0 means no separate * OS thread runs physics logic either. BIRTH/KILL/VM-EXEC/VM-CALL/ * VM-STEP all execute synchronously in Hera's single interpreter * context with nothing able to preempt them mid-transfer. * * L8 coupling (rev q): the only place this file reaches outside its own * VMPhysics/VMFleetWindow state is l8_regime_modulation_q16(), a * read-only look at Hera's own ssm_l8_state via sk_get_mama_vm() -- * modulating the fleet's empirically-recovered transfer rate uniformly, * never deciding which VM receives heat. Still a passive observer: this * changes how fast the fleet's own rate estimate responds, not who runs * or who gets touched. */ #include "starkernel/capsule_vm_physics.h" #include "starkernel/capsule_birth.h" #include "starkernel/kmalloc.h" #include "starkernel/q48_16.h" #include "starkernel/console.h" #include "starforth_config.h" /* STADIUM_CAPACITY_TICK */ #include "starkernel/vm/bootstrap/sk_vm_bootstrap.h" /* sk_get_mama_vm */ #include "vm.h" /* VM, vm->ssm_l8_state */ #include "ssm_jacquard.h" /* ssm_l8_state_t, CDConfigTable */ /* Word-level ROLLING_WINDOW_SIZE defaults to 4096 because word executions * number in the millions. Fleet touches are real VM-EXEC/VM-CALL/VM-STEP * dispatches -- dozens to low hundreds over a full boot + acceptance run. * 64 gives the regression enough samples to stabilize without being * wastefully oversized for a 3-8 VM fleet. Compile-time constant, same * tuning-knob convention as ROLLING_WINDOW_SIZE. */ #define VM_FLEET_WINDOW_DEPTH 64 /* |fleet_heat_sum - Q48_ONE| < 5% of Q48_ONE, mirrors fleet-k.4th's * K-EPSILON (3277). */ #define VM_PHYSICS_EPSILON_Q48 3277ULL typedef struct { uint64_t execution_heat_q48; uint64_t last_active_tick; /* fleet_heartbeat_tick_count value at last touch */ int touched; /* 0 until the first touch; explicit rather than * overloading last_active_tick == 0, which is a * legitimate tick value a first touch can land on */ int is_live; } VMPhysics; typedef struct vm_physics_node { VMUuid vm_id; VMPhysics physics; struct vm_physics_node *next; } vm_physics_node_t; /* kmalloc-backed linked list, same pattern as capsule_birth.c's * vm_registry_head/vm_registry_count -- unbounded, not a fixed array. */ static vm_physics_node_t *vm_physics_head = (void *)0; /* One recorded touch's inputs to the transfer law (amount = elapsed_ticks * * slope >> 16, clamped to available heat) -- not a derived heat value. * Rev o (VM-FLEET-ATTRACTOR-DESIGN-20260705.md) replaced the earlier * heat-snapshot design (rev m) with this: since the transfer law is known * exactly, the true rate can be recovered directly from any touch that * wasn't clamped (rate = amount / elapsed_ticks), rather than curve-fitting * a reconstructed trajectory against a synthetic time axis. * * Restated on the virtual tick (FABRIC.md item 2.1, 2026-08-04): this used * to be elapsed_us, wall-clock microseconds -- non-reproducible under TCG * per GAP-A1 (FABRIC.md §25.7.1). Now counts fleet_heartbeat_tick_count * ticks, which is execution-paced (incremented once per vm_tick() call, * confirmed at vm_runtime.c:143). */ typedef struct { uint64_t elapsed_ticks; /* since this vm_id's previous touch; 0 if there * was no previous touch to compare against */ uint64_t amount; /* elapsed_ticks * slope >> 16 at touch time, * i.e. the rate-implied transfer before any * clamp; 0 if not computed (first touch, zero * slope, or non-monotonic tick) */ int clamped; /* 1 if the heat actually moved was capped below * `amount` by what the rest of the fleet held -- * carries no rate information, excluded from * the estimator rather than treated as a zero */ } VMFleetTouchSample; typedef struct { VMFleetTouchSample touch_samples[VM_FLEET_WINDOW_DEPTH]; uint32_t head; uint32_t count; /* saturates at VM_FLEET_WINDOW_DEPTH */ int is_warm; /* count >= VM_FLEET_WINDOW_DEPTH */ } VMFleetWindow; /* One instance, fleet-wide -- not per-VM state. */ static VMFleetWindow fleet_window = { { { 0, 0, 0 } }, 0, 0, 0 }; /* Below this many informative (unclamped) samples in a full window, skip * the fit and keep the current slope rather than trust a handful of * points -- same "skip, don't substitute a degenerate value" philosophy * as the is_warm gate. Arbitrary but conservative relative to the * 64-deep window: needs an eighth of the window to be informative. */ #define VM_PHYSICS_MIN_INFORMATIVE_SAMPLES 8 /* Shared adaptive slope, fit from aggregate fleet statistics (one slope * for the whole fleet), exactly mirroring how the word engine infers one * decay_slope_q48 per VM instance rather than one per word. * * Seeded at 65536/3 (Q48.16 for 1/3), matching the word engine's actual * bootstrap value (src/vm_bootstrap.c / src/starkernel/vm/vm_bootstrap.c: * "vm->decay_slope_q48 = (1ULL << 16) / 3"). The old comment on that field * in include/vm.h ("starts at 2:1 = 131072") is stale relative to the real * code -- 1/3 is what's actually seeded there, so 1/3 is what's mirrored * here. This was previously seeded at literal 0, which cannot bootstrap: * vm_physics_touch's transfer amount is elapsed_ticks * slope >> 16, so a * zero slope moves zero heat on every touch forever, which starves * vm_physics_tick's regression of the very heat-trajectory signal it * needs to infer a better slope -- a closed loop with no way out. Found * via doe_log.c's per-VM heat CSV columns showing a dead-flat trajectory * across a full boot (VM-FLEET-ATTRACTOR-DESIGN-20260705.md rev f). * * UNVALIDATED after the ns-to-tick restatement (FABRIC.md item 2.1, * 2026-08-04): this value was calibrated against elapsed wall-clock * microseconds. Elapsed fleet-heartbeat *ticks* between touches is a * different quantity at a different scale, and the seed has not been * re-fit against it. Left as-is rather than guessed at -- the acceptance * boot test below only checks determinism, not transfer magnitude; a real * re-tune is DoE work (item 5.1), not something to invent here. */ static uint64_t fleet_transfer_slope_q48 = 65536ULL / 3; static uint64_t slope_fit_quality_q48 = 0; /* Fleet-wide heartbeat tick, distinct from any VM's own per-VM * HeartbeatState.tick_count -- see the rationale in the header. Advances * from whichever VM's own heartbeat cycle happens to fire, so the * readiness signal reflects the fleet's aggregate activity rather than * any one VM's (in practice, Hera's) personal word-execution rate. * Declared here (rather than just above vm_physics_heartbeat_tick, where * it used to live) so vm_physics_touch() can read the current tick * without a forward declaration -- it is now this counter's second * reader, alongside vm_physics_heartbeat_tick()'s own gating use. */ static uint64_t fleet_heartbeat_tick_count = 0; static uint64_t fleet_last_capacity_tick = 0; /* renamed 2026-08-15 from fleet_last_inference_tick -- gated on STADIUM_CAPACITY_TICK now, not HEARTBEAT_ INFERENCE_FREQUENCY, see vm_physics_heartbeat_tick() */ static vm_physics_node_t *vm_physics_find(VMUuid vm_id) { vm_physics_node_t *n = vm_physics_head; while (n) { if (vm_uuid_equal(n->vm_id, vm_id)) return n; n = n->next; } return (void *)0; } /** * vm_physics_transfer - The one conservative primitive everything else * is a special case of. Subtracts amount from from (clamped at 0) and * adds the same amount to to. Nothing is created or destroyed: * sum(execution_heat for all LIVE VMs) is invariant across any call. */ static void vm_physics_transfer(VMPhysics *from, VMPhysics *to, uint64_t amount_q48) { uint64_t moved = (amount_q48 > from->execution_heat_q48) ? from->execution_heat_q48 : amount_q48; from->execution_heat_q48 -= moved; to->execution_heat_q48 += moved; } void vm_physics_init(VMUuid vm_id) { vm_physics_node_t *node = vm_physics_find(vm_id); /* Hera (vm_uuid_hera()) is the fleet's single structural root -- the one * entry capsule_vm_kill refuses to ever kill, and the same fixed * point vm_physics_find_root_id below walks every parent chain up * to. She is where the fleet's whole Q48_ONE initially resides; * every other VM joins an already-nonzero fleet and starts at 0, * per the "cold mass added to a closed system" reasoning -- which * requires a nonzero sum to already exist, so Hera's own * registration is where that sum is seeded. */ int is_root = vm_uuid_is_hera(vm_id); if (node) { node->physics.execution_heat_q48 = is_root ? Q48_ONE : 0; node->physics.last_active_tick = 0; node->physics.touched = 0; node->physics.is_live = 1; return; } node = (vm_physics_node_t *)kmalloc(sizeof(vm_physics_node_t)); if (!node) return; node->vm_id = vm_id; node->physics.execution_heat_q48 = is_root ? Q48_ONE : 0; node->physics.last_active_tick = 0; node->physics.touched = 0; node->physics.is_live = 1; node->next = vm_physics_head; vm_physics_head = node; } /** * vm_physics_find_root_id - Walk parent_vm_id edges up to Hera * * Every VM's parent_vm_id (VMRegistryEntry, capsule_run.h) is set once * at its own birth and never rewritten -- including a dead VM's, so the * walk continues correctly through an already-killed intermediate * parent without any special dead-parent handling. Hera's own entry is * self-referential (parent_vm_id == vm_id == vm_uuid_hera()), which is * what stops the walk: she is the only node with no outbound edge. * * The 64-hop guard is defensive only -- a well-formed chain terminates * in O(fleet depth) hops, nowhere near 64. It exists so a corrupted * chain (which should never happen) returns the starting vm_id instead * of spinning. */ static VMUuid vm_physics_find_root_id(VMUuid vm_id) { VMUuid cursor = vm_id; uint32_t guard; for (guard = 0; guard < 64; guard++) { VMRegistryEntry entry; if (capsule_vm_registry_get(cursor, &entry) != 0) break; if (vm_uuid_equal(entry.parent_vm_id, cursor)) break; /* self-referential: root */ cursor = entry.parent_vm_id; } return cursor; } void vm_physics_retire(VMUuid vm_id) { vm_physics_node_t *dying = vm_physics_find(vm_id); vm_physics_node_t *root; VMUuid root_id; if (!dying || !dying->physics.is_live) return; root_id = vm_physics_find_root_id(vm_id); root = !vm_uuid_equal(root_id, vm_id) ? vm_physics_find(root_id) : (void *)0; if (root) { /* The dying VM's entire remaining heat flows to the root it * chains up to (in practice always Hera today) -- no division, * no weighting policy, no "no survivors" case, since a root is * always there by construction. This is also what makes * TRIPOD-TEST's kill-then-rebirth "K soak" check meaningful: a * root VM (e.g. Hermes) being killed and later respawned by * Hera doesn't lose heat across the gap, it just parks at Hera * in the meantime. */ vm_physics_transfer(&dying->physics, &root->physics, dying->physics.execution_heat_q48); } /* root_id == vm_id means the dying VM IS the root itself (Hera -- * shouldn't happen, she can't be killed) or the chain was broken * (shouldn't happen, see vm_physics_find_root_id). Either way * there's nowhere conservation-preserving to send the remainder; * it is dropped along with the dying entry. */ dying->physics.execution_heat_q48 = 0; dying->physics.is_live = 0; } void vm_physics_touch(VMUuid vm_id) { vm_physics_node_t *target = vm_physics_find(vm_id); VMFleetTouchSample sample = { 0, 0, 0 }; uint64_t now_tick = fleet_heartbeat_tick_count; if (!target || !target->physics.is_live) return; /* Restated on the virtual tick (FABRIC.md item 2.1, 2026-08-04): this * used to gate on target->physics.last_active_ns > 0 and scale by * elapsed wall-clock microseconds -- non-reproducible run to run under * TCG (GAP-A1, FABRIC.md §25.7.1). fleet_heartbeat_tick_count is * execution-paced (vm_runtime.c:143), so elapsed_ticks is now a pure * function of the execution stream. `touched` replaces the old * `> 0` sentinel: tick 0 is a legitimate value for a genuine first * touch to land on, so "never touched" needs its own flag rather than * overloading the tick value. */ if (fleet_transfer_slope_q48 > 0 && target->physics.touched && now_tick > target->physics.last_active_tick) { uint64_t elapsed_ticks = now_tick - target->physics.last_active_tick; uint64_t amount = (elapsed_ticks * fleet_transfer_slope_q48) >> 16; sample.elapsed_ticks = elapsed_ticks; sample.amount = amount; if (amount > 0) { /* Pull toward the touched VM, proportional to each other LIVE * VM's current heat -- same fan-out shape as retire, mirror * direction. Clamped to what the rest of the fleet actually * holds, so this can never manufacture heat. */ vm_physics_node_t *n; uint64_t others_total = 0; uint64_t moved_total; for (n = vm_physics_head; n; n = n->next) { if (n != target && n->physics.is_live) { others_total += n->physics.execution_heat_q48; } } /* moved_total == 0 when others_total == 0 (nothing to pull -- * the fleet is already fully concentrated elsewhere), handled * uniformly by the same clamp as a partial cap: either way * the actual transfer fell short of what the rate implied, so * this touch carries no usable rate information (rev o). */ moved_total = (amount > others_total) ? others_total : amount; sample.clamped = (moved_total != amount); if (moved_total > 0) { 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); } } } } } target->physics.last_active_tick = now_tick; target->physics.touched = 1; /* Record this touch's transfer-law inputs, not a derived heat value * (rev o, VM-FLEET-ATTRACTOR-DESIGN-20260705.md) -- see * VMFleetTouchSample for why. */ fleet_window.touch_samples[fleet_window.head] = sample; fleet_window.head = (fleet_window.head + 1) % VM_FLEET_WINDOW_DEPTH; if (fleet_window.count < VM_FLEET_WINDOW_DEPTH) { fleet_window.count++; } fleet_window.is_warm = (fleet_window.count >= VM_FLEET_WINDOW_DEPTH); } /** * l8_regime_modulation_q16 - Read-only Q16 multiplier from Hera's own L8 * * Gives L8 a real, passive-observer-respecting causal channel into the * fleet's transfer rate (rev q, VM-FLEET-ATTRACTOR-DESIGN-20260705.md): * previously nothing L8 did could affect fleet dynamics at all (rev j's * architectural diagnosis for its null DoE result). Reuses Hera's * adaptive table's current_regime -- the same 3-bit entropy/cv/temporal * classification the bandit already stratifies its own scores by, * rather than inventing a new signal -- as a proxy for fleet-wide * dispatch activity, since all VM-EXEC/VM-CALL flows through her. * * modulation = (popcount(regime) + 1) * 0.5, i.e. 0.5x-2.0x: more of the * three high-signal bits active scales the fleet's already-recovered * rate up (track change more aggressively), fewer scales it down (damp * toward the raw empirical rate). No per-VM favoritism -- this scales * whatever vm_physics_tick() already computed uniformly, it doesn't * decide who receives heat. * * Returns Q16 65536 (neutral 1.0x) if Hera or her adaptive table isn't * available, so a missing signal never blocks or corrupts the estimate * this multiplies. */ static uint32_t l8_regime_modulation_q16(void) { VM *mama = (VM*)sk_get_mama_vm(); ssm_l8_state_t *l8; uint32_t popcount; if (!mama || !mama->ssm_l8_state) return 65536u; l8 = (ssm_l8_state_t*)mama->ssm_l8_state; if (!l8->table) return 65536u; /* legacy path tracks no regime */ popcount = (uint32_t)(((l8->table->current_regime >> 2) & 1u) + ((l8->table->current_regime >> 1) & 1u) + (l8->table->current_regime & 1u)); return (popcount + 1u) * 32768u; } void vm_physics_tick(uint64_t now_ns) { uint64_t rates[VM_FLEET_WINDOW_DEPTH]; uint32_t rate_count = 0; uint32_t i; (void)now_ns; /* Unlike the log-linear regression this replaced, the * estimator below needs no external time reference -- * each sample already carries its own elapsed_ticks. */ /* Mirrors vm_tick_inference_engine's is_warm gate: skip rather than * substitute a default slope. Expected to stay unwarmed (or warm * with a low-quality fit) at the current 3-VM Tripod scale -- there * simply aren't enough distinct touch events yet. */ if (!fleet_window.is_warm) return; /* Direct rate recovery, not curve-fitting (rev o, * VM-FLEET-ATTRACTOR-DESIGN-20260705.md, superseding the log-linear * OLS approach of rev b-n): the transfer law is known exactly * (amount = elapsed_ticks * slope >> 16), so any unclamped touch can * be inverted directly for an exact per-sample rate. Order doesn't * matter here -- a median over the observation window, not a * time-indexed fit -- so this walks the fixed-size array directly * rather than replaying ring order from fleet_window.head. */ for (i = 0; i < VM_FLEET_WINDOW_DEPTH; i++) { VMFleetTouchSample *s = &fleet_window.touch_samples[i]; if (s->clamped || s->amount == 0 || s->elapsed_ticks == 0) continue; rates[rate_count++] = (s->amount << 16) / s->elapsed_ticks; } /* slope_fit_quality_q48 as the informative fraction of the window -- * a real measure now, not a fixed placeholder. Reported even when * there's too little to fit, same as a partial-sample regression * would report low confidence rather than nothing at all. */ slope_fit_quality_q48 = ((uint64_t)rate_count << 16) / VM_FLEET_WINDOW_DEPTH; /* Too few informative samples to trust: keep the current slope * rather than overwrite it with an estimate from a handful of * points (or zero) -- the same "skip, don't substitute a degenerate * value" philosophy as the is_warm gate above. */ if (rate_count < VM_PHYSICS_MIN_INFORMATIVE_SAMPLES) return; /* Insertion sort for the median -- rate_count <= VM_FLEET_WINDOW_DEPTH * (64), no library qsort in this freestanding kernel-only file. */ for (i = 1; i < rate_count; i++) { uint64_t key = rates[i]; uint32_t j = i; while (j > 0 && rates[j - 1] > key) { rates[j] = rates[j - 1]; j--; } rates[j] = key; } fleet_transfer_slope_q48 = (rates[rate_count / 2] * l8_regime_modulation_q16()) >> 16; } /* Gated on STADIUM_CAPACITY_TICK, not HEARTBEAT_INFERENCE_FREQUENCY (fixed * 2026-08-15, FABRIC-2.md §12 Q5): fleet_heartbeat_tick_count is fed by * EVERY live VM's own vm_tick() (see the comment above this counter's * declaration), so it advances several times faster in wall-clock terms * than a single VM's own tick_count -- comparing it against * HEARTBEAT_INFERENCE_FREQUENCY (written assuming a single VM's stream) * meant this fired far more often than §22.4's required ~1000:1 separation * from a VM's own heat-inference loop. Still one clock, one counter -- * this is a bigger flat threshold on the same counter, not a second tick * source or anything that reads live VM count to adjust itself. */ void vm_physics_heartbeat_tick(uint64_t now_ns) { fleet_heartbeat_tick_count++; if ((fleet_heartbeat_tick_count - fleet_last_capacity_tick) >= STADIUM_CAPACITY_TICK) { vm_physics_tick(now_ns); fleet_last_capacity_tick = fleet_heartbeat_tick_count; } } uint64_t vm_physics_fleet_heat_sum(void) { uint64_t total = 0; vm_physics_node_t *n; for (n = vm_physics_head; n; n = n->next) { if (n->physics.is_live) { total += n->physics.execution_heat_q48; } } return total; } int vm_physics_conserved(void) { uint64_t sum = vm_physics_fleet_heat_sum(); uint64_t diff = (sum > Q48_ONE) ? (sum - Q48_ONE) : (Q48_ONE - sum); return diff < VM_PHYSICS_EPSILON_Q48; } uint64_t vm_physics_heat_of(VMUuid vm_id) { vm_physics_node_t *n = vm_physics_find(vm_id); if (!n || !n->physics.is_live) return 0; return n->physics.execution_heat_q48; } /* Decimal print, same shape as parity.c's parity_put_u64 -- freestanding, * no snprintf dependency. */ static void console_put_u64(uint64_t val) { char buf[21]; int i = 20; buf[i--] = '\0'; if (val == 0) { buf[i--] = '0'; } else { while (val > 0 && i >= 0) { buf[i--] = (char)('0' + (val % 10)); val /= 10; } } console_puts(&buf[i + 1]); } void vm_physics_status(void) { uint64_t sum = vm_physics_fleet_heat_sum(); console_puts("VM-PHYSICS: fleet_heat_sum="); console_put_u64(sum); console_println(""); console_puts("VM-PHYSICS: conserved="); console_println(vm_physics_conserved() ? "CONSERVED" : "DRIFTED"); console_puts("VM-PHYSICS: fleet_transfer_slope_q48="); console_put_u64(fleet_transfer_slope_q48); console_println(""); console_puts("VM-PHYSICS: slope_fit_quality_q48="); console_put_u64(slope_fit_quality_q48); console_println(""); console_puts("VM-PHYSICS: fleet_window_warm="); console_println(fleet_window.is_warm ? "YES" : "NO"); /* Per-VM breakdown (VM-FLEET-ATTRACTOR-DESIGN-20260705.md transparency * requirement): the aggregate above always reads Q.1 when conserved, * which says nothing about how heat is actually split among live VMs. */ { vm_physics_node_t *n; for (n = vm_physics_head; n; n = n->next) { VMRegistryEntry entry; if (!n->physics.is_live) continue; console_puts("VM-PHYSICS: vm_id="); { char uuid_buf[37]; vm_uuid_format(n->vm_id, uuid_buf); console_puts(uuid_buf); } if (capsule_vm_registry_get(n->vm_id, &entry) == 0) { console_puts(" name="); console_puts(entry.name); } console_puts(" heat_q48="); console_put_u64(n->physics.execution_heat_q48); console_println(""); } } }