core_api/memory/identity.rs
1//! Identity: the alias set `SAME_AS` compares.
2//!
3//! `SAME_AS` is a rule, and a rule can only compare what a node holds. The
4//! engine's `Overlap` predicate is an exact Jaccard over list tokens — no case
5//! folding, no tokenising — so `"Matt"` and `"matt"` never meet unless both
6//! sides were written the same way. This module is the one place that writes
7//! them the same way: every entity `remember` or `upsert_entity` touches gets an
8//! `aliases` list built here, from its key and its current `name` and nothing
9//! else, normalised identically on every node.
10//!
11//! The list is recomputed on each describing write, not accumulated: it is a
12//! function of the key and the name as they stand, sorted and deduplicated, so
13//! a node rewritten with the same inputs is not rewritten at all, and a
14//! renamed node stops matching its old name.
15//!
16//! A second list, `alias_keys`, holds what a caller declared, as declared, and
17//! only there. Declared aliases are kept out of `aliases` for two reasons.
18//! Jaccard punishes unequal sets: every full-name link sits exactly on the
19//! floor, 3/5, and one alias declared on one side would make it 3/6 and
20//! retract it. And `aliases` holds derived name words, which must not act as
21//! claims: an entity merely *named* "Alex" would otherwise claim every stub
22//! keyed `alex`. The declared list is what a `KeyMatch` rule reads instead —
23//! an entity that names a stub's key links it exactly. So two entities that
24//! declare the same alias gain no overlap from it; a declared alias links
25//! only through a claim on a stub's key.
26
27use crate::GraphDb;
28use core_storage::fs::Fs;
29use core_storage::{GraphError, Result, Value};
30use std::collections::BTreeSet;
31use unicode_normalization::UnicodeNormalization;
32
33/// The property every entity's alias list lives in.
34pub const ALIASES_FIELD: &str = "aliases";
35
36/// The property an entity's declared aliases live in, as the caller wrote
37/// them: the list the identity preset's `KeyMatch` rules read.
38pub const ALIAS_KEYS_FIELD: &str = "alias_keys";
39
40/// The edge type the identity preset derives.
41pub const SAME_AS_EDGE: &str = "SAME_AS";
42
43/// The edge property a `SAME_AS` edge carries its Jaccard score in.
44pub const SAME_AS_WEIGHT: &str = "weight";
45
46/// The lowest alias-set Jaccard the identity preset links at.
47///
48/// Under [`derive_aliases`], two nodes that share a complete two-word name and
49/// differ only in key score exactly 3/5 = 0.6; one shared word of such a name
50/// scores at most 1/7. So 0.6 is the floor at which a link means "the same
51/// full name", and below which it would start meaning "the same first name" —
52/// the conservative setting the spec's O-3 settled on.
53pub const SAME_AS_FLOOR: f64 = 0.6;
54
55/// One `SAME_AS` claim: two keys, in byte order, and the score that links them.
56///
57/// A same-label rule derives both directions; this is the one claim they make.
58#[derive(Debug, Clone, PartialEq, serde::Serialize)]
59pub struct SameAsPair {
60 pub a: String,
61 pub b: String,
62 pub score: f64,
63}
64
65/// Every `SAME_AS` claim touching any of `keys`, as unordered pairs, scored.
66///
67/// Read through [`GraphDb::node_edges`], so a key that does not exist is
68/// skipped rather than an error. An edge with no stored score — a caller's own
69/// `SAME_AS` written through `query` — counts as 1.0. Of a pair's two
70/// directions, the higher score is kept. Sorted by `(a, b)`.
71pub fn same_as_pairs<F: Fs>(db: &GraphDb<F>, keys: &[String]) -> Vec<SameAsPair> {
72 let mut best: std::collections::BTreeMap<(String, String), f64> =
73 std::collections::BTreeMap::new();
74 for key in keys {
75 let Ok(edges) = db.node_edges(key) else {
76 continue;
77 };
78 for e in edges.into_iter().filter(|e| e.edge_type == SAME_AS_EDGE) {
79 let score = match db.get_edge_prop(SAME_AS_EDGE, &e.src_key, &e.dst_key, SAME_AS_WEIGHT)
80 {
81 Some(Value::Float(f)) => f,
82 Some(Value::Int(i)) => i as f64,
83 _ => 1.0,
84 };
85 let pair = if e.src_key <= e.dst_key {
86 (e.src_key, e.dst_key)
87 } else {
88 (e.dst_key, e.src_key)
89 };
90 let slot = best.entry(pair).or_insert(score);
91 if score > *slot {
92 *slot = score;
93 }
94 }
95 }
96 best.into_iter()
97 .map(|((a, b), score)| SameAsPair { a, b, score })
98 .collect()
99}
100
101/// The claims in `before` that `after` no longer holds, each with the score it
102/// had: the identity links a write retracted.
103///
104/// Both lists are [`same_as_pairs`] answers for the same keys, either side of
105/// one write. A pair whose score only changed is still held, and is not here.
106#[must_use]
107pub fn same_as_lost(before: &[SameAsPair], after: &[SameAsPair]) -> Vec<SameAsPair> {
108 before
109 .iter()
110 .filter(|b| !after.iter().any(|p| p.a == b.a && p.b == b.b))
111 .cloned()
112 .collect()
113}
114
115/// Most aliases one node may carry.
116///
117/// A real entity carries its key, its full name and that name's two to four
118/// words — about six. Thirty-two is five times that, and it keeps one node
119/// from becoming a bag of tokens that is a rule candidate for half the store
120/// on every write. Only a name of more than thirty words can reach it.
121pub const MAX_ALIASES: usize = 32;
122
123/// `s` in Unicode NFC, lowercased, with every run of characters that are not
124/// letters or digits collapsed to one space and the ends trimmed.
125///
126/// `"Matthew Sherlin"`, `"matthew_sherlin"` and `"MATTHEW-SHERLIN"` all
127/// become `"matthew sherlin"`. Letters are Unicode letters: `"José Ñúñez"`
128/// becomes `"josé ñúñez"`.
129///
130/// NFC comes first, so a name typed in decomposed form — `e` followed by
131/// U+0301, a combining accent — becomes the same `é` as its precomposed
132/// spelling instead of splitting at the mark. A combining mark with no
133/// precomposed form is still not a letter, and still separates words.
134#[must_use]
135pub fn canonical(s: &str) -> String {
136 tokens(s).join(" ")
137}
138
139/// The words of `s`: maximal runs of letters and digits, in NFC, lowercased.
140///
141/// NFC is applied on both sides of the lowercasing. Before, so a decomposed
142/// letter is one letter when it is lowercased and split. After, because
143/// lowercasing can leave a sequence that composes further — `ᾼ` with an acute
144/// lowercases to `ᾳ` beside the accent, which is `ᾴ` — and without the second
145/// pass [`canonical`] would not be idempotent on it.
146fn tokens(s: &str) -> Vec<String> {
147 s.nfc()
148 .collect::<String>()
149 .to_lowercase()
150 .nfc()
151 .collect::<String>()
152 .split(|c: char| !c.is_alphanumeric())
153 .filter(|t| !t.is_empty())
154 .map(str::to_string)
155 .collect()
156}
157
158/// The aliases a node's own fields imply — the whole of its `aliases` list.
159///
160/// - the key, lowercased — keys are identifiers, so they are not tokenised;
161/// - the name in [`canonical`] form, and each of its words when it has more
162/// than one.
163///
164/// Sorted and deduplicated. Empty strings never appear. An alias a caller
165/// declares is not here: it goes to `alias_keys` ([`declared_alias_keys`]).
166#[must_use]
167pub fn derive_aliases(key: &str, name: Option<&str>) -> Vec<String> {
168 let mut out: BTreeSet<String> = BTreeSet::new();
169 let key = key.to_lowercase();
170 if !key.trim().is_empty() {
171 out.insert(key);
172 }
173 if let Some(name) = name {
174 let words = tokens(name);
175 if !words.is_empty() {
176 out.insert(words.join(" "));
177 }
178 if words.len() > 1 {
179 out.extend(words);
180 }
181 }
182 out.into_iter().collect()
183}
184
185/// The list as a stored value.
186#[must_use]
187pub fn aliases_value(aliases: &[String]) -> Value {
188 Value::List(aliases.iter().cloned().map(Value::Str).collect())
189}
190
191/// Most declared aliases one node may carry in `alias_keys`.
192///
193/// The same bound as [`MAX_ALIASES`]: a real entity has a few nicknames, and
194/// thirty-two keeps one node from claiming half the store's stubs. It is far
195/// below the engine's `MAX_KEYMATCH_LIST`, so a `KeyMatch` rule always reads
196/// the whole list.
197pub const MAX_ALIAS_KEYS: usize = MAX_ALIASES;
198
199/// The aliases a caller declared, as keys a `KeyMatch` rule can compare:
200/// trimmed and otherwise verbatim, blanks dropped, sorted and deduplicated.
201///
202/// Not lowercased and not tokenised. A key is an identifier and is matched
203/// byte for byte, so `Matt` does not name a node keyed `matt`.
204#[must_use]
205pub fn declared_alias_keys(caller: &[String]) -> Vec<String> {
206 let out: BTreeSet<String> = caller
207 .iter()
208 .map(|a| a.trim())
209 .filter(|a| !a.is_empty())
210 .map(str::to_string)
211 .collect();
212 out.into_iter().collect()
213}
214
215/// The strings a stored `aliases` or `alias_keys` value holds: a string is
216/// one, a list of strings is each. `None` for anything else — a number, a
217/// map, a list holding one — which the caller refuses rather than replaces.
218fn stored_strings(value: Option<&Value>) -> Option<Vec<String>> {
219 match value {
220 None => Some(Vec::new()),
221 Some(Value::Str(s)) => Some(vec![s.clone()]),
222 Some(Value::List(items)) => items
223 .iter()
224 .map(|item| match item {
225 Value::Str(s) => Some(s.clone()),
226 _ => None,
227 })
228 .collect(),
229 Some(_) => None,
230 }
231}
232
233/// Whether the stored `aliases` is a list this store derived for `key` from
234/// some name — the current one or an earlier one it can no longer read.
235///
236/// A list the store writes always holds its name in [`canonical`] form, and
237/// deriving from that form reproduces the list. So the stored value is the
238/// store's own exactly when it is byte-identical to [`derive_aliases`] of the
239/// key and one of its own items. Every item of such a list is a derived
240/// token, and none is a declaration.
241///
242/// The cost of telling by shape, accepted: a list a user wrote before 0.7
243/// that happens to be this node's lowercased key plus one name and that
244/// name's words, sorted — key `bob`, list `["bob", "bobby"]` — reads as the
245/// store's, and `bobby` is let go rather than kept in `alias_keys`. A list
246/// with anything more in it, such as one the store wrote while declared
247/// aliases still went into `aliases`, does not have the shape and is moved
248/// item by item.
249fn store_wrote(key: &str, stored: Option<&Value>, held: &[String]) -> bool {
250 held.iter()
251 .any(|name| stored == Some(&aliases_value(&derive_aliases(key, Some(name)))))
252}
253
254/// The refusal for a stored identity list that is not a string or a list of
255/// strings: naming the node, the property, and the way out.
256fn foreign_value(key: &str, field: &str) -> GraphError {
257 GraphError::IngestError {
258 detail: format!(
259 "'{key}' already carries an '{field}' property that is not a string or \
260 a list of strings; clear it with forget {{key: \"{key}\", prop: \
261 \"{field}\"}} first"
262 ),
263 }
264}
265
266/// The two identity lists a node should carry, each `None` when the stored
267/// value is already exactly that.
268struct IdentityLists {
269 aliases: Option<Vec<String>>,
270 alias_keys: Option<Vec<String>>,
271}
272
273/// What `key`'s identity lists become when its name goes from `old_name` to
274/// `new_name` and the caller declares `caller`.
275///
276/// `aliases` is recomputed, not accumulated: it is exactly
277/// [`derive_aliases`] of the key and `new_name`. A changed or removed name
278/// leaves nothing behind.
279///
280/// `alias_keys` accumulates: what it holds, what `caller` declares, and
281/// whatever the stored `aliases` holds that the store did not derive. That
282/// last part is how a user's own data survives. A node written before 0.7 may
283/// carry an `aliases` property of its owner's, and a list this store wrote
284/// before declared aliases left `aliases` holds them too. An item is the
285/// store's when the key with `old_name`, or with `new_name`, implies it byte
286/// for byte; every other item is moved to `alias_keys` as it stands — trimmed,
287/// never canonicalised — on the first describing write, after which `aliases`
288/// holds nothing to move. Only a blank item carries nothing and is let go.
289///
290/// Derived name words must never become claims, and a name can change
291/// underneath the store: a raw write — `query`, `ingest_json`, HTTP — sets a
292/// new name and leaves `aliases` holding the old one's words, which neither
293/// name the store can now read implies. [`store_wrote`] recognises such a
294/// list by its shape, and nothing is moved from it.
295///
296/// A former key `rename_node` left behind is not recognised that way: the
297/// list was derived from another key. It is kept, as a declared alias.
298///
299/// Refused, naming the node, when either stored value is not a string or a
300/// list of strings, or when either list would exceed its cap.
301fn identity_lists<F: Fs>(
302 db: &GraphDb<F>,
303 key: &str,
304 old_name: Option<&str>,
305 new_name: Option<&str>,
306 caller: &[String],
307) -> Result<IdentityLists> {
308 let stored_aliases = db.get_prop(key, ALIASES_FIELD);
309 let stored_keys = db.get_prop(key, ALIAS_KEYS_FIELD);
310 let held_aliases =
311 stored_strings(stored_aliases.as_ref()).ok_or_else(|| foreign_value(key, ALIASES_FIELD))?;
312 let mut declared =
313 stored_strings(stored_keys.as_ref()).ok_or_else(|| foreign_value(key, ALIAS_KEYS_FIELD))?;
314
315 let derived = derive_aliases(key, new_name);
316 if derived.len() > MAX_ALIASES {
317 return Err(GraphError::IngestError {
318 detail: format!(
319 "'{key}' would carry {} aliases, more than {MAX_ALIASES}: its key, its name \
320 and each word of that name; give it a shorter name",
321 derived.len()
322 ),
323 });
324 }
325
326 let was_derived = derive_aliases(key, old_name);
327 let moved: Vec<String> = if store_wrote(key, stored_aliases.as_ref(), &held_aliases) {
328 Vec::new()
329 } else {
330 held_aliases
331 .into_iter()
332 .filter(|a| !derived.contains(a) && !was_derived.contains(a))
333 .collect()
334 };
335 let moved_count = declared_alias_keys(&moved).len();
336 declared.extend(moved);
337 declared.extend(caller.iter().cloned());
338 let declared = declared_alias_keys(&declared);
339 if declared.len() > MAX_ALIAS_KEYS {
340 let carried = if moved_count > 0 {
341 format!(
342 " ({moved_count} of them carried over from its '{ALIASES_FIELD}' property; \
343 forget that to drop them)"
344 )
345 } else {
346 String::new()
347 };
348 return Err(GraphError::IngestError {
349 detail: format!(
350 "'{key}' would carry {} declared aliases, more than {MAX_ALIAS_KEYS}{carried}; \
351 forget its '{ALIAS_KEYS_FIELD}' property first, or pass fewer",
352 declared.len()
353 ),
354 });
355 }
356
357 let aliases = (stored_aliases.as_ref() != Some(&aliases_value(&derived))).then_some(derived);
358 // Never written empty: a node that holds none and declares none carries
359 // no `alias_keys` property at all.
360 let alias_keys = if declared.is_empty() && stored_keys.is_none() {
361 None
362 } else {
363 (stored_keys.as_ref() != Some(&aliases_value(&declared))).then_some(declared)
364 };
365 Ok(IdentityLists {
366 aliases,
367 alias_keys,
368 })
369}
370
371/// [`identity_lists`] for a write that sets `props` on `key`: the name is the
372/// one `props` sets, else the one the node already has. A `props` entry named
373/// `aliases` or `alias_keys` is refused first — both lists are the store's.
374fn identity_lists_after_write<F: Fs>(
375 db: &GraphDb<F>,
376 key: &str,
377 props: &[(String, Value)],
378 caller: &[String],
379) -> Result<IdentityLists> {
380 for field in [ALIASES_FIELD, ALIAS_KEYS_FIELD] {
381 if props.iter().any(|(f, _)| f == field) {
382 return Err(GraphError::IngestError {
383 detail: format!(
384 "'{field}' is maintained by the store; pass aliases as the \
385 'aliases' argument instead of a property"
386 ),
387 });
388 }
389 }
390 let old_name = db.get_prop(key, crate::memory_schema::NAME_FIELD);
391 let new_name = props
392 .iter()
393 .find(|(f, _)| f == crate::memory_schema::NAME_FIELD)
394 .map(|(_, v)| v.clone())
395 .or_else(|| old_name.clone());
396 let text = |v: &Option<Value>| match v {
397 Some(Value::Str(s)) => Some(s.clone()),
398 _ => None,
399 };
400 identity_lists(
401 db,
402 key,
403 text(&old_name).as_deref(),
404 text(&new_name).as_deref(),
405 caller,
406 )
407}
408
409/// The `aliases` list `key` should carry after a write that sets `props` on
410/// it, or `None` when that is exactly what it already carries.
411///
412/// Reads the store, so call it before a batch takes `db` mutably.
413pub fn aliases_after_write<F: Fs>(
414 db: &GraphDb<F>,
415 key: &str,
416 props: &[(String, Value)],
417 caller: &[String],
418) -> Result<Option<Vec<String>>> {
419 Ok(identity_lists_after_write(db, key, props, caller)?.aliases)
420}
421
422/// The `alias_keys` list `key` should carry after a write that declares
423/// `caller`, or `None` when that is exactly what it already carries — which
424/// includes a node that holds none and declares none: the property is never
425/// written empty.
426///
427/// Reads the store, so call it before a batch takes `db` mutably.
428pub fn alias_keys_after_write<F: Fs>(
429 db: &GraphDb<F>,
430 key: &str,
431 props: &[(String, Value)],
432 caller: &[String],
433) -> Result<Option<Vec<String>>> {
434 Ok(identity_lists_after_write(db, key, props, caller)?.alias_keys)
435}
436
437fn identity_props(lists: IdentityLists) -> Vec<(String, Value)> {
438 let mut out = Vec::new();
439 if let Some(list) = lists.aliases {
440 out.push((ALIASES_FIELD.to_string(), aliases_value(&list)));
441 }
442 if let Some(list) = lists.alias_keys {
443 out.push((ALIAS_KEYS_FIELD.to_string(), aliases_value(&list)));
444 }
445 out
446}
447
448/// The store-maintained identity properties a write to `key` must set:
449/// `aliases` and `alias_keys`, each only when it changes.
450///
451/// One call so the two lists are always decided together — an item leaves
452/// `aliases` only by entering `alias_keys` in the same write — and so a
453/// refusal of either (a property of that name, a foreign value, a cap) happens
454/// before anything is written. Reads the store, so call it before a batch
455/// takes `db` mutably.
456pub fn identity_props_after_write<F: Fs>(
457 db: &GraphDb<F>,
458 key: &str,
459 props: &[(String, Value)],
460 caller: &[String],
461) -> Result<Vec<(String, Value)>> {
462 identity_lists_after_write(db, key, props, caller).map(identity_props)
463}
464
465/// The identity properties to set on `key` in the write that removes its
466/// `name`: `aliases` as the key alone implies it.
467///
468/// The name being removed is the old name here, so its words are recognised
469/// as derived and dropped rather than kept as declared. Empty for a node that
470/// carries no `aliases` list — one the memory tools never described is not
471/// given one by a `forget`.
472pub fn identity_props_after_forgetting_name<F: Fs>(
473 db: &GraphDb<F>,
474 key: &str,
475) -> Result<Vec<(String, Value)>> {
476 if db.get_prop(key, ALIASES_FIELD).is_none() {
477 return Ok(Vec::new());
478 }
479 let old_name = match db.get_prop(key, crate::memory_schema::NAME_FIELD) {
480 Some(Value::Str(s)) => Some(s),
481 _ => None,
482 };
483 identity_lists(db, key, old_name.as_deref(), None, &[]).map(identity_props)
484}
485
486/// Labels whose nodes carry an `aliases` list: the entity labels and the
487/// provisional label.
488fn identity_labels() -> impl Iterator<Item = &'static str> {
489 use crate::memory_schema::{MEMORY_ENTITY_LABELS, PROVISIONAL_LABEL};
490 MEMORY_ENTITY_LABELS
491 .iter()
492 .copied()
493 .chain(std::iter::once(PROVISIONAL_LABEL))
494}
495
496/// Nodes under an entity label or the provisional label — the nodes the
497/// identity preset's rules compare.
498pub fn entity_node_count<F: Fs>(db: &GraphDb<F>) -> usize {
499 identity_labels()
500 .map(|l| db.nodes_with_label(l).len())
501 .sum()
502}
503
504/// One node the identity backfill writes to: its key, and the identity
505/// properties to set on it.
506#[derive(Debug, Clone, PartialEq)]
507pub struct IdentityBackfill {
508 pub key: String,
509 pub props: Vec<(String, Value)>,
510}
511
512impl IdentityBackfill {
513 /// Whether this node's backfill sets `field`.
514 #[must_use]
515 pub fn sets(&self, field: &str) -> bool {
516 self.props.iter().any(|(f, _)| f == field)
517 }
518}
519
520/// Every node under an entity label or the provisional label whose stored
521/// identity lists differ from what its key, its name and its own data imply,
522/// with the properties to set. Key order.
523///
524/// For most nodes that is one property: an `aliases` list where there was
525/// none. A node that carries items in `aliases` its key and name do not imply
526/// — a user's own, or aliases declared before they left that list — has them
527/// moved to `alias_keys` here too, so applying the preset drops nothing.
528pub fn aliases_to_backfill<F: Fs>(db: &GraphDb<F>) -> Result<Vec<IdentityBackfill>> {
529 let mut keys: Vec<String> = identity_labels()
530 .flat_map(|l| db.nodes_with_label(l))
531 .map(|n| n.key().to_string())
532 .collect();
533 keys.sort();
534 let mut out = Vec::new();
535 for key in keys {
536 let props = identity_props_after_write(db, &key, &[], &[])?;
537 if !props.is_empty() {
538 out.push(IdentityBackfill { key, props });
539 }
540 }
541 Ok(out)
542}
543
544/// Write the properties [`aliases_to_backfill`] found, in one commit.
545pub fn write_aliases<F: Fs>(db: &mut GraphDb<F>, lists: &[IdentityBackfill]) -> Result<()> {
546 if lists.is_empty() {
547 return Ok(());
548 }
549 let mut batch = db.batch();
550 for node in lists {
551 for (field, value) in &node.props {
552 batch.set_prop(&node.key, field, value.clone());
553 }
554 }
555 batch.commit().map(|_| ())
556}
557
558/// One resolved identity: nodes every pair of which is linked by `SAME_AS`.
559#[derive(Debug, Clone, PartialEq, serde::Serialize)]
560pub struct IdentityCluster {
561 /// The oldest member — the lowest live dense id.
562 pub canonical: String,
563 /// Every member, oldest first; `members[0] == canonical`.
564 pub members: Vec<String>,
565 /// The lowest pairwise score inside the cluster.
566 pub weakest: f64,
567}
568
569/// [`identity_clusters`]' answer.
570#[derive(Debug, Clone, PartialEq, serde::Serialize)]
571pub struct IdentityReport {
572 /// Largest first, then oldest canonical first.
573 pub clusters: Vec<IdentityCluster>,
574 /// Nodes with at least one `SAME_AS` claim at or above the floor.
575 pub linked: usize,
576 /// Distinct unordered pairs at or above the floor.
577 pub claims: usize,
578 /// The floor the claims were read at.
579 pub floor: f64,
580}
581
582/// Resolve `SAME_AS` claims into identities, by complete linkage.
583///
584/// A cluster is a set in which **every** pair is linked at `floor` or above.
585/// Built greedily and deterministically: seeds in ascending dense id, and for
586/// each seed its neighbours in ascending dense id, each admitted only if it is
587/// linked to every member admitted so far. A node goes into the first cluster
588/// that admits it and no other.
589///
590/// Three properties follow, and each is the reason for the choice:
591///
592/// - **No daisy chain.** `a~b` and `b~c` without `a~c` is two claims, not one
593/// person, so `c` is not pulled in. Transitive closure would merge them;
594/// so does modularity clustering once the rest of the graph is large
595/// (`communities` put a six-node chain into one community beside 300
596/// unrelated pairs, and split it when alone).
597/// - **Locality.** A cluster depends only on the nodes linked to its members.
598/// Adding a node with no claim to them cannot move it.
599/// - **A stable canonical.** Ids are never reused and a new node always gets
600/// a higher one, so a later arrival can join a cluster but never displace
601/// its canonical — the oldest node, as settled in the spec's O-2.
602///
603/// A singleton is not a cluster and is not returned. Reads every `SAME_AS`
604/// edge once ([`GraphDb::weighted_edges`]); an edge with no score is a
605/// caller's own assertion and counts as 1.0, and so does one whose `weight`
606/// is not a number (`weighted_edges` reads only `Int` and `Float`) — the same
607/// rule [`same_as_pairs`] applies.
608pub fn identity_clusters<F: Fs>(db: &GraphDb<F>, floor: f64) -> IdentityReport {
609 use std::collections::BTreeMap;
610 let mut key_of: BTreeMap<u32, String> = BTreeMap::new();
611 let mut adj: BTreeMap<u32, BTreeMap<u32, f64>> = BTreeMap::new();
612 for (src, dst, weight) in db.weighted_edges(SAME_AS_EDGE, Some(SAME_AS_WEIGHT)) {
613 let score = weight.unwrap_or(1.0);
614 if score < floor || src == dst {
615 continue;
616 }
617 let (Some(s), Some(d)) = (db.dense_id(&src), db.dense_id(&dst)) else {
618 continue;
619 };
620 key_of.insert(s, src);
621 key_of.insert(d, dst);
622 for (x, y) in [(s, d), (d, s)] {
623 let slot = adj.entry(x).or_default().entry(y).or_insert(score);
624 if score > *slot {
625 *slot = score;
626 }
627 }
628 }
629 let claims = adj.values().map(BTreeMap::len).sum::<usize>() / 2;
630 let mut assigned: BTreeSet<u32> = BTreeSet::new();
631 let mut clusters: Vec<(u32, Vec<u32>, f64)> = Vec::new();
632 for (&seed, neighbours) in &adj {
633 if !assigned.insert(seed) {
634 continue;
635 }
636 let mut members = vec![seed];
637 let mut weakest = f64::INFINITY;
638 for &cand in neighbours.keys() {
639 if assigned.contains(&cand) {
640 continue;
641 }
642 let scores: Option<Vec<f64>> = members
643 .iter()
644 .map(|m| adj.get(m).and_then(|n| n.get(&cand)).copied())
645 .collect();
646 if let Some(scores) = scores {
647 weakest = scores.into_iter().fold(weakest, f64::min);
648 members.push(cand);
649 }
650 }
651 if members.len() > 1 {
652 assigned.extend(members.iter().copied());
653 clusters.push((seed, members, weakest));
654 }
655 }
656 clusters.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then(a.0.cmp(&b.0)));
657 IdentityReport {
658 clusters: clusters
659 .into_iter()
660 .map(|(seed, members, weakest)| IdentityCluster {
661 canonical: key_of[&seed].clone(),
662 members: members.iter().map(|m| key_of[m].clone()).collect(),
663 weakest,
664 })
665 .collect(),
666 linked: adj.len(),
667 claims,
668 floor,
669 }
670}
671
672#[cfg(test)]
673mod tests {
674 use super::*;
675
676 #[test]
677 fn canonical_is_idempotent() {
678 for s in [
679 "Matthew Sherlin",
680 "matthew_sherlin",
681 "JOSÉ-Ñúñez",
682 "E\u{301}mile Zola",
683 "x\u{301}y",
684 "İstanbul",
685 "Džemal",
686 // Lowercasing `ᾼ` leaves `ᾳ` beside the accent, which composes
687 // to `ᾴ` — only a second NFC pass makes this one stable.
688 "ᾼ\u{301}",
689 "",
690 "!!",
691 "a1 B2",
692 ] {
693 assert_eq!(canonical(&canonical(s)), canonical(s), "{s:?}");
694 }
695 }
696
697 #[test]
698 fn derive_is_deterministic_sorted_and_deduplicated() {
699 let once = derive_aliases("Sherlin", Some("Matthew SHERLIN"));
700 assert_eq!(once, ["matthew", "matthew sherlin", "sherlin"]);
701 assert_eq!(once, derive_aliases("Sherlin", Some("Matthew SHERLIN")));
702 }
703}