use crate::GraphDb;
use core_storage::fs::Fs;
use core_storage::{GraphError, Result, Value};
use std::collections::BTreeSet;
use unicode_normalization::UnicodeNormalization;
pub const ALIASES_FIELD: &str = "aliases";
pub const ALIAS_KEYS_FIELD: &str = "alias_keys";
pub const SAME_AS_EDGE: &str = "SAME_AS";
pub const SAME_AS_WEIGHT: &str = "weight";
pub const SAME_AS_FLOOR: f64 = 0.6;
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub struct SameAsPair {
pub a: String,
pub b: String,
pub score: f64,
}
pub fn same_as_pairs<F: Fs>(db: &GraphDb<F>, keys: &[String]) -> Vec<SameAsPair> {
let mut best: std::collections::BTreeMap<(String, String), f64> =
std::collections::BTreeMap::new();
for key in keys {
let Ok(edges) = db.node_edges(key) else {
continue;
};
for e in edges.into_iter().filter(|e| e.edge_type == SAME_AS_EDGE) {
let score = match db.get_edge_prop(SAME_AS_EDGE, &e.src_key, &e.dst_key, SAME_AS_WEIGHT)
{
Some(Value::Float(f)) => f,
Some(Value::Int(i)) => i as f64,
_ => 1.0,
};
let pair = if e.src_key <= e.dst_key {
(e.src_key, e.dst_key)
} else {
(e.dst_key, e.src_key)
};
let slot = best.entry(pair).or_insert(score);
if score > *slot {
*slot = score;
}
}
}
best.into_iter()
.map(|((a, b), score)| SameAsPair { a, b, score })
.collect()
}
#[must_use]
pub fn same_as_lost(before: &[SameAsPair], after: &[SameAsPair]) -> Vec<SameAsPair> {
before
.iter()
.filter(|b| !after.iter().any(|p| p.a == b.a && p.b == b.b))
.cloned()
.collect()
}
pub const MAX_ALIASES: usize = 32;
#[must_use]
pub fn canonical(s: &str) -> String {
tokens(s).join(" ")
}
fn tokens(s: &str) -> Vec<String> {
s.nfc()
.collect::<String>()
.to_lowercase()
.nfc()
.collect::<String>()
.split(|c: char| !c.is_alphanumeric())
.filter(|t| !t.is_empty())
.map(str::to_string)
.collect()
}
#[must_use]
pub fn derive_aliases(key: &str, name: Option<&str>) -> Vec<String> {
let mut out: BTreeSet<String> = BTreeSet::new();
let key = key.to_lowercase();
if !key.trim().is_empty() {
out.insert(key);
}
if let Some(name) = name {
let words = tokens(name);
if !words.is_empty() {
out.insert(words.join(" "));
}
if words.len() > 1 {
out.extend(words);
}
}
out.into_iter().collect()
}
#[must_use]
pub fn aliases_value(aliases: &[String]) -> Value {
Value::List(aliases.iter().cloned().map(Value::Str).collect())
}
pub const MAX_ALIAS_KEYS: usize = MAX_ALIASES;
#[must_use]
pub fn declared_alias_keys(caller: &[String]) -> Vec<String> {
let out: BTreeSet<String> = caller
.iter()
.map(|a| a.trim())
.filter(|a| !a.is_empty())
.map(str::to_string)
.collect();
out.into_iter().collect()
}
fn stored_strings(value: Option<&Value>) -> Option<Vec<String>> {
match value {
None => Some(Vec::new()),
Some(Value::Str(s)) => Some(vec![s.clone()]),
Some(Value::List(items)) => items
.iter()
.map(|item| match item {
Value::Str(s) => Some(s.clone()),
_ => None,
})
.collect(),
Some(_) => None,
}
}
fn store_wrote(key: &str, stored: Option<&Value>, held: &[String]) -> bool {
held.iter()
.any(|name| stored == Some(&aliases_value(&derive_aliases(key, Some(name)))))
}
fn foreign_value(key: &str, field: &str) -> GraphError {
GraphError::IngestError {
detail: format!(
"'{key}' already carries an '{field}' property that is not a string or \
a list of strings; clear it with forget {{key: \"{key}\", prop: \
\"{field}\"}} first"
),
}
}
struct IdentityLists {
aliases: Option<Vec<String>>,
alias_keys: Option<Vec<String>>,
}
fn identity_lists<F: Fs>(
db: &GraphDb<F>,
key: &str,
old_name: Option<&str>,
new_name: Option<&str>,
caller: &[String],
) -> Result<IdentityLists> {
let stored_aliases = db.get_prop(key, ALIASES_FIELD);
let stored_keys = db.get_prop(key, ALIAS_KEYS_FIELD);
let held_aliases =
stored_strings(stored_aliases.as_ref()).ok_or_else(|| foreign_value(key, ALIASES_FIELD))?;
let mut declared =
stored_strings(stored_keys.as_ref()).ok_or_else(|| foreign_value(key, ALIAS_KEYS_FIELD))?;
let derived = derive_aliases(key, new_name);
if derived.len() > MAX_ALIASES {
return Err(GraphError::IngestError {
detail: format!(
"'{key}' would carry {} aliases, more than {MAX_ALIASES}: its key, its name \
and each word of that name; give it a shorter name",
derived.len()
),
});
}
let was_derived = derive_aliases(key, old_name);
let moved: Vec<String> = if store_wrote(key, stored_aliases.as_ref(), &held_aliases) {
Vec::new()
} else {
held_aliases
.into_iter()
.filter(|a| !derived.contains(a) && !was_derived.contains(a))
.collect()
};
let moved_count = declared_alias_keys(&moved).len();
declared.extend(moved);
declared.extend(caller.iter().cloned());
let declared = declared_alias_keys(&declared);
if declared.len() > MAX_ALIAS_KEYS {
let carried = if moved_count > 0 {
format!(
" ({moved_count} of them carried over from its '{ALIASES_FIELD}' property; \
forget that to drop them)"
)
} else {
String::new()
};
return Err(GraphError::IngestError {
detail: format!(
"'{key}' would carry {} declared aliases, more than {MAX_ALIAS_KEYS}{carried}; \
forget its '{ALIAS_KEYS_FIELD}' property first, or pass fewer",
declared.len()
),
});
}
let aliases = (stored_aliases.as_ref() != Some(&aliases_value(&derived))).then_some(derived);
let alias_keys = if declared.is_empty() && stored_keys.is_none() {
None
} else {
(stored_keys.as_ref() != Some(&aliases_value(&declared))).then_some(declared)
};
Ok(IdentityLists {
aliases,
alias_keys,
})
}
fn identity_lists_after_write<F: Fs>(
db: &GraphDb<F>,
key: &str,
props: &[(String, Value)],
caller: &[String],
) -> Result<IdentityLists> {
for field in [ALIASES_FIELD, ALIAS_KEYS_FIELD] {
if props.iter().any(|(f, _)| f == field) {
return Err(GraphError::IngestError {
detail: format!(
"'{field}' is maintained by the store; pass aliases as the \
'aliases' argument instead of a property"
),
});
}
}
let old_name = db.get_prop(key, crate::memory_schema::NAME_FIELD);
let new_name = props
.iter()
.find(|(f, _)| f == crate::memory_schema::NAME_FIELD)
.map(|(_, v)| v.clone())
.or_else(|| old_name.clone());
let text = |v: &Option<Value>| match v {
Some(Value::Str(s)) => Some(s.clone()),
_ => None,
};
identity_lists(
db,
key,
text(&old_name).as_deref(),
text(&new_name).as_deref(),
caller,
)
}
pub fn aliases_after_write<F: Fs>(
db: &GraphDb<F>,
key: &str,
props: &[(String, Value)],
caller: &[String],
) -> Result<Option<Vec<String>>> {
Ok(identity_lists_after_write(db, key, props, caller)?.aliases)
}
pub fn alias_keys_after_write<F: Fs>(
db: &GraphDb<F>,
key: &str,
props: &[(String, Value)],
caller: &[String],
) -> Result<Option<Vec<String>>> {
Ok(identity_lists_after_write(db, key, props, caller)?.alias_keys)
}
fn identity_props(lists: IdentityLists) -> Vec<(String, Value)> {
let mut out = Vec::new();
if let Some(list) = lists.aliases {
out.push((ALIASES_FIELD.to_string(), aliases_value(&list)));
}
if let Some(list) = lists.alias_keys {
out.push((ALIAS_KEYS_FIELD.to_string(), aliases_value(&list)));
}
out
}
pub fn identity_props_after_write<F: Fs>(
db: &GraphDb<F>,
key: &str,
props: &[(String, Value)],
caller: &[String],
) -> Result<Vec<(String, Value)>> {
identity_lists_after_write(db, key, props, caller).map(identity_props)
}
pub fn identity_props_after_forgetting_name<F: Fs>(
db: &GraphDb<F>,
key: &str,
) -> Result<Vec<(String, Value)>> {
if db.get_prop(key, ALIASES_FIELD).is_none() {
return Ok(Vec::new());
}
let old_name = match db.get_prop(key, crate::memory_schema::NAME_FIELD) {
Some(Value::Str(s)) => Some(s),
_ => None,
};
identity_lists(db, key, old_name.as_deref(), None, &[]).map(identity_props)
}
fn identity_labels() -> impl Iterator<Item = &'static str> {
use crate::memory_schema::{MEMORY_ENTITY_LABELS, PROVISIONAL_LABEL};
MEMORY_ENTITY_LABELS
.iter()
.copied()
.chain(std::iter::once(PROVISIONAL_LABEL))
}
pub fn entity_node_count<F: Fs>(db: &GraphDb<F>) -> usize {
identity_labels()
.map(|l| db.nodes_with_label(l).len())
.sum()
}
#[derive(Debug, Clone, PartialEq)]
pub struct IdentityBackfill {
pub key: String,
pub props: Vec<(String, Value)>,
}
impl IdentityBackfill {
#[must_use]
pub fn sets(&self, field: &str) -> bool {
self.props.iter().any(|(f, _)| f == field)
}
}
pub fn aliases_to_backfill<F: Fs>(db: &GraphDb<F>) -> Result<Vec<IdentityBackfill>> {
let mut keys: Vec<String> = identity_labels()
.flat_map(|l| db.nodes_with_label(l))
.map(|n| n.key().to_string())
.collect();
keys.sort();
let mut out = Vec::new();
for key in keys {
let props = identity_props_after_write(db, &key, &[], &[])?;
if !props.is_empty() {
out.push(IdentityBackfill { key, props });
}
}
Ok(out)
}
pub fn write_aliases<F: Fs>(db: &mut GraphDb<F>, lists: &[IdentityBackfill]) -> Result<()> {
if lists.is_empty() {
return Ok(());
}
let mut batch = db.batch();
for node in lists {
for (field, value) in &node.props {
batch.set_prop(&node.key, field, value.clone());
}
}
batch.commit().map(|_| ())
}
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub struct IdentityCluster {
pub canonical: String,
pub members: Vec<String>,
pub weakest: f64,
}
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub struct IdentityReport {
pub clusters: Vec<IdentityCluster>,
pub linked: usize,
pub claims: usize,
pub floor: f64,
}
pub fn identity_clusters<F: Fs>(db: &GraphDb<F>, floor: f64) -> IdentityReport {
use std::collections::BTreeMap;
let mut key_of: BTreeMap<u32, String> = BTreeMap::new();
let mut adj: BTreeMap<u32, BTreeMap<u32, f64>> = BTreeMap::new();
for (src, dst, weight) in db.weighted_edges(SAME_AS_EDGE, Some(SAME_AS_WEIGHT)) {
let score = weight.unwrap_or(1.0);
if score < floor || src == dst {
continue;
}
let (Some(s), Some(d)) = (db.dense_id(&src), db.dense_id(&dst)) else {
continue;
};
key_of.insert(s, src);
key_of.insert(d, dst);
for (x, y) in [(s, d), (d, s)] {
let slot = adj.entry(x).or_default().entry(y).or_insert(score);
if score > *slot {
*slot = score;
}
}
}
let claims = adj.values().map(BTreeMap::len).sum::<usize>() / 2;
let mut assigned: BTreeSet<u32> = BTreeSet::new();
let mut clusters: Vec<(u32, Vec<u32>, f64)> = Vec::new();
for (&seed, neighbours) in &adj {
if !assigned.insert(seed) {
continue;
}
let mut members = vec![seed];
let mut weakest = f64::INFINITY;
for &cand in neighbours.keys() {
if assigned.contains(&cand) {
continue;
}
let scores: Option<Vec<f64>> = members
.iter()
.map(|m| adj.get(m).and_then(|n| n.get(&cand)).copied())
.collect();
if let Some(scores) = scores {
weakest = scores.into_iter().fold(weakest, f64::min);
members.push(cand);
}
}
if members.len() > 1 {
assigned.extend(members.iter().copied());
clusters.push((seed, members, weakest));
}
}
clusters.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then(a.0.cmp(&b.0)));
IdentityReport {
clusters: clusters
.into_iter()
.map(|(seed, members, weakest)| IdentityCluster {
canonical: key_of[&seed].clone(),
members: members.iter().map(|m| key_of[m].clone()).collect(),
weakest,
})
.collect(),
linked: adj.len(),
claims,
floor,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canonical_is_idempotent() {
for s in [
"Matthew Sherlin",
"matthew_sherlin",
"JOSÉ-Ñúñez",
"E\u{301}mile Zola",
"x\u{301}y",
"İstanbul",
"Džemal",
"ᾼ\u{301}",
"",
"!!",
"a1 B2",
] {
assert_eq!(canonical(&canonical(s)), canonical(s), "{s:?}");
}
}
#[test]
fn derive_is_deterministic_sorted_and_deduplicated() {
let once = derive_aliases("Sherlin", Some("Matthew SHERLIN"));
assert_eq!(once, ["matthew", "matthew sherlin", "sherlin"]);
assert_eq!(once, derive_aliases("Sherlin", Some("Matthew SHERLIN")));
}
}