Files
LithosAnanake/src/physics_hotwords_cache.c
T

991 lines
45 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.
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) 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.
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");
}