Skip to main content

ruvector_domain_expansion/
meta_learning.rs

1//! Meta-Learning Improvements for AGI Learning Architecture
2//!
3//! Five composable enhancements that layer on top of the existing
4//! Thompson Sampling + Population Search + Cost Curve pipeline:
5//!
6//! 1. **RegretTracker**: Measures optimality gap — cumulative difference
7//!    between chosen arms and the best-known arm. You can't improve
8//!    what you don't measure.
9//!
10//! 2. **DecayingBeta**: Beta distribution with exponential forgetting.
11//!    Old evidence decays so the system adapts to non-stationary
12//!    environments instead of calcifying on stale data.
13//!
14//! 3. **PlateauDetector**: Detects when learning has stalled by comparing
15//!    recent accuracy windows. Triggers strategy changes: more exploration,
16//!    cross-domain transfer, or population diversity injection.
17//!
18//! 4. **ParetoFront**: Multi-objective optimization tracking. Instead of
19//!    collapsing accuracy/cost/robustness into one scalar, tracks the full
20//!    Pareto front of non-dominated solutions.
21//!
22//! 5. **CuriosityBonus**: UCB-style exploration bonus for under-visited
23//!    context buckets. Directs exploration toward novel contexts rather
24//!    than relying solely on Thompson Sampling's implicit exploration.
25
26use crate::cost_curve::CostCurvePoint;
27use crate::transfer::{ArmId, BetaParams, ContextBucket};
28use serde::{Deserialize, Serialize};
29use std::collections::HashMap;
30
31// ═══════════════════════════════════════════════════════════════════
32// 1. Regret Tracker
33// ═══════════════════════════════════════════════════════════════════
34
35/// Per-bucket regret state: tracks best arm and cumulative regret.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct BucketRegret {
38    /// Best known arm mean reward.
39    pub best_mean: f32,
40    /// Which arm is currently best.
41    pub best_arm: ArmId,
42    /// Cumulative regret: Σ(best_reward - chosen_reward).
43    pub cumulative_regret: f64,
44    /// Total observations in this bucket.
45    pub observations: u64,
46    /// Per-cycle regret snapshots for trend analysis.
47    pub regret_history: Vec<f64>,
48    /// Per-arm running mean for best-arm tracking.
49    arm_means: HashMap<ArmId, (f64, u64)>,
50}
51
52impl BucketRegret {
53    fn new() -> Self {
54        Self {
55            best_mean: 0.0,
56            best_arm: ArmId("unknown".into()),
57            cumulative_regret: 0.0,
58            observations: 0,
59            regret_history: Vec::new(),
60            arm_means: HashMap::new(),
61        }
62    }
63}
64
65/// Tracks cumulative regret across all context buckets.
66///
67/// Regret = Σ(best_arm_mean - chosen_arm_reward) over time.
68/// Sublinear regret growth (O(√T)) indicates the system is learning.
69/// Linear regret means it's not adapting.
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct RegretTracker {
72    buckets: HashMap<ContextBucket, BucketRegret>,
73    /// Global cumulative regret across all buckets.
74    pub total_regret: f64,
75    /// Total observations across all buckets.
76    pub total_observations: u64,
77    /// Snapshot interval: take regret snapshot every N observations.
78    snapshot_interval: u64,
79}
80
81impl RegretTracker {
82    /// Create a new regret tracker.
83    pub fn new(snapshot_interval: u64) -> Self {
84        Self {
85            buckets: HashMap::new(),
86            total_regret: 0.0,
87            total_observations: 0,
88            snapshot_interval: snapshot_interval.max(1),
89        }
90    }
91
92    /// Record a choice and its reward, updating regret.
93    pub fn record(&mut self, bucket: &ContextBucket, arm: &ArmId, reward: f32) {
94        // Avoid cloning when entry already exists (hot path optimization).
95        if !self.buckets.contains_key(bucket) {
96            self.buckets.insert(bucket.clone(), BucketRegret::new());
97        }
98        let entry = self.buckets.get_mut(bucket).unwrap();
99
100        // Update arm running mean (avoid clone when arm exists).
101        if !entry.arm_means.contains_key(arm) {
102            entry.arm_means.insert(arm.clone(), (0.0, 0));
103        }
104        let (sum, count) = entry.arm_means.get_mut(arm).unwrap();
105        *sum += reward as f64;
106        *count += 1;
107        let arm_mean = *sum / *count as f64;
108
109        // Update best arm if this arm's mean exceeds current best
110        if arm_mean > entry.best_mean as f64 {
111            entry.best_mean = arm_mean as f32;
112            entry.best_arm = arm.clone();
113        }
114
115        // Instantaneous regret: best_mean - observed_reward
116        let instant_regret = (entry.best_mean as f64 - reward as f64).max(0.0);
117        entry.cumulative_regret += instant_regret;
118        entry.observations += 1;
119        self.total_regret += instant_regret;
120        self.total_observations += 1;
121
122        // Snapshot on interval
123        if entry.observations % self.snapshot_interval == 0 {
124            entry.regret_history.push(entry.cumulative_regret);
125        }
126    }
127
128    /// Regret growth rate for a bucket. Sublinear (< 1.0) means learning.
129    ///
130    /// Computed as: log(regret) / log(observations).
131    /// Perfect learning → 0.5 (O(√T)). No learning → 1.0 (O(T)).
132    pub fn regret_growth_rate(&self, bucket: &ContextBucket) -> Option<f32> {
133        let entry = self.buckets.get(bucket)?;
134        if entry.observations < 10 || entry.cumulative_regret < 1e-10 {
135            return None;
136        }
137        let log_regret = (entry.cumulative_regret).ln();
138        let log_t = (entry.observations as f64).ln();
139        Some((log_regret / log_t) as f32)
140    }
141
142    /// Average regret per observation (lower = better).
143    pub fn average_regret(&self) -> f64 {
144        if self.total_observations == 0 {
145            return 0.0;
146        }
147        self.total_regret / self.total_observations as f64
148    }
149
150    /// Check if learning has converged: regret growth rate < threshold.
151    pub fn has_converged(&self, bucket: &ContextBucket, threshold: f32) -> bool {
152        self.regret_growth_rate(bucket)
153            .map_or(false, |rate| rate < threshold)
154    }
155
156    /// Get regret summary for all buckets.
157    pub fn summary(&self) -> RegretSummary {
158        let bucket_rates: Vec<(ContextBucket, f32)> = self
159            .buckets
160            .keys()
161            .filter_map(|b| self.regret_growth_rate(b).map(|r| (b.clone(), r)))
162            .collect();
163
164        let mean_rate = if bucket_rates.is_empty() {
165            1.0
166        } else {
167            bucket_rates.iter().map(|(_, r)| r).sum::<f32>() / bucket_rates.len() as f32
168        };
169
170        RegretSummary {
171            total_regret: self.total_regret,
172            total_observations: self.total_observations,
173            average_regret: self.average_regret(),
174            mean_growth_rate: mean_rate,
175            bucket_count: self.buckets.len(),
176            converged_buckets: bucket_rates.iter().filter(|(_, r)| *r < 0.7).count(),
177        }
178    }
179}
180
181/// Summary of regret across all buckets.
182#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct RegretSummary {
184    pub total_regret: f64,
185    pub total_observations: u64,
186    pub average_regret: f64,
187    /// Mean regret growth rate across buckets. < 0.7 = sublinear = learning.
188    pub mean_growth_rate: f32,
189    pub bucket_count: usize,
190    /// Buckets where regret growth is sublinear (learning converged).
191    pub converged_buckets: usize,
192}
193
194// ═══════════════════════════════════════════════════════════════════
195// 2. Decaying Beta Distribution
196// ═══════════════════════════════════════════════════════════════════
197
198/// Beta distribution with exponential forgetting for non-stationary environments.
199///
200/// On each update, old evidence decays by `decay_factor` before the new
201/// observation is added. This gives recent evidence more weight while
202/// gradually forgetting stale data.
203///
204/// Effective window size ≈ 1 / (1 - decay_factor).
205/// decay_factor = 0.995 → window ≈ 200 observations.
206/// decay_factor = 0.99  → window ≈ 100 observations.
207#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct DecayingBeta {
209    pub alpha: f32,
210    pub beta: f32,
211    /// Decay factor per observation. 1.0 = no decay (standard Beta).
212    pub decay_factor: f32,
213    /// Effective sample size (decayed observation count).
214    pub effective_n: f32,
215}
216
217impl DecayingBeta {
218    /// Create with uniform prior and specified decay.
219    pub fn new(decay_factor: f32) -> Self {
220        Self {
221            alpha: 1.0,
222            beta: 1.0,
223            decay_factor: decay_factor.clamp(0.9, 1.0),
224            effective_n: 0.0,
225        }
226    }
227
228    /// Create from an existing BetaParams with decay.
229    pub fn from_beta(params: &BetaParams, decay_factor: f32) -> Self {
230        Self {
231            alpha: params.alpha,
232            beta: params.beta,
233            decay_factor: decay_factor.clamp(0.9, 1.0),
234            effective_n: params.alpha + params.beta - 2.0,
235        }
236    }
237
238    /// Update with exponential decay: old evidence shrinks, new evidence adds.
239    pub fn update(&mut self, reward: f32) {
240        // Decay existing evidence toward the prior
241        self.alpha = 1.0 + (self.alpha - 1.0) * self.decay_factor;
242        self.beta = 1.0 + (self.beta - 1.0) * self.decay_factor;
243
244        // Add new observation
245        self.alpha += reward;
246        self.beta += 1.0 - reward;
247
248        // Track effective sample size
249        self.effective_n = self.effective_n * self.decay_factor + 1.0;
250    }
251
252    /// Mean of the distribution.
253    pub fn mean(&self) -> f32 {
254        self.alpha / (self.alpha + self.beta)
255    }
256
257    /// Variance of the distribution.
258    pub fn variance(&self) -> f32 {
259        let total = self.alpha + self.beta;
260        (self.alpha * self.beta) / (total * total * (total + 1.0))
261    }
262
263    /// Convert back to standard BetaParams (snapshot).
264    pub fn to_beta_params(&self) -> BetaParams {
265        BetaParams {
266            alpha: self.alpha,
267            beta: self.beta,
268        }
269    }
270
271    /// Effective window size: how many recent observations dominate.
272    pub fn effective_window(&self) -> f32 {
273        if self.decay_factor >= 1.0 {
274            self.effective_n
275        } else {
276            1.0 / (1.0 - self.decay_factor)
277        }
278    }
279}
280
281// ═══════════════════════════════════════════════════════════════════
282// 3. Plateau Detector
283// ═══════════════════════════════════════════════════════════════════
284
285/// Detects when learning has stalled by comparing accuracy windows.
286///
287/// Compares the mean accuracy of the most recent `window_size` points
288/// against the prior window. If improvement is below threshold,
289/// learning has plateaued.
290#[derive(Debug, Clone, Serialize, Deserialize)]
291pub struct PlateauDetector {
292    /// Number of points per comparison window.
293    pub window_size: usize,
294    /// Minimum improvement to not be considered a plateau.
295    pub improvement_threshold: f32,
296    /// Number of consecutive plateau detections.
297    pub consecutive_plateaus: u32,
298    /// Total plateaus detected.
299    pub total_plateaus: u32,
300}
301
302/// What to do when a plateau is detected.
303#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
304pub enum PlateauAction {
305    /// Continue learning, no plateau detected.
306    Continue,
307    /// Mild plateau: increase exploration budget.
308    IncreaseExploration,
309    /// Moderate plateau: trigger cross-domain transfer.
310    TriggerTransfer,
311    /// Severe plateau: inject diversity into population.
312    InjectDiversity,
313    /// Extreme plateau: reset and restart with new strategy.
314    Reset,
315}
316
317impl PlateauDetector {
318    /// Create a new plateau detector.
319    pub fn new(window_size: usize, improvement_threshold: f32) -> Self {
320        Self {
321            window_size: window_size.max(3),
322            improvement_threshold: improvement_threshold.max(0.001),
323            consecutive_plateaus: 0,
324            total_plateaus: 0,
325        }
326    }
327
328    /// Check if learning has plateaued and recommend an action.
329    pub fn check(&mut self, points: &[CostCurvePoint]) -> PlateauAction {
330        if points.len() < self.window_size * 2 {
331            self.consecutive_plateaus = 0;
332            return PlateauAction::Continue;
333        }
334
335        let n = points.len();
336        let recent = &points[n - self.window_size..];
337        let prior = &points[n - 2 * self.window_size..n - self.window_size];
338
339        let recent_mean = recent.iter().map(|p| p.accuracy).sum::<f32>() / recent.len() as f32;
340        let prior_mean = prior.iter().map(|p| p.accuracy).sum::<f32>() / prior.len() as f32;
341
342        let improvement = recent_mean - prior_mean;
343
344        if improvement.abs() < self.improvement_threshold {
345            self.consecutive_plateaus += 1;
346            self.total_plateaus += 1;
347
348            match self.consecutive_plateaus {
349                1 => PlateauAction::IncreaseExploration,
350                2..=3 => PlateauAction::TriggerTransfer,
351                4..=6 => PlateauAction::InjectDiversity,
352                _ => PlateauAction::Reset,
353            }
354        } else {
355            self.consecutive_plateaus = 0;
356            PlateauAction::Continue
357        }
358    }
359
360    /// Check if cost has plateaued (not just accuracy).
361    pub fn check_cost(&self, points: &[CostCurvePoint]) -> bool {
362        if points.len() < self.window_size * 2 {
363            return false;
364        }
365
366        let n = points.len();
367        let recent = &points[n - self.window_size..];
368        let prior = &points[n - 2 * self.window_size..n - self.window_size];
369
370        let recent_cost =
371            recent.iter().map(|p| p.cost_per_solve).sum::<f32>() / recent.len() as f32;
372        let prior_cost = prior.iter().map(|p| p.cost_per_solve).sum::<f32>() / prior.len() as f32;
373
374        // Cost should be decreasing; if it's not, that's a plateau
375        (prior_cost - recent_cost).abs() < self.improvement_threshold
376    }
377
378    /// Compute learning velocity: rate of accuracy change per cycle.
379    pub fn learning_velocity(&self, points: &[CostCurvePoint]) -> f32 {
380        if points.len() < 2 {
381            return 0.0;
382        }
383        let n = points.len();
384        let window = self.window_size.min(n);
385        let recent = &points[n - window..];
386
387        if recent.len() < 2 {
388            return 0.0;
389        }
390
391        let first = recent.first().unwrap();
392        let last = recent.last().unwrap();
393        let dt = (last.cycle - first.cycle).max(1) as f32;
394
395        (last.accuracy - first.accuracy) / dt
396    }
397}
398
399// ═══════════════════════════════════════════════════════════════════
400// 4. Pareto Front (Multi-Objective Optimization)
401// ═══════════════════════════════════════════════════════════════════
402
403/// A point in objective space with its kernel identity.
404#[derive(Debug, Clone, Serialize, Deserialize)]
405pub struct ParetoPoint {
406    /// Kernel identifier.
407    pub kernel_id: String,
408    /// Objective values (higher is better for all).
409    /// Convention: [accuracy, -cost, robustness].
410    pub objectives: Vec<f32>,
411    /// Generation when this point was added.
412    pub generation: u32,
413}
414
415/// Multi-objective Pareto front tracker.
416///
417/// Instead of collapsing multiple objectives into one weighted scalar,
418/// tracks the full set of non-dominated solutions. A solution is
419/// non-dominated if no other solution is better on ALL objectives.
420#[derive(Debug, Clone, Default, Serialize, Deserialize)]
421pub struct ParetoFront {
422    /// Current non-dominated solutions.
423    pub front: Vec<ParetoPoint>,
424    /// Total points evaluated.
425    pub evaluated: u64,
426    /// Number of front updates (when a new point enters the front).
427    pub front_updates: u64,
428}
429
430impl ParetoFront {
431    pub fn new() -> Self {
432        Self::default()
433    }
434
435    /// Check if point `a` dominates point `b`.
436    ///
437    /// Dominance: a is at least as good as b on all objectives,
438    /// and strictly better on at least one.
439    pub fn dominates(a: &[f32], b: &[f32]) -> bool {
440        if a.len() != b.len() {
441            return false;
442        }
443        let mut at_least_equal = true;
444        let mut strictly_better = false;
445
446        for (ai, bi) in a.iter().zip(b.iter()) {
447            if ai < bi {
448                at_least_equal = false;
449                break;
450            }
451            if ai > bi {
452                strictly_better = true;
453            }
454        }
455
456        at_least_equal && strictly_better
457    }
458
459    /// Insert a point into the front. Returns true if the point is non-dominated.
460    ///
461    /// Removes any existing points that the new point dominates.
462    pub fn insert(&mut self, point: ParetoPoint) -> bool {
463        self.evaluated += 1;
464
465        // Check if any existing point dominates the new one
466        for existing in &self.front {
467            if Self::dominates(&existing.objectives, &point.objectives) {
468                return false; // Dominated, don't add
469            }
470        }
471
472        // Remove points dominated by the new one
473        self.front
474            .retain(|existing| !Self::dominates(&point.objectives, &existing.objectives));
475
476        self.front.push(point);
477        self.front_updates += 1;
478        true
479    }
480
481    /// Hypervolume indicator: volume of objective space dominated by the front.
482    ///
483    /// Uses a reference point (all zeros) as the origin.
484    /// Higher hypervolume = better front coverage.
485    /// Only exact for 2D; uses approximation for higher dimensions.
486    pub fn hypervolume(&self, reference: &[f32]) -> f32 {
487        if self.front.is_empty() || reference.is_empty() {
488            return 0.0;
489        }
490
491        let dim = reference.len();
492        if dim == 2 {
493            self.hypervolume_2d(reference)
494        } else {
495            // Approximate: sum of per-point dominated rectangles (overcounts overlaps)
496            self.front
497                .iter()
498                .map(|p| {
499                    p.objectives
500                        .iter()
501                        .zip(reference.iter())
502                        .map(|(oi, ri)| (oi - ri).max(0.0))
503                        .product::<f32>()
504                })
505                .sum()
506        }
507    }
508
509    /// Exact 2D hypervolume via sweep line.
510    fn hypervolume_2d(&self, reference: &[f32]) -> f32 {
511        if self.front.is_empty() {
512            return 0.0;
513        }
514
515        let mut points: Vec<(f32, f32)> = self
516            .front
517            .iter()
518            .map(|p| {
519                let x = p.objectives.first().copied().unwrap_or(0.0);
520                let y = p.objectives.get(1).copied().unwrap_or(0.0);
521                (x, y)
522            })
523            .collect();
524
525        // Sort by x descending
526        points.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
527
528        let ref_x = reference.first().copied().unwrap_or(0.0);
529        let ref_y = reference.get(1).copied().unwrap_or(0.0);
530
531        let mut volume = 0.0f32;
532        let mut prev_y = ref_y;
533
534        for &(x, y) in &points {
535            if y > prev_y {
536                volume += (x - ref_x) * (y - prev_y);
537                prev_y = y;
538            }
539        }
540
541        volume
542    }
543
544    /// Size of the Pareto front.
545    pub fn len(&self) -> usize {
546        self.front.len()
547    }
548
549    /// Whether the front is empty.
550    pub fn is_empty(&self) -> bool {
551        self.front.is_empty()
552    }
553
554    /// Get the front point that maximizes a specific objective.
555    pub fn best_on(&self, objective_index: usize) -> Option<&ParetoPoint> {
556        self.front.iter().max_by(|a, b| {
557            let va = a.objectives.get(objective_index).copied().unwrap_or(0.0);
558            let vb = b.objectives.get(objective_index).copied().unwrap_or(0.0);
559            va.partial_cmp(&vb).unwrap_or(std::cmp::Ordering::Equal)
560        })
561    }
562
563    /// Spread: range on each objective dimension. Higher = more diverse front.
564    pub fn spread(&self) -> Vec<f32> {
565        if self.front.is_empty() {
566            return Vec::new();
567        }
568        let dim = self.front[0].objectives.len();
569        (0..dim)
570            .map(|i| {
571                let vals: Vec<f32> = self.front.iter().map(|p| p.objectives[i]).collect();
572                let min = vals.iter().cloned().fold(f32::INFINITY, f32::min);
573                let max = vals.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
574                max - min
575            })
576            .collect()
577    }
578}
579
580// ═══════════════════════════════════════════════════════════════════
581// 5. Curiosity Bonus (UCB-style exploration)
582// ═══════════════════════════════════════════════════════════════════
583
584/// UCB-style exploration bonus for under-visited context buckets.
585///
586/// Adds sqrt(2 * ln(N) / n_i) bonus to arm selection, where N is total
587/// observations and n_i is observations for this bucket/arm.
588/// This prioritizes under-explored contexts.
589#[derive(Debug, Clone, Serialize, Deserialize)]
590pub struct CuriosityBonus {
591    /// Per-bucket, per-arm visit counts.
592    visit_counts: HashMap<ContextBucket, HashMap<ArmId, u64>>,
593    /// Total visit count across everything.
594    pub total_visits: u64,
595    /// Exploration coefficient (higher = more curious).
596    pub exploration_coeff: f32,
597}
598
599impl CuriosityBonus {
600    /// Create with a given exploration coefficient.
601    /// Standard UCB uses sqrt(2) ≈ 1.41. Higher = more exploration.
602    pub fn new(exploration_coeff: f32) -> Self {
603        Self {
604            visit_counts: HashMap::new(),
605            total_visits: 0,
606            exploration_coeff: exploration_coeff.max(0.0),
607        }
608    }
609
610    /// Record a visit to a bucket/arm.
611    pub fn record_visit(&mut self, bucket: &ContextBucket, arm: &ArmId) {
612        // Hot path: avoid cloning when entries already exist.
613        if let Some(arms) = self.visit_counts.get_mut(bucket) {
614            if let Some(count) = arms.get_mut(arm) {
615                *count += 1;
616            } else {
617                arms.insert(arm.clone(), 1);
618            }
619        } else {
620            let mut arms = HashMap::new();
621            arms.insert(arm.clone(), 1u64);
622            self.visit_counts.insert(bucket.clone(), arms);
623        }
624        self.total_visits += 1;
625    }
626
627    /// Compute the exploration bonus for a bucket/arm combination.
628    ///
629    /// Bonus = c * sqrt(ln(N) / n), where:
630    /// - c is the exploration coefficient
631    /// - N is total visits
632    /// - n is visits to this specific bucket/arm
633    pub fn bonus(&self, bucket: &ContextBucket, arm: &ArmId) -> f32 {
634        if self.total_visits < 2 {
635            return self.exploration_coeff; // Maximum bonus when no data
636        }
637
638        let arm_visits = self
639            .visit_counts
640            .get(bucket)
641            .and_then(|arms| arms.get(arm))
642            .copied()
643            .unwrap_or(0);
644
645        if arm_visits == 0 {
646            return self.exploration_coeff * 2.0; // Never-visited bonus
647        }
648
649        let log_n = (self.total_visits as f32).ln();
650        self.exploration_coeff * (log_n / arm_visits as f32).sqrt()
651    }
652
653    /// Find the most under-explored bucket (lowest total visits).
654    pub fn most_curious_bucket(&self) -> Option<&ContextBucket> {
655        // Find buckets with fewest total visits
656        let mut min_visits = u64::MAX;
657        let mut most_curious = None;
658
659        for (bucket, arms) in &self.visit_counts {
660            let total: u64 = arms.values().sum();
661            if total < min_visits {
662                min_visits = total;
663                most_curious = Some(bucket);
664            }
665        }
666
667        most_curious
668    }
669
670    /// Novelty score for a bucket: inverse of visit density.
671    /// Higher = more novel / less explored.
672    pub fn novelty_score(&self, bucket: &ContextBucket) -> f32 {
673        if self.total_visits == 0 {
674            return 1.0;
675        }
676
677        let bucket_visits: u64 = self
678            .visit_counts
679            .get(bucket)
680            .map(|arms| arms.values().sum())
681            .unwrap_or(0);
682
683        if bucket_visits == 0 {
684            return 1.0;
685        }
686
687        1.0 - (bucket_visits as f32 / self.total_visits as f32)
688    }
689}
690
691// ═══════════════════════════════════════════════════════════════════
692// Integrated Meta-Learning Engine
693// ═══════════════════════════════════════════════════════════════════
694
695/// Unified meta-learning engine that composes all five improvements.
696///
697/// Drop-in enhancement for the existing DomainExpansionEngine.
698/// Call `record_decision` after each arm selection and `check_plateau`
699/// periodically to get adaptive strategy recommendations.
700#[derive(Debug, Clone, Serialize, Deserialize)]
701pub struct MetaLearningEngine {
702    pub regret: RegretTracker,
703    pub plateau: PlateauDetector,
704    pub pareto: ParetoFront,
705    pub curiosity: CuriosityBonus,
706    /// Per-bucket decaying beta distributions (optional overlay).
707    pub decaying_betas: HashMap<(ContextBucket, ArmId), DecayingBeta>,
708    /// Decay factor for the decaying beta distributions.
709    decay_factor: f32,
710}
711
712impl MetaLearningEngine {
713    /// Create with standard parameters.
714    pub fn new() -> Self {
715        Self {
716            regret: RegretTracker::new(50),
717            plateau: PlateauDetector::new(5, 0.005),
718            pareto: ParetoFront::new(),
719            curiosity: CuriosityBonus::new(1.41),
720            decaying_betas: HashMap::new(),
721            decay_factor: 0.995,
722        }
723    }
724
725    /// Create with custom parameters.
726    pub fn with_config(
727        regret_snapshot_interval: u64,
728        plateau_window: usize,
729        plateau_threshold: f32,
730        exploration_coeff: f32,
731        decay_factor: f32,
732    ) -> Self {
733        Self {
734            regret: RegretTracker::new(regret_snapshot_interval),
735            plateau: PlateauDetector::new(plateau_window, plateau_threshold),
736            pareto: ParetoFront::new(),
737            curiosity: CuriosityBonus::new(exploration_coeff),
738            decaying_betas: HashMap::new(),
739            decay_factor,
740        }
741    }
742
743    /// Record a decision outcome. Call after every arm selection.
744    pub fn record_decision(&mut self, bucket: &ContextBucket, arm: &ArmId, reward: f32) {
745        // 1. Track regret
746        self.regret.record(bucket, arm, reward);
747
748        // 2. Update curiosity counts
749        self.curiosity.record_visit(bucket, arm);
750
751        // 3. Update decaying beta for this bucket/arm.
752        //    Avoid tuple clone on hot path when entry exists.
753        let key = (bucket.clone(), arm.clone());
754        if let Some(db) = self.decaying_betas.get_mut(&key) {
755            db.update(reward);
756        } else {
757            let mut db = DecayingBeta::new(self.decay_factor);
758            db.update(reward);
759            self.decaying_betas.insert(key, db);
760        }
761    }
762
763    /// Record a population kernel's multi-objective performance.
764    pub fn record_kernel(
765        &mut self,
766        kernel_id: &str,
767        accuracy: f32,
768        cost: f32,
769        robustness: f32,
770        generation: u32,
771    ) {
772        let point = ParetoPoint {
773            kernel_id: kernel_id.to_string(),
774            // Convention: higher is better, so negate cost
775            objectives: vec![accuracy, -cost, robustness],
776            generation,
777        };
778        self.pareto.insert(point);
779    }
780
781    /// Check the cost curve for plateau and recommend action.
782    pub fn check_plateau(&mut self, points: &[CostCurvePoint]) -> PlateauAction {
783        self.plateau.check(points)
784    }
785
786    /// Get the curiosity-boosted score for an arm.
787    ///
788    /// Combines the Thompson Sampling estimate with an exploration bonus.
789    pub fn boosted_score(&self, bucket: &ContextBucket, arm: &ArmId, thompson_sample: f32) -> f32 {
790        let bonus = self.curiosity.bonus(bucket, arm);
791        thompson_sample + bonus
792    }
793
794    /// Get the decaying beta mean for a bucket/arm (if tracked).
795    pub fn decaying_mean(&self, bucket: &ContextBucket, arm: &ArmId) -> Option<f32> {
796        let key = (bucket.clone(), arm.clone());
797        self.decaying_betas.get(&key).map(|db| db.mean())
798    }
799
800    /// Comprehensive health check of the learning system.
801    pub fn health_check(&self) -> MetaLearningHealth {
802        let regret_summary = self.regret.summary();
803        let pareto_size = self.pareto.len();
804
805        let is_learning = regret_summary.mean_growth_rate < 0.8;
806        let is_diverse = pareto_size >= 3;
807        let is_exploring = self.curiosity.total_visits > 0;
808
809        MetaLearningHealth {
810            regret: regret_summary,
811            pareto_size,
812            pareto_hypervolume: self.pareto.hypervolume(&[0.0, -1.0, 0.0]),
813            consecutive_plateaus: self.plateau.consecutive_plateaus,
814            total_plateaus: self.plateau.total_plateaus,
815            curiosity_total_visits: self.curiosity.total_visits,
816            decaying_beta_count: self.decaying_betas.len(),
817            is_learning,
818            is_diverse,
819            is_exploring,
820        }
821    }
822}
823
824impl Default for MetaLearningEngine {
825    fn default() -> Self {
826        Self::new()
827    }
828}
829
830/// Health summary of the meta-learning system.
831#[derive(Debug, Clone, Serialize, Deserialize)]
832pub struct MetaLearningHealth {
833    pub regret: RegretSummary,
834    pub pareto_size: usize,
835    pub pareto_hypervolume: f32,
836    pub consecutive_plateaus: u32,
837    pub total_plateaus: u32,
838    pub curiosity_total_visits: u64,
839    pub decaying_beta_count: usize,
840    /// True if regret growth is sublinear (system is learning).
841    pub is_learning: bool,
842    /// True if Pareto front has diverse solutions.
843    pub is_diverse: bool,
844    /// True if curiosity is actively exploring.
845    pub is_exploring: bool,
846}
847
848// ═══════════════════════════════════════════════════════════════════
849// Tests
850// ═══════════════════════════════════════════════════════════════════
851
852#[cfg(test)]
853mod tests {
854    use super::*;
855
856    fn test_bucket(tier: &str, cat: &str) -> ContextBucket {
857        ContextBucket {
858            difficulty_tier: tier.into(),
859            category: cat.into(),
860        }
861    }
862
863    // -- RegretTracker tests --
864
865    #[test]
866    fn test_regret_tracker_empty() {
867        let tracker = RegretTracker::new(10);
868        assert_eq!(tracker.total_regret, 0.0);
869        assert_eq!(tracker.average_regret(), 0.0);
870    }
871
872    #[test]
873    fn test_regret_tracker_optimal_arm() {
874        let mut tracker = RegretTracker::new(10);
875        let bucket = test_bucket("easy", "test");
876        let arm = ArmId("best".into());
877
878        // Always picking the best arm → zero regret
879        for _ in 0..100 {
880            tracker.record(&bucket, &arm, 0.9);
881        }
882
883        assert_eq!(tracker.total_observations, 100);
884        // All same arm, so regret is 0
885        assert!(tracker.total_regret < 1e-6);
886    }
887
888    #[test]
889    fn test_regret_tracker_suboptimal_arm() {
890        let mut tracker = RegretTracker::new(10);
891        let bucket = test_bucket("medium", "test");
892        let good = ArmId("good".into());
893        let bad = ArmId("bad".into());
894
895        // Establish good arm's mean
896        for _ in 0..50 {
897            tracker.record(&bucket, &good, 0.9);
898        }
899
900        // Now pick the bad arm repeatedly → regret accumulates
901        for _ in 0..50 {
902            tracker.record(&bucket, &bad, 0.3);
903        }
904
905        assert!(tracker.total_regret > 0.0);
906        assert!(tracker.average_regret() > 0.0);
907    }
908
909    #[test]
910    fn test_regret_growth_rate() {
911        let mut tracker = RegretTracker::new(5);
912        let bucket = test_bucket("hard", "test");
913        let arm_a = ArmId("a".into());
914        let arm_b = ArmId("b".into());
915
916        for _ in 0..50 {
917            tracker.record(&bucket, &arm_a, 0.8);
918        }
919        for _ in 0..50 {
920            tracker.record(&bucket, &arm_b, 0.4);
921        }
922
923        let rate = tracker.regret_growth_rate(&bucket);
924        assert!(rate.is_some());
925        // Rate should be defined (we have enough observations)
926    }
927
928    #[test]
929    fn test_regret_summary() {
930        let mut tracker = RegretTracker::new(10);
931        let bucket = test_bucket("easy", "algo");
932        let arm = ArmId("test".into());
933
934        for _ in 0..20 {
935            tracker.record(&bucket, &arm, 0.7);
936        }
937
938        let summary = tracker.summary();
939        assert_eq!(summary.total_observations, 20);
940        assert_eq!(summary.bucket_count, 1);
941    }
942
943    // -- DecayingBeta tests --
944
945    #[test]
946    fn test_decaying_beta_initial() {
947        let db = DecayingBeta::new(0.995);
948        assert!((db.mean() - 0.5).abs() < 1e-6); // Uniform prior
949        assert_eq!(db.effective_n, 0.0);
950    }
951
952    #[test]
953    fn test_decaying_beta_update() {
954        let mut db = DecayingBeta::new(0.995);
955
956        for _ in 0..100 {
957            db.update(0.9); // Mostly successes
958        }
959
960        assert!(db.mean() > 0.7); // Should reflect high success rate
961        assert!(db.effective_n > 50.0); // Decayed but substantial
962    }
963
964    #[test]
965    fn test_decaying_beta_adapts() {
966        let mut db = DecayingBeta::new(0.99); // Faster decay
967
968        // First: many successes
969        for _ in 0..100 {
970            db.update(0.95);
971        }
972        let mean_after_good = db.mean();
973        assert!(mean_after_good > 0.8);
974
975        // Then: many failures (environment changed)
976        for _ in 0..100 {
977            db.update(0.1);
978        }
979        let mean_after_bad = db.mean();
980
981        // With decay, it should adapt toward the new distribution
982        assert!(mean_after_bad < mean_after_good);
983        assert!(mean_after_bad < 0.5); // Should reflect recent failures
984    }
985
986    #[test]
987    fn test_decaying_beta_window() {
988        let db = DecayingBeta::new(0.99);
989        let window = db.effective_window();
990        assert!((window - 100.0).abs() < 1.0); // 1/(1-0.99) = 100
991
992        let db2 = DecayingBeta::new(0.995);
993        let window2 = db2.effective_window();
994        assert!((window2 - 200.0).abs() < 1.0); // 1/(1-0.005) = 200
995    }
996
997    #[test]
998    fn test_decaying_to_standard() {
999        let mut db = DecayingBeta::new(0.995);
1000        for _ in 0..10 {
1001            db.update(0.8);
1002        }
1003        let params = db.to_beta_params();
1004        assert!(params.alpha > 1.0);
1005        assert!(params.beta > 1.0);
1006        assert!((params.mean() - db.mean()).abs() < 1e-6);
1007    }
1008
1009    // -- PlateauDetector tests --
1010
1011    #[test]
1012    fn test_plateau_no_data() {
1013        let mut detector = PlateauDetector::new(3, 0.01);
1014        let action = detector.check(&[]);
1015        assert_eq!(action, PlateauAction::Continue);
1016    }
1017
1018    #[test]
1019    fn test_plateau_not_enough_data() {
1020        let mut detector = PlateauDetector::new(3, 0.01);
1021        let points: Vec<CostCurvePoint> = (0..4)
1022            .map(|i| CostCurvePoint {
1023                cycle: i as u64,
1024                accuracy: 0.5 + i as f32 * 0.1,
1025                cost_per_solve: 0.1,
1026                robustness: 0.8,
1027                policy_violations: 0,
1028                timestamp: i as f64,
1029            })
1030            .collect();
1031
1032        let action = detector.check(&points);
1033        assert_eq!(action, PlateauAction::Continue);
1034    }
1035
1036    #[test]
1037    fn test_plateau_detected() {
1038        let mut detector = PlateauDetector::new(3, 0.01);
1039
1040        // Flat accuracy → plateau
1041        let points: Vec<CostCurvePoint> = (0..6)
1042            .map(|i| CostCurvePoint {
1043                cycle: i as u64,
1044                accuracy: 0.80 + (i as f32 * 0.001), // Nearly flat
1045                cost_per_solve: 0.1,
1046                robustness: 0.8,
1047                policy_violations: 0,
1048                timestamp: i as f64,
1049            })
1050            .collect();
1051
1052        let action = detector.check(&points);
1053        assert_ne!(action, PlateauAction::Continue);
1054    }
1055
1056    #[test]
1057    fn test_plateau_improving() {
1058        let mut detector = PlateauDetector::new(3, 0.01);
1059
1060        // Clear improvement → no plateau
1061        let points: Vec<CostCurvePoint> = (0..6)
1062            .map(|i| CostCurvePoint {
1063                cycle: i as u64,
1064                accuracy: 0.50 + i as f32 * 0.08, // Strong improvement
1065                cost_per_solve: 0.1,
1066                robustness: 0.8,
1067                policy_violations: 0,
1068                timestamp: i as f64,
1069            })
1070            .collect();
1071
1072        let action = detector.check(&points);
1073        assert_eq!(action, PlateauAction::Continue);
1074    }
1075
1076    #[test]
1077    fn test_plateau_escalation() {
1078        let mut detector = PlateauDetector::new(3, 0.01);
1079
1080        let flat_points: Vec<CostCurvePoint> = (0..6)
1081            .map(|i| CostCurvePoint {
1082                cycle: i as u64,
1083                accuracy: 0.80,
1084                cost_per_solve: 0.1,
1085                robustness: 0.8,
1086                policy_violations: 0,
1087                timestamp: i as f64,
1088            })
1089            .collect();
1090
1091        assert_eq!(
1092            detector.check(&flat_points),
1093            PlateauAction::IncreaseExploration
1094        );
1095        assert_eq!(detector.check(&flat_points), PlateauAction::TriggerTransfer);
1096        assert_eq!(detector.check(&flat_points), PlateauAction::TriggerTransfer);
1097        assert_eq!(detector.check(&flat_points), PlateauAction::InjectDiversity);
1098    }
1099
1100    #[test]
1101    fn test_learning_velocity() {
1102        let detector = PlateauDetector::new(3, 0.01);
1103
1104        let points: Vec<CostCurvePoint> = (0..6)
1105            .map(|i| CostCurvePoint {
1106                cycle: i as u64,
1107                accuracy: 0.50 + i as f32 * 0.1,
1108                cost_per_solve: 0.1,
1109                robustness: 0.8,
1110                policy_violations: 0,
1111                timestamp: i as f64,
1112            })
1113            .collect();
1114
1115        let velocity = detector.learning_velocity(&points);
1116        assert!(velocity > 0.0); // Should be positive (improving)
1117    }
1118
1119    // -- ParetoFront tests --
1120
1121    #[test]
1122    fn test_pareto_dominates() {
1123        assert!(ParetoFront::dominates(&[0.9, -0.1, 0.8], &[0.8, -0.2, 0.7]));
1124        assert!(!ParetoFront::dominates(
1125            &[0.9, -0.3, 0.8],
1126            &[0.8, -0.1, 0.7]
1127        ));
1128        assert!(!ParetoFront::dominates(
1129            &[0.9, -0.1, 0.8],
1130            &[0.9, -0.1, 0.8]
1131        )); // Equal
1132    }
1133
1134    #[test]
1135    fn test_pareto_insert_non_dominated() {
1136        let mut front = ParetoFront::new();
1137
1138        // Two non-dominated points (tradeoff: accuracy vs cost)
1139        assert!(front.insert(ParetoPoint {
1140            kernel_id: "a".into(),
1141            objectives: vec![0.9, -0.3, 0.7],
1142            generation: 0,
1143        }));
1144        assert!(front.insert(ParetoPoint {
1145            kernel_id: "b".into(),
1146            objectives: vec![0.7, -0.1, 0.9],
1147            generation: 0,
1148        }));
1149
1150        assert_eq!(front.len(), 2);
1151    }
1152
1153    #[test]
1154    fn test_pareto_insert_dominated() {
1155        let mut front = ParetoFront::new();
1156
1157        front.insert(ParetoPoint {
1158            kernel_id: "good".into(),
1159            objectives: vec![0.9, -0.1, 0.9],
1160            generation: 0,
1161        });
1162
1163        // This is dominated by "good" on all objectives
1164        let added = front.insert(ParetoPoint {
1165            kernel_id: "bad".into(),
1166            objectives: vec![0.5, -0.5, 0.5],
1167            generation: 0,
1168        });
1169
1170        assert!(!added);
1171        assert_eq!(front.len(), 1);
1172    }
1173
1174    #[test]
1175    fn test_pareto_removes_dominated() {
1176        let mut front = ParetoFront::new();
1177
1178        front.insert(ParetoPoint {
1179            kernel_id: "old".into(),
1180            objectives: vec![0.5, -0.3, 0.5],
1181            generation: 0,
1182        });
1183
1184        // New point dominates old
1185        front.insert(ParetoPoint {
1186            kernel_id: "new".into(),
1187            objectives: vec![0.9, -0.1, 0.9],
1188            generation: 1,
1189        });
1190
1191        assert_eq!(front.len(), 1);
1192        assert_eq!(front.front[0].kernel_id, "new");
1193    }
1194
1195    #[test]
1196    fn test_pareto_best_on_objective() {
1197        let mut front = ParetoFront::new();
1198
1199        front.insert(ParetoPoint {
1200            kernel_id: "accurate".into(),
1201            objectives: vec![0.95, -0.5, 0.6],
1202            generation: 0,
1203        });
1204        front.insert(ParetoPoint {
1205            kernel_id: "cheap".into(),
1206            objectives: vec![0.7, -0.05, 0.7],
1207            generation: 0,
1208        });
1209        front.insert(ParetoPoint {
1210            kernel_id: "robust".into(),
1211            objectives: vec![0.8, -0.3, 0.95],
1212            generation: 0,
1213        });
1214
1215        assert_eq!(front.best_on(0).unwrap().kernel_id, "accurate");
1216        assert_eq!(front.best_on(1).unwrap().kernel_id, "cheap"); // -0.05 > -0.5
1217        assert_eq!(front.best_on(2).unwrap().kernel_id, "robust");
1218    }
1219
1220    #[test]
1221    fn test_pareto_spread() {
1222        let mut front = ParetoFront::new();
1223
1224        // Non-dominated tradeoff: a is better on obj0, b is better on obj1.
1225        front.insert(ParetoPoint {
1226            kernel_id: "a".into(),
1227            objectives: vec![0.9, -0.5],
1228            generation: 0,
1229        });
1230        front.insert(ParetoPoint {
1231            kernel_id: "b".into(),
1232            objectives: vec![0.5, -0.1],
1233            generation: 0,
1234        });
1235
1236        assert_eq!(front.len(), 2); // Both should survive (non-dominated)
1237        let spread = front.spread();
1238        assert_eq!(spread.len(), 2);
1239        assert!((spread[0] - 0.4).abs() < 1e-4); // 0.9 - 0.5
1240        assert!((spread[1] - 0.4).abs() < 1e-4); // -0.1 - (-0.5)
1241    }
1242
1243    #[test]
1244    fn test_pareto_hypervolume_2d() {
1245        let mut front = ParetoFront::new();
1246
1247        front.insert(ParetoPoint {
1248            kernel_id: "a".into(),
1249            objectives: vec![1.0, 1.0],
1250            generation: 0,
1251        });
1252
1253        let hv = front.hypervolume(&[0.0, 0.0]);
1254        assert!((hv - 1.0).abs() < 1e-4); // 1x1 rectangle
1255    }
1256
1257    // -- CuriosityBonus tests --
1258
1259    #[test]
1260    fn test_curiosity_bonus_unvisited() {
1261        let curiosity = CuriosityBonus::new(1.41);
1262        let bucket = test_bucket("hard", "novel");
1263        let arm = ArmId("new".into());
1264
1265        let bonus = curiosity.bonus(&bucket, &arm);
1266        assert!(bonus > 0.0); // Should have high bonus for unvisited
1267    }
1268
1269    #[test]
1270    fn test_curiosity_bonus_decays_with_visits() {
1271        let mut curiosity = CuriosityBonus::new(1.41);
1272        let bucket = test_bucket("easy", "test");
1273        let arm = ArmId("a".into());
1274
1275        let bonus_before = curiosity.bonus(&bucket, &arm);
1276
1277        for _ in 0..50 {
1278            curiosity.record_visit(&bucket, &arm);
1279        }
1280
1281        let bonus_after = curiosity.bonus(&bucket, &arm);
1282        assert!(bonus_after < bonus_before); // Bonus should decrease
1283    }
1284
1285    #[test]
1286    fn test_curiosity_novelty_score() {
1287        let mut curiosity = CuriosityBonus::new(1.41);
1288        let explored = test_bucket("easy", "common");
1289        let novel = test_bucket("hard", "rare");
1290        let arm = ArmId("a".into());
1291
1292        for _ in 0..100 {
1293            curiosity.record_visit(&explored, &arm);
1294        }
1295        curiosity.record_visit(&novel, &arm);
1296
1297        let explored_novelty = curiosity.novelty_score(&explored);
1298        let novel_novelty = curiosity.novelty_score(&novel);
1299
1300        assert!(novel_novelty > explored_novelty);
1301    }
1302
1303    // -- MetaLearningEngine integration tests --
1304
1305    #[test]
1306    fn test_meta_engine_creation() {
1307        let engine = MetaLearningEngine::new();
1308        assert_eq!(engine.regret.total_observations, 0);
1309        assert!(engine.pareto.is_empty());
1310        assert_eq!(engine.curiosity.total_visits, 0);
1311    }
1312
1313    #[test]
1314    fn test_meta_engine_record_decision() {
1315        let mut engine = MetaLearningEngine::new();
1316        let bucket = test_bucket("medium", "algo");
1317        let arm = ArmId("greedy".into());
1318
1319        for _ in 0..50 {
1320            engine.record_decision(&bucket, &arm, 0.85);
1321        }
1322
1323        assert_eq!(engine.regret.total_observations, 50);
1324        assert_eq!(engine.curiosity.total_visits, 50);
1325        assert!(engine.decaying_mean(&bucket, &arm).unwrap() > 0.7);
1326    }
1327
1328    #[test]
1329    fn test_meta_engine_boosted_score() {
1330        let mut engine = MetaLearningEngine::new();
1331        let explored = test_bucket("easy", "common");
1332        let novel = test_bucket("hard", "rare");
1333        let arm = ArmId("a".into());
1334
1335        // Explore one bucket heavily
1336        for _ in 0..100 {
1337            engine.record_decision(&explored, &arm, 0.8);
1338        }
1339
1340        let score_explored = engine.boosted_score(&explored, &arm, 0.5);
1341        let score_novel = engine.boosted_score(&novel, &arm, 0.5);
1342
1343        // Novel bucket should get higher boosted score
1344        assert!(score_novel > score_explored);
1345    }
1346
1347    #[test]
1348    fn test_meta_engine_kernel_recording() {
1349        let mut engine = MetaLearningEngine::new();
1350
1351        engine.record_kernel("k1", 0.9, 0.3, 0.7, 0);
1352        engine.record_kernel("k2", 0.7, 0.1, 0.9, 0);
1353        engine.record_kernel("k3", 0.5, 0.5, 0.5, 0); // Dominated by k1
1354
1355        // k1 and k2 are non-dominated; k3 is dominated
1356        assert!(engine.pareto.len() <= 2);
1357    }
1358
1359    #[test]
1360    fn test_meta_engine_health_check() {
1361        let mut engine = MetaLearningEngine::new();
1362        let bucket = test_bucket("medium", "test");
1363        let arm = ArmId("a".into());
1364
1365        for _ in 0..100 {
1366            engine.record_decision(&bucket, &arm, 0.8);
1367        }
1368
1369        let health = engine.health_check();
1370        assert_eq!(health.curiosity_total_visits, 100);
1371        assert!(health.is_exploring);
1372    }
1373
1374    #[test]
1375    fn test_meta_engine_plateau_check() {
1376        let mut engine = MetaLearningEngine::new();
1377
1378        let flat_points: Vec<CostCurvePoint> = (0..10)
1379            .map(|i| CostCurvePoint {
1380                cycle: i as u64,
1381                accuracy: 0.80,
1382                cost_per_solve: 0.1,
1383                robustness: 0.8,
1384                policy_violations: 0,
1385                timestamp: i as f64,
1386            })
1387            .collect();
1388
1389        let action = engine.check_plateau(&flat_points);
1390        assert_ne!(action, PlateauAction::Continue);
1391    }
1392
1393    #[test]
1394    fn test_meta_engine_default() {
1395        let engine = MetaLearningEngine::default();
1396        assert_eq!(engine.curiosity.exploration_coeff, 1.41);
1397    }
1398}