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 {
788        node.left as u64
789    } else {
790        0
791    }
792}
793
794/// The publish quality gate. The tree replaces the previous model only when
795/// its holdout MAE beats the exact per-sample EMA baseline on the same
796/// holdout slice and the Pearson correlation clears the quality floor
797/// and strictly improves on the currently committed model.
798/// The holdout MAE is weighted by the recency weights of the window, so
799/// recent samples dominate the gate. Correlation is monotonic: once a
800/// model at 0.30 is committed, a later fit at 0.30 or below is held
801/// out; 0.52 then 0.72 ratchet toward 1.0 on general workloads.
802/// A higher correlation must also beat the baseline, so a high but
803/// narrow fit on one game cannot regress the tail.
804///
805/// This is the daemon's contract with the README's claim that the tree
806/// is published when it beats the baseline: a regressed model is kept
807/// out and the previous model stays committed.
808pub fn should_publish(mae_tree: f64, mae_ema: f64, corr: f64, published_corr: Option<f64>) -> bool {
809    if mae_tree > mae_ema {
810        return false;
811    }
812    if corr < 0.30 {
813        return false;
814    }
815    if let Some(pc) = published_corr {
816        if corr <= pc + 1e-9 {
817            return false;
818        }
819    }
820    true
821}
822
823/// Weighted MAE for the holdout slice. Each holdout sample carries its
824/// recency weight w_i = 2^(-age_i / (n/2)) where n is the full window
825/// length, so the gate emphasizes the recent regime without dropping
826/// the older tail entirely.
827pub fn weighted_holdout_mae(preds: &[u64], actuals: &[u64], weights: &[f64]) -> f64 {
828    assert_eq!(preds.len(), actuals.len());
829    assert_eq!(preds.len(), weights.len());
830    if preds.is_empty() {
831        return 0.0;
832    }
833    let mut sw = 0.0;
834    let mut sw_err = 0.0;
835    for ((p, a), w) in preds.iter().zip(actuals).zip(weights.iter()) {
836        let err = if p >= a { *p - *a } else { *a - *p } as f64;
837        sw += *w;
838        sw_err += *w * err;
839    }
840    if sw == 0.0 {
841        0.0
842    } else {
843        sw_err / sw
844    }
845}
846
847/// Record-layout version tag of the emitted training samples, from
848/// `enum mlfq_consts` in `src/bpf/intf.h`.
849pub const MLFQ_TREE_SAMPLE_VERSION: u32 = crate::bpf_intf::mlfq_consts_MLFQ_TREE_SAMPLE_VERSION;
850
851/// True when a parsed sample carries the current record-layout version.
852///
853/// The daemon and the BPF object compile from the same `intf.h`, so a
854/// mismatch is either an out-of-tree producer or a stale build; the
855/// check turns that into a loud drop at the parse instead of a silent
856/// misread of the feature fields.
857pub fn sample_version_matches(s: &TreeSample) -> bool {
858    s.version == MLFQ_TREE_SAMPLE_VERSION
859}
860
861/// Bit position of the active-buffer flag in the committed-tree meta
862/// (bit 1, the value of `MLFQ_TREE_META_ACTIVE`).
863const MLFQ_TREE_META_ACTIVE_SHIFT: u32 = 1;
864
865/// Serialize the committed-tree meta value from its parts.
866///
867/// The bit layout mirrors `struct mlfq_tree_ctrl` in `src/bpf/intf.h`:
868/// bit 0 the trained bit (always set by a publish), bit 1 the active
869/// buffer index, bits 8..31 the node count and bits 32..63 the
870/// generation. The active bit is the caller's decision (the inactive
871/// buffer index); the trained bit is constant because this function is
872/// only used for a publish.
873pub fn tree_meta(generation: u64, nr_nodes: usize, active: u64) -> u64 {
874    /*
875     * Mask the generation to its 32 meta bits before the shift. The
876     * field is 32 bits wide and the wrap is unreachable (2^32 publishes
877     * at the 60 s cadence is ~8200 years), so the mask is defensive
878     * hygiene. It pins the shift input to the field width, so the
879     * committed meta can never carry bits above the generation field
880     * even if a caller passed an out-of-range value.
881     */
882    ((generation & 0xFFFF_FFFF) << crate::bpf_intf::MLFQ_TREE_META_GENERATION_SHIFT)
883        | ((nr_nodes as u64) << crate::bpf_intf::MLFQ_TREE_META_NR_NODES_SHIFT)
884        | ((active & 1) << MLFQ_TREE_META_ACTIVE_SHIFT)
885        | crate::bpf_intf::MLFQ_TREE_META_TRAINED as u64
886}
887
888/// Mean absolute error between predictions and actuals.
889///
890/// Lengths must match. The empty case returns 0. The sums accumulate in
891/// u128 so near-u64 values cannot overflow.
892#[allow(dead_code)]
893pub fn mae(preds: &[u64], actuals: &[u64]) -> f64 {
894    assert_eq!(
895        preds.len(),
896        actuals.len(),
897        "prediction and actual vectors differ in length"
898    );
899    if preds.is_empty() {
900        return 0.0;
901    }
902    let total: u128 = preds
903        .iter()
904        .zip(actuals)
905        .map(|(p, a)| (if p >= a { *p - *a } else { *a - *p }) as u128)
906        .sum();
907    total as f64 / preds.len() as f64
908}
909
910/// Pearson product-moment correlation of two equally long vectors.
911///
912/// Returns 0 for the empty case and for a constant vector (zero variance),
913/// where the coefficient is undefined; keeping the metric finite lets the
914/// daemon compare the tree against the EMA predictor without special
915/// cases.
916pub fn pearson(x: &[u64], y: &[u64]) -> f64 {
917    assert_eq!(x.len(), y.len(), "correlation vectors differ in length");
918    let n = x.len();
919    if n == 0 {
920        return 0.0;
921    }
922
923    let mean_x = x.iter().map(|v| *v as f64).sum::<f64>() / n as f64;
924    let mean_y = y.iter().map(|v| *v as f64).sum::<f64>() / n as f64;
925
926    let (mut cov, mut var_x, mut var_y) = (0.0, 0.0, 0.0);
927    for (xi, yi) in x.iter().zip(y) {
928        let dx = *xi as f64 - mean_x;
929        let dy = *yi as f64 - mean_y;
930        cov += dx * dy;
931        var_x += dx * dx;
932        var_y += dy * dy;
933    }
934    if var_x == 0.0 || var_y == 0.0 {
935        return 0.0;
936    }
937    cov / (var_x * var_y).sqrt()
938}
939
940#[cfg(test)]
941mod tests {
942    use super::*;
943
944    /// Deterministic xorshift64* PRNG so the datasets are reproducible.
945    struct Rng(u64);
946
947    impl Rng {
948        fn next_u64(&mut self) -> u64 {
949            let mut x = self.0;
950            x ^= x << 13;
951            x ^= x >> 7;
952            x ^= x << 17;
953            self.0 = x;
954            x.wrapping_mul(0x2545_F491_4F6C_DD1D)
955        }
956
957        fn next_in(&mut self, lo: u64, hi: u64) -> u64 {
958            lo + self.next_u64() % (hi - lo)
959        }
960    }
961
962    fn sample(pid: u32, sleep_ns: u64, label_ns: u64) -> TreeSample {
963        TreeSample {
964            pid,
965            version: MLFQ_TREE_SAMPLE_VERSION,
966            queue: 1,
967            feats: TreeFeats {
968                sleep_ns,
969                ..TreeFeats::default()
970            },
971            label_ns,
972        }
973    }
974
975    /// The mirror of `predict()` that returns the terminal node index, so
976    /// tests can reconstruct which training samples share a leaf.
977    fn leaf_index(tree: &SerializedTree, feats: &TreeFeats) -> usize {
978        let feat: [u64; 16] = [
979            feats.prev_burst_ns,
980            feats.sleep_ns,
981            feats.ema,
982            feats.io_wait as u64,
983            feats.wake_cnt as u64,
984            feats.wake_lat_us as u64,
985            feats.queue_wait_us as u64,
986            feats.sq_ema,
987            feats.gpu_submit as u64,
988            feats.sleep_var_ratio as u64,
989            0,
990            0,
991            0,
992            0,
993            0,
994            0,
995        ];
996        let mask = MLFQ_TREE_MAX_NODES - 1;
997        let mut idx = 0usize;
998        for _ in 0..MLFQ_TREE_MAX_DEPTH {
999            if idx >= tree.nodes.len() {
1000                return idx;
1001            }
1002            let node = &tree.nodes[idx];
1003            if node.right == 0 {
1004                return idx;
1005            }
1006            let feature = (node.feature & 0xF) as usize;
1007            let next = if feat[feature] <= node.threshold {
1008                node.left as usize
1009            } else {
1010                node.right as usize
1011            };
1012            idx = next & mask;
1013        }
1014        idx
1015    }
1016
1017    #[test]
1018    fn separable_dataset_recovers_split() {
1019        // Two classes separated by sleep: ~500us sleeps burst ~= 100us,
1020        // ~2ms sleeps burst ~= 10ms, both with deterministic jitter. Only
1021        // sleep_ns carries signal, so the root must split on it and the
1022        // threshold must sit between the two class regions.
1023        let mut rng = Rng(0xC0FFEE);
1024        let mut samples = Vec::new();
1025        for pid in 0..512u32 {
1026            let interactive = pid % 2 == 0;
1027            let sleep = if interactive { 500_000 } else { 2_000_000 };
1028            let sleep = sleep + rng.next_in(0, 100_000);
1029            let label = if interactive { 100_000 } else { 10_000_000 };
1030            let label = label + rng.next_in(0, 10_000);
1031            samples.push(sample(pid, sleep, label));
1032        }
1033
1034        let tree = fit(&samples, 12, 2, 63, DEFAULT_MIN_REL_VAR_REDUCTION);
1035        serialize_validate(&tree).unwrap();
1036
1037        let root = &tree.nodes[0];
1038        assert!(root.right != 0, "the root must be an internal node");
1039        assert_eq!(root.feature, 1, "the root must split on sleep_ns");
1040        assert!(
1041            root.threshold > 1_000_000 && root.threshold < 1_500_000,
1042            "root threshold {} must sit between the class regions",
1043            root.threshold
1044        );
1045
1046        // The leaf predictions must separate the classes.
1047        for s in &samples {
1048            let feats = s.feats;
1049            let pred = predict(&tree, &feats);
1050            if s.feats.sleep_ns < 1_000_000 {
1051                assert!(
1052                    (90_000..=110_000).contains(&pred),
1053                    "interactive sample predicted {pred}"
1054                );
1055            } else {
1056                assert!(
1057                    (9_900_000..=10_100_000).contains(&pred),
1058                    "CPU-bound sample predicted {pred}"
1059                );
1060            }
1061        }
1062    }
1063
1064    #[test]
1065    fn irrelevant_feature_never_split_on() {
1066        // wake_cnt (feature 4) is uniform noise over a wide range; the
1067        // label depends only on sleep_ns, and exactly (no label noise), so
1068        // no split on wake_cnt can reduce the variance at all after the
1069        // sleep split. The tree must never split on feature 4.
1070        let mut rng = Rng(0xBEEF);
1071        let mut samples = Vec::new();
1072        for pid in 0..1024u32 {
1073            let interactive = pid % 2 == 0;
1074            let sleep = if interactive { 500_000 } else { 2_000_000 };
1075            let label = if interactive { 100_000 } else { 10_000_000 };
1076            let wake_cnt = rng.next_in(0, 1_000_000);
1077            let mut s = sample(pid, sleep, label);
1078            s.feats.wake_cnt = wake_cnt as u32;
1079            samples.push(s);
1080        }
1081
1082        let tree = fit(&samples, 4, 2, 127, DEFAULT_MIN_REL_VAR_REDUCTION);
1083        serialize_validate(&tree).unwrap();
1084
1085        for (i, node) in tree.nodes.iter().enumerate() {
1086            if node.right != 0 {
1087                assert_ne!(
1088                    node.feature, 4,
1089                    "node {i} must not split on the irrelevant wake_cnt"
1090                );
1091            }
1092        }
1093    }
1094
1095    #[test]
1096    fn depth_and_min_leaf_caps_respected() {
1097        // A staircase label (label == sleep) forces repeated splitting:
1098        // with min_rel_var_reduction 0 the tree grows greedily until the
1099        // depth or leaf caps stop it. All leaves must hold at least
1100        // min_samples_leaf training samples and no internal node may sit
1101        // below the depth cap.
1102        let mut samples = Vec::new();
1103        for (i, sleep) in (100_000u64..1_700_000).step_by(100_000).enumerate() {
1104            for k in 0..4 {
1105                let mut s = sample(i as u32 + k, sleep, sleep);
1106                s.feats.prev_burst_ns = 50_000 + 10_000 * (i % 5) as u64;
1107                samples.push(s);
1108            }
1109        }
1110
1111        let max_depth = 3;
1112        let min_samples_leaf = 2;
1113        let tree = fit(&samples, max_depth, min_samples_leaf, 1000, 0.0);
1114        serialize_validate(&tree).unwrap();
1115
1116        // Depth of every node in the BFS layout, computed from the parents.
1117        let mut depth = vec![0usize; tree.nodes.len()];
1118        for (i, node) in tree.nodes.iter().enumerate() {
1119            if node.right != 0 {
1120                assert!(
1121                    depth[i] < max_depth,
1122                    "node {i} at depth {} exceeds the cap {max_depth}",
1123                    depth[i]
1124                );
1125                depth[node.left as usize] = depth[i] + 1;
1126                depth[node.right as usize] = depth[i] + 1;
1127            }
1128        }
1129
1130        // Every leaf receives at least min_samples_leaf training samples.
1131        let mut leaf_counts = std::collections::HashMap::new();
1132        for s in &samples {
1133            let feats = s.feats;
1134            *leaf_counts
1135                .entry(leaf_index(&tree, &feats))
1136                .or_insert(0usize) += 1;
1137        }
1138        assert!(!leaf_counts.is_empty(), "the tree must have leaves");
1139        for (leaf, count) in &leaf_counts {
1140            assert!(
1141                *count >= min_samples_leaf,
1142                "leaf {leaf} holds {count} samples, below the cap {min_samples_leaf}"
1143            );
1144        }
1145    }
1146
1147    #[test]
1148    fn serialize_walk_roundtrip() {
1149        // The walk over the serialized tree must return, for every
1150        // training sample, the recency-weighted mean of the labels
1151        // routed to the sample's leaf: the expected value of the
1152        // weighted fit.
1153        let mut rng = Rng(0xABCDEF);
1154        let mut samples = Vec::new();
1155        for pid in 0..1024u32 {
1156            let interactive = pid % 3 == 0;
1157            let sleep = if interactive { 400_000 } else { 2_500_000 };
1158            let sleep = sleep + rng.next_in(0, 50_000);
1159            let label = if interactive { 80_000 } else { 12_000_000 };
1160            let label = label + rng.next_in(0, 200_000);
1161            samples.push(sample(pid, sleep, label));
1162        }
1163
1164        let tree = fit(&samples, 8, 4, 511, DEFAULT_MIN_REL_VAR_REDUCTION);
1165        serialize_validate(&tree).unwrap();
1166
1167        let n = samples.len();
1168        let half_life = n as f64 / 2.0;
1169        let weight_of = |i: usize| 2.0f64.powf(-((n - i) as f64) / half_life);
1170
1171        for s in &samples {
1172            let feats = s.feats; /* copy out of the packed sample */
1173            let leaf = leaf_index(&tree, &feats);
1174            let mut wsum = 0.0f64;
1175            let mut wsum_y = 0.0f64;
1176            for (j, t) in samples.iter().enumerate() {
1177                let t_feats = t.feats;
1178                if leaf_index(&tree, &t_feats) == leaf {
1179                    wsum += weight_of(j);
1180                    wsum_y += weight_of(j) * t.label_ns as f64;
1181                }
1182            }
1183            let expected = leaf_prediction((wsum_y / wsum) as u64) as u64;
1184            let pid = s.pid;
1185            assert_eq!(
1186                predict(&tree, &feats),
1187                expected,
1188                "round-trip prediction for pid {} via leaf {leaf}",
1189                pid
1190            );
1191        }
1192    }
1193
1194    #[test]
1195    fn holdout_mae_beats_constant_mean() {
1196        // A tree trained on two thirds of the data must beat the
1197        // constant-mean predictor on the held-out third.
1198        let mut rng = Rng(0x1234_5678);
1199        let mut samples = Vec::new();
1200        for pid in 0..1200u32 {
1201            let interactive = pid % 2 == 0;
1202            let sleep = if interactive { 300_000 } else { 3_000_000 };
1203            let sleep = sleep + rng.next_in(0, 100_000);
1204            let label = if interactive { 120_000 } else { 15_000_000 };
1205            let label = label + rng.next_in(0, 500_000);
1206            samples.push(sample(pid, sleep, label));
1207        }
1208
1209        let split = 2 * samples.len() / 3;
1210        let (train, test) = samples.split_at(split);
1211        let tree = fit(train, 8, 4, 511, DEFAULT_MIN_REL_VAR_REDUCTION);
1212        serialize_validate(&tree).unwrap();
1213
1214        let actuals: Vec<u64> = test.iter().map(|s| s.label_ns).collect();
1215        let preds: Vec<u64> = test
1216            .iter()
1217            .map(|s| {
1218                let feats = s.feats;
1219                predict(&tree, &feats)
1220            })
1221            .collect();
1222        let mae_tree = mae(&preds, &actuals);
1223
1224        let mean_label = train.iter().map(|s| s.label_ns as f64).sum::<f64>() / train.len() as f64;
1225        let const_preds = vec![mean_label as u64; test.len()];
1226        let mae_const = mae(&const_preds, &actuals);
1227
1228        assert!(
1229            mae_tree < mae_const,
1230            "tree MAE {mae_tree} must beat the constant-mean MAE {mae_const}"
1231        );
1232    }
1233
1234    #[test]
1235    fn empty_input_errors() {
1236        let tree = fit(&[], 4, 2, 63, DEFAULT_MIN_REL_VAR_REDUCTION);
1237        assert!(tree.nodes.is_empty(), "an empty fit yields no nodes");
1238        assert!(
1239            serialize_validate(&tree).is_err(),
1240            "an empty tree must not validate"
1241        );
1242        assert_eq!(predict(&tree, &TreeFeats::default()), 0);
1243
1244        // Zero node budget: same empty shape.
1245        let s = sample(0, 1_000, 1_000);
1246        let tree = fit(&[s], 4, 2, 0, DEFAULT_MIN_REL_VAR_REDUCTION);
1247        assert!(tree.nodes.is_empty());
1248        assert!(serialize_validate(&tree).is_err());
1249
1250        // Length mismatch is a caller bug and must be loud.
1251        assert_eq!(mae(&[], &[]), 0.0);
1252        assert_eq!(pearson(&[], &[]), 0.0);
1253    }
1254
1255    #[test]
1256    fn threshold_roundtrip_u64_precision() {
1257        // Two classes separated by a sleep gap just below u64::MAX: the
1258        // stored threshold must be the exact overflow-safe midpoint and
1259        // the walk must route on it without wrapping.
1260        let lo = u64::MAX - 1_000_000;
1261        let hi = u64::MAX - 100_000;
1262        let mut samples = Vec::new();
1263        for k in 0..4 {
1264            let mut a = sample(k, lo, 100_000);
1265            a.feats.prev_burst_ns = 1_000; /* constant across classes */
1266            let mut b = sample(k + 4, hi, 10_000_000);
1267            b.feats.prev_burst_ns = 1_000;
1268            samples.push(a);
1269            samples.push(b);
1270        }
1271
1272        let tree = fit(&samples, 4, 2, 31, DEFAULT_MIN_REL_VAR_REDUCTION);
1273        serialize_validate(&tree).unwrap();
1274
1275        let root = &tree.nodes[0];
1276        assert_eq!(root.feature, 1, "the root must split on sleep_ns");
1277        assert_eq!(root.threshold, lo + (hi - lo) / 2);
1278
1279        for s in &samples {
1280            let feats = s.feats;
1281            let pid = s.pid;
1282            let pred = predict(&tree, &feats);
1283            let expected = if s.feats.sleep_ns == lo {
1284                100_000
1285            } else {
1286                10_000_000
1287            };
1288            assert_eq!(pred, expected, "pid {}", pid);
1289        }
1290
1291        // Consecutive distinct values collapse to the lower one, which
1292        // still separates them (lower routes left, higher routes right).
1293        let samples = [sample(0, 100, 1_000), sample(1, 101, 9_000)];
1294        let tree = fit(&samples, 4, 1, 31, DEFAULT_MIN_REL_VAR_REDUCTION);
1295        serialize_validate(&tree).unwrap();
1296        assert_eq!(tree.nodes[0].threshold, 100);
1297        let f0 = samples[0].feats;
1298        let f1 = samples[1].feats;
1299        assert_eq!(predict(&tree, &f0), 1_000);
1300        assert_eq!(predict(&tree, &f1), 9_000);
1301    }
1302
1303    #[test]
1304    fn validate_rejects_broken_trees() {
1305        // Two samples with distinct sleep make the root an internal node.
1306        let samples = [sample(0, 100, 1_000), sample(1, 101, 9_000)];
1307        let fit_ok = |min_rel: f64| fit(&samples, 4, 1, 31, min_rel);
1308        assert!(serialize_validate(&fit_ok(0.0)).is_ok());
1309
1310        // Internal node splitting on feature 9 (beyond NR_FEATURES 9).
1311        let mut bad = fit_ok(0.0);
1312        bad.nodes[0].feature = 9;
1313        assert!(serialize_validate(&bad).is_err());
1314
1315        // Child index out of range.
1316        let mut bad = fit_ok(0.0);
1317        bad.nodes[0].right = MLFQ_TREE_MAX_NODES as u32;
1318        assert!(serialize_validate(&bad).is_err());
1319
1320        // Child preceding its parent (BFS order violation).
1321        let mut bad = fit_ok(0.0);
1322        bad.nodes[0].left = 0;
1323        assert!(serialize_validate(&bad).is_err());
1324
1325        // A larger fit passes.
1326        let mut samples = Vec::new();
1327        for pid in 0..64u32 {
1328            let sleep = if pid % 2 == 0 { 400_000 } else { 2_500_000 };
1329            let label = if pid % 2 == 0 { 80_000 } else { 12_000_000 };
1330            samples.push(sample(pid, sleep, label));
1331        }
1332        let tree = fit(&samples, 4, 2, 63, DEFAULT_MIN_REL_VAR_REDUCTION);
1333        assert!(serialize_validate(&tree).is_ok());
1334    }
1335
1336    #[test]
1337    fn metrics_on_known_vectors() {
1338        assert_eq!(mae(&[10, 20], &[12, 18]), 2.0);
1339        assert_eq!(mae(&[1_000], &[2_000]), 1_000.0);
1340
1341        // Perfectly correlated vectors give 1.0, anticorrelated -1.0,
1342        // orthogonal ~0.0 (the last with tolerance).
1343        let x = [1, 2, 3, 4];
1344        let y = [10, 20, 30, 40];
1345        assert!((pearson(&x, &y) - 1.0).abs() < 1e-12);
1346        assert!((pearson(&x, &[40, 30, 20, 10]) + 1.0).abs() < 1e-12);
1347        assert!(pearson(&x, &[1, 4, 4, 1]).abs() < 1e-12);
1348
1349        // A constant vector has undefined correlation; the metric stays 0.
1350        assert_eq!(pearson(&x, &[5, 5, 5, 5]), 0.0);
1351    }
1352
1353    #[test]
1354    fn publish_gate_and_tree_meta() {
1355        // The gate requires MAE_tree <= MAE_ema and corr >=0.30, and once
1356        // a model is committed its correlation must be strictly exceeded
1357        // (monotonic ratchet toward 1.0). A regressed MAE or a non-
1358        // improving correlation keeps the previous model.
1359        assert!(should_publish(100.0, 100.0, 0.5, None));
1360        assert!(should_publish(99.0, 100.0, 0.5, None));
1361        assert!(!should_publish(101.0, 100.0, 0.5, None));
1362        assert!(should_publish(0.0, 0.0, 0.5, None));
1363        assert!(!should_publish(90.0, 100.0, 0.29, None));
1364        assert!(should_publish(90.0, 100.0, 0.30, None));
1365        assert!(!should_publish(90.0, 100.0, 0.0, None));
1366        assert!(!should_publish(90.0, 100.0, 0.30, Some(0.30)));
1367        assert!(should_publish(90.0, 100.0, 0.31, Some(0.30)));
1368        assert!(!should_publish(90.0, 100.0, 0.50, Some(0.50)));
1369        assert!(should_publish(90.0, 100.0, 0.52, Some(0.50)));
1370        assert!(should_publish(90.0, 100.0, 0.72, Some(0.52)));
1371        // Weighted holdout: recent samples dominate.
1372        let preds = [100, 200];
1373        let actuals = [110, 190];
1374        let w = [0.25, 1.0];
1375        let wm = weighted_holdout_mae(&preds, &actuals, &w);
1376        assert!((wm - 10.0).abs() < 1e-9);
1377
1378        // The meta bits are exact: trained bit 0 (always set by a
1379        // publish), active bit 1, node count in bits 8..31 and the
1380        // generation in bits 32..63.
1381        assert_eq!(tree_meta(0, 1, 0), (1 << 8) | 1);
1382        assert_eq!(tree_meta(0, 2048, 1), (2048 << 8) | (1 << 1) | 1);
1383        let m = tree_meta(3, 7, 0);
1384        assert_eq!(m, (3u64 << 32) | (7 << 8) | 1);
1385        assert_eq!(
1386            m & crate::bpf_intf::MLFQ_TREE_META_TRAINED as u64,
1387            crate::bpf_intf::MLFQ_TREE_META_TRAINED as u64
1388        );
1389        assert_eq!((m >> 1) & 1, 0);
1390        assert_eq!((m >> 8) & 0xFFFFFF, 7);
1391        assert_eq!(m >> 32, 3);
1392        // The active bit flips the entry the walk reads.
1393        assert_eq!((tree_meta(0, 1, 1) >> 1) & 1, 1);
1394
1395        // The generation is masked to its 32 meta bits before the shift:
1396        // an out-of-range value must not overflow the shift (debug builds
1397        // would panic), and the mask keeps the field semantics exact.
1398        assert_eq!(tree_meta(0x1_0000_0000, 1, 0), (1 << 8) | 1);
1399        assert_eq!(
1400            tree_meta(0xFFFF_FFFF_FFFF_FFFF, 0, 0),
1401            0xFFFF_FFFF_0000_0000 | 1
1402        );
1403        assert_eq!(tree_meta(0x1_FFFF_FFFF, 1, 0), 0xFFFF_FFFF_0000_0101);
1404    }
1405
1406    #[test]
1407    fn predict_depth_exhaustion_yields_zero_on_internal() {
1408        // A chain of MLFQ_TREE_MAX_DEPTH + 1 internal nodes: the walk
1409        // descends the first 12 edges and stops, and the node it lands
1410        // on (depth 12) is internal, so the prediction must be 0. A
1411        // child index must never leak out as a burst.
1412        let mut tree = SerializedTree::default();
1413        for i in 0..=MLFQ_TREE_MAX_DEPTH {
1414            tree.nodes.push(TreeNode {
1415                threshold: 0,
1416                left: (i + 1) as u32,
1417                right: (i + 1) as u32,
1418                feature: 0,
1419                pad: [0; 7],
1420            });
1421        }
1422        assert_eq!(predict(&tree, &TreeFeats::default()), 0);
1423        // Such a tree is deeper than the walk bound: the validator
1424        // rejects the internal node at depth 12.
1425        assert!(serialize_validate(&tree).is_err());
1426
1427        // A chain that ends in a leaf exactly at the bound is valid and
1428        // predicts the leaf.
1429        let mut tree = SerializedTree::default();
1430        for i in 0..MLFQ_TREE_MAX_DEPTH {
1431            tree.nodes.push(TreeNode {
1432                threshold: 0,
1433                left: (i + 1) as u32,
1434                right: (i + 1) as u32,
1435                feature: 0,
1436                pad: [0; 7],
1437            });
1438        }
1439        tree.nodes.push(TreeNode {
1440            threshold: 0,
1441            left: 777_777,
1442            right: 0,
1443            feature: 0,
1444            pad: [0; 7],
1445        });
1446        assert!(serialize_validate(&tree).is_ok());
1447        assert_eq!(predict(&tree, &TreeFeats::default()), 777_777);
1448    }
1449
1450    #[test]
1451    fn serialize_validate_rejects_deep_trees() {
1452        // A leaf at depth 13 needs an internal parent at depth 12, which
1453        // the walk can only ever reach through the exhaustion fallback;
1454        // the validator rejects the internal-at-the-bound shape.
1455        let mut tree = SerializedTree::default();
1456        for i in 0..=MLFQ_TREE_MAX_DEPTH {
1457            tree.nodes.push(TreeNode {
1458                threshold: 0,
1459                left: (i + 1) as u32,
1460                right: (i + 1) as u32,
1461                feature: 0,
1462                pad: [0; 7],
1463            });
1464        }
1465        tree.nodes.push(TreeNode {
1466            threshold: 0,
1467            left: 42,
1468            right: 0,
1469            feature: 0,
1470            pad: [0; 7],
1471        });
1472        assert!(serialize_validate(&tree).is_err());
1473    }
1474
1475    #[test]
1476    fn adversarial_constant_labels_never_split() {
1477        // All labels identical: the SSE is exactly 0, so no split can
1478        // reduce it and the tree must be a single leaf predicting the
1479        // constant.
1480        let samples: Vec<TreeSample> = (0..64u32)
1481            .map(|pid| sample(pid, 1_000_000, 123_456))
1482            .collect();
1483        let tree = fit(&samples, 12, 2, 2048, 0.0);
1484        assert_eq!(tree.nodes.len(), 1, "a constant label set yields one leaf");
1485        assert_eq!(tree.nodes[0].right, 0);
1486        let feats = samples[0].feats;
1487        assert_eq!(predict(&tree, &feats), 123_456);
1488        serialize_validate(&tree).unwrap();
1489    }
1490
1491    #[test]
1492    fn adversarial_max_nodes_exhaustion_mid_growth() {
1493        // Staircase labels force repeated splitting; a node budget of 3
1494        // must stop the growth right after the root split, leaving two
1495        // leaves.
1496        let mut samples = Vec::new();
1497        for (i, sleep) in (100_000u64..700_000).step_by(100_000).enumerate() {
1498            for k in 0..4 {
1499                let mut s = sample(i as u32 + k, sleep, sleep);
1500                s.feats.prev_burst_ns = 1_000;
1501                samples.push(s);
1502            }
1503        }
1504        let tree = fit(&samples, 12, 1, 3, 0.0);
1505        assert_eq!(
1506            tree.nodes.len(),
1507            3,
1508            "max_nodes 3 caps the growth at root + two leaves"
1509        );
1510        assert!(tree.nodes[0].right != 0);
1511        assert_eq!(tree.nodes[1].right, 0);
1512        assert_eq!(tree.nodes[2].right, 0);
1513        serialize_validate(&tree).unwrap();
1514    }
1515
1516    #[test]
1517    fn adversarial_extreme_labels_clamp() {
1518        // Labels at the top of the u64 range: the f64 SSE math must not
1519        // misbehave, and the leaf mean saturates through leaf_prediction
1520        // to u32::MAX (the walk returns the field as-is).
1521        let mut samples = Vec::new();
1522        for k in 0..4 {
1523            let mut a = sample(k, 100_000, u64::MAX);
1524            a.feats.prev_burst_ns = 1_000;
1525            let mut b = sample(k + 4, 200_000, 0);
1526            b.feats.prev_burst_ns = 1_000;
1527            samples.push(a);
1528            samples.push(b);
1529        }
1530        let tree = fit(&samples, 4, 2, 31, DEFAULT_MIN_REL_VAR_REDUCTION);
1531        serialize_validate(&tree).unwrap();
1532        assert_eq!(tree.nodes[0].feature, 1, "the root must split on sleep_ns");
1533        assert_eq!(
1534            predict(
1535                &tree,
1536                &TreeFeats {
1537                    prev_burst_ns: 1_000,
1538                    sleep_ns: 100_000,
1539                    ..TreeFeats::default()
1540                }
1541            ),
1542            u32::MAX as u64,
1543            "the extreme class clamps to the u32 leaf field"
1544        );
1545        assert_eq!(
1546            predict(
1547                &tree,
1548                &TreeFeats {
1549                    prev_burst_ns: 1_000,
1550                    sleep_ns: 200_000,
1551                    ..TreeFeats::default()
1552                }
1553            ),
1554            0
1555        );
1556    }
1557
1558    #[test]
1559    fn adversarial_min_leaf_boundary() {
1560        // n == 2*min_samples_leaf splits (both children reach the leaf
1561        // cap); n == 2*min_samples_leaf - 1 does not, because the left
1562        // group would fall below the cap.
1563        let class_a: Vec<TreeSample> = (0..3u32).map(|pid| sample(pid, 100_000, 100_000)).collect();
1564        let class_b: Vec<TreeSample> = (3..6u32).map(|pid| sample(pid, 200_000, 900_000)).collect();
1565        let mut samples = class_a.clone();
1566        samples.extend(class_b.iter().copied());
1567        let tree = fit(&samples, 4, 3, 31, 0.0);
1568        assert!(tree.nodes[0].right != 0, "n == 2*min_samples_leaf splits");
1569        serialize_validate(&tree).unwrap();
1570
1571        let samples = [class_a[0], class_a[1], class_b[0], class_b[1], class_b[2]];
1572        let tree = fit(&samples, 4, 3, 31, 0.0);
1573        assert_eq!(
1574            tree.nodes.len(),
1575            1,
1576            "n == 2*min_samples_leaf - 1 leaves one child below the cap: no split"
1577        );
1578    }
1579
1580    #[test]
1581    fn golden_tree_predict_matches_shared_spec() {
1582        // The shared crafted tree walked by both the native harness
1583        // (test_tree_golden_shared in mlfq_math_test.c) and this Rust
1584        // mirror, with identical expected outputs: a chain on
1585        // prev_burst_ns, mixed leaves, and a node whose raw feature id
1586        // is out of the populated range but masks in-bounds (0x84 ->
1587        // 4 = wake_cnt). The tree is a walk spec, not a publish spec.
1588        // The raw feature above 4 means serialize_validate rejects it,
1589        // and published trees are validated before the walk sees them.
1590        let nodes = vec![
1591            TreeNode {
1592                threshold: 1_000_000,
1593                left: 1,
1594                right: 8,
1595                feature: 0,
1596                pad: [0; 7],
1597            },
1598            TreeNode {
1599                threshold: 1_000_000,
1600                left: 2,
1601                right: 8,
1602                feature: 0,
1603                pad: [0; 7],
1604            },
1605            TreeNode {
1606                threshold: 1_000_000,
1607                left: 3,
1608                right: 8,
1609                feature: 0,
1610                pad: [0; 7],
1611            },
1612            TreeNode {
1613                threshold: 500_000,
1614                left: 4,
1615                right: 5,
1616                feature: 1,
1617                pad: [0; 7],
1618            },
1619            TreeNode {
1620                threshold: 0,
1621                left: 1_111_111,
1622                right: 0,
1623                feature: 0,
1624                pad: [0; 7],
1625            },
1626            TreeNode {
1627                threshold: 1,
1628                left: 6,
1629                right: 7,
1630                feature: 0x84,
1631                pad: [0; 7],
1632            },
1633            TreeNode {
1634                threshold: 0,
1635                left: 2_222_222,
1636                right: 0,
1637                feature: 0,
1638                pad: [0; 7],
1639            },
1640            TreeNode {
1641                threshold: 0,
1642                left: 3_333_333,
1643                right: 0,
1644                feature: 0,
1645                pad: [0; 7],
1646            },
1647            TreeNode {
1648                threshold: 0,
1649                left: 8_888_888,
1650                right: 0,
1651                feature: 0,
1652                pad: [0; 7],
1653            },
1654        ];
1655        let tree = SerializedTree { nodes };
1656
1657        let f = |prev: u64, sleep: u64, wake: u32| TreeFeats {
1658            prev_burst_ns: prev,
1659            sleep_ns: sleep,
1660            wake_cnt: wake,
1661            ..TreeFeats::default()
1662        };
1663        assert_eq!(predict(&tree, &f(0, 400_000, 0)), 1_111_111);
1664        assert_eq!(predict(&tree, &f(0, 600_000, 0)), 2_222_222);
1665        assert_eq!(predict(&tree, &f(0, 600_000, 5)), 3_333_333);
1666        assert_eq!(predict(&tree, &f(2_000_000, 0, 0)), 8_888_888);
1667        // wake_cnt is irrelevant on the sleep split's left branch.
1668        assert_eq!(predict(&tree, &f(0, 400_000, 9)), 1_111_111);
1669    }
1670
1671    #[test]
1672    fn abi_layout_matches_bpf_structs() {
1673        // The Rust structs are the byte-for-byte mirrors of the BPF
1674        // types in src/bpf/intf.h (via the bindgen-generated bpf_intf):
1675        // the ring-buffer records are parsed straight into TreeSample
1676        // and the serialized nodes are written straight into the store
1677        // map entry, so the sizes and offsets must match exactly.
1678        use crate::bpf_intf::{mlfq_tree_feats, mlfq_tree_node, mlfq_tree_sample};
1679        use std::mem::{offset_of, size_of};
1680
1681        assert_eq!(size_of::<TreeFeats>(), size_of::<mlfq_tree_feats>());
1682        assert_eq!(size_of::<TreeNode>(), size_of::<mlfq_tree_node>());
1683        assert_eq!(size_of::<TreeSample>(), size_of::<mlfq_tree_sample>());
1684        assert_eq!(size_of::<TreeFeats>(), 64);
1685        assert_eq!(size_of::<TreeSample>(), 84);
1686        assert_eq!(size_of::<TreeNode>(), 24);
1687
1688        assert_eq!(
1689            offset_of!(TreeFeats, prev_burst_ns),
1690            offset_of!(mlfq_tree_feats, prev_burst_ns)
1691        );
1692        assert_eq!(
1693            offset_of!(TreeFeats, sleep_ns),
1694            offset_of!(mlfq_tree_feats, sleep_ns)
1695        );
1696        assert_eq!(offset_of!(TreeFeats, ema), offset_of!(mlfq_tree_feats, ema));
1697        assert_eq!(
1698            offset_of!(TreeFeats, io_wait),
1699            offset_of!(mlfq_tree_feats, io_wait)
1700        );
1701        assert_eq!(
1702            offset_of!(TreeFeats, wake_cnt),
1703            offset_of!(mlfq_tree_feats, wake_cnt)
1704        );
1705        assert_eq!(
1706            offset_of!(TreeFeats, wake_lat_us),
1707            offset_of!(mlfq_tree_feats, wake_lat_us)
1708        );
1709        assert_eq!(
1710            offset_of!(TreeFeats, queue_wait_us),
1711            offset_of!(mlfq_tree_feats, queue_wait_us)
1712        );
1713        assert_eq!(
1714            offset_of!(TreeFeats, sq_ema),
1715            offset_of!(mlfq_tree_feats, sq_ema)
1716        );
1717        assert_eq!(
1718            offset_of!(TreeFeats, sleep_var_ratio),
1719            offset_of!(mlfq_tree_feats, sleep_var_ratio)
1720        );
1721        assert_eq!(
1722            offset_of!(TreeFeats, gpu_submit),
1723            offset_of!(mlfq_tree_feats, gpu_submit)
1724        );
1725        assert_eq!(offset_of!(TreeFeats, wake_lat_us), 32);
1726        assert_eq!(offset_of!(TreeFeats, queue_wait_us), 36);
1727        assert_eq!(offset_of!(TreeFeats, sq_ema), 40);
1728        assert_eq!(offset_of!(TreeFeats, sleep_var_ratio), 48);
1729        assert_eq!(offset_of!(TreeFeats, pad), 52);
1730        assert_eq!(offset_of!(TreeFeats, gpu_submit), 56);
1731        assert_eq!(offset_of!(TreeFeats, pad2), 60);
1732
1733        assert_eq!(
1734            offset_of!(TreeNode, threshold),
1735            offset_of!(mlfq_tree_node, threshold)
1736        );
1737        assert_eq!(offset_of!(TreeNode, left), offset_of!(mlfq_tree_node, left));
1738        assert_eq!(
1739            offset_of!(TreeNode, right),
1740            offset_of!(mlfq_tree_node, right)
1741        );
1742        assert_eq!(
1743            offset_of!(TreeNode, feature),
1744            offset_of!(mlfq_tree_node, feature)
1745        );
1746        assert_eq!(offset_of!(TreeNode, feature), 16);
1747        assert_eq!(offset_of!(TreeNode, pad), offset_of!(mlfq_tree_node, pad));
1748        assert_eq!(offset_of!(TreeNode, pad), 17);
1749
1750        assert_eq!(
1751            offset_of!(TreeSample, pid),
1752            offset_of!(mlfq_tree_sample, pid)
1753        );
1754        assert_eq!(
1755            offset_of!(TreeSample, queue),
1756            offset_of!(mlfq_tree_sample, queue)
1757        );
1758        assert_eq!(
1759            offset_of!(TreeSample, feats),
1760            offset_of!(mlfq_tree_sample, feats)
1761        );
1762        assert_eq!(
1763            offset_of!(TreeSample, label_ns),
1764            offset_of!(mlfq_tree_sample, label_ns)
1765        );
1766        assert_eq!(
1767            offset_of!(TreeSample, version),
1768            offset_of!(mlfq_tree_sample, version)
1769        );
1770        assert_eq!(offset_of!(TreeSample, queue), 4);
1771        assert_eq!(offset_of!(TreeSample, feats), 8);
1772        assert_eq!(offset_of!(TreeSample, label_ns), 72);
1773        assert_eq!(offset_of!(TreeSample, version), 80);
1774    }
1775
1776    #[test]
1777    fn sample_version_tag_is_checked() {
1778        // The version tag distinguishes a current-format record from a
1779        // foreign or stale one: the parse check accepts exactly the
1780        // compiled-in version and nothing else.
1781        let cur = TreeSample {
1782            pid: 1,
1783            version: MLFQ_TREE_SAMPLE_VERSION,
1784            queue: 2,
1785            feats: TreeFeats::default(),
1786            label_ns: 0,
1787        };
1788        assert!(sample_version_matches(&cur));
1789
1790        let stale = TreeSample { version: 0, ..cur };
1791        assert!(!sample_version_matches(&stale));
1792        let future = TreeSample {
1793            version: MLFQ_TREE_SAMPLE_VERSION + 1,
1794            ..cur
1795        };
1796        assert!(!sample_version_matches(&future));
1797    }
1798
1799    #[test]
1800    fn recency_weights_pin_the_ends() {
1801        // The recency weighting formula: the newest sample weighs ~1 and
1802        // the oldest exactly 0.25, so the admitted window cannot
1803        // underflow and the recent regime dominates the fit.
1804        let n = 8;
1805        let half_life = n as f64 / 2.0;
1806        let w_newest = 2.0f64.powf(-1.0 / half_life);
1807        let w_oldest = 2.0f64.powf(-(n as f64) / half_life);
1808        assert!((w_oldest - 0.25).abs() < 1e-15);
1809        assert!((w_newest - 1.0).abs() < 0.2);
1810
1811        let ws = sample_weights(n);
1812        assert_eq!(ws.len(), n);
1813        assert!((ws[0] - w_oldest).abs() < 1e-15);
1814        assert!((ws[n - 1] - w_newest).abs() < 1e-15);
1815        // Strictly increasing toward the newest.
1816        for i in 0..n - 1 {
1817            assert!(ws[i] < ws[i + 1]);
1818        }
1819    }
1820}