Skip to main content

cortiq_engine/
swarm.rs

1//! Dynamic per-token skill routing with hysteresis (spec §9 runtime
2//! implementation).
3//!
4//! The recon-argmin error E(skill) is computed against the rolling φ
5//! (EMA of the router layer's hidden state, updated on-policy).
6//! Switching uses TWO thresholds, which create a dead band between the
7//! "off" (φ far from any skill) and "on" (φ inside a skill's subspace) states:
8//!   - activate a skill only when its E drops below `e_on`;
9//!   - abandon the active skill only when its E rises above `e_off`
10//!     (> e_on), or a rival beats it by more than `margin`.
11//!
12//! The gap `e_off − e_on` suppresses thrashing at domain boundaries (the
13//! very effect a single threshold cannot give).
14
15use base64::Engine as _;
16use cortiq_core::SelectionDescriptor;
17use cortiq_core::quant::f16_to_f32;
18
19/// One routable skill's precomputed subspace (decoded once).
20pub struct RoutableSkill {
21    /// Index into model.header.skills (pipeline.set_active_skill).
22    pub idx: usize,
23    pub id: String,
24    pub phi_layer: usize,
25    mean: Vec<f32>,
26    basis: Vec<f32>,
27    rank: usize,
28}
29
30fn decode_f16(b64: &str) -> Option<Vec<f32>> {
31    let bytes = base64::engine::general_purpose::STANDARD.decode(b64).ok()?;
32    Some(
33        bytes
34            .chunks_exact(2)
35            .map(|c| f16_to_f32(u16::from_le_bytes([c[0], c[1]])))
36            .collect(),
37    )
38}
39
40impl RoutableSkill {
41    pub fn from_descriptor(
42        idx: usize,
43        id: String,
44        sel: &SelectionDescriptor,
45        hidden: usize,
46    ) -> Option<Self> {
47        if sel.metric != "mse" {
48            return None;
49        }
50        let mean = decode_f16(&sel.mean)?;
51        let basis = decode_f16(&sel.basis)?;
52        if mean.len() != hidden || basis.len() != sel.rank * hidden {
53            return None;
54        }
55        Some(Self {
56            idx,
57            id,
58            phi_layer: sel.phi_layer,
59            mean,
60            basis,
61            rank: sel.rank,
62        })
63    }
64
65    /// Normalized reconstruction error E = ‖r − BBᵀr‖²/‖φ‖²,
66    /// r = φ − mean (identical math to router::route).
67    pub fn error(&self, phi: &[f32]) -> f32 {
68        let hidden = self.mean.len();
69        if phi.len() != hidden {
70            return f32::INFINITY;
71        }
72        let r: Vec<f32> = phi.iter().zip(&self.mean).map(|(p, m)| p - m).collect();
73        let rr: f32 = r.iter().map(|v| v * v).sum();
74        let pp: f32 = phi.iter().map(|v| v * v).sum();
75        let mut proj = 0f32;
76        for k in 0..self.rank {
77            let row = &self.basis[k * hidden..(k + 1) * hidden];
78            let c: f32 = row.iter().zip(&r).map(|(b, v)| b * v).sum();
79            proj += c * c;
80        }
81        (rr - proj).max(0.0) / pp.max(1e-12)
82    }
83}
84
85/// Hysteresis controller for dynamic routing.
86pub struct DynRouter {
87    pub skills: Vec<RoutableSkill>,
88    /// Nucleation threshold: activate below this E.
89    pub e_on: f32,
90    /// Abandon threshold: drop the active skill above this E (> e_on).
91    pub e_off: f32,
92    /// A rival must beat the active skill by this margin to steal it.
93    pub margin: f32,
94    /// Re-route every `period` tokens (dispatch amortization; 1 = every).
95    pub period: usize,
96    /// Currently active skill index (model.header.skills), None = base.
97    active: Option<usize>,
98    tick: usize,
99    /// Switch log for demo/telemetry: (token#, from_id, to_id).
100    pub switches: Vec<(usize, Option<String>, Option<String>)>,
101    /// Min recon error E at the last evaluation tick (telemetry): low E =
102    /// high coherence with a skill subspace. INFINITY before any eval.
103    last_best_e: f32,
104}
105
106impl DynRouter {
107    pub fn new(skills: Vec<RoutableSkill>) -> Self {
108        let e_on = std::env::var("CMF_ROUTE_EON")
109            .ok()
110            .and_then(|v| v.parse().ok())
111            .unwrap_or(0.62);
112        let e_off = std::env::var("CMF_ROUTE_EOFF")
113            .ok()
114            .and_then(|v| v.parse().ok())
115            .unwrap_or(0.74);
116        let margin = std::env::var("CMF_ROUTE_MARGIN")
117            .ok()
118            .and_then(|v| v.parse().ok())
119            .unwrap_or(0.03);
120        let period = std::env::var("CMF_ROUTE_PERIOD")
121            .ok()
122            .and_then(|v| v.parse().ok())
123            .unwrap_or(8usize)
124            .max(1);
125        Self {
126            skills,
127            e_on,
128            e_off,
129            margin,
130            period,
131            active: None,
132            tick: 0,
133            switches: Vec::new(),
134            last_best_e: f32::INFINITY,
135        }
136    }
137
138    /// The single phi_layer to capture (skills share it in the swarm;
139    /// if they differ, the first is used and a warning is the caller's).
140    pub fn phi_layer(&self) -> Option<usize> {
141        self.skills.first().map(|s| s.phi_layer)
142    }
143
144    /// Decide the active skill for the next window given the current φ.
145    /// Returns Some(new_active) when a switch is warranted (caller calls
146    /// pipeline.set_active_skill), else None (unchanged). `token_no` is
147    /// only for the switch log.
148    pub fn step(&mut self, phi: &[f32], token_no: usize) -> Option<Option<usize>> {
149        self.tick += 1;
150        if self.tick % self.period != 0 || phi.is_empty() || self.skills.is_empty() {
151            return None;
152        }
153        // Score all skills.
154        let mut best_idx = None;
155        let mut best_e = f32::INFINITY;
156        let mut active_e = f32::INFINITY;
157        for s in &self.skills {
158            let e = s.error(phi);
159            if Some(s.idx) == self.active {
160                active_e = e;
161            }
162            if e < best_e {
163                best_e = e;
164                best_idx = Some(s.idx);
165            }
166        }
167
168        self.last_best_e = best_e; // telemetry: coherence at this eval
169
170        let next = decide(
171            self.active,
172            active_e,
173            best_idx,
174            best_e,
175            self.e_on,
176            self.e_off,
177            self.margin,
178        );
179
180        if next != self.active {
181            let from = self
182                .active
183                .and_then(|i| self.skills.iter().find(|s| s.idx == i))
184                .map(|s| s.id.clone());
185            let to = next
186                .and_then(|i| self.skills.iter().find(|s| s.idx == i))
187                .map(|s| s.id.clone());
188            self.switches.push((token_no, from, to));
189            self.active = next;
190            return Some(next);
191        }
192        None
193    }
194
195    pub fn active(&self) -> Option<usize> {
196        self.active
197    }
198
199    /// Id of the currently active skill (telemetry), None = backbone.
200    pub fn active_id(&self) -> Option<String> {
201        self.active
202            .and_then(|i| self.skills.iter().find(|s| s.idx == i))
203            .map(|s| s.id.clone())
204    }
205
206    /// Min recon error E at the last evaluation (telemetry coherence).
207    pub fn last_best_e(&self) -> f32 {
208        self.last_best_e
209    }
210
211    /// Reset per-generation state (active=backbone, empty log, tick 0) so
212    /// the router matches a freshly-reset pipeline overlay.
213    pub fn reset(&mut self) {
214        self.active = None;
215        self.tick = 0;
216        self.switches.clear();
217        self.last_best_e = f32::INFINITY;
218    }
219}
220
221/// Pure hysteresis decision (first-order transition analogue): given the
222/// current active skill, its error, and the best rival, return the next
223/// active. Two thresholds e_on < e_off open the anti-thrash barrier.
224#[allow(clippy::too_many_arguments)]
225pub fn decide(
226    active: Option<usize>,
227    active_e: f32,
228    best_idx: Option<usize>,
229    best_e: f32,
230    e_on: f32,
231    e_off: f32,
232    margin: f32,
233) -> Option<usize> {
234    match active {
235        // Nucleation: activate the best only if it clears e_on.
236        None => {
237            if best_e < e_on {
238                best_idx
239            } else {
240                None
241            }
242        }
243        Some(cur) => {
244            if active_e > e_off {
245                // Melted: re-nucleate, else fall back to backbone.
246                if best_e < e_on { best_idx } else { None }
247            } else if best_idx != Some(cur) && best_e + margin < active_e {
248                // Rival decisively better while active still holds.
249                best_idx
250            } else {
251                Some(cur)
252            }
253        }
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::decide;
260
261    #[test]
262    fn hysteresis_barrier_suppresses_thrashing() {
263        let (e_on, e_off, m) = (0.60, 0.75, 0.03);
264
265        // From backbone: does NOT activate in the barrier band [e_on,e_off).
266        assert_eq!(
267            decide(None, f32::INFINITY, Some(0), 0.70, e_on, e_off, m),
268            None
269        );
270        // From backbone: activates below e_on (nucleation).
271        assert_eq!(
272            decide(None, f32::INFINITY, Some(0), 0.55, e_on, e_off, m),
273            Some(0)
274        );
275
276        // Active skill 0 at E=0.70 (in the band) STAYS — this is the whole
277        // point: a single threshold at 0.62 would have flip-flopped here.
278        assert_eq!(
279            decide(Some(0), 0.70, Some(1), 0.68, e_on, e_off, m),
280            Some(0)
281        );
282        // Active melts above e_off → re-nucleate to the qualifying rival.
283        assert_eq!(
284            decide(Some(0), 0.80, Some(1), 0.55, e_on, e_off, m),
285            Some(1)
286        );
287        // Active melts but no rival clears e_on → back to backbone.
288        assert_eq!(decide(Some(0), 0.80, Some(1), 0.70, e_on, e_off, m), None);
289        // Rival must beat active by `margin`, not merely be lower.
290        assert_eq!(
291            decide(Some(0), 0.70, Some(1), 0.69, e_on, e_off, m),
292            Some(0)
293        );
294        assert_eq!(
295            decide(Some(0), 0.70, Some(1), 0.66, e_on, e_off, m),
296            Some(1)
297        );
298    }
299}