Files
LithosAnanake/src/starkernel/capsule/capsule_vm_physics.c
T

534 lines
21 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
StarForth — Steady-State Virtual Machine Runtime
Copyright (c) 20232025 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("");
}
}
}