use std::collections::HashSet;
use anyhow::Result;
use rusqlite::Connection;
#[cfg(test)]
use crate::memory::Memory;
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub(super) enum RelationKind {
Affect,
Block,
Delete,
Fix,
Maintain,
Own,
Supersede,
Use,
Verify,
}
pub(super) fn entity_scope_candidates(query_text: &str, project: Option<&str>) -> Vec<String> {
let project_terms = project_entity_terms(project);
crate::retrieval::entity::extract_entities("", query_text)
.into_iter()
.filter(|term| {
normalize_claim_token(term).is_some_and(|normalized| {
!normalized
.chars()
.all(|character| character.is_ascii_digit())
&& !project_terms.contains(&normalized)
&& text_contains_exact_token(query_text, term)
})
})
.collect()
}
pub(super) fn text_contains_exact_token(text: &str, term: &str) -> bool {
if term.chars().any(is_cjk) {
return text_contains_cjk_term(text, term);
}
text_contains_phrase_boundary(text, term)
}
pub(super) fn claim_terms(
core_terms: &[String],
project: Option<&str>,
explicit_entity_terms: &[String],
) -> Vec<String> {
let entity_terms: HashSet<String> = explicit_entity_terms
.iter()
.filter_map(|term| normalize_claim_token(term))
.collect();
let project_terms = project_entity_terms(project);
core_terms
.iter()
.filter_map(|term| normalize_claim_token(term))
.filter(|term| !entity_terms.contains(term) && !project_terms.contains(term))
.collect()
}
#[cfg(test)]
pub(super) fn claim_term_coverage(memory: &Memory, claim_terms: &[String]) -> f64 {
claim_text_coverage(&format!("{} {}", memory.title, memory.text), claim_terms)
}
pub(super) fn claim_text_coverage(text: &str, claim_terms: &[String]) -> f64 {
if claim_terms.is_empty() {
return 1.0;
}
claim_text_match_count(text, claim_terms) as f64 / claim_terms.len() as f64
}
pub(super) fn claim_text_match_count(text: &str, claim_terms: &[String]) -> usize {
let haystack = text.to_lowercase();
claim_terms
.iter()
.filter(|term| claim_term_matches(&haystack, term))
.count()
}
fn claim_term_matches(haystack: &str, term: &str) -> bool {
if term.chars().any(is_cjk) {
return text_contains_cjk_term(haystack, term);
}
if text_contains_phrase_boundary(haystack, term) {
return true;
}
let aliases = claim_term_aliases(term);
if aliases
.iter()
.any(|alias| text_contains_phrase_boundary(haystack, alias))
{
return true;
}
std::iter::once(term)
.chain(aliases.iter().copied())
.filter(|candidate| {
candidate
.chars()
.all(|character| character.is_alphanumeric())
})
.any(|candidate| {
haystack
.split(|character: char| !character.is_alphanumeric() && !is_cjk(character))
.filter(|word| !word.is_empty())
.any(|word| claim_words_share_form(candidate, word))
})
}
fn text_contains_cjk_term(text: &str, term: &str) -> bool {
let needle = term.trim().to_lowercase();
needle.chars().count() >= 2 && text.to_lowercase().contains(&needle)
}
fn claim_term_aliases(term: &str) -> &'static [&'static str] {
match term {
"child" | "children" | "kid" | "kids" => &[
"child",
"children",
"kid",
"kids",
"son",
"daughter",
"sons",
"daughters",
],
"host" | "hosted" | "hosting" => &[
"host",
"hosts",
"hosted",
"hosting",
"runs on",
"deployed on",
],
"handle" | "handled" | "handler" | "handlers" | "handles" | "handling" | "responsible"
| "responsibility" => &[
"handle",
"handled",
"handler",
"handlers",
"handles",
"handling",
"responsible",
"responsibility",
"owner",
"owners",
"owned",
"owning",
"owns",
"pager",
"pagers",
],
"own" | "owned" | "owner" | "owning" | "owns" => {
&["own", "owned", "owner", "owners", "owning", "owns"]
}
"migrate" | "migrated" | "migration" => {
&["migrate", "migrated", "migrates", "migrating", "migration"]
}
_ => &[],
}
}
pub(super) fn is_relation_only_claim_term(term: &str) -> bool {
let normalized = term
.trim()
.trim_matches(|character: char| !character.is_alphanumeric())
.to_lowercase();
if normalized.is_empty() || !normalized.chars().all(char::is_alphanumeric) {
return false;
}
if normalized.chars().any(is_cjk) {
return matches!(
normalized.as_str(),
"修复"
| "删除"
| "拥有"
| "替代"
| "使用"
| "维护"
| "影响"
| "验证"
| "负责"
| "阻塞"
);
}
expressed_relation_kinds(&normalized).len() == 1
}
pub(super) fn expressed_relation_kinds(text: &str) -> HashSet<RelationKind> {
let mut relations = HashSet::new();
for (relation, english_terms, cjk_terms) in [
(RelationKind::Fix, &["fix", "repair"][..], &["修复"][..]),
(
RelationKind::Verify,
&[
"approve",
"okayed",
"sign",
"signer",
"verification",
"verifier",
"verify",
][..],
&["验证"][..],
),
(
RelationKind::Supersede,
&["replace", "supersede"][..],
&["替代"][..],
),
(RelationKind::Block, &["block"][..], &["阻塞"][..]),
(RelationKind::Use, &["use"][..], &["使用"][..]),
(RelationKind::Affect, &["affect"][..], &["影响"][..]),
(
RelationKind::Delete,
&["delete", "remove"][..],
&["删除"][..],
),
(RelationKind::Maintain, &["maintain"][..], &["维护"][..]),
(
RelationKind::Own,
&["own", "responsibility", "responsible"][..],
&["负责", "拥有"][..],
),
] {
let english_match = english_terms
.iter()
.any(|term| claim_text_match_count(text, &[(*term).to_string()]) > 0);
if english_match || cjk_terms.iter().any(|term| text.contains(term)) {
relations.insert(relation);
}
}
relations
}
fn text_contains_phrase_boundary(text: &str, phrase: &str) -> bool {
let haystack = text.to_lowercase();
let needle = phrase.trim().to_lowercase();
if needle.is_empty() {
return false;
}
haystack.match_indices(&needle).any(|(start, _)| {
let end = start + needle.len();
let before = haystack[..start].chars().next_back();
let after = haystack[end..].chars().next();
before.is_none_or(|character| !is_claim_word_character(character))
&& after.is_none_or(|character| !is_claim_word_character(character))
})
}
fn is_claim_word_character(character: char) -> bool {
character.is_alphanumeric() || character == '-' || character == '_'
}
fn claim_words_share_form(left: &str, right: &str) -> bool {
let left_forms = claim_word_forms(left);
claim_word_forms(right)
.iter()
.any(|form| left_forms.contains(form))
}
fn claim_word_forms(word: &str) -> HashSet<String> {
let word = word.to_lowercase();
let mut forms = HashSet::from([word.clone()]);
if word == "built" {
insert_claim_form(&mut forms, "build");
}
for suffix in ["ment", "ence", "ance", "ing"] {
if let Some(stem) = word.strip_suffix(suffix) {
insert_claim_form(&mut forms, stem);
}
}
if let Some(stem) = word.strip_suffix("ied") {
insert_claim_form(&mut forms, &format!("{stem}y"));
}
if let Some(stem) = word.strip_suffix("ed") {
insert_claim_form(&mut forms, stem);
insert_claim_form(&mut forms, &format!("{stem}e"));
}
if let Some(stem) = word.strip_suffix("ies") {
insert_claim_form(&mut forms, &format!("{stem}y"));
}
if let Some(stem) = word.strip_suffix('s') {
insert_claim_form(&mut forms, stem);
}
forms
}
fn insert_claim_form(forms: &mut HashSet<String>, form: &str) {
if form.chars().count() >= 3 {
forms.insert(form.to_string());
}
}
fn normalize_claim_token(term: &str) -> Option<String> {
let clean = term.trim_matches(|c: char| !c.is_alphanumeric() && !is_cjk(c));
let short_ascii_identifier = {
let length = clean.chars().count();
length == 2
&& clean
.chars()
.all(|character| character.is_ascii_alphanumeric())
&& clean
.chars()
.any(|character| character.is_ascii_uppercase() || character.is_ascii_digit())
};
let normalized = clean.to_lowercase();
let min_len = if normalized.chars().any(is_cjk) || short_ascii_identifier {
2
} else {
3
};
if normalized.chars().count() < min_len
|| is_nonsemantic_claim_modifier(&normalized)
|| is_generic_query_term(&normalized)
{
None
} else {
Some(normalized)
}
}
pub(super) fn is_nonsemantic_claim_modifier(term: &str) -> bool {
matches!(
term.trim().to_lowercase().as_str(),
"current" | "currently" | "recent" | "recently" | "当前" | "目前" | "最近"
)
}
pub(super) fn project_entity_terms(project: Option<&str>) -> HashSet<String> {
project
.into_iter()
.flat_map(|project| project.split(|c: char| !c.is_alphanumeric() && !is_cjk(c)))
.filter_map(normalize_claim_token)
.collect()
}
pub(super) fn has_distinctive_entity_shape(term: &str) -> bool {
let clean = term.trim_matches(|character: char| {
!character.is_alphanumeric() && character != '-' && character != '_'
});
let mut characters = clean.chars();
let Some(first) = characters.next() else {
return false;
};
let remaining = characters.collect::<Vec<_>>();
let internal_uppercase = remaining.iter().any(|character| character.is_uppercase());
let has_lowercase = clean.chars().any(|character| character.is_lowercase());
let structural_marker = clean
.chars()
.any(|character| character.is_ascii_digit() || matches!(character, '-' | '_'));
structural_marker || (first.is_uppercase() && internal_uppercase && has_lowercase)
}
#[allow(clippy::too_many_arguments)]
pub(super) fn select_entity_anchors(
conn: &Connection,
candidates: &[String],
project: Option<&str>,
memory_type: Option<&str>,
branch: Option<&str>,
limit: i64,
include_inactive: bool,
include_suppressed: bool,
) -> Result<(Vec<String>, Vec<i64>)> {
let mut lookups = Vec::with_capacity(candidates.len());
for (index, term) in candidates.iter().enumerate() {
let ids = crate::retrieval::entity::search_exact_entity_names_filtered(
conn,
std::slice::from_ref(term),
project,
memory_type,
branch,
limit,
include_inactive,
)?;
let ids = super::suppression_filter::ids(conn, ids, include_suppressed)?;
lookups.push((index, term, ids));
}
let distinctive = lookups
.iter()
.filter(|(_, term, _)| has_distinctive_entity_shape(term))
.collect::<Vec<_>>();
let selected = if distinctive.is_empty() {
lookups
.iter()
.filter(|(_, term, ids)| !ids.is_empty() && !is_relation_only_claim_term(term))
.min_by(|left, right| {
left.2
.len()
.cmp(&right.2.len())
.then_with(|| right.0.cmp(&left.0))
})
.into_iter()
.collect::<Vec<_>>()
} else {
distinctive
};
let terms = selected
.iter()
.map(|(_, term, _)| (*term).clone())
.collect::<Vec<_>>();
let mut matching_ids = HashSet::new();
if selected.iter().all(|(_, _, ids)| !ids.is_empty()) {
matching_ids.extend(selected.iter().flat_map(|(_, _, ids)| ids.iter().copied()));
}
let mut matching_ids = matching_ids.into_iter().collect::<Vec<_>>();
matching_ids.sort_unstable();
Ok((terms, matching_ids))
}
fn is_generic_query_term(term: &str) -> bool {
matches!(
term,
"all"
| "and"
| "are"
| "did"
| "does"
| "for"
| "from"
| "current"
| "had"
| "has"
| "have"
| "how"
| "into"
| "is"
| "its"
| "latest"
| "onto"
| "project"
| "show"
| "that"
| "the"
| "this"
| "through"
| "today"
| "tomorrow"
| "yesterday"
| "before"
| "after"
| "during"
| "only"
| "production"
| "was"
| "were"
| "what"
| "when"
| "where"
| "which"
| "who"
| "why"
| "with"
| "何时"
| "什么"
| "什么时候"
| "为何"
| "为什么"
| "哪里"
| "哪些"
| "哪个"
| "如何"
| "怎么"
| "谁"
)
}
pub(super) fn is_cjk(c: char) -> bool {
matches!(
c,
'\u{4E00}'..='\u{9FFF}' | '\u{3400}'..='\u{4DBF}' | '\u{F900}'..='\u{FAFF}'
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn entity_scope_candidates_exclude_project_and_generic_query_words() {
let terms = entity_scope_candidates(
"Has Project KestrelNook migrated NebulaLatch to Oracle Cloud?",
Some("synthetic/kestrelnook"),
);
assert!(terms.iter().any(|term| term == "NebulaLatch"));
assert!(!terms.iter().any(|term| term == "KestrelNook"));
assert!(!terms.iter().any(|term| term == "Project"));
assert!(!terms.iter().any(|term| term == "Has"));
}
#[test]
fn entity_scope_candidates_reject_technical_substring_matches() {
let terms =
entity_scope_candidates("How can I remember capitalization rules?", Some("/repo"));
assert!(!terms.iter().any(|term| term.eq_ignore_ascii_case("remem")));
assert!(!terms.iter().any(|term| term.eq_ignore_ascii_case("api")));
}
#[test]
fn parsed_temporal_number_is_not_an_entity_scope_candidate() -> Result<()> {
let conn = Connection::open_in_memory()?;
crate::memory::tests_helper::setup_memory_schema(&conn);
let candidates =
entity_scope_candidates("What changed in the last 30 days?", Some("/repo"));
let (terms, ids) = select_entity_anchors(
&conn,
&candidates,
Some("/repo"),
None,
None,
5,
false,
false,
)?;
assert!(
!candidates.iter().any(|term| term == "30"),
"{candidates:?}"
);
assert!(terms.is_empty(), "{terms:?}");
assert!(ids.is_empty(), "{ids:?}");
Ok(())
}
#[test]
fn known_entity_hosting_paraphrase_has_claim_support() {
let memory = Memory {
id: 1,
session_id: None,
project: "/repo".to_string(),
topic_key: None,
title: "NebulaLatch deployment".to_string(),
text: "NebulaLatch runs on Oracle Cloud.".to_string(),
memory_type: "decision".to_string(),
files: None,
created_at_epoch: 1,
updated_at_epoch: 1,
status: "active".to_string(),
branch: None,
scope: "project".to_string(),
};
let core_terms =
crate::retrieval::query_expand::core_tokens("Where is NebulaLatch hosted?");
let terms = claim_terms(&core_terms, Some("/repo"), &["NebulaLatch".to_string()]);
assert_eq!(terms, vec!["hosted"]);
assert_eq!(claim_term_coverage(&memory, &terms), 1.0);
}
#[test]
fn unselected_title_case_candidates_remain_claim_terms() {
let query = "Which Pager Handles NebulaLatch Through Its Owning Team?";
let candidates = entity_scope_candidates(query, Some("/repo"));
let core_terms = crate::retrieval::query_expand::core_tokens(query);
let claims = claim_terms(&core_terms, Some("/repo"), &["NebulaLatch".to_string()]);
assert!(candidates.iter().any(|term| term == "NebulaLatch"));
for predicate in ["pager", "handles", "owning", "team"] {
assert!(claims.iter().any(|term| term == predicate), "{claims:?}");
}
}
#[test]
fn arbitrary_title_case_predicate_is_not_removed_by_a_static_list() {
let query = "Who Maintains NebulaLatch?";
let core_terms = crate::retrieval::query_expand::core_tokens(query);
let claims = claim_terms(&core_terms, Some("/repo"), &["NebulaLatch".to_string()]);
assert_eq!(claims, vec!["maintains"]);
}
#[test]
fn short_uppercase_qualifiers_remain_claim_terms() {
let query = "Who verified HarborMint in EU with R2?";
let core_terms = crate::retrieval::query_expand::core_tokens(query);
let claims = claim_terms(&core_terms, Some("/repo"), &["HarborMint".to_string()]);
assert!(claims.contains(&"eu".to_string()), "{claims:?}");
assert!(claims.contains(&"r2".to_string()), "{claims:?}");
}
#[test]
fn lowercase_short_query_words_are_not_claim_terms() {
let core_terms =
crate::retrieval::query_expand::core_tokens("Who is HarborMint assigned to?");
let claims = claim_terms(&core_terms, Some("/repo"), &["HarborMint".to_string()]);
assert!(!claims.contains(&"is".to_string()), "{claims:?}");
assert!(!claims.contains(&"to".to_string()), "{claims:?}");
}
#[test]
fn distinctive_scope_shape_rejects_plain_title_case_words() {
assert!(has_distinctive_entity_shape("NebulaLatch"));
assert!(has_distinctive_entity_shape("incident-17"));
assert!(!has_distinctive_entity_shape("Maintains"));
assert!(!has_distinctive_entity_shape("Current"));
}
#[test]
fn fallback_anchor_excludes_indexed_relation_candidate() -> Result<()> {
let conn = Connection::open_in_memory()?;
crate::memory::tests_helper::setup_memory_schema(&conn);
let relation_memory = crate::memory::insert_memory_full(
&conn,
None,
"/repo",
None,
"Handles escalation",
"Handles release escalation for another service.",
"decision",
None,
None,
"project",
None,
)?;
let subject_memory = crate::memory::insert_memory_full(
&conn,
None,
"/repo",
None,
"Rust ownership",
"Rust is handled by Team Ferris.",
"decision",
None,
None,
"project",
None,
)?;
let other_subject_memory = crate::memory::insert_memory_full(
&conn,
None,
"/repo",
None,
"Rust tooling",
"Rust uses Cargo for builds.",
"decision",
None,
None,
"project",
None,
)?;
crate::retrieval::entity::link_entities(&conn, relation_memory, &["Handles".to_string()])?;
crate::retrieval::entity::link_entities(&conn, subject_memory, &["Rust".to_string()])?;
crate::retrieval::entity::link_entities(
&conn,
other_subject_memory,
&["Rust".to_string()],
)?;
let candidates = entity_scope_candidates("Who Handles Rust?", Some("/repo"));
let (terms, ids) = select_entity_anchors(
&conn,
&candidates,
Some("/repo"),
None,
None,
5,
false,
false,
)?;
assert_eq!(terms, vec!["Rust"]);
assert_eq!(ids, vec![subject_memory, other_subject_memory]);
Ok(())
}
#[test]
fn claim_matching_uses_token_and_phrase_boundaries() {
assert!(!claim_term_matches("capitalization", "api"));
assert!(!claim_term_matches("ghosted deployment", "host"));
assert!(claim_term_matches(
"NebulaLatch runs on Oracle Cloud",
"hosted"
));
assert!(text_contains_exact_token(
"Team Mica owns NebulaLatch",
"Team Mica"
));
}
#[test]
fn claim_matching_allows_exact_cjk_substrings_without_weakening_latin_boundaries() {
assert_eq!(
claim_text_coverage("使用SQLCipher实现数据库加密", &["数据库加密".to_string()]),
1.0
);
assert!(!claim_term_matches("capitalization", "api"));
assert!(!claim_term_matches("remember", "remem"));
}
#[test]
fn claim_matching_handles_inflection_and_hyphenated_compounds() {
let text = "Vite deployment supersedes webpack. The queue-runner capture-ledger is active.";
for term in [
"deploy",
"superseded",
"queue",
"runner",
"capture",
"ledger",
] {
assert!(claim_term_matches(text, term), "missing {term}");
}
let preference = "The user prefers concise evidence-backed PR handoffs.";
for term in ["preference", "evidence", "handoff"] {
assert!(
claim_term_matches(preference, term),
"missing {term} in preference text"
);
}
}
#[test]
fn exact_entity_claim_support_uses_irregular_build_lemma() {
let core_terms = crate::retrieval::query_expand::core_tokens("Who built NebulaLatch?");
let claims = claim_terms(&core_terms, Some("/repo"), &["NebulaLatch".to_string()]);
assert_eq!(claims, vec!["built"]);
let cases = [
("Team Mica builds NebulaLatch.", "built", true),
("Team Mica built NebulaLatch.", "build", true),
("Team Mica built NebulaLatch.", "builds", true),
("Team Mica is building NebulaLatch.", "built", true),
("A prebuilt NebulaLatch bundle ships.", "built", false),
("The NebulaLatch builder is Team Mica.", "built", false),
("Team Mica audited NebulaLatch.", "built", false),
];
assert_eq!(claim_term_coverage(&test_memory(cases[0].0), &claims), 1.0);
for (text, term, expected) in cases {
assert_eq!(claim_term_matches(text, term), expected);
}
}
#[test]
fn ownership_alias_preserves_multi_hop_owner_evidence() {
assert!(claim_term_matches(
"NebulaLatch is owned by Team Mica",
"owning"
));
}
#[test]
fn handler_aliases_cover_roles_but_not_unrelated_entity_text() {
let claims = vec!["handles".to_string()];
assert_eq!(
claim_term_coverage(&test_memory("NebulaLatch is owned by Team Mica."), &claims),
1.0
);
assert_eq!(
claim_term_coverage(&test_memory("Team Mica uses pager mica-17."), &claims),
1.0
);
assert_eq!(
claim_term_coverage(&test_memory("NebulaLatch uses SQLite WAL mode."), &claims),
0.0
);
}
#[test]
fn multi_hop_owner_and_pager_both_cover_title_case_claims() {
let claims = vec![
"pager".to_string(),
"owning".to_string(),
"team".to_string(),
];
let mut owner = test_memory("NebulaLatch is owned by Team Mica.");
assert!(claim_term_coverage(&owner, &claims) >= 0.5);
owner.text = "Team Mica uses pager mica-17.".to_string();
assert!(claim_term_coverage(&owner, &claims) >= 0.5);
}
fn test_memory(text: &str) -> Memory {
Memory {
id: 1,
session_id: None,
project: "/repo".to_string(),
topic_key: None,
title: String::new(),
text: text.to_string(),
memory_type: "decision".to_string(),
files: None,
created_at_epoch: 1,
updated_at_epoch: 1,
status: "active".to_string(),
branch: None,
scope: "project".to_string(),
}
}
}