use std::collections::{HashMap, HashSet};
pub use brainwires_core::graph::EntityType;
#[derive(Debug, Clone)]
pub struct Entity {
pub name: String,
pub entity_type: EntityType,
pub message_ids: Vec<String>,
pub first_seen: i64,
pub last_seen: i64,
pub mention_count: u32,
}
impl Entity {
pub fn new(name: String, entity_type: EntityType, message_id: String, timestamp: i64) -> Self {
Self {
name,
entity_type,
message_ids: vec![message_id],
first_seen: timestamp,
last_seen: timestamp,
mention_count: 1,
}
}
pub fn add_mention(&mut self, message_id: String, timestamp: i64) {
if !self.message_ids.contains(&message_id) {
self.message_ids.push(message_id);
}
self.last_seen = timestamp.max(self.last_seen);
self.mention_count += 1;
}
}
#[derive(Debug, Clone)]
pub enum Relationship {
Defines {
definer: String,
defined: String,
context: String,
},
References {
from: String,
to: String,
},
Modifies {
modifier: String,
modified: String,
change_type: String,
},
DependsOn {
dependent: String,
dependency: String,
},
Contains {
container: String,
contained: String,
},
CoOccurs {
entity_a: String,
entity_b: String,
message_id: String,
},
}
#[derive(Debug, Clone)]
pub struct ExtractionResult {
pub entities: Vec<(String, EntityType)>,
pub relationships: Vec<Relationship>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ContradictionKind {
ConflictingDefinition,
ConflictingModification,
}
#[derive(Debug, Clone)]
pub struct ContradictionEvent {
pub kind: ContradictionKind,
pub subject: String,
pub existing_context: String,
pub new_context: String,
}
#[derive(Debug, Default)]
pub struct EntityStore {
entities: HashMap<String, Entity>,
relationships: Vec<Relationship>,
contradictions: Vec<ContradictionEvent>,
}
impl EntityStore {
pub fn new() -> Self {
Self::default()
}
pub fn add_extraction(&mut self, result: ExtractionResult, message_id: &str, timestamp: i64) {
for (name, entity_type) in result.entities {
let key = format!("{}:{}", entity_type.as_str(), name);
if let Some(entity) = self.entities.get_mut(&key) {
entity.add_mention(message_id.to_string(), timestamp);
} else {
self.entities.insert(
key,
Entity::new(name, entity_type, message_id.to_string(), timestamp),
);
}
}
for new_rel in result.relationships {
self.check_and_record_contradiction(&new_rel);
self.relationships.push(new_rel);
}
}
fn check_and_record_contradiction(&mut self, new_rel: &Relationship) {
match new_rel {
Relationship::Defines {
definer,
defined,
context: new_ctx,
} => {
for existing in &self.relationships {
if let Relationship::Defines {
definer: ex_definer,
defined: ex_defined,
context: ex_ctx,
} = existing
&& ex_definer == definer
&& ex_defined == defined
&& ex_ctx != new_ctx
{
self.contradictions.push(ContradictionEvent {
kind: ContradictionKind::ConflictingDefinition,
subject: format!("{}::{}", definer, defined),
existing_context: ex_ctx.clone(),
new_context: new_ctx.clone(),
});
break;
}
}
}
Relationship::Modifies {
modifier,
modified,
change_type: new_change,
} => {
for existing in &self.relationships {
if let Relationship::Modifies {
modifier: ex_modifier,
modified: ex_modified,
change_type: ex_change,
} = existing
&& ex_modifier == modifier
&& ex_modified == modified
&& ex_change != new_change
{
self.contradictions.push(ContradictionEvent {
kind: ContradictionKind::ConflictingModification,
subject: format!("{}::{}", modifier, modified),
existing_context: ex_change.clone(),
new_context: new_change.clone(),
});
break;
}
}
}
_ => {}
}
}
pub fn pending_contradictions(&self) -> &[ContradictionEvent] {
&self.contradictions
}
pub fn drain_contradictions(&mut self) -> Vec<ContradictionEvent> {
std::mem::take(&mut self.contradictions)
}
pub fn get(&self, name: &str, entity_type: &EntityType) -> Option<&Entity> {
let key = format!("{}:{}", entity_type.as_str(), name);
self.entities.get(&key)
}
pub fn get_by_type(&self, entity_type: &EntityType) -> Vec<&Entity> {
self.entities
.values()
.filter(|e| &e.entity_type == entity_type)
.collect()
}
pub fn get_top_entities(&self, limit: usize) -> Vec<&Entity> {
let mut entities: Vec<_> = self.entities.values().collect();
entities.sort_by(|a, b| b.mention_count.cmp(&a.mention_count));
entities.into_iter().take(limit).collect()
}
pub fn get_related(&self, entity_name: &str) -> Vec<String> {
let mut related = HashSet::new();
for rel in &self.relationships {
match rel {
Relationship::CoOccurs {
entity_a, entity_b, ..
} => {
if entity_a == entity_name {
related.insert(entity_b.clone());
} else if entity_b == entity_name {
related.insert(entity_a.clone());
}
}
Relationship::Contains {
container,
contained,
} => {
if container == entity_name {
related.insert(contained.clone());
} else if contained == entity_name {
related.insert(container.clone());
}
}
Relationship::References { from, to } => {
if from == entity_name {
related.insert(to.clone());
} else if to == entity_name {
related.insert(from.clone());
}
}
Relationship::DependsOn {
dependent,
dependency,
} => {
if dependent == entity_name {
related.insert(dependency.clone());
} else if dependency == entity_name {
related.insert(dependent.clone());
}
}
Relationship::Modifies {
modifier, modified, ..
} => {
if modifier == entity_name {
related.insert(modified.clone());
} else if modified == entity_name {
related.insert(modifier.clone());
}
}
Relationship::Defines {
definer, defined, ..
} => {
if definer == entity_name {
related.insert(defined.clone());
} else if defined == entity_name {
related.insert(definer.clone());
}
}
}
}
related.into_iter().collect()
}
pub fn get_message_ids(&self, entity_name: &str) -> Vec<String> {
self.entities
.values()
.filter(|e| e.name == entity_name)
.flat_map(|e| e.message_ids.clone())
.collect()
}
pub fn all_entities(&self) -> impl Iterator<Item = &Entity> {
self.entities.values()
}
pub fn all_relationships(&self) -> &[Relationship] {
&self.relationships
}
pub fn stats(&self) -> EntityStoreStats {
let mut by_type = HashMap::new();
for entity in self.entities.values() {
*by_type.entry(entity.entity_type.as_str()).or_insert(0) += 1;
}
EntityStoreStats {
total_entities: self.entities.len(),
total_relationships: self.relationships.len(),
entities_by_type: by_type,
}
}
}
impl brainwires_core::graph::EntityStoreT for EntityStore {
fn entity_names_by_type(&self, entity_type: &EntityType) -> Vec<String> {
self.get_by_type(entity_type)
.iter()
.map(|e| e.name.clone())
.collect()
}
fn top_entity_info(&self, limit: usize) -> Vec<(String, EntityType)> {
self.get_top_entities(limit)
.iter()
.map(|e| (e.name.clone(), e.entity_type.clone()))
.collect()
}
}
#[derive(Debug)]
pub struct EntityStoreStats {
pub total_entities: usize,
pub total_relationships: usize,
pub entities_by_type: HashMap<&'static str, usize>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_entity_type_as_str() {
assert_eq!(EntityType::File.as_str(), "file");
assert_eq!(EntityType::Function.as_str(), "function");
}
#[test]
fn test_entity_lifecycle() {
let mut entity = Entity::new("main.rs".into(), EntityType::File, "msg-1".into(), 100);
assert_eq!(entity.mention_count, 1);
entity.add_mention("msg-2".into(), 200);
assert_eq!(entity.mention_count, 2);
assert_eq!(entity.last_seen, 200);
}
#[test]
fn test_entity_store() {
let mut store = EntityStore::new();
let result = ExtractionResult {
entities: vec![
("main.rs".into(), EntityType::File),
("process".into(), EntityType::Function),
],
relationships: vec![],
};
store.add_extraction(result, "msg-1", 100);
assert_eq!(store.stats().total_entities, 2);
}
#[test]
fn test_no_contradiction_on_fresh_store() {
let mut store = EntityStore::new();
let result = ExtractionResult {
entities: vec![],
relationships: vec![Relationship::Defines {
definer: "main".into(),
defined: "return_type".into(),
context: "returns i32".into(),
}],
};
store.add_extraction(result, "msg-1", 100);
assert!(store.pending_contradictions().is_empty());
}
#[test]
fn test_contradicting_definitions_flagged() {
let mut store = EntityStore::new();
store.add_extraction(
ExtractionResult {
entities: vec![],
relationships: vec![Relationship::Defines {
definer: "main".into(),
defined: "return_type".into(),
context: "returns i32".into(),
}],
},
"msg-1",
100,
);
store.add_extraction(
ExtractionResult {
entities: vec![],
relationships: vec![Relationship::Defines {
definer: "main".into(),
defined: "return_type".into(),
context: "returns String".into(),
}],
},
"msg-2",
200,
);
let contradictions = store.pending_contradictions();
assert_eq!(contradictions.len(), 1);
assert_eq!(
contradictions[0].kind,
ContradictionKind::ConflictingDefinition
);
assert_eq!(contradictions[0].subject, "main::return_type");
assert_eq!(contradictions[0].existing_context, "returns i32");
assert_eq!(contradictions[0].new_context, "returns String");
}
#[test]
fn test_identical_definitions_not_flagged() {
let mut store = EntityStore::new();
for msg_id in ["msg-1", "msg-2"] {
store.add_extraction(
ExtractionResult {
entities: vec![],
relationships: vec![Relationship::Defines {
definer: "Config".into(),
defined: "timeout".into(),
context: "30 seconds".into(),
}],
},
msg_id,
100,
);
}
assert!(store.pending_contradictions().is_empty());
}
#[test]
fn test_contradicting_modifications_flagged() {
let mut store = EntityStore::new();
store.add_extraction(
ExtractionResult {
entities: vec![],
relationships: vec![Relationship::Modifies {
modifier: "patch_v2".into(),
modified: "timeout".into(),
change_type: "increase".into(),
}],
},
"msg-1",
100,
);
store.add_extraction(
ExtractionResult {
entities: vec![],
relationships: vec![Relationship::Modifies {
modifier: "patch_v2".into(),
modified: "timeout".into(),
change_type: "decrease".into(),
}],
},
"msg-2",
200,
);
let contradictions = store.pending_contradictions();
assert_eq!(contradictions.len(), 1);
assert_eq!(
contradictions[0].kind,
ContradictionKind::ConflictingModification
);
}
#[test]
fn test_drain_contradictions_clears_buffer() {
let mut store = EntityStore::new();
for ctx in ["returns i32", "returns String"] {
store.add_extraction(
ExtractionResult {
entities: vec![],
relationships: vec![Relationship::Defines {
definer: "main".into(),
defined: "return_type".into(),
context: ctx.into(),
}],
},
"msg-1",
100,
);
}
assert!(!store.pending_contradictions().is_empty());
let drained = store.drain_contradictions();
assert_eq!(drained.len(), 1);
assert!(store.pending_contradictions().is_empty());
}
#[test]
fn test_both_relationships_retained_after_contradiction() {
let mut store = EntityStore::new();
store.add_extraction(
ExtractionResult {
entities: vec![],
relationships: vec![Relationship::Defines {
definer: "fn".into(),
defined: "x".into(),
context: "old".into(),
}],
},
"msg-1",
100,
);
store.add_extraction(
ExtractionResult {
entities: vec![],
relationships: vec![Relationship::Defines {
definer: "fn".into(),
defined: "x".into(),
context: "new".into(),
}],
},
"msg-2",
200,
);
assert_eq!(store.all_relationships().len(), 2);
let event = &store.pending_contradictions()[0];
assert_eq!(event.existing_context, "old");
assert_eq!(event.new_context, "new");
}
}