Skip to main content

brainwires_cognition/knowledge/
entity.rs

1//! Entity Types for Knowledge Graph
2//!
3//! Types for representing extracted entities and their relationships
4//! in conversation memory. Used by the relationship graph and knowledge system.
5
6use std::collections::{HashMap, HashSet};
7
8// Re-export EntityType from core (canonical definition)
9pub use brainwires_core::graph::EntityType;
10
11/// A named entity extracted from conversation
12#[derive(Debug, Clone)]
13pub struct Entity {
14    /// Display name of the entity.
15    pub name: String,
16    /// The kind of entity (file, function, type, etc.).
17    pub entity_type: EntityType,
18    /// Message IDs where this entity appears.
19    pub message_ids: Vec<String>,
20    /// Unix timestamp when first seen.
21    pub first_seen: i64,
22    /// Unix timestamp when last seen.
23    pub last_seen: i64,
24    /// Total number of mentions.
25    pub mention_count: u32,
26}
27
28impl Entity {
29    /// Create a new entity with its first mention.
30    pub fn new(name: String, entity_type: EntityType, message_id: String, timestamp: i64) -> Self {
31        Self {
32            name,
33            entity_type,
34            message_ids: vec![message_id],
35            first_seen: timestamp,
36            last_seen: timestamp,
37            mention_count: 1,
38        }
39    }
40
41    /// Record an additional mention of this entity.
42    pub fn add_mention(&mut self, message_id: String, timestamp: i64) {
43        if !self.message_ids.contains(&message_id) {
44            self.message_ids.push(message_id);
45        }
46        self.last_seen = timestamp.max(self.last_seen);
47        self.mention_count += 1;
48    }
49}
50
51/// Relationship between entities
52#[derive(Debug, Clone)]
53pub enum Relationship {
54    /// One entity defines another.
55    Defines {
56        /// The defining entity.
57        definer: String,
58        /// The entity being defined.
59        defined: String,
60        /// Context of the definition.
61        context: String,
62    },
63    /// One entity references another.
64    References {
65        /// Source entity.
66        from: String,
67        /// Target entity.
68        to: String,
69    },
70    /// One entity modifies another.
71    Modifies {
72        /// The modifying entity.
73        modifier: String,
74        /// The modified entity.
75        modified: String,
76        /// Kind of modification.
77        change_type: String,
78    },
79    /// One entity depends on another.
80    DependsOn {
81        /// The dependent entity.
82        dependent: String,
83        /// The dependency.
84        dependency: String,
85    },
86    /// One entity contains another.
87    Contains {
88        /// The container entity.
89        container: String,
90        /// The contained entity.
91        contained: String,
92    },
93    /// Two entities co-occur in a message.
94    CoOccurs {
95        /// First entity.
96        entity_a: String,
97        /// Second entity.
98        entity_b: String,
99        /// Message where co-occurrence was observed.
100        message_id: String,
101    },
102}
103
104/// Extraction result from a single message
105#[derive(Debug, Clone)]
106pub struct ExtractionResult {
107    /// Extracted entities as (name, type) pairs.
108    pub entities: Vec<(String, EntityType)>,
109    /// Extracted relationships between entities.
110    pub relationships: Vec<Relationship>,
111}
112
113// ── Memory poisoning detection ────────────────────────────────────────────────
114
115/// Why two stored facts were flagged as a potential contradiction.
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum ContradictionKind {
118    /// Two `Defines` relationships share the same definer + defined but have different contexts.
119    ConflictingDefinition,
120    /// Two `Modifies` relationships describe different change types for the same modifier + target.
121    ConflictingModification,
122}
123
124/// A potential contradiction detected when inserting a new fact.
125///
126/// The store does **not** silently overwrite the existing entry; instead it
127/// appends both relationships and records this event so callers can surface it
128/// for human review.
129#[derive(Debug, Clone)]
130pub struct ContradictionEvent {
131    /// What kind of contradiction was detected.
132    pub kind: ContradictionKind,
133    /// The entity key (e.g. `"file:main.rs"`) involved.
134    pub subject: String,
135    /// Context string from the previously stored relationship.
136    pub existing_context: String,
137    /// Context string from the newly inserted relationship.
138    pub new_context: String,
139}
140
141// ── Entity store ──────────────────────────────────────────────────────────────
142
143/// Entity store for tracking entities across a conversation
144#[derive(Debug, Default)]
145pub struct EntityStore {
146    entities: HashMap<String, Entity>,
147    relationships: Vec<Relationship>,
148    /// Contradiction events accumulated since the last call to [`drain_contradictions`].
149    contradictions: Vec<ContradictionEvent>,
150}
151
152impl EntityStore {
153    /// Create a new empty entity store.
154    pub fn new() -> Self {
155        Self::default()
156    }
157
158    /// Add an extraction result, recording entities and relationships.
159    pub fn add_extraction(&mut self, result: ExtractionResult, message_id: &str, timestamp: i64) {
160        for (name, entity_type) in result.entities {
161            let key = format!("{}:{}", entity_type.as_str(), name);
162            if let Some(entity) = self.entities.get_mut(&key) {
163                entity.add_mention(message_id.to_string(), timestamp);
164            } else {
165                self.entities.insert(
166                    key,
167                    Entity::new(name, entity_type, message_id.to_string(), timestamp),
168                );
169            }
170        }
171
172        // Check for contradictions before appending each relationship.
173        for new_rel in result.relationships {
174            self.check_and_record_contradiction(&new_rel);
175            self.relationships.push(new_rel);
176        }
177    }
178
179    /// Inspect `new_rel` against previously stored relationships and record any
180    /// contradiction events.  Both the existing and the new relationship are kept
181    /// so no information is silently discarded.
182    fn check_and_record_contradiction(&mut self, new_rel: &Relationship) {
183        match new_rel {
184            Relationship::Defines {
185                definer,
186                defined,
187                context: new_ctx,
188            } => {
189                for existing in &self.relationships {
190                    if let Relationship::Defines {
191                        definer: ex_definer,
192                        defined: ex_defined,
193                        context: ex_ctx,
194                    } = existing
195                        && ex_definer == definer
196                        && ex_defined == defined
197                        && ex_ctx != new_ctx
198                    {
199                        self.contradictions.push(ContradictionEvent {
200                            kind: ContradictionKind::ConflictingDefinition,
201                            subject: format!("{}::{}", definer, defined),
202                            existing_context: ex_ctx.clone(),
203                            new_context: new_ctx.clone(),
204                        });
205                        break;
206                    }
207                }
208            }
209            Relationship::Modifies {
210                modifier,
211                modified,
212                change_type: new_change,
213            } => {
214                for existing in &self.relationships {
215                    if let Relationship::Modifies {
216                        modifier: ex_modifier,
217                        modified: ex_modified,
218                        change_type: ex_change,
219                    } = existing
220                        && ex_modifier == modifier
221                        && ex_modified == modified
222                        && ex_change != new_change
223                    {
224                        self.contradictions.push(ContradictionEvent {
225                            kind: ContradictionKind::ConflictingModification,
226                            subject: format!("{}::{}", modifier, modified),
227                            existing_context: ex_change.clone(),
228                            new_context: new_change.clone(),
229                        });
230                        break;
231                    }
232                }
233            }
234            _ => {}
235        }
236    }
237
238    /// Returns all contradiction events accumulated so far (for inspection /
239    /// human review) without removing them.
240    pub fn pending_contradictions(&self) -> &[ContradictionEvent] {
241        &self.contradictions
242    }
243
244    /// Drains and returns all accumulated contradiction events, clearing the
245    /// internal buffer.
246    pub fn drain_contradictions(&mut self) -> Vec<ContradictionEvent> {
247        std::mem::take(&mut self.contradictions)
248    }
249
250    /// Look up an entity by name and type.
251    pub fn get(&self, name: &str, entity_type: &EntityType) -> Option<&Entity> {
252        let key = format!("{}:{}", entity_type.as_str(), name);
253        self.entities.get(&key)
254    }
255
256    /// Get all entities of a given type.
257    pub fn get_by_type(&self, entity_type: &EntityType) -> Vec<&Entity> {
258        self.entities
259            .values()
260            .filter(|e| &e.entity_type == entity_type)
261            .collect()
262    }
263
264    /// Get the most-mentioned entities, up to `limit`.
265    pub fn get_top_entities(&self, limit: usize) -> Vec<&Entity> {
266        let mut entities: Vec<_> = self.entities.values().collect();
267        entities.sort_by(|a, b| b.mention_count.cmp(&a.mention_count));
268        entities.into_iter().take(limit).collect()
269    }
270
271    /// Get names of entities related to the given entity.
272    pub fn get_related(&self, entity_name: &str) -> Vec<String> {
273        let mut related = HashSet::new();
274        for rel in &self.relationships {
275            match rel {
276                Relationship::CoOccurs {
277                    entity_a, entity_b, ..
278                } => {
279                    if entity_a == entity_name {
280                        related.insert(entity_b.clone());
281                    } else if entity_b == entity_name {
282                        related.insert(entity_a.clone());
283                    }
284                }
285                Relationship::Contains {
286                    container,
287                    contained,
288                } => {
289                    if container == entity_name {
290                        related.insert(contained.clone());
291                    } else if contained == entity_name {
292                        related.insert(container.clone());
293                    }
294                }
295                Relationship::References { from, to } => {
296                    if from == entity_name {
297                        related.insert(to.clone());
298                    } else if to == entity_name {
299                        related.insert(from.clone());
300                    }
301                }
302                Relationship::DependsOn {
303                    dependent,
304                    dependency,
305                } => {
306                    if dependent == entity_name {
307                        related.insert(dependency.clone());
308                    } else if dependency == entity_name {
309                        related.insert(dependent.clone());
310                    }
311                }
312                Relationship::Modifies {
313                    modifier, modified, ..
314                } => {
315                    if modifier == entity_name {
316                        related.insert(modified.clone());
317                    } else if modified == entity_name {
318                        related.insert(modifier.clone());
319                    }
320                }
321                Relationship::Defines {
322                    definer, defined, ..
323                } => {
324                    if definer == entity_name {
325                        related.insert(defined.clone());
326                    } else if defined == entity_name {
327                        related.insert(definer.clone());
328                    }
329                }
330            }
331        }
332        related.into_iter().collect()
333    }
334
335    /// Get all message IDs associated with an entity name.
336    pub fn get_message_ids(&self, entity_name: &str) -> Vec<String> {
337        self.entities
338            .values()
339            .filter(|e| e.name == entity_name)
340            .flat_map(|e| e.message_ids.clone())
341            .collect()
342    }
343
344    /// Iterate over all stored entities.
345    pub fn all_entities(&self) -> impl Iterator<Item = &Entity> {
346        self.entities.values()
347    }
348
349    /// Get all stored relationships.
350    pub fn all_relationships(&self) -> &[Relationship] {
351        &self.relationships
352    }
353
354    /// Get statistics about the entity store.
355    pub fn stats(&self) -> EntityStoreStats {
356        let mut by_type = HashMap::new();
357        for entity in self.entities.values() {
358            *by_type.entry(entity.entity_type.as_str()).or_insert(0) += 1;
359        }
360        EntityStoreStats {
361            total_entities: self.entities.len(),
362            total_relationships: self.relationships.len(),
363            entities_by_type: by_type,
364        }
365    }
366}
367
368impl brainwires_core::graph::EntityStoreT for EntityStore {
369    fn entity_names_by_type(&self, entity_type: &EntityType) -> Vec<String> {
370        self.get_by_type(entity_type)
371            .iter()
372            .map(|e| e.name.clone())
373            .collect()
374    }
375
376    fn top_entity_info(&self, limit: usize) -> Vec<(String, EntityType)> {
377        self.get_top_entities(limit)
378            .iter()
379            .map(|e| (e.name.clone(), e.entity_type.clone()))
380            .collect()
381    }
382}
383
384/// Statistics about the entity store.
385#[derive(Debug)]
386pub struct EntityStoreStats {
387    /// Total number of entities.
388    pub total_entities: usize,
389    /// Total number of relationships.
390    pub total_relationships: usize,
391    /// Entity counts grouped by type.
392    pub entities_by_type: HashMap<&'static str, usize>,
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398
399    #[test]
400    fn test_entity_type_as_str() {
401        assert_eq!(EntityType::File.as_str(), "file");
402        assert_eq!(EntityType::Function.as_str(), "function");
403    }
404
405    #[test]
406    fn test_entity_lifecycle() {
407        let mut entity = Entity::new("main.rs".into(), EntityType::File, "msg-1".into(), 100);
408        assert_eq!(entity.mention_count, 1);
409        entity.add_mention("msg-2".into(), 200);
410        assert_eq!(entity.mention_count, 2);
411        assert_eq!(entity.last_seen, 200);
412    }
413
414    #[test]
415    fn test_entity_store() {
416        let mut store = EntityStore::new();
417        let result = ExtractionResult {
418            entities: vec![
419                ("main.rs".into(), EntityType::File),
420                ("process".into(), EntityType::Function),
421            ],
422            relationships: vec![],
423        };
424        store.add_extraction(result, "msg-1", 100);
425        assert_eq!(store.stats().total_entities, 2);
426    }
427
428    // ── Memory poisoning detection ─────────────────────────────────────────
429
430    #[test]
431    fn test_no_contradiction_on_fresh_store() {
432        let mut store = EntityStore::new();
433        let result = ExtractionResult {
434            entities: vec![],
435            relationships: vec![Relationship::Defines {
436                definer: "main".into(),
437                defined: "return_type".into(),
438                context: "returns i32".into(),
439            }],
440        };
441        store.add_extraction(result, "msg-1", 100);
442        assert!(store.pending_contradictions().is_empty());
443    }
444
445    #[test]
446    fn test_contradicting_definitions_flagged() {
447        let mut store = EntityStore::new();
448
449        store.add_extraction(
450            ExtractionResult {
451                entities: vec![],
452                relationships: vec![Relationship::Defines {
453                    definer: "main".into(),
454                    defined: "return_type".into(),
455                    context: "returns i32".into(),
456                }],
457            },
458            "msg-1",
459            100,
460        );
461
462        // Same definer/defined, different context → contradiction
463        store.add_extraction(
464            ExtractionResult {
465                entities: vec![],
466                relationships: vec![Relationship::Defines {
467                    definer: "main".into(),
468                    defined: "return_type".into(),
469                    context: "returns String".into(),
470                }],
471            },
472            "msg-2",
473            200,
474        );
475
476        let contradictions = store.pending_contradictions();
477        assert_eq!(contradictions.len(), 1);
478        assert_eq!(
479            contradictions[0].kind,
480            ContradictionKind::ConflictingDefinition
481        );
482        assert_eq!(contradictions[0].subject, "main::return_type");
483        assert_eq!(contradictions[0].existing_context, "returns i32");
484        assert_eq!(contradictions[0].new_context, "returns String");
485    }
486
487    #[test]
488    fn test_identical_definitions_not_flagged() {
489        let mut store = EntityStore::new();
490
491        for msg_id in ["msg-1", "msg-2"] {
492            store.add_extraction(
493                ExtractionResult {
494                    entities: vec![],
495                    relationships: vec![Relationship::Defines {
496                        definer: "Config".into(),
497                        defined: "timeout".into(),
498                        context: "30 seconds".into(),
499                    }],
500                },
501                msg_id,
502                100,
503            );
504        }
505
506        assert!(store.pending_contradictions().is_empty());
507    }
508
509    #[test]
510    fn test_contradicting_modifications_flagged() {
511        let mut store = EntityStore::new();
512
513        store.add_extraction(
514            ExtractionResult {
515                entities: vec![],
516                relationships: vec![Relationship::Modifies {
517                    modifier: "patch_v2".into(),
518                    modified: "timeout".into(),
519                    change_type: "increase".into(),
520                }],
521            },
522            "msg-1",
523            100,
524        );
525
526        store.add_extraction(
527            ExtractionResult {
528                entities: vec![],
529                relationships: vec![Relationship::Modifies {
530                    modifier: "patch_v2".into(),
531                    modified: "timeout".into(),
532                    change_type: "decrease".into(),
533                }],
534            },
535            "msg-2",
536            200,
537        );
538
539        let contradictions = store.pending_contradictions();
540        assert_eq!(contradictions.len(), 1);
541        assert_eq!(
542            contradictions[0].kind,
543            ContradictionKind::ConflictingModification
544        );
545    }
546
547    #[test]
548    fn test_drain_contradictions_clears_buffer() {
549        let mut store = EntityStore::new();
550
551        for ctx in ["returns i32", "returns String"] {
552            store.add_extraction(
553                ExtractionResult {
554                    entities: vec![],
555                    relationships: vec![Relationship::Defines {
556                        definer: "main".into(),
557                        defined: "return_type".into(),
558                        context: ctx.into(),
559                    }],
560                },
561                "msg-1",
562                100,
563            );
564        }
565
566        assert!(!store.pending_contradictions().is_empty());
567        let drained = store.drain_contradictions();
568        assert_eq!(drained.len(), 1);
569        assert!(store.pending_contradictions().is_empty());
570    }
571
572    #[test]
573    fn test_both_relationships_retained_after_contradiction() {
574        let mut store = EntityStore::new();
575
576        store.add_extraction(
577            ExtractionResult {
578                entities: vec![],
579                relationships: vec![Relationship::Defines {
580                    definer: "fn".into(),
581                    defined: "x".into(),
582                    context: "old".into(),
583                }],
584            },
585            "msg-1",
586            100,
587        );
588
589        store.add_extraction(
590            ExtractionResult {
591                entities: vec![],
592                relationships: vec![Relationship::Defines {
593                    definer: "fn".into(),
594                    defined: "x".into(),
595                    context: "new".into(),
596                }],
597            },
598            "msg-2",
599            200,
600        );
601
602        // Both relationships are kept — no silent overwrite
603        assert_eq!(store.all_relationships().len(), 2);
604        let event = &store.pending_contradictions()[0];
605        assert_eq!(event.existing_context, "old");
606        assert_eq!(event.new_context, "new");
607    }
608}