Skip to main content

scx_mlfq/
mlfq_tree.rs

1// SPDX-License-Identifier: GPL-2.0
2//
3// Copyright (c) 2026 Galih Tama <galpt@v.recipes>
4//
5// This software may be used and distributed according to the terms of the GNU
6// General Public License version 2.
7
8//! CART regression tree for next-CPU-burst prediction.
9//!
10//! The tree predicts the next burst in nsecs from per-task features and
11//! the prediction maps to a queue band (pred < T_INT -> Q1, pred <
12//! T_BOUND -> Q2, else Q3). It is trained offline in the daemon on
13//! samples emitted by the BPF side and published into a double-buffered
14//! two-entry array map in `src/bpf/intf.h`, which the classification
15//! path walks.
16//!
17//! The node format, the BFS level-order serialization and the prediction
18//! walk are shared with the BPF side: [`TreeNode`] is the byte-for-byte
19//! mirror of `struct mlfq_tree_node` (24 bytes under `#[repr(C)]`), and
20//! [`predict`] implements the same masked, depth-capped descent as
21//! `mlfq_tree_walk()` in `src/bpf/intf.h`. `serialize_validate()`
22//! checks a tree against the invariants the walk relies on; the daemon
23//! must not commit a tree that fails it.
24//!
25//! Growth is classic CART variance reduction: each split minimizes the
26//! sum of squared errors of the two child groups, thresholds are exact
27//! u64 nsec midpoints between distinct feature values, and growth stops
28//! at `max_depth`, `min_samples_leaf`, `max_nodes` or a minimum relative
29//! variance reduction.
30
31use std::collections::VecDeque;
32
33/// Node budget of the shared store entry, from `src/bpf/intf.h`. A power
34/// of two, so the walk's index mask is `MAX_NODES - 1`.
35const MLFQ_TREE_MAX_NODES: usize = crate::bpf_intf::mlfq_consts_MLFQ_TREE_MAX_NODES as usize;
36
37/// Walk depth bound of the shared store entry, from `src/bpf/intf.h`.
38const MLFQ_TREE_MAX_DEPTH: usize = crate::bpf_intf::mlfq_consts_MLFQ_TREE_MAX_DEPTH as usize;
39
40/// Number of populated features; ids 0..8 index the walk's `feat[9]` slots,
41/// sleep_var_ratio at id 9 is carry-along for the next ABI, gpu_submit at 8 quantised 0..4.
42const MLFQ_TREE_NR_FEATURES: usize = 9;
43
44/// Default minimum relative variance reduction for a split.
45///
46/// A split must remove at least 0.1% of the node's label variance to be
47/// taken. At the daemon's training-set size (2048 samples) a random split
48/// on a noise feature removes about 1/sqrt(n) ~= 2% of the variance at
49/// best by chance, so the gate is below that; it prunes the splits that
50/// only chase sample noise while keeping every split that materially
51/// separates the queue bands. The choice is a relative threshold, so it
52/// scales with the label magnitude and needs no unit-dependent tuning.
53pub const DEFAULT_MIN_REL_VAR_REDUCTION: f64 = 1e-3;
54
55/// Scratch buffers for the CART fit, sized to the maximum window and node
56/// budget. The buffers are allocated once with the capacities below and
57/// reused across fits by clearing in place, so the training path does not
58/// allocate after the first fit. The queue holds owned sample vectors per
59/// node; those vectors are still allocated per node, but the major buffers
60/// (weights, sorted, left/right) are reused. This keeps the 60s training
61/// free of steady-state allocations while preserving the exact CART logic.
62pub struct FitScratch {
63    pub weights: Vec<f64>,
64    pub sorted: Vec<WeightedSample>,
65    pub left: Vec<WeightedSample>,
66    pub right: Vec<WeightedSample>,
67    pub nodes: Vec<TreeNode>,
68    #[allow(private_interfaces)]
69    pub queue: VecDeque<NodeSpec>,
70    pub preds: Vec<u64>,
71    pub actuals: Vec<u64>,
72    pub ema_preds: Vec<u64>,
73    pub weights_full: Vec<f64>,
74}
75
76impl FitScratch {
77    /// Create a scratch arena with capacities for the maximum window.
78    pub fn new() -> Self {
79        Self {
80            weights: Vec::with_capacity(16384),
81            sorted: Vec::with_capacity(16384),
82            left: Vec::with_capacity(16384),
83            right: Vec::with_capacity(16384),
84            nodes: Vec::with_capacity(2048),
85            queue: VecDeque::with_capacity(2048),
86            preds: Vec::with_capacity(2048),
87            actuals: Vec::with_capacity(2048),
88            ema_preds: Vec::with_capacity(2048),
89            weights_full: Vec::with_capacity(16384),
90        }
91    }
92}
93
94impl Default for FitScratch {
95    fn default() -> Self {
96        Self::new()
97    }
98}
99
100/// Per-task feature vector, the mirror of `struct mlfq_tree_feats`.
101///
102/// Field order is part of the shared ABI with the BPF sample struct and
103/// the emitted `mlfq_tree_sample` layout. `prev_burst_ns`, `sleep_ns`,
104/// `ema`, `io_wait`, `wake_cnt`, then the measured service fields
105/// (`wake_lat_us`, `queue_wait_us`, `sq_ema`), the cadence feature
106/// (`sleep_var_ratio`) and the gpu feature (`gpu_submit` quant 0..4).
107#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
108#[repr(C)]
109pub struct TreeFeats {
110    /// Last completed run segment, in nsecs.
111    pub prev_burst_ns: u64,
112    /// Sleep before the current wakeup, in nsecs.
113    pub sleep_ns: u64,
114    /// EMA interactivity gauge.
115    pub ema: u64,
116    /// 1 if the wakeup is an I/O completion.
117    pub io_wait: u32,
118    /// Consecutive short-sleep wakeups.
119    pub wake_cnt: u32,
120    /// Last wakeup-to-run latency of the task, in microseconds.
121    pub wake_lat_us: u32,
122    /// Last enqueue-to-run wait of the task, in microseconds.
123    pub queue_wait_us: u32,
124    /// Per-task service-quality EMA (nsecs), saturating.
125    pub sq_ema: u64,
126    /// Sleep variation ratio, FP_SHIFT fixed point (8).
127    pub sleep_var_ratio: u32,
128    /// Pad to 64-byte alignment.
129    pub pad: u32,
130    /// GPU submissions quantised 0..4.
131    pub gpu_submit: u32,
132    /// Pad to 64 bytes, keeps 8-byte tail alignment.
133    pub pad2: u32,
134}
135
136/// One training sample, the mirror of `struct mlfq_tree_sample`.
137///
138/// `label_ns` is the run segment that followed the feature capture; the
139/// tree regresses it against `feats`. `version` carries
140/// `MLFQ_TREE_SAMPLE_VERSION` and is checked by the daemon's parse, so
141/// a record from an out-of-tree producer fails the check instead of
142/// being misread. The BPF struct is `packed, aligned(4)` to keep the
143/// record at 84 bytes, so this mirror is packed identically; every
144/// field sits at its naturally aligned offset, and the daemon reads the
145/// record with `read_unaligned`.
146#[derive(Clone, Copy, Debug)]
147#[repr(C, packed)]
148pub struct TreeSample {
149    /// Emitting task.
150    pub pid: u32,
151    /// Queue the task was placed in at capture.
152    pub queue: u32,
153    /// Feature vector at capture.
154    pub feats: TreeFeats,
155    /// Run segment that followed, in nsecs (the label).
156    pub label_ns: u64,
157    /// Sample-record layout version (`MLFQ_TREE_SAMPLE_VERSION`).
158    pub version: u32,
159}
160
161/// One tree node, the byte-for-byte mirror of `struct mlfq_tree_node`.
162///
163/// For an internal node (`right != 0`), `threshold` is the split point in
164/// nsecs, `left`/`right` the child indices and `feature` the split feature
165/// id (0..7). For a leaf (`right == 0`), `left` carries the prediction in
166/// nsecs. `pad` is the 7 reserved bytes of the 24-byte node; it is always
167/// zeroed so the published node bytes are deterministic.
168#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
169#[repr(C)]
170pub struct TreeNode {
171    /// Split point in nsecs (internal nodes); 0 on leaves.
172    pub threshold: u64,
173    /// Left child index, or the leaf prediction when `right == 0`.
174    pub left: u32,
175    /// Right child index; 0 marks a leaf.
176    pub right: u32,
177    /// Split feature id (0..7); 0 on leaves.
178    pub feature: u8,
179    /// Reserved padding, always zeroed.
180    pub pad: [u8; 7],
181}
182
183/// A fitted tree in the shared serialized form: BFS level order, index 0
184/// = root, parents before children. The daemon writes these nodes at the
185/// front of a map entry's node buffer (the entry and its tail are
186/// zeroed, which is the untrained shape); `serialize_validate()` must
187/// pass before the daemon commits the tree.
188#[derive(Clone, Debug, Default)]
189pub struct SerializedTree {
190    /// Live nodes in BFS level order; index 0 is the root.
191    pub nodes: Vec<TreeNode>,
192}
193
194/// Feature value for a feature id, matching the BPF walk's `feat[9]` slot
195/// layout in `src/bpf/intf.h` (`mlfq_tree_walk`). Ids 0..8 are the split
196/// features (prev_burst, sleep, ema, io_wait, wake_cnt, wake_lat,
197/// queue_wait, sq_ema, gpu_submit), id 9 the cadence ratio (carry-along,
198/// split when NR_FEATURES promotes it), and the rest zero.
199fn feat_value(f: TreeFeats, id: u8) -> u64 {
200    match id {
201        0 => f.prev_burst_ns,
202        1 => f.sleep_ns,
203        2 => f.ema,
204        3 => f.io_wait as u64,
205        4 => f.wake_cnt as u64,
206        5 => f.wake_lat_us as u64,
207        6 => f.queue_wait_us as u64,
208        7 => f.sq_ema,
209        8 => f.gpu_submit as u64,
210        9 => f.sleep_var_ratio as u64,
211        _ => 0,
212    }
213}
214
215/// Overflow-safe midpoint between two distinct u64 values.
216///
217/// `v + (w - v) / 2` never wraps: `w - v` is exact for `w > v`, and the
218/// halved difference is at most `w - v`, so the sum is at most `w`.
219/// Consecutive values collapse to the lower one, which still separates
220/// them (the lower value routes left, the higher routes right).
221fn midpoint(v: u64, w: u64) -> u64 {
222    debug_assert!(w > v);
223    v + (w - v) / 2
224}
225
226/// A training sample paired with its recency weight, the unit the fit
227/// carries through the node partitions.
228type WeightedSample = (TreeSample, f64);
229
230/// One node in the BFS queue during fit. The samples are the weighted
231/// samples that reached this node. This is an internal detail of the
232/// fit and is not part of the published tree.
233pub(crate) struct NodeSpec {
234    idx: usize,
235    samples: Vec<WeightedSample>,
236    depth: usize,
237}
238
239/// Recency weight of each training sample, by its age in the window.
240///
241/// age_i = n - i (i = 0 is the oldest sample, n the window length) and
242/// the half-life is half the window: w_i = 2^(-age_i / (n / 2)). The
243/// newest sample weighs ~1 and the oldest exactly 2^-2 = 0.25, so the
244/// fit concentrates on the recent regime without dropping the older
245/// data entirely and no weight can underflow. Each weight is one
246/// `powf`, so there is no error accumulation across samples.
247#[allow(dead_code)]
248pub fn sample_weights(n: usize) -> Vec<f64> {
249    let half_life = n as f64 / 2.0;
250    (0..n)
251        .map(|i| 2.0f64.powf(-((n - i) as f64) / half_life))
252        .collect()
253}
254
255/// Fill the provided buffer with recency weights without allocating.
256/// The buffer is cleared and filled to length n; capacity is retained
257/// so the second call with the same n does not allocate.
258pub fn sample_weights_into(n: usize, out: &mut Vec<f64>) {
259    out.clear();
260    if out.capacity() < n {
261        out.reserve(n - out.len());
262    }
263    let half_life = n as f64 / 2.0;
264    for i in 0..n {
265        out.push(2.0f64.powf(-((n - i) as f64) / half_life));
266    }
267}
268
269/// Sum of weights, weighted sum and weighted sum-of-squares of a node's
270/// labels, in f64 for the variance math. Each sample carries its
271/// recency weight alongside it.
272fn label_totals(samples: &[WeightedSample]) -> (f64, f64, f64) {
273    samples
274        .iter()
275        .fold((0.0, 0.0, 0.0), |(sw, swy, swy2), (s, w)| {
276            let y = s.label_ns as f64;
277            (sw + w, swy + w * y, swy2 + w * y * y)
278        })
279}
280
281/// Partition a node's samples by a split, mirroring the walk's `<=`
282/// routing. `feat_value <= threshold` goes left. The recency weights
283/// ride along with their samples.
284#[allow(dead_code)]
285fn partition(
286    samples: &[WeightedSample],
287    feature: u8,
288    threshold: u64,
289) -> (Vec<WeightedSample>, Vec<WeightedSample>) {
290    let mut left = Vec::with_capacity(samples.len());
291    let mut right = Vec::with_capacity(samples.len());
292    for s in samples {
293        if feat_value(s.0.feats, feature) <= threshold {
294            left.push(*s);
295        } else {
296            right.push(*s);
297        }
298    }
299    (left, right)
300}
301
302/// Clamp a leaf mean to the u32 prediction field of the shared node.
303///
304/// A mean beyond 2^32 - 1 nsecs (about 4.3 s) is beyond the scheduler's
305/// timescale (the Q3 slice is 4 ms), so it saturates; the walk returns
306/// the field as-is.
307fn leaf_prediction(mean_ns: u64) -> u32 {
308    mean_ns.min(u32::MAX as u64) as u32
309}
310
311/// Search the best binary split for a node's samples.
312///
313/// For each feature, the samples are sorted by the feature value and the
314/// midpoints between distinct values are swept in order. The left group
315/// accumulates the weighted label sum and sum-of-squares so the weighted
316/// SSE of both groups is O(1) per candidate. Both groups must meet
317/// `min_samples_leaf` (a sample count, unchanged by the recency
318/// weighting) and the split must remove at least
319/// `min_rel_var_reduction * sse`. The first maximum-reduction split
320/// wins, so ties resolve to the smallest threshold that reaches the
321/// reduction.
322///
323/// Returns `(feature, threshold)`.
324#[allow(dead_code)]
325fn best_split(
326    samples: &[WeightedSample],
327    min_samples_leaf: usize,
328    sse: f64,
329    min_rel_var_reduction: f64,
330) -> Option<(u8, u64)> {
331    let n = samples.len();
332    let (total_w, total_wy, total_wy2) = label_totals(samples);
333    let min_reduction = sse * min_rel_var_reduction;
334    let mut best: Option<(u8, u64, f64)> = None;
335
336    for feature in 0..MLFQ_TREE_NR_FEATURES {
337        let mut sorted: Vec<WeightedSample> = samples.to_vec();
338        sorted.sort_by_key(|(s, _)| feat_value(s.feats, feature as u8));
339
340        let mut sw_l = 0.0f64;
341        let mut swy_l = 0.0f64;
342        let mut swy2_l = 0.0f64;
343        let mut i = 0usize;
344        while i < n {
345            let v = feat_value(sorted[i].0.feats, feature as u8);
346            let mut j = i;
347            while j < n && feat_value(sorted[j].0.feats, feature as u8) == v {
348                let (s, w) = sorted[j];
349                let y = s.label_ns as f64;
350                sw_l += w;
351                swy_l += w * y;
352                swy2_l += w * y * y;
353                j += 1;
354            }
355
356            /* Left group = all values <= v. A split needs a higher value. */
357            let n_l = j;
358            let n_r = n - j;
359            if n_l >= min_samples_leaf && n_r >= min_samples_leaf && j < n {
360                let v_next = feat_value(sorted[j].0.feats, feature as u8);
361                let threshold = midpoint(v, v_next);
362                let sse_l = swy2_l - swy_l * swy_l / sw_l;
363                let sw_r = total_w - sw_l;
364                let swy_r = total_wy - swy_l;
365                let swy2_r = total_wy2 - swy2_l;
366                let sse_r = swy2_r - swy_r * swy_r / sw_r;
367                let reduction = sse - sse_l - sse_r;
368
369                if reduction > min_reduction {
370                    let replace = match best {
371                        Some((_, _, r)) => reduction > r,
372                        None => true,
373                    };
374                    if replace {
375                        best = Some((feature as u8, threshold, reduction));
376                    }
377                }
378            }
379            i = j;
380        }
381    }
382
383    best.map(|(f, t, _)| (f, t))
384}
385
386/// Variant of best_split that reuses a caller-provided buffer for sorting.
387/// The buffer is cleared and filled from samples for each feature, so the
388/// per-feature allocation is avoided after the first call.
389fn best_split_with_scratch(
390    samples: &[WeightedSample],
391    min_samples_leaf: usize,
392    sse: f64,
393    min_rel_var_reduction: f64,
394    scratch: &mut Vec<WeightedSample>,
395) -> Option<(u8, u64)> {
396    let n = samples.len();
397    let (total_w, total_wy, total_wy2) = label_totals(samples);
398    let min_reduction = sse * min_rel_var_reduction;
399    let mut best: Option<(u8, u64, f64)> = None;
400
401    for feature in 0..MLFQ_TREE_NR_FEATURES {
402        scratch.clear();
403        scratch.extend_from_slice(samples);
404        scratch.sort_by_key(|(s, _)| feat_value(s.feats, feature as u8));
405        let sorted = &*scratch;
406
407        let mut sw_l = 0.0f64;
408        let mut swy_l = 0.0f64;
409        let mut swy2_l = 0.0f64;
410        let mut i = 0usize;
411        while i < n {
412            let v = feat_value(sorted[i].0.feats, feature as u8);
413            let mut j = i;
414            while j < n && feat_value(sorted[j].0.feats, feature as u8) == v {
415                let (s, w) = sorted[j];
416                let y = s.label_ns as f64;
417                sw_l += w;
418                swy_l += w * y;
419                swy2_l += w * y * y;
420                j += 1;
421            }
422
423            /* Left group = all values <= v. A split needs a higher value. */
424            let n_l = j;
425            let n_r = n - j;
426            if n_l >= min_samples_leaf && n_r >= min_samples_leaf && j < n {
427                let v_next = feat_value(sorted[j].0.feats, feature as u8);
428                let threshold = midpoint(v, v_next);
429                let sse_l = swy2_l - swy_l * swy_l / sw_l;
430                let sw_r = total_w - sw_l;
431                let swy_r = total_wy - swy_l;
432                let swy2_r = total_wy2 - swy2_l;
433                let sse_r = swy2_r - swy_r * swy_r / sw_r;
434                let reduction = sse - sse_l - sse_r;
435
436                if reduction > min_reduction {
437                    let replace = match best {
438                        Some((_, _, r)) => reduction > r,
439                        None => true,
440                    };
441                    if replace {
442                        best = Some((feature as u8, threshold, reduction));
443                    }
444                }
445            }
446            i = j;
447        }
448    }
449
450    best.map(|(f, t, _)| (f, t))
451}
452
453/// Grow a CART regression tree over `samples`.
454///
455/// The growth is breadth-first so the serialized node order is the
456/// level-order layout the store requires (parents before children, index
457/// 0 = root). Each node is created as a placeholder, queued, and filled
458/// when processed. A node that clears the growth limits becomes an
459/// internal node (two new children) and everything else becomes a leaf
460/// predicting the weighted mean of its labels.
461///
462/// Growth stops at `max_depth` edges, when either child would fall below
463/// `min_samples_leaf`, when the node budget `max_nodes` would be exceeded
464/// (every node in the tree, internal or leaf, counts), or when no split
465/// removes `min_rel_var_reduction` of the node's label variance.
466///
467/// Every training sample is weighted by its recency in the window
468/// (`sample_weights`: 2^(-age/(n/2))), so the fit concentrates on the
469/// recent regime while the full window still provides the data quantity
470/// and the gates bound every publish. The weighting enters the fit as a
471/// weighted SSE. The leaf means and the variance-reduction ranking are
472/// weighted, and the `min_samples_leaf` cap still counts samples, not
473/// weight.
474///
475/// All arithmetic is f64 on the weighted label sums. The labels are
476/// emitted by the BPF side clamped to `MLFQ_TREE_LABEL_MAX_NS` (see
477/// `src/bpf/intf.h`), so the exact-integer range the SSE math sums is
478/// bounded: a label value of at most 192 ms squares to ~3.7e16, far
479/// below the f64 rounding error. The weights live in [0.25, 1], so the
480/// weighted sums stay within the same magnitude as the uniform fit and
481/// the split ranking is exact except for near-tied candidates containing
482/// extreme labels, where consecutive u64 values collapse in the f64
483/// conversion and the tie-break is approximate; the daemon never sees
484/// such labels because of the emission clamp.
485///
486/// An empty `samples` slice or `max_nodes == 0` yields an empty tree,
487/// which `serialize_validate()` rejects; the daemon treats an empty tree
488/// as untrained.
489#[allow(dead_code)]
490pub fn fit(
491    samples: &[TreeSample],
492    max_depth: usize,
493    min_samples_leaf: usize,
494    max_nodes: usize,
495    min_rel_var_reduction: f64,
496) -> SerializedTree {
497    let mut scratch = FitScratch::new();
498    fit_with_scratch(
499        samples,
500        max_depth,
501        min_samples_leaf,
502        max_nodes,
503        min_rel_var_reduction,
504        &mut scratch,
505    )
506}
507
508/// Fit a tree reusing the caller-provided scratch arena. After the first
509/// call the arena retains its capacity, so subsequent fits do not allocate.
510/// The logic is identical to `fit()`, only the temporary buffers are reused.
511pub fn fit_with_scratch(
512    samples: &[TreeSample],
513    max_depth: usize,
514    min_samples_leaf: usize,
515    max_nodes: usize,
516    min_rel_var_reduction: f64,
517    scratch: &mut FitScratch,
518) -> SerializedTree {
519    if samples.is_empty() || max_nodes == 0 {
520        return SerializedTree::default();
521    }
522
523    sample_weights_into(samples.len(), &mut scratch.weights);
524    let weights = &scratch.weights;
525    scratch.nodes.clear();
526    scratch.nodes.reserve(max_nodes);
527    scratch.queue.clear();
528    // Node storage is in the scratch arena. Clear but keep capacity.
529    let nodes = &mut scratch.nodes;
530    let queue = &mut scratch.queue;
531    nodes.push(TreeNode::default()); /* root placeholder */
532    // Build the weighted samples for the root. Reuse the left buffer as
533    // temporary weighted storage, then move it into the root.
534    scratch.left.clear();
535    for (s, w) in samples.iter().copied().zip(weights.iter().copied()) {
536        scratch.left.push((s, w));
537    }
538    let mut root_samples = Vec::new();
539    std::mem::swap(&mut root_samples, &mut scratch.left);
540    queue.push_back(NodeSpec {
541        idx: 0,
542        samples: root_samples,
543        depth: 0,
544    });
545
546    while let Some(spec) = queue.pop_front() {
547        let n = spec.samples.len();
548        let (sw, swy, swy2) = label_totals(&spec.samples);
549        let sse = swy2 - swy * swy / sw;
550
551        let splittable = spec.depth < max_depth
552            && n >= 2 * min_samples_leaf
553            && nodes.len() + 2 <= max_nodes
554            /*
555             * The SSE of a node with (near-)constant labels is dominated
556             * by floating-point cancellation noise, which is bounded by
557             * ~1e-13 of the squared-label magnitude. A node whose SSE
558             * sits below 1e-12 of its weighted sum-of-squares is treated
559             * as a leaf, so the splitter never chases rounding noise
560             * (a random split on a noise feature can only "reduce" that
561             * noise, and min_rel_var_reduction is far above this floor).
562             */
563            && sse > 1e-12 * swy2;
564        let best = if splittable {
565            // Reuse the sorted buffer from the scratch arena.
566            best_split_with_scratch(
567                &spec.samples,
568                min_samples_leaf,
569                sse,
570                min_rel_var_reduction,
571                &mut scratch.sorted,
572            )
573        } else {
574            None
575        };
576
577        match best {
578            Some((feature, threshold)) => {
579                let left_idx = nodes.len() as u32;
580                let right_idx = left_idx + 1;
581                nodes[spec.idx] = TreeNode {
582                    threshold,
583                    left: left_idx,
584                    right: right_idx,
585                    feature,
586                    pad: [0; 7],
587                };
588                /* Child placeholders, filled when dequeued. */
589                nodes.push(TreeNode::default());
590                nodes.push(TreeNode::default());
591                // Reuse the left/right buffers from the scratch arena.
592                // partition_into clears and fills them, then we move the
593                // filled vectors into the queue. The scratch buffers are
594                // left empty but retain capacity for the next split.
595                scratch.left.clear();
596                scratch.right.clear();
597                for s in &spec.samples {
598                    if feat_value(s.0.feats, feature) <= threshold {
599                        scratch.left.push(*s);
600                    } else {
601                        scratch.right.push(*s);
602                    }
603                }
604                let mut left = Vec::new();
605                let mut right = Vec::new();
606                std::mem::swap(&mut left, &mut scratch.left);
607                std::mem::swap(&mut right, &mut scratch.right);
608                queue.push_back(NodeSpec {
609                    idx: left_idx as usize,
610                    samples: left,
611                    depth: spec.depth + 1,
612                });
613                queue.push_back(NodeSpec {
614                    idx: right_idx as usize,
615                    samples: right,
616                    depth: spec.depth + 1,
617                });
618            }
619            None => {
620                nodes[spec.idx] = TreeNode {
621                    threshold: 0,
622                    left: leaf_prediction((swy / sw) as u64),
623                    right: 0,
624                    feature: 0,
625                    pad: [0; 7],
626                };
627            }
628        }
629    }
630
631    let mut out_nodes = Vec::new();
632    std::mem::swap(&mut out_nodes, nodes);
633    SerializedTree { nodes: out_nodes }
634}
635
636/// Validate a tree against the walk's invariants before publishing.
637///
638/// Every node count must sit in `[1, MLFQ_TREE_MAX_NODES]`. Every node
639/// must be reachable from the root. Every leaf (`right == 0`) is
640/// unconstrained beyond that. Every internal node must split on a feature
641/// id below the nine populated slots, both children must be in-bounds,
642/// and both must follow the parent in the BFS order (parents before
643/// children), which the store layout relies on.
644///
645/// The walk descends at most `MLFQ_TREE_MAX_DEPTH` edges and then, depth
646/// exhausted, returns the last reachable node's `left` only when that
647/// node is a leaf. A node deeper than `MLFQ_TREE_MAX_DEPTH` is therefore
648/// unreachable, and an internal node at depth `MLFQ_TREE_MAX_DEPTH`
649/// would only ever be read through the exhaustion fallback (predicting
650/// 0). Both shapes are rejected, so a published tree's every reachable
651/// prediction is a real leaf.
652pub fn serialize_validate(tree: &SerializedTree) -> Result<(), String> {
653    let nr = tree.nodes.len();
654    if nr < 1 {
655        return Err("empty tree: the root node is required".into());
656    }
657    if nr > MLFQ_TREE_MAX_NODES {
658        return Err(format!(
659            "{nr} nodes exceed the store bound {MLFQ_TREE_MAX_NODES}"
660        ));
661    }
662
663    /* BFS from the root. Depth per node, rejecting the walk's cut shapes. */
664    let mut depth = vec![usize::MAX; nr];
665    depth[0] = 0;
666    let mut queue = VecDeque::from([0usize]);
667    while let Some(i) = queue.pop_front() {
668        let node = &tree.nodes[i];
669        if node.right == 0 {
670            continue; /* leaf */
671        }
672        if node.feature >= MLFQ_TREE_NR_FEATURES as u8 {
673            return Err(format!(
674                "node {i} splits on feature {} beyond the {} populated slots",
675                node.feature, MLFQ_TREE_NR_FEATURES
676            ));
677        }
678        if depth[i] >= MLFQ_TREE_MAX_DEPTH {
679            return Err(format!(
680                "node {i} at depth {} is an internal node at or beyond the walk bound {MLFQ_TREE_MAX_DEPTH}",
681                depth[i]
682            ));
683        }
684        for child in [node.left, node.right] {
685            let c = child as usize;
686            if c >= nr {
687                return Err(format!(
688                    "node {i} child index {c} is out of range (nr_nodes {nr})"
689                ));
690            }
691            if c <= i {
692                return Err(format!(
693                    "node {i} child index {c} precedes its parent, the BFS order is violated"
694                ));
695            }
696            /*
697             * Degenerate chains (left == right) are walked like any
698             * other edge. The index ordering above makes cycles
699             * impossible, so re-visiting a node only re-computes the
700             * same depth.
701             */
702            if depth[c] == usize::MAX {
703                depth[c] = depth[i] + 1;
704                queue.push_back(c);
705            }
706        }
707    }
708    for (i, d) in depth.iter().enumerate() {
709        if *d > MLFQ_TREE_MAX_DEPTH {
710            return Err(format!(
711                "node {i} sits at depth {d}, beyond the walk bound {MLFQ_TREE_MAX_DEPTH}"
712            ));
713        }
714        if *d == usize::MAX {
715            return Err(format!(
716                "node {i} is not reachable from the root, the layout is not a tree"
717            ));
718        }
719    }
720    Ok(())
721}
722
723/// Walk a serialized tree and predict the next burst, the Rust mirror of
724/// `mlfq_tree_walk()` in `src/bpf/intf.h` (the BPF wrapper adds only the
725/// meta gate and the map lookup around this walk).
726///
727/// The walk descends at most `MLFQ_TREE_MAX_DEPTH` internal nodes, masking
728/// every index with `MLFQ_TREE_MAX_NODES - 1`, splitting on
729/// `feature & 0xF`, and returning `left` for a leaf (`right == 0`). A
730/// masked index past the live nodes of an unpadded tree reads like a
731/// zeroed store node (a leaf predicting 0). Depth exhausted, the last
732/// reachable node's `left` is returned only when that node is a leaf; an
733/// internal node there (a tree deeper than the bound) yields 0, matching
734/// the BPF side.
735///
736/// An empty tree predicts 0, mirroring the untrained store.
737pub fn predict(tree: &SerializedTree, feats: &TreeFeats) -> u64 {
738    if tree.nodes.is_empty() {
739        return 0; /* untrained */
740    }
741
742    let feat: [u64; 16] = [
743        feats.prev_burst_ns,
744        feats.sleep_ns,
745        feats.ema,
746        feats.io_wait as u64,
747        feats.wake_cnt as u64,
748        feats.wake_lat_us as u64,
749        feats.queue_wait_us as u64,
750        feats.sq_ema,
751        feats.gpu_submit as u64,
752        feats.sleep_var_ratio as u64,
753        0,
754        0,
755        0,
756        0,
757        0,
758        0,
759    ];
760    let mask = MLFQ_TREE_MAX_NODES - 1;
761    let mut idx = 0usize;
762
763    for _ in 0..MLFQ_TREE_MAX_DEPTH {
764        if idx >= tree.nodes.len() {
765            return 0; /* zeroed store node: a leaf predicting 0 */
766        }
767        let node = &tree.nodes[idx];
768        if node.right == 0 {
769            return node.left as u64;
770        }
771        let feature = (node.feature & 0xF) as usize;
772        let next = if feat[feature] <= node.threshold {
773            node.left as usize
774        } else {
775            node.right as usize
776        };
777        idx = next & mask;
778    }
779
780    /* Depth exhausted. The last reachable node is the prediction only
781     * when it is a leaf. An internal node here would leak a child index
782     * as a prediction, so it yields 0. */
783    if idx >= tree.nodes.len() {
784        return 0;
785    }
786    let node = &tree.nodes[idx];
787    if node.right == 0 { node.left as u64 } else { 0 }
788}
789
790/// The publish quality gate. The tree replaces the previous model only when
791/// its holdout MAE beats the exact per-sample EMA baseline on the same
792/// holdout slice and the Pearson correlation clears the quality floor
793/// and strictly improves on the currently committed model.
794/// The holdout MAE is weighted by the recency weights of the window, so
795/// recent samples dominate the gate. Correlation is monotonic: once a
796/// model at 0.30 is committed, a later fit at 0.30 or below is held
797/// out; 0.52 then 0.72 ratchet toward 1.0 on general workloads.
798/// A higher correlation must also beat the baseline, so a high but
799/// narrow fit on one game cannot regress the tail.
800///
801/// This is the daemon's contract with the README's claim that the tree
802/// is published when it beats the baseline: a regressed model is kept
803/// out and the previous model stays committed.
804pub fn should_publish(mae_tree: f64, mae_ema: f64, corr: f64, published_corr: Option<f64>) -> bool {
805    if mae_tree > mae_ema {
806        return false;
807    }
808    if corr < 0.30 {
809        return false;
810    }
811    if let Some(pc) = published_corr
812        && corr <= pc + 1e-9
813    {
814        return false;
815    }
816    true
817}
818
819/// Weighted MAE for the holdout slice. Each holdout sample carries its
820/// recency weight w_i = 2^(-age_i / (n/2)) where n is the full window
821/// length, so the gate emphasizes the recent regime without dropping
822/// the older tail entirely.
823pub fn weighted_holdout_mae(preds: &[u64], actuals: &[u64], weights: &[f64]) -> f64 {
824    assert_eq!(preds.len(), actuals.len());
825    assert_eq!(preds.len(), weights.len());
826    if preds.is_empty() {
827        return 0.0;
828    }
829    let mut sw = 0.0;
830    let mut sw_err = 0.0;
831    for ((p, a), w) in preds.iter().zip(actuals).zip(weights.iter()) {
832        let err = if p >= a { *p - *a } else { *a - *p } as f64;
833        sw += *w;
834        sw_err += *w * err;
835    }
836    if sw == 0.0 { 0.0 } else { sw_err / sw }
837}
838
839/// Record-layout version tag of the emitted training samples, from
840/// `enum mlfq_consts` in `src/bpf/intf.h`.
841pub const MLFQ_TREE_SAMPLE_VERSION: u32 = crate::bpf_intf::mlfq_consts_MLFQ_TREE_SAMPLE_VERSION;
842
843/// True when a parsed sample carries the current record-layout version.
844///
845/// The daemon and the BPF object compile from the same `intf.h`, so a
846/// mismatch is either an out-of-tree producer or a stale build; the
847/// check turns that into a loud drop at the parse instead of a silent
848/// misread of the feature fields.
849pub fn sample_version_matches(s: &TreeSample) -> bool {
850    s.version == MLFQ_TREE_SAMPLE_VERSION
851}
852
853/// Bit position of the active-buffer flag in the committed-tree meta
854/// (bit 1, the value of `MLFQ_TREE_META_ACTIVE`).
855const MLFQ_TREE_META_ACTIVE_SHIFT: u32 = 1;
856
857/// Serialize the committed-tree meta value from its parts.
858///
859/// The bit layout mirrors `struct mlfq_tree_ctrl` in `src/bpf/intf.h`:
860/// bit 0 the trained bit (always set by a publish), bit 1 the active
861/// buffer index, bits 8..31 the node count and bits 32..63 the
862/// generation. The active bit is the caller's decision (the inactive
863/// buffer index); the trained bit is constant because this function is
864/// only used for a publish.
865pub fn tree_meta(generation: u64, nr_nodes: usize, active: u64) -> u64 {
866    /*
867     * Mask the generation to its 32 meta bits before the shift. The
868     * field is 32 bits wide and the wrap is unreachable (2^32 publishes
869     * at the 60 s cadence is ~8200 years), so the mask is defensive
870     * hygiene. It pins the shift input to the field width, so the
871     * committed meta can never carry bits above the generation field
872     * even if a caller passed an out-of-range value.
873     */
874    ((generation & 0xFFFF_FFFF) << crate::bpf_intf::MLFQ_TREE_META_GENERATION_SHIFT)
875        | ((nr_nodes as u64) << crate::bpf_intf::MLFQ_TREE_META_NR_NODES_SHIFT)
876        | ((active & 1) << MLFQ_TREE_META_ACTIVE_SHIFT)
877        | crate::bpf_intf::MLFQ_TREE_META_TRAINED as u64
878}
879
880/// Mean absolute error between predictions and actuals.
881///
882/// Lengths must match. The empty case returns 0. The sums accumulate in
883/// u128 so near-u64 values cannot overflow.
884#[allow(dead_code)]
885pub fn mae(preds: &[u64], actuals: &[u64]) -> f64 {
886    assert_eq!(
887        preds.len(),
888        actuals.len(),
889        "prediction and actual vectors differ in length"
890    );
891    if preds.is_empty() {
892        return 0.0;
893    }
894    let total: u128 = preds
895        .iter()
896        .zip(actuals)
897        .map(|(p, a)| (if p >= a { *p - *a } else { *a - *p }) as u128)
898        .sum();
899    total as f64 / preds.len() as f64
900}
901
902/// Pearson product-moment correlation of two equally long vectors.
903///
904/// Returns 0 for the empty case and for a constant vector (zero variance),
905/// where the coefficient is undefined; keeping the metric finite lets the
906/// daemon compare the tree against the EMA predictor without special
907/// cases.
908pub fn pearson(x: &[u64], y: &[u64]) -> f64 {
909    assert_eq!(x.len(), y.len(), "correlation vectors differ in length");
910    let n = x.len();
911    if n == 0 {
912        return 0.0;
913    }
914
915    let mean_x = x.iter().map(|v| *v as f64).sum::<f64>() / n as f64;
916    let mean_y = y.iter().map(|v| *v as f64).sum::<f64>() / n as f64;
917
918    let (mut cov, mut var_x, mut var_y) = (0.0, 0.0, 0.0);
919    for (xi, yi) in x.iter().zip(y) {
920        let dx = *xi as f64 - mean_x;
921        let dy = *yi as f64 - mean_y;
922        cov += dx * dy;
923        var_x += dx * dx;
924        var_y += dy * dy;
925    }
926    if var_x == 0.0 || var_y == 0.0 {
927        return 0.0;
928    }
929    cov / (var_x * var_y).sqrt()
930}
931
932#[cfg(test)]
933mod tests {
934    use super::*;
935
936    /// Deterministic xorshift64* PRNG so the datasets are reproducible.
937    struct Rng(u64);
938
939    impl Rng {
940        fn next_u64(&mut self) -> u64 {
941            let mut x = self.0;
942            x ^= x << 13;
943            x ^= x >> 7;
944            x ^= x << 17;
945            self.0 = x;
946            x.wrapping_mul(0x2545_F491_4F6C_DD1D)
947        }
948
949        fn next_in(&mut self, lo: u64, hi: u64) -> u64 {
950            lo + self.next_u64() % (hi - lo)
951        }
952    }
953
954    fn sample(pid: u32, sleep_ns: u64, label_ns: u64) -> TreeSample {
955        TreeSample {
956            pid,
957            version: MLFQ_TREE_SAMPLE_VERSION,
958            queue: 1,
959            feats: TreeFeats {
960                sleep_ns,
961                ..TreeFeats::default()
962            },
963            label_ns,
964        }
965    }
966
967    /// The mirror of `predict()` that returns the terminal node index, so
968    /// tests can reconstruct which training samples share a leaf.
969    fn leaf_index(tree: &SerializedTree, feats: &TreeFeats) -> usize {
970        let feat: [u64; 16] = [
971            feats.prev_burst_ns,
972            feats.sleep_ns,
973            feats.ema,
974            feats.io_wait as u64,
975            feats.wake_cnt as u64,
976            feats.wake_lat_us as u64,
977            feats.queue_wait_us as u64,
978            feats.sq_ema,
979            feats.gpu_submit as u64,
980            feats.sleep_var_ratio as u64,
981            0,
982            0,
983            0,
984            0,
985            0,
986            0,
987        ];
988        let mask = MLFQ_TREE_MAX_NODES - 1;
989        let mut idx = 0usize;
990        for _ in 0..MLFQ_TREE_MAX_DEPTH {
991            if idx >= tree.nodes.len() {
992                return idx;
993            }
994            let node = &tree.nodes[idx];
995            if node.right == 0 {
996                return idx;
997            }
998            let feature = (node.feature & 0xF) as usize;
999            let next = if feat[feature] <= node.threshold {
1000                node.left as usize
1001            } else {
1002                node.right as usize
1003            };
1004            idx = next & mask;
1005        }
1006        idx
1007    }
1008
1009    #[test]
1010    fn separable_dataset_recovers_split() {
1011        // Two classes separated by sleep: ~500us sleeps burst ~= 100us,
1012        // ~2ms sleeps burst ~= 10ms, both with deterministic jitter. Only
1013        // sleep_ns carries signal, so the root must split on it and the
1014        // threshold must sit between the two class regions.
1015        let mut rng = Rng(0xC0FFEE);
1016        let mut samples = Vec::new();
1017        for pid in 0..512u32 {
1018            let interactive = pid % 2 == 0;
1019            let sleep = if interactive { 500_000 } else { 2_000_000 };
1020            let sleep = sleep + rng.next_in(0, 100_000);
1021            let label = if interactive { 100_000 } else { 10_000_000 };
1022            let label = label + rng.next_in(0, 10_000);
1023            samples.push(sample(pid, sleep, label));
1024        }
1025
1026        let tree = fit(&samples, 12, 2, 63, DEFAULT_MIN_REL_VAR_REDUCTION);
1027        serialize_validate(&tree).unwrap();
1028
1029        let root = &tree.nodes[0];
1030        assert!(root.right != 0, "the root must be an internal node");
1031        assert_eq!(root.feature, 1, "the root must split on sleep_ns");
1032        assert!(
1033            root.threshold > 1_000_000 && root.threshold < 1_500_000,
1034            "root threshold {} must sit between the class regions",
1035            root.threshold
1036        );
1037
1038        // The leaf predictions must separate the classes.
1039        for s in &samples {
1040            let feats = s.feats;
1041            let pred = predict(&tree, &feats);
1042            if s.feats.sleep_ns < 1_000_000 {
1043                assert!(
1044                    (90_000..=110_000).contains(&pred),
1045                    "interactive sample predicted {pred}"
1046                );
1047            } else {
1048                assert!(
1049                    (9_900_000..=10_100_000).contains(&pred),
1050                    "CPU-bound sample predicted {pred}"
1051                );
1052            }
1053        }
1054    }
1055
1056    #[test]
1057    fn irrelevant_feature_never_split_on() {
1058        // wake_cnt (feature 4) is uniform noise over a wide range; the
1059        // label depends only on sleep_ns, and exactly (no label noise), so
1060        // no split on wake_cnt can reduce the variance at all after the
1061        // sleep split. The tree must never split on feature 4.
1062        let mut rng = Rng(0xBEEF);
1063        let mut samples = Vec::new();
1064        for pid in 0..1024u32 {
1065            let interactive = pid % 2 == 0;
1066            let sleep = if interactive { 500_000 } else { 2_000_000 };
1067            let label = if interactive { 100_000 } else { 10_000_000 };
1068            let wake_cnt = rng.next_in(0, 1_000_000);
1069            let mut s = sample(pid, sleep, label);
1070            s.feats.wake_cnt = wake_cnt as u32;
1071            samples.push(s);
1072        }
1073
1074        let tree = fit(&samples, 4, 2, 127, DEFAULT_MIN_REL_VAR_REDUCTION);
1075        serialize_validate(&tree).unwrap();
1076
1077        for (i, node) in tree.nodes.iter().enumerate() {
1078            if node.right != 0 {
1079                assert_ne!(
1080                    node.feature, 4,
1081                    "node {i} must not split on the irrelevant wake_cnt"
1082                );
1083            }
1084        }
1085    }
1086
1087    #[test]
1088    fn depth_and_min_leaf_caps_respected() {
1089        // A staircase label (label == sleep) forces repeated splitting:
1090        // with min_rel_var_reduction 0 the tree grows greedily until the
1091        // depth or leaf caps stop it. All leaves must hold at least
1092        // min_samples_leaf training samples and no internal node may sit
1093        // below the depth cap.
1094        let mut samples = Vec::new();
1095        for (i, sleep) in (100_000u64..1_700_000).step_by(100_000).enumerate() {
1096            for k in 0..4 {
1097                let mut s = sample(i as u32 + k, sleep, sleep);
1098                s.feats.prev_burst_ns = 50_000 + 10_000 * (i % 5) as u64;
1099                samples.push(s);
1100            }
1101        }
1102
1103        let max_depth = 3;
1104        let min_samples_leaf = 2;
1105        let tree = fit(&samples, max_depth, min_samples_leaf, 1000, 0.0);
1106        serialize_validate(&tree).unwrap();
1107
1108        // Depth of every node in the BFS layout, computed from the parents.
1109        let mut depth = vec![0usize; tree.nodes.len()];
1110        for (i, node) in tree.nodes.iter().enumerate() {
1111            if node.right != 0 {
1112                assert!(
1113                    depth[i] < max_depth,
1114                    "node {i} at depth {} exceeds the cap {max_depth}",
1115                    depth[i]
1116                );
1117                depth[node.left as usize] = depth[i] + 1;
1118                depth[node.right as usize] = depth[i] + 1;
1119            }
1120        }
1121
1122        // Every leaf receives at least min_samples_leaf training samples.
1123        let mut leaf_counts = std::collections::HashMap::new();
1124        for s in &samples {
1125            let feats = s.feats;
1126            *leaf_counts
1127                .entry(leaf_index(&tree, &feats))
1128                .or_insert(0usize) += 1;
1129        }
1130        assert!(!leaf_counts.is_empty(), "the tree must have leaves");
1131        for (leaf, count) in &leaf_counts {
1132            assert!(
1133                *count >= min_samples_leaf,
1134                "leaf {leaf} holds {count} samples, below the cap {min_samples_leaf}"
1135            );
1136        }
1137    }
1138
1139    #[test]
1140    fn serialize_walk_roundtrip() {
1141        // The walk over the serialized tree must return, for every
1142        // training sample, the recency-weighted mean of the labels
1143        // routed to the sample's leaf: the expected value of the
1144        // weighted fit.
1145        let mut rng = Rng(0xABCDEF);
1146        let mut samples = Vec::new();
1147        for pid in 0..1024u32 {
1148            let interactive = pid % 3 == 0;
1149            let sleep = if interactive { 400_000 } else { 2_500_000 };
1150            let sleep = sleep + rng.next_in(0, 50_000);
1151            let label = if interactive { 80_000 } else { 12_000_000 };
1152            let label = label + rng.next_in(0, 200_000);
1153            samples.push(sample(pid, sleep, label));
1154        }
1155
1156        let tree = fit(&samples, 8, 4, 511, DEFAULT_MIN_REL_VAR_REDUCTION);
1157        serialize_validate(&tree).unwrap();
1158
1159        let n = samples.len();
1160        let half_life = n as f64 / 2.0;
1161        let weight_of = |i: usize| 2.0f64.powf(-((n - i) as f64) / half_life);
1162
1163        for s in &samples {
1164            let feats = s.feats; /* copy out of the packed sample */
1165            let leaf = leaf_index(&tree, &feats);
1166            let mut wsum = 0.0f64;
1167            let mut wsum_y = 0.0f64;
1168            for (j, t) in samples.iter().enumerate() {
1169                let t_feats = t.feats;
1170                if leaf_index(&tree, &t_feats) == leaf {
1171                    wsum += weight_of(j);
1172                    wsum_y += weight_of(j) * t.label_ns as f64;
1173                }
1174            }
1175            let expected = leaf_prediction((wsum_y / wsum) as u64) as u64;
1176            let pid = s.pid;
1177            assert_eq!(
1178                predict(&tree, &feats),
1179                expected,
1180                "round-trip prediction for pid {} via leaf {leaf}",
1181                pid
1182            );
1183        }
1184    }
1185
1186    #[test]
1187    fn holdout_mae_beats_constant_mean() {
1188        // A tree trained on two thirds of the data must beat the
1189        // constant-mean predictor on the held-out third.
1190        let mut rng = Rng(0x1234_5678);
1191        let mut samples = Vec::new();
1192        for pid in 0..1200u32 {
1193            let interactive = pid % 2 == 0;
1194            let sleep = if interactive { 300_000 } else { 3_000_000 };
1195            let sleep = sleep + rng.next_in(0, 100_000);
1196            let label = if interactive { 120_000 } else { 15_000_000 };
1197            let label = label + rng.next_in(0, 500_000);
1198            samples.push(sample(pid, sleep, label));
1199        }
1200
1201        let split = 2 * samples.len() / 3;
1202        let (train, test) = samples.split_at(split);
1203        let tree = fit(train, 8, 4, 511, DEFAULT_MIN_REL_VAR_REDUCTION);
1204        serialize_validate(&tree).unwrap();
1205
1206        let actuals: Vec<u64> = test.iter().map(|s| s.label_ns).collect();
1207        let preds: Vec<u64> = test
1208            .iter()
1209            .map(|s| {
1210                let feats = s.feats;
1211                predict(&tree, &feats)
1212            })
1213            .collect();
1214        let mae_tree = mae(&preds, &actuals);
1215
1216        let mean_label = train.iter().map(|s| s.label_ns as f64).sum::<f64>() / train.len() as f64;
1217        let const_preds = vec![mean_label as u64; test.len()];
1218        let mae_const = mae(&const_preds, &actuals);
1219
1220        assert!(
1221            mae_tree < mae_const,
1222            "tree MAE {mae_tree} must beat the constant-mean MAE {mae_const}"
1223        );
1224    }
1225
1226    #[test]
1227    fn empty_input_errors() {
1228        let tree = fit(&[], 4, 2, 63, DEFAULT_MIN_REL_VAR_REDUCTION);
1229        assert!(tree.nodes.is_empty(), "an empty fit yields no nodes");
1230        assert!(
1231            serialize_validate(&tree).is_err(),
1232            "an empty tree must not validate"
1233        );
1234        assert_eq!(predict(&tree, &TreeFeats::default()), 0);
1235
1236        // Zero node budget: same empty shape.
1237        let s = sample(0, 1_000, 1_000);
1238        let tree = fit(&[s], 4, 2, 0, DEFAULT_MIN_REL_VAR_REDUCTION);
1239        assert!(tree.nodes.is_empty());
1240        assert!(serialize_validate(&tree).is_err());
1241
1242        // Length mismatch is a caller bug and must be loud.
1243        assert_eq!(mae(&[], &[]), 0.0);
1244        assert_eq!(pearson(&[], &[]), 0.0);
1245    }
1246
1247    #[test]
1248    fn threshold_roundtrip_u64_precision() {
1249        // Two classes separated by a sleep gap just below u64::MAX: the
1250        // stored threshold must be the exact overflow-safe midpoint and
1251        // the walk must route on it without wrapping.
1252        let lo = u64::MAX - 1_000_000;
1253        let hi = u64::MAX - 100_000;
1254        let mut samples = Vec::new();
1255        for k in 0..4 {
1256            let mut a = sample(k, lo, 100_000);
1257            a.feats.prev_burst_ns = 1_000; /* constant across classes */
1258            let mut b = sample(k + 4, hi, 10_000_000);
1259            b.feats.prev_burst_ns = 1_000;
1260            samples.push(a);
1261            samples.push(b);
1262        }
1263
1264        let tree = fit(&samples, 4, 2, 31, DEFAULT_MIN_REL_VAR_REDUCTION);
1265        serialize_validate(&tree).unwrap();
1266
1267        let root = &tree.nodes[0];
1268        assert_eq!(root.feature, 1, "the root must split on sleep_ns");
1269        assert_eq!(root.threshold, lo + (hi - lo) / 2);
1270
1271        for s in &samples {
1272            let feats = s.feats;
1273            let pid = s.pid;
1274            let pred = predict(&tree, &feats);
1275            let expected = if s.feats.sleep_ns == lo {
1276                100_000
1277            } else {
1278                10_000_000
1279            };
1280            assert_eq!(pred, expected, "pid {}", pid);
1281        }
1282
1283        // Consecutive distinct values collapse to the lower one, which
1284        // still separates them (lower routes left, higher routes right).
1285        let samples = [sample(0, 100, 1_000), sample(1, 101, 9_000)];
1286        let tree = fit(&samples, 4, 1, 31, DEFAULT_MIN_REL_VAR_REDUCTION);
1287        serialize_validate(&tree).unwrap();
1288        assert_eq!(tree.nodes[0].threshold, 100);
1289        let f0 = samples[0].feats;
1290        let f1 = samples[1].feats;
1291        assert_eq!(predict(&tree, &f0), 1_000);
1292        assert_eq!(predict(&tree, &f1), 9_000);
1293    }
1294
1295    #[test]
1296    fn validate_rejects_broken_trees() {
1297        // Two samples with distinct sleep make the root an internal node.
1298        let samples = [sample(0, 100, 1_000), sample(1, 101, 9_000)];
1299        let fit_ok = |min_rel: f64| fit(&samples, 4, 1, 31, min_rel);
1300        assert!(serialize_validate(&fit_ok(0.0)).is_ok());
1301
1302        // Internal node splitting on feature 9 (beyond NR_FEATURES 9).
1303        let mut bad = fit_ok(0.0);
1304        bad.nodes[0].feature = 9;
1305        assert!(serialize_validate(&bad).is_err());
1306
1307        // Child index out of range.
1308        let mut bad = fit_ok(0.0);
1309        bad.nodes[0].right = MLFQ_TREE_MAX_NODES as u32;
1310        assert!(serialize_validate(&bad).is_err());
1311
1312        // Child preceding its parent (BFS order violation).
1313        let mut bad = fit_ok(0.0);
1314        bad.nodes[0].left = 0;
1315        assert!(serialize_validate(&bad).is_err());
1316
1317        // A larger fit passes.
1318        let mut samples = Vec::new();
1319        for pid in 0..64u32 {
1320            let sleep = if pid % 2 == 0 { 400_000 } else { 2_500_000 };
1321            let label = if pid % 2 == 0 { 80_000 } else { 12_000_000 };
1322            samples.push(sample(pid, sleep, label));
1323        }
1324        let tree = fit(&samples, 4, 2, 63, DEFAULT_MIN_REL_VAR_REDUCTION);
1325        assert!(serialize_validate(&tree).is_ok());
1326    }
1327
1328    #[test]
1329    fn metrics_on_known_vectors() {
1330        assert_eq!(mae(&[10, 20], &[12, 18]), 2.0);
1331        assert_eq!(mae(&[1_000], &[2_000]), 1_000.0);
1332
1333        // Perfectly correlated vectors give 1.0, anticorrelated -1.0,
1334        // orthogonal ~0.0 (the last with tolerance).
1335        let x = [1, 2, 3, 4];
1336        let y = [10, 20, 30, 40];
1337        assert!((pearson(&x, &y) - 1.0).abs() < 1e-12);
1338        assert!((pearson(&x, &[40, 30, 20, 10]) + 1.0).abs() < 1e-12);
1339        assert!(pearson(&x, &[1, 4, 4, 1]).abs() < 1e-12);
1340
1341        // A constant vector has undefined correlation; the metric stays 0.
1342        assert_eq!(pearson(&x, &[5, 5, 5, 5]), 0.0);
1343    }
1344
1345    #[test]
1346    fn publish_gate_and_tree_meta() {
1347        // The gate requires MAE_tree <= MAE_ema and corr >=0.30, and once
1348        // a model is committed its correlation must be strictly exceeded
1349        // (monotonic ratchet toward 1.0). A regressed MAE or a non-
1350        // improving correlation keeps the previous model.
1351        assert!(should_publish(100.0, 100.0, 0.5, None));
1352        assert!(should_publish(99.0, 100.0, 0.5, None));
1353        assert!(!should_publish(101.0, 100.0, 0.5, None));
1354        assert!(should_publish(0.0, 0.0, 0.5, None));
1355        assert!(!should_publish(90.0, 100.0, 0.29, None));
1356        assert!(should_publish(90.0, 100.0, 0.30, None));
1357        assert!(!should_publish(90.0, 100.0, 0.0, None));
1358        assert!(!should_publish(90.0, 100.0, 0.30, Some(0.30)));
1359        assert!(should_publish(90.0, 100.0, 0.31, Some(0.30)));
1360        assert!(!should_publish(90.0, 100.0, 0.50, Some(0.50)));
1361        assert!(should_publish(90.0, 100.0, 0.52, Some(0.50)));
1362        assert!(should_publish(90.0, 100.0, 0.72, Some(0.52)));
1363        // Weighted holdout: recent samples dominate.
1364        let preds = [100, 200];
1365        let actuals = [110, 190];
1366        let w = [0.25, 1.0];
1367        let wm = weighted_holdout_mae(&preds, &actuals, &w);
1368        assert!((wm - 10.0).abs() < 1e-9);
1369
1370        // The meta bits are exact: trained bit 0 (always set by a
1371        // publish), active bit 1, node count in bits 8..31 and the
1372        // generation in bits 32..63.
1373        assert_eq!(tree_meta(0, 1, 0), (1 << 8) | 1);
1374        assert_eq!(tree_meta(0, 2048, 1), (2048 << 8) | (1 << 1) | 1);
1375        let m = tree_meta(3, 7, 0);
1376        assert_eq!(m, (3u64 << 32) | (7 << 8) | 1);
1377        assert_eq!(
1378            m & crate::bpf_intf::MLFQ_TREE_META_TRAINED as u64,
1379            crate::bpf_intf::MLFQ_TREE_META_TRAINED as u64
1380        );
1381        assert_eq!((m >> 1) & 1, 0);
1382        assert_eq!((m >> 8) & 0xFFFFFF, 7);
1383        assert_eq!(m >> 32, 3);
1384        // The active bit flips the entry the walk reads.
1385        assert_eq!((tree_meta(0, 1, 1) >> 1) & 1, 1);
1386
1387        // The generation is masked to its 32 meta bits before the shift:
1388        // an out-of-range value must not overflow the shift (debug builds
1389        // would panic), and the mask keeps the field semantics exact.
1390        assert_eq!(tree_meta(0x1_0000_0000, 1, 0), (1 << 8) | 1);
1391        assert_eq!(
1392            tree_meta(0xFFFF_FFFF_FFFF_FFFF, 0, 0),
1393            0xFFFF_FFFF_0000_0000 | 1
1394        );
1395        assert_eq!(tree_meta(0x1_FFFF_FFFF, 1, 0), 0xFFFF_FFFF_0000_0101);
1396    }
1397
1398    #[test]
1399    fn predict_depth_exhaustion_yields_zero_on_internal() {
1400        // A chain of MLFQ_TREE_MAX_DEPTH + 1 internal nodes: the walk
1401        // descends the first 12 edges and stops, and the node it lands
1402        // on (depth 12) is internal, so the prediction must be 0. A
1403        // child index must never leak out as a burst.
1404        let mut tree = SerializedTree::default();
1405        for i in 0..=MLFQ_TREE_MAX_DEPTH {
1406            tree.nodes.push(TreeNode {
1407                threshold: 0,
1408                left: (i + 1) as u32,
1409                right: (i + 1) as u32,
1410                feature: 0,
1411                pad: [0; 7],
1412            });
1413        }
1414        assert_eq!(predict(&tree, &TreeFeats::default()), 0);
1415        // Such a tree is deeper than the walk bound: the validator
1416        // rejects the internal node at depth 12.
1417        assert!(serialize_validate(&tree).is_err());
1418
1419        // A chain that ends in a leaf exactly at the bound is valid and
1420        // predicts the leaf.
1421        let mut tree = SerializedTree::default();
1422        for i in 0..MLFQ_TREE_MAX_DEPTH {
1423            tree.nodes.push(TreeNode {
1424                threshold: 0,
1425                left: (i + 1) as u32,
1426                right: (i + 1) as u32,
1427                feature: 0,
1428                pad: [0; 7],
1429            });
1430        }
1431        tree.nodes.push(TreeNode {
1432            threshold: 0,
1433            left: 777_777,
1434            right: 0,
1435            feature: 0,
1436            pad: [0; 7],
1437        });
1438        assert!(serialize_validate(&tree).is_ok());
1439        assert_eq!(predict(&tree, &TreeFeats::default()), 777_777);
1440    }
1441
1442    #[test]
1443    fn serialize_validate_rejects_deep_trees() {
1444        // A leaf at depth 13 needs an internal parent at depth 12, which
1445        // the walk can only ever reach through the exhaustion fallback;
1446        // the validator rejects the internal-at-the-bound shape.
1447        let mut tree = SerializedTree::default();
1448        for i in 0..=MLFQ_TREE_MAX_DEPTH {
1449            tree.nodes.push(TreeNode {
1450                threshold: 0,
1451                left: (i + 1) as u32,
1452                right: (i + 1) as u32,
1453                feature: 0,
1454                pad: [0; 7],
1455            });
1456        }
1457        tree.nodes.push(TreeNode {
1458            threshold: 0,
1459            left: 42,
1460            right: 0,
1461            feature: 0,
1462            pad: [0; 7],
1463        });
1464        assert!(serialize_validate(&tree).is_err());
1465    }
1466
1467    #[test]
1468    fn adversarial_constant_labels_never_split() {
1469        // All labels identical: the SSE is exactly 0, so no split can
1470        // reduce it and the tree must be a single leaf predicting the
1471        // constant.
1472        let samples: Vec<TreeSample> = (0..64u32)
1473            .map(|pid| sample(pid, 1_000_000, 123_456))
1474            .collect();
1475        let tree = fit(&samples, 12, 2, 2048, 0.0);
1476        assert_eq!(tree.nodes.len(), 1, "a constant label set yields one leaf");
1477        assert_eq!(tree.nodes[0].right, 0);
1478        let feats = samples[0].feats;
1479        assert_eq!(predict(&tree, &feats), 123_456);
1480        serialize_validate(&tree).unwrap();
1481    }
1482
1483    #[test]
1484    fn adversarial_max_nodes_exhaustion_mid_growth() {
1485        // Staircase labels force repeated splitting; a node budget of 3
1486        // must stop the growth right after the root split, leaving two
1487        // leaves.
1488        let mut samples = Vec::new();
1489        for (i, sleep) in (100_000u64..700_000).step_by(100_000).enumerate() {
1490            for k in 0..4 {
1491                let mut s = sample(i as u32 + k, sleep, sleep);
1492                s.feats.prev_burst_ns = 1_000;
1493                samples.push(s);
1494            }
1495        }
1496        let tree = fit(&samples, 12, 1, 3, 0.0);
1497        assert_eq!(
1498            tree.nodes.len(),
1499            3,
1500            "max_nodes 3 caps the growth at root + two leaves"
1501        );
1502        assert!(tree.nodes[0].right != 0);
1503        assert_eq!(tree.nodes[1].right, 0);
1504        assert_eq!(tree.nodes[2].right, 0);
1505        serialize_validate(&tree).unwrap();
1506    }
1507
1508    #[test]
1509    fn adversarial_extreme_labels_clamp() {
1510        // Labels at the top of the u64 range: the f64 SSE math must not
1511        // misbehave, and the leaf mean saturates through leaf_prediction
1512        // to u32::MAX (the walk returns the field as-is).
1513        let mut samples = Vec::new();
1514        for k in 0..4 {
1515            let mut a = sample(k, 100_000, u64::MAX);
1516            a.feats.prev_burst_ns = 1_000;
1517            let mut b = sample(k + 4, 200_000, 0);
1518            b.feats.prev_burst_ns = 1_000;
1519            samples.push(a);
1520            samples.push(b);
1521        }
1522        let tree = fit(&samples, 4, 2, 31, DEFAULT_MIN_REL_VAR_REDUCTION);
1523        serialize_validate(&tree).unwrap();
1524        assert_eq!(tree.nodes[0].feature, 1, "the root must split on sleep_ns");
1525        assert_eq!(
1526            predict(
1527                &tree,
1528                &TreeFeats {
1529                    prev_burst_ns: 1_000,
1530                    sleep_ns: 100_000,
1531                    ..TreeFeats::default()
1532                }
1533            ),
1534            u32::MAX as u64,
1535            "the extreme class clamps to the u32 leaf field"
1536        );
1537        assert_eq!(
1538            predict(
1539                &tree,
1540                &TreeFeats {
1541                    prev_burst_ns: 1_000,
1542                    sleep_ns: 200_000,
1543                    ..TreeFeats::default()
1544                }
1545            ),
1546            0
1547        );
1548    }
1549
1550    #[test]
1551    fn adversarial_min_leaf_boundary() {
1552        // n == 2*min_samples_leaf splits (both children reach the leaf
1553        // cap); n == 2*min_samples_leaf - 1 does not, because the left
1554        // group would fall below the cap.
1555        let class_a: Vec<TreeSample> = (0..3u32).map(|pid| sample(pid, 100_000, 100_000)).collect();
1556        let class_b: Vec<TreeSample> = (3..6u32).map(|pid| sample(pid, 200_000, 900_000)).collect();
1557        let mut samples = class_a.clone();
1558        samples.extend(class_b.iter().copied());
1559        let tree = fit(&samples, 4, 3, 31, 0.0);
1560        assert!(tree.nodes[0].right != 0, "n == 2*min_samples_leaf splits");
1561        serialize_validate(&tree).unwrap();
1562
1563        let samples = [class_a[0], class_a[1], class_b[0], class_b[1], class_b[2]];
1564        let tree = fit(&samples, 4, 3, 31, 0.0);
1565        assert_eq!(
1566            tree.nodes.len(),
1567            1,
1568            "n == 2*min_samples_leaf - 1 leaves one child below the cap: no split"
1569        );
1570    }
1571
1572    #[test]
1573    fn golden_tree_predict_matches_shared_spec() {
1574        // The shared crafted tree walked by both the native harness
1575        // (test_tree_golden_shared in mlfq_math_test.c) and this Rust
1576        // mirror, with identical expected outputs: a chain on
1577        // prev_burst_ns, mixed leaves, and a node whose raw feature id
1578        // is out of the populated range but masks in-bounds (0x84 ->
1579        // 4 = wake_cnt). The tree is a walk spec, not a publish spec.
1580        // The raw feature above 4 means serialize_validate rejects it,
1581        // and published trees are validated before the walk sees them.
1582        let nodes = vec![
1583            TreeNode {
1584                threshold: 1_000_000,
1585                left: 1,
1586                right: 8,
1587                feature: 0,
1588                pad: [0; 7],
1589            },
1590            TreeNode {
1591                threshold: 1_000_000,
1592                left: 2,
1593                right: 8,
1594                feature: 0,
1595                pad: [0; 7],
1596            },
1597            TreeNode {
1598                threshold: 1_000_000,
1599                left: 3,
1600                right: 8,
1601                feature: 0,
1602                pad: [0; 7],
1603            },
1604            TreeNode {
1605                threshold: 500_000,
1606                left: 4,
1607                right: 5,
1608                feature: 1,
1609                pad: [0; 7],
1610            },
1611            TreeNode {
1612                threshold: 0,
1613                left: 1_111_111,
1614                right: 0,
1615                feature: 0,
1616                pad: [0; 7],
1617            },
1618            TreeNode {
1619                threshold: 1,
1620                left: 6,
1621                right: 7,
1622                feature: 0x84,
1623                pad: [0; 7],
1624            },
1625            TreeNode {
1626                threshold: 0,
1627                left: 2_222_222,
1628                right: 0,
1629                feature: 0,
1630                pad: [0; 7],
1631            },
1632            TreeNode {
1633                threshold: 0,
1634                left: 3_333_333,
1635                right: 0,
1636                feature: 0,
1637                pad: [0; 7],
1638            },
1639            TreeNode {
1640                threshold: 0,
1641                left: 8_888_888,
1642                right: 0,
1643                feature: 0,
1644                pad: [0; 7],
1645            },
1646        ];
1647        let tree = SerializedTree { nodes };
1648
1649        let f = |prev: u64, sleep: u64, wake: u32| TreeFeats {
1650            prev_burst_ns: prev,
1651            sleep_ns: sleep,
1652            wake_cnt: wake,
1653            ..TreeFeats::default()
1654        };
1655        assert_eq!(predict(&tree, &f(0, 400_000, 0)), 1_111_111);
1656        assert_eq!(predict(&tree, &f(0, 600_000, 0)), 2_222_222);
1657        assert_eq!(predict(&tree, &f(0, 600_000, 5)), 3_333_333);
1658        assert_eq!(predict(&tree, &f(2_000_000, 0, 0)), 8_888_888);
1659        // wake_cnt is irrelevant on the sleep split's left branch.
1660        assert_eq!(predict(&tree, &f(0, 400_000, 9)), 1_111_111);
1661    }
1662
1663    #[test]
1664    fn abi_layout_matches_bpf_structs() {
1665        // The Rust structs are the byte-for-byte mirrors of the BPF
1666        // types in src/bpf/intf.h (via the bindgen-generated bpf_intf):
1667        // the ring-buffer records are parsed straight into TreeSample
1668        // and the serialized nodes are written straight into the store
1669        // map entry, so the sizes and offsets must match exactly.
1670        use crate::bpf_intf::{mlfq_tree_feats, mlfq_tree_node, mlfq_tree_sample};
1671        use std::mem::{offset_of, size_of};
1672
1673        assert_eq!(size_of::<TreeFeats>(), size_of::<mlfq_tree_feats>());
1674        assert_eq!(size_of::<TreeNode>(), size_of::<mlfq_tree_node>());
1675        assert_eq!(size_of::<TreeSample>(), size_of::<mlfq_tree_sample>());
1676        assert_eq!(size_of::<TreeFeats>(), 64);
1677        assert_eq!(size_of::<TreeSample>(), 84);
1678        assert_eq!(size_of::<TreeNode>(), 24);
1679
1680        assert_eq!(
1681            offset_of!(TreeFeats, prev_burst_ns),
1682            offset_of!(mlfq_tree_feats, prev_burst_ns)
1683        );
1684        assert_eq!(
1685            offset_of!(TreeFeats, sleep_ns),
1686            offset_of!(mlfq_tree_feats, sleep_ns)
1687        );
1688        assert_eq!(offset_of!(TreeFeats, ema), offset_of!(mlfq_tree_feats, ema));
1689        assert_eq!(
1690            offset_of!(TreeFeats, io_wait),
1691            offset_of!(mlfq_tree_feats, io_wait)
1692        );
1693        assert_eq!(
1694            offset_of!(TreeFeats, wake_cnt),
1695            offset_of!(mlfq_tree_feats, wake_cnt)
1696        );
1697        assert_eq!(
1698            offset_of!(TreeFeats, wake_lat_us),
1699            offset_of!(mlfq_tree_feats, wake_lat_us)
1700        );
1701        assert_eq!(
1702            offset_of!(TreeFeats, queue_wait_us),
1703            offset_of!(mlfq_tree_feats, queue_wait_us)
1704        );
1705        assert_eq!(
1706            offset_of!(TreeFeats, sq_ema),
1707            offset_of!(mlfq_tree_feats, sq_ema)
1708        );
1709        assert_eq!(
1710            offset_of!(TreeFeats, sleep_var_ratio),
1711            offset_of!(mlfq_tree_feats, sleep_var_ratio)
1712        );
1713        assert_eq!(
1714            offset_of!(TreeFeats, gpu_submit),
1715            offset_of!(mlfq_tree_feats, gpu_submit)
1716        );
1717        assert_eq!(offset_of!(TreeFeats, wake_lat_us), 32);
1718        assert_eq!(offset_of!(TreeFeats, queue_wait_us), 36);
1719        assert_eq!(offset_of!(TreeFeats, sq_ema), 40);
1720        assert_eq!(offset_of!(TreeFeats, sleep_var_ratio), 48);
1721        assert_eq!(offset_of!(TreeFeats, pad), 52);
1722        assert_eq!(offset_of!(TreeFeats, gpu_submit), 56);
1723        assert_eq!(offset_of!(TreeFeats, pad2), 60);
1724
1725        assert_eq!(
1726            offset_of!(TreeNode, threshold),
1727            offset_of!(mlfq_tree_node, threshold)
1728        );
1729        assert_eq!(offset_of!(TreeNode, left), offset_of!(mlfq_tree_node, left));
1730        assert_eq!(
1731            offset_of!(TreeNode, right),
1732            offset_of!(mlfq_tree_node, right)
1733        );
1734        assert_eq!(
1735            offset_of!(TreeNode, feature),
1736            offset_of!(mlfq_tree_node, feature)
1737        );
1738        assert_eq!(offset_of!(TreeNode, feature), 16);
1739        assert_eq!(offset_of!(TreeNode, pad), offset_of!(mlfq_tree_node, pad));
1740        assert_eq!(offset_of!(TreeNode, pad), 17);
1741
1742        assert_eq!(
1743            offset_of!(TreeSample, pid),
1744            offset_of!(mlfq_tree_sample, pid)
1745        );
1746        assert_eq!(
1747            offset_of!(TreeSample, queue),
1748            offset_of!(mlfq_tree_sample, queue)
1749        );
1750        assert_eq!(
1751            offset_of!(TreeSample, feats),
1752            offset_of!(mlfq_tree_sample, feats)
1753        );
1754        assert_eq!(
1755            offset_of!(TreeSample, label_ns),
1756            offset_of!(mlfq_tree_sample, label_ns)
1757        );
1758        assert_eq!(
1759            offset_of!(TreeSample, version),
1760            offset_of!(mlfq_tree_sample, version)
1761        );
1762        assert_eq!(offset_of!(TreeSample, queue), 4);
1763        assert_eq!(offset_of!(TreeSample, feats), 8);
1764        assert_eq!(offset_of!(TreeSample, label_ns), 72);
1765        assert_eq!(offset_of!(TreeSample, version), 80);
1766    }
1767
1768    #[test]
1769    fn sample_version_tag_is_checked() {
1770        // The version tag distinguishes a current-format record from a
1771        // foreign or stale one: the parse check accepts exactly the
1772        // compiled-in version and nothing else.
1773        let cur = TreeSample {
1774            pid: 1,
1775            version: MLFQ_TREE_SAMPLE_VERSION,
1776            queue: 2,
1777            feats: TreeFeats::default(),
1778            label_ns: 0,
1779        };
1780        assert!(sample_version_matches(&cur));
1781
1782        let stale = TreeSample { version: 0, ..cur };
1783        assert!(!sample_version_matches(&stale));
1784        let future = TreeSample {
1785            version: MLFQ_TREE_SAMPLE_VERSION + 1,
1786            ..cur
1787        };
1788        assert!(!sample_version_matches(&future));
1789    }
1790
1791    #[test]
1792    fn recency_weights_pin_the_ends() {
1793        // The recency weighting formula: the newest sample weighs ~1 and
1794        // the oldest exactly 0.25, so the admitted window cannot
1795        // underflow and the recent regime dominates the fit.
1796        let n = 8;
1797        let half_life = n as f64 / 2.0;
1798        let w_newest = 2.0f64.powf(-1.0 / half_life);
1799        let w_oldest = 2.0f64.powf(-(n as f64) / half_life);
1800        assert!((w_oldest - 0.25).abs() < 1e-15);
1801        assert!((w_newest - 1.0).abs() < 0.2);
1802
1803        let ws = sample_weights(n);
1804        assert_eq!(ws.len(), n);
1805        assert!((ws[0] - w_oldest).abs() < 1e-15);
1806        assert!((ws[n - 1] - w_newest).abs() < 1e-15);
1807        // Strictly increasing toward the newest.
1808        for i in 0..n - 1 {
1809            assert!(ws[i] < ws[i + 1]);
1810        }
1811    }
1812}