Reorganize source tree: kernel/, v3/, v4/ split and board infrastructure

Source tree reorganization:
- Move StarForth v3 engine to v3/ (src/, include/, Makefile)
- Move kernel to kernel/ (src/, include/, linker/, Makefile)
- Create v4/ skeleton for F18-ISA golden model (DECOMPOSITION.md, JUSTIFICATION.md)
- Move FABRIC-0..4.md to docs/fabric/
- Move ONTOLOGY.md and ROADMAP.md to docs/

Board infrastructure:
- Add boards/ser5/, boards/raspi/, boards/milkv/, boards/zynq7020/
- Each board has board.mk (ISA, CPU flags, boot recipe) and README.md
- Root Makefile becomes thin dispatcher: boot_image, all, clean, docs take TARGET
- make boot_image TARGET=SER5|RASPI|MILKV builds one GPT/MBR image per board
- ZYNQ7020 target exists but stops with clear error (ARMv7 port not built yet)
- scripts/mkdiskimage.sh builds disk images for all boards

Docs pipeline:
- docs/book/ with LaTeX master (main.tex) and Makefile
- pandoc converts Markdown to LaTeX at build time
- Two Lua filters: table-widths.lua (wide tables wrap), code-breaks.lua (inline code breaks)
- make docs builds single PDF (754 pages, 0 missing characters)
- make docs TARGET=<board> adds board appendix
- build/docs/<book|board>/meta.tex stamps git commit into PDF

Bug fixes:
- 42 include paths that only worked by accident now use correct relative paths
- clang-18 hardcode replaced with configurable CC variable (fixed aarch64 build)
- Pi 5: kernel_2712.img linked at 0x80000, .bss zeroed, memory reserved
- Doxyfile, .clang-tidy, README.md, Kconfig paths updated

Verified:
- Hosted v3 build passes 1012 tests, 0 failures
- SER5 image boots in QEMU (OVMF), POST passes, K exact (65536 = Q48_ONE)
- Milk-V image boots in QEMU (OpenSBI + U-Boot + bootefi), POST passes
- make clean TARGET=<board> removes only that board and its ISA objects
- make all builds all boards, hosted v3, and docs in one run

Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
rajames
2026-10-01 15:40:09 -04:00
co-authored by Junie
parent 3c709c115b
commit a8b70e88d3
630 changed files with 2646 additions and 2036 deletions
+991
View File
@@ -0,0 +1,991 @@
/*
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.
*/
/*
* physics_hotwords_cache.c
*
* Hot-words cache implementation
* Provides frequency-driven dictionary acceleration via LRU cache
*/
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include "physics_hotwords_cache.h"
/* In kernel builds, suppress platform_time.h inline wrappers to avoid
* GOTPCREL relocations in PE binaries (no GOT in PE format). shim.c
* provides the concrete sf_monotonic_ns() symbol in that case.
* PLATFORM_TIME_NO_INLINE is also set globally via COMMON_CFLAGS in
* Makefile.starkernel; the #ifndef guard prevents -Wall -Werror redefinition. */
#ifdef __STARKERNEL__
#ifndef PLATFORM_TIME_NO_INLINE
#define PLATFORM_TIME_NO_INLINE
#endif
#endif /* __STARKERNEL__ */
#include "platform_time.h"
#include "math_portable.h"
/* ============================================================================
* Global Cache Instance
* ============================================================================
*/
/* Global hot-words cache for the VM (used by benchmark and diagnostic words) */
HotwordsCache g_hotwords_cache = {0};
/* Forward declarations for helper functions */
static inline double q48_to_ns_double(int64_t q48);
/* ============================================================================
* Cache Initialization
* ============================================================================
*/
/**
* @brief Initialise a @c HotwordsCache to its empty, enabled state.
*
* Zeroes all fields via @c memset then explicitly sets:
* - @c cache->enabled = @c ENABLE_HOTWORDS_CACHE (build-time compile flag;
* 0 disables the cache globally without a code change).
* - @c cache->cache_count = 0, @c cache->lru_index = 0 — empty round-robin
* eviction ring.
* - All counter fields in @c cache->stats to 0.
* - @c stats.min_cache_hit_ns = @c INT64_MAX, @c stats.max_cache_hit_ns =
* @c INT64_MIN (sentinel values; updated to real observations on the first
* cache hit).
* - @c stats.min_bucket_search_ns = @c INT64_MAX, @c stats.max_bucket_search_ns
* = @c INT64_MIN (same sentinel convention for bucket searches).
* - All Q48.16 variance accumulators to 0.
*
* Safe to call multiple times (idempotent via @c memset). Must be called
* before the first @c hotwords_cache_lookup() invocation; failure to do so
* leaves the sentinel min/max fields uninitialised, producing nonsense in the
* Bayesian posterior report.
*
* @param cache Cache to initialise; no-op if NULL.
*/
void hotwords_cache_init(HotwordsCache *cache) {
if (!cache) return;
memset(cache, 0, sizeof(*cache));
cache->enabled = ENABLE_HOTWORDS_CACHE;
cache->cache_count = 0;
cache->lru_index = 0;
/* Initialize statistics with proper sentinel values */
cache->stats.total_lookups = 0;
cache->stats.cache_hits = 0;
cache->stats.bucket_hits = 0;
cache->stats.misses = 0;
cache->stats.evictions = 0;
cache->stats.promotions = 0;
cache->stats.bucket_reorders = 0;
/* Fixed-point statistics (Q48.16 format) */
cache->stats.cache_hit_total_ns_q48 = 0;
cache->stats.bucket_search_total_ns_q48 = 0;
cache->stats.cache_hit_samples = 0;
cache->stats.bucket_search_samples = 0;
/* Min/max latencies */
cache->stats.min_cache_hit_ns = INT64_MAX;
cache->stats.max_cache_hit_ns = INT64_MIN;
cache->stats.min_bucket_search_ns = INT64_MAX;
cache->stats.max_bucket_search_ns = INT64_MIN;
/* Variance accumulators (Q48.16) */
cache->stats.cache_hit_variance_sum_q48 = 0;
cache->stats.bucket_search_variance_sum_q48 = 0;
}
/**
* @brief Zero all fields in a @c HotwordsCache, releasing no heap memory.
*
* Calls @c memset(cache, 0, sizeof(*cache)) to scrub every field including
* the inline @c cache[] pointer array and all statistics. The cache holds no
* separately allocated memory (the @c DictEntry pointers inside @c cache[]
* are owned by the dictionary, not the cache), so no @c free() calls are
* needed.
*
* Intended for use at VM teardown or when the caller wants to reset the cache
* to a known-zero state between benchmark runs. After this call the cache
* is in an unusable state (@c enabled = 0); call @c hotwords_cache_init()
* to reinitialise it.
*
* @param cache Cache to zero; no-op if NULL.
*/
void hotwords_cache_cleanup(HotwordsCache *cache) {
if (!cache) return;
memset(cache, 0, sizeof(*cache));
}
/* ============================================================================
* Main Lookup: Cache + Bucket Search
* ============================================================================
*/
/**
* @brief Look up a FORTH word using the hot-words cache, falling back to the bucket.
*
* The primary dictionary fast-path. Records a start timestamp via
* @c sf_monotonic_ns() and then executes a two-stage search:
*
* **Stage 1 — Cache check (O(HOTWORDS_CACHE_SIZE)):**
* Scans the @c cache->cache[] ring for an entry whose @c name_len and name
* match @p name/@p len, using the cheap-first filter (@c name_len →
* last-character → @c memcmp). On a hit the elapsed nanoseconds are
* converted to Q48.16 and accumulated into @c stats.cache_hit_total_ns_q48;
* the squared value is accumulated into @c stats.cache_hit_variance_sum_q48
* for Bayesian posterior computation by @c calculate_posterior(). Min/max
* latency sentinels are also updated.
*
* **Stage 2 — Bucket fallback (O(bucket_count)):**
* Scans @p bucket[] in reverse-insertion order (newest definition wins).
* On a hit the elapsed time is recorded in the @c bucket_search_* accumulators
* with the same Q48.16 and variance treatment as Stage 1. If the found
* word's @c execution_heat exceeds @c HOTWORDS_EXECUTION_HEAT_THRESHOLD it
* is promoted into the cache via @c hotwords_cache_promote().
*
* **Disabled path:** If @c cache is NULL or @c cache->enabled is 0, the
* function skips Stage 1 and performs a bare reverse-linear scan of @p bucket[]
* returning the result immediately (no statistics are recorded).
*
* A miss increments @c stats.misses and returns NULL.
*
* @param cache Hot-words cache; may be NULL or disabled (falls back
* to bucket scan without statistics).
* @param bucket First-character hash bucket — array of @c DictEntry*
* pointers, searched in reverse order.
* @param bucket_count Number of valid pointers in @p bucket.
* @param name Word name to find (not NUL-terminated).
* @param len Length of @p name in bytes.
* @return Matching @c DictEntry*, or NULL if not found.
*/
DictEntry *hotwords_cache_lookup(VM *vm,
HotwordsCache *cache,
DictEntry **bucket,
size_t bucket_count,
const char *name,
size_t len) {
uint64_t start_ns = sf_monotonic_ns();
// Disabled? Go straight to the arbitrated bucket resolver
if (!cache || !cache->enabled) {
return vm_dict_resolve_in_bucket(vm, bucket, bucket_count, name, len);
}
cache->stats.total_lookups++;
// --- STEP 1: Check hot-words cache ---
for (uint32_t i = 0; i < cache->cache_count; i++) {
DictEntry *e = cache->cache[i];
if (!e) continue;
if ((size_t)e->name_len != len) continue;
#ifdef WORD_HIDDEN
if (e->flags & WORD_HIDDEN) continue;
#endif
#ifdef WORD_SMUDGED
if (e->flags & WORD_SMUDGED) continue;
#endif
if (len > 1 && (unsigned char)e->name[len - 1] != (unsigned char)name[len - 1]) continue;
if (memcmp(e->name, name, len) == 0) {
// Cache hit! Record latency with 64-bit fixed-point precision
cache->stats.cache_hits++;
int64_t elapsed_ns = (int64_t)(sf_monotonic_ns() - start_ns);
/* Store in Q48.16 fixed-point format (shift left by 16 bits for fractional precision) */
cache->stats.cache_hit_total_ns_q48 += (elapsed_ns << 16);
cache->stats.cache_hit_samples++;
/* Track min/max */
if (elapsed_ns < cache->stats.min_cache_hit_ns) {
cache->stats.min_cache_hit_ns = elapsed_ns;
}
if (elapsed_ns > cache->stats.max_cache_hit_ns) {
cache->stats.max_cache_hit_ns = elapsed_ns;
}
/* Accumulate squared values for variance calculation (Bayesian posteriors) */
int64_t elapsed_sq_q48 = (elapsed_ns * elapsed_ns) << 16;
cache->stats.cache_hit_variance_sum_q48 += elapsed_sq_q48;
return e;
}
}
// --- STEP 2: Fall back to bucket search (arbitrated: newest visible
// same-named definition wins regardless of heat ordering) ---
{
DictEntry *e = vm_dict_resolve_in_bucket(vm, bucket, bucket_count, name, len);
if (e) {
// Bucket hit! Record latency with 64-bit fixed-point precision
cache->stats.bucket_hits++;
int64_t elapsed_ns = (int64_t)(sf_monotonic_ns() - start_ns);
/* Store in Q48.16 fixed-point format (shift left by 16 bits for fractional precision) */
cache->stats.bucket_search_total_ns_q48 += (elapsed_ns << 16);
cache->stats.bucket_search_samples++;
/* Track min/max */
if (elapsed_ns < cache->stats.min_bucket_search_ns) {
cache->stats.min_bucket_search_ns = elapsed_ns;
}
if (elapsed_ns > cache->stats.max_bucket_search_ns) {
cache->stats.max_bucket_search_ns = elapsed_ns;
}
/* Accumulate squared values for variance calculation (Bayesian posteriors) */
int64_t elapsed_sq_q48 = (elapsed_ns * elapsed_ns) << 16;
cache->stats.bucket_search_variance_sum_q48 += elapsed_sq_q48;
// If hot enough, promote to cache
if (e->execution_heat > HOTWORDS_EXECUTION_HEAT_THRESHOLD) {
hotwords_cache_promote(cache, e);
}
return e;
}
}
// Miss
cache->stats.misses++;
return NULL;
}
/* ============================================================================
* Cache Eviction (coherence with dictionary mutations)
* ============================================================================
*/
/* Remove ring slot i, compacting the tail down one position. */
static void hotwords_cache_remove_at(HotwordsCache *cache, uint32_t i) {
for (uint32_t j = i; j + 1 < cache->cache_count; j++) {
cache->cache[j] = cache->cache[j + 1];
}
cache->cache_count--;
cache->cache[cache->cache_count] = NULL;
if (cache->lru_index >= cache->cache_count) {
cache->lru_index = 0;
}
}
void hotwords_cache_evict_entry(HotwordsCache *cache, const DictEntry *word) {
if (!cache || !word || cache->cache_count == 0) return;
for (uint32_t i = cache->cache_count; i-- > 0;) {
if (cache->cache[i] == word) {
hotwords_cache_remove_at(cache, i);
}
}
}
void hotwords_cache_evict_name(HotwordsCache *cache, const char *name, size_t len) {
if (!cache || !name || len == 0 || cache->cache_count == 0) return;
for (uint32_t i = cache->cache_count; i-- > 0;) {
DictEntry *e = cache->cache[i];
if (!e) continue;
if ((size_t)e->name_len != len) continue;
if (memcmp(e->name, name, len) != 0) continue;
hotwords_cache_remove_at(cache, i);
}
}
/* ============================================================================
* Cache Promotion (LRU eviction)
* ============================================================================
*/
/**
* @brief Promote a @c DictEntry into the hot-words cache, evicting the oldest entry if full.
*
* Two paths depending on whether the cache has a free slot:
*
* - **Slot available** (@c cache->cache_count < @c HOTWORDS_CACHE_SIZE):
* Appends @p word at @c cache->cache[cache_count++] after verifying it is
* not already present (linear scan). Increments @c stats.promotions.
*
* - **Cache full** (@c cache->cache_count >= @c HOTWORDS_CACHE_SIZE):
* Overwrites @c cache->cache[cache->lru_index] with @p word (round-robin
* least-recently-used eviction) and advances @c lru_index modulo
* @c HOTWORDS_CACHE_SIZE. Increments @c stats.evictions. The evicted entry
* is not freed — it remains alive in the dictionary; only its slot in the
* cache ring is surrendered.
*
* Called from @c hotwords_cache_lookup() whenever a bucket search finds a
* word hot enough to warrant caching (heat > @c HOTWORDS_EXECUTION_HEAT_THRESHOLD).
*
* @param cache The cache to promote into; no-op if NULL.
* @param word The @c DictEntry to promote; no-op if NULL. Ownership is not
* transferred — the cache holds a borrowed pointer into the
* dictionary, which owns the entry.
*/
void hotwords_cache_promote(HotwordsCache *cache, DictEntry *word) {
if (!cache || !word || cache->cache_count >= HOTWORDS_CACHE_SIZE) {
if (cache && cache->cache_count >= HOTWORDS_CACHE_SIZE) {
// LRU eviction: remove oldest entry (round-robin)
cache->cache[cache->lru_index] = word;
cache->lru_index = (cache->lru_index + 1) % HOTWORDS_CACHE_SIZE;
cache->stats.evictions++;
}
return;
}
// Check if already in cache
for (uint32_t i = 0; i < cache->cache_count; i++) {
if (cache->cache[i] == word) {
return; // Already cached
}
}
// Add to cache
cache->cache[cache->cache_count++] = word;
cache->stats.promotions++;
}
/* ============================================================================
* Bucket Reordering (move hot words forward)
* ============================================================================
*/
/**
* @brief Sort a hash bucket in-place by descending execution heat (bubble sort).
*
* Performs a classic in-place bubble sort over the @p bucket array so that
* words with higher @c execution_heat appear at lower array indices. The
* hot-words cache and heat-stratified lookup both benefit from having the
* hottest words at the front of each bucket: the first-character hash buckets
* are linear arrays, and placing hot words up front reduces the scan depth on
* Stage 1 cache misses that fall through to Stage 2 bucket search.
*
* Bubble sort is chosen deliberately: buckets are small (typically single
* digits to low dozens of words per first-character class), already nearly
* sorted after the previous reorganization pass, and the cost of a O(n²)
* inner loop on n ≤ 30 is negligible compared to the outer @c qsort on
* all 256 buckets in @c dict_reorganize_buckets_by_heat(). For the individual
* per-bucket call path (e.g. after a single promotion), bubble sort avoids
* the @c malloc overhead that @c qsort's temporary storage would require.
*
* No-op for @p count < 2.
*
* @param bucket Array of @c DictEntry* pointers to sort in-place.
* NULL-check is the caller's responsibility.
* @param count Number of valid entries in @p bucket.
*/
void hotwords_bucket_reorder(DictEntry **bucket, size_t count) {
if (!bucket || count < 2) return;
// Simple bubble sort: move high-execution-heat words forward
for (size_t i = 0; i < count - 1; i++) {
for (size_t j = 0; j < count - i - 1; j++) {
if (bucket[j]->execution_heat < bucket[j + 1]->execution_heat) {
// Swap
DictEntry *tmp = bucket[j];
bucket[j] = bucket[j + 1];
bucket[j + 1] = tmp;
}
}
}
}
/* ============================================================================
* Statistics
* ============================================================================
*/
/**
* @brief Zero all fields in a @c HotwordsStats structure.
*
* Resets every counter (@c total_lookups, @c cache_hits, @c bucket_hits,
* @c misses, @c evictions, @c promotions, @c bucket_reorders) to zero,
* resets all Q48.16 accumulator fields (@c cache_hit_total_ns_q48,
* @c bucket_search_total_ns_q48, @c cache_hit_variance_sum_q48,
* @c bucket_search_variance_sum_q48) to zero, resets sample counts to zero,
* and restores the min/max latency sentinels:
* - @c min_*_ns = @c INT64_MAX
* - @c max_*_ns = @c INT64_MIN
*
* Intended for DoE benchmark runs that need a clean statistical baseline
* between treatment cells. The parent @c HotwordsCache also holds the
* @c cache[] pointer array and LRU state; those are not touched — only the
* @c HotwordsStats sub-struct is cleared.
*
* @param stats Statistics structure to reset; no-op if NULL.
*/
void hotwords_stats_reset(HotwordsStats *stats) {
if (!stats) return;
/* Reset counters */
stats->total_lookups = 0;
stats->cache_hits = 0;
stats->bucket_hits = 0;
stats->misses = 0;
stats->evictions = 0;
stats->promotions = 0;
stats->bucket_reorders = 0;
/* Reset fixed-point accumulators */
stats->cache_hit_total_ns_q48 = 0;
stats->bucket_search_total_ns_q48 = 0;
stats->cache_hit_samples = 0;
stats->bucket_search_samples = 0;
/* Reset min/max latencies */
stats->min_cache_hit_ns = INT64_MAX;
stats->max_cache_hit_ns = INT64_MIN;
stats->min_bucket_search_ns = INT64_MAX;
stats->max_bucket_search_ns = INT64_MIN;
/* Reset variance accumulators */
stats->cache_hit_variance_sum_q48 = 0;
stats->bucket_search_variance_sum_q48 = 0;
}
/**
* @brief Print a formatted hot-words cache statistics report to @c stdout.
*
* Emits a Unicode box-drawing table covering three sections:
*
* **LOOKUPS** — total, cache hits, bucket hits, misses with percentages.
*
* **LATENCY STATISTICS** — for each of cache-hits and bucket-searches
* (when their sample counts are non-zero):
* - Min / avg / max in nanoseconds, derived from the Q48.16 accumulators
* by right-shifting 16 bits: @c avg_ns = @c total_ns_q48 / samples >> 16.
* - Standard deviation computed from the Welford-style variance accumulator:
* @c var = E[X²] − E[X]², then @c stddev = sqrt_q48(var), converted to
* nanoseconds via @c q48_to_ns_double() for display only.
* - Speedup ratio (bucket avg / cache avg) when both sample sets are present.
*
* **CACHE MANAGEMENT** — promotions, evictions, and bucket reorder counts.
*
* If @p stats is NULL or the total lookup count is zero, prints a brief
* "No lookups performed." notice instead of the full table. All floating-point
* usage in this function is display-only (via @c q48_to_ns_double()); the
* underlying accumulators remain in Q48.16 integer format.
*
* @param stats Statistics to report; no-op if NULL.
*/
void hotwords_stats_print(const HotwordsStats *stats) {
if (!stats) return;
printf("\n╔════════════════════════════════════════════════════════════════╗\n");
printf("║ Hot-Words Cache Statistics (64-bit Fixed-Point Q48.16) ║\n");
printf("╚════════════════════════════════════════════════════════════════╝\n\n");
uint64_t total = stats->cache_hits + stats->bucket_hits + stats->misses;
if (total == 0) {
printf("No lookups performed.\n");
return;
}
double cache_hit_pct = 100.0 * stats->cache_hits / total;
double bucket_hit_pct = 100.0 * stats->bucket_hits / total;
double miss_pct = 100.0 * stats->misses / total;
printf("LOOKUPS (Statistically Valid Sample Size):\n");
printf(" Total: %lu\n", total);
printf(" Cache hits: %lu (%.2f%%)\n", stats->cache_hits, cache_hit_pct);
printf(" Bucket hits: %lu (%.2f%%)\n", stats->bucket_hits, bucket_hit_pct);
printf(" Misses: %lu (%.2f%%)\n", stats->misses, miss_pct);
printf("\nLATENCY STATISTICS (64-bit Fixed-Point Precision):\n");
/* Cache hit latency (Q48.16 format: shift right by 16 bits to get nanoseconds) */
if (stats->cache_hit_samples > 0) {
int64_t avg_cache_ns_q48 = stats->cache_hit_total_ns_q48 / (int64_t)stats->cache_hit_samples;
double avg_cache_ns = (double)avg_cache_ns_q48 / 65536.0; /* Q48.16 to double */
double min_cache_ns = (double)stats->min_cache_hit_ns;
double max_cache_ns = (double)stats->max_cache_hit_ns;
printf(" Cache Hits (%lu samples):\n", stats->cache_hit_samples);
printf(" Min: %.3f ns\n", min_cache_ns);
printf(" Avg: %.3f ns\n", avg_cache_ns);
printf(" Max: %.3f ns\n", max_cache_ns);
/* Calculate variance and standard deviation */
int64_t variance_sum_q48 = stats->cache_hit_variance_sum_q48 / (int64_t)stats->cache_hit_samples;
int64_t mean_sq_q48 = (avg_cache_ns_q48 * avg_cache_ns_q48) / (1LL << 16);
int64_t var_q48 = variance_sum_q48 - mean_sq_q48;
int64_t stddev_q48 = (var_q48 >= 0) ? sqrt_q48(var_q48) : 0;
double stddev = q48_to_ns_double(stddev_q48);
printf(" StdDev: %.3f ns\n", stddev);
}
if (stats->bucket_search_samples > 0) {
int64_t avg_bucket_ns_q48 = stats->bucket_search_total_ns_q48 / (int64_t)stats->bucket_search_samples;
double avg_bucket_ns = (double)avg_bucket_ns_q48 / 65536.0; /* Q48.16 to double */
double min_bucket_ns = (double)stats->min_bucket_search_ns;
double max_bucket_ns = (double)stats->max_bucket_search_ns;
printf(" Bucket Searches (%lu samples):\n", stats->bucket_search_samples);
printf(" Min: %.3f ns\n", min_bucket_ns);
printf(" Avg: %.3f ns\n", avg_bucket_ns);
printf(" Max: %.3f ns\n", max_bucket_ns);
/* Calculate variance and standard deviation */
int64_t variance_sum_q48 = stats->bucket_search_variance_sum_q48 / (int64_t)stats->bucket_search_samples;
int64_t mean_sq_q48 = (avg_bucket_ns_q48 * avg_bucket_ns_q48) / (1LL << 16);
int64_t var_q48 = variance_sum_q48 - mean_sq_q48;
int64_t stddev_q48 = (var_q48 >= 0) ? sqrt_q48(var_q48) : 0;
double stddev = q48_to_ns_double(stddev_q48);
printf(" StdDev: %.3f ns\n", stddev);
/* Speedup calculation */
if (stats->cache_hit_samples > 0) {
int64_t avg_cache_ns_q48 = stats->cache_hit_total_ns_q48 / (int64_t)stats->cache_hit_samples;
double avg_bucket_ns_dbl = (double)avg_bucket_ns_q48 / 65536.0;
double speedup = avg_bucket_ns_dbl / ((double)avg_cache_ns_q48 / 65536.0);
printf(" Speedup: %.2f× (bucket vs cache)\n", speedup);
}
}
printf("\nCACHE MANAGEMENT:\n");
printf(" Promotions: %lu\n", stats->promotions);
printf(" Evictions: %lu\n", stats->evictions);
printf(" Reorders: %lu\n", stats->bucket_reorders);
printf("\n════════════════════════════════════════════════════════════════\n\n");
}
/**
* @brief Enable or disable the hot-words cache at runtime.
*
* Sets @c cache->enabled to @p enabled (non-zero = active, 0 = bypassed).
* When disabled, @c hotwords_cache_lookup() skips Stage 1 entirely and
* falls through directly to a bare bucket scan, recording no statistics.
* This allows benchmark harnesses to compare cache-on vs. cache-off behaviour
* without rebuilding the binary.
*
* @param cache Cache to configure; no-op if NULL.
* @param enabled Non-zero to enable the cache; 0 to disable.
*/
void hotwords_cache_set_enabled(HotwordsCache *cache, int enabled) {
if (!cache) return;
cache->enabled = enabled;
}
/* ============================================================================
* Bayesian Inference Implementation
* ============================================================================
*/
/**
* @brief Convert a Q48.16 fixed-point value to a @c double nanosecond count.
*
* Divides @p q48 by 65536.0 to recover the real-valued nanosecond count.
* This is the only floating-point operation in the module and is intentionally
* confined to display paths — @c hotwords_stats_print() and
* @c hotwords_bayesian_report() — so that all internal accumulation and
* statistical computation remains in Q48.16 integer arithmetic.
*
* @param q48 Value in Q48.16 format (1.0 ns = 65536).
* @return The same quantity as a @c double in nanoseconds.
*/
static inline double q48_to_ns_double(int64_t q48) {
return (double)q48 / 65536.0;
}
/**
* @brief Compute a Bayesian latency posterior for one measurement distribution.
*
* Given the accumulated Q48.16 statistics for a set of @p sample_count
* timing observations (either cache-hit or bucket-search latencies), computes
* the following posterior quantities entirely in Q48.16 integer arithmetic:
*
* - **Mean** (@c mean_ns_q48): @c total_ns_q48 / sample_count.
* - **Variance** (@c stddev_ns_q48): @c E[X²] − @c E[X]² where @c E[X²] is
* the per-sample average of @p variance_sum_q48 and @c E[X]² =
* @c mean² scaled to Q48.16 via @c >> 16. Variance is clamped to 0 to
* guard numerical rounding.
* - **Standard deviation** (@c stddev_ns_q48): @c sqrt_q48(variance).
* - **Median** (@c median_ns_q48): @c (min_ns + max_ns) / 2 — a quick
* midpoint estimate rather than a true median, consistent across all
* call sites.
* - **Standard error** (@c se_q48): @c stddev / √N, computed by converting
* @p sample_count to Q48.16 (@c sample_count << 16) and calling
* @c sqrt_q48(), then dividing @c stddev << 16 by the result.
* - **95% credible interval**: @c mean ± Z₉₅ × SE where
* @c Z₉₅ = 128431 (1.96 × 65536).
* - **99% credible interval**: @c mean ± Z₉₉ × SE where
* @c Z₉₉ = 168888 (2.576 × 65536).
* Lower credible bounds are clamped at 0 (latency is non-negative).
*
* Returns a zeroed @c BayesianLatencyPosterior if @p sample_count is 0.
*
* @param total_ns_q48 Sum of all latency observations in Q48.16.
* @param variance_sum_q48 Sum of squared latency observations in Q48.16
* (i.e., @c Σ(xᵢ² << 16)).
* @param min_ns Minimum observed latency in integer nanoseconds.
* @param max_ns Maximum observed latency in integer nanoseconds.
* @param sample_count Number of observations; 0 → returns zeroed struct.
* @return @c BayesianLatencyPosterior with all fields in Q48.16.
*/
static BayesianLatencyPosterior calculate_posterior(
int64_t total_ns_q48,
int64_t variance_sum_q48,
int64_t min_ns,
int64_t max_ns,
uint64_t sample_count) {
const int64_t Z95 = 128431LL; /* 1.96 * 65536 */
const int64_t Z99 = 168888LL; /* 2.576 * 65536 */
BayesianLatencyPosterior posterior = {0};
posterior.sample_count = sample_count;
if (sample_count == 0) {
return posterior;
}
/* Calculate mean from accumulated Q48.16 values (pure integer division) */
int64_t mean_q48 = total_ns_q48 / (int64_t)sample_count;
posterior.mean_ns_q48 = mean_q48;
/* Calculate variance: E[X^2] - E[X]^2, all in Q48.16 */
int64_t mean_sq_q48 = (mean_q48 * mean_q48) >> 16;
int64_t variance_sum_corrected = variance_sum_q48 / (int64_t)sample_count;
int64_t variance_q48 = variance_sum_corrected - mean_sq_q48;
if (variance_q48 < 0) variance_q48 = 0; /* Guard numerical errors */
/* Calculate standard deviation using Q48.16 sqrt */
int64_t stddev_q48 = sqrt_q48(variance_q48);
posterior.stddev_ns_q48 = stddev_q48;
/* Median: average of min/max */
posterior.median_ns_q48 = (min_ns + max_ns) / 2;
/* Standard error: stddev / sqrt(N) in Q48.16 */
/* sqrt(sample_count) approximation for Q48.16 arithmetic */
int64_t sqrt_n_q48 = sqrt_q48((int64_t)sample_count << 16); /* Convert N to Q48.16 */
int64_t se_q48 = (stddev_q48 << 16) / (sqrt_n_q48 >> 16); /* Careful division */
/* 95% credible interval: mean ± Z95 * SE (Z95 = 1.96 << 16) */
int64_t margin_95 = (Z95 * se_q48) >> 16;
posterior.credible_lower_95 = mean_q48 - margin_95;
posterior.credible_upper_95 = mean_q48 + margin_95;
/* 99% credible interval: mean ± Z99 * SE (Z99 = 2.576 << 16) */
int64_t margin_99 = (Z99 * se_q48) >> 16;
posterior.credible_lower_99 = mean_q48 - margin_99;
posterior.credible_upper_99 = mean_q48 + margin_99;
/* Clamp lower bounds to >= 0 */
if (posterior.credible_lower_95 < 0) posterior.credible_lower_95 = 0;
if (posterior.credible_lower_99 < 0) posterior.credible_lower_99 = 0;
return posterior;
}
/**
* @brief Return the Bayesian latency posterior for cache-hit observations.
*
* Forwards the cache-hit accumulators from @p stats to @c calculate_posterior():
* @c cache_hit_total_ns_q48, @c cache_hit_variance_sum_q48, @c min_cache_hit_ns,
* @c max_cache_hit_ns, and @c cache_hit_samples. Returns a zeroed
* @c BayesianLatencyPosterior if @p stats is NULL or no cache-hit samples have
* been recorded yet.
*
* The returned posterior's @c mean_ns_q48, @c stddev_ns_q48, and 95%/99%
* credible-interval fields are consumed by @c hotwords_speedup_estimate() and
* @c hotwords_bayesian_report() to compare cache-hit latency against bucket-search
* latency.
*
* @param stats @c HotwordsStats containing cache-hit timing accumulators.
* @return Posterior in Q48.16; all fields zero if no samples exist.
*/
BayesianLatencyPosterior hotwords_posterior_cache_hits(const HotwordsStats *stats) {
if (!stats || stats->cache_hit_samples == 0) {
BayesianLatencyPosterior empty = {0};
return empty;
}
return calculate_posterior(
stats->cache_hit_total_ns_q48,
stats->cache_hit_variance_sum_q48,
stats->min_cache_hit_ns,
stats->max_cache_hit_ns,
stats->cache_hit_samples
);
}
/**
* @brief Return the Bayesian latency posterior for bucket-search observations.
*
* Forwards the bucket-search accumulators from @p stats to @c calculate_posterior():
* @c bucket_search_total_ns_q48, @c bucket_search_variance_sum_q48,
* @c min_bucket_search_ns, @c max_bucket_search_ns, and @c bucket_search_samples.
* Returns a zeroed @c BayesianLatencyPosterior if @p stats is NULL or no
* bucket-search samples have been recorded.
*
* The returned posterior's mean and credible-interval fields feed
* @c hotwords_speedup_estimate() (as the slower distribution) and the
* baseline section of @c hotwords_bayesian_report().
*
* @param stats @c HotwordsStats containing bucket-search timing accumulators.
* @return Posterior in Q48.16; all fields zero if no samples exist.
*/
BayesianLatencyPosterior hotwords_posterior_bucket_searches(const HotwordsStats *stats) {
if (!stats || stats->bucket_search_samples == 0) {
BayesianLatencyPosterior empty = {0};
return empty;
}
return calculate_posterior(
stats->bucket_search_total_ns_q48,
stats->bucket_search_variance_sum_q48,
stats->min_bucket_search_ns,
stats->max_bucket_search_ns,
stats->bucket_search_samples
);
}
/**
* @brief Compute a speedup estimate with credible intervals using the delta method.
*
* Derives a dimensionless speedup ratio @c S = bucket_mean / cache_mean in
* Q48.16 fixed-point and propagates uncertainty via the delta method for ratios:
* @code
* SE(S) ≈ S × sqrt( (SE_bucket / bucket_mean)² + (SE_cache / cache_mean)² )
* @endcode
* All arithmetic is Q48.16 integer throughout; the only deviation is the
* intermediate @c sqrt() call which also runs in Q48.16 via @c sqrt_q48().
*
* Computed fields in the returned @c SpeedupEstimate (all Q48.16):
* - @c speedup_factor_q48 — point estimate of @c S.
* - @c credible_lower_95_q48 / @c credible_upper_95_q48 — 95% credible
* interval: @c S ± 1.96 × SE(S) (Z₉₅ = 128431 in Q48.16). Lower bound
* clamped at 0.
* - @c probability_gt_10pct_q48 — P(speedup > 1.1×), computed as
* @c 0.5 × (1 + erf(z / √2)) where @c z = (1.1 − S) / SE(S) is negated
* to give the right-tail probability and @c √2 = 92681 in Q48.16.
* - @c probability_gt_double_q48 — P(speedup > 2.0×), same formula with
* target = 2.0 × 65536 = 131072.
*
* Returns a default @c SpeedupEstimate with @c speedup_factor_q48 = 65536
* (1.0×) if either distribution has zero samples or if @p stats is NULL.
*
* @param stats @c HotwordsStats with both cache-hit and bucket-search samples.
* @return @c SpeedupEstimate with all fields in Q48.16 fixed-point.
*/
struct SpeedupEstimate hotwords_speedup_estimate(const HotwordsStats *stats) {
const int64_t Q48 = 65536LL;
const int64_t SQRT2 = 92681LL; /* sqrt(2) * 65536 ≈ 1.414 * 65536 */
struct SpeedupEstimate est = {0};
if (!stats || stats->cache_hit_samples == 0 || stats->bucket_search_samples == 0) {
est.speedup_factor_q48 = Q48; /* Default: 1.0x speedup */
return est;
}
BayesianLatencyPosterior cache_post = hotwords_posterior_cache_hits(stats);
BayesianLatencyPosterior bucket_post = hotwords_posterior_bucket_searches(stats);
/* Guard divide-by-zero */
if (cache_post.mean_ns_q48 < 1) cache_post.mean_ns_q48 = 1;
/* Point estimate: speedup = bucket_time / cache_time (dimensionless, in Q48.16) */
int64_t speedup_q48 = (bucket_post.mean_ns_q48 << 16) / cache_post.mean_ns_q48;
est.speedup_factor_q48 = speedup_q48;
/* Delta-method SE for ratio: SE(speedup) ≈ speedup * sqrt((SE_bucket/bucket)^2 + (SE_cache/cache)^2) */
int64_t se_cache_q48 = (cache_post.stddev_ns_q48 << 16) / (int64_t)stats->cache_hit_samples;
int64_t se_bucket_q48 = (bucket_post.stddev_ns_q48 << 16) / (int64_t)stats->bucket_search_samples;
/* Relative standard errors: SE/mean */
int64_t rel_se_cache_q48 = (se_cache_q48 << 16) / cache_post.mean_ns_q48;
int64_t rel_se_bucket_q48 = (se_bucket_q48 << 16) / bucket_post.mean_ns_q48;
/* Combine: sqrt((rel_se_cache)^2 + (rel_se_bucket)^2) */
int64_t sum_sq = ((rel_se_cache_q48 * rel_se_cache_q48) >> 16) +
((rel_se_bucket_q48 * rel_se_bucket_q48) >> 16);
int64_t rel_se_ratio_q48 = sqrt_q48(sum_sq);
/* SE(speedup) = speedup * rel_se_ratio */
int64_t se_speedup_q48 = (speedup_q48 * rel_se_ratio_q48) >> 16;
/* 95% credible interval for speedup (Z1.96 = 128431 in Q48.16) */
int64_t margin_95_q48 = (128431LL * se_speedup_q48) >> 16;
est.credible_lower_95_q48 = speedup_q48 - margin_95_q48;
est.credible_upper_95_q48 = speedup_q48 + margin_95_q48;
if (est.credible_lower_95_q48 < 0) est.credible_lower_95_q48 = 0;
/* Probability of speedup > 1.1× using erf_q48 (pure fixed-point) */
/* Z-score: (1.1 - speedup) / SE_speedup, computed in Q48.16 */
/* Result: erf returns Q48.16 in [-65536, 65536], convert to probability [0, 65536] */
int64_t target_110_q48 = 72090LL; /* 1.1 * 65536 */
int64_t z_10pct_q48 = ((target_110_q48 - speedup_q48) << 16) / se_speedup_q48;
int64_t erf_val_10pct = erf_q48((-z_10pct_q48) / SQRT2);
/* Convert erf result to probability: P = 0.5 * (1 + erf) in Q48.16 */
est.probability_gt_10pct_q48 = (Q48 + erf_val_10pct) >> 1;
/* Probability of speedup > 2.0× */
int64_t target_200_q48 = 131072LL; /* 2.0 * 65536 */
int64_t z_2x_q48 = ((target_200_q48 - speedup_q48) << 16) / se_speedup_q48;
int64_t erf_val_2x = erf_q48((-z_2x_q48) / SQRT2);
est.probability_gt_double_q48 = (Q48 + erf_val_2x) >> 1;
return est;
}
/**
* @brief Print a full Bayesian cache performance report comparing two experimental conditions.
*
* Emits a structured Unicode report to @c stdout comparing the latency
* distributions of two measurement sets — one collected with the hot-words
* cache enabled, the other collected without (baseline). The report is divided
* into five sections:
*
* 1. **WITH CACHE ENABLED** — posterior mean, stddev, and 95%/99% credible
* intervals for cache-hits and bucket-searches separately, via
* @c hotwords_posterior_cache_hits() and @c hotwords_posterior_bucket_searches().
*
* 2. **WITHOUT CACHE (Baseline)** — posterior mean, stddev, and 95% credible
* interval for all lookups (treated as bucket searches). If no bucket data
* is available in @p stats_without_cache, the bucket-search mean from the
* with-cache run is used as an approximation.
*
* 3. **SPEEDUP ESTIMATE (Bucket vs Cache)** — @c hotwords_speedup_estimate()
* point estimate, 95% credible interval, P(>1.1×), and P(>2.0×).
*
* 4. **CACHE EFFECTIVENESS** — total lookups, cache-hit rate, bucket-hit rate,
* and miss rate as percentages of total.
*
* 5. **OVERALL PERFORMANCE** — weighted-average latency with cache vs. baseline
* all-bucket latency, overall speedup, and absolute time saved per lookup.
* The weighted average is: @c (N_cache×avg_cache + N_bucket×avg_bucket) /
* @c (N_cache + N_bucket).
*
* All latency values are displayed in nanoseconds (floating-point via
* @c q48_to_ns_double()) for readability; underlying accumulators are Q48.16
* integer throughout.
*
* No-op if either @p stats_with_cache or @p stats_without_cache is NULL.
*
* @param stats_with_cache Statistics from a run with cache enabled.
* @param stats_without_cache Statistics from a baseline run without cache.
*/
void hotwords_bayesian_report(const HotwordsStats *stats_with_cache,
const HotwordsStats *stats_without_cache) {
if (!stats_with_cache || !stats_without_cache) return;
printf("\n╔════════════════════════════════════════════════════════════════╗\n");
printf("║ Bayesian Performance Analysis: Cache Impact ║\n");
printf("╚════════════════════════════════════════════════════════════════╝\n\n");
/* WITH CACHE statistics */
BayesianLatencyPosterior cache_with = hotwords_posterior_cache_hits(stats_with_cache);
BayesianLatencyPosterior bucket_with = hotwords_posterior_bucket_searches(stats_with_cache);
printf("WITH CACHE ENABLED:\n");
printf("─────────────────────────────────────────────────────────────────\n");
printf("Cache Hits (N=%lu):\n", cache_with.sample_count);
printf(" Mean: %.2f ns\n", q48_to_ns_double(cache_with.mean_ns_q48));
printf(" Std Dev: %.2f ns\n", q48_to_ns_double(cache_with.stddev_ns_q48));
printf(" 95%% Credible: [%.2f, %.2f] ns\n", q48_to_ns_double(cache_with.credible_lower_95), q48_to_ns_double(cache_with.credible_upper_95));
printf(" 99%% Credible: [%.2f, %.2f] ns\n\n", q48_to_ns_double(cache_with.credible_lower_99), q48_to_ns_double(cache_with.credible_upper_99));
printf("Bucket Searches (N=%lu):\n", bucket_with.sample_count);
printf(" Mean: %.2f ns\n", q48_to_ns_double(bucket_with.mean_ns_q48));
printf(" Std Dev: %.2f ns\n", q48_to_ns_double(bucket_with.stddev_ns_q48));
printf(" 95%% Credible: [%.2f, %.2f] ns\n", q48_to_ns_double(bucket_with.credible_lower_95), q48_to_ns_double(bucket_with.credible_upper_95));
printf(" 99%% Credible: [%.2f, %.2f] ns\n\n", q48_to_ns_double(bucket_with.credible_lower_99), q48_to_ns_double(bucket_with.credible_upper_99));
/* WITHOUT CACHE statistics */
printf("WITHOUT CACHE (Baseline):\n");
printf("─────────────────────────────────────────────────────────────────\n");
printf("All Lookups (N=%lu):\n", stats_without_cache->cache_hits + stats_without_cache->bucket_hits);
/* Estimate average latency without cache (all go to bucket) */
BayesianLatencyPosterior all_lookups_baseline = hotwords_posterior_bucket_searches(stats_without_cache);
if (all_lookups_baseline.sample_count == 0) {
/* Fallback if bucket data not available */
all_lookups_baseline.mean_ns_q48 = bucket_with.mean_ns_q48;
}
printf(" Mean: %.2f ns\n", q48_to_ns_double(all_lookups_baseline.mean_ns_q48));
printf(" Std Dev: %.2f ns\n", q48_to_ns_double(all_lookups_baseline.stddev_ns_q48));
printf(" 95%% Credible: [%.2f, %.2f] ns\n\n", q48_to_ns_double(all_lookups_baseline.credible_lower_95), q48_to_ns_double(all_lookups_baseline.credible_upper_95));
/* Speedup Analysis */
struct SpeedupEstimate speedup = hotwords_speedup_estimate(stats_with_cache);
printf("SPEEDUP ESTIMATE (Bucket vs Cache):\n");
printf("─────────────────────────────────────────────────────────────────\n");
printf(" Point Estimate: %.2f×\n", q48_to_ns_double(speedup.speedup_factor_q48));
printf(" 95%% Credible Interval: [%.2f×, %.2f×]\n", q48_to_ns_double(speedup.credible_lower_95_q48), q48_to_ns_double(speedup.credible_upper_95_q48));
printf(" P(Speedup > 1.1×): %.1f%% (credible improvement exists)\n", q48_to_ns_double(speedup.probability_gt_10pct_q48) * 100.0);
printf(" P(Speedup > 2.0×): %.1f%% (significant speedup)\n\n", q48_to_ns_double(speedup.probability_gt_double_q48) * 100.0);
/* Hit Rate Analysis */
uint64_t total_lookups_with = stats_with_cache->cache_hits + stats_with_cache->bucket_hits + stats_with_cache->misses;
double cache_hit_rate = (total_lookups_with > 0) ? (100.0 * stats_with_cache->cache_hits / total_lookups_with) : 0.0;
printf("CACHE EFFECTIVENESS:\n");
printf("─────────────────────────────────────────────────────────────────\n");
printf(" Total Lookups: %lu\n", total_lookups_with);
printf(" Cache Hits: %lu (%.1f%%)\n", stats_with_cache->cache_hits, cache_hit_rate);
printf(" Bucket Hits: %lu (%.1f%%)\n", stats_with_cache->bucket_hits,
100.0 * stats_with_cache->bucket_hits / total_lookups_with);
printf(" Misses: %lu (%.1f%%)\n\n", stats_with_cache->misses,
100.0 * stats_with_cache->misses / total_lookups_with);
/* Overall Performance */
double mixed_avg_latency = ((double)stats_with_cache->cache_hits * q48_to_ns_double(cache_with.mean_ns_q48) +
(double)stats_with_cache->bucket_hits * q48_to_ns_double(bucket_with.mean_ns_q48)) /
(stats_with_cache->cache_hits + stats_with_cache->bucket_hits);
double baseline_avg_latency = q48_to_ns_double(all_lookups_baseline.mean_ns_q48);
double overall_speedup = baseline_avg_latency / mixed_avg_latency;
printf("OVERALL PERFORMANCE (WITH vs WITHOUT CACHE):\n");
printf("─────────────────────────────────────────────────────────────────\n");
printf(" Baseline (all bucket): %.2f ns/lookup\n", baseline_avg_latency);
printf(" With cache: %.2f ns/lookup\n", mixed_avg_latency);
printf(" Overall Speedup: %.2f×\n", overall_speedup);
printf(" Time Saved per Lookup: %.2f ns (%.1f%%)\n\n", baseline_avg_latency - mixed_avg_latency,
100.0 * (baseline_avg_latency - mixed_avg_latency) / baseline_avg_latency);
printf("╚════════════════════════════════════════════════════════════════╝\n\n");
}