aede_core/model/
identity.rs

1//! Which spellings of a name are the same person, decided before any of them
2//! becomes an entity.
3//!
4//! # The problem, and its two halves
5//!
6//! The catalog builds an artist per **normalised name**, so a library holding
7//! `Ozzy Osbourne` on one album and `O. Osbourne` on another holds two
8//! musicians. Every count, every listing and every page is wrong by one, and no
9//! amount of comparing the two strings can fix it safely: matching on a
10//! fragment of a name would merge Angus Young with Neil Young, which is worse
11//! than the fault it cures.
12//!
13//! **The half that has an exact answer.** A great many libraries have been
14//! through **Picard**, and those files carry `MUSICBRAINZ_ARTISTID`. Two
15//! spellings under one identifier are not a resemblance to be judged — they are
16//! the same artist, said so by the only authority there is on the question.
17//! That half needs no heuristic at all.
18//!
19//! **The half nobody outside can answer.** Old rips, downloads, a friend's
20//! drive: nobody on earth knows that a particular `O. Osbourne` is Ozzy except
21//! the person whose disk it is. So the program does not guess — it asks, with
22//! `aede merge`, and keeps the answer in `user.json` beside everything else
23//! that was said rather than derived. `doctor` **suggests** pairs worth looking
24//! at and applies none of them.
25//!
26//! Both halves arrive here, and they are resolved together rather than one
27//! after the other, for the reason in [`aliases`].
28//!
29//! # Only where the pairing is unambiguous
30//!
31//! A tag may name several artists at once, and the identifiers arrive as their
32//! own list: `artist` may be one string that [`crate::text::split_artists`]
33//! turns into two names, while `MUSICBRAINZ_ARTISTID` holds two values in an
34//! order nothing guarantees to be the same. Pairing them by position would
35//! attach an identifier to whichever name happened to sort first.
36//!
37//! So a file speaks only when it names **exactly one artist and exactly one
38//! identifier**. That covers the overwhelming majority of tracks, and it is
39//! never a guess. The rule this program follows everywhere: several equally
40//! good answers are refused rather than arbitrated.
41//!
42//! A spelling seen under distinct identifiers is ambiguous and cannot establish
43//! automatic aliases. The local artist key remains the normalised name, so two
44//! people with exactly the same spelling still share an ambiguous local row;
45//! their other spellings must not merge merely through that homonym. An explicit
46//! owner choice can still join those spellings.
47//!
48//! # Which spelling survives
49//!
50//! Where a person has said so, the one they named. Otherwise the one that names
51//! the most tracks, ties broken by the normalised key so that two runs over one
52//! library answer alike. Either way it is **a key that already existed**, so
53//! merging can only ever shrink the set of keys: anything filed under the
54//! surviving key in `user.json` or `sources.json` keeps pointing at it.
55
56use std::collections::{HashMap, HashSet};
57
58use crate::text;
59
60/// The name every spelling of one artist is filed under.
61///
62/// Keys and values are normalised names. A name that is nobody's alias is
63/// absent rather than mapped to itself, so the common case costs one lookup
64/// that misses.
65pub type Aliases = HashMap<String, String>;
66
67/// What one file says about one artist, when it says it unambiguously.
68///
69/// Both halves come from the same file and the same tag pair, which is what
70/// makes them evidence rather than a coincidence.
71pub struct Said {
72    /// The identifier the file carries.
73    pub mbid: String,
74    /// The one artist name it carries beside it.
75    ///
76    /// Owned rather than borrowed from the tags. The name a file *credits* is
77    /// not always a string the file holds — a tag naming two artists is split
78    /// before it gets here — so borrowing would mean handing back a slice of
79    /// something the caller had just built. Two small allocations per file,
80    /// against the cost of having read the file at all.
81    pub name: String,
82}
83
84/// What the owner of the disk says, for the files no identifier can answer for.
85///
86/// Both keys normalised, which is the form `user::SameArtist` holds them in and
87/// the form every merge in this program is decided on.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct Chosen {
90    /// The spelling that gives way.
91    pub spelling: String,
92    /// The spelling it is to be filed under.
93    pub filed_as: String,
94}
95
96/// Reads the aliases out of everything the scan saw and everything the owner
97/// said.
98///
99/// `said` is every unambiguous (identifier, name) pair in the library, in any
100/// order; a file contributes one for its artist and one for its album artist
101/// when each is unambiguous, and nothing when it is not. `chosen` is what the
102/// person typed.
103///
104/// **The two are resolved together, not one on top of the other.** Laying a
105/// person's statement over a finished table is where this goes wrong: if the
106/// files elect `o osbourne` — perfectly possible, the count decides — and the
107/// person says `o osbourne` gives way to `Ozzy`, overwriting one row leaves the
108/// other standing and the two names point at each other for ever. Every
109/// statement, from either source, is therefore read as *these spellings are one
110/// artist*; the spellings are gathered into groups; and each group elects one
111/// survivor, which cannot loop because there is exactly one per group.
112pub fn aliases(said: impl Iterator<Item = Said>, chosen: &[Chosen]) -> Aliases {
113    // How often each spelling was seen, per identifier. Counted rather than
114    // collected into a set, because the count is what decides which spelling
115    // survives where nobody has said otherwise, and a set would throw it away.
116    let mut seen: HashMap<String, HashMap<String, usize>> = HashMap::new();
117    for Said { mbid, name } in said {
118        let key = text::normalize(&name);
119        if mbid.trim().is_empty() || key.is_empty() {
120            continue;
121        }
122        *seen.entry(mbid).or_default().entry(key).or_insert(0) += 1;
123    }
124
125    // How many tracks each spelling names, whatever identifier it came under.
126    // All that is left of the counts once the groups are formed, and the
127    // tie-break of last resort.
128    let mut tracks: HashMap<String, usize> = HashMap::new();
129    for spellings in seen.values() {
130        for (key, count) in spellings {
131            *tracks.entry(key.clone()).or_insert(0) += count;
132        }
133    }
134
135    // A shared spelling is not evidence that distinct identifiers name one
136    // person. Keep it out of automatic unions, including indirect bridges
137    // between each person's other, unambiguous spellings.
138    let mut identifiers = HashMap::new();
139    let mut ambiguous = HashSet::new();
140    for (mbid, spellings) in &seen {
141        for key in spellings.keys() {
142            if identifiers
143                .insert(key.as_str(), mbid.as_str())
144                .is_some_and(|previous| previous != mbid)
145            {
146                ambiguous.insert(key.as_str());
147            }
148        }
149    }
150
151    let mut groups = Groups::default();
152    for spellings in seen.values() {
153        // One spelling is not an alias of anything. Joining it to itself would
154        // be harmless and pointless; skipping keeps the table to the names that
155        // really are somebody's alias.
156        let mut keys = spellings
157            .keys()
158            .filter(|key| !ambiguous.contains(key.as_str()));
159        if let Some(first) = keys.next() {
160            for other in keys {
161                groups.join(first, other);
162            }
163        }
164    }
165    // A person saying two names are one artist has said something no file can
166    // contradict, and it joins the same groups: their statement decides *which*
167    // spelling survives, below, not whether the two belong together.
168    for Chosen { spelling, filed_as } in chosen {
169        if spelling.is_empty() || filed_as.is_empty() || spelling == filed_as {
170            continue;
171        }
172        groups.join(spelling, filed_as);
173    }
174
175    // A spelling somebody said gives way can never be the survivor, and one
176    // they named as the destination is the survivor unless they also said it
177    // gives way — which is how `A → B` followed by `B → C` lands everybody on
178    // `C` without either statement having to know about the other.
179    let gives_way: Vec<&str> = chosen.iter().map(|c| c.spelling.as_str()).collect();
180    let named: Vec<&str> = chosen.iter().map(|c| c.filed_as.as_str()).collect();
181
182    let mut aliases = Aliases::new();
183    for mut ranked in groups.members() {
184        if ranked.len() < 2 {
185            continue;
186        }
187        // Ranked once, so both rules below read the same list: most tracks
188        // first, then the normalised key. Sorting on the key rather than on
189        // iteration order matters — a `HashMap` promises none, and a catalog
190        // that named an artist differently on Tuesday would be unusable.
191        ranked.sort_by(|a, b| {
192            tracks
193                .get(b)
194                .unwrap_or(&0)
195                .cmp(tracks.get(a).unwrap_or(&0))
196                .then(a.cmp(b))
197        });
198        // Where the statements cancel out — `A → B` and `B → A`, a person
199        // contradicting themselves — nobody is elected by name and the count
200        // decides, as it does when nobody has said anything at all. The two
201        // names still become one artist, which is the part they agreed on.
202        let winner = ranked
203            .iter()
204            .find(|k| named.contains(&k.as_str()) && !gives_way.contains(&k.as_str()))
205            .unwrap_or(&ranked[0])
206            .clone();
207        for loser in ranked {
208            if loser != winner {
209                aliases.insert(loser, winner.clone());
210            }
211        }
212    }
213    aliases
214}
215
216/// The name a spelling is filed under: its alias, or itself.
217pub fn filed_as<'a>(aliases: &'a Aliases, key: &'a str) -> &'a str {
218    aliases.get(key).map(String::as_str).unwrap_or(key)
219}
220
221/// Spellings gathered into the sets that name one artist.
222///
223/// A union-find, and the reason for it is the one in [`aliases`]: statements
224/// arrive as *pairs*, from two sources that know nothing of each other, and
225/// turning pairs into one winner per name is exactly what a disjoint-set
226/// structure does — with no path that can produce a cycle, whatever order the
227/// pairs arrive in.
228#[derive(Default)]
229struct Groups {
230    /// Each spelling's parent, walked up to the root that stands for its group.
231    parent: HashMap<String, String>,
232}
233
234impl Groups {
235    /// Puts two spellings in one group.
236    fn join(&mut self, one: &str, other: &str) {
237        let (a, b) = (self.root(one), self.root(other));
238        if a != b {
239            // The smaller key becomes the root, so a group's root depends on
240            // its members rather than on the order the pairs arrived in. Which
241            // member is the root says nothing about which spelling wins — that
242            // is decided per group in `aliases` — but a stable root is what
243            // makes this structure testable.
244            let (root, child) = if a < b { (a, b) } else { (b, a) };
245            self.parent.insert(child, root);
246        }
247    }
248
249    /// The root of the group a spelling belongs to, adding it if it is new.
250    fn root(&mut self, key: &str) -> String {
251        let mut at = key.to_string();
252        while let Some(up) = self.parent.get(&at) {
253            if up == &at {
254                break;
255            }
256            at = up.clone();
257        }
258        self.parent
259            .entry(key.to_string())
260            .or_insert_with(|| at.clone());
261        at
262    }
263
264    /// Every group, as the spellings it holds.
265    fn members(mut self) -> Vec<Vec<String>> {
266        let keys: Vec<String> = self.parent.keys().cloned().collect();
267        let mut grouped: HashMap<String, Vec<String>> = HashMap::new();
268        for key in keys {
269            let root = self.root(&key);
270            grouped.entry(root).or_default().push(key);
271        }
272        grouped.into_values().collect()
273    }
274}
275
276#[cfg(test)]
277#[path = "identity_tests.rs"]
278mod tests;