use std::collections::{HashMap, HashSet, VecDeque};
use rusqlite::{params, Connection};
use crate::error::Result;
pub fn tokenize(text: &str) -> Vec<String> {
text.split(|c: char| !c.is_alphanumeric() && c != '\'')
.filter(|s| !s.is_empty())
.map(|s| s.to_lowercase())
.collect()
}
pub fn entity_matches_text(entity: &str, text_tokens: &[String]) -> bool {
let entity_tokens = tokenize(entity);
if entity_tokens.is_empty() {
return false;
}
if entity_tokens.len() == 1 {
text_tokens.iter().any(|t| t == &entity_tokens[0])
} else {
text_tokens
.windows(entity_tokens.len())
.any(|window| window.iter().zip(entity_tokens.iter()).all(|(w, e)| w == e))
}
}
const ENTITY_STOPWORDS: &[&str] = &[
"The", "A", "An", "I", "We", "You", "He", "She", "It", "They", "This", "That", "These",
"Those", "My", "Your", "His", "Her", "Its", "Our", "Their", "But", "And", "Or", "So", "If",
"When", "Where", "What", "Who", "Why", "How", "Is", "Are", "Was", "Were", "Be", "Been",
"Being", "Have", "Has", "Had", "Do", "Does", "Did", "Of", "In", "On", "At", "To", "For",
"With", "From", "By", "As", "Than", "Then", "Also", "Just", "Only", "Very", "Much",
];
pub fn extract_heuristic_entities(text: &str) -> Vec<String> {
let mut entities: Vec<String> = Vec::new();
let mut chunk: Vec<String> = Vec::new();
let flush = |chunk: &mut Vec<String>, out: &mut Vec<String>| {
while !chunk.is_empty() && ENTITY_STOPWORDS.contains(&chunk[0].as_str()) {
chunk.remove(0);
}
while let Some(last) = chunk.last() {
if ENTITY_STOPWORDS.contains(&last.as_str()) && last.chars().count() > 1 {
chunk.pop();
} else {
break;
}
}
if !chunk.is_empty() {
let candidate = chunk.join(" ");
let alpha_chars = candidate.chars().filter(|c| c.is_alphanumeric()).count();
if alpha_chars >= 2 {
out.push(candidate);
}
}
chunk.clear();
};
for word in text
.split(|c: char| !c.is_alphanumeric() && c != '\'')
.filter(|s| !s.is_empty())
{
let first = word.chars().next().unwrap();
let starts_upper = first.is_uppercase();
let is_all_caps =
word.len() > 1 && word.chars().all(|c| !c.is_alphabetic() || c.is_uppercase());
let joins_chunk = if chunk.is_empty() {
starts_upper || is_all_caps
} else {
starts_upper || is_all_caps || (word.len() == 1 && first.is_ascii_uppercase())
};
if joins_chunk {
chunk.push(word.to_string());
} else {
flush(&mut chunk, &mut entities);
}
}
flush(&mut chunk, &mut entities);
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
entities.retain(|e| seen.insert(e.clone()));
entities
}
#[derive(Debug, Clone)]
pub struct RelationCandidate {
pub src: String,
pub rel_type: String,
pub dst: String,
pub polarity: i32, pub modality: String, pub confidence_band: String, }
const RELATION_PATTERNS: &[(&[&str], &str)] = &[
(
&["is the ceo of", "is ceo of", "serves as ceo of"],
"ceo_of",
),
(
&["is the cto of", "is cto of", "serves as cto of"],
"cto_of",
),
(
&["is the cfo of", "is cfo of", "serves as cfo of"],
"cfo_of",
),
(
&["is the founder of", "is founder of", "co-founded"],
"founded",
),
(&["founded"], "founded"),
(&["leads", "heads", "runs", "manages", "directs"], "leads"),
(
&[
"works at",
"works for",
"employed at",
"employed by",
"joined",
],
"works_at",
),
(&["was born in", "born in"], "born_in"),
(
&[
"is headquartered in",
"headquartered in",
"is based in",
"based in",
"located in",
],
"headquartered_in",
),
(&["is married to", "married to", "wed to"], "married_to"),
(
&["acquired", "bought", "purchased", "took over"],
"acquired",
),
(
&[
"is a subsidiary of",
"subsidiary of",
"is owned by",
"owned by",
],
"subsidiary_of",
),
(&["speaks", "is fluent in"], "speaks"),
(
&["is a member of", "member of", "belongs to", "part of"],
"member_of",
),
(&["reports to"], "reports_to"),
];
const REVERSE_ROLE_PATTERNS: &[(&str, &str)] = &[
("ceo", "ceo_of"),
("cto", "cto_of"),
("cfo", "cfo_of"),
("founder", "founded"),
("president", "leads"),
("director", "leads"),
("head", "leads"),
];
pub fn extract_heuristic_relations(text: &str, entities: &[String]) -> Vec<RelationCandidate> {
if entities.len() < 2 {
return vec![];
}
let text_lower = text.to_lowercase();
let mut candidates: Vec<RelationCandidate> = Vec::new();
let mut entity_positions: Vec<(usize, &str)> = Vec::new();
for entity in entities {
let entity_lower = entity.to_lowercase();
if let Some(pos) = text_lower.find(&entity_lower) {
entity_positions.push((pos, entity.as_str()));
}
}
entity_positions.sort_by_key(|(pos, _)| *pos);
for i in 0..entity_positions.len() {
for j in (i + 1)..entity_positions.len() {
let (pos_a, entity_a) = entity_positions[i];
let (pos_b, entity_b) = entity_positions[j];
if pos_b - pos_a > 150 {
continue;
}
let between_start = pos_a + entity_a.to_lowercase().len();
let between_end = pos_b;
if between_start >= between_end || between_end > text_lower.len() {
continue;
}
let between = text_lower[between_start..between_end].trim();
if between.is_empty() {
continue;
}
let has_negation = NEGATION_CUES
.iter()
.any(|cue| between.split_whitespace().any(|w| w == *cue));
let polarity = if has_negation { -1 } else { 1 };
let between_stripped: String = between
.split_whitespace()
.filter(|w| !NEGATION_CUES.contains(w))
.collect::<Vec<_>>()
.join(" ");
let modality = if MODALITY_CUES.iter().any(|cue| between.contains(cue)) {
"reported"
} else {
"asserted"
};
for (patterns, rel_type) in RELATION_PATTERNS {
for pattern in *patterns {
if between_stripped.contains(pattern) {
candidates.push(RelationCandidate {
src: entity_a.to_string(),
rel_type: rel_type.to_string(),
dst: entity_b.to_string(),
polarity,
modality: modality.to_string(),
confidence_band: "medium".to_string(),
});
break; }
}
}
for (role_keyword, rel_type) in REVERSE_ROLE_PATTERNS {
let possessive = format!("'s {}", role_keyword);
let possessive2 = format!("s {}", role_keyword);
if between_stripped.contains(&possessive) || between_stripped.contains(&possessive2)
{
candidates.push(RelationCandidate {
src: entity_b.to_string(), rel_type: rel_type.to_string(),
dst: entity_a.to_string(), polarity,
modality: modality.to_string(),
confidence_band: "medium".to_string(),
});
break;
}
}
}
}
let mut seen = std::collections::HashSet::new();
candidates.retain(|c| seen.insert((c.src.clone(), c.rel_type.clone(), c.dst.clone())));
candidates
}
const NEGATION_CUES: &[&str] = &[
"not", "no", "never", "denied", "refuted", "isn't", "wasn't", "aren't", "weren't", "doesn't",
"didn't", "disputes", "denies",
];
const TEMPORAL_CUES: &[&str] = &[
"was",
"were",
"until",
"before",
"after",
"since",
"during",
"former",
"current",
"currently",
"previously",
"recently",
"now",
"then",
"later",
"earlier",
"ago",
"yesterday",
"tomorrow",
];
const MODALITY_CUES: &[&str] = &[
"may",
"might",
"allegedly",
"reportedly",
"rumor",
"rumored",
"said",
"claims",
"according",
"stated",
"announced",
];
const COMPOUND_MARKERS: &[&str] = &[
"; ",
", then ",
", subsequently ",
" but ",
" however ",
" although ",
];
#[derive(Debug, Clone, Default)]
pub struct TextFeatures {
pub char_length: usize,
pub sentence_count: usize,
pub entity_count: usize,
pub negation_cue_count: usize,
pub temporal_cue_count: usize,
pub modality_cue_count: usize,
pub has_compound_markers: bool,
pub likely_assertion: bool,
}
pub fn analyze_text_features(text: &str, extracted_entities: &[String]) -> TextFeatures {
let lower = text.to_lowercase();
let tokens: Vec<&str> = text
.split(|c: char| !c.is_alphanumeric() && c != '\'')
.filter(|s| !s.is_empty())
.collect();
let tokens_lower: Vec<String> = tokens.iter().map(|t| t.to_lowercase()).collect();
let sentence_count = text
.chars()
.filter(|c| matches!(c, '.' | '!' | '?'))
.count()
.max(1);
let negation_cue_count = tokens_lower
.iter()
.filter(|t| NEGATION_CUES.contains(&t.as_str()))
.count();
let temporal_cue_count = tokens_lower
.iter()
.filter(|t| TEMPORAL_CUES.contains(&t.as_str()))
.count();
let modality_cue_count = tokens_lower
.iter()
.filter(|t| MODALITY_CUES.contains(&t.as_str()))
.count();
let has_compound_markers = COMPOUND_MARKERS.iter().any(|m| lower.contains(m));
let likely_assertion =
!text.trim_end().ends_with('?') && tokens.len() >= 2 && modality_cue_count == 0;
TextFeatures {
char_length: text.chars().count(),
sentence_count,
entity_count: extracted_entities.len(),
negation_cue_count,
temporal_cue_count,
modality_cue_count,
has_compound_markers,
likely_assertion,
}
}
const TECH_BLOCKLIST: &[&str] = &[
"faiss",
"onnx",
"scann",
"redis",
"kafka",
"docker",
"kubernetes",
"react",
"python",
"rust",
"java",
"swift",
"flutter",
"pytorch",
"tensorflow",
"numpy",
"pandas",
"spark",
"hadoop",
"nginx",
"postgres",
"mysql",
"sqlite",
"graphql",
"grpc",
"oauth",
"jwt",
"html",
"css",
"api",
"sdk",
"ml",
"ai",
"gpu",
"cpu",
"ram",
"ssd",
"aws",
"gcp",
"claude",
"openai",
"anthropic",
"gemini",
"llama",
"ollama",
];
const NON_PERSON_PREFIXES: &[&str] = &[
"project",
"team",
"company",
"group",
"department",
"org",
"the",
"operation",
"task",
"plan",
"system",
"service",
"app",
"tool",
"code",
"server",
"client",
"api",
"db",
"database",
"agent",
"model",
"version",
"release",
"build",
"deploy",
"config",
];
pub fn classify_entity_type(name: &str) -> &'static str {
let trimmed = name.trim();
if trimmed.is_empty() {
return "unknown";
}
let lower = trimmed.to_lowercase();
if TECH_BLOCKLIST.contains(&lower.as_str()) {
return "tech";
}
if trimmed.len() > 1
&& trimmed
.chars()
.all(|c| c.is_uppercase() || !c.is_alphabetic())
{
return "tech";
}
if trimmed.contains(' ') {
let words: Vec<&str> = trimmed.split_whitespace().collect();
if words.len() == 2
&& words
.iter()
.all(|w| w.chars().next().map(|c| c.is_uppercase()).unwrap_or(false))
{
let first_lower = words[0].to_lowercase();
if NON_PERSON_PREFIXES.contains(&first_lower.as_str()) {
return "unknown";
}
if words
.iter()
.any(|w| TECH_BLOCKLIST.contains(&w.to_lowercase().as_str()))
{
return "tech";
}
return "person";
}
}
"unknown"
}
const PERSON_PERSON_RELS: &[&str] = &[
"married_to",
"mother_of",
"father_of",
"daughter_of",
"son_of",
"sister_of",
"brother_of",
"sibling_of",
"parent_of",
"child_of",
"knows",
"friends_with",
"met",
"dating",
"engaged_to",
"mentors",
"mentored_by",
"reports_to",
"manages",
"colleagues",
"roommate",
"neighbor",
"called",
"texted",
"messaged",
"date_night",
];
const PLACE_DST_RELS: &[&str] = &[
"lives_in",
"born_in",
"grew_up_in",
"located_in",
"based_in",
"visited",
"moved_to",
"traveled_to",
"from",
];
const ORG_DST_RELS: &[&str] = &[
"works_at",
"works_for",
"employed_at",
"employed_by",
"studied_at",
"attended",
"enrolled_in",
"graduated_from",
"member_of",
"belongs_to",
"founded",
];
const TECH_DST_RELS: &[&str] = &[
"built_with",
"uses",
"depends_on",
"integrates",
"requires",
"written_in",
"coded_in",
"implemented_with",
"powered_by",
"runs_on",
"compiled_with",
];
const INFRA_DST_RELS: &[&str] = &[
"deployed_on",
"hosted_on",
"deployed_to",
"hosted_at",
"runs_on_infra",
"served_by",
];
const PERSON_PROJECT_RELS: &[&str] = &[
"works_on",
"contributes_to",
"maintains",
"leads",
"created",
"built",
"designed",
"architected",
"owns",
];
const PROJECT_PROJECT_RELS: &[&str] = &[
"depends_on_project",
"extends",
"forks",
"replaces",
"supersedes",
"derived_from",
];
const EVENT_DST_RELS: &[&str] = &[
"attended_event",
"participated_in",
"scheduled_for",
"presented_at",
"spoke_at",
];
const CONCEPT_DST_RELS: &[&str] = &[
"interested_in",
"studies",
"researches",
"specializes_in",
"expert_in",
"learning",
"teaches",
];
pub fn classify_with_relationship(
src: &str,
dst: &str,
rel_type: &str,
) -> (&'static str, &'static str) {
let rel_lower = rel_type.to_lowercase();
let rel = rel_lower.as_str();
if PERSON_PERSON_RELS.contains(&rel) {
return ("person", "person");
}
if PLACE_DST_RELS.contains(&rel) {
return ("person", "place");
}
if ORG_DST_RELS.contains(&rel) {
return ("person", "organization");
}
if TECH_DST_RELS.contains(&rel) {
let src_type = classify_entity_type(src);
return (
if src_type == "unknown" {
"project"
} else {
src_type
},
"tech",
);
}
if INFRA_DST_RELS.contains(&rel) {
let src_type = classify_entity_type(src);
return (
if src_type == "unknown" {
"project"
} else {
src_type
},
"infrastructure",
);
}
if PERSON_PROJECT_RELS.contains(&rel) {
return ("person", "project");
}
if PROJECT_PROJECT_RELS.contains(&rel) {
return ("project", "project");
}
if EVENT_DST_RELS.contains(&rel) {
return (classify_entity_type(src), "event");
}
if CONCEPT_DST_RELS.contains(&rel) {
return ("person", "concept");
}
(classify_entity_type(src), classify_entity_type(dst))
}
pub fn entities_for_memories(conn: &Connection, rids: &[&str]) -> Result<Vec<String>> {
if rids.is_empty() {
return Ok(vec![]);
}
let placeholders: String = (0..rids.len())
.map(|i| format!("?{}", i + 1))
.collect::<Vec<_>>()
.join(",");
let sql = format!(
"SELECT DISTINCT entity_name FROM memory_entities WHERE memory_rid IN ({placeholders})"
);
let mut stmt = conn.prepare(&sql)?;
let param_values: Vec<Box<dyn rusqlite::types::ToSql>> = rids
.iter()
.map(|r| Box::new(r.to_string()) as Box<dyn rusqlite::types::ToSql>)
.collect();
let params_ref: Vec<&dyn rusqlite::types::ToSql> =
param_values.iter().map(|p| p.as_ref()).collect();
let entities = stmt
.query_map(params_ref.as_slice(), |row| row.get(0))?
.collect::<std::result::Result<Vec<String>, _>>()?;
Ok(entities)
}
pub fn memories_for_entities(conn: &Connection, entity_names: &[&str]) -> Result<HashSet<String>> {
if entity_names.is_empty() {
return Ok(HashSet::new());
}
let placeholders: String = (0..entity_names.len())
.map(|i| format!("?{}", i + 1))
.collect::<Vec<_>>()
.join(",");
let sql = format!(
"SELECT DISTINCT memory_rid FROM memory_entities WHERE entity_name IN ({placeholders})"
);
let mut stmt = conn.prepare(&sql)?;
let param_values: Vec<Box<dyn rusqlite::types::ToSql>> = entity_names
.iter()
.map(|e| Box::new(e.to_string()) as Box<dyn rusqlite::types::ToSql>)
.collect();
let params_ref: Vec<&dyn rusqlite::types::ToSql> =
param_values.iter().map(|p| p.as_ref()).collect();
let rids = stmt
.query_map(params_ref.as_slice(), |row| row.get(0))?
.collect::<std::result::Result<HashSet<String>, _>>()?;
Ok(rids)
}
pub fn expand_entities_nhop(
conn: &Connection,
seeds: &[&str],
max_hops: u8,
max_entities: usize,
) -> Result<Vec<(String, u8, f64)>> {
let mut result: Vec<(String, u8, f64)> = Vec::new();
let mut visited: HashMap<String, (u8, f64)> = HashMap::new();
for s in seeds {
visited.insert(s.to_string(), (0, 1.0));
result.push((s.to_string(), 0, 1.0));
}
let mut frontier: VecDeque<(String, u8, f64)> =
seeds.iter().map(|s| (s.to_string(), 0u8, 1.0f64)).collect();
while let Some((entity, hops, weight)) = frontier.pop_front() {
if hops >= max_hops || result.len() >= max_entities {
break;
}
let mut stmt = conn.prepare(
"SELECT src, dst, weight FROM edges WHERE (src = ?1 OR dst = ?1) AND tombstoned = 0",
)?;
let neighbors: Vec<(String, f64)> = stmt
.query_map(params![entity], |row| {
let src: String = row.get(0)?;
let dst: String = row.get(1)?;
let w: f64 = row.get(2)?;
let neighbor = if src == entity { dst } else { src };
Ok((neighbor, w))
})?
.collect::<std::result::Result<Vec<_>, _>>()?;
for (neighbor, edge_weight) in neighbors {
if visited.contains_key(&neighbor) {
continue;
}
if result.len() >= max_entities {
break;
}
let cumulative = weight * edge_weight;
let next_hops = hops + 1;
visited.insert(neighbor.clone(), (next_hops, cumulative));
result.push((neighbor.clone(), next_hops, cumulative));
if next_hops < max_hops {
frontier.push_back((neighbor, next_hops, cumulative));
}
}
}
Ok(result)
}
pub fn graph_proximity(
conn: &Connection,
memory_rid: &str,
expanded_entities: &HashMap<String, (u8, f64)>,
) -> Result<f64> {
let mem_entities: Vec<String> = conn
.prepare("SELECT entity_name FROM memory_entities WHERE memory_rid = ?1")?
.query_map(params![memory_rid], |row| row.get(0))?
.collect::<std::result::Result<Vec<_>, _>>()?;
let mut max_proximity = 0.0f64;
for entity in &mem_entities {
if let Some(&(hops, weight)) = expanded_entities.get(entity) {
let prox = weight / f64::powf(2.0, hops as f64);
if prox > max_proximity {
max_proximity = prox;
}
}
}
Ok(max_proximity)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::YantrikDB;
#[test]
fn test_extract_heuristic_entities_basic_names() {
let got = extract_heuristic_entities("Alice Chen is the CEO of Acme Corp");
assert!(got.contains(&"Alice Chen".to_string()), "got: {:?}", got);
assert!(got.contains(&"Acme Corp".to_string()), "got: {:?}", got);
assert!(got.contains(&"CEO".to_string()), "got: {:?}", got);
}
#[test]
fn test_extract_heuristic_entities_strips_sentence_start() {
let got = extract_heuristic_entities("The database backend is PostgreSQL");
assert_eq!(got, vec!["PostgreSQL".to_string()]);
}
#[test]
fn test_extract_heuristic_entities_multi_word_place() {
let got = extract_heuristic_entities("Acme is headquartered in San Francisco");
assert!(got.contains(&"Acme".to_string()), "got: {:?}", got);
assert!(got.contains(&"San Francisco".to_string()), "got: {:?}", got);
}
#[test]
fn test_extract_heuristic_entities_single_letter_suffix() {
let got = extract_heuristic_entities("Series A funding was 20 million dollars");
assert!(got.contains(&"Series A".to_string()), "got: {:?}", got);
}
#[test]
fn test_extract_heuristic_entities_dedupe() {
let got = extract_heuristic_entities("Alice met Alice at the cafe");
let alice_count = got.iter().filter(|e| *e == "Alice").count();
assert_eq!(alice_count, 1);
}
#[test]
fn test_extract_heuristic_entities_empty_on_lowercase() {
let got = extract_heuristic_entities("the quick brown fox jumps over the lazy dog");
assert!(got.is_empty(), "got: {:?}", got);
}
#[test]
fn test_extract_relations_ceo_of() {
let entities = vec!["Alice Chen".to_string(), "Acme Corp".to_string()];
let rels = extract_heuristic_relations("Alice Chen is the CEO of Acme Corp", &entities);
assert_eq!(rels.len(), 1, "got: {:?}", rels);
assert_eq!(rels[0].src, "Alice Chen");
assert_eq!(rels[0].rel_type, "ceo_of");
assert_eq!(rels[0].dst, "Acme Corp");
assert_eq!(rels[0].polarity, 1);
}
#[test]
fn test_extract_relations_works_at() {
let entities = vec!["Bob".to_string(), "Google".to_string()];
let rels = extract_heuristic_relations("Bob works at Google as an engineer", &entities);
assert!(
rels.iter().any(|r| r.rel_type == "works_at"),
"got: {:?}",
rels
);
}
#[test]
fn test_extract_relations_headquartered() {
let entities = vec!["Acme".to_string(), "San Francisco".to_string()];
let rels = extract_heuristic_relations("Acme is headquartered in San Francisco", &entities);
assert!(
rels.iter().any(|r| r.rel_type == "headquartered_in"),
"got: {:?}",
rels
);
}
#[test]
fn test_extract_relations_negation_detected() {
let entities = vec!["Alice".to_string(), "Acme".to_string()];
let rels = extract_heuristic_relations("Alice is not the CEO of Acme", &entities);
assert_eq!(rels.len(), 1);
assert_eq!(rels[0].polarity, -1, "negation should set polarity to -1");
}
#[test]
fn test_extract_relations_no_match_unrelated() {
let entities = vec!["Alice".to_string(), "Bob".to_string()];
let rels = extract_heuristic_relations("Alice and Bob went for coffee", &entities);
assert!(
rels.is_empty(),
"should not extract relation from unrelated text, got: {:?}",
rels
);
}
#[test]
fn test_extract_relations_multiple_pairs() {
let entities = vec![
"Alice".to_string(),
"Acme".to_string(),
"San Francisco".to_string(),
];
let rels = extract_heuristic_relations(
"Alice is the CEO of Acme which is headquartered in San Francisco",
&entities,
);
assert!(
rels.len() >= 2,
"should find CEO + headquartered, got: {:?}",
rels
);
}
#[test]
fn test_extract_relations_needs_two_entities() {
let entities = vec!["Alice".to_string()];
let rels = extract_heuristic_relations("Alice is the CEO", &entities);
assert!(
rels.is_empty(),
"cannot extract relation with only one entity"
);
}
#[test]
fn test_analyze_text_features_basic_assertion() {
let entities = vec!["Alice Chen".to_string(), "Acme Corp".to_string()];
let f = analyze_text_features("Alice Chen is the CEO of Acme Corp", &entities);
assert_eq!(f.entity_count, 2);
assert_eq!(f.negation_cue_count, 0);
assert_eq!(f.modality_cue_count, 0);
assert!(f.likely_assertion);
assert!(!f.has_compound_markers);
}
#[test]
fn test_analyze_text_features_negation() {
let f = analyze_text_features("Alice is not the CEO of Acme", &[]);
assert_eq!(f.negation_cue_count, 1);
}
#[test]
fn test_analyze_text_features_temporal() {
let f = analyze_text_features("Alice was previously the CEO before 2024", &[]);
assert!(f.temporal_cue_count >= 2, "got: {}", f.temporal_cue_count);
}
#[test]
fn test_analyze_text_features_modality_suppresses_assertion() {
let f = analyze_text_features("Alice may become CEO allegedly", &[]);
assert!(f.modality_cue_count >= 2);
assert!(!f.likely_assertion);
}
#[test]
fn test_analyze_text_features_compound() {
let f = analyze_text_features("Alice was CEO until 2024; then Bob took over", &[]);
assert!(f.has_compound_markers);
}
#[test]
fn test_analyze_text_features_question_not_assertion() {
let f = analyze_text_features("Who is the CEO of Acme?", &[]);
assert!(!f.likely_assertion);
}
#[test]
fn test_extract_heuristic_entities_distinct_people() {
let a = extract_heuristic_entities("Alice Chen is the CEO of Acme Corp");
let b = extract_heuristic_entities("Sarah Kim is the CTO of Acme Corp");
let a_set: std::collections::HashSet<_> = a.iter().collect();
let b_set: std::collections::HashSet<_> = b.iter().collect();
assert!(a_set.contains(&"Alice Chen".to_string()));
assert!(b_set.contains(&"Sarah Kim".to_string()));
assert!(!a_set.contains(&"Sarah Kim".to_string()));
assert!(!b_set.contains(&"Alice Chen".to_string()));
}
fn setup_db() -> YantrikDB {
let db = YantrikDB::new(":memory:", 4).unwrap();
db.relate("Alice", "Bob", "knows", 1.0).unwrap();
db.relate("Bob", "Charlie", "knows", 0.8).unwrap();
db.relate("Alice", "ProjectX", "works_on", 1.0).unwrap();
db.relate("Dave", "ProjectX", "works_on", 0.9).unwrap();
let emb = vec![1.0f32, 0.0, 0.0, 0.0];
let r1 = db
.record(
"Alice discussed the plan",
"episodic",
0.5,
0.0,
604800.0,
&serde_json::json!({}),
&emb,
"default",
0.8,
"general",
"user",
None,
)
.unwrap();
let r2 = db
.record(
"Bob reviewed the code",
"episodic",
0.5,
0.0,
604800.0,
&serde_json::json!({}),
&emb,
"default",
0.8,
"general",
"user",
None,
)
.unwrap();
let r3 = db
.record(
"Charlie deployed to production",
"episodic",
0.5,
0.0,
604800.0,
&serde_json::json!({}),
&emb,
"default",
0.8,
"general",
"user",
None,
)
.unwrap();
db.link_memory_entity(&r1, "Alice").unwrap();
db.link_memory_entity(&r1, "ProjectX").unwrap();
db.link_memory_entity(&r2, "Bob").unwrap();
db.link_memory_entity(&r3, "Charlie").unwrap();
db
}
#[test]
fn test_entities_for_memories() {
let db = setup_db();
let rid: String = db
.conn()
.query_row(
"SELECT rid FROM memories ORDER BY created_at LIMIT 1",
[],
|row| row.get(0),
)
.unwrap();
let entities = entities_for_memories(&*db.conn(), &[&rid]).unwrap();
assert!(entities.contains(&"Alice".to_string()));
assert!(entities.contains(&"ProjectX".to_string()));
}
#[test]
fn test_memories_for_entities() {
let db = setup_db();
let rids = memories_for_entities(&*db.conn(), &["Alice"]).unwrap();
assert_eq!(rids.len(), 1); }
#[test]
fn test_expand_1hop() {
let db = setup_db();
let expanded = expand_entities_nhop(&*db.conn(), &["Alice"], 1, 30).unwrap();
let names: HashSet<String> = expanded.iter().map(|(n, _, _)| n.clone()).collect();
assert!(names.contains("Alice"));
assert!(names.contains("Bob"));
assert!(names.contains("ProjectX"));
}
#[test]
fn test_expand_2hop() {
let db = setup_db();
let expanded = expand_entities_nhop(&*db.conn(), &["Alice"], 2, 30).unwrap();
let names: HashSet<String> = expanded.iter().map(|(n, _, _)| n.clone()).collect();
assert!(names.contains("Charlie"));
assert!(names.contains("Dave"));
}
#[test]
fn test_expand_budget_limit() {
let db = setup_db();
let expanded = expand_entities_nhop(&*db.conn(), &["Alice"], 2, 3).unwrap();
assert!(expanded.len() <= 3);
}
#[test]
fn test_no_tombstoned_edges() {
let db = setup_db();
db.conn()
.execute(
"UPDATE claims SET tombstoned = 1 WHERE src = 'Alice' AND dst = 'Bob'",
[],
)
.unwrap();
let expanded = expand_entities_nhop(&*db.conn(), &["Alice"], 1, 30).unwrap();
let names: HashSet<String> = expanded.iter().map(|(n, _, _)| n.clone()).collect();
assert!(!names.contains("Bob"));
assert!(names.contains("ProjectX"));
}
#[test]
fn test_graph_proximity_score() {
let db = setup_db();
let rid: String = db
.conn()
.query_row(
"SELECT rid FROM memories ORDER BY created_at LIMIT 1",
[],
|row| row.get(0),
)
.unwrap();
let mut expanded = HashMap::new();
expanded.insert("Alice".to_string(), (0u8, 1.0f64));
expanded.insert("ProjectX".to_string(), (1u8, 1.0f64));
let prox = graph_proximity(&*db.conn(), &rid, &expanded).unwrap();
assert!((prox - 1.0).abs() < 1e-10);
}
#[test]
fn test_tokenize_basic() {
let tokens = tokenize("What is Sarah working on?");
assert_eq!(tokens, vec!["what", "is", "sarah", "working", "on"]);
}
#[test]
fn test_tokenize_preserves_apostrophes() {
let tokens = tokenize("daughter's school play");
assert_eq!(tokens, vec!["daughter's", "school", "play"]);
}
#[test]
fn test_entity_matches_single_word() {
let tokens = tokenize("Sarah discussed the plan with Mike");
assert!(entity_matches_text("Sarah", &tokens));
assert!(entity_matches_text("Mike", &tokens));
assert!(!entity_matches_text("Sara", &tokens)); }
#[test]
fn test_entity_matches_multi_word() {
let tokens = tokenize("The data pipeline crashed during migration");
assert!(entity_matches_text("data pipeline", &tokens));
assert!(!entity_matches_text("data migration", &tokens)); }
#[test]
fn test_entity_no_substring_false_positive() {
let tokens = tokenize("The database was updated successfully");
assert!(!entity_matches_text("data", &tokens));
}
#[test]
fn test_entity_matches_case_insensitive() {
let tokens = tokenize("We evaluated FAISS for vector search");
assert!(entity_matches_text("FAISS", &tokens));
assert!(entity_matches_text("faiss", &tokens));
}
#[test]
fn test_classify_name_only_ambiguous() {
assert_eq!(classify_entity_type("Sarah"), "unknown");
assert_eq!(classify_entity_type("Bangalore"), "unknown");
assert_eq!(classify_entity_type("Flipkart"), "unknown");
}
#[test]
fn test_classify_name_multi_word_person() {
assert_eq!(classify_entity_type("Sarah Chen"), "person");
assert_eq!(classify_entity_type("Priya Sharma"), "person");
}
#[test]
fn test_classify_tech_blocklist() {
assert_eq!(classify_entity_type("FAISS"), "tech");
assert_eq!(classify_entity_type("ONNX"), "tech");
assert_eq!(classify_entity_type("Redis"), "tech");
assert_eq!(classify_entity_type("Python"), "tech");
}
#[test]
fn test_classify_tech_allcaps() {
assert_eq!(classify_entity_type("GPU"), "tech");
assert_eq!(classify_entity_type("API"), "tech");
}
#[test]
fn test_classify_unknown() {
assert_eq!(classify_entity_type("recommendation engine"), "unknown");
assert_eq!(classify_entity_type("data pipeline"), "unknown");
assert_eq!(classify_entity_type("sleep patterns"), "unknown");
}
#[test]
fn test_classify_with_rel_person_person() {
let (s, d) = classify_with_relationship("Arjun", "Priya", "married_to");
assert_eq!(s, "person");
assert_eq!(d, "person");
}
#[test]
fn test_classify_with_rel_person_place() {
let (s, d) = classify_with_relationship("Priya", "Bangalore", "lives_in");
assert_eq!(s, "person");
assert_eq!(d, "place");
}
#[test]
fn test_classify_with_rel_person_org() {
let (s, d) = classify_with_relationship("Priya", "Flipkart", "works_at");
assert_eq!(s, "person");
assert_eq!(d, "organization");
}
#[test]
fn test_classify_with_rel_tech_dst() {
let (s, d) = classify_with_relationship("FAISS", "data pipeline", "uses");
assert_eq!(s, "tech");
assert_eq!(d, "tech");
}
#[test]
fn test_classify_with_rel_built_with() {
let (s, d) = classify_with_relationship("MyApp", "React", "built_with");
assert_eq!(s, "project");
assert_eq!(d, "tech");
}
#[test]
fn test_classify_with_rel_deployed_on() {
let (s, d) = classify_with_relationship("MyApp", "AWS", "deployed_on");
assert_eq!(s, "project");
assert_eq!(d, "infrastructure");
}
#[test]
fn test_classify_with_rel_works_on() {
let (s, d) = classify_with_relationship("Pranab", "YantrikDB", "works_on");
assert_eq!(s, "person");
assert_eq!(d, "project");
}
#[test]
fn test_classify_with_rel_fallback() {
let (s, d) = classify_with_relationship("FAISS", "data pipeline", "related_to");
assert_eq!(s, "tech");
assert_eq!(d, "unknown");
}
}