use super::cooccurrence::base;
use super::entities::EntityCandidate;
use std::collections::BTreeMap;
pub struct RelationCandidate {
pub subject: String,
pub relation: String,
pub object: String,
pub rule: String,
pub span: [usize; 2],
pub base: f32,
pub gap: usize,
}
pub fn extract_relations(
text: &str,
entities: &[EntityCandidate],
ontology: &BTreeMap<String, String>,
) -> Vec<RelationCandidate> {
let mut relations = Vec::new();
for i in 0..entities.len() {
for j in (i + 1)..entities.len() {
let subj = &entities[i];
let obj = &entities[j];
if subj.normalized == obj.normalized {
continue;
}
if let Some(m) = find_relation_pattern(text, subj, obj) {
let (s, o) = if m.swapped { (obj, subj) } else { (subj, obj) };
relations.push(RelationCandidate {
subject: s.normalized.clone(),
relation: normalize_relation(&m.relation, ontology),
object: o.normalized.clone(),
rule: m.rule,
span: [subj.start, m.span_end.unwrap_or(obj.end)],
base: m.base,
gap: m.gap,
});
}
}
}
relations
}
struct PatternMatch {
relation: String,
rule: String,
base: f32,
gap: usize,
swapped: bool,
span_end: Option<usize>,
}
impl PatternMatch {
fn new(relation: &str, rule: &str, base: f32, gap: usize) -> Self {
Self {
relation: relation.to_string(),
rule: rule.to_string(),
base,
gap,
swapped: false,
span_end: None,
}
}
fn swapped(self, swapped: bool) -> Self {
Self { swapped, ..self }
}
fn span_end(self, span_end: usize) -> Self {
Self {
span_end: Some(span_end),
..self
}
}
}
const POSSESSIVE_NOUNS: &[(&str, &str, f32)] = &[
("sister", "sister_of", base::POSSESSIVE_FACTUAL),
("brother", "brother_of", base::POSSESSIVE_FACTUAL),
("mother", "mother_of", base::POSSESSIVE_FACTUAL),
("father", "father_of", base::POSSESSIVE_FACTUAL),
("grandmother", "grandmother_of", base::POSSESSIVE_FACTUAL),
("grandfather", "grandfather_of", base::POSSESSIVE_FACTUAL),
("daughter", "daughter_of", base::POSSESSIVE_FACTUAL),
("son", "son_of", base::POSSESSIVE_FACTUAL),
("wife", "wife_of", base::POSSESSIVE_FACTUAL),
("husband", "husband_of", base::POSSESSIVE_FACTUAL),
("cousin", "cousin_of", base::POSSESSIVE_FACTUAL),
("aunt", "aunt_of", base::POSSESSIVE_FACTUAL),
("uncle", "uncle_of", base::POSSESSIVE_FACTUAL),
("niece", "niece_of", base::POSSESSIVE_FACTUAL),
("nephew", "nephew_of", base::POSSESSIVE_FACTUAL),
("widow", "widow_of", base::POSSESSIVE_FACTUAL),
("guardian", "guardian_of", base::POSSESSIVE_FACTUAL),
("employer", "employer_of", base::POSSESSIVE_FACTUAL),
("servant", "servant_of", base::POSSESSIVE_FACTUAL),
("master", "master_of", base::POSSESSIVE_FACTUAL),
("teacher", "teacher_of", base::POSSESSIVE_FACTUAL),
("student", "student_of", base::POSSESSIVE_FACTUAL),
("pupil", "pupil_of", base::POSSESSIVE_FACTUAL),
("apprentice", "apprentice_of", base::POSSESSIVE_FACTUAL),
("mentor", "mentors", base::POSSESSIVE_FACTUAL),
("friend", "friend_of", base::POSSESSIVE_STANCE),
("enemy", "enemy_of", base::POSSESSIVE_STANCE),
("rival", "rival_of", base::POSSESSIVE_STANCE),
("lover", "lover_of", base::POSSESSIVE_STANCE),
("companion", "companion_of", base::POSSESSIVE_STANCE),
("ally", "ally_of", base::POSSESSIVE_STANCE),
("acquaintance", "acquaintance_of", base::POSSESSIVE_STANCE),
];
const POSSESSIVE_COPULAS: &[&str] = &["is", "was"];
#[rustfmt::skip]
const APPOSITIVE_MODIFIERS: &[&str] = &[
"the", "a", "an", "this", "that",
"his", "her", "their", "its", "my", "our", "your",
"old", "young", "little", "poor", "dear", "late", "good",
];
fn find_ascii_ci_word(haystack: &str, needle: &str) -> Option<(usize, usize)> {
debug_assert!(needle.is_ascii() && !needle.is_empty());
let (hay, needle) = (haystack.as_bytes(), needle.as_bytes());
if hay.len() < needle.len() {
return None;
}
(0..=hay.len() - needle.len())
.find(|&i| {
hay[i..i + needle.len()].eq_ignore_ascii_case(needle)
&& !hay
.get(i.wrapping_sub(1))
.is_some_and(u8::is_ascii_alphanumeric)
&& !hay
.get(i + needle.len())
.is_some_and(u8::is_ascii_alphanumeric)
})
.map(|i| (i, i + needle.len()))
}
fn find_ascii_ci(haystack: &str, needle: &str) -> Option<(usize, usize)> {
debug_assert!(needle.is_ascii() && !needle.is_empty());
let needle = needle.as_bytes();
haystack
.as_bytes()
.windows(needle.len())
.position(|w| w.eq_ignore_ascii_case(needle))
.map(|start| (start, start + needle.len()))
}
fn possessed_noun_phrase_len(rest: &str) -> usize {
let punct = rest
.find([',', '.', ';', ':', '!', '?'])
.unwrap_or(rest.len());
let conjunction = find_ascii_ci(rest, " and ")
.map(|(start, _)| start)
.unwrap_or(rest.len());
punct.min(conjunction)
}
const SUSPENDING_WORDS: &[&str] = &[
"not", "never", "no", "nor", "neither", "if", "unless", "whether", "could", "would", "might",
"may", "should",
];
fn suspends_assertion(between: &str) -> bool {
between.split(|c: char| !c.is_ascii_alphanumeric() && c != '\'').any(|word| {
word.ends_with("n't") || SUSPENDING_WORDS.contains(&word)
})
|| between.split_whitespace().any(|word| word == "to")
}
fn ends_in_question(text: &str, from: usize) -> bool {
let tail = &text[from..];
matches!(tail.find(['.', '!', '?']), Some(i) if tail[i..].starts_with('?'))
}
fn heads_possessed_phrase(phrase: &str, start: usize, end: usize) -> bool {
phrase[end..].trim().is_empty() && find_ascii_ci(&phrase[..start], "'s").is_none()
}
fn reversed_possessive_phrase(between: &str) -> Option<&str> {
let rest = between.strip_prefix("'s")?;
if !rest.starts_with(' ') {
return None;
}
let (phrase, copula) = rest.trim().rsplit_once(' ')?;
POSSESSIVE_COPULAS
.contains(&copula)
.then(|| phrase.trim_end())
}
fn is_appositive_link(between: &str) -> bool {
let Some(rest) = between.trim_start().strip_prefix(',') else {
return false;
};
rest.split_whitespace()
.all(|word| APPOSITIVE_MODIFIERS.contains(&word))
}
fn of_genitive_noun(between: &str) -> Option<&str> {
let trimmed = between.trim();
let rest = match trimmed.strip_prefix(',') {
Some(rest) => rest,
None => {
let (link, rest) = trimmed.split_once(' ')?;
POSSESSIVE_COPULAS.contains(&link).then_some(rest)?
}
};
let mut words: Vec<&str> = rest.split_whitespace().collect();
if words.pop()? != "of" {
return None;
}
let noun = words.pop()?;
words
.iter()
.all(|word| APPOSITIVE_MODIFIERS.contains(word))
.then_some(noun)
}
fn opens_possessive(text: &str, from: usize) -> bool {
let bytes = &text.as_bytes()[from..];
bytes.len() >= 2 && bytes[0] == b'\'' && bytes[1].eq_ignore_ascii_case(&b's')
}
fn find_relation_pattern(
text: &str,
subj: &EntityCandidate,
obj: &EntityCandidate,
) -> Option<PatternMatch> {
if subj.end > obj.start {
return None;
}
let (start, end) = (subj.end, obj.start);
let gap = end - start;
if gap > 30 {
return None;
}
let between = &text[start..end].to_lowercase();
if suspends_assertion(between) || ends_in_question(text, obj.end) {
return None;
}
let after_obj = &text[obj.end..];
let bytes = after_obj.as_bytes();
let possessive = bytes.len() >= 3
&& bytes[0] == b'\''
&& bytes[1].eq_ignore_ascii_case(&b's')
&& matches!(bytes[2], b' ' | b'.');
let asserts_possessive =
POSSESSIVE_COPULAS.contains(&between.trim()) || is_appositive_link(between);
if possessive && asserts_possessive {
let rest = &after_obj["'s".len()..];
let phrase = &rest[..possessed_noun_phrase_len(rest)];
let phrase_start = obj.end + "'s".len();
for (noun, relation, base) in POSSESSIVE_NOUNS {
let Some((noun_start, noun_end)) = find_ascii_ci_word(phrase, noun) else {
continue;
};
if !heads_possessed_phrase(phrase, noun_start, noun_end) {
continue;
}
return Some(
PatternMatch::new(relation, &format!("possessive-{noun}-pattern"), *base, gap)
.span_end(phrase_start + noun_end),
);
}
}
if let Some(noun) = of_genitive_noun(between) {
if !opens_possessive(text, obj.end) {
if let Some((_, relation, base)) = POSSESSIVE_NOUNS.iter().find(|(n, _, _)| *n == noun)
{
return Some(PatternMatch::new(
relation,
&format!("of-genitive-{noun}-pattern"),
*base,
gap,
));
}
}
}
if let Some(phrase) = reversed_possessive_phrase(between) {
if !opens_possessive(text, obj.end) {
for (noun, relation, base) in POSSESSIVE_NOUNS {
let Some((noun_start, noun_end)) = find_ascii_ci_word(phrase, noun) else {
continue;
};
if !heads_possessed_phrase(phrase, noun_start, noun_end) {
continue;
}
return Some(
PatternMatch::new(relation, &format!("possessive-{noun}-pattern"), *base, gap)
.swapped(true),
);
}
}
}
if between.contains(" mentor") {
let passive = between.contains(" by");
return Some(
PatternMatch::new("mentors", "verb-mentor-pattern", base::VERB, gap).swapped(passive),
);
}
if between.contains(" work") && between.contains(" at") {
return Some(PatternMatch::new(
"works_at",
"verb-works-at-pattern",
base::VERB,
gap,
));
}
if between.contains(", who ") && (between.contains("work") || between.contains("mentor")) {
if between.contains("work") && between.contains("at") {
return Some(PatternMatch::new(
"works_at",
"relative-works-at-pattern",
base::RELATIVE_CLAUSE,
gap,
));
}
if between.contains("mentor") {
return Some(PatternMatch::new(
"mentors",
"relative-mentor-pattern",
base::RELATIVE_CLAUSE,
gap,
));
}
}
None
}
pub fn normalize_relation(rel: &str, ontology: &BTreeMap<String, String>) -> String {
let rel_lower = rel.to_lowercase();
if let Some(canonical) = ontology.get(&rel_lower) {
canonical.clone()
} else {
rel.to_string()
}
}