Skip to main content

ruvector_sona/
types.rs

1//! SONA Core Types
2//!
3//! Defines the fundamental data structures for the Self-Optimizing Neural Architecture.
4
5use crate::time_compat::Instant;
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9/// Learning signal generated from inference trajectory
10#[derive(Clone, Debug, Serialize, Deserialize)]
11pub struct LearningSignal {
12    /// Query embedding vector
13    pub query_embedding: Vec<f32>,
14    /// Estimated gradient direction
15    pub gradient_estimate: Vec<f32>,
16    /// Quality score [0.0, 1.0]
17    pub quality_score: f32,
18    /// Signal generation timestamp (serialized as nanos)
19    #[serde(skip)]
20    pub timestamp: Option<Instant>,
21    /// Additional metadata
22    pub metadata: SignalMetadata,
23}
24
25/// Metadata for learning signals
26#[derive(Clone, Debug, Default, Serialize, Deserialize)]
27pub struct SignalMetadata {
28    /// Source trajectory ID
29    pub trajectory_id: u64,
30    /// Number of steps in trajectory
31    pub step_count: usize,
32    /// Model route taken
33    pub model_route: Option<String>,
34    /// Custom tags
35    pub tags: HashMap<String, String>,
36}
37
38impl LearningSignal {
39    /// Create signal from query trajectory using REINFORCE gradient estimation
40    pub fn from_trajectory(trajectory: &QueryTrajectory) -> Self {
41        let gradient = Self::estimate_gradient(trajectory);
42
43        Self {
44            query_embedding: trajectory.query_embedding.clone(),
45            gradient_estimate: gradient,
46            quality_score: trajectory.final_quality,
47            timestamp: Some(Instant::now()),
48            metadata: SignalMetadata {
49                trajectory_id: trajectory.id,
50                step_count: trajectory.steps.len(),
51                model_route: trajectory.model_route.clone(),
52                tags: HashMap::new(),
53            },
54        }
55    }
56
57    /// Create signal with pre-computed gradient
58    pub fn with_gradient(embedding: Vec<f32>, gradient: Vec<f32>, quality: f32) -> Self {
59        Self {
60            query_embedding: embedding,
61            gradient_estimate: gradient,
62            quality_score: quality,
63            timestamp: Some(Instant::now()),
64            metadata: SignalMetadata::default(),
65        }
66    }
67
68    /// Estimate gradient using REINFORCE with baseline
69    fn estimate_gradient(trajectory: &QueryTrajectory) -> Vec<f32> {
70        if trajectory.steps.is_empty() {
71            return trajectory.query_embedding.clone();
72        }
73
74        let dim = trajectory.query_embedding.len();
75        let mut gradient = vec![0.0f32; dim];
76
77        // Compute baseline (average reward)
78        let baseline =
79            trajectory.steps.iter().map(|s| s.reward).sum::<f32>() / trajectory.steps.len() as f32;
80
81        // REINFORCE: gradient = sum((reward - baseline) * activation)
82        for step in &trajectory.steps {
83            let advantage = step.reward - baseline;
84            let activation_len = step.activations.len().min(dim);
85            for (grad, &act) in gradient
86                .iter_mut()
87                .zip(step.activations.iter())
88                .take(activation_len)
89            {
90                *grad += advantage * act;
91            }
92        }
93
94        // L2 normalize
95        let norm: f32 = gradient.iter().map(|x| x * x).sum::<f32>().sqrt();
96        if norm > 1e-8 {
97            gradient.iter_mut().for_each(|x| *x /= norm);
98            return gradient;
99        }
100
101        // Degenerate case (fixes #519): single-step trajectories, or trajectories
102        // where every step has the same reward, have zero advantage everywhere
103        // (reward - baseline == 0), which produced an exact-zero gradient and
104        // therefore no learning. Fall back to baseline-free REINFORCE
105        // (advantage = raw reward) so single-feedback trajectories still adapt.
106        // Tradeoff: without the baseline the estimate has higher variance, but
107        // it only applies when the baselined estimate is identically zero —
108        // multi-step varying-reward trajectories are unaffected.
109        let mut fallback = vec![0.0f32; dim];
110        for step in &trajectory.steps {
111            let activation_len = step.activations.len().min(dim);
112            for (grad, &act) in fallback
113                .iter_mut()
114                .zip(step.activations.iter())
115                .take(activation_len)
116            {
117                *grad += step.reward * act;
118            }
119        }
120
121        let fallback_norm: f32 = fallback.iter().map(|x| x * x).sum::<f32>().sqrt();
122        if fallback_norm > 1e-8 {
123            fallback.iter_mut().for_each(|x| *x /= fallback_norm);
124            return fallback;
125        }
126
127        gradient
128    }
129
130    /// Scale gradient by quality
131    pub fn scaled_gradient(&self) -> Vec<f32> {
132        self.gradient_estimate
133            .iter()
134            .map(|&g| g * self.quality_score)
135            .collect()
136    }
137}
138
139/// Query trajectory recording
140#[derive(Clone, Debug, Serialize, Deserialize)]
141pub struct QueryTrajectory {
142    /// Unique trajectory identifier
143    pub id: u64,
144    /// Query embedding vector
145    pub query_embedding: Vec<f32>,
146    /// Execution steps
147    pub steps: Vec<TrajectoryStep>,
148    /// Final quality score [0.0, 1.0]
149    pub final_quality: f32,
150    /// Total latency in microseconds
151    pub latency_us: u64,
152    /// Model route taken
153    pub model_route: Option<String>,
154    /// Context used
155    pub context_ids: Vec<String>,
156}
157
158impl QueryTrajectory {
159    /// Create new trajectory
160    pub fn new(id: u64, query_embedding: Vec<f32>) -> Self {
161        Self {
162            id,
163            query_embedding,
164            steps: Vec::with_capacity(16),
165            final_quality: 0.0,
166            latency_us: 0,
167            model_route: None,
168            context_ids: Vec::new(),
169        }
170    }
171
172    /// Add execution step
173    pub fn add_step(&mut self, step: TrajectoryStep) {
174        self.steps.push(step);
175    }
176
177    /// Finalize trajectory with quality score
178    pub fn finalize(&mut self, quality: f32, latency_us: u64) {
179        self.final_quality = quality;
180        self.latency_us = latency_us;
181    }
182
183    /// Get total reward
184    pub fn total_reward(&self) -> f32 {
185        self.steps.iter().map(|s| s.reward).sum()
186    }
187
188    /// Get average reward
189    pub fn avg_reward(&self) -> f32 {
190        if self.steps.is_empty() {
191            0.0
192        } else {
193            self.total_reward() / self.steps.len() as f32
194        }
195    }
196}
197
198/// Single step in a trajectory
199#[derive(Clone, Debug, Serialize, Deserialize)]
200pub struct TrajectoryStep {
201    /// Layer/module activations (subset for efficiency)
202    pub activations: Vec<f32>,
203    /// Attention weights (flattened)
204    pub attention_weights: Vec<f32>,
205    /// Reward signal for this step
206    pub reward: f32,
207    /// Step index
208    pub step_idx: usize,
209    /// Optional layer name
210    pub layer_name: Option<String>,
211}
212
213impl TrajectoryStep {
214    /// Create new step
215    pub fn new(
216        activations: Vec<f32>,
217        attention_weights: Vec<f32>,
218        reward: f32,
219        step_idx: usize,
220    ) -> Self {
221        Self {
222            activations,
223            attention_weights,
224            reward,
225            step_idx,
226            layer_name: None,
227        }
228    }
229
230    /// Create step with layer name
231    pub fn with_layer(mut self, name: &str) -> Self {
232        self.layer_name = Some(name.to_string());
233        self
234    }
235}
236
237/// Learned pattern from trajectory clustering
238#[derive(Clone, Debug, Serialize, Deserialize)]
239pub struct LearnedPattern {
240    /// Pattern identifier
241    pub id: u64,
242    /// Cluster centroid embedding
243    pub centroid: Vec<f32>,
244    /// Number of trajectories in cluster
245    pub cluster_size: usize,
246    /// Sum of trajectory weights
247    pub total_weight: f32,
248    /// Average quality of member trajectories
249    pub avg_quality: f32,
250    /// Creation timestamp (Unix seconds)
251    pub created_at: u64,
252    /// Last access timestamp
253    pub last_accessed: u64,
254    /// Total access count
255    pub access_count: u32,
256    /// Pattern type/category
257    pub pattern_type: PatternType,
258}
259
260/// Pattern classification
261#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
262pub enum PatternType {
263    #[default]
264    General,
265    Reasoning,
266    Factual,
267    Creative,
268    CodeGen,
269    Conversational,
270}
271
272impl std::fmt::Display for PatternType {
273    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
274        match self {
275            PatternType::General => write!(f, "general"),
276            PatternType::Reasoning => write!(f, "reasoning"),
277            PatternType::Factual => write!(f, "factual"),
278            PatternType::Creative => write!(f, "creative"),
279            PatternType::CodeGen => write!(f, "codegen"),
280            PatternType::Conversational => write!(f, "conversational"),
281        }
282    }
283}
284
285impl LearnedPattern {
286    /// Create new pattern
287    pub fn new(id: u64, centroid: Vec<f32>) -> Self {
288        use crate::time_compat::SystemTime;
289        let now = SystemTime::now().duration_since_epoch().as_secs();
290
291        Self {
292            id,
293            centroid,
294            cluster_size: 1,
295            total_weight: 1.0,
296            avg_quality: 0.0,
297            created_at: now,
298            last_accessed: now,
299            access_count: 0,
300            pattern_type: PatternType::default(),
301        }
302    }
303
304    /// Merge two patterns
305    pub fn merge(&self, other: &Self) -> Self {
306        let total_size = self.cluster_size + other.cluster_size;
307        let w1 = self.cluster_size as f32 / total_size as f32;
308        let w2 = other.cluster_size as f32 / total_size as f32;
309
310        let centroid: Vec<f32> = self
311            .centroid
312            .iter()
313            .zip(&other.centroid)
314            .map(|(&a, &b)| a * w1 + b * w2)
315            .collect();
316
317        Self {
318            id: self.id,
319            centroid,
320            cluster_size: total_size,
321            total_weight: self.total_weight + other.total_weight,
322            avg_quality: self.avg_quality * w1 + other.avg_quality * w2,
323            created_at: self.created_at.min(other.created_at),
324            last_accessed: self.last_accessed.max(other.last_accessed),
325            access_count: self.access_count + other.access_count,
326            pattern_type: self.pattern_type.clone(),
327        }
328    }
329
330    /// Decay pattern importance
331    pub fn decay(&mut self, factor: f32) {
332        self.total_weight *= factor;
333    }
334
335    /// Record access
336    pub fn touch(&mut self) {
337        use crate::time_compat::SystemTime;
338        self.access_count += 1;
339        self.last_accessed = SystemTime::now().duration_since_epoch().as_secs();
340    }
341
342    /// Check if pattern should be pruned
343    pub fn should_prune(&self, min_quality: f32, min_accesses: u32, max_age_secs: u64) -> bool {
344        use crate::time_compat::SystemTime;
345        let now = SystemTime::now().duration_since_epoch().as_secs();
346        let age = now.saturating_sub(self.last_accessed);
347
348        self.avg_quality < min_quality && self.access_count < min_accesses && age > max_age_secs
349    }
350
351    /// Compute cosine similarity with query
352    pub fn similarity(&self, query: &[f32]) -> f32 {
353        if self.centroid.len() != query.len() {
354            return 0.0;
355        }
356
357        let dot: f32 = self.centroid.iter().zip(query).map(|(a, b)| a * b).sum();
358        let norm_a: f32 = self.centroid.iter().map(|x| x * x).sum::<f32>().sqrt();
359        let norm_b: f32 = query.iter().map(|x| x * x).sum::<f32>().sqrt();
360
361        if norm_a > 1e-8 && norm_b > 1e-8 {
362            dot / (norm_a * norm_b)
363        } else {
364            0.0
365        }
366    }
367}
368
369/// SONA configuration
370#[derive(Clone, Debug, Serialize, Deserialize)]
371pub struct SonaConfig {
372    /// Hidden dimension
373    pub hidden_dim: usize,
374    /// Embedding dimension
375    pub embedding_dim: usize,
376    /// Micro-LoRA rank
377    pub micro_lora_rank: usize,
378    /// Base LoRA rank
379    pub base_lora_rank: usize,
380    /// Micro-LoRA learning rate
381    pub micro_lora_lr: f32,
382    /// Base LoRA learning rate
383    pub base_lora_lr: f32,
384    /// EWC lambda
385    pub ewc_lambda: f32,
386    /// Pattern extraction clusters
387    pub pattern_clusters: usize,
388    /// Trajectory buffer capacity
389    pub trajectory_capacity: usize,
390    /// Background learning interval (ms)
391    pub background_interval_ms: u64,
392    /// Quality threshold for learning
393    pub quality_threshold: f32,
394    /// Enable SIMD optimizations
395    pub enable_simd: bool,
396}
397
398impl Default for SonaConfig {
399    fn default() -> Self {
400        // OPTIMIZED DEFAULTS based on @ruvector/sona v0.1.1 benchmarks:
401        // - Rank-2 is 5% faster than Rank-1 due to better SIMD vectorization
402        // - Learning rate 0.002 yields +55% quality improvement
403        // - 100 clusters = 1.3ms search vs 50 clusters = 3.0ms (2.3x faster)
404        // - EWC lambda 2000 optimal for catastrophic forgetting prevention
405        // - Quality threshold 0.3 balances learning vs noise filtering
406        Self {
407            hidden_dim: 256,
408            embedding_dim: 256,
409            micro_lora_rank: 2, // OPTIMIZED: Rank-2 faster than Rank-1 (2,211 vs 2,100 ops/sec)
410            base_lora_rank: 8,  // Balanced for production
411            micro_lora_lr: 0.002, // OPTIMIZED: +55.3% quality improvement
412            base_lora_lr: 0.0001,
413            ewc_lambda: 2000.0,    // OPTIMIZED: Better forgetting prevention
414            pattern_clusters: 100, // OPTIMIZED: 2.3x faster search (1.3ms vs 3.0ms)
415            trajectory_capacity: 10000,
416            background_interval_ms: 3600000, // 1 hour
417            quality_threshold: 0.15,         // Was 0.3; lowered 50% so patterns crystallize earlier
418            enable_simd: true,
419        }
420    }
421}
422
423impl SonaConfig {
424    /// Create config optimized for maximum throughput (real-time chat)
425    pub fn max_throughput() -> Self {
426        Self {
427            hidden_dim: 256,
428            embedding_dim: 256,
429            micro_lora_rank: 2,    // Rank-2 + SIMD = 2,211 ops/sec
430            base_lora_rank: 4,     // Minimal base for speed
431            micro_lora_lr: 0.0005, // Conservative for stability
432            base_lora_lr: 0.0001,
433            ewc_lambda: 2000.0,
434            pattern_clusters: 100,
435            trajectory_capacity: 5000,
436            background_interval_ms: 7200000, // 2 hours
437            quality_threshold: 0.4,
438            enable_simd: true,
439        }
440    }
441
442    /// Create config optimized for maximum quality (research/batch)
443    pub fn max_quality() -> Self {
444        Self {
445            hidden_dim: 256,
446            embedding_dim: 256,
447            micro_lora_rank: 2,
448            base_lora_rank: 16,   // Higher rank for expressiveness
449            micro_lora_lr: 0.002, // Optimal learning rate
450            base_lora_lr: 0.001,  // Aggressive base learning
451            ewc_lambda: 2000.0,
452            pattern_clusters: 100,
453            trajectory_capacity: 20000,
454            background_interval_ms: 1800000, // 30 minutes
455            quality_threshold: 0.2,          // Learn from more trajectories
456            enable_simd: true,
457        }
458    }
459
460    /// Create config for edge/mobile deployment (<5MB memory)
461    pub fn edge_deployment() -> Self {
462        Self {
463            hidden_dim: 256,
464            embedding_dim: 256,
465            micro_lora_rank: 1, // Minimal rank for memory
466            base_lora_rank: 4,
467            micro_lora_lr: 0.001,
468            base_lora_lr: 0.0001,
469            ewc_lambda: 1000.0,
470            pattern_clusters: 50,
471            trajectory_capacity: 200, // Small buffer
472            background_interval_ms: 3600000,
473            quality_threshold: 0.5,
474            enable_simd: true,
475        }
476    }
477
478    /// Create config for batch processing (50+ inferences/sec)
479    pub fn batch_processing() -> Self {
480        Self {
481            hidden_dim: 256,
482            embedding_dim: 256,
483            micro_lora_rank: 2,
484            base_lora_rank: 8,
485            micro_lora_lr: 0.001,
486            base_lora_lr: 0.0001,
487            ewc_lambda: 2000.0,
488            pattern_clusters: 100,
489            trajectory_capacity: 10000,
490            background_interval_ms: 3600000,
491            quality_threshold: 0.3,
492            enable_simd: true,
493        }
494    }
495
496    /// Create config for ephemeral agents (~5MB footprint)
497    ///
498    /// Optimized for lightweight federated learning nodes that collect
499    /// trajectories locally before aggregation.
500    pub fn for_ephemeral() -> Self {
501        Self {
502            hidden_dim: 256,
503            embedding_dim: 256,
504            micro_lora_rank: 2,
505            base_lora_rank: 4, // Small base for memory efficiency
506            micro_lora_lr: 0.002,
507            base_lora_lr: 0.0001,
508            ewc_lambda: 1000.0,
509            pattern_clusters: 50,          // Fewer clusters for memory
510            trajectory_capacity: 500,      // Local buffer before aggregation
511            background_interval_ms: 60000, // 1 minute for quick local updates
512            quality_threshold: 0.3,
513            enable_simd: true,
514        }
515    }
516
517    /// Create config for federated coordinator (central aggregation)
518    ///
519    /// Optimized for aggregating trajectories from multiple ephemeral agents
520    /// with larger capacity and pattern storage.
521    pub fn for_coordinator() -> Self {
522        Self {
523            hidden_dim: 256,
524            embedding_dim: 256,
525            micro_lora_rank: 2,
526            base_lora_rank: 16,             // Higher rank for aggregated learning
527            micro_lora_lr: 0.001,           // Conservative for stability
528            base_lora_lr: 0.0005,           // Moderate base learning
529            ewc_lambda: 2000.0,             // Strong forgetting prevention
530            pattern_clusters: 200,          // More clusters for diverse patterns
531            trajectory_capacity: 50000,     // Large capacity for aggregation
532            background_interval_ms: 300000, // 5 minutes consolidation
533            quality_threshold: 0.4,         // Higher threshold for quality filtering
534            enable_simd: true,
535        }
536    }
537}
538
539#[cfg(test)]
540mod tests {
541    use super::*;
542
543    #[test]
544    fn test_learning_signal_from_trajectory() {
545        let mut trajectory = QueryTrajectory::new(1, vec![0.1, 0.2, 0.3]);
546        trajectory.add_step(TrajectoryStep::new(
547            vec![0.5, 0.3, 0.2],
548            vec![0.4, 0.4, 0.2],
549            0.8,
550            0,
551        ));
552        trajectory.finalize(0.8, 1000);
553
554        let signal = LearningSignal::from_trajectory(&trajectory);
555        assert_eq!(signal.quality_score, 0.8);
556        assert_eq!(signal.gradient_estimate.len(), 3);
557        assert_eq!(signal.metadata.trajectory_id, 1);
558    }
559
560    #[test]
561    fn test_gradient_nonzero_for_single_step_trajectory() {
562        // Regression test for #519: single-step (or constant-reward)
563        // trajectories used to yield an exact-zero REINFORCE gradient
564        // (advantage = reward - baseline = 0), so feedback never learned.
565        let mut trajectory = QueryTrajectory::new(1, vec![0.1; 8]);
566        trajectory.add_step(TrajectoryStep::new(vec![0.5; 8], vec![], 0.9, 0));
567        trajectory.finalize(0.9, 1000);
568
569        let signal = LearningSignal::from_trajectory(&trajectory);
570        let norm: f32 = signal
571            .gradient_estimate
572            .iter()
573            .map(|x| x * x)
574            .sum::<f32>()
575            .sqrt();
576        assert!(
577            norm > 1e-6,
578            "Expected non-zero gradient for single-step trajectory, norm={}",
579            norm
580        );
581
582        // Negative reward should flip the gradient direction.
583        let mut neg = QueryTrajectory::new(2, vec![0.1; 8]);
584        neg.add_step(TrajectoryStep::new(vec![0.5; 8], vec![], -0.9, 0));
585        neg.finalize(0.9, 1000);
586        let neg_signal = LearningSignal::from_trajectory(&neg);
587        let dot: f32 = signal
588            .gradient_estimate
589            .iter()
590            .zip(neg_signal.gradient_estimate.iter())
591            .map(|(a, b)| a * b)
592            .sum();
593        assert!(
594            dot < 0.0,
595            "Negative reward should flip gradient, dot={}",
596            dot
597        );
598    }
599
600    #[test]
601    fn test_gradient_unchanged_for_varying_reward_trajectory() {
602        // The baselined REINFORCE path must remain in effect when step
603        // rewards vary (non-degenerate case).
604        let mut trajectory = QueryTrajectory::new(1, vec![0.1; 4]);
605        trajectory.add_step(TrajectoryStep::new(
606            vec![1.0, 0.0, 0.0, 0.0],
607            vec![],
608            0.2,
609            0,
610        ));
611        trajectory.add_step(TrajectoryStep::new(
612            vec![0.0, 1.0, 0.0, 0.0],
613            vec![],
614            0.8,
615            1,
616        ));
617        trajectory.finalize(0.8, 1000);
618
619        let signal = LearningSignal::from_trajectory(&trajectory);
620        // advantages: -0.3 and +0.3 -> gradient ∝ (-0.3, 0.3, 0, 0), normalized
621        assert!(signal.gradient_estimate[0] < 0.0);
622        assert!(signal.gradient_estimate[1] > 0.0);
623        let norm: f32 = signal
624            .gradient_estimate
625            .iter()
626            .map(|x| x * x)
627            .sum::<f32>()
628            .sqrt();
629        assert!((norm - 1.0).abs() < 1e-4);
630    }
631
632    #[test]
633    fn test_pattern_merge() {
634        let p1 = LearnedPattern {
635            id: 1,
636            centroid: vec![1.0, 0.0],
637            cluster_size: 10,
638            total_weight: 5.0,
639            avg_quality: 0.8,
640            created_at: 100,
641            last_accessed: 200,
642            access_count: 5,
643            pattern_type: PatternType::General,
644        };
645
646        let p2 = LearnedPattern {
647            id: 2,
648            centroid: vec![0.0, 1.0],
649            cluster_size: 10,
650            total_weight: 5.0,
651            avg_quality: 0.9,
652            created_at: 150,
653            last_accessed: 250,
654            access_count: 3,
655            pattern_type: PatternType::General,
656        };
657
658        let merged = p1.merge(&p2);
659        assert_eq!(merged.cluster_size, 20);
660        assert!((merged.centroid[0] - 0.5).abs() < 1e-6);
661        assert!((merged.centroid[1] - 0.5).abs() < 1e-6);
662        assert!((merged.avg_quality - 0.85).abs() < 1e-6);
663    }
664
665    #[test]
666    fn test_pattern_similarity() {
667        let pattern = LearnedPattern::new(1, vec![1.0, 0.0, 0.0]);
668
669        assert!((pattern.similarity(&[1.0, 0.0, 0.0]) - 1.0).abs() < 1e-6);
670        assert!(pattern.similarity(&[0.0, 1.0, 0.0]).abs() < 1e-6);
671    }
672
673    #[test]
674    fn test_trajectory_rewards() {
675        let mut trajectory = QueryTrajectory::new(1, vec![0.1]);
676        trajectory.add_step(TrajectoryStep::new(vec![], vec![], 0.5, 0));
677        trajectory.add_step(TrajectoryStep::new(vec![], vec![], 0.7, 1));
678        trajectory.add_step(TrajectoryStep::new(vec![], vec![], 0.9, 2));
679
680        assert!((trajectory.total_reward() - 2.1).abs() < 1e-6);
681        assert!((trajectory.avg_reward() - 0.7).abs() < 1e-6);
682    }
683}