/* 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. You may obtain a copy of the License at: https://github.com/star.4th@proton.me/StarForth/LICENSE.txt This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose, and noninfringement. See the License for the specific language governing permissions and limitations under the License. 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. You may obtain a copy of the License at: https://github.com/star.4th@proton.me/StarForth/LICENSE.txt This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose, and noninfringement. See the License for the specific language governing permissions and limitations under the License. */ #include "ssm_jacquard.h" #include #include /* ============================================================================ * L8 Initialization * ============================================================================ */ /** * @brief Initialise the L8 Jacquard steady-state machine. * * Sets @c current_mode and @c pending_mode to @c initial_mode, zeroes * @c hysteresis_counter, and sets @c table to NULL. Callers must invoke * @c ssm_l8_init_table() separately if the adaptive UCB table is required. * * @param state Pointer to the L8 state to initialise * @param initial_mode Starting mode (typically @c SSM_MODE_C0) */ void ssm_l8_init(ssm_l8_state_t *state, ssm_l8_mode_t initial_mode) { if (!state) return; state->current_mode = initial_mode; state->hysteresis_counter = 0; state->pending_mode = initial_mode; state->table = NULL; /* Caller calls ssm_l8_init_table() separately */ } /* ============================================================================ * L8 Mode Selection Logic (Data-Driven from Top 5% DoE Analysis) * ============================================================================ */ /** * @brief Update the L8 mode selector using current runtime metrics. * * Classifies @c metrics into four binary feature bits (L2/L3/L5/L6) derived * from DoE top-5% analysis and computes a target mode. Applies hysteresis: * the mode only commits after @c SSM_HYSTERESIS_TICKS consecutive ticks with * the same target. Called by the heartbeat thread on every tick. * * @param metrics Runtime metrics snapshot (entropy, CV, temporal decay) * @param state L8 state to update; @c current_mode is written on commit */ void ssm_l8_update(const ssm_l8_metrics_t *metrics, ssm_l8_state_t *state) { if (!metrics || !state) return; /* Classify metrics into binary features */ int entropy_high = (metrics->entropy >= SSM_ENTROPY_HIGH_THRESHOLD); int cv_high = (metrics->cv >= SSM_CV_HIGH_THRESHOLD); int temporal_high = (metrics->temporal_decay >= SSM_TEMPORAL_DECAY_THRESHOLD); int temporal_med = (metrics->temporal_decay >= SSM_TEMPORAL_DECAY_LOW_THRESHOLD); /* Determine target mode using 4-bit logic (L2 L3 L5 L6) */ ssm_l8_mode_t target_mode = SSM_MODE_C0; /* Default: minimal */ /* L2 bit: Enable rolling window if high entropy (diversity) */ int L2_bit = entropy_high ? 1 : 0; /* L3 bit: Enable linear decay if high temporal locality */ int L3_bit = temporal_high ? 1 : 0; /* L5 bit: Enable window inference if high CV (volatility) */ int L5_bit = cv_high ? 1 : 0; /* L6 bit: Enable decay inference if CV high AND moderate temporal */ int L6_bit = (cv_high && temporal_med) ? 1 : 0; /* Combine bits into mode selector: L2 L3 L5 L6 */ target_mode = (ssm_l8_mode_t)((L2_bit << 3) | (L3_bit << 2) | (L5_bit << 1) | L6_bit); /* Hysteresis: only change mode after SSM_HYSTERESIS_TICKS consecutive votes */ if (target_mode == state->pending_mode) { /* Same target as last time, increment counter */ state->hysteresis_counter++; if (state->hysteresis_counter >= SSM_HYSTERESIS_TICKS) { /* Threshold reached, commit mode change */ if (target_mode != state->current_mode) { state->current_mode = target_mode; } state->hysteresis_counter = 0; /* Reset for next change */ } } else { /* Target changed, reset hysteresis */ state->pending_mode = target_mode; state->hysteresis_counter = 1; } } /* ============================================================================ * L8 Mode Application (Set L2/L3/L5/L6 Bits) * ============================================================================ */ /** * @brief Apply the current L8 mode to a runtime configuration struct. * * Extracts the four loop-enable bits (L2/L3/L5/L6) from * @c state->current_mode and writes them into @c config. Used by the * legacy (non-table) L8 path when the adaptive UCB table is not present. * * @param state L8 state containing the committed current mode * @param config Runtime configuration to update */ void ssm_apply_mode(const ssm_l8_state_t *state, ssm_config_t *config) { if (!state || !config) return; /* Extract bits from mode (L2 L3 L5 L6) */ int mode_val = (int)state->current_mode; config->L2_rolling_window = (mode_val >> 3) & 1; /* Bit 3 */ config->L3_linear_decay = (mode_val >> 2) & 1; /* Bit 2 */ config->L5_window_inference = (mode_val >> 1) & 1; /* Bit 1 */ config->L6_decay_inference = (mode_val >> 0) & 1; /* Bit 0 */ } /* ============================================================================ * L8 Utility Functions * ============================================================================ */ /** * @brief Return a human-readable name string for the given L8 mode. * * Returns a static string such as @c "C4_TEMPORAL" or @c "C15_FULL_ADAPTIVE". * Modes marked ✅ TOP 5% are the configurations favoured by the DoE study. * Returns @c "UNKNOWN" for out-of-range values. * * @param mode L8 mode enumeration value * @return Pointer to a static null-terminated string (never NULL) */ const char* ssm_l8_mode_name(ssm_l8_mode_t mode) { switch (mode) { case SSM_MODE_C0: return "C0_MINIMAL"; case SSM_MODE_C1: return "C1_DECAY_INF"; case SSM_MODE_C2: return "C2_WINDOW_INF"; case SSM_MODE_C3: return "C3_VOLATILE"; case SSM_MODE_C4: return "C4_TEMPORAL"; /* ✅ TOP 5% */ case SSM_MODE_C5: return "C5_TEMPORAL_DECAY_INF"; case SSM_MODE_C6: return "C6_TEMPORAL_WINDOW_INF"; case SSM_MODE_C7: return "C7_FULL_INFERENCE"; /* ✅ TOP 5% */ case SSM_MODE_C8: return "C8_DIVERSE"; case SSM_MODE_C9: return "C9_DIVERSE_DECAY_INF"; /* ✅ TOP 5% */ case SSM_MODE_C10: return "C10_DIVERSE_WINDOW_INF"; case SSM_MODE_C11: return "C11_DIVERSE_INFERENCE"; /* ✅ TOP 5% */ case SSM_MODE_C12: return "C12_DIVERSE_TEMPORAL"; /* ✅ TOP 5% */ case SSM_MODE_C13: return "C13_COMPLEX"; case SSM_MODE_C14: return "C14_FULL_ADAPTIVE_NO_DECAY_INF"; case SSM_MODE_C15: return "C15_FULL_ADAPTIVE"; default: return "UNKNOWN"; } } /* ============================================================================ * Adaptive Table: DoE Prior Seeding * ============================================================================ * * Maps the DoE top-5% 4-bit modes (L2/L3/L5/L6) to the 7-bit space * (L1=0, L4=0, L7=1 held fixed for the high-score variants). * * Bit layout: b6=L1, b5=L2, b4=L3, b3=L4, b2=L5, b1=L6, b0=L7 * * Top-5% modes (4-bit → 7-bit with L1=0,L4=0,L7=1): * C4 (L3 only): 0100 → 0 0 1 0 0 0 1 = 0x11 = 17 * C7 (L3+L5+L6): 0111 → 0 0 1 0 1 1 1 = 0x17 = 23 * C9 (L2+L6): 1001 → 0 1 0 0 0 1 1 = 0x23 = 35 * C11 (L2+L5+L6): 1011 → 0 1 0 0 1 1 1 = 0x27 = 39 * C12 (L2+L3): 1100 → 0 1 1 0 0 0 1 = 0x31 = 49 */ /** * @brief Seed the adaptive UCB config table with DoE prior scores. * * Computes benefit scores derived from the DoE top-5% analysis for all * @c cd_tuning_word().num_configs (128) configs and hands them to * @c cd_config_table_seed(). The five top-5% configs (C4/C7/C9/C11/C12 in * 4-bit space → indices 17/23/35/39/49 in 7-bit space) receive 80% of * @c cd_tuning_word().score_max; configs involving L1 or L4 (known expensive * loops) receive 5–10%; all others receive 40%. Sets the starting config * to index 17 (C4-equivalent: L3+L7 only). * * @param table Pointer to the CDConfigTable to seed (already initialised * via cd_config_table_init()) */ static void ssm_l8_seed_from_doe(CDConfigTable *table) { /* Top-5% config indices in 7-bit space */ static const uint8_t top5[] = { 17u, 23u, 35u, 39u, 49u }; CDTuning tuning = cd_tuning_word(); uint32_t initial_scores[128]; uint32_t score_max = tuning.score_max; uint32_t c, i; for (c = 0u; c < tuning.num_configs; c++) { uint8_t bits = (uint8_t)c; int has_l1 = (bits & SSM_CFG_L1) != 0; int has_l4 = (bits & SSM_CFG_L4) != 0; int is_top5 = 0; uint32_t score; for (i = 0u; i < 5u; i++) { if (bits == top5[i]) { is_top5 = 1; break; } } if (is_top5) score = (score_max * 80u) / 100u; else if (has_l1 && has_l4) score = (score_max * 5u) / 100u; else if (has_l1 || has_l4) score = (score_max * 10u) / 100u; else score = (score_max * 40u) / 100u; initial_scores[c] = score; } /* Start on C4-equivalent (config index 17: L3+L7 only) */ cd_config_table_seed(table, &tuning, initial_scores, 17u); } /* ============================================================================ * Adaptive Table: Per-Tick Reward + UCB Selection (static) * ============================================================================ */ /** * @brief Score the current config from this tick's outcome and UCB-select * this tick's config. * * Computes a joint convergence signal from this tick's ANOVA outcome and * the relative delta between this tick's and the previous tick's (window, * locality) pair. Updates the regime-specific benefit score for the current * config using exponential decay plus a signed delta -- the decay is what * provides smoothing over time; there's no separate multi-tick batching on * top of it (VM-FLEET-ATTRACTOR-DESIGN-20260705.md rev r: one clock, the * heartbeat tick -- a tick is a tick). Then runs * UCB1 over all 128 configs for the current regime, selecting the one with * the highest upper confidence bound as this tick's config. * * @param table Adaptive config table * @param config Runtime config to update with the newly selected bits * @param metrics This tick's metrics snapshot (for the ANOVA signal) * @param current_window Current rolling window width, this tick * @param locality_q16 Current locality signal from cd_classify_ids(), this tick */ static void ssm_l8_tick_score_and_select(CDConfigTable *table, ssm_config_t *config, const ssm_l8_metrics_t *metrics, uint32_t current_window, uint32_t locality_q16) { CDTuning tuning = cd_tuning_word(); uint8_t active_bits = table->entries[table->current_config].config_bits; int use_joint_convergence = ((active_bits & SSM_CFG_L5) || (active_bits & SSM_CFG_L6)) ? 1 : 0; /* --- ANOVA stability: this tick's own outcome, not an accumulated * fraction. Neutral (32768) when inference didn't run this tick -- * no signal either way, not "unstable." */ uint32_t anova_stability = metrics->inference_ran_this_tick ? (metrics->inference_early_exited ? 65536u : 0u) : 32768u; uint8_t bits = cd_config_table_tick(table, &tuning, table->current_regime, anova_stability, use_joint_convergence, current_window, locality_q16); /* Apply new config bits to ssm_config_t -- all 7, not just the 4 * the legacy 16-mode selector could express. */ if (config != NULL) { config->L1_heat_tracking = (bits & SSM_CFG_L1) ? 1 : 0; config->L2_rolling_window = (bits & SSM_CFG_L2) ? 1 : 0; config->L3_linear_decay = (bits & SSM_CFG_L3) ? 1 : 0; config->L4_pipelining = (bits & SSM_CFG_L4) ? 1 : 0; config->L5_window_inference = (bits & SSM_CFG_L5) ? 1 : 0; config->L6_decay_inference = (bits & SSM_CFG_L6) ? 1 : 0; config->L7_adaptive_heartrate = (bits & SSM_CFG_L7) ? 1 : 0; } } /* ============================================================================ * Adaptive Table: Public API * ============================================================================ */ /** * @brief Allocate and initialise the adaptive UCB config table. * * Mallocs and initialises a @c CDConfigTable sized by @c cd_tuning_word(), * seeds it with @c ssm_l8_seed_from_doe(), and attaches it to * @c state->table. If either allocation fails, @c state->table is set to * NULL and the system falls back to legacy mode (@c ssm_apply_mode()). * * @param state L8 state that will own the table */ void ssm_l8_init_table(ssm_l8_state_t *state) { CDConfigTable *table; CDTuning tuning; if (!state) return; table = (CDConfigTable *)malloc(sizeof(CDConfigTable)); if (!table) { state->table = NULL; /* Fall back to legacy mode */ return; } tuning = cd_tuning_word(); if (cd_config_table_init(table, &tuning) != 0) { free(table); state->table = NULL; /* Fall back to legacy mode */ return; } ssm_l8_seed_from_doe(table); state->table = table; } /** * @brief Free the adaptive UCB config table owned by @c state. * * Safe to call with @c state->table == NULL. Sets @c state->table to NULL * after freeing so the state falls back to legacy mode. * * @param state L8 state whose table is freed */ void ssm_l8_free_table(ssm_l8_state_t *state) { if (!state || !state->table) return; cd_config_table_free(state->table); free(state->table); state->table = NULL; } /** * @brief Tick the adaptive UCB table: classify, score, and reselect. * * Classifies the current tick into one of 8 regimes (entropy x CV x * locality) and stores it unconditionally, then scores the current config * from this tick's outcome and UCB-reselects, every single call -- no * batching, no derived counter of any kind (VM-FLEET-ATTRACTOR-DESIGN- * 20260705.md rev r: one clock, the heartbeat tick, applied uniformly). * The locality component of both the regime and the joint-convergence * reward comes from cd_classify_ids() over recent_word_ids -- purely * execution-count-derived, no wall-clock input (rev s; see header comment * on this function's declaration for why that matters). Also mirrors the * winning L2/L3/L5/L6 bits back into @c state->current_mode for * diagnostic display. No-op if @c state->table is NULL. * * @param state L8 state with attached adaptive table * @param metrics Current runtime metrics snapshot * @param config Runtime config updated this tick * @param current_window Current rolling window width * @param recent_word_ids Recent word-execution-ID history, oldest first * @param recent_word_ids_count Number of valid entries in recent_word_ids */ void ssm_l8_update_table(ssm_l8_state_t *state, const ssm_l8_metrics_t *metrics, ssm_config_t *config, uint32_t current_window, const uint32_t *recent_word_ids, uint32_t recent_word_ids_count) { CDConfigTable *table; CDTuning tuning = cd_tuning_word(); CDRegimeMetrics cd; uint8_t regime; if (!state || !state->table || !metrics) return; table = state->table; cd = cd_classify_ids(recent_word_ids, recent_word_ids_count, tuning.locality_lookback); /* Current regime (same 3-bit shape as legacy L8), stored every tick, * unconditionally. */ regime = (uint8_t)( ((metrics->entropy >= SSM_ENTROPY_HIGH_THRESHOLD) ? 4u : 0u) | ((metrics->cv >= SSM_CV_HIGH_THRESHOLD) ? 2u : 0u) | ((cd.locality_q16 >= tuning.locality_high_threshold_q16) ? 1u : 0u) ); table->current_regime = regime; ssm_l8_tick_score_and_select(table, config, metrics, current_window, cd.locality_q16); /* Mirror L2/L3/L5/L6 bits into the legacy mode field for diagnostics */ { uint8_t bits = table->entries[table->current_config].config_bits; state->current_mode = (ssm_l8_mode_t)( (((bits & SSM_CFG_L2) ? 1u : 0u) << 3) | (((bits & SSM_CFG_L3) ? 1u : 0u) << 2) | (((bits & SSM_CFG_L5) ? 1u : 0u) << 1) | (((bits & SSM_CFG_L6) ? 1u : 0u) << 0) ); } } /** * @brief Apply the UCB-selected config bits to a runtime configuration struct. * * Reads the current winning config entry from the adaptive table and writes * all 7 of its bits into @c config. Use this instead of @c ssm_apply_mode() * when the adaptive table is present (@c state->table != NULL). * * @param state L8 state with an attached, initialised adaptive table * @param config Runtime configuration to update */ void ssm_apply_mode_from_table(const ssm_l8_state_t *state, ssm_config_t *config) { const CDConfigTable *table; uint8_t bits; if (!state || !state->table || !config) return; table = state->table; bits = table->entries[table->current_config].config_bits; config->L1_heat_tracking = (bits & SSM_CFG_L1) ? 1 : 0; config->L2_rolling_window = (bits & SSM_CFG_L2) ? 1 : 0; config->L3_linear_decay = (bits & SSM_CFG_L3) ? 1 : 0; config->L4_pipelining = (bits & SSM_CFG_L4) ? 1 : 0; config->L5_window_inference = (bits & SSM_CFG_L5) ? 1 : 0; config->L6_decay_inference = (bits & SSM_CFG_L6) ? 1 : 0; config->L7_adaptive_heartrate = (bits & SSM_CFG_L7) ? 1 : 0; } /** * @brief Force the adaptive table onto an externally-chosen config. * * Sets @c current_config directly (as if the bandit's own UCB selection had * picked it) and applies its bits immediately. Since scoring and UCB * reselection now run on every heartbeat tick (rev r: one clock, no * batching of any kind), this is a one-tick nudge, not a hold: the * forced config earns exactly one tick's worth of reward signal into its * own regime_score, then the very next tick's ssm_l8_update_table() call * scores it and reselects on its own, same as any other tick. Calling * this repeatedly (e.g. once per DoE draw, faster than the VM's own * tick cadence) keeps a config in effect in practice, not because it's * pinned -- because it's what's currently forced, most recently. * * @param state L8 state with an attached, initialised adaptive table * @param config Runtime configuration to update immediately * @param config_idx Desired config, masked to the 128-entry (7-bit) space */ void ssm_l8_force_config(ssm_l8_state_t *state, ssm_config_t *config, uint8_t config_idx) { if (!state || !state->table || !config) return; cd_config_table_force(state->table, config_idx & 0x7Fu); ssm_apply_mode_from_table(state, config); }