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;