1use pulsedb::{Experience, ExperienceId};
15
16#[derive(Debug, Clone)]
18pub struct AttractorConfig {
19 pub default_radius: f32,
22 pub default_warp_factor: f32,
25 pub reinforcement_boost: f32,
28}
29
30impl Default for AttractorConfig {
31 fn default() -> Self {
32 Self {
33 default_radius: 0.3,
34 default_warp_factor: 1.0,
35 reinforcement_boost: 0.1,
36 }
37 }
38}
39
40#[derive(Debug, Clone)]
45pub struct AttractorDynamics {
46 pub experience_id: ExperienceId,
48 pub strength: f32,
50 pub radius: f32,
52 pub warp_factor: f32,
54}
55
56impl AttractorDynamics {
57 pub fn from_experience(exp: &Experience, config: &AttractorConfig) -> Self {
61 let reinforcement = 1.0 + (exp.applications as f32 * config.reinforcement_boost);
62 Self {
63 experience_id: exp.id,
64 strength: exp.importance * exp.confidence * reinforcement,
65 radius: config.default_radius,
66 warp_factor: config.default_warp_factor,
67 }
68 }
69
70 pub fn influence_at(&self, query_embedding: &[f32], experience_embedding: &[f32]) -> f32 {
76 if query_embedding.is_empty() || experience_embedding.is_empty() {
77 return 0.0;
78 }
79 let distance = cosine_distance(query_embedding, experience_embedding);
80 if distance > self.radius {
81 return 0.0;
82 }
83 self.strength * (1.0 - distance / self.radius) * self.warp_factor
84 }
85}
86
87pub fn cosine_distance(a: &[f32], b: &[f32]) -> f32 {
91 if a.is_empty() || b.is_empty() || a.len() != b.len() {
92 return 1.0;
93 }
94
95 let mut dot = 0.0_f32;
96 let mut norm_a = 0.0_f32;
97 let mut norm_b = 0.0_f32;
98
99 for (ai, bi) in a.iter().zip(b.iter()) {
100 dot += ai * bi;
101 norm_a += ai * ai;
102 norm_b += bi * bi;
103 }
104
105 let denominator = norm_a.sqrt() * norm_b.sqrt();
106 if denominator < f32::EPSILON {
107 return 1.0;
108 }
109
110 let similarity = (dot / denominator).clamp(-1.0, 1.0);
111 1.0 - similarity
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117 use pulsedb::{AgentId, CollectiveId, ExperienceType, Timestamp};
118
119 fn mock_experience(importance: f32, confidence: f32, applications: u32) -> Experience {
120 Experience {
121 id: ExperienceId::new(),
122 collective_id: CollectiveId::new(),
123 content: "test".to_string(),
124 experience_type: ExperienceType::Generic { category: None },
125 embedding: vec![1.0, 0.0, 0.0],
126 importance,
127 confidence,
128 applications,
129 domain: vec![],
130 source_agent: AgentId("test".to_string()),
131 source_task: None,
132 related_files: vec![],
133 timestamp: Timestamp::now(),
134 archived: false,
135 }
136 }
137
138 #[test]
141 fn test_cosine_distance_identical_vectors() {
142 let a = vec![1.0, 0.0, 0.0];
143 let dist = cosine_distance(&a, &a);
144 assert!(
145 dist.abs() < 0.001,
146 "Identical vectors should have distance ~0, got {dist}"
147 );
148 }
149
150 #[test]
151 fn test_cosine_distance_orthogonal_vectors() {
152 let a = vec![1.0, 0.0, 0.0];
153 let b = vec![0.0, 1.0, 0.0];
154 let dist = cosine_distance(&a, &b);
155 assert!(
156 (dist - 1.0).abs() < 0.001,
157 "Orthogonal vectors should have distance ~1, got {dist}"
158 );
159 }
160
161 #[test]
162 fn test_cosine_distance_opposite_vectors() {
163 let a = vec![1.0, 0.0];
164 let b = vec![-1.0, 0.0];
165 let dist = cosine_distance(&a, &b);
166 assert!(
167 (dist - 2.0).abs() < 0.001,
168 "Opposite vectors should have distance ~2, got {dist}"
169 );
170 }
171
172 #[test]
173 fn test_cosine_distance_empty_vectors() {
174 assert_eq!(cosine_distance(&[], &[1.0]), 1.0);
175 assert_eq!(cosine_distance(&[1.0], &[]), 1.0);
176 assert_eq!(cosine_distance(&[], &[]), 1.0);
177 }
178
179 #[test]
180 fn test_cosine_distance_mismatched_lengths() {
181 assert_eq!(cosine_distance(&[1.0], &[1.0, 2.0]), 1.0);
182 }
183
184 #[test]
185 fn test_cosine_distance_zero_vectors() {
186 let zero = vec![0.0, 0.0, 0.0];
187 let dist = cosine_distance(&zero, &[1.0, 0.0, 0.0]);
188 assert_eq!(dist, 1.0);
189 }
190
191 #[test]
194 fn test_from_experience_strength_formula() {
195 let config = AttractorConfig {
196 reinforcement_boost: 0.1,
197 ..Default::default()
198 };
199 let exp = mock_experience(0.8, 0.9, 5);
200 let attractor = AttractorDynamics::from_experience(&exp, &config);
201
202 assert!(
204 (attractor.strength - 1.08).abs() < 0.001,
205 "Expected strength ~1.08, got {}",
206 attractor.strength
207 );
208 }
209
210 #[test]
211 fn test_from_experience_zero_applications() {
212 let config = AttractorConfig::default();
213 let exp = mock_experience(0.5, 0.5, 0);
214 let attractor = AttractorDynamics::from_experience(&exp, &config);
215 assert!(
217 (attractor.strength - 0.25).abs() < 0.001,
218 "Expected strength ~0.25, got {}",
219 attractor.strength
220 );
221 }
222
223 #[test]
224 fn test_influence_at_zero_distance() {
225 let config = AttractorConfig::default();
226 let exp = mock_experience(1.0, 1.0, 0);
227 let attractor = AttractorDynamics::from_experience(&exp, &config);
228
229 let emb = vec![1.0, 0.0, 0.0];
230 let influence = attractor.influence_at(&emb, &emb);
231 assert!(
233 (influence - 1.0).abs() < 0.001,
234 "Expected influence ~1.0 at zero distance, got {influence}"
235 );
236 }
237
238 #[test]
239 fn test_influence_at_beyond_radius() {
240 let config = AttractorConfig {
241 default_radius: 0.1,
242 ..Default::default()
243 };
244 let exp = mock_experience(1.0, 1.0, 0);
245 let attractor = AttractorDynamics::from_experience(&exp, &config);
246
247 let q = vec![1.0, 0.0, 0.0];
248 let e = vec![0.0, 1.0, 0.0]; assert_eq!(
250 attractor.influence_at(&q, &e),
251 0.0,
252 "Beyond radius should return 0"
253 );
254 }
255
256 #[test]
257 fn test_influence_at_empty_embedding() {
258 let config = AttractorConfig::default();
259 let exp = mock_experience(1.0, 1.0, 0);
260 let attractor = AttractorDynamics::from_experience(&exp, &config);
261
262 assert_eq!(attractor.influence_at(&[], &[1.0]), 0.0);
263 assert_eq!(attractor.influence_at(&[1.0], &[]), 0.0);
264 }
265
266 #[test]
267 fn test_influence_linear_falloff() {
268 let config = AttractorConfig {
269 default_radius: 1.0,
270 default_warp_factor: 1.0,
271 reinforcement_boost: 0.0,
272 };
273 let exp = mock_experience(1.0, 1.0, 0);
274 let attractor = AttractorDynamics::from_experience(&exp, &config);
275
276 let q = vec![1.0, 0.0];
279 let e = vec![0.707, 0.707]; let influence = attractor.influence_at(&q, &e);
281 assert!(
283 influence > 0.5 && influence < 1.0,
284 "Expected partial influence, got {influence}"
285 );
286 }
287
288 #[test]
289 fn test_warp_factor_scales_influence() {
290 let config_low = AttractorConfig {
291 default_warp_factor: 0.5,
292 ..Default::default()
293 };
294 let config_high = AttractorConfig {
295 default_warp_factor: 2.0,
296 ..Default::default()
297 };
298
299 let exp = mock_experience(1.0, 1.0, 0);
300 let a_low = AttractorDynamics::from_experience(&exp, &config_low);
301 let a_high = AttractorDynamics::from_experience(&exp, &config_high);
302
303 let emb = vec![1.0, 0.0, 0.0];
304 let inf_low = a_low.influence_at(&emb, &emb);
305 let inf_high = a_high.influence_at(&emb, &emb);
306
307 assert!(
308 inf_high > inf_low,
309 "Higher warp_factor should produce stronger influence: {inf_high} vs {inf_low}"
310 );
311 assert!(
312 (inf_high / inf_low - 4.0).abs() < 0.001,
313 "Should scale by 4x"
314 );
315 }
316}