/* 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" /* HEARTBEAT_INFERENCE_FREQUENCY */ #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_ns; int is_live; } VMPhysics; typedef struct vm_physics_node { uint32_t 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_us * * 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_us), rather than curve-fitting * a reconstructed trajectory against a synthetic time axis. */ typedef struct { uint64_t elapsed_us; /* since this vm_id's previous touch; 0 if there * was no previous touch to compare against */ uint64_t amount; /* elapsed_us * 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 clock) */ 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_ns * 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). */ static uint64_t fleet_transfer_slope_q48 = 65536ULL / 3; static uint64_t slope_fit_quality_q48 = 0; static vm_physics_node_t *vm_physics_find(uint32_t vm_id) { vm_physics_node_t *n = vm_physics_head; while (n) { if (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(uint32_t vm_id) { vm_physics_node_t *node = vm_physics_find(vm_id); /* Hera (vm_id 0) 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_id == 0); if (node) { node->physics.execution_heat_q48 = is_root ? Q48_ONE : 0; node->physics.last_active_ns = 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_ns = 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 == 0), 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 uint32_t vm_physics_find_root_id(uint32_t vm_id) { uint32_t cursor = vm_id; uint32_t guard; for (guard = 0; guard < 64; guard++) { VMRegistryEntry entry; if (capsule_vm_registry_get(cursor, &entry) != 0) break; if (entry.parent_vm_id == cursor) break; /* self-referential: root */ cursor = entry.parent_vm_id; } return cursor; } void vm_physics_retire(uint32_t vm_id) { vm_physics_node_t *dying = vm_physics_find(vm_id); vm_physics_node_t *root; uint32_t root_id; if (!dying || !dying->physics.is_live) return; root_id = vm_physics_find_root_id(vm_id); root = (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(uint32_t vm_id, uint64_t now_ns) { vm_physics_node_t *target = vm_physics_find(vm_id); VMFleetTouchSample sample = { 0, 0, 0 }; if (!target || !target->physics.is_live) return; if (fleet_transfer_slope_q48 > 0 && target->physics.last_active_ns > 0 && now_ns > target->physics.last_active_ns) { /* Microseconds, not nanoseconds, matching the word engine's own * convention (physics_metadata_apply_linear_decay, * physics_metadata.c) that fleet_transfer_slope_q48's seed value * was explicitly calibrated to mirror (see the comment on the * static above). Using raw elapsed_ns here was an outright unit * bug, not a design choice: real touch-to-touch gaps run to * milliseconds, so (elapsed_ns * slope_q48) >> 16 overshot the * fleet's entire conserved heat (Q48_ONE) by more than 10x on the * very first post-seed touch, clamping to a full winner-take-all * transfer in a single step instead of the gradual pull this was * meant to model (VM-FLEET-ATTRACTOR-DESIGN-20260705.md rev n). */ uint64_t elapsed_us = (now_ns - target->physics.last_active_ns) / 1000; uint64_t amount = (elapsed_us * fleet_transfer_slope_q48) >> 16; sample.elapsed_us = elapsed_us; 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_ns = now_ns; /* 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_us. */ /* 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_us * 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_us == 0) continue; rates[rate_count++] = (s->amount << 16) / s->elapsed_us; } /* 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; } /* 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. */ static uint64_t fleet_heartbeat_tick_count = 0; static uint64_t fleet_last_inference_tick = 0; void vm_physics_heartbeat_tick(uint64_t now_ns) { fleet_heartbeat_tick_count++; if ((fleet_heartbeat_tick_count - fleet_last_inference_tick) >= HEARTBEAT_INFERENCE_FREQUENCY) { vm_physics_tick(now_ns); fleet_last_inference_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(uint32_t 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="); console_put_u64(n->vm_id); 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(""); } } }