diff --git a/core/crates/solstone-core-entity/src/lib.rs b/core/crates/solstone-core-entity/src/lib.rs index 2608a5b74..4b32067b0 100644 --- a/core/crates/solstone-core-entity/src/lib.rs +++ b/core/crates/solstone-core-entity/src/lib.rs @@ -8,6 +8,7 @@ mod ambiguity; mod matcher; mod normalize; +mod resolution; mod slug; mod store; mod trust_lock; @@ -15,6 +16,10 @@ mod trust_lock; pub use ambiguity::ambiguity_id; pub use matcher::{EntityNameCandidate, EntityNameMatch, MatchTier, find_matching_entity}; pub use normalize::normalize_resolution_query; +pub use resolution::{ + EntityResolution, EntityResolutionEntity, EntityResolutionError, EntityResolutionOutcome, + ResolutionCandidate, record_entity_resolution, +}; pub use slug::{MAX_ENTITY_SLUG_LENGTH, entity_slug}; pub use store::{ AmbiguityChoiceEntity, AmbiguityChoiceRequest, AmbiguityObservation, EntityIdentityMap, @@ -40,6 +45,8 @@ pub(crate) use store::{ #[cfg(test)] mod fixture_tests; #[cfg(test)] +mod resolution_tests; +#[cfg(test)] mod store_tests; #[cfg(test)] mod test_support; diff --git a/core/crates/solstone-core-entity/src/normalize.rs b/core/crates/solstone-core-entity/src/normalize.rs index 71f535c1d..4c61ed277 100644 --- a/core/crates/solstone-core-entity/src/normalize.rs +++ b/core/crates/solstone-core-entity/src/normalize.rs @@ -9,10 +9,15 @@ pub fn normalize_resolution_query(query: &str) -> String { let normalized: String = query.nfkc().collect(); let collapsed = collapse_python_whitespace(&normalized); // Python uses full case folding for ambiguity identity; unlike simple lowercase, - // it handles equivalences such as ß → ss. Matcher tiers intentionally use lowercase. + // it handles equivalences such as ß → ss. default_case_fold_str(&collapsed) } +pub(crate) fn matchable_resolution_query(query: &str) -> String { + let normalized: String = query.nfkc().collect(); + collapse_python_whitespace(&normalized) +} + fn collapse_python_whitespace(value: &str) -> String { let mut output = String::with_capacity(value.len()); let mut pending_space = false; @@ -52,7 +57,9 @@ fn is_python_regex_whitespace(ch: char) -> bool { #[cfg(test)] mod tests { - use super::{is_python_regex_whitespace, normalize_resolution_query}; + use super::{ + is_python_regex_whitespace, matchable_resolution_query, normalize_resolution_query, + }; #[test] fn python_regex_whitespace_table_is_exact() { @@ -84,4 +91,9 @@ mod tests { } assert_eq!(normalize_resolution_query("A\u{1C}B"), "a b"); } + + #[test] + fn matchable_resolution_query_normalizes_without_case_folding() { + assert_eq!(matchable_resolution_query(" fi Straße "), "fi Straße"); + } } diff --git a/core/crates/solstone-core-entity/src/resolution.rs b/core/crates/solstone-core-entity/src/resolution.rs new file mode 100644 index 000000000..4bfdecdbb --- /dev/null +++ b/core/crates/solstone-core-entity/src/resolution.rs @@ -0,0 +1,410 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (c) 2026 sol pbc + +//! Resolution boundary that records low-confidence entity-name ambiguities. + +use std::collections::BTreeMap; +use std::error::Error; +use std::fmt; +use std::path::Path; + +use serde_json::{Value, json}; + +use crate::matcher::{ + char_len, first_word_match, prefix_token_match, single_token_first_word_match, token_sort, + token_subset_match, +}; +use crate::normalize::matchable_resolution_query; +use crate::{ + AmbiguityObservation, EntityNameCandidate, EntityStoreError, EntityTrustLockError, + EntityWriteError, MatchTier, find_matching_entity, hold_entity_trust_lock, + load_resolved_ambiguity_choice, normalize_resolution_query, record_ambiguity_observation, +}; + +/// One caller-supplied entity available for resolution. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EntityResolutionEntity { + pub id: Option, + pub name: String, + pub aka: Vec, + pub emails: Vec, + pub blocked: bool, +} + +/// One ranked candidate retained in a low-confidence resolution result. +#[derive(Debug, Clone, PartialEq)] +pub struct ResolutionCandidate { + pub id: String, + pub name: String, + pub tier: MatchTier, + pub score: f64, +} + +impl ResolutionCandidate { + fn to_value(&self) -> Value { + json!({ + "id": self.id, + "name": self.name, + "tier": i64::from(self.tier as u8), + "score": self.score, + }) + } +} + +/// Result category for an entity-resolution attempt. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EntityResolutionOutcome { + Resolved, + Ambiguous, + NoMatch, +} + +/// Result of resolving one query against the caller-supplied entity slice. +#[derive(Debug, Clone, PartialEq)] +pub struct EntityResolution { + pub outcome: EntityResolutionOutcome, + pub entity_index: Option, + pub tier: Option, + pub candidates: Vec, + pub ambiguity_id: Option, +} + +/// Failure while resolving an entity name or recording a low-confidence result. +#[derive(Debug)] +pub enum EntityResolutionError { + TrustLock(EntityTrustLockError), + Read(EntityStoreError), + Write(EntityWriteError), + /// A recorded choice names an ID absent from the entities slice supplied to + /// this call; it does not mean that the entity is absent from the journal. + ResolvedChoiceEntityAbsent { + ambiguity_id: String, + entity_id: String, + }, + ResolvedChoiceEntityBlocked { + ambiguity_id: String, + entity_id: String, + }, +} + +impl fmt::Display for EntityResolutionError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::TrustLock(error) => error.fmt(formatter), + Self::Read(error) => error.fmt(formatter), + Self::Write(error) => error.fmt(formatter), + Self::ResolvedChoiceEntityAbsent { + ambiguity_id, + entity_id, + } => write!( + formatter, + "resolved ambiguity {ambiguity_id} names entity {entity_id:?} absent from this resolution call" + ), + Self::ResolvedChoiceEntityBlocked { + ambiguity_id, + entity_id, + } => write!( + formatter, + "resolved ambiguity {ambiguity_id} names blocked entity {entity_id:?}" + ), + } + } +} + +impl Error for EntityResolutionError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::TrustLock(error) => Some(error), + Self::Read(error) => Some(error), + Self::Write(error) => Some(error), + Self::ResolvedChoiceEntityAbsent { .. } | Self::ResolvedChoiceEntityBlocked { .. } => { + None + } + } + } +} + +impl From for EntityResolutionError { + fn from(error: EntityTrustLockError) -> Self { + Self::TrustLock(error) + } +} + +impl From for EntityResolutionError { + fn from(error: EntityStoreError) -> Self { + Self::Read(error) + } +} + +impl From for EntityResolutionError { + fn from(error: EntityWriteError) -> Self { + Self::Write(error) + } +} + +/// Rank retained candidates by similarity, then raw name and ID. +pub(crate) fn rank_resolution_candidates( + query: &str, + tier: MatchTier, + entities: &[&EntityResolutionEntity], +) -> Vec { + let mut by_id = BTreeMap::new(); + for entity in entities { + let Some(id) = entity.id.as_deref().filter(|id| !id.is_empty()) else { + continue; + }; + if entity.name.is_empty() { + continue; + } + by_id.entry(id).or_insert(*entity); + } + + let mut candidates: Vec<_> = by_id + .into_iter() + .map(|(id, entity)| ResolutionCandidate { + id: id.to_owned(), + name: entity.name.clone(), + tier, + score: candidate_similarity_score(query, entity), + }) + .collect(); + candidates.sort_by(|left, right| { + right + .score + .total_cmp(&left.score) + .then_with(|| left.name.cmp(&right.name)) + .then_with(|| left.id.cmp(&right.id)) + }); + candidates +} + +/// Collect all low-confidence candidates without the matcher's uniqueness guards. +pub(crate) fn collect_low_confidence_candidates( + match_query: &str, + entities: &[EntityResolutionEntity], + fuzzy_threshold: f64, +) -> (Option, Vec) { + if match_query.is_empty() || entities.is_empty() { + return (None, Vec::new()); + } + + let normalized_query = normalize_resolution_query(match_query); + if char_len(match_query) >= 3 { + let first_word_matches: Vec<_> = entities + .iter() + .filter(|entity| first_word_match(&normalized_query, &entity.name)) + .collect(); + if !first_word_matches.is_empty() { + return ( + Some(MatchTier::FirstWord), + rank_resolution_candidates(match_query, MatchTier::FirstWord, &first_word_matches), + ); + } + + let query_first = match_query + .split_whitespace() + .next() + .map(normalize_resolution_query); + if let Some(query_first) = query_first + && query_first != normalized_query + && char_len(&query_first) >= 3 + { + let long_to_short_matches: Vec<_> = entities + .iter() + .filter(|entity| single_token_first_word_match(&query_first, &entity.name)) + .collect(); + if !long_to_short_matches.is_empty() { + return ( + Some(MatchTier::FirstWord), + rank_resolution_candidates( + match_query, + MatchTier::FirstWord, + &long_to_short_matches, + ), + ); + } + } + } + + let subset_matches: Vec<_> = entities + .iter() + .filter(|entity| { + !entity.name.is_empty() + && token_subset_match(&normalized_query, &normalize_resolution_query(&entity.name)) + }) + .collect(); + if !subset_matches.is_empty() { + return ( + Some(MatchTier::TokenSubset), + rank_resolution_candidates(match_query, MatchTier::TokenSubset, &subset_matches), + ); + } + + let prefix_matches: Vec<_> = entities + .iter() + .filter(|entity| { + !entity.name.is_empty() + && prefix_token_match(&normalized_query, &normalize_resolution_query(&entity.name)) + }) + .collect(); + if !prefix_matches.is_empty() { + return ( + Some(MatchTier::Prefix), + rank_resolution_candidates(match_query, MatchTier::Prefix, &prefix_matches), + ); + } + + if char_len(match_query) >= 4 { + let fuzzy_matches: Vec<_> = entities + .iter() + .filter(|entity| candidate_similarity_score(match_query, entity) >= fuzzy_threshold) + .collect(); + if !fuzzy_matches.is_empty() { + return ( + Some(MatchTier::Fuzzy), + rank_resolution_candidates(match_query, MatchTier::Fuzzy, &fuzzy_matches), + ); + } + } + + (None, Vec::new()) +} + +/// Resolve a query, recording a low-confidence ambiguity in mutation mode. +pub fn record_entity_resolution( + journal_root: &Path, + query: &str, + entities: &[EntityResolutionEntity], + scope: Value, + origin: Value, + fuzzy_threshold: f64, + read_only: bool, +) -> Result { + if query.trim().is_empty() { + return Ok(no_match()); + } + + let _trust = (!read_only) + .then(|| hold_entity_trust_lock(journal_root)) + .transpose()?; + let normalized_query = normalize_resolution_query(query); + let match_query = matchable_resolution_query(query); + + if let Some(row) = load_resolved_ambiguity_choice(journal_root, &scope, &normalized_query)? { + let ambiguity_id = row + .get("ambiguity_id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(); + let entity_id = row + .get("resolved_entity_id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(); + let Some(entity_index) = entities + .iter() + .position(|entity| entity.id.as_deref() == Some(entity_id.as_str())) + else { + return Err(EntityResolutionError::ResolvedChoiceEntityAbsent { + ambiguity_id, + entity_id, + }); + }; + if entities[entity_index].blocked { + return Err(EntityResolutionError::ResolvedChoiceEntityBlocked { + ambiguity_id, + entity_id, + }); + } + return Ok(EntityResolution { + outcome: EntityResolutionOutcome::Resolved, + entity_index: Some(entity_index), + tier: None, + candidates: Vec::new(), + ambiguity_id: None, + }); + } + + if entities.is_empty() { + return Ok(no_match()); + } + + let candidates: Vec<_> = entities + .iter() + .map(|entity| EntityNameCandidate { + id: entity.id.clone(), + name: entity.name.clone(), + aka: entity.aka.clone(), + emails: entity.emails.clone(), + }) + .collect(); + if let Some(entity_match) = find_matching_entity(&match_query, &candidates, fuzzy_threshold) + && entity_match.tier.is_high_confidence() + { + return Ok(EntityResolution { + outcome: EntityResolutionOutcome::Resolved, + entity_index: Some(entity_match.candidate_index), + tier: Some(entity_match.tier), + candidates: Vec::new(), + ambiguity_id: None, + }); + } + + let (tier, candidates) = + collect_low_confidence_candidates(&match_query, entities, fuzzy_threshold); + if let Some(tier) = tier + && !candidates.is_empty() + { + let ambiguity_id = if read_only { + String::new() + } else { + let observation = AmbiguityObservation { + scope, + query: query.to_owned(), + normalized_query, + observed_tier: i64::from(tier as u8), + ranked_candidates: candidates + .iter() + .map(ResolutionCandidate::to_value) + .collect(), + origin, + }; + record_ambiguity_observation(journal_root, &observation)? + .get("ambiguity_id") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned() + }; + return Ok(EntityResolution { + outcome: EntityResolutionOutcome::Ambiguous, + entity_index: None, + tier: Some(tier), + candidates, + ambiguity_id: Some(ambiguity_id), + }); + } + + Ok(no_match()) +} + +fn candidate_similarity_score(query: &str, entity: &EntityResolutionEntity) -> f64 { + let sorted_query = token_sort(query); + std::iter::once(entity.name.as_str()) + .chain(entity.aka.iter().map(String::as_str)) + .filter(|choice| !choice.is_empty()) + .map(|choice| { + rapidfuzz::fuzz::ratio(sorted_query.chars(), token_sort(choice).chars()) * 100.0 + }) + .max_by(f64::total_cmp) + .unwrap_or(0.0) +} + +fn no_match() -> EntityResolution { + EntityResolution { + outcome: EntityResolutionOutcome::NoMatch, + entity_index: None, + tier: None, + candidates: Vec::new(), + ambiguity_id: None, + } +} diff --git a/core/crates/solstone-core-entity/src/resolution_tests.rs b/core/crates/solstone-core-entity/src/resolution_tests.rs new file mode 100644 index 000000000..a95be9555 --- /dev/null +++ b/core/crates/solstone-core-entity/src/resolution_tests.rs @@ -0,0 +1,555 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (c) 2026 sol pbc + +#![allow(clippy::disallowed_methods, clippy::disallowed_types)] + +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::mpsc; +use std::thread; +use std::time::Duration; + +use serde_json::{Value, json}; + +use crate::resolution::collect_low_confidence_candidates; +use crate::{ + AmbiguityChoiceEntity, AmbiguityChoiceRequest, EntityNameCandidate, EntityResolutionEntity, + EntityResolutionError, EntityResolutionOutcome, MatchTier, find_matching_entity, + hold_entity_trust_lock, record_ambiguity_choice, record_entity_resolution, +}; + +static NEXT_TEMP_DIR: AtomicU64 = AtomicU64::new(0); + +struct TempDir { + path: PathBuf, +} + +impl TempDir { + fn new() -> Self { + let sequence = NEXT_TEMP_DIR.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "solstone-core-entity-resolution-{}-{sequence}", + std::process::id() + )); + fs::create_dir_all(&path).unwrap(); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +#[test] +fn corrupt_store_rows_fail_before_the_empty_entities_check() { + let temporary = TempDir::new(); + let ambiguities = temporary.path().join("entities/ambiguities.jsonl"); + fs::create_dir_all(ambiguities.parent().unwrap()).unwrap(); + fs::write(&ambiguities, "{not valid json}\n").unwrap(); + + let result = resolve( + temporary.path(), + "Sarah", + &[], + journal_scope(), + origin("first"), + false, + ); + + assert!(matches!(result, Err(EntityResolutionError::Read(_)))); +} + +#[test] +fn resolved_choices_fail_loudly_when_absent_or_blocked() { + let absent_root = TempDir::new(); + let available = vec![entity(Some("sarah_lee"), "Sarah Lee", false)]; + choose_sarah_lee(absent_root.path(), &available); + let absent = resolve( + absent_root.path(), + "Sarah", + &[entity(Some("sarah_connor"), "Sarah Connor", false)], + journal_scope(), + origin("absent"), + false, + ); + assert!(matches!( + absent, + Err(EntityResolutionError::ResolvedChoiceEntityAbsent { entity_id, .. }) + if entity_id == "sarah_lee" + )); + + let blocked_root = TempDir::new(); + choose_sarah_lee(blocked_root.path(), &available); + let blocked = resolve( + blocked_root.path(), + "Sarah", + &[entity(Some("sarah_lee"), "Sarah Lee", true)], + journal_scope(), + origin("blocked"), + false, + ); + assert!(matches!( + blocked, + Err(EntityResolutionError::ResolvedChoiceEntityBlocked { entity_id, .. }) + if entity_id == "sarah_lee" + )); +} + +#[test] +fn high_confidence_tiers_resolve_without_creating_ambiguities() { + let cases = [ + ( + "Sarah Lee", + entity(Some("sarah_lee"), "Sarah Lee", false), + MatchTier::Exact, + ), + ( + "SARAH LEE", + entity(Some("sarah_lee"), "Sarah Lee", false), + MatchTier::CaseInsensitive, + ), + ( + "sarah@example.com", + entity_with_email("sarah_lee", "Sarah Lee", "sarah@example.com"), + MatchTier::Email, + ), + ( + "sarah-lee", + entity(None, "Sarah Lee", false), + MatchTier::Slug, + ), + ]; + + for (index, (query, candidate, tier)) in cases.into_iter().enumerate() { + let temporary = TempDir::new(); + let result = resolve( + temporary.path(), + query, + &[candidate], + journal_scope(), + origin(&format!("high-{index}")), + false, + ) + .unwrap(); + assert_eq!(result.outcome, EntityResolutionOutcome::Resolved); + assert_eq!(result.entity_index, Some(0)); + assert_eq!(result.tier, Some(tier)); + assert!(!ambiguities_path(temporary.path()).exists()); + } +} + +#[test] +fn low_confidence_tiers_are_ambiguous_without_matcher_uniqueness_guards() { + let cases = [ + ( + "Sarah", + vec![ + entity(Some("sarah_connor"), "Sarah Connor", false), + entity(Some("sarah_lee"), "Sarah Lee", false), + ], + MatchTier::FirstWord, + true, + 2, + ), + ( + "Jones Dilworth", + vec![ + entity(Some("josh"), "Josh Jones Dilworth", false), + entity(Some("mary"), "Mary Jones Dilworth", false), + ], + MatchTier::TokenSubset, + true, + 2, + ), + ( + "Jona Dilt", + vec![ + entity(Some("jonathan"), "Jonathan Dilton", false), + entity(Some("jonas"), "Jonas Diltmore", false), + ], + MatchTier::Prefix, + true, + 2, + ), + ( + "Robert Jonson", + vec![entity(Some("robert-johnson"), "Robert Johnson", false)], + MatchTier::Fuzzy, + false, + 1, + ), + ]; + + for (index, (query, entities, tier, matcher_returns_none, expected_candidates)) in + cases.into_iter().enumerate() + { + let temporary = TempDir::new(); + let matcher_candidates = adapt(&entities); + if matcher_returns_none { + assert_eq!(find_matching_entity(query, &matcher_candidates, 90.0), None); + } + + let result = resolve( + temporary.path(), + query, + &entities, + journal_scope(), + origin(&format!("low-{index}")), + false, + ) + .unwrap(); + assert_eq!(result.outcome, EntityResolutionOutcome::Ambiguous); + assert_eq!(result.tier, Some(tier)); + assert_eq!(result.candidates.len(), expected_candidates); + assert!(result.ambiguity_id.is_some_and(|id| !id.is_empty())); + } +} + +#[test] +fn idless_low_confidence_candidates_are_dropped_after_collection() { + let entities = vec![ + entity(None, "Sarah Connor", false), + entity(Some("sarah_lee"), "Sarah Lee", false), + ]; + assert_eq!(find_matching_entity("Sarah", &adapt(&entities), 90.0), None); + let temporary = TempDir::new(); + let result = resolve( + temporary.path(), + "Sarah", + &entities, + journal_scope(), + origin("idless-with-id"), + false, + ) + .unwrap(); + assert_eq!(result.outcome, EntityResolutionOutcome::Ambiguous); + assert_eq!(result.candidates.len(), 1); + assert_eq!(result.candidates[0].id, "sarah_lee"); + + let idless_only = vec![entity(None, "Sarah Connor", false)]; + let no_match = resolve( + temporary.path(), + "Sarah", + &idless_only, + journal_scope(), + origin("idless-only"), + false, + ) + .unwrap(); + assert_eq!(no_match.outcome, EntityResolutionOutcome::NoMatch); +} + +#[test] +fn read_only_ambiguity_does_not_create_locks_or_store_rows() { + let temporary = TempDir::new(); + let result = resolve( + temporary.path(), + "Sarah", + &[ + entity(Some("sarah_connor"), "Sarah Connor", false), + entity(Some("sarah_lee"), "Sarah Lee", false), + ], + journal_scope(), + origin("read-only"), + true, + ) + .unwrap(); + + assert_eq!(result.outcome, EntityResolutionOutcome::Ambiguous); + assert_eq!(result.ambiguity_id.as_deref(), Some("")); + assert!(!ambiguities_path(temporary.path()).exists()); + assert!( + !temporary + .path() + .join("health/locks/entity-trust.lock") + .exists() + ); +} + +#[test] +fn mutation_resolution_waits_for_the_outermost_trust_guard() { + let temporary = TempDir::new(); + let outer = hold_entity_trust_lock(temporary.path()).unwrap(); + let root = temporary.path().to_path_buf(); + let (started_tx, started_rx) = mpsc::channel(); + let (finished_tx, finished_rx) = mpsc::channel(); + + let worker = thread::spawn(move || { + started_tx.send(()).unwrap(); + let result = resolve( + &root, + "Sarah", + &[ + entity(Some("sarah_connor"), "Sarah Connor", false), + entity(Some("sarah_lee"), "Sarah Lee", false), + ], + journal_scope(), + origin("lock-worker"), + false, + ); + finished_tx.send(result).unwrap(); + }); + + started_rx.recv().unwrap(); + assert!( + finished_rx + .recv_timeout(Duration::from_millis(100)) + .is_err(), + "resolution completed before the trust guard dropped" + ); + drop(outer); + let result = finished_rx + .recv_timeout(Duration::from_secs(1)) + .unwrap() + .unwrap(); + assert_eq!(result.outcome, EntityResolutionOutcome::Ambiguous); + worker.join().unwrap(); +} + +#[test] +fn ranking_deduplicates_by_first_id_and_orders_by_score_name_and_id() { + let entities = vec![ + entity(Some("shared"), "Sarah Zed", false), + entity(Some("shared"), "Sarah", false), + entity(Some("amy"), "Sarah Amy", false), + entity(Some("zoe"), "Sarah Zoe", false), + ]; + let (tier, candidates) = collect_low_confidence_candidates("Sarah", &entities, 90.0); + + assert_eq!(tier, Some(MatchTier::FirstWord)); + assert_eq!( + candidates + .iter() + .find(|candidate| candidate.id == "shared") + .map(|candidate| candidate.name.as_str()), + Some("Sarah Zed") + ); + assert_eq!(candidates.len(), 3); + assert!(candidates.windows(2).all(|pair| { + pair[0].score > pair[1].score + || (pair[0].score == pair[1].score + && (&pair[0].name, &pair[0].id) <= (&pair[1].name, &pair[1].id)) + })); +} + +#[test] +fn repeated_observations_delegate_store_updates_and_origin_deduplication() { + let temporary = TempDir::new(); + let first = resolve( + temporary.path(), + "Sarah", + &[ + entity(Some("sarah_connor"), "Sarah Connor", false), + entity(Some("sarah_lee"), "Sarah Lee", false), + ], + journal_scope(), + origin("first"), + false, + ) + .unwrap(); + let first_row = single_ambiguity_row(temporary.path()); + + let second = resolve( + temporary.path(), + " SARAH ", + &[ + entity(Some("sarah_connor"), "Sarah Connor", false), + entity(Some("sarah_brown"), "Sarah Brown", false), + ], + journal_scope(), + origin("second"), + false, + ) + .unwrap(); + let second_row = single_ambiguity_row(temporary.path()); + + assert_eq!(first.ambiguity_id, second.ambiguity_id); + assert_eq!(second_row["first_seen"], first_row["first_seen"]); + assert!(second_row["last_seen"].as_str().unwrap() >= first_row["last_seen"].as_str().unwrap()); + assert_eq!(second_row["latest_query"], " SARAH "); + assert_eq!(second_row["observed_tier"], 5); + assert_eq!(second_row["occurrence_count"], 2); + assert_eq!(second_row["origins"].as_array().map(Vec::len), Some(2)); + let ids: Vec<_> = second_row["ranked_candidates"] + .as_array() + .unwrap() + .iter() + .filter_map(|candidate| candidate["id"].as_str()) + .collect(); + assert!(ids.contains(&"sarah_brown")); + + let third = resolve( + temporary.path(), + "Sarah", + &[ + entity(Some("sarah_connor"), "Sarah Connor", false), + entity(Some("sarah_brown"), "Sarah Brown", false), + ], + journal_scope(), + origin("second"), + false, + ) + .unwrap(); + assert_eq!(third.ambiguity_id, first.ambiguity_id); + let third_row = single_ambiguity_row(temporary.path()); + assert_eq!(third_row["occurrence_count"], 2); + assert_eq!(third_row["origins"].as_array().map(Vec::len), Some(2)); +} + +#[test] +fn true_no_match_does_not_create_an_ambiguity_row() { + let temporary = TempDir::new(); + let result = resolve( + temporary.path(), + "unrelated query", + &[entity(Some("sarah_lee"), "Sarah Lee", false)], + journal_scope(), + origin("none"), + false, + ) + .unwrap(); + + assert_eq!(result.outcome, EntityResolutionOutcome::NoMatch); + assert!(!ambiguities_path(temporary.path()).exists()); +} + +#[test] +fn scope_discriminator_creates_separate_ambiguity_rows() { + let temporary = TempDir::new(); + let entities = [ + entity(Some("sarah_connor"), "Sarah Connor", false), + entity(Some("sarah_lee"), "Sarah Lee", false), + ]; + + let journal = resolve( + temporary.path(), + "Sarah", + &entities, + journal_scope(), + origin("journal"), + false, + ) + .unwrap(); + let facet = resolve( + temporary.path(), + "Sarah", + &entities, + json!({"kind": "facet", "facet": "work"}), + origin("facet"), + false, + ) + .unwrap(); + + assert_ne!(journal.ambiguity_id, facet.ambiguity_id); + assert_eq!(ambiguity_rows(temporary.path()).len(), 2); +} + +fn resolve( + root: &Path, + query: &str, + entities: &[EntityResolutionEntity], + scope: Value, + origin: Value, + read_only: bool, +) -> Result { + record_entity_resolution(root, query, entities, scope, origin, 90.0, read_only) +} + +fn choose_sarah_lee(root: &Path, entities: &[EntityResolutionEntity]) { + let initial = resolve( + root, + "Sarah", + entities, + journal_scope(), + origin("observation"), + false, + ) + .unwrap(); + assert_eq!(initial.outcome, EntityResolutionOutcome::Ambiguous); + let eligible: Vec<_> = entities + .iter() + .filter_map(|entity| { + entity.id.as_ref().map(|id| AmbiguityChoiceEntity { + id: id.clone(), + blocked: entity.blocked, + }) + }) + .collect(); + record_ambiguity_choice( + root, + &AmbiguityChoiceRequest { + scope: journal_scope(), + query: "Sarah".to_owned(), + entity_id: "sarah_lee".to_owned(), + origin: None, + }, + &eligible, + ) + .unwrap(); +} + +fn entity(id: Option<&str>, name: &str, blocked: bool) -> EntityResolutionEntity { + EntityResolutionEntity { + id: id.map(str::to_owned), + name: name.to_owned(), + aka: Vec::new(), + emails: Vec::new(), + blocked, + } +} + +fn entity_with_email(id: &str, name: &str, email: &str) -> EntityResolutionEntity { + EntityResolutionEntity { + id: Some(id.to_owned()), + name: name.to_owned(), + aka: Vec::new(), + emails: vec![email.to_owned()], + blocked: false, + } +} + +fn adapt(entities: &[EntityResolutionEntity]) -> Vec { + entities + .iter() + .map(|entity| EntityNameCandidate { + id: entity.id.clone(), + name: entity.name.clone(), + aka: entity.aka.clone(), + emails: entity.emails.clone(), + }) + .collect() +} + +fn journal_scope() -> Value { + json!({"kind": "journal"}) +} + +fn origin(name: &str) -> Value { + json!({"lane": "resolution-tests", "name": name}) +} + +fn ambiguities_path(root: &Path) -> PathBuf { + root.join("entities/ambiguities.jsonl") +} + +fn ambiguity_rows(root: &Path) -> Vec { + fs::read_to_string(ambiguities_path(root)) + .unwrap() + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str(line).unwrap()) + .collect() +} + +fn single_ambiguity_row(root: &Path) -> Value { + let mut rows = ambiguity_rows(root); + assert_eq!(rows.len(), 1); + rows.pop().unwrap() +}