Skip to main content

pulsehive_runtime/intelligence/
relationship.rs

1//! Automatic relationship inference between experiences.
2//!
3//! When a new experience is recorded, the [`RelationshipDetector`] searches for
4//! semantically similar experiences and creates typed relations based on
5//! ExperienceType pair heuristics (e.g., Difficulty + Solution → Supports).
6
7use pulsedb::{Experience, NewExperienceRelation, RelationType, SubstrateProvider};
8use tracing::Instrument;
9
10/// Configuration for automatic relationship detection.
11#[derive(Debug, Clone)]
12pub struct RelationshipDetectorConfig {
13    /// Similarity threshold for automatic relation creation.
14    /// Pairs above this threshold get relations created automatically.
15    /// Default: 0.85
16    pub auto_threshold: f32,
17
18    /// Lower bound for suggested relations (used with LLM classification).
19    /// Pairs between suggest_threshold and auto_threshold may be classified by LLM.
20    /// Default: 0.65
21    pub suggest_threshold: f32,
22
23    /// Whether to use LLM classification for pairs in the suggest range.
24    /// Default: false
25    pub use_llm_classification: bool,
26}
27
28impl Default for RelationshipDetectorConfig {
29    fn default() -> Self {
30        Self {
31            auto_threshold: 0.85,
32            suggest_threshold: 0.65,
33            use_llm_classification: false,
34        }
35    }
36}
37
38/// Detects relationships between experiences based on semantic similarity
39/// and ExperienceType heuristics.
40///
41/// Created via [`RelationshipDetector::new()`] with a [`RelationshipDetectorConfig`].
42pub struct RelationshipDetector {
43    config: RelationshipDetectorConfig,
44}
45
46impl RelationshipDetector {
47    /// Create a new detector with the given configuration.
48    pub fn new(config: RelationshipDetectorConfig) -> Self {
49        Self { config }
50    }
51
52    /// Create a new detector with default configuration.
53    pub fn with_defaults() -> Self {
54        Self::new(RelationshipDetectorConfig::default())
55    }
56
57    /// Access the configuration.
58    pub fn config(&self) -> &RelationshipDetectorConfig {
59        &self.config
60    }
61
62    /// Find semantically similar experiences and create relations for high-similarity pairs.
63    ///
64    /// Searches for the top 20 similar experiences in the same collective. For each pair
65    /// with similarity above `auto_threshold`, creates a [`NewExperienceRelation`] with
66    /// the similarity score as strength.
67    ///
68    /// Returns the relations to be stored — the caller is responsible for calling
69    /// `substrate.store_relation()` and emitting events.
70    pub async fn infer_relations(
71        &self,
72        experience: &Experience,
73        substrate: &dyn SubstrateProvider,
74    ) -> Vec<NewExperienceRelation> {
75        // Search for top-20 similar experiences
76        let similar = match substrate
77            .search_similar(experience.collective_id, &experience.embedding, 20)
78            .instrument(tracing::debug_span!("infer_relations", experience_id = %experience.id))
79            .await
80        {
81            Ok(results) => results,
82            Err(e) => {
83                tracing::warn!(error = %e, "RelationshipDetector: search_similar failed");
84                return Vec::new();
85            }
86        };
87
88        similar
89            .into_iter()
90            .filter(|(target, similarity)| {
91                // Exclude self-matches and below-threshold pairs
92                target.id != experience.id && *similarity >= self.config.auto_threshold
93            })
94            .map(|(target, similarity)| {
95                let relation_type =
96                    classify_relation_type(&experience.experience_type, &target.experience_type);
97
98                NewExperienceRelation {
99                    source_id: experience.id,
100                    target_id: target.id,
101                    relation_type,
102                    strength: similarity,
103                    metadata: None,
104                }
105            })
106            .collect()
107    }
108}
109
110/// Classify the relation type based on ExperienceType pair heuristics.
111///
112/// Rules (from SRS FR-018):
113/// - Difficulty + Solution → Supports
114/// - ErrorPattern + ErrorPattern → Supersedes
115/// - ArchitecturalDecision + TechInsight → Implies
116/// - Default → RelatedTo
117fn classify_relation_type(
118    source: &pulsedb::ExperienceType,
119    target: &pulsedb::ExperienceType,
120) -> RelationType {
121    use pulsedb::ExperienceType;
122
123    match (source, target) {
124        (ExperienceType::Difficulty { .. }, ExperienceType::Solution { .. })
125        | (ExperienceType::Solution { .. }, ExperienceType::Difficulty { .. }) => {
126            RelationType::Supports
127        }
128        (ExperienceType::ErrorPattern { .. }, ExperienceType::ErrorPattern { .. }) => {
129            RelationType::Supersedes
130        }
131        (ExperienceType::ArchitecturalDecision { .. }, ExperienceType::TechInsight { .. })
132        | (ExperienceType::TechInsight { .. }, ExperienceType::ArchitecturalDecision { .. }) => {
133            RelationType::Implies
134        }
135        _ => RelationType::RelatedTo,
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use pulsedb::{ExperienceType, RelationType, Severity};
143
144    #[test]
145    fn test_classify_difficulty_solution_supports() {
146        let source = ExperienceType::Difficulty {
147            description: "network timeout".into(),
148            severity: Severity::Medium,
149        };
150        let target = ExperienceType::Solution {
151            problem_ref: None,
152            approach: "add retry".into(),
153            worked: true,
154        };
155        assert_eq!(
156            classify_relation_type(&source, &target),
157            RelationType::Supports
158        );
159        assert_eq!(
160            classify_relation_type(&target, &source),
161            RelationType::Supports
162        );
163    }
164
165    #[test]
166    fn test_classify_error_error_supersedes() {
167        let source = ExperienceType::ErrorPattern {
168            signature: "timeout".into(),
169            fix: "retry".into(),
170            prevention: "set timeout".into(),
171        };
172        let target = ExperienceType::ErrorPattern {
173            signature: "timeout_v2".into(),
174            fix: "circuit breaker".into(),
175            prevention: "backoff".into(),
176        };
177        assert_eq!(
178            classify_relation_type(&source, &target),
179            RelationType::Supersedes
180        );
181    }
182
183    #[test]
184    fn test_classify_decision_insight_implies() {
185        let source = ExperienceType::ArchitecturalDecision {
186            decision: "use circuit breaker".into(),
187            rationale: "resilience".into(),
188        };
189        let target = ExperienceType::TechInsight {
190            technology: "tokio".into(),
191            insight: "spawn_blocking for CPU".into(),
192        };
193        assert_eq!(
194            classify_relation_type(&source, &target),
195            RelationType::Implies
196        );
197        assert_eq!(
198            classify_relation_type(&target, &source),
199            RelationType::Implies
200        );
201    }
202
203    #[test]
204    fn test_classify_default_related_to() {
205        let source = ExperienceType::Generic { category: None };
206        let target = ExperienceType::Generic { category: None };
207        assert_eq!(
208            classify_relation_type(&source, &target),
209            RelationType::RelatedTo
210        );
211    }
212
213    #[test]
214    fn test_config_defaults() {
215        let config = RelationshipDetectorConfig::default();
216        assert!((config.auto_threshold - 0.85).abs() < f32::EPSILON);
217        assert!((config.suggest_threshold - 0.65).abs() < f32::EPSILON);
218        assert!(!config.use_llm_classification);
219    }
220}