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())
.filter(|s| !s.is_empty())
.map(|s| s.to_lowercase())
.collect()
}
#[cfg(test)]
mod c5a_tests {
use super::*;
#[test]
fn possessive_resolves_to_the_bare_entity() {
let toks = tokenize("What is Taylor's role?");
assert!(toks.contains(&"taylor".to_string()), "{toks:?}");
assert_eq!(
tokenize("What is Taylor's role?")
.iter()
.filter(|t| *t == "taylor")
.count(),
tokenize("What is Taylor s role?")
.iter()
.filter(|t| *t == "taylor")
.count(),
"possessive and plain forms must tokenize alike"
);
}
#[test]
fn apostrophe_names_match_symmetrically() {
let text_tokens = tokenize("A meeting with O'Brien about the launch");
assert!(entity_matches_text("O'Brien", &text_tokens));
assert!(entity_matches_text("o brien", &text_tokens));
}
#[test]
fn contractions_stop_being_coherent_tokens() {
assert_eq!(tokenize("Don't"), vec!["don", "t"]);
}
}
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",
"Not",
"No",
"Nor",
"Most",
"More",
"Less",
"Some",
"Any",
"All",
"Each",
"Every",
"Both",
"Such",
"Same",
"Other",
"Another",
"Yet",
"Still",
"Because",
"While",
"After",
"Before",
"During",
"Since",
"Until",
"Between",
"Through",
"About",
"Into",
"Over",
"Under",
"Again",
"Once",
"Here",
"There",
"Now",
"Thus",
"However",
"Therefore",
"Note",
"See",
"Can",
"Could",
"Will",
"Would",
"Should",
"May",
"Might",
"Must",
"Let",
"Get",
"Got",
];
const AMBIGUOUS_COMMON_ENTITIES: &[&str] = &[
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday",
];
fn is_entity_stopword(tok: &str) -> bool {
ENTITY_STOPWORDS.iter().any(|s| s.eq_ignore_ascii_case(tok))
|| AMBIGUOUS_COMMON_ENTITIES
.iter()
.any(|s| s.eq_ignore_ascii_case(tok))
}
const MAX_ENTITY_TOKENS: usize = 6;
const MAX_ALLCAPS_TOKENS: usize = 2;
fn is_contraction(tok: &str) -> bool {
let lower = tok.to_lowercase();
if let Some(pos) = lower.find('\'') {
let tail = &lower[pos + 1..];
let next_upper = tok[pos + 1..]
.chars()
.next()
.is_some_and(|c| c.is_uppercase());
return matches!(tail, "m" | "d" | "ll" | "ve" | "re" | "s" | "t") && !next_upper;
}
false
}
fn is_all_caps_token(tok: &str) -> bool {
tok.chars().any(|c| c.is_alphabetic())
&& tok.chars().all(|c| !c.is_alphabetic() || c.is_uppercase())
}
fn is_prose_run(chunk: &[String]) -> bool {
if chunk.len() > MAX_ENTITY_TOKENS {
return true;
}
chunk.iter().filter(|t| is_all_caps_token(t)).count() > MAX_ALLCAPS_TOKENS
}
pub fn is_rejected_entity_name(name: &str) -> bool {
let toks: Vec<String> = name.split_whitespace().map(|s| s.to_string()).collect();
if toks.is_empty() {
return true;
}
if !name.chars().any(|c| c.is_alphabetic()) {
return true;
}
if toks.iter().all(|t| is_entity_stopword(t)) {
return true;
}
is_prose_run(&toks)
}
fn strip_code(text: &str) -> std::borrow::Cow<'_, str> {
if !text.contains('`') {
return std::borrow::Cow::Borrowed(text);
}
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(t) = rest.find('`') {
out.push_str(&rest[..t]);
out.push(' ');
let after = &rest[t..];
if let Some(body) = after.strip_prefix("```") {
match body.find("```") {
Some(end) => rest = &body[end + 3..],
None => return std::borrow::Cow::Owned(out), }
} else {
let body = &after[1..];
match body.find('`') {
Some(end) => rest = &body[end + 1..],
None => {
out.push_str(body);
return std::borrow::Cow::Owned(out);
}
}
}
}
out.push_str(rest);
std::borrow::Cow::Owned(out)
}
pub const SENTENCE_OPENERS: &[&str] = &[
"today",
"tonight",
"tomorrow",
"yesterday",
"meanwhile",
"however",
"later",
"earlier",
"then",
"now",
"also",
"finally",
"recently",
"currently",
"previously",
"next",
"first",
"second",
"last",
"after",
"before",
"during",
"since",
"until",
"when",
"while",
"once",
"still",
"already",
"soon",
"again",
"here",
"there",
"overall",
"otherwise",
"instead",
"besides",
"anyway",
"note",
"update",
"reminder",
"result",
"status",
"conclusion",
];
pub const COMMON_WORD_SEED: &[&str] = &[
"about",
"above",
"actually",
"add",
"added",
"adding",
"after",
"again",
"against",
"ago",
"all",
"almost",
"already",
"also",
"although",
"always",
"another",
"anyway",
"apparently",
"around",
"ask",
"asked",
"back",
"basically",
"because",
"before",
"began",
"begin",
"behind",
"below",
"besides",
"better",
"between",
"big",
"both",
"bring",
"build",
"builder",
"built",
"call",
"called",
"came",
"can",
"cannot",
"capability",
"certainly",
"change",
"changed",
"check",
"checked",
"clearly",
"close",
"closed",
"code",
"come",
"coming",
"common",
"compare",
"consider",
"critically",
"current",
"currently",
"day",
"days",
"decide",
"decided",
"default",
"definitely",
"delete",
"deleted",
"did",
"different",
"do",
"does",
"doing",
"done",
"down",
"during",
"each",
"early",
"easy",
"efficient",
"either",
"else",
"end",
"enough",
"especially",
"even",
"eventually",
"ever",
"every",
"everything",
"exactly",
"example",
"except",
"expected",
"fail",
"failed",
"failing",
"fails",
"far",
"fast",
"few",
"final",
"finally",
"find",
"first",
"fix",
"fixed",
"fixing",
"follow",
"following",
"found",
"from",
"full",
"further",
"general",
"generally",
"get",
"gets",
"getting",
"give",
"given",
"go",
"going",
"good",
"got",
"great",
"had",
"happens",
"hard",
"has",
"have",
"having",
"hence",
"here",
"high",
"hopefully",
"how",
"however",
"idea",
"ideally",
"idempotent",
"if",
"important",
"instead",
"into",
"issue",
"just",
"keep",
"key",
"kind",
"large",
"last",
"later",
"least",
"less",
"let",
"lets",
"like",
"likely",
"line",
"link",
"linked",
"little",
"long",
"look",
"looked",
"looking",
"low",
"made",
"main",
"make",
"makes",
"making",
"many",
"may",
"maybe",
"mean",
"means",
"meanwhile",
"might",
"more",
"moreover",
"most",
"mostly",
"move",
"moved",
"much",
"must",
"near",
"need",
"needed",
"needs",
"never",
"new",
"next",
"nice",
"no",
"nope",
"normally",
"not",
"note",
"nothing",
"now",
"obviously",
"of",
"off",
"often",
"ok",
"okay",
"old",
"on",
"once",
"one",
"only",
"open",
"opened",
"option",
"or",
"other",
"otherwise",
"our",
"out",
"over",
"overall",
"own",
"part",
"pass",
"passed",
"past",
"per",
"perhaps",
"plan",
"please",
"point",
"possible",
"possibly",
"previous",
"previously",
"probably",
"problem",
"put",
"quick",
"quickly",
"quite",
"rather",
"ready",
"real",
"really",
"reason",
"recent",
"recently",
"remove",
"removed",
"result",
"results",
"right",
"run",
"running",
"runs",
"said",
"same",
"saw",
"say",
"says",
"second",
"see",
"seems",
"seen",
"set",
"several",
"should",
"show",
"shows",
"similar",
"simple",
"simply",
"since",
"small",
"so",
"some",
"something",
"sometimes",
"soon",
"start",
"started",
"starting",
"still",
"stop",
"stopped",
"such",
"sure",
"take",
"taken",
"target",
"test",
"tested",
"testing",
"tests",
"than",
"that",
"then",
"there",
"therefore",
"these",
"thing",
"things",
"think",
"this",
"those",
"though",
"three",
"through",
"thus",
"time",
"today",
"together",
"tomorrow",
"tonight",
"too",
"took",
"total",
"tried",
"true",
"try",
"trying",
"turn",
"two",
"under",
"unfortunately",
"unless",
"until",
"up",
"update",
"updated",
"upon",
"use",
"used",
"using",
"usually",
"very",
"want",
"wanted",
"way",
"weaker",
"well",
"went",
"were",
"what",
"whatever",
"when",
"whenever",
"where",
"whether",
"which",
"while",
"why",
"will",
"with",
"within",
"without",
"work",
"worked",
"working",
"works",
"would",
"wrong",
"yes",
"yesterday",
"yet",
"you",
"your",
"false",
"nil",
"none",
"null",
"undefined",
];
pub const COMMON_WORD_MIN_LOWER: i64 = 3;
pub const COMMON_WORD_LOWER_RATIO: i64 = 2;
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct CaseStats {
pub lower_n: i64,
pub cap_mid_n: i64,
pub cap_start_n: i64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TokenCase {
Lower,
CapStart,
CapMid,
}
pub fn token_case_observations(text: &str) -> Vec<(String, TokenCase)> {
let stripped: String = mark_sentence_ends(strip_code(text).as_ref());
let mut seen: std::collections::HashSet<(String, TokenCase)> = std::collections::HashSet::new();
let mut out = Vec::new();
for segment in stripped.split(|c: char| {
matches!(
c,
'.' | '!'
| '?'
| ':'
| ';'
| '\n'
| '('
| '['
| '"'
| '\u{201c}'
| '\u{201d}'
| '\u{2014}'
| '\u{2013}'
| '*'
| '\u{2022}'
| '>'
| '|'
)
}) {
let mut first = true;
for word in segment
.split(|c: char| !c.is_alphanumeric() && c != '\'')
.filter(|s| !s.is_empty())
{
let word = word.trim_matches('\'');
if word.chars().count() < 2 || !word.chars().all(|c| c.is_alphabetic() || c == '\'') {
if !word.is_empty() {
first = false;
}
continue;
}
let class = if word.chars().next().is_some_and(|c| c.is_uppercase()) {
if first {
TokenCase::CapStart
} else {
TokenCase::CapMid
}
} else {
TokenCase::Lower
};
first = false;
let key = (word.to_lowercase(), class);
if seen.insert(key.clone()) {
out.push(key);
}
}
}
out
}
pub fn is_common_word(token: &str, stats: Option<CaseStats>) -> bool {
if let Some(s) = stats {
if s.cap_mid_n >= COMMON_WORD_MIN_LOWER
&& s.cap_mid_n > s.lower_n
&& s.cap_mid_n >= s.cap_start_n
{
return false;
}
if s.lower_n >= COMMON_WORD_MIN_LOWER && s.lower_n >= COMMON_WORD_LOWER_RATIO * s.cap_mid_n
{
return true;
}
}
let lower = token.to_lowercase();
COMMON_WORD_SEED.contains(&lower.as_str())
}
pub fn admit_entity_with<F>(name: &str, lookup: F) -> bool
where
F: Fn(&str) -> Option<CaseStats>,
{
if !admit_entity(name) {
return false;
}
let toks: Vec<&str> = name.split_whitespace().collect();
if toks.len() != 1 {
return true;
}
let tok = toks[0];
!is_common_word(tok, lookup(&tok.to_lowercase()))
}
pub const ENTITY_MAX_CHARS: usize = 40;
pub const ENTITY_MAX_WORDS: usize = 4;
pub const ACRONYM_MAX_CHARS: usize = 6;
pub const ACRONYM_RUN_TOKEN_MAX_CHARS: usize = 5;
pub fn admit_entity(name: &str) -> bool {
let name = name.trim();
if name.ends_with("'s") || name.ends_with("\u{2019}s") || name.ends_with('\'') {
return false;
}
if name.split_whitespace().any(is_contraction) {
return false;
}
if is_rejected_entity_name(name) {
return false;
}
let mut toks: Vec<&str> = name.split_whitespace().collect();
while toks.first().is_some_and(|t| is_entity_stopword(t)) {
toks.remove(0);
}
while toks
.last()
.is_some_and(|t| is_entity_stopword(t) && t.chars().count() > 1)
{
toks.pop();
}
if toks.is_empty() || !toks.iter().any(|t| t.chars().any(|c| c.is_alphabetic())) {
return false;
}
if toks.len() > ENTITY_MAX_WORDS || name.chars().count() >= ENTITY_MAX_CHARS {
return false;
}
let caps: Vec<bool> = toks.iter().map(|t| is_all_caps_token(t)).collect();
if toks.len() == 1 && caps[0] && toks[0].chars().count() > ACRONYM_MAX_CHARS {
return false;
}
if toks.len() > 1
&& caps.iter().all(|&c| c)
&& toks
.iter()
.any(|t| t.chars().count() > ACRONYM_RUN_TOKEN_MAX_CHARS)
{
return false;
}
true
}
pub const VALUE_OBJECT_RELS: &[&str] = &["runs", "born_in", "founded_in", "released"];
pub fn relation_admits_value_object(rel_type: &str, dst: &str) -> bool {
!is_value_object(dst) || VALUE_OBJECT_RELS.contains(&rel_type)
}
pub fn is_value_object(name: &str) -> bool {
let name = name.trim();
if name.is_empty() || !name.chars().any(|c| c.is_ascii_digit()) {
return false;
}
let mut prev_sep = true;
for c in name.chars() {
if c.is_ascii_digit() {
prev_sep = false;
} else if (c == '.' || c == '-') && !prev_sep {
prev_sep = true;
} else {
return false;
}
}
!prev_sep
}
pub fn extract_value_candidates(text: &str) -> Vec<String> {
let stripped = strip_code(text);
let mut out: Vec<String> = Vec::new();
for word in stripped
.split(|c: char| {
c.is_whitespace() || matches!(c, ',' | ';' | ':' | '(' | ')' | '[' | ']' | '"' | '\'')
})
.filter(|s| !s.is_empty())
{
let w = word.trim_end_matches(|c: char| c == '.' || c == '!' || c == '?');
if !is_value_object(w) {
continue;
}
if !out.iter().any(|o| o == w) {
out.push(w.to_string());
}
}
out
}
pub fn extract_heuristic_entities(text: &str) -> Vec<String> {
extract_heuristic_entities_with(text, |_| None)
}
pub fn extract_heuristic_entities_with<F>(text: &str, lookup: F) -> Vec<String>
where
F: Fn(&str) -> Option<CaseStats>,
{
let stripped = strip_code(text);
extract_heuristic_entities_inner(stripped.as_ref(), &lookup)
}
const ABBREVIATIONS_BEFORE_PERIOD: &[&str] = &[
"mr", "mrs", "ms", "dr", "prof", "st", "mt", "ft", "gen", "sen", "rep", "gov", "capt", "lt",
"sgt", "col",
];
fn mark_sentence_ends(text: &str) -> String {
let mut out = String::with_capacity(text.len() + 8);
let chars: Vec<char> = text.chars().collect();
let mut word = String::new();
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if c == '.' {
let next_ws = i + 1 >= chars.len() || chars[i + 1].is_whitespace();
let prev = word.to_lowercase();
let is_abbrev = prev.chars().count() == 1 && prev.chars().all(|ch| ch.is_alphabetic())
|| ABBREVIATIONS_BEFORE_PERIOD.contains(&prev.as_str());
out.push('.');
if next_ws && !prev.is_empty() && !is_abbrev {
out.push('\n');
}
word.clear();
} else {
if c.is_alphanumeric() || c == '\'' {
word.push(c);
} else {
word.clear();
}
out.push(c);
}
i += 1;
}
out
}
fn extract_heuristic_entities_inner(
text: &str,
lookup: &dyn Fn(&str) -> Option<CaseStats>,
) -> Vec<String> {
let text_owned = mark_sentence_ends(text);
let text = text_owned.as_str();
let mut entities: Vec<String> = Vec::new();
for segment in text.split(|c: char| {
matches!(
c,
':' | ';' | ',' | '!' | '?' | '\n' | '(' | ')' | '[' | ']' | '"'
)
}) {
extract_entities_from_segment(segment, &mut entities, lookup);
}
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
entities.retain(|e| seen.insert(e.clone()));
entities
}
fn extract_entities_from_segment(
text: &str,
entities: &mut Vec<String>,
lookup: &dyn Fn(&str) -> Option<CaseStats>,
) {
let mut chunk: Vec<String> = Vec::new();
let mut chunk_opens_segment = false;
let flush = |chunk: &mut Vec<String>, out: &mut Vec<String>, opens_segment: bool| {
if opens_segment && chunk.len() >= 2 {
let first = &chunk[0];
if is_sentence_opener_with(first, lookup) {
chunk.remove(0);
}
}
while !chunk.is_empty() && is_entity_stopword(&chunk[0]) {
chunk.remove(0);
}
while let Some(last) = chunk.last() {
if is_entity_stopword(last) && last.chars().count() > 1 {
chunk.pop();
} else {
break;
}
}
if !chunk.is_empty() && !is_prose_run(chunk) {
let candidate = chunk.join(" ");
let alpha_chars = candidate.chars().filter(|c| c.is_alphanumeric()).count();
if alpha_chars >= 2 && admit_entity_with(&candidate, lookup) {
out.push(candidate);
}
}
chunk.clear();
};
let mut first_word = true;
for word in text
.split(|c: char| !c.is_alphanumeric() && c != '\'')
.filter(|s| !s.is_empty())
{
let at_segment_start = first_word;
first_word = false;
let word = word.trim_start_matches('\'');
if word.is_empty() {
flush(&mut chunk, entities, chunk_opens_segment);
continue;
}
let possessive = word
.strip_suffix("'s")
.or_else(|| word.strip_suffix("'S"))
.or_else(|| word.strip_suffix('\''))
.filter(|bare| !bare.is_empty());
let entity_word = possessive.unwrap_or(word);
if is_contraction(entity_word) {
flush(&mut chunk, entities, chunk_opens_segment);
continue;
}
if !entity_word.chars().any(|c| c.is_alphabetic()) {
flush(&mut chunk, entities, chunk_opens_segment);
continue;
}
let first = entity_word.chars().next().unwrap();
let starts_upper = first.is_uppercase();
let is_all_caps = entity_word.len() > 1
&& entity_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 || (entity_word.len() == 1 && first.is_ascii_uppercase())
};
if joins_chunk {
if chunk.is_empty() {
chunk_opens_segment = at_segment_start;
}
chunk.push(entity_word.to_string());
if possessive.is_some() {
flush(&mut chunk, entities, chunk_opens_segment);
}
} else {
flush(&mut chunk, entities, chunk_opens_segment);
}
}
flush(&mut chunk, entities, chunk_opens_segment);
}
fn is_sentence_opener_with(token: &str, lookup: &dyn Fn(&str) -> Option<CaseStats>) -> bool {
let lower = token.to_lowercase();
if SENTENCE_OPENERS.contains(&lower.as_str()) {
return true;
}
match lookup(&lower) {
Some(s) => {
s.lower_n >= COMMON_WORD_MIN_LOWER
&& s.lower_n >= COMMON_WORD_LOWER_RATIO * s.cap_mid_n
&& !(s.cap_mid_n >= COMMON_WORD_MIN_LOWER && s.cap_mid_n > s.lower_n)
}
None => false,
}
}
#[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, pub span: Option<(usize, usize)>,
}
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", "manages", "directs"], "leads"),
(&["runs", "is running", "now runs"], "runs"),
(
&[
"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"),
];
const ANCHORED_RELATION_PATTERNS: &[(&[&str], &str)] = &[
(
&[
"lives in",
"live in",
"living in",
"now lives in",
"resides in",
"reside in",
"residing in",
"moved to",
"has moved to",
"relocated to",
"has relocated to",
],
"lives_in",
),
(
&["hometown is", "grew up in", "originally from"],
"hometown",
),
];
fn contains_word_phrase(hay: &str, phrase: &str) -> bool {
if phrase.is_empty() {
return false;
}
let mut start = 0;
while let Some(idx) = hay[start..].find(phrase) {
let at = start + idx;
let end = at + phrase.len();
if boundary_before(hay, at) && boundary_after(hay, end) {
return true;
}
start = at + hay[at..].chars().next().map_or(1, char::len_utf8);
}
false
}
fn ends_with_word_phrase(hay: &str, phrase: &str) -> bool {
let hay = hay.trim_end();
if phrase.is_empty() || !hay.ends_with(phrase) {
return false;
}
boundary_before(hay, hay.len() - phrase.len())
}
fn contains_phrase_end_bounded(hay: &str, phrase: &str) -> bool {
if phrase.is_empty() {
return false;
}
let mut start = 0;
while let Some(idx) = hay[start..].find(phrase) {
let at = start + idx;
if boundary_after(hay, at + phrase.len()) {
return true;
}
start = at + hay[at..].chars().next().map_or(1, char::len_utf8);
}
false
}
fn strip_trailing_articles(window: &str) -> String {
let mut toks: Vec<&str> = window.split_whitespace().collect();
while matches!(toks.last().copied(), Some("the" | "a" | "an")) {
toks.pop();
}
toks.join(" ")
}
fn boundary_before(hay: &str, at: usize) -> bool {
at == 0
|| !hay[..at]
.chars()
.next_back()
.is_some_and(char::is_alphanumeric)
}
fn boundary_after(hay: &str, end: usize) -> bool {
end >= hay.len() || !hay[end..].chars().next().is_some_and(char::is_alphanumeric)
}
pub fn extract_learned_relations(
text: &str,
entities: &[String],
templates: &[(String, String)],
) -> Vec<RelationCandidate> {
if templates.is_empty() {
return vec![];
}
bind_relations(text, entities, templates).relations
}
pub fn extract_heuristic_relations(text: &str, entities: &[String]) -> Vec<RelationCandidate> {
extract_relations_bound(text, entities).relations
}
pub const REFUSAL_NO_SUBJECT: &str = "no_subject";
pub const REFUSAL_LOWERCASE_SUBJECT: &str = "lowercase_subject";
pub const REFUSAL_SUBJECT_NOT_ADMITTED: &str = "subject_not_admitted";
pub const REFUSAL_SUBJECT_NOT_ADJACENT: &str = "subject_not_adjacent";
pub const REFUSAL_NO_OBJECT: &str = "no_object";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExtractionRefusal {
pub rel_type: String,
pub trigger: String,
pub reason: &'static str,
pub left: String,
pub right: String,
pub at: usize,
}
#[derive(Debug, Clone, Default)]
pub struct RelationExtraction {
pub relations: Vec<RelationCandidate>,
pub refusals: Vec<ExtractionRefusal>,
}
const ARROW_MARKERS: &[&str] = &["→", "->", "=>", "⇒"];
const LEAD_WRAPPERS: &[&str] = &[
"who",
"which",
"that",
"whom",
"does",
"did",
"do",
"has",
"have",
"had",
"also",
"now",
"still",
"currently",
"already",
"just",
"officially",
"then",
];
const OBJECT_ARTICLES: &[&str] = &["the", "a", "an"];
fn relation_segments(text: &str) -> Vec<(usize, usize)> {
let mut out = Vec::new();
let mut seg_start = 0usize;
let mut word = String::new();
let mut i = 0usize;
while i < text.len() {
if let Some(arrow) = ARROW_MARKERS.iter().find(|a| text[i..].starts_with(*a)) {
out.push((seg_start, i));
i += arrow.len();
seg_start = i;
word.clear();
continue;
}
let c = text[i..].chars().next().unwrap_or(' ');
let clen = c.len_utf8();
let next_ws_or_end = i + clen >= text.len()
|| text[i + clen..]
.chars()
.next()
.is_some_and(char::is_whitespace);
let boundary = match c {
'\n' | ';' | '!' | '?' => true,
':' => next_ws_or_end,
'.' => {
let prev = word.to_lowercase();
let is_abbrev = (prev.chars().count() == 1
&& prev.chars().all(|ch| ch.is_alphabetic()))
|| ABBREVIATIONS_BEFORE_PERIOD.contains(&prev.as_str());
next_ws_or_end && !prev.is_empty() && !is_abbrev
}
_ => false,
};
if boundary {
out.push((seg_start, i));
seg_start = i + clen;
word.clear();
} else if c.is_alphanumeric() || c == '\'' {
word.push(c);
} else {
word.clear();
}
i += clen;
}
out.push((seg_start, text.len()));
out.into_iter().filter(|(a, b)| b > a).collect()
}
struct Tok<'a> {
raw: &'a str,
key: String,
start: usize,
end: usize,
}
fn possessive_stripped(lower: &str) -> &str {
lower
.strip_suffix("'s")
.or_else(|| lower.strip_suffix('\''))
.filter(|bare| !bare.is_empty())
.unwrap_or(lower)
}
fn segment_tokens(seg: &str) -> Vec<Tok<'_>> {
let mut toks = Vec::new();
let mut open: Option<usize> = None;
let mut push = |s: usize, e: usize| {
let raw = &seg[s..e];
let trimmed = raw.trim_matches('\'');
if trimmed.is_empty() {
return;
}
let lead = raw.len() - raw.trim_start_matches('\'').len();
let start = s + lead;
let lower = trimmed.to_lowercase();
toks.push(Tok {
raw: trimmed,
key: possessive_stripped(&lower).to_string(),
start,
end: start + trimmed.len(),
});
};
for (i, c) in seg.char_indices() {
if c.is_alphanumeric() || c == '\'' {
if open.is_none() {
open = Some(i);
}
} else if let Some(s) = open.take() {
push(s, i);
}
}
if let Some(s) = open {
push(s, seg.len());
}
toks
}
fn key_sequence(phrase: &str) -> Vec<String> {
segment_tokens(phrase).into_iter().map(|t| t.key).collect()
}
fn find_runs(keys: &[&str], needle: &[String]) -> Vec<usize> {
if needle.is_empty() || needle.len() > keys.len() {
return Vec::new();
}
(0..=keys.len() - needle.len())
.filter(|&i| needle.iter().enumerate().all(|(j, n)| keys[i + j] == n))
.collect()
}
struct TriggerHit {
rel_type: String,
phrase: String,
s: usize,
e: usize,
}
struct MentionHit {
name: String,
s: usize,
e: usize,
}
fn dedupe_runs<T>(mut hits: Vec<T>, span: impl Fn(&T) -> (usize, usize)) -> Vec<T> {
hits.sort_by(|a, b| {
let (sa, ea) = span(a);
let (sb, eb) = span(b);
sa.cmp(&sb).then_with(|| (eb - sb).cmp(&(ea - sa)))
});
let mut kept: Vec<T> = Vec::new();
for h in hits {
let (s, e) = span(&h);
if kept.iter().any(|k| {
let (ks, ke) = span(k);
s < ke && ks < e
}) {
continue;
}
kept.push(h);
}
kept
}
fn is_negation_key(key: &str) -> bool {
NEGATION_CUES.contains(&key)
}
fn is_modality_key(key: &str) -> bool {
MODALITY_CUES.contains(&key)
}
fn bind_relations(
text: &str,
entities: &[String],
patterns: &[(String, String)],
) -> RelationExtraction {
let mut out = RelationExtraction::default();
if entities.is_empty() || patterns.is_empty() {
return out;
}
let entity_keys: Vec<(String, Vec<String>)> = entities
.iter()
.map(|e| (e.clone(), key_sequence(e)))
.filter(|(_, k)| !k.is_empty())
.collect();
let pattern_keys: Vec<(String, String, Vec<String>)> = patterns
.iter()
.map(|(phrase, rel)| (phrase.clone(), rel.clone(), key_sequence(phrase)))
.filter(|(_, _, k)| !k.is_empty())
.collect();
for (seg_start, seg_end) in relation_segments(text) {
let seg = &text[seg_start..seg_end];
let toks = segment_tokens(seg);
if toks.len() < 2 {
continue;
}
let norm: Vec<usize> = (0..toks.len())
.filter(|&i| !is_negation_key(&toks[i].key) && !is_modality_key(&toks[i].key))
.collect();
let nkeys: Vec<&str> = norm.iter().map(|&i| toks[i].key.as_str()).collect();
let mut mentions: Vec<MentionHit> = Vec::new();
for (name, keys) in &entity_keys {
for s in find_runs(&nkeys, keys) {
mentions.push(MentionHit {
name: name.clone(),
s,
e: s + keys.len(),
});
}
}
let mentions = dedupe_runs(mentions, |m| (m.s, m.e));
if mentions.is_empty() {
continue;
}
let mut triggers: Vec<TriggerHit> = Vec::new();
for (phrase, rel, keys) in &pattern_keys {
for s in find_runs(&nkeys, keys) {
triggers.push(TriggerHit {
rel_type: rel.clone(),
phrase: phrase.clone(),
s,
e: s + keys.len(),
});
}
}
let triggers = dedupe_runs(triggers, |t| (t.s, t.e));
let raw_at = |n: usize| toks[norm[n]].raw.to_string();
let mut last_bound: Option<(usize, usize, usize)> = None; for t in &triggers {
let at = seg_start + toks[norm[t.s]].start;
let mut object: Option<&MentionHit> = None;
let mut object_break: Option<String> = None;
for m in mentions.iter().filter(|m| m.s >= t.e) {
let gap = &nkeys[t.e..m.s];
match gap.iter().find(|g| !OBJECT_ARTICLES.contains(g)) {
None => object = Some(m),
Some(_) => {
object_break = gap
.iter()
.position(|g| !OBJECT_ARTICLES.contains(g))
.map(|i| raw_at(t.e + i));
}
}
break;
}
let Some(object) = object else {
out.refusals.push(ExtractionRefusal {
rel_type: t.rel_type.clone(),
trigger: t.phrase.clone(),
reason: REFUSAL_NO_OBJECT,
left: if t.s > 0 {
raw_at(t.s - 1)
} else {
String::new()
},
right: object_break.unwrap_or_else(|| {
(t.e..nkeys.len())
.find(|&i| !OBJECT_ARTICLES.contains(&nkeys[i]))
.map(raw_at)
.unwrap_or_default()
}),
at,
});
continue;
};
let subject = mentions.iter().filter(|m| m.e <= t.s).last();
let mut coordinated: Option<(usize, usize)> = None;
let (reason, left) = match subject {
None => {
if t.s == 0 {
(Some(REFUSAL_NO_SUBJECT), String::new())
} else {
let tok = &toks[norm[t.s - 1]];
let lower = tok
.raw
.chars()
.next()
.is_some_and(|c| c.is_alphabetic() && c.is_lowercase());
(
Some(if lower {
REFUSAL_LOWERCASE_SUBJECT
} else {
REFUSAL_SUBJECT_NOT_ADMITTED
}),
tok.raw.to_string(),
)
}
}
Some(m) => {
let gap = &nkeys[m.e..t.s];
if gap == ["and"] {
if let Some((ss, se, oe)) = last_bound {
if oe == m.e {
coordinated = Some((ss, se));
}
}
}
if coordinated.is_some() {
(None, String::new())
} else if gap.is_empty() {
let sep = &seg[toks[norm[m.e - 1]].end..toks[norm[t.s]].start];
if sep.contains(',') {
(Some(REFUSAL_SUBJECT_NOT_ADJACENT), ",".to_string())
} else {
(None, String::new())
}
} else {
match gap.iter().position(|g| !LEAD_WRAPPERS.contains(g)) {
None => (None, String::new()),
Some(i) => (Some(REFUSAL_SUBJECT_NOT_ADJACENT), raw_at(m.e + i)),
}
}
}
};
if let Some(reason) = reason {
out.refusals.push(ExtractionRefusal {
rel_type: t.rel_type.clone(),
trigger: t.phrase.clone(),
reason,
left,
right: object.name.clone(),
at,
});
continue;
}
let subject = subject.expect("checked above");
let (subj_name, subj_s, subj_e) = match coordinated {
Some((ss, se)) => {
let name = mentions
.iter()
.find(|m| m.s == ss && m.e == se)
.map(|m| m.name.clone())
.unwrap_or_else(|| subject.name.clone());
(name, ss, se)
}
None => (subject.name.clone(), subject.s, subject.e),
};
if subj_name == object.name {
continue;
}
last_bound = Some((subj_s, subj_e, object.e));
let lo = if coordinated.is_some() {
norm[t.s].saturating_sub(1)
} else {
norm[subj_e - 1]
};
let hi = norm[object.s];
let cues = &toks[lo..hi];
let polarity = if cues.iter().any(|c| is_negation_key(&c.key)) {
-1
} else {
1
};
let modality = if cues.iter().any(|c| is_modality_key(&c.key)) {
"reported"
} else {
"asserted"
};
out.relations.push(RelationCandidate {
src: subj_name,
rel_type: t.rel_type.clone(),
dst: object.name.clone(),
polarity,
modality: modality.to_string(),
confidence_band: "medium".to_string(),
span: Some((
seg_start + toks[norm[subj_s]].start,
seg_start + toks[norm[object.e - 1]].end,
)),
});
}
for pair in mentions.windows(2) {
let (a, b) = (&pair[0], &pair[1]);
let between = &nkeys[a.e..b.s];
if between.len() != 1 {
continue;
}
let owner_raw = toks[norm[a.e - 1]].raw;
let possessive =
owner_raw.ends_with("'s") || owner_raw.ends_with("'S") || owner_raw.ends_with("s'");
if !possessive {
continue;
}
if let Some((_, rel)) = REVERSE_ROLE_PATTERNS
.iter()
.find(|(role, _)| *role == between[0])
{
out.relations.push(RelationCandidate {
src: b.name.clone(),
rel_type: rel.to_string(),
dst: a.name.clone(),
polarity: 1,
modality: "asserted".to_string(),
confidence_band: "medium".to_string(),
span: Some((
seg_start + toks[norm[a.s]].start,
seg_start + toks[norm[b.e - 1]].end,
)),
});
}
}
}
let mut seen = std::collections::HashSet::new();
out.relations
.retain(|c| seen.insert((c.src.clone(), c.rel_type.clone(), c.dst.clone())));
let mut seen_r = std::collections::HashSet::new();
out.refusals
.retain(|r| seen_r.insert((r.rel_type.clone(), r.at)));
out
}
fn builtin_patterns() -> Vec<(String, String)> {
let mut v: Vec<(String, String)> = Vec::new();
for (patterns, rel) in RELATION_PATTERNS
.iter()
.chain(ANCHORED_RELATION_PATTERNS.iter())
{
for p in patterns.iter() {
v.push((p.to_string(), rel.to_string()));
}
}
v
}
pub fn extract_relations_bound(text: &str, entities: &[String]) -> RelationExtraction {
bind_relations(text, entities, &builtin_patterns())
}
pub fn builtin_relation_types() -> Vec<String> {
let mut v: Vec<String> = RELATION_PATTERNS
.iter()
.chain(ANCHORED_RELATION_PATTERNS.iter())
.map(|(_, rel)| rel.to_string())
.chain(REVERSE_ROLE_PATTERNS.iter().map(|(_, rel)| rel.to_string()))
.collect();
v.sort();
v.dedup();
v
}
const NEGATION_CUES: &[&str] = &[
"not", "no", "never", "denied", "refuted", "isn't", "wasn't", "aren't", "weren't", "doesn't",
"didn't", "disputes", "denies",
];
pub fn negation_cue(word: &str) -> bool {
NEGATION_CUES.contains(&word)
}
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_lives_in_is_anchored_to_the_next_entity() {
let entities = vec![
"Pranab".to_string(),
"Berlin".to_string(),
"Maria".to_string(),
];
let rels = extract_heuristic_relations("Pranab lives in Berlin with Maria", &entities);
let lives: Vec<_> = rels.iter().filter(|r| r.rel_type == "lives_in").collect();
assert_eq!(lives.len(), 1, "got: {:?}", rels);
assert_eq!(lives[0].src, "Pranab");
assert_eq!(lives[0].dst, "Berlin");
assert_eq!(lives[0].polarity, 1);
}
#[test]
fn test_extract_relations_moved_to_shares_the_lives_in_key() {
let entities = vec!["Alice Moreau".to_string(), "Munich".to_string()];
let rels = extract_heuristic_relations("Alice Moreau moved to Munich last year", &entities);
assert_eq!(rels.len(), 1, "got: {:?}", rels);
assert_eq!(rels[0].rel_type, "lives_in");
assert_eq!(rels[0].dst, "Munich");
}
#[test]
fn test_extract_relations_lives_in_negation() {
let entities = vec!["Pranab".to_string(), "Berlin".to_string()];
let rels = extract_heuristic_relations("Pranab does not live in Berlin", &entities);
assert_eq!(rels.len(), 1, "got: {:?}", rels);
assert_eq!(rels[0].rel_type, "lives_in");
assert_eq!(rels[0].polarity, -1);
}
#[test]
fn test_extract_relations_hometown() {
let entities = vec!["Pranab".to_string(), "Kolkata".to_string()];
for text in ["Pranab's hometown is Kolkata", "Pranab grew up in Kolkata"] {
let rels = extract_heuristic_relations(text, &entities);
assert_eq!(rels.len(), 1, "{text}: {:?}", rels);
assert_eq!(rels[0].rel_type, "hometown", "{text}");
assert_eq!(rels[0].dst, "Kolkata", "{text}");
}
}
#[test]
fn test_extract_relations_headquartered_does_not_mint_reverse_leads() {
let entities = vec!["Fennwick Labs".to_string(), "Berlin".to_string()];
let rels =
extract_heuristic_relations("Fennwick Labs is headquartered in Berlin", &entities);
assert!(
rels.iter().all(|r| r.rel_type == "headquartered_in"),
"got: {:?}",
rels
);
assert_eq!(rels.len(), 1, "got: {:?}", rels);
}
#[test]
fn test_extract_relations_patterns_match_whole_words_only() {
let entities = vec!["Acme".to_string(), "Globex".to_string()];
let rels =
extract_heuristic_relations("Acme dismissed unfounded rumors about Globex", &entities);
assert!(
rels.is_empty(),
"'unfounded' must not mint founded, got: {:?}",
rels
);
let rels = extract_heuristic_relations("Acme founded Globex", &entities);
assert_eq!(rels.len(), 1, "got: {:?}", rels);
assert_eq!(rels[0].rel_type, "founded");
}
#[test]
fn test_extract_relations_possessive_role_still_matches() {
let entities = vec!["Acme".to_string(), "Alice".to_string()];
let rels = extract_heuristic_relations("Acme's CEO, Alice, spoke first", &entities);
assert!(
rels.iter()
.any(|r| r.rel_type == "ceo_of" && r.src == "Alice" && r.dst == "Acme"),
"got: {:?}",
rels
);
}
#[test]
fn test_extract_learned_relations_is_anchored_and_labelled() {
let entities = vec!["Dana".to_string(), "Priya".to_string(), "Acme".to_string()];
let templates = vec![("mentors".to_string(), "mentors".to_string())];
let rels = extract_learned_relations(
"Dana mentors Priya at Acme this quarter",
&entities,
&templates,
);
assert_eq!(rels.len(), 1, "got: {:?}", rels);
assert_eq!(
(
rels[0].src.as_str(),
rels[0].rel_type.as_str(),
rels[0].dst.as_str()
),
("Dana", "mentors", "Priya")
);
let rels = extract_learned_relations(
"Dana does not mentor Priya",
&entities,
&[("mentor".into(), "mentors".into())],
);
assert_eq!(rels.len(), 1);
assert_eq!(rels[0].polarity, -1);
assert!(extract_learned_relations("Dana mentors Priya", &entities, &[]).is_empty());
}
#[test]
fn test_extract_relations_runs_is_a_version_relation_not_leadership() {
let entities = vec!["CT128".to_string(), "Yantrikdb".to_string()];
let rels = extract_heuristic_relations("CT128 runs Yantrikdb in production", &entities);
assert_eq!(rels.len(), 1, "got: {:?}", rels);
assert_eq!(rels[0].rel_type, "runs");
assert!(rels.iter().all(|r| r.rel_type != "leads"));
let entities = vec!["Alice".to_string(), "Acme".to_string()];
let rels = extract_heuristic_relations("Alice leads Acme", &entities);
assert_eq!(rels[0].rel_type, "leads");
}
#[test]
fn test_forward_patterns_are_anchored_to_the_adjacent_pair() {
let entities = vec![
"Pranab".to_string(),
"Materializer".to_string(),
"UTC".to_string(),
];
let rels = extract_heuristic_relations(
"Pranab confirmed the Materializer runs the loop every tick at UTC midnight",
&entities,
);
assert!(
!rels.iter().any(|r| r.src == "Pranab" && r.dst == "UTC"),
"no claim may bridge Pranab and UTC across Materializer: {:?}",
rels
);
let entities = vec!["Alice".to_string(), "Acme".to_string()];
let rels = extract_heuristic_relations("Alice works at the Acme office", &entities);
assert!(rels.iter().any(|r| r.rel_type == "works_at"), "{:?}", rels);
let rels =
extract_heuristic_relations("Alice works at home and later visited Acme", &entities);
assert!(
rels.is_empty(),
"verb not adjacent to the object: {:?}",
rels
);
let entities = vec![
"Alice Chen".to_string(),
"CEO".to_string(),
"Acme Corp".to_string(),
];
let rels = extract_heuristic_relations("Alice Chen is the CEO of Acme Corp", &entities);
assert!(
rels.iter()
.any(|r| r.rel_type == "ceo_of" && r.src == "Alice Chen" && r.dst == "Acme Corp"),
"{:?}",
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_splits_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");
}
}
#[cfg(test)]
mod code_stripping_tests {
use super::*;
#[test]
fn code_identifiers_do_not_become_entities() {
let text = "Alice deployed the service.\n\n```python\n\
@app.route('/login', methods=['GET', 'POST'])\n\
def login():\n data = LoginSchema(String)\n```\n\
She reported it to Acme Corp.";
let got = extract_heuristic_entities(text);
for bad in ["GET", "POST", "String", "LoginSchema"] {
assert!(
!got.iter().any(|e| e.contains(bad)),
"code identifier {bad:?} leaked into entities: {got:?}"
);
}
assert!(
got.iter().any(|e| e == "Alice"),
"lost prose entity: {got:?}"
);
assert!(
got.iter().any(|e| e.contains("Acme")),
"lost prose entity after the block: {got:?}"
);
}
#[test]
fn inline_spans_are_stripped_without_welding_neighbours() {
let got = extract_heuristic_entities("Bob set `MAX_RETRIES` Carol reviewed it");
assert!(!got.iter().any(|e| e.contains("MAX_RETRIES")), "{got:?}");
assert!(got.iter().any(|e| e == "Bob"), "{got:?}");
assert!(got.iter().any(|e| e == "Carol"), "{got:?}");
assert!(!got.iter().any(|e| e == "Bob Carol"), "welded: {got:?}");
}
#[test]
fn inline_span_before_a_fence_is_still_stripped() {
let text = "`GET` Alice then
```python
class User: pass
```
done";
let got = extract_heuristic_entities(text);
assert!(
!got.iter().any(|e| e.contains("GET")),
"inline span leaked: {got:?}"
);
assert!(
!got.iter().any(|e| e.contains("User")),
"fence leaked: {got:?}"
);
assert!(got.iter().any(|e| e == "Alice"), "prose lost: {got:?}");
}
#[test]
fn text_without_backticks_is_unchanged() {
let plain = "Alice Chen is the CEO of Acme Corp";
assert_eq!(
extract_heuristic_entities(plain),
extract_heuristic_entities_inner(plain, &|_| None),
"no-backtick path must be byte-identical to the pre-change behavior"
);
assert!(matches!(strip_code(plain), std::borrow::Cow::Borrowed(_)));
}
#[test]
fn unterminated_markers_do_not_drop_prose() {
let got = extract_heuristic_entities("Dave noted ` then Erin shipped it");
assert!(
got.iter().any(|e| e == "Erin"),
"prose lost after stray tick: {got:?}"
);
}
}
#[cfg(test)]
mod stopword_hygiene_tests {
use super::*;
#[test]
fn all_caps_function_words_are_not_entities() {
for text in [
"AT the meeting we shipped it",
"THE release went out",
"DID the migration finish",
"NOT a real entity here",
] {
let ents = extract_heuristic_entities(text);
for bad in ["AT", "THE", "DID", "NOT"] {
assert!(
!ents.iter().any(|e| e == bad),
"{bad:?} became an entity from {text:?} -> {ents:?}"
);
}
}
}
#[test]
fn mixed_case_function_words_are_not_entities() {
let ents = extract_heuristic_entities("aT tHe meeting, dId anything ship");
assert!(
!ents.iter().any(|e| e.eq_ignore_ascii_case("at")
|| e.eq_ignore_ascii_case("the")
|| e.eq_ignore_ascii_case("did")),
"mixed-case function word survived: {ents:?}"
);
}
#[test]
fn newly_listed_function_words_are_not_entities() {
let ents = extract_heuristic_entities("Most of it shipped. Not all. More later.");
for bad in ["Most", "Not", "More"] {
assert!(
!ents.iter().any(|e| e == bad),
"{bad:?} became an entity -> {ents:?}"
);
}
}
#[test]
fn bare_month_names_are_not_entities() {
let ents = extract_heuristic_entities("June was busy. We shipped in March.");
for bad in ["June", "March"] {
assert!(
!ents.iter().any(|e| e == bad),
"{bad:?} became an entity -> {ents:?}"
);
}
}
#[test]
fn real_entities_still_extracted() {
let ents = extract_heuristic_entities(
"At Yantrik Systems we met Alice Chen about the Boston office.",
);
for good in ["Yantrik Systems", "Alice Chen", "Boston"] {
assert!(
ents.iter()
.any(|e| e.contains(good) || good.contains(e.as_str())),
"real entity {good:?} was lost -> {ents:?}"
);
}
}
#[test]
fn all_caps_acronyms_survive() {
let ents = extract_heuristic_entities("The NASA contract and the HNSW index shipped.");
assert!(
ents.iter().any(|e| e.contains("NASA")),
"NASA was stripped as if it were a function word -> {ents:?}"
);
}
}
#[cfg(test)]
mod prose_run_tests {
use super::*;
#[test]
fn all_caps_headings_are_not_entities() {
for text in [
"THINGS I MISSED THAT CODEX FOUND BY READING THE CODE follow.",
"USER MUST UPDATE MCP CONFIG before restarting.",
"REAL ESTATE TAX ANALYSIS was attached.",
"HERMES REMOTE DESKTOP LIVE VERIFICATION PASSED today.",
] {
for e in extract_heuristic_entities(text) {
let caps = e
.split_whitespace()
.filter(|t| is_all_caps_token(t))
.count();
assert!(
caps <= MAX_ALLCAPS_TOKENS,
"heading became entity {e:?} from {text:?}"
);
}
}
}
#[test]
fn overlong_capitalized_runs_are_not_entities() {
let ents =
extract_heuristic_entities("Recall Return Unrelated Records Root Cause Found Today");
assert!(
ents.iter()
.all(|e| e.split_whitespace().count() <= MAX_ENTITY_TOKENS),
"overlong run survived -> {ents:?}"
);
}
#[test]
fn short_acronyms_and_names_survive() {
let ents = extract_heuristic_entities(
"NASA and IBM Watson met Alice Chen at Yantrik Systems in San Francisco.",
);
for good in [
"NASA",
"IBM Watson",
"Alice Chen",
"Yantrik Systems",
"San Francisco",
] {
assert!(
ents.iter().any(|e| e.contains(good)),
"real entity {good:?} lost -> {ents:?}"
);
}
}
#[test]
fn two_token_all_caps_names_survive() {
let ents = extract_heuristic_entities("The NASA JPL team shipped it.");
assert!(
ents.iter().any(|e| e.contains("NASA JPL")),
"two-token acronym name lost -> {ents:?}"
);
}
}
#[cfg(test)]
mod possessive_entity_tests {
use super::*;
#[test]
fn possessives_are_canonicalized_before_becoming_entities() {
let ents = extract_heuristic_entities(
"Pranab's benchmark compared Reddit's API with Sol's Q2 plan.",
);
for canonical in ["Pranab", "Reddit", "Sol", "Q2"] {
assert!(
ents.iter().any(|e| e == canonical),
"canonical {canonical:?} missing from {ents:?}"
);
}
assert!(
ents.iter()
.all(|e| !e.ends_with("'s") && !e.ends_with('\'')),
"possessive phantom survived: {ents:?}"
);
}
#[test]
fn apostrophes_inside_names_are_preserved() {
let ents = extract_heuristic_entities("O'Brien met D'Arcy about O'Brien's release.");
assert!(ents.iter().any(|e| e == "O'Brien"), "{ents:?}");
assert!(ents.iter().any(|e| e == "D'Arcy"), "{ents:?}");
assert!(!ents.iter().any(|e| e == "O'Brien's"), "{ents:?}");
}
#[test]
fn capitalized_contractions_do_not_create_bare_phantoms() {
let ents = extract_heuristic_entities("Let's begin. It's ready. What's next?");
for bad in ["Let", "It", "What"] {
assert!(!ents.iter().any(|e| e == bad), "{bad:?} survived: {ents:?}");
}
}
}
#[cfg(test)]
mod entity_admission_tests {
use super::*;
#[test]
fn measured_junk_classes_are_refused() {
for bad in [
"2026", "0.19.0", "15", "STRATEGIC POINT", "MASTERING", "NOT 1348", "Recall Return Unrelated Records Root", "A Very Long Capitalized Phrase That Is Clearly A Sentence Not A Name",
] {
assert!(!admit_entity(bad), "{bad:?} was admitted");
}
}
#[test]
fn real_names_and_acronyms_are_admitted() {
for good in [
"Alice Chen",
"Fennwick Labs",
"San Francisco",
"NASA",
"HNSW",
"FAISS",
"CT128",
"ONNX",
"NASA JPL",
"Series A",
"Q2",
"Indian Institute",
"O'Brien",
"Yantrikdb",
] {
assert!(admit_entity(good), "{good:?} was refused");
}
}
#[test]
fn possessive_stragglers_are_refused_as_nodes() {
assert!(!admit_entity("Pranab\u{2019}s"));
assert!(!admit_entity("Pranab's"));
assert!(admit_entity("Pranab"));
}
#[test]
fn numbers_are_values_not_entities_but_still_relation_objects() {
let text = "CT128 runs 0.19.0 in production since 2026.";
let ents = extract_heuristic_entities(text);
assert!(ents.iter().any(|e| e == "CT128"), "{ents:?}");
assert!(
!ents.iter().any(|e| e == "0.19.0" || e == "2026"),
"value minted as entity: {ents:?}"
);
let values = extract_value_candidates(text);
assert_eq!(values, vec!["0.19.0".to_string(), "2026".to_string()]);
for good in ["1985", "0.19.0", "3.6", "2026-08-01", "12"] {
assert!(is_value_object(good), "{good:?} refused");
}
for bad in [
"67%", "2+", "24/7", "*/5", "~12", "+4.6%", "~121-127", "1.", "-3", "v2", "",
] {
assert!(!is_value_object(bad), "{bad:?} admitted as a value");
}
let mut cands = ents.clone();
cands.extend(values);
let rels = extract_heuristic_relations(text, &cands);
assert!(
rels.iter()
.any(|r| r.src == "CT128" && r.rel_type == "runs" && r.dst == "0.19.0"),
"runs claim lost its value object: {rels:?}"
);
}
#[test]
fn values_are_objects_only_for_relations_that_can_take_one() {
assert!(relation_admits_value_object("runs", "0.19.0"));
assert!(relation_admits_value_object("born_in", "1985"));
assert!(relation_admits_value_object("leads", "Acme")); assert!(!relation_admits_value_object("leads", "2"));
assert!(!relation_admits_value_object("works_at", "2026-08-11"));
assert!(!relation_admits_value_object("ceo_of", "42"));
}
#[test]
fn shouted_headings_never_reach_the_entity_list() {
let ents = extract_heuristic_entities(
"STRATEGIC POINT: MASTERING the release. The NASA JPL team shipped it.",
);
assert!(
!ents
.iter()
.any(|e| e.contains("STRATEGIC") || e == "MASTERING"),
"{ents:?}"
);
assert!(ents.iter().any(|e| e == "NASA JPL"), "{ents:?}");
}
}
#[cfg(test)]
mod common_word_tests {
use super::*;
#[test]
fn observations_classify_by_position_and_case_once_per_memory() {
let obs = token_case_observations(
"Critically, the build failed. Alice Moreau fixed it; make it green. Make it so!",
);
let has = |t: &str, c: TokenCase| obs.contains(&(t.to_string(), c));
assert!(has("critically", TokenCase::CapStart), "{obs:?}");
assert!(has("alice", TokenCase::CapStart), "{obs:?}");
assert!(has("moreau", TokenCase::CapMid), "{obs:?}");
assert!(
has("make", TokenCase::Lower) && has("make", TokenCase::CapStart),
"{obs:?}"
);
assert!(!has("make", TokenCase::CapMid), "{obs:?}");
assert_eq!(
obs.iter().filter(|(t, _)| t == "it").count(),
1,
"deduplicated per class"
);
}
#[test]
fn seed_refuses_sentence_starters_and_stats_override_both_ways() {
for w in [
"Critically",
"Failed",
"Idempotent",
"Lets",
"Make",
"Trying",
"Target",
] {
assert!(
is_common_word(w, None),
"{w} should be a common word by seed"
);
}
for w in ["Pranab", "Fennwick", "Berlin", "Yantrikdb"] {
assert!(!is_common_word(w, None), "{w} is a name");
}
assert!(is_common_word(
"Recall",
Some(CaseStats {
lower_n: 500,
cap_mid_n: 40,
cap_start_n: 0,
})
));
assert!(!is_common_word(
"Python",
Some(CaseStats {
lower_n: 200,
cap_mid_n: 150,
cap_start_n: 33,
})
));
assert!(!is_common_word(
"Target",
Some(CaseStats {
lower_n: 2,
cap_mid_n: 9,
cap_start_n: 1,
})
));
assert!(is_common_word(
"Make",
Some(CaseStats {
lower_n: 1,
cap_mid_n: 0,
cap_start_n: 0,
})
));
assert!(!is_common_word(
"Gizmo",
Some(CaseStats {
lower_n: 2,
cap_mid_n: 0,
cap_start_n: 0,
})
));
assert!(is_common_word(
"Gizmo",
Some(CaseStats {
lower_n: 4,
cap_mid_n: 1,
cap_start_n: 0,
})
));
}
#[test]
fn admission_with_stats_only_touches_single_token_names_and_never_acronyms() {
let none = |_: &str| None;
assert!(!admit_entity_with("Critically", none));
assert!(admit_entity_with("Alice Moreau", none));
assert!(
admit_entity_with("API", none),
"cold store, not a seed word"
);
assert!(
!admit_entity_with("CODE", none),
"cold store, shouted seed word"
);
assert!(admit_entity_with("Pranab", none));
let stats = |t: &str| match t {
"class" => Some(CaseStats {
lower_n: 450,
cap_mid_n: 57,
cap_start_n: 5,
}),
"api" => Some(CaseStats {
lower_n: 366,
cap_mid_n: 650,
cap_start_n: 28,
}),
_ => None,
};
assert!(!admit_entity_with("CLASS", stats));
assert!(admit_entity_with("API", stats));
assert!(is_common_word(
"Critically",
Some(CaseStats {
lower_n: 0,
cap_mid_n: 3,
cap_start_n: 8
})
));
assert!(is_common_word(
"FIX",
Some(CaseStats {
lower_n: 1083,
cap_mid_n: 232,
cap_start_n: 424
})
));
assert!(!is_common_word(
"Pranab",
Some(CaseStats {
lower_n: 108,
cap_mid_n: 1474,
cap_start_n: 903
})
));
assert!(!is_common_word(
"UTC",
Some(CaseStats {
lower_n: 4,
cap_mid_n: 952,
cap_start_n: 8
})
));
assert!(
is_common_word("None", None),
"literal values are seed words"
);
let obs2 = token_case_observations("we shipped it (Critically, twice) \u{2014} Finally.");
assert!(
obs2.contains(&("critically".to_string(), TokenCase::CapStart)),
"{obs2:?}"
);
assert!(
obs2.contains(&("finally".to_string(), TokenCase::CapStart)),
"{obs2:?}"
);
let learned = |t: &str| {
if t == "gizmo" {
Some(CaseStats {
lower_n: 6,
cap_mid_n: 0,
cap_start_n: 0,
})
} else {
None
}
};
assert!(!admit_entity_with("Gizmo", learned));
assert!(admit_entity_with("Gizmo Labs", learned));
}
#[test]
fn seed_is_lowercase_and_has_no_duplicates() {
let mut seen = std::collections::HashSet::new();
for w in COMMON_WORD_SEED {
assert_eq!(*w, w.to_lowercase(), "{w}");
assert!(seen.insert(*w), "duplicate {w}");
}
}
}
#[cfg(test)]
mod sentence_boundary_tests {
use super::*;
#[test]
fn a_name_at_a_sentence_end_is_not_welded_to_the_next_sentence() {
for (text, must_have, must_not) in [
(
"[June-02-2024 | Turn 0] User: Alice Moreau moved to Munich. Assistant: ok.",
vec!["Alice Moreau", "Munich"],
vec!["Munich Assistant"],
),
(
"Alice Moreau works at Fennwick Labs. Alice Moreau lives in Berlin.",
vec!["Fennwick Labs", "Berlin"],
vec!["Fennwick Labs Alice Moreau"],
),
(
"We met in St. Louis with Dr. Smith of Acme Inc. Then Bob left.",
vec!["St Louis", "Dr Smith", "Acme Inc"],
vec!["Acme Inc Then", "Louis"],
),
(
"J. K. Rowling signed. Carol Vance read it.",
vec!["J K Rowling", "Carol Vance"],
vec!["Rowling Carol Vance"],
),
] {
let ents = extract_heuristic_entities(text);
for e in &must_have {
assert!(
ents.iter().any(|x| x == e),
"{e:?} missing from {ents:?} for {text:?}"
);
}
for e in &must_not {
assert!(
!ents.iter().any(|x| x == e),
"{e:?} welded in {ents:?} for {text:?}"
);
}
}
}
#[test]
fn beam_turn_format_now_mints_the_relation() {
let t = "[June-02-2024 | Turn 0] User: Alice Moreau moved to Munich. Assistant: ok.";
let ents = extract_heuristic_entities(t);
let rels = extract_heuristic_relations(t, &ents);
assert!(
rels.iter()
.any(|r| r.src == "Alice Moreau" && r.rel_type == "lives_in" && r.dst == "Munich"),
"{rels:?}"
);
}
#[test]
fn decimals_and_initials_keep_their_periods() {
let ents = extract_heuristic_entities("CT128 runs 0.19.0 now. Mt. Fuji is tall.");
assert!(ents.iter().any(|e| e == "Mt Fuji"), "{ents:?}");
assert_eq!(
extract_value_candidates("CT128 runs 0.19.0 now."),
vec!["0.19.0".to_string()]
);
}
}
#[cfg(test)]
mod contraction_tests {
use super::*;
#[test]
fn contractions_are_never_names_but_irish_names_are() {
for bad in ["I'm", "I'd", "I'll", "We're", "Don't", "It's"] {
assert!(is_contraction(bad), "{bad}");
assert!(!admit_entity(bad), "{bad} admitted");
}
for good in ["O'Brien", "D'Arcy", "Alice", "Fennwick Labs"] {
assert!(!is_contraction(good), "{good}");
assert!(admit_entity(good), "{good} refused");
}
let ents = extract_heuristic_entities(
"I'm headquartered in East Janethaven. We'll meet Alice Moreau there.",
);
assert!(
!ents
.iter()
.any(|e| e.starts_with("I'm") || e.starts_with("We'll")),
"{ents:?}"
);
assert!(
ents.iter().any(|e| e == "East Janethaven") && ents.iter().any(|e| e == "Alice Moreau"),
"{ents:?}"
);
assert!(!admit_entity("I'm Alice"), "a contraction inside a name");
}
}
#[cfg(test)]
mod binding_tests {
use super::*;
fn triples(text: &str, entities: &[&str]) -> Vec<(String, String, String, i32)> {
let ents: Vec<String> = entities.iter().map(|e| e.to_string()).collect();
extract_heuristic_relations(text, &ents)
.into_iter()
.map(|r| (r.src, r.rel_type, r.dst, r.polarity))
.collect()
}
fn refusals(text: &str, entities: &[&str]) -> Vec<(String, &'static str, String, String)> {
let ents: Vec<String> = entities.iter().map(|e| e.to_string()).collect();
extract_relations_bound(text, &ents)
.refusals
.into_iter()
.map(|r| (r.rel_type, r.reason, r.left, r.right))
.collect()
}
#[test]
fn the_subject_search_never_walks_back_past_a_boundary() {
let text = "PyPI and latest release both 0.15.6. RE-VERIFIED: 'Sarah works at Google'.";
let ents = extract_heuristic_entities(text);
assert!(
ents.iter().any(|e| e == "Sarah"),
"a quoted name is admitted: {ents:?}"
);
let got = triples(text, &ents.iter().map(String::as_str).collect::<Vec<_>>());
assert_eq!(
got,
vec![("Sarah".into(), "works_at".into(), "Google".into(), 1)],
"the quoted subject binds, PyPI never does"
);
let text = "PyPI (trusted publishing) → swarm ping core+server → core runs CT128 dogfood";
let ents = extract_heuristic_entities(text);
let names: Vec<&str> = ents.iter().map(String::as_str).collect();
assert!(
triples(text, &names).is_empty(),
"no claim, not a wrong one"
);
let r = refusals(text, &names);
assert!(
r.iter().any(|(rel, reason, left, right)| rel == "runs"
&& *reason == REFUSAL_LOWERCASE_SUBJECT
&& left == "core"
&& right == "CT128"),
"the abstention names its reason: {r:?}"
);
}
#[test]
fn prepending_unrelated_text_never_changes_the_triple() {
let base = [
(
"Alice Moreau works at Fennwick Labs.",
&["Alice Moreau", "Fennwick Labs"][..],
("Alice Moreau", "works_at", "Fennwick Labs"),
),
(
"Acme is headquartered in Berlin.",
&["Acme", "Berlin"][..],
("Acme", "headquartered_in", "Berlin"),
),
(
"Pranab lives in Berlin.",
&["Pranab", "Berlin"][..],
("Pranab", "lives_in", "Berlin"),
),
];
let prefixes = [
"",
"PyPI is a package index. ",
"NOTE: ",
"Deploy → verify → ",
"Fennwick Labs; Berlin; Acme. ",
"Ünïcode prélude. ",
];
for (text, ents, want) in base {
for prefix in prefixes {
let full = format!("{prefix}{text}");
let mut all: Vec<&str> = ents.to_vec();
all.push("PyPI");
let got = triples(&full, &all);
assert_eq!(
got,
vec![(want.0.into(), want.1.into(), want.2.into(), 1)],
"prefix {prefix:?} on {text:?}"
);
}
}
}
#[test]
fn a_later_mention_binds_even_when_the_name_appeared_earlier() {
let text = "CT128 is the memory host. Backups live on node4. Tonight CT128 runs 0.19.0.";
let got = triples(text, &["CT128", "node4", "0.19.0"]);
assert_eq!(
got,
vec![("CT128".into(), "runs".into(), "0.19.0".into(), 1)]
);
let ents: Vec<String> = ["CT128", "0.19.0"].iter().map(|s| s.to_string()).collect();
let rels = extract_relations_bound(text, &ents).relations;
assert_eq!(
rels[0].span,
Some((text.find("Tonight CT128").unwrap() + 8, text.len() - 1))
);
}
#[test]
fn segment_boundaries_are_hard() {
let names = ["Sarah", "Google", "PyPI", "CT128"];
assert_eq!(
triples("Sarah joined Google; PyPI runs CT128.", &names),
vec![
("Sarah".into(), "works_at".into(), "Google".into(), 1),
("PyPI".into(), "runs".into(), "CT128".into(), 1)
]
);
assert!(triples("PyPI: runs CT128", &names).is_empty());
assert!(refusals("PyPI: runs CT128", &names)
.iter()
.any(|(_, reason, _, _)| *reason == REFUSAL_NO_SUBJECT));
assert_eq!(
triples("At 12:30 Sarah said \"CT128 runs Google\"", &names),
vec![("CT128".into(), "runs".into(), "Google".into(), 1)]
);
}
#[test]
fn subject_adjacency_admits_wrappers_and_refuses_phrases() {
let names = ["Alice Moreau", "Fennwick Labs", "Acme", "Berlin"];
assert_eq!(
triples(
"Acme, which is headquartered in Berlin, hired Alice Moreau.",
&names
),
vec![("Acme".into(), "headquartered_in".into(), "Berlin".into(), 1)]
);
assert_eq!(
triples("Alice Moreau now works at Fennwick Labs.", &names),
vec![(
"Alice Moreau".into(),
"works_at".into(),
"Fennwick Labs".into(),
1
)]
);
let text = "Alice Moreau, an engineer from Berlin, works at Fennwick Labs.";
assert!(
triples(text, &names).is_empty(),
"an appositive phrase is not adjacency"
);
let r = refusals(text, &names);
assert!(
r.iter().any(|(rel, reason, left, _)| rel == "works_at"
&& *reason == REFUSAL_SUBJECT_NOT_ADJACENT
&& left == ","),
"{r:?}"
);
}
#[test]
fn negation_and_modality_are_read_off_the_original_stream() {
let names = ["Alice", "Acme", "Pranab", "Berlin"];
assert_eq!(
triples("Alice is not the CEO of Acme.", &names),
vec![("Alice".into(), "ceo_of".into(), "Acme".into(), -1)]
);
assert_eq!(
triples("Pranab does not live in Berlin.", &names),
vec![("Pranab".into(), "lives_in".into(), "Berlin".into(), -1)]
);
let ents: Vec<String> = names.iter().map(|s| s.to_string()).collect();
let rels = extract_relations_bound("Alice reportedly works at Acme.", &ents).relations;
assert_eq!(rels.len(), 1);
assert_eq!(rels[0].modality, "reported");
}
#[test]
fn coordination_shares_the_subject_only_through_the_previous_object() {
let names = ["Alice Moreau", "Fennwick Labs", "Berlin", "Bob Lin"];
assert_eq!(
triples(
"Alice Moreau works at Fennwick Labs and lives in Berlin.",
&names
),
vec![
(
"Alice Moreau".into(),
"works_at".into(),
"Fennwick Labs".into(),
1
),
("Alice Moreau".into(), "lives_in".into(), "Berlin".into(), 1)
]
);
let text = "Alice Moreau met Bob Lin and lives in Berlin.";
assert!(
triples(text, &names).is_empty(),
"{:?}",
triples(text, &names)
);
assert!(refusals(text, &names)
.iter()
.any(|(_, reason, left, _)| *reason == REFUSAL_SUBJECT_NOT_ADJACENT && left == "and"));
}
#[test]
fn missing_object_is_a_refusal_not_a_guess() {
let r = refusals(
"Alice Moreau works at the new office downtown.",
&["Alice Moreau"],
);
assert!(
r.iter().any(|(rel, reason, left, right)| rel == "works_at"
&& *reason == REFUSAL_NO_OBJECT
&& left == "Moreau"
&& right == "new"),
"{r:?}"
);
}
#[test]
fn possessive_role_binds_in_reverse_inside_a_segment() {
assert_eq!(
triples(
"Acme's CEO, Alice Chen, spoke first.",
&["Acme", "Alice Chen"]
),
vec![("Alice Chen".into(), "ceo_of".into(), "Acme".into(), 1)]
);
}
#[test]
fn learned_templates_bind_with_the_same_rules() {
let templates = vec![("mentors".to_string(), "mentors".to_string())];
let ents: Vec<String> = ["Carol", "Taylor", "Pat"]
.iter()
.map(|s| s.to_string())
.collect();
let rels = extract_learned_relations(
"Carol mentors Taylor. Pat, a friend of Carol, mentors nobody.",
&ents,
&templates,
);
assert_eq!(rels.len(), 1, "{rels:?}");
assert_eq!(
(rels[0].src.as_str(), rels[0].dst.as_str()),
("Carol", "Taylor")
);
}
}
#[cfg(test)]
mod sentence_opener_tests {
use super::*;
#[test]
fn a_sentence_opener_is_never_welded_onto_the_name_after_it() {
for (text, want, never) in [
(
"Tonight CT128 runs 0.19.0 after the deploy.",
"CT128",
"Tonight CT128",
),
(
"Meanwhile Alice Moreau moved to Munich.",
"Alice Moreau",
"Meanwhile Alice Moreau",
),
(
"Yesterday Fennwick Labs shipped.",
"Fennwick Labs",
"Yesterday Fennwick Labs",
),
(
"Note: CT128 is the host. Later CT128 rebooted.",
"CT128",
"Later CT128",
),
] {
let ents = extract_heuristic_entities(text);
assert!(ents.iter().any(|e| e == want), "{text:?} → {ents:?}");
assert!(
!ents.iter().any(|e| e == never),
"{text:?} welded: {ents:?}"
);
}
}
#[test]
fn a_real_first_name_at_a_sentence_start_stays_whole() {
let ents = extract_heuristic_entities("Alice Moreau works at Fennwick Labs.");
assert!(ents.iter().any(|e| e == "Alice Moreau"), "{ents:?}");
let lookup = |tok: &str| match tok {
"result" => Some(CaseStats {
lower_n: 40,
cap_mid_n: 1,
cap_start_n: 9,
}),
"alice" => Some(CaseStats {
lower_n: 0,
cap_mid_n: 30,
cap_start_n: 12,
}),
_ => None,
};
let ents =
extract_heuristic_entities_with("Result CT128 passed. Alice Moreau agreed.", lookup);
assert!(
ents.iter().any(|e| e == "CT128") && !ents.iter().any(|e| e == "Result CT128"),
"{ents:?}"
);
assert!(ents.iter().any(|e| e == "Alice Moreau"), "{ents:?}");
}
#[test]
fn the_bound_extractor_now_sees_the_later_mention_in_prose() {
let text = "CT128 is the memory host. Tonight CT128 runs 0.19.0 after the deploy.";
let mut ents = extract_heuristic_entities(text);
ents.extend(extract_value_candidates(text));
let rels = extract_relations_bound(text, &ents).relations;
assert!(
rels.iter()
.any(|r| r.src == "CT128" && r.rel_type == "runs" && r.dst == "0.19.0"),
"{rels:?}"
);
}
}