aede_core/
graph.rs

1//! One read-only view of every relationship Aède can justify.
2//!
3//! The catalog contains relationships derived from local tags while the
4//! source layer deliberately keeps external relationships attributed and
5//! reviewable.  They must not be merged as facts, but exports, annotations and
6//! diagnostics need one vocabulary for referring to either kind of edge.  A
7//! [`GraphEdge`] is that projection: it keeps provenance and trust while
8//! giving the relationship a stable [`RelationRef`].
9
10use std::collections::BTreeMap;
11
12use crate::model::{Catalog, CreditAttribute, EntityKind};
13use crate::sources::{
14    Confidence, ReviewDecision, Side, SourcedCreditLink, SourcedEditionCreditLink, Sources,
15};
16use crate::user::EntityRef;
17
18/// Stable identity of one directed relationship.
19///
20/// Catalog vector positions never appear here. Endpoints use the same stable
21/// references as personal annotations; provenance and the source relationship
22/// identifier keep two independent assertions about the same pair distinct.
23#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
24pub struct RelationRef {
25    /// Where the directed relationship starts.
26    pub source: EntityRef,
27    /// Relationship type, such as `performance_of` or `credit:producer`.
28    pub kind: String,
29    /// Where it ends.
30    pub target: EntityRef,
31    /// Who asserted or derived it: `tags`, `musicbrainz`, `manual`…
32    pub provenance: String,
33    /// Stable relationship-row identifier supplied by the source, when any.
34    pub source_id: Option<String>,
35}
36
37impl RelationRef {
38    /// Compact stable selector used by the CLI.
39    ///
40    /// FNV-1a is only a stable display hash, never security or identity. The
41    /// CLI still refuses an ambiguous prefix.
42    pub fn id(&self) -> String {
43        let mut hash = 0xcbf29ce484222325u64;
44        for part in [
45            self.source.to_token(),
46            self.kind.clone(),
47            self.target.to_token(),
48            self.provenance.clone(),
49            self.source_id.clone().unwrap_or_default(),
50        ] {
51            for byte in part.bytes().chain(std::iter::once(0)) {
52                hash ^= u64::from(byte);
53                hash = hash.wrapping_mul(0x100000001b3);
54            }
55        }
56        format!("{hash:016x}")
57    }
58}
59
60/// One local or externally sourced relationship ready for display or export.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct GraphEdge {
63    /// Stable relationship identity.
64    pub reference: RelationRef,
65    /// Readable name of the source endpoint at projection time.
66    pub source_name: String,
67    /// Readable name of the target endpoint at projection time.
68    pub target_name: String,
69    /// Number of local observations summarized by this edge.
70    pub weight: u32,
71    /// Whether this edge may participate in navigation and queries.
72    pub trusted: bool,
73    /// A user excluded this specific credit; the original evidence remains.
74    pub excluded: bool,
75    /// Matching confidence for an external assertion.
76    pub confidence: Option<Confidence>,
77    /// Explicit source-review decision, if one exists.
78    pub review: Option<ReviewDecision>,
79    /// Time at which an external assertion was fetched.
80    pub fetched_at: Option<u64>,
81    /// Stable source identifier of the relationship type, when exposed.
82    pub relationship_type_id: Option<String>,
83    /// Direction supplied by the external source.
84    pub direction: Option<String>,
85    /// Exact name used on this relationship rather than the canonical name.
86    pub credited_as: Option<String>,
87    /// Instruments, qualifiers, and other relationship attributes.
88    pub attributes: Vec<CreditAttribute>,
89    /// Optional start of the relationship's validity period.
90    pub began: Option<String>,
91    /// Optional end of the relationship's validity period.
92    pub ended: Option<String>,
93    /// Whether the source explicitly says the relationship has ended.
94    pub over: Option<bool>,
95    /// Ordering key supplied by the source.
96    pub order: Option<u32>,
97}
98
99/// Projects local and sourced relationships into one attributed graph view.
100///
101/// Repeated observations of the same relationship contribute to its weight.
102/// Its display snapshot prefers trusted evidence, then the latest fetch; the
103/// original source claims remain available in [`Sources`].
104pub fn edges(catalog: &Catalog, sources: &Sources) -> Vec<GraphEdge> {
105    let mut edges = local_edges(catalog);
106    edges.extend(work_edges(catalog, sources));
107    edges.extend(work_parent_edges(catalog, sources));
108    edges.extend(credit_edges(catalog, sources));
109    edges.extend(edition_credit_edges(catalog, sources));
110    edges.extend(membership_edges(catalog, sources));
111    let mut by_reference: BTreeMap<RelationRef, GraphEdge> = BTreeMap::new();
112    for edge in edges {
113        match by_reference.entry(edge.reference.clone()) {
114            std::collections::btree_map::Entry::Vacant(entry) => {
115                entry.insert(edge);
116            }
117            std::collections::btree_map::Entry::Occupied(mut entry) => {
118                let previous = entry.get_mut();
119                let weight = previous.weight.saturating_add(edge.weight);
120                let priority = (edge.trusted, edge.fetched_at);
121                let previous_priority = (previous.trusted, previous.fetched_at);
122                if priority > previous_priority
123                    || (priority == previous_priority
124                        && (&edge.source_name, &edge.target_name)
125                            < (&previous.source_name, &previous.target_name))
126                {
127                    *previous = edge;
128                }
129                previous.weight = weight;
130            }
131        }
132    }
133    by_reference.into_values().collect()
134}
135
136fn local_edges(catalog: &Catalog) -> Vec<GraphEdge> {
137    catalog
138        .relations
139        .iter()
140        .filter_map(|relation| {
141            let source = EntityRef::of(catalog, relation.source_kind, relation.source_id)?;
142            let target = EntityRef::of(catalog, relation.target_kind, relation.target_id)?;
143            Some(GraphEdge {
144                source_name: source.display_name(catalog),
145                target_name: target.display_name(catalog),
146                reference: RelationRef {
147                    source,
148                    kind: relation.kind.clone(),
149                    target,
150                    provenance: relation.source.clone(),
151                    source_id: None,
152                },
153                weight: relation.weight,
154                trusted: true,
155                excluded: false,
156                confidence: None,
157                review: None,
158                fetched_at: None,
159                relationship_type_id: None,
160                direction: None,
161                credited_as: None,
162                attributes: Vec::new(),
163                began: None,
164                ended: None,
165                over: None,
166                order: None,
167            })
168        })
169        .collect()
170}
171
172fn work_edges(catalog: &Catalog, sources: &Sources) -> Vec<GraphEdge> {
173    sources
174        .work_links(catalog)
175        .into_iter()
176        .filter_map(|link| {
177            let source = EntityRef::of(catalog, EntityKind::Recording, link.recording_id)?;
178            let source_name = source.display_name(catalog);
179            let target = EntityRef::new(EntityKind::Work, link.work.mbid.clone());
180            let target_name = if link.work.title.is_empty() {
181                link.work.mbid.clone()
182            } else {
183                link.work.title.clone()
184            };
185            Some(GraphEdge {
186                reference: RelationRef {
187                    source,
188                    kind: link
189                        .work
190                        .relation_type
191                        .clone()
192                        .unwrap_or_else(|| "performance_of".into()),
193                    target,
194                    provenance: link.source,
195                    source_id: link.work.relation_id.clone(),
196                },
197                source_name,
198                target_name,
199                weight: 1,
200                trusted: link.trusted,
201                excluded: false,
202                confidence: Some(link.confidence),
203                review: link.review,
204                fetched_at: Some(link.fetched_at),
205                relationship_type_id: link.work.relation_type_id.clone(),
206                direction: link.work.direction.clone(),
207                credited_as: None,
208                attributes: link.work.attributes,
209                began: None,
210                ended: None,
211                over: None,
212                order: None,
213            })
214        })
215        .collect()
216}
217
218fn work_parent_edges(catalog: &Catalog, sources: &Sources) -> Vec<GraphEdge> {
219    sources
220        .work_parent_links(catalog)
221        .into_iter()
222        .map(|link| GraphEdge {
223            reference: RelationRef {
224                source: EntityRef::new(EntityKind::Work, link.child_mbid),
225                kind: "part_of_work".into(),
226                target: EntityRef::new(EntityKind::Work, link.parent.mbid.clone()),
227                provenance: link.source,
228                source_id: link.parent.relation_id.clone(),
229            },
230            source_name: link.child_title,
231            target_name: link.parent.title,
232            weight: 1,
233            trusted: link.trusted,
234            excluded: false,
235            confidence: Some(link.confidence),
236            review: link.review,
237            fetched_at: Some(link.fetched_at),
238            relationship_type_id: link.parent.relation_type_id,
239            direction: link.parent.direction,
240            credited_as: None,
241            attributes: link.parent.attributes,
242            began: None,
243            ended: None,
244            over: None,
245            order: link.parent.order,
246        })
247        .collect()
248}
249
250fn credit_edges(catalog: &Catalog, sources: &Sources) -> Vec<GraphEdge> {
251    sources
252        .credit_links(catalog)
253        .into_iter()
254        .filter_map(|link| {
255            let (source, source_name) =
256                artist_endpoint(catalog, &link.credit.artist_mbid, &link.credit.artist_name);
257            let (target, target_name) = match &link.work {
258                Some(work) => (
259                    EntityRef::new(EntityKind::Work, work.mbid.clone()),
260                    if work.title.is_empty() {
261                        work.mbid.clone()
262                    } else {
263                        work.title.clone()
264                    },
265                ),
266                None => {
267                    let target = EntityRef::of(catalog, EntityKind::Recording, link.recording_id)?;
268                    let name = target.display_name(catalog);
269                    (target, name)
270                }
271            };
272            Some(GraphEdge {
273                reference: RelationRef {
274                    source,
275                    kind: format!("credit:{}", link.credit.role),
276                    target,
277                    provenance: link.source,
278                    source_id: link.credit.relation_id.clone(),
279                },
280                source_name,
281                target_name,
282                weight: 1,
283                trusted: link.trusted,
284                excluded: link.excluded,
285                confidence: Some(link.confidence),
286                review: link.review,
287                fetched_at: Some(link.fetched_at),
288                relationship_type_id: link.credit.role_id.clone(),
289                direction: link.credit.direction.clone(),
290                credited_as: link.credit.credited_as.clone(),
291                attributes: link.credit.attributes.clone(),
292                began: link.credit.began.clone(),
293                ended: link.credit.ended.clone(),
294                over: link.credit.over,
295                order: link.credit.order,
296            })
297        })
298        .collect()
299}
300
301fn edition_credit_edges(catalog: &Catalog, sources: &Sources) -> Vec<GraphEdge> {
302    sources
303        .edition_credit_links(catalog)
304        .into_iter()
305        .filter_map(|link| {
306            let (source, source_name) =
307                artist_endpoint(catalog, &link.credit.artist_mbid, &link.credit.artist_name);
308            let target = EntityRef::of(catalog, EntityKind::Release, link.release_id)?;
309            let target_name = target.display_name(catalog);
310            Some(GraphEdge {
311                reference: RelationRef {
312                    source,
313                    kind: format!("credit:{}", link.credit.role),
314                    target,
315                    provenance: link.source,
316                    source_id: link.credit.relation_id,
317                },
318                source_name,
319                target_name,
320                weight: 1,
321                trusted: link.trusted,
322                excluded: link.excluded,
323                confidence: Some(link.confidence),
324                review: link.review,
325                fetched_at: Some(link.fetched_at),
326                relationship_type_id: link.credit.role_id,
327                direction: link.credit.direction,
328                credited_as: link.credit.credited_as,
329                attributes: link.credit.attributes,
330                began: link.credit.began,
331                ended: link.credit.ended,
332                over: link.credit.over,
333                order: link.credit.order,
334            })
335        })
336        .collect()
337}
338
339fn membership_edges(catalog: &Catalog, sources: &Sources) -> Vec<GraphEdge> {
340    sources
341        .membership_links(catalog)
342        .into_iter()
343        .filter_map(|link| {
344            let local = EntityRef::of(catalog, EntityKind::Artist, link.artist_id)?;
345            let local_name = local.display_name(catalog);
346            let (related, related_name) = link
347                .related_artist_id
348                .and_then(|id| {
349                    let reference = EntityRef::of(catalog, EntityKind::Artist, id)?;
350                    let name = reference.display_name(catalog);
351                    Some((reference, name))
352                })
353                .unwrap_or_else(|| {
354                    (
355                        EntityRef::new(
356                            EntityKind::Artist,
357                            format!("mbid:{}", link.membership.mbid),
358                        ),
359                        link.membership.name.clone(),
360                    )
361                });
362            let (source, source_name, target, target_name) = match link.membership.side {
363                Side::Player => (related, related_name, local, local_name),
364                Side::Group => (local, local_name, related, related_name),
365            };
366            Some(GraphEdge {
367                reference: RelationRef {
368                    source,
369                    kind: format!("membership:{}", link.membership.kind),
370                    target,
371                    provenance: link.source,
372                    source_id: link.membership.relation_id.clone(),
373                },
374                source_name,
375                target_name,
376                weight: 1,
377                trusted: link.trusted,
378                excluded: false,
379                confidence: Some(link.confidence),
380                review: link.review,
381                fetched_at: Some(link.fetched_at),
382                relationship_type_id: link.membership.role_id.clone(),
383                direction: link.membership.direction.clone(),
384                credited_as: link.membership.credited_as.clone(),
385                attributes: link
386                    .membership
387                    .attributes
388                    .iter()
389                    .map(|name| CreditAttribute {
390                        id: None,
391                        name: name.clone(),
392                        value: None,
393                        credited_as: None,
394                    })
395                    .collect(),
396                began: link.membership.began.clone(),
397                ended: link.membership.ended.clone(),
398                over: link.membership.over,
399                order: None,
400            })
401        })
402        .collect()
403}
404
405/// Stable selector for a recording/work credit, shared with exclusions.
406pub(crate) fn credit_reference(catalog: &Catalog, link: &SourcedCreditLink) -> Option<RelationRef> {
407    let (source, _) = artist_endpoint(catalog, &link.credit.artist_mbid, &link.credit.artist_name);
408    let target = match &link.work {
409        Some(work) => EntityRef::new(EntityKind::Work, work.mbid.clone()),
410        None => EntityRef::of(catalog, EntityKind::Recording, link.recording_id)?,
411    };
412    Some(RelationRef {
413        source,
414        kind: format!("credit:{}", link.credit.role),
415        target,
416        provenance: link.source.clone(),
417        source_id: link.credit.relation_id.clone(),
418    })
419}
420
421/// Stable selector for an edition credit, never projected onto recordings.
422pub(crate) fn edition_credit_reference(
423    catalog: &Catalog,
424    link: &SourcedEditionCreditLink,
425) -> Option<RelationRef> {
426    let (source, _) = artist_endpoint(catalog, &link.credit.artist_mbid, &link.credit.artist_name);
427    Some(RelationRef {
428        source,
429        kind: format!("credit:{}", link.credit.role),
430        target: EntityRef::of(catalog, EntityKind::Release, link.release_id)?,
431        provenance: link.source.clone(),
432        source_id: link.credit.relation_id.clone(),
433    })
434}
435
436pub(crate) fn same_credit_relation(
437    catalog: &Catalog,
438    held: &RelationRef,
439    current: &RelationRef,
440) -> bool {
441    let same_endpoint = |left: &EntityRef, right: &EntityRef| {
442        left == right
443            || (left.kind == right.kind
444                && left
445                    .resolve(catalog)
446                    .is_some_and(|id| right.resolve(catalog) == Some(id)))
447    };
448    held.kind == current.kind
449        && held.provenance == current.provenance
450        && held.source_id == current.source_id
451        && same_endpoint(&held.source, &current.source)
452        && same_endpoint(&held.target, &current.target)
453}
454
455pub(crate) fn canonical_credit_relation(
456    catalog: &Catalog,
457    mut relation: RelationRef,
458) -> RelationRef {
459    if relation.source.kind == EntityKind::Artist
460        && let Some(artist_id) = relation.source.resolve(catalog)
461        && let Some(mbid) = catalog
462            .artist(artist_id)
463            .and_then(|artist| artist.mbid.as_deref())
464            .filter(|mbid| !mbid.trim().is_empty())
465    {
466        let canonical = EntityRef::new(EntityKind::Artist, format!("mbid:{mbid}"));
467        // Shared homonyms can leave several local rows carrying the same MBID.
468        if canonical.resolve(catalog) == Some(artist_id) {
469            relation.source = canonical;
470        }
471    }
472    relation
473}
474
475fn artist_endpoint(catalog: &Catalog, mbid: &str, name: &str) -> (EntityRef, String) {
476    if mbid.is_empty() {
477        if let Some(artist) = catalog
478            .artists
479            .iter()
480            .find(|artist| artist.key == crate::text::normalize(name))
481            && let Some(reference) = EntityRef::of(catalog, EntityKind::Artist, artist.id)
482        {
483            return (reference, artist.name.clone());
484        }
485        return (
486            EntityRef::new(
487                EntityKind::Artist,
488                format!("manual:{}", crate::text::normalize(name)),
489            ),
490            name.to_string(),
491        );
492    }
493    if let Some(artist) = catalog
494        .artists
495        .iter()
496        .find(|artist| artist.mbid.as_deref() == Some(mbid))
497        && let Some(reference) = EntityRef::of(catalog, EntityKind::Artist, artist.id)
498    {
499        return (reference, artist.name.clone());
500    }
501    (
502        EntityRef::new(EntityKind::Artist, format!("mbid:{mbid}")),
503        name.to_string(),
504    )
505}
506
507/// Finds a relationship by its full ID or an unambiguous prefix.
508pub fn select(edges: &[GraphEdge], selector: &str) -> Result<GraphEdge, String> {
509    let selector = selector.trim().to_ascii_lowercase();
510    if selector.is_empty() {
511        return Err("a relation ID is required; run aede relations to list them".into());
512    }
513    let mut found = edges
514        .iter()
515        .filter(|edge| edge.reference.id().starts_with(&selector));
516    let Some(edge) = found.next() else {
517        return Err(format!(
518            "no relation starts with \"{selector}\"; run aede relations for the current list"
519        ));
520    };
521    if found.next().is_some() {
522        return Err(format!(
523            "relation ID \"{selector}\" is ambiguous; copy more characters from aede relations"
524        ));
525    }
526    Ok(edge.clone())
527}
528
529#[cfg(test)]
530#[path = "graph_tests.rs"]
531mod tests;