Skip to main content

datacortex_core/mixer/
meta_mixer.rs

1//! MetaMixer — adaptive blending of CM and GRU bit-level predictions.
2//!
3//! Blends P_cm and P_gru into P_final using an adaptive interpolation table.
4//! The table is indexed by (quantized P_cm, quantized P_gru) and learns the
5//! optimal blend from the data stream.
6//!
7//! This is a 2D APM (Adaptive Probability Map), same design as the neural
8//! crate's MetaMixer but lives in core so it works without the neural feature.
9
10/// Number of quantization bins for each input probability.
11/// 64 bins gives reasonable resolution: 4096/64 = 64 values per bin.
12const N_BINS: usize = 64;
13
14/// Total table size: N_BINS * N_BINS = 4096 entries.
15const TABLE_SIZE: usize = N_BINS * N_BINS;
16
17/// MetaMixer blends two 12-bit probability sources.
18pub struct MetaMixer {
19    /// 2D interpolation table: [cm_bin][gru_bin] -> blended 12-bit probability.
20    /// Initialized to weighted average, then adapts.
21    table: Vec<u32>,
22    /// Fixed blend weight for the GRU signal (0-256).
23    /// 128 = 50/50, 13 = ~5% GRU (conservative start).
24    gru_weight: u32,
25    /// Last table index used (for update).
26    last_idx: usize,
27    /// Last output probability (for update error calculation).
28    last_p: u32,
29    /// Learning rate shift (higher = slower learning). 5 = 1/32nd toward target.
30    lr_shift: u32,
31}
32
33impl MetaMixer {
34    /// Create a new MetaMixer with a given GRU weight percentage (0-100).
35    /// Start conservative at 5% since GRU needs warmup time.
36    pub fn new(gru_weight_pct: u32) -> Self {
37        let mut table = vec![0u32; TABLE_SIZE];
38        for cm_bin in 0..N_BINS {
39            for gru_bin in 0..N_BINS {
40                let cm_center =
41                    (cm_bin as u32 * 4095 + (N_BINS as u32 - 1) / 2) / (N_BINS as u32 - 1);
42                let gru_center =
43                    (gru_bin as u32 * 4095 + (N_BINS as u32 - 1) / 2) / (N_BINS as u32 - 1);
44                // Weighted average to start, biased toward CM.
45                let w = (gru_weight_pct * 256 / 100).min(256);
46                let avg =
47                    (cm_center as u64 * (256 - w) as u64 + gru_center as u64 * w as u64) / 256;
48                table[cm_bin * N_BINS + gru_bin] = (avg as u32).clamp(1, 4095);
49            }
50        }
51
52        MetaMixer {
53            table,
54            gru_weight: (gru_weight_pct * 256 / 100).min(256),
55            last_idx: 0,
56            last_p: 2048,
57            lr_shift: 5,
58        }
59    }
60
61    /// Blend CM and GRU predictions.
62    ///
63    /// `p_cm`: CM engine 12-bit probability [1, 4095].
64    /// `p_gru`: GRU 12-bit probability [1, 4095].
65    ///
66    /// Returns: blended 12-bit probability [1, 4095].
67    #[inline(always)]
68    pub fn blend(&mut self, p_cm: u32, p_gru: u32) -> u32 {
69        let cm_bin = ((p_cm.min(4095) as u64 * (N_BINS as u64 - 1)) / 4095) as usize;
70        let gru_bin = ((p_gru.min(4095) as u64 * (N_BINS as u64 - 1)) / 4095) as usize;
71        let idx = cm_bin.min(N_BINS - 1) * N_BINS + gru_bin.min(N_BINS - 1);
72        self.last_idx = idx;
73
74        let table_p = self.table[idx];
75
76        // Direct weighted average as fallback/blend.
77        let direct = (p_cm as u64 * (256 - self.gru_weight) as u64
78            + p_gru as u64 * self.gru_weight as u64)
79            / 256;
80
81        // Blend table output with direct average (50/50).
82        let blended = (table_p as u64 + direct) / 2;
83        self.last_p = (blended as u32).clamp(1, 4095);
84        self.last_p
85    }
86
87    /// Update after observing a bit. Must be called after blend().
88    #[inline(always)]
89    pub fn update(&mut self, bit: u8) {
90        let target = if bit != 0 { 4095u32 } else { 1u32 };
91        let old = self.table[self.last_idx];
92        let delta = (target as i32 - old as i32) >> self.lr_shift;
93        self.table[self.last_idx] = (old as i32 + delta).clamp(1, 4095) as u32;
94    }
95
96    /// Get the last output probability (for diagnostics).
97    pub fn last_prediction(&self) -> u32 {
98        self.last_p
99    }
100}
101
102impl Default for MetaMixer {
103    fn default() -> Self {
104        Self::new(5) // Conservative: 5% GRU weight
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn initial_blend_biased_to_cm() {
114        let mut mixer = MetaMixer::new(5);
115        // When both inputs are 2048, output should be near 2048.
116        let p = mixer.blend(2048, 2048);
117        assert!(
118            (1900..=2200).contains(&p),
119            "equal inputs should give ~2048, got {p}"
120        );
121    }
122
123    #[test]
124    fn blend_always_in_range() {
125        let mut mixer = MetaMixer::new(5);
126        for cm in [1u32, 100, 1000, 2048, 3000, 4000, 4095] {
127            for gru in [1u32, 100, 1000, 2048, 3000, 4000, 4095] {
128                let p = mixer.blend(cm, gru);
129                assert!(
130                    (1..=4095).contains(&p),
131                    "out of range: cm={cm}, gru={gru}, got {p}"
132                );
133            }
134        }
135    }
136
137    #[test]
138    fn cm_dominates_at_low_weight() {
139        let mut mixer = MetaMixer::new(5); // 5% GRU
140        // CM says high, GRU says low.
141        let p = mixer.blend(3500, 500);
142        // Should be much closer to CM.
143        assert!(p > 2500, "5% GRU should let CM dominate: got {p}");
144    }
145
146    #[test]
147    fn update_adapts() {
148        let mut mixer = MetaMixer::new(5);
149        for _ in 0..200 {
150            mixer.blend(2048, 2048);
151            mixer.update(1);
152        }
153        let p = mixer.blend(2048, 2048);
154        assert!(p > 2048, "after many 1s, should predict higher: {p}");
155    }
156}