Skip to main content

khora_data/ecs/
layout.rs

1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Adaptive-layout learner — the decision core of online memory-layout
16//! adaptation.
17//!
18//! Choosing the best physical layout for a component (`Soa` vs an `AoSoA`
19//! tiling, …) can't be known a priori: it depends on the access pattern *and*
20//! the hardware. So the engine treats it as an explore/exploit problem and
21//! **learns** it on the machine it actually runs on — one [`Ucb1`] bandit per
22//! component, arms = candidate layouts, reward = measured iteration throughput.
23//! `UCB1` is deterministic (no RNG), which keeps decisions reproducible.
24//!
25//! This module is the learner only. Wiring it to a *physical* repack the query
26//! path consumes requires CRPECS storage and `WorldQuery::fetch` to become
27//! layout-polymorphic (the column is a type-erased `Vec<T>` today, which only
28//! the `Soa` layout fits) — a larger, hot-path change tracked separately. The
29//! learner is built and tested now so that work has its decision core ready.
30//!
31//! Beyond the textbook [`Ucb1`], this module carries the refinements a *game*
32//! workload needs (all deterministic, so decisions stay reproducible):
33//! [`SlidingWindowUcb1`] for the **non-stationary** reward a session produces
34//! (the best layout in a menu is not the best in a firefight), the
35//! [`net_reward`]/[`should_switch`] helpers that fold the **repack cost** into
36//! the decision so a learner doesn't thrash between layouts, a [`DecayCounter`]
37//! (an evaporating access tally — recency-weighting for free), and the
38//! read-only [`LayoutAdvisor`] that turns live access counters into actionable
39//! layout advice without ever repacking.
40
41use std::collections::VecDeque;
42
43/// Per-arm running statistics for the bandit.
44#[derive(Debug, Clone, Copy, Default)]
45struct ArmStats {
46    /// How many times this arm has been pulled.
47    pulls: u64,
48    /// Running mean of the rewards observed for this arm.
49    mean: f64,
50}
51
52/// A deterministic **UCB1** multi-armed bandit.
53///
54/// Balances *exploration* (try arms whose value is uncertain) and *exploitation*
55/// (favour the arm with the best observed reward) using the classic upper
56/// confidence bound `x̄ᵢ + c·√(ln N / nᵢ)`. Unpulled arms are tried first. No
57/// randomness, so the same reward stream always yields the same decisions —
58/// which is what makes the adaptive layer reproducible.
59#[derive(Debug, Clone)]
60pub struct Ucb1 {
61    arms: Vec<ArmStats>,
62    total: u64,
63    exploration: f64,
64}
65
66impl Ucb1 {
67    /// Default exploration constant `c = √2`, the textbook UCB1 value.
68    pub const DEFAULT_EXPLORATION: f64 = std::f64::consts::SQRT_2;
69
70    /// Creates a bandit with `num_arms` arms (clamped to at least 1) and the
71    /// default exploration constant.
72    pub fn new(num_arms: usize) -> Self {
73        Self::with_exploration(num_arms, Self::DEFAULT_EXPLORATION)
74    }
75
76    /// Creates a bandit with an explicit exploration constant `c` (higher = more
77    /// exploration).
78    pub fn with_exploration(num_arms: usize, exploration: f64) -> Self {
79        Self {
80            arms: vec![ArmStats::default(); num_arms.max(1)],
81            total: 0,
82            exploration,
83        }
84    }
85
86    /// The number of arms.
87    pub fn arm_count(&self) -> usize {
88        self.arms.len()
89    }
90
91    /// Number of times `arm` has been pulled (0 if out of range).
92    pub fn pulls(&self, arm: usize) -> u64 {
93        self.arms.get(arm).map(|a| a.pulls).unwrap_or(0)
94    }
95
96    /// Selects the next arm to try. Any never-pulled arm is chosen first (forced
97    /// initial exploration); otherwise the arm with the highest UCB score.
98    pub fn select(&self) -> usize {
99        // Forced exploration: pull each arm once before scoring.
100        if let Some(i) = self.arms.iter().position(|a| a.pulls == 0) {
101            return i;
102        }
103        let ln_total = (self.total as f64).max(1.0).ln();
104        let mut best = 0usize;
105        let mut best_score = f64::NEG_INFINITY;
106        for (i, arm) in self.arms.iter().enumerate() {
107            let bonus = self.exploration * (ln_total / arm.pulls as f64).sqrt();
108            let score = arm.mean + bonus;
109            if score > best_score {
110                best_score = score;
111                best = i;
112            }
113        }
114        best
115    }
116
117    /// Records a `reward` for `arm` (incremental mean update). Out-of-range arms
118    /// and non-finite rewards are ignored.
119    pub fn record(&mut self, arm: usize, reward: f64) {
120        if !reward.is_finite() {
121            return;
122        }
123        let Some(stats) = self.arms.get_mut(arm) else {
124            return;
125        };
126        stats.pulls += 1;
127        // Welford-style incremental mean.
128        stats.mean += (reward - stats.mean) / stats.pulls as f64;
129        self.total += 1;
130    }
131
132    /// The current best arm by observed mean reward (pure exploitation), or
133    /// `None` until at least one arm has been pulled.
134    pub fn best_arm(&self) -> Option<usize> {
135        self.arms
136            .iter()
137            .enumerate()
138            .filter(|(_, a)| a.pulls > 0)
139            .max_by(|(_, a), (_, b)| {
140                a.mean
141                    .partial_cmp(&b.mean)
142                    .unwrap_or(std::cmp::Ordering::Equal)
143            })
144            .map(|(i, _)| i)
145    }
146
147    /// The observed mean reward for `arm`, or `None` if it hasn't been pulled.
148    pub fn mean_reward(&self, arm: usize) -> Option<f64> {
149        self.arms.get(arm).filter(|a| a.pulls > 0).map(|a| a.mean)
150    }
151}
152
153/// One arm of a [`SlidingWindowUcb1`] — the most recent `window` rewards and
154/// their running sum (so the mean is `O(1)` to read).
155#[derive(Debug, Clone, Default)]
156struct WindowArm {
157    rewards: VecDeque<f64>,
158    sum: f64,
159}
160
161impl WindowArm {
162    fn record(&mut self, reward: f64, window: usize) {
163        self.rewards.push_back(reward);
164        self.sum += reward;
165        while self.rewards.len() > window {
166            if let Some(old) = self.rewards.pop_front() {
167                self.sum -= old;
168            }
169        }
170    }
171
172    fn count(&self) -> usize {
173        self.rewards.len()
174    }
175
176    fn mean(&self) -> f64 {
177        if self.rewards.is_empty() {
178            0.0
179        } else {
180            self.sum / self.rewards.len() as f64
181        }
182    }
183}
184
185/// A **sliding-window** UCB1 bandit for *non-stationary* rewards.
186///
187/// Plain [`Ucb1`] assumes the reward distribution never changes, so its
188/// confidence bound shrinks as `1/√n` forever and old samples pin the estimate
189/// — which is wrong for a game session, where the best layout in a quiet scene
190/// is not the best in a crowded one. This variant scores each arm over only its
191/// most recent `window` rewards (a per-arm ring buffer), so it keeps adapting
192/// when the workload shifts. It is still fully deterministic (no RNG), matching
193/// the reproducibility the adaptive layer requires.
194///
195/// (Garivier & Moulines, *On Upper-Confidence Bound Policies for Non-Stationary
196/// Bandit Problems*, 2011 — the SW-UCB construction.)
197#[derive(Debug, Clone)]
198pub struct SlidingWindowUcb1 {
199    arms: Vec<WindowArm>,
200    window: usize,
201    exploration: f64,
202}
203
204impl SlidingWindowUcb1 {
205    /// Creates a bandit with `num_arms` arms (min 1), each scored over its last
206    /// `window` rewards (min 1), using the default exploration constant `√2`.
207    pub fn new(num_arms: usize, window: usize) -> Self {
208        Self::with_exploration(num_arms, window, Ucb1::DEFAULT_EXPLORATION)
209    }
210
211    /// Creates a bandit with an explicit exploration constant `c`.
212    pub fn with_exploration(num_arms: usize, window: usize, exploration: f64) -> Self {
213        Self {
214            arms: vec![WindowArm::default(); num_arms.max(1)],
215            window: window.max(1),
216            exploration,
217        }
218    }
219
220    /// The number of arms.
221    pub fn arm_count(&self) -> usize {
222        self.arms.len()
223    }
224
225    /// How many rewards are currently retained for `arm` (capped at the window).
226    pub fn samples(&self, arm: usize) -> usize {
227        self.arms.get(arm).map(|a| a.count()).unwrap_or(0)
228    }
229
230    /// Selects the next arm: any arm with an empty window first (forced
231    /// exploration), otherwise the highest windowed UCB score.
232    pub fn select(&self) -> usize {
233        if let Some(i) = self.arms.iter().position(|a| a.count() == 0) {
234            return i;
235        }
236        let total: usize = self.arms.iter().map(|a| a.count()).sum();
237        let ln_total = (total as f64).max(1.0).ln();
238        let mut best = 0usize;
239        let mut best_score = f64::NEG_INFINITY;
240        for (i, arm) in self.arms.iter().enumerate() {
241            let bonus = self.exploration * (ln_total / arm.count() as f64).sqrt();
242            let score = arm.mean() + bonus;
243            if score > best_score {
244                best_score = score;
245                best = i;
246            }
247        }
248        best
249    }
250
251    /// Records a `reward` for `arm` into its window (evicting the oldest once
252    /// full). Out-of-range arms and non-finite rewards are ignored.
253    pub fn record(&mut self, arm: usize, reward: f64) {
254        if !reward.is_finite() {
255            return;
256        }
257        let window = self.window;
258        if let Some(stats) = self.arms.get_mut(arm) {
259            stats.record(reward, window);
260        }
261    }
262
263    /// The best arm by current windowed mean (pure exploitation), or `None`
264    /// until at least one arm has a reward.
265    pub fn best_arm(&self) -> Option<usize> {
266        self.arms
267            .iter()
268            .enumerate()
269            .filter(|(_, a)| a.count() > 0)
270            .max_by(|(_, a), (_, b)| {
271                a.mean()
272                    .partial_cmp(&b.mean())
273                    .unwrap_or(std::cmp::Ordering::Equal)
274            })
275            .map(|(i, _)| i)
276    }
277
278    /// The current windowed mean reward for `arm`, or `None` if its window is
279    /// empty.
280    pub fn mean_reward(&self, arm: usize) -> Option<f64> {
281        self.arms
282            .get(arm)
283            .filter(|a| a.count() > 0)
284            .map(|a| a.mean())
285    }
286}
287
288/// Net reward of running under a layout once the cost of *getting there* is
289/// charged against it: `benefit − migration_cost`.
290///
291/// Feeding this (rather than raw benefit) to a bandit is what makes switching
292/// layouts self-limiting — a challenger has to beat the incumbent by *more than
293/// the repack it would cost*, so a learner cannot thrash between layouts for a
294/// marginal gain. (The cost-into-reward form from contextual index-tuning
295/// bandits; the same idea as ski-rental "amortise the one-time cost".)
296#[inline]
297pub fn net_reward(benefit: f64, migration_cost: f64) -> f64 {
298    benefit - migration_cost
299}
300
301/// Whether a challenger layout should displace the incumbent, given a
302/// `hysteresis` margin it must clear: `challenger > incumbent + hysteresis`.
303///
304/// The margin is the anti-thrash / dwell mechanism (set it to the amortised
305/// repack cost): without it a controller chatters between two near-equal
306/// layouts, paying the switch cost each time. (OREO's α-gate and switched-system
307/// hysteresis converge on the same "switch rarely" rule.)
308#[inline]
309pub fn should_switch(incumbent_score: f64, challenger_score: f64, hysteresis: f64) -> bool {
310    challenger_score > incumbent_score + hysteresis.max(0.0)
311}
312
313/// An **evaporating** access tally — a counter that is added to on access and
314/// decays geometrically over time.
315///
316/// This is recency-weighting for free: a steady stream of accesses holds the
317/// value up, a burst that stops fades away, so the value tracks *current*
318/// pressure without a separate windowing scheme. (Stigmergy: the same mechanic
319/// as ant-trail pheromones — deposit on use, evaporate over time — and the
320/// discounted-count cousin of [`SlidingWindowUcb1`].)
321#[derive(Debug, Clone, Copy)]
322pub struct DecayCounter {
323    value: f64,
324    decay: f64,
325}
326
327impl DecayCounter {
328    /// Creates a counter starting at zero with per-tick retention `decay`,
329    /// clamped to `[0, 1]` (e.g. `0.9` keeps 90 % of the value each tick).
330    pub fn new(decay: f64) -> Self {
331        Self {
332            value: 0.0,
333            decay: decay.clamp(0.0, 1.0),
334        }
335    }
336
337    /// Deposits `amount` (an access this tick).
338    pub fn add(&mut self, amount: f64) {
339        if amount.is_finite() {
340            self.value += amount;
341        }
342    }
343
344    /// Evaporates the value by the decay factor — call once per time step.
345    pub fn tick(&mut self) {
346        self.value *= self.decay;
347    }
348
349    /// The current (recency-weighted) value.
350    pub fn value(&self) -> f64 {
351        self.value
352    }
353}
354
355/// A layout the [`LayoutAdvisor`] can recommend for a component.
356#[derive(Debug, Clone, Copy, PartialEq, Eq)]
357pub enum LayoutRecommendation {
358    /// Keep the default `Soa` column — nothing observed warrants a change.
359    KeepSoa,
360    /// A lean component swept in large batches: a candidate for the field-SoA
361    /// `wide::f32x8` kernels *if* its hot loop is compute-bound and stays
362    /// SoA-resident (see `khora_core::math::simd`).
363    SimdFieldSoa,
364    /// A fat component: a candidate for a hot/cold field split so the per-frame
365    /// sweep drags only the hot bytes through cache.
366    HotColdSplit,
367}
368
369impl LayoutRecommendation {
370    /// A short, human-readable rationale for the glass-box report.
371    pub fn reason(self) -> &'static str {
372        match self {
373            LayoutRecommendation::KeepSoa => {
374                "default SoA is fine — no large-batch or fat-component pressure observed"
375            }
376            LayoutRecommendation::SimdFieldSoa => {
377                "lean component swept in large batches — field-SoA + explicit SIMD if the loop is compute-bound and stays resident"
378            }
379            LayoutRecommendation::HotColdSplit => {
380                "fat component — split hot fields from cold so the sweep touches only hot cache lines"
381            }
382        }
383    }
384}
385
386/// Read-only **layout advisor**: turns the live access counters a component has
387/// accumulated into an actionable layout suggestion, *without ever repacking*.
388///
389/// This is the glass-box half of AGDF that works today (the physical repack is
390/// deferred): a developer can ask "which of my components would benefit from a
391/// different layout, and why?" and get an answer grounded in observed access,
392/// not a guess. The thresholds below encode the engine's own benchmark findings
393/// (`crates/khora-data/examples/layout_bench.rs`): the field-SoA/SIMD lever pays
394/// on *large, compute-bound* batches, and the hot/cold split pays on *fat*
395/// components — Khora's lean built-ins want neither. They are deliberately
396/// coarse heuristics and meant to be tuned.
397#[derive(Debug, Clone, Copy)]
398pub struct LayoutAdvisor {
399    /// A component at least this large (bytes) is "fat" → hot/cold split.
400    pub fat_bytes: usize,
401    /// A query touching at least this many rows on average is a "large batch".
402    pub large_batch_rows: u64,
403}
404
405impl Default for LayoutAdvisor {
406    fn default() -> Self {
407        // `fat_bytes`: the bench's synthetic fat record (128 B = two cache
408        // lines) split 2×; Khora's built-ins (≤64 B) sit below it. `large_batch`:
409        // a few SIMD tiles' worth of rows before the field-SoA kernels are worth
410        // suggesting.
411        Self {
412            fat_bytes: 96,
413            large_batch_rows: 1024,
414        }
415    }
416}
417
418impl LayoutAdvisor {
419    /// Recommends a layout for a component from its size and its
420    /// `(query_count, rows_scanned)` access stats (as returned by
421    /// `World::component_access_stats`).
422    pub fn recommend(
423        &self,
424        component_bytes: usize,
425        query_count: u64,
426        rows_scanned: u64,
427    ) -> LayoutRecommendation {
428        if component_bytes >= self.fat_bytes {
429            return LayoutRecommendation::HotColdSplit;
430        }
431        let avg_rows = rows_scanned.checked_div(query_count).unwrap_or(0);
432        if query_count > 0 && avg_rows >= self.large_batch_rows {
433            return LayoutRecommendation::SimdFieldSoa;
434        }
435        LayoutRecommendation::KeepSoa
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442
443    #[test]
444    fn explores_every_arm_first() {
445        let mut b = Ucb1::new(3);
446        // First three selections must each be a distinct, never-pulled arm.
447        for _ in 0..3 {
448            let arm = b.select();
449            assert_eq!(b.pulls(arm), 0);
450            b.record(arm, 0.0);
451        }
452        assert_eq!((0..3).map(|i| b.pulls(i)).sum::<u64>(), 3);
453    }
454
455    #[test]
456    fn converges_to_best_arm() {
457        // Arm 1 is best (reward 1.0); arms 0 and 2 give 0.0.
458        let mut b = Ucb1::new(3);
459        let reward = |arm: usize| if arm == 1 { 1.0 } else { 0.0 };
460        for _ in 0..200 {
461            let arm = b.select();
462            b.record(arm, reward(arm));
463        }
464        assert_eq!(b.best_arm(), Some(1));
465        // The best arm should dominate the pull budget.
466        assert!(
467            b.pulls(1) > b.pulls(0) + b.pulls(2),
468            "best arm pulled {} vs others {}+{}",
469            b.pulls(1),
470            b.pulls(0),
471            b.pulls(2)
472        );
473    }
474
475    #[test]
476    fn deterministic_same_stream_same_choice() {
477        let run = || {
478            let mut b = Ucb1::new(4);
479            let mut picks = Vec::new();
480            for _ in 0..50 {
481                let arm = b.select();
482                picks.push(arm);
483                b.record(arm, (arm as f64) * 0.1);
484            }
485            picks
486        };
487        assert_eq!(run(), run(), "UCB1 must be deterministic");
488    }
489
490    #[test]
491    fn ignores_bad_input() {
492        let mut b = Ucb1::new(2);
493        b.record(99, 1.0); // out of range
494        b.record(0, f64::NAN); // non-finite
495        assert_eq!(b.pulls(0), 0);
496        assert_eq!(b.best_arm(), None);
497    }
498
499    #[test]
500    fn sliding_window_explores_then_tracks_a_shifting_best_arm() {
501        // Arm 0 is best in "phase 1", arm 1 in "phase 2". A short window must
502        // let the bandit follow the shift instead of pinning to arm 0.
503        let mut b = SlidingWindowUcb1::new(2, 16);
504        let phase1 = |arm: usize| if arm == 0 { 1.0 } else { 0.0 };
505        let phase2 = |arm: usize| if arm == 1 { 1.0 } else { 0.0 };
506
507        for _ in 0..100 {
508            let arm = b.select();
509            b.record(arm, phase1(arm));
510        }
511        assert_eq!(b.best_arm(), Some(0), "should favour arm 0 in phase 1");
512
513        for _ in 0..100 {
514            let arm = b.select();
515            b.record(arm, phase2(arm));
516        }
517        assert_eq!(
518            b.best_arm(),
519            Some(1),
520            "should follow the shift to arm 1 once the window has turned over"
521        );
522    }
523
524    #[test]
525    fn sliding_window_is_deterministic() {
526        let run = || {
527            let mut b = SlidingWindowUcb1::new(4, 8);
528            let mut picks = Vec::new();
529            for _ in 0..50 {
530                let arm = b.select();
531                picks.push(arm);
532                b.record(arm, (arm as f64) * 0.1);
533            }
534            picks
535        };
536        assert_eq!(run(), run(), "SW-UCB1 must be deterministic");
537    }
538
539    #[test]
540    fn net_reward_and_hysteresis_gate_switching() {
541        // A challenger that beats the incumbent by less than the repack cost
542        // must not trigger a switch.
543        let incumbent = 1.0;
544        let migration_cost = 0.3;
545        let marginal_challenger = net_reward(1.2, migration_cost); // 0.9 net
546        assert!(!should_switch(incumbent, marginal_challenger, 0.0));
547        // A challenger that clears the cost does switch.
548        let worthwhile = net_reward(1.8, migration_cost); // 1.5 net
549        assert!(should_switch(incumbent, worthwhile, 0.0));
550        // The explicit hysteresis margin adds dwell on top.
551        assert!(!should_switch(1.0, 1.2, 0.5));
552        assert!(should_switch(1.0, 1.6, 0.5));
553    }
554
555    #[test]
556    fn decay_counter_evaporates_and_favours_recency() {
557        let mut c = DecayCounter::new(0.5);
558        c.add(1.0);
559        c.tick(); // 0.5
560        c.tick(); // 0.25
561        assert!((c.value() - 0.25).abs() < 1e-9);
562        // A fresh deposit dominates the faded history.
563        c.add(1.0);
564        assert!(c.value() > 1.0 && c.value() < 1.5);
565
566        // Decay is clamped: a >1 factor cannot grow the value unbounded.
567        let mut clamped = DecayCounter::new(2.0);
568        clamped.add(1.0);
569        clamped.tick();
570        assert!(clamped.value() <= 1.0);
571    }
572
573    #[test]
574    fn advisor_maps_access_stats_to_layout() {
575        let advisor = LayoutAdvisor::default();
576        // Fat component → hot/cold split, whatever the access.
577        assert_eq!(
578            advisor.recommend(128, 10, 100),
579            LayoutRecommendation::HotColdSplit
580        );
581        // Lean component swept in large batches → field-SoA/SIMD candidate.
582        assert_eq!(
583            advisor.recommend(40, 60, 60 * 4096),
584            LayoutRecommendation::SimdFieldSoa
585        );
586        // Lean component, small/occasional access → leave it as SoA.
587        assert_eq!(advisor.recommend(40, 5, 20), LayoutRecommendation::KeepSoa);
588        // No access recorded → no recommendation to change.
589        assert_eq!(advisor.recommend(40, 0, 0), LayoutRecommendation::KeepSoa);
590    }
591}