Stage S-1b: dictionary word ids grow with the dictionary; a word that can't get an id is refused whole (FABRIC-3.9.0 §8.1)

- word_id_map / recycled_word_ids are heap tables (one shared capacity,
  64 doubling), grown all-or-nothing by allocate-copy-free. Numbering is
  unchanged, so boot parity is unchanged.
- vm_create_word() takes the id before linking; on failure it frees the
  entry, sets vm->error, logs LOG_ERROR and counts
  (vm_dictionary_word_id_refusals()). No word runs with WORD_ID_INVALID.
- Readers of the id space bound by vm->next_word_id, not DICTIONARY_SIZE.
- transition_heat gets its own capacity and grows on record (was sized
  once -- a heap overflow under a dynamic bound); every reader bounds by it.
- Stadium id->cell rows start empty and grow on dispatch; a failed grow is
  logged, counted and not admitted. S-2 (the cell pool) stays open.

Probe (reverted), identical on amd64/aarch64/riscv64: +2000 words get real
ids (capacity 4096); a word at id 2738 lands 200x in the rolling window and
is Stadium-admitted; FORGET recycles across the old boundary; an injected
grow failure refuses the word whole (NULL, error, counter, dictionary
unchanged) and the retry succeeds. 3-ISA acceptance passes; boot parity
(531, 0x572daac83b797332) and the dict_hash set unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Robert Allan James
2026-09-24 13:59:43 -04:00
co-authored by Claude Opus 5.5
parent ee3db96ea8
commit 1dbd0c74ed
26 changed files with 55442 additions and 83 deletions
+38 -8
View File
@@ -52,6 +52,7 @@
#include "physics_pipelining_metrics.h"
#include "vm.h"
#include "log.h" /* S-1b: a failed transition_heat grow is logged */
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
@@ -149,6 +150,7 @@ int transition_metrics_init(WordTransitionMetrics *metrics) {
if (!metrics) return -1;
metrics->transition_heat = NULL; /* Lazy allocation on first use */
metrics->transition_heat_cap = 0;
metrics->total_transitions = 0;
metrics->prefetch_attempts = 0;
metrics->prefetch_hits = 0;
@@ -208,17 +210,41 @@ int transition_metrics_init(WordTransitionMetrics *metrics) {
* @return 0 on success; −1 on NULL @p metrics, out-of-bounds
* @p next_word_id, or allocation failure.
*/
static uint64_t g_transition_grow_failures;
uint64_t transition_metrics_grow_failures(void) { return g_transition_grow_failures; }
int transition_metrics_record(WordTransitionMetrics *metrics, uint32_t next_word_id, uint32_t dict_size) {
if (!metrics || next_word_id >= dict_size) {
return -1;
}
/* Lazy allocation: only allocate transition_heat array when first transition occurs */
if (!metrics->transition_heat) {
metrics->transition_heat = (uint64_t *)calloc(dict_size, sizeof(uint64_t));
if (!metrics->transition_heat) {
return -1; /* Memory allocation failure */
/* Lazy allocation on the first transition, and growth when a word id at
* or past the current length arrives (S-1b: ids are no longer capped, so
* an array sized once can be outgrown). Grown to the VM's whole id bound,
* at least doubling, so a steady stream of new words doesn't regrow on
* each one. A failed grow drops this one transition -- the word itself
* already ran; there is nothing to refuse -- and is logged and counted. */
if (next_word_id >= metrics->transition_heat_cap) {
uint32_t new_cap = dict_size;
uint64_t *grown;
if (metrics->transition_heat_cap > new_cap / 2u) {
new_cap = (metrics->transition_heat_cap > UINT32_MAX / 2u)
? UINT32_MAX : metrics->transition_heat_cap * 2u;
}
grown = (uint64_t *)calloc(new_cap, sizeof(uint64_t));
if (!grown) {
g_transition_grow_failures++;
log_message(LOG_ERROR, "transition_heat can't grow: dropped");
return -1;
}
if (metrics->transition_heat) {
memcpy(grown, metrics->transition_heat,
(size_t)metrics->transition_heat_cap * sizeof(uint64_t));
free(metrics->transition_heat);
}
metrics->transition_heat = grown;
metrics->transition_heat_cap = new_cap;
}
/* Record the transition */
@@ -265,7 +291,7 @@ int64_t transition_metrics_get_probability_q48(const WordTransitionMetrics *metr
/* Safety: Bounds check on target_word_id to prevent out-of-bounds read
* Note: transition_heat is allocated with dict_size passed to transition_metrics_record()
* Callers must ensure target_word_id < dict_size used during allocation */
if (!metrics->transition_heat) {
if (!metrics->transition_heat || target_word_id >= metrics->transition_heat_cap) {
return 0;
}
@@ -311,8 +337,10 @@ void transition_metrics_update_cache(WordTransitionMetrics *metrics, uint32_t di
int64_t max_prob_q48 = 0;
uint32_t max_idx = 0;
/* Find word with highest transition count */
for (uint32_t i = 0; i < dict_size; i++) {
/* Find word with highest transition count. Bounded by the array's own
* length (S-1b): it may lag dict_size until the next grow. */
uint32_t limit = (dict_size < metrics->transition_heat_cap) ? dict_size : metrics->transition_heat_cap;
for (uint32_t i = 0; i < limit; i++) {
if (metrics->transition_heat[i] > 0) {
int64_t prob_q48 = transition_metrics_get_probability_q48(metrics, i);
if (prob_q48 > max_prob_q48) {
@@ -511,6 +539,7 @@ void transition_metrics_reset(WordTransitionMetrics *metrics) {
free(metrics->transition_heat);
metrics->transition_heat = NULL;
}
metrics->transition_heat_cap = 0;
metrics->total_transitions = 0;
metrics->prefetch_attempts = 0;
@@ -559,6 +588,7 @@ void transition_metrics_cleanup(WordTransitionMetrics *metrics) {
free(metrics->transition_heat);
metrics->transition_heat = NULL;
}
metrics->transition_heat_cap = 0;
/* Phase 1 Extension: Clean up context window */
if (metrics->context_window) {