pulsehive_runtime/intelligence/
relationship.rs1use pulsedb::{Experience, NewExperienceRelation, RelationType, SubstrateProvider};
8use tracing::Instrument;
9
10#[derive(Debug, Clone)]
12pub struct RelationshipDetectorConfig {
13 pub auto_threshold: f32,
17
18 pub suggest_threshold: f32,
22
23 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
38pub struct RelationshipDetector {
43 config: RelationshipDetectorConfig,
44}
45
46impl RelationshipDetector {
47 pub fn new(config: RelationshipDetectorConfig) -> Self {
49 Self { config }
50 }
51
52 pub fn with_defaults() -> Self {
54 Self::new(RelationshipDetectorConfig::default())
55 }
56
57 pub fn config(&self) -> &RelationshipDetectorConfig {
59 &self.config
60 }
61
62 pub async fn infer_relations(
71 &self,
72 experience: &Experience,
73 substrate: &dyn SubstrateProvider,
74 ) -> Vec<NewExperienceRelation> {
75 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 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
110fn 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}