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    /// `metric == "mse_unit"`: φ is unit-normalized before the residual
29    /// (the Embryo trainer's descriptors); `"mse"` uses φ as is.
30    unit: bool,
31}
32
33fn decode_f16(b64: &str) -> Option<Vec<f32>> {
34    let bytes = base64::engine::general_purpose::STANDARD.decode(b64).ok()?;
35    Some(
36        bytes
37            .chunks_exact(2)
38            .map(|c| f16_to_f32(u16::from_le_bytes([c[0], c[1]])))
39            .collect(),
40    )
41}
42
43impl RoutableSkill {
44    pub fn from_descriptor(
45        idx: usize,
46        id: String,
47        sel: &SelectionDescriptor,
48        hidden: usize,
49    ) -> Option<Self> {
50        let unit = match sel.metric.as_str() {
51            "mse" => false,
52            "mse_unit" => true,
53            _ => return None,
54        };
55        let mean = decode_f16(&sel.mean)?;
56        let basis = decode_f16(&sel.basis)?;
57        if mean.len() != hidden || basis.len() != sel.rank * hidden {
58            return None;
59        }
60        Some(Self {
61            idx,
62            id,
63            phi_layer: sel.phi_layer,
64            mean,
65            basis,
66            rank: sel.rank,
67            unit,
68        })
69    }
70
71    /// Normalized reconstruction error E = ‖r − BBᵀr‖²/‖φ‖²,
72    /// r = φ − mean (identical math to router::route); for `mse_unit`
73    /// φ is unit-normalized first (then ‖φ‖² = 1).
74    pub fn error(&self, phi: &[f32]) -> f32 {
75        let hidden = self.mean.len();
76        if phi.len() != hidden {
77            return f32::INFINITY;
78        }
79        let norm2: f32 = phi.iter().map(|v| v * v).sum();
80        let scale = if self.unit && norm2 > 0.0 {
81            1.0 / norm2.sqrt()
82        } else {
83            1.0
84        };
85        let r: Vec<f32> = phi
86            .iter()
87            .zip(&self.mean)
88            .map(|(p, m)| p * scale - m)
89            .collect();
90        let rr: f32 = r.iter().map(|v| v * v).sum();
91        let pp: f32 = norm2 * scale * scale;
92        let mut proj = 0f32;
93        for k in 0..self.rank {
94            let row = &self.basis[k * hidden..(k + 1) * hidden];
95            let c: f32 = row.iter().zip(&r).map(|(b, v)| b * v).sum();
96            proj += c * c;
97        }
98        (rr - proj).max(0.0) / pp.max(1e-12)
99    }
100}
101
102/// Hysteresis controller for dynamic routing.
103pub struct DynRouter {
104    pub skills: Vec<RoutableSkill>,
105    /// Nucleation threshold: activate below this E.
106    pub e_on: f32,
107    /// Abandon threshold: drop the active skill above this E (> e_on).
108    pub e_off: f32,
109    /// A rival must beat the active skill by this margin to steal it.
110    pub margin: f32,
111    /// Re-route every `period` tokens (dispatch amortization; 1 = every).
112    pub period: usize,
113    /// Currently active skill index (model.header.skills), None = base.
114    active: Option<usize>,
115    tick: usize,
116    /// Switch log for demo/telemetry: (token#, from_id, to_id).
117    pub switches: Vec<(usize, Option<String>, Option<String>)>,
118    /// Min recon error E at the last evaluation tick (telemetry): low E =
119    /// high coherence with a skill subspace. INFINITY before any eval.
120    last_best_e: f32,
121}
122
123impl DynRouter {
124    pub fn new(skills: Vec<RoutableSkill>) -> Self {
125        let e_on = std::env::var("CMF_ROUTE_EON")
126            .ok()
127            .and_then(|v| v.parse().ok())
128            .unwrap_or(0.62);
129        let e_off = std::env::var("CMF_ROUTE_EOFF")
130            .ok()
131            .and_then(|v| v.parse().ok())
132            .unwrap_or(0.74);
133        let margin = std::env::var("CMF_ROUTE_MARGIN")
134            .ok()
135            .and_then(|v| v.parse().ok())
136            .unwrap_or(0.03);
137        let period = std::env::var("CMF_ROUTE_PERIOD")
138            .ok()
139            .and_then(|v| v.parse().ok())
140            .unwrap_or(8usize)
141            .max(1);
142        Self {
143            skills,
144            e_on,
145            e_off,
146            margin,
147            period,
148            active: None,
149            tick: 0,
150            switches: Vec::new(),
151            last_best_e: f32::INFINITY,
152        }
153    }
154
155    /// The single phi_layer to capture (skills share it in the swarm;
156    /// if they differ, the first is used and a warning is the caller's).
157    pub fn phi_layer(&self) -> Option<usize> {
158        self.skills.first().map(|s| s.phi_layer)
159    }
160
161    /// Decide the active skill for the next window given the current φ.
162    /// Returns Some(new_active) when a switch is warranted (caller calls
163    /// pipeline.set_active_skill), else None (unchanged). `token_no` is
164    /// only for the switch log.
165    pub fn step(&mut self, phi: &[f32], token_no: usize) -> Option<Option<usize>> {
166        self.tick += 1;
167        if self.tick % self.period != 0 || phi.is_empty() || self.skills.is_empty() {
168            return None;
169        }
170        // Score all skills.
171        let mut best_idx = None;
172        let mut best_e = f32::INFINITY;
173        let mut active_e = f32::INFINITY;
174        for s in &self.skills {
175            let e = s.error(phi);
176            if Some(s.idx) == self.active {
177                active_e = e;
178            }
179            if e < best_e {
180                best_e = e;
181                best_idx = Some(s.idx);
182            }
183        }
184
185        self.last_best_e = best_e; // telemetry: coherence at this eval
186
187        let next = decide(
188            self.active,
189            active_e,
190            best_idx,
191            best_e,
192            self.e_on,
193            self.e_off,
194            self.margin,
195        );
196
197        if next != self.active {
198            let from = self
199                .active
200                .and_then(|i| self.skills.iter().find(|s| s.idx == i))
201                .map(|s| s.id.clone());
202            let to = next
203                .and_then(|i| self.skills.iter().find(|s| s.idx == i))
204                .map(|s| s.id.clone());
205            self.switches.push((token_no, from, to));
206            self.active = next;
207            return Some(next);
208        }
209        None
210    }
211
212    pub fn active(&self) -> Option<usize> {
213        self.active
214    }
215
216    /// Id of the currently active skill (telemetry), None = backbone.
217    pub fn active_id(&self) -> Option<String> {
218        self.active
219            .and_then(|i| self.skills.iter().find(|s| s.idx == i))
220            .map(|s| s.id.clone())
221    }
222
223    /// Min recon error E at the last evaluation (telemetry coherence).
224    pub fn last_best_e(&self) -> f32 {
225        self.last_best_e
226    }
227
228    /// Reset per-generation state (active=backbone, empty log, tick 0) so
229    /// the router matches a freshly-reset pipeline overlay.
230    pub fn reset(&mut self) {
231        self.active = None;
232        self.tick = 0;
233        self.switches.clear();
234        self.last_best_e = f32::INFINITY;
235    }
236}
237
238/// Pure hysteresis decision (first-order transition analogue): given the
239/// current active skill, its error, and the best rival, return the next
240/// active. Two thresholds e_on < e_off open the anti-thrash barrier.
241#[allow(clippy::too_many_arguments)]
242pub fn decide(
243    active: Option<usize>,
244    active_e: f32,
245    best_idx: Option<usize>,
246    best_e: f32,
247    e_on: f32,
248    e_off: f32,
249    margin: f32,
250) -> Option<usize> {
251    match active {
252        // Nucleation: activate the best only if it clears e_on.
253        None => {
254            if best_e < e_on {
255                best_idx
256            } else {
257                None
258            }
259        }
260        Some(cur) => {
261            if active_e > e_off {
262                // Melted: re-nucleate, else fall back to backbone.
263                if best_e < e_on { best_idx } else { None }
264            } else if best_idx != Some(cur) && best_e + margin < active_e {
265                // Rival decisively better while active still holds.
266                best_idx
267            } else {
268                Some(cur)
269            }
270        }
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::{RoutableSkill, decide};
277    use base64::Engine as _;
278
279    fn f16_b64(v: &[f32]) -> String {
280        let bytes: Vec<u8> = v
281            .iter()
282            .flat_map(|x| cortiq_core::quant::f32_to_f16(*x).to_le_bytes())
283            .collect();
284        base64::engine::general_purpose::STANDARD.encode(bytes)
285    }
286
287    /// The Embryo trainer writes `mse_unit` descriptors: the dynamic router
288    /// must accept them (they used to drop out silently) and measure E on
289    /// the unit-normalized φ — scale-invariant, unlike `mse`.
290    #[test]
291    fn mse_unit_descriptors_are_routable_and_scale_invariant() {
292        let hidden = 4;
293        let mut mean = vec![0.0f32; hidden];
294        mean[0] = 1.0;
295        let mut basis = vec![0.0f32; hidden];
296        basis[1] = 1.0;
297        let sel = |metric: &str| cortiq_core::SelectionDescriptor {
298            metric: metric.into(),
299            phi_layer: 2,
300            mean: f16_b64(&mean),
301            basis: f16_b64(&basis),
302            rank: 1,
303            err_mean: None,
304            err_std: None,
305            holdout: None,
306            holdout_n: None,
307        };
308        let unit = RoutableSkill::from_descriptor(0, "u".into(), &sel("mse_unit"), hidden)
309            .expect("mse_unit accepted");
310        let raw = RoutableSkill::from_descriptor(1, "r".into(), &sel("mse"), hidden)
311            .expect("mse accepted");
312        assert!(RoutableSkill::from_descriptor(2, "x".into(), &sel("cosine"), hidden).is_none());
313        // φ along the mean at any scale: zero error under mse_unit.
314        for s in [0.5f32, 1.0, 7.0] {
315            let phi = vec![s, 0.0, 0.0, 0.0];
316            assert!(unit.error(&phi) < 1e-6, "scale {s}: {}", unit.error(&phi));
317        }
318        // mse on an unnormalized φ is scale-dependent.
319        assert!(raw.error(&[7.0, 0.0, 0.0, 0.0]) > 0.5);
320        // The basis direction is projected out; the orthogonal one is not.
321        assert!(unit.error(&[3.0, 1.0, 0.0, 0.0]) < 0.01);
322        assert!(unit.error(&[0.0, 0.0, 1.0, 0.0]) > 0.9);
323    }
324
325    #[test]
326    fn hysteresis_barrier_suppresses_thrashing() {
327        let (e_on, e_off, m) = (0.60, 0.75, 0.03);
328
329        // From backbone: does NOT activate in the barrier band [e_on,e_off).
330        assert_eq!(
331            decide(None, f32::INFINITY, Some(0), 0.70, e_on, e_off, m),
332            None
333        );
334        // From backbone: activates below e_on (nucleation).
335        assert_eq!(
336            decide(None, f32::INFINITY, Some(0), 0.55, e_on, e_off, m),
337            Some(0)
338        );
339
340        // Active skill 0 at E=0.70 (in the band) STAYS — this is the whole
341        // point: a single threshold at 0.62 would have flip-flopped here.
342        assert_eq!(
343            decide(Some(0), 0.70, Some(1), 0.68, e_on, e_off, m),
344            Some(0)
345        );
346        // Active melts above e_off → re-nucleate to the qualifying rival.
347        assert_eq!(
348            decide(Some(0), 0.80, Some(1), 0.55, e_on, e_off, m),
349            Some(1)
350        );
351        // Active melts but no rival clears e_on → back to backbone.
352        assert_eq!(decide(Some(0), 0.80, Some(1), 0.70, e_on, e_off, m), None);
353        // Rival must beat active by `margin`, not merely be lower.
354        assert_eq!(
355            decide(Some(0), 0.70, Some(1), 0.69, e_on, e_off, m),
356            Some(0)
357        );
358        assert_eq!(
359            decide(Some(0), 0.70, Some(1), 0.66, e_on, e_off, m),
360            Some(1)
361        );
362    }
363}