Skip to main content

cortiq_engine/
swarm.rs

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