Skip to main content

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}