Skip to main content

pulsehive_runtime/
field.rs

1//! Field dynamics — attractor-based perception warping.
2//!
3//! [`AttractorDynamics`] models how high-importance experiences pull nearby queries
4//! toward themselves, creating "gravitational wells" in embedding space. This enables
5//! strong knowledge patterns to attract agent attention proportional to their strength.
6//!
7//! # Example
8//! ```rust,ignore
9//! let config = AttractorConfig::default();
10//! let attractor = AttractorDynamics::from_experience(&experience, &config);
11//! let influence = attractor.influence_at(&query_embedding, &experience.embedding);
12//! ```
13
14use pulsedb::{Experience, ExperienceId};
15
16/// Configuration for attractor dynamics computation.
17#[derive(Debug, Clone)]
18pub struct AttractorConfig {
19    /// Default influence radius in embedding space (cosine distance).
20    /// Experiences beyond this radius have zero attractor influence.
21    pub default_radius: f32,
22    /// How strongly attractors pull nearby queries.
23    /// Higher values make high-strength experiences more dominant.
24    pub default_warp_factor: f32,
25    /// Boost per application count when computing strength.
26    /// `strength = importance * confidence * (1 + applications * boost)`
27    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/// Attractor dynamics for a single experience — its gravitational properties
41/// in embedding space.
42///
43/// Computed at query time from experience fields (not pre-stored).
44#[derive(Debug, Clone)]
45pub struct AttractorDynamics {
46    /// Experience this attractor represents.
47    pub experience_id: ExperienceId,
48    /// Combined strength: `importance * confidence * reinforcement`.
49    pub strength: f32,
50    /// Influence radius in embedding space (cosine distance).
51    pub radius: f32,
52    /// How strongly this attractor pulls nearby queries.
53    pub warp_factor: f32,
54}
55
56impl AttractorDynamics {
57    /// Compute attractor dynamics from an experience's fields.
58    ///
59    /// Strength formula: `importance * confidence * (1 + applications * reinforcement_boost)`
60    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    /// Compute the influence of this attractor on a query at the given position.
71    ///
72    /// Returns 0.0 if the query is beyond the attractor's radius.
73    /// Returns `strength * warp_factor` at distance 0.0.
74    /// Linear falloff between 0 and radius.
75    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
87/// Compute cosine distance between two vectors: `1.0 - cosine_similarity`.
88///
89/// Returns 1.0 (maximum distance) for empty, zero-length, or orthogonal vectors.
90pub 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    // ── cosine_distance tests ─────────────────────────────────────────
139
140    #[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    // ── AttractorDynamics tests ──────────────────────────────────────
192
193    #[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        // strength = 0.8 * 0.9 * (1 + 5 * 0.1) = 0.72 * 1.5 = 1.08
203        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        // strength = 0.5 * 0.5 * 1.0 = 0.25
216        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        // distance = 0, influence = strength * (1 - 0/radius) * warp = 1.0 * 1.0 * 1.0 = 1.0
232        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]; // orthogonal = distance 1.0
249        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        // At distance 0.5 from center, influence should be ~0.5
277        // Use vectors that produce cosine distance ~0.5
278        let q = vec![1.0, 0.0];
279        let e = vec![0.707, 0.707]; // ~45 degrees, cosine_sim ~0.707, distance ~0.293
280        let influence = attractor.influence_at(&q, &e);
281        // Influence at distance 0.293 with radius 1.0 = 1.0 * (1 - 0.293) * 1.0 ≈ 0.707
282        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}