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}