Skip to main content

suno_core/
lineage.rs

1//! Pure lineage classification: classify a clip's parent edge and model the
2//! data types a resolved library is expressed in.
3//!
4//! Suno records how a clip was derived across a scatter of metadata fields
5//! (`task`, `type`, and a family of `*_clip_id` pointers plus `history` and
6//! `concat_history`). This module turns those into a single primary parent per
7//! clip ([`immediate_parent`]) classified by [`EdgeType`] and the full set of
8//! parent [`Edge`]s for the later graph store ([`lineage_edges`]).
9//!
10//! Classification is deliberately blind to `is_remix`: that flag is a UI hint,
11//! not a structural fact, so it never changes an edge. Everything here is pure
12//! and free of IO: the async root-ancestor walk that gap-fills missing
13//! ancestors over the network lives in [`crate::roots`], which depends on these
14//! classifiers and data types (a one-way edge).
15
16use std::collections::HashMap;
17
18use serde::{Deserialize, Serialize};
19
20use crate::model::Clip;
21
22/// The all-zero UUID Suno uses as a "no clip" sentinel in pointer fields.
23const ZERO_UUID: &str = "00000000-0000-0000-0000-000000000000";
24
25/// How one clip relates to its immediate parent.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum EdgeType {
28    /// A cover: re-performed audio from a source clip.
29    Cover,
30    /// A remaster or upsample to higher fidelity.
31    Remaster,
32    /// A playback-speed edit.
33    SpeedEdit,
34    /// A studio edit export.
35    Edit,
36    /// An extension appended after a source clip.
37    Extend,
38    /// A section (infill) replaced within a source clip.
39    SectionReplace,
40    /// A stitch (concatenation) of two or more segments.
41    Stitch,
42    /// A derived clip with a parent pointer but no more specific marker.
43    Derived,
44    /// An external upload with no Suno parent.
45    Uploaded,
46}
47
48impl EdgeType {
49    /// A human label describing the relationship to the parent.
50    pub fn label(self) -> &'static str {
51        match self {
52            EdgeType::Cover => "Cover of",
53            EdgeType::Remaster => "Remaster of",
54            EdgeType::SpeedEdit => "Speed-edited from",
55            EdgeType::Edit => "Edited from",
56            EdgeType::Extend => "Extended from",
57            EdgeType::SectionReplace => "Section replaced from",
58            EdgeType::Stitch => "Stitched from",
59            EdgeType::Derived => "Derived from",
60            EdgeType::Uploaded => "Uploaded",
61        }
62    }
63}
64
65/// Whether an [`Edge`] is the clip's primary parent or a supporting one.
66#[derive(
67    Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
68)]
69#[serde(rename_all = "snake_case")]
70pub enum EdgeRole {
71    /// The single lineage parent used for root resolution and album grouping.
72    #[default]
73    Primary,
74    /// An additional source (an extra stitch segment, an infill's future half).
75    Secondary,
76}
77
78/// One parent link of a clip, for the later lineage graph store.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct Edge {
81    /// The parent clip id, normalised (`m_` stripped, sentinel dropped).
82    pub parent_id: String,
83    /// How the clip relates to this parent.
84    pub edge_type: EdgeType,
85    /// Whether this is the primary parent or a secondary source.
86    pub role: EdgeRole,
87    /// Position within its role (0 for the primary, then secondaries in order).
88    pub ordinal: u32,
89    /// The metadata field this parent id was read from.
90    pub source_field: &'static str,
91}
92
93/// The outcome of resolving a clip's root ancestor.
94#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(rename_all = "snake_case")]
96pub enum ResolveStatus {
97    /// Resolution stopped at an ancestor outside the index (gap-fill budget
98    /// exhausted, or the API reported it has no parent of its own).
99    External,
100    /// The root could not be determined within the hop cap.
101    Unresolved,
102    /// A cycle was detected while walking (pathological data).
103    Cycle,
104    /// The root was reached: a clip present in the index with no parent.
105    /// Also the fallback for any unknown future status slug.
106    #[default]
107    #[serde(other)]
108    Resolved,
109}
110
111/// The resolved root ancestor of a clip.
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct RootInfo {
114    /// The root (or boundary) ancestor id.
115    pub root_id: String,
116    /// The root clip's title, if it is present in the index (else empty).
117    pub root_title: String,
118    /// How resolution terminated.
119    pub status: ResolveStatus,
120}
121
122/// The outcome of [`resolve_roots`](crate::resolve_roots): a root for every input clip, plus the
123/// ancestor clips fetched to bridge gaps.
124///
125/// `gap_filled` is kept structurally separate from `roots` on purpose. Those
126/// ancestors (often trashed) exist only so lineage could be walked; a later
127/// phase persists them to the graph store so a trashed ancestor is archived
128/// before Suno's purge, but they must never be treated as download candidates.
129#[derive(Debug, Clone, PartialEq)]
130pub struct Resolution {
131    /// The resolved root for every clip passed to [`resolve_roots`](crate::resolve_roots), keyed by
132    /// clip id.
133    pub roots: HashMap<String, RootInfo>,
134    /// Ancestor clips fetched during gap-fill, sorted by id. Not download
135    /// candidates: they were pulled solely to complete the lineage walk.
136    pub gap_filled: Vec<Clip>,
137    /// Parent links discovered via the parent endpoint (`get_clip_parent`) as
138    /// `(child_id, parent_id)`, sorted. The child is a bridged id that may have
139    /// no clip of its own, so it is persisted as an archived edge (never a
140    /// download candidate) to keep the parent-endpoint hop durable.
141    pub bridges: Vec<(String, String)>,
142}
143
144/// The resolved lineage of a single clip, threaded into naming, tagging, and
145/// change detection.
146///
147/// The bridge between the pure resolver ([`Resolution`]) and the parts of the
148/// engine that turn a clip into files: it carries exactly the resolved values
149/// embedded in a path or tag (the folder root, the immediate parent and its
150/// edge), so those consumers never re-read raw feed fields.
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct LineageContext {
153    /// The resolved root ancestor id (the clip's own id when it is a root).
154    pub root_id: String,
155    /// The root ancestor's title (empty when the root is outside the index).
156    ///
157    /// Built via the lineage store ([`context_for`]/[`album_for_id`]), this
158    /// carries the *effective* album title, so a manual override supplants the
159    /// derived title here and the path, `ALBUM` tag, and change hash all reflect
160    /// it from one source. Store-less contexts ([`own_root`]) carry the raw
161    /// title.
162    ///
163    /// [`context_for`]: crate::LineageStore::context_for
164    /// [`album_for_id`]: crate::LineageStore::album_for_id
165    /// [`own_root`]: LineageContext::own_root
166    pub root_title: String,
167    /// The root ancestor's creation timestamp (its raw `created_at`), or empty
168    /// when the root is outside the index.
169    ///
170    /// Surfaced so the Year tag groups an album under its lineage root's year: a
171    /// later revision crossing a calendar boundary still carries the root's year.
172    /// Store-less contexts ([`own_root`]/[`for_clip`]) carry the clip's own
173    /// `created_at`, so [`year`] then falls back to the clip's own year.
174    ///
175    /// [`own_root`]: LineageContext::own_root
176    /// [`for_clip`]: LineageContext::for_clip
177    /// [`year`]: LineageContext::year
178    pub root_date: String,
179    /// The immediate parent id ([`immediate_parent`]); empty for a root.
180    pub parent_id: String,
181    /// How the clip derives from its parent; `None` for a root.
182    pub edge_type: Option<EdgeType>,
183    /// How root resolution terminated.
184    pub status: ResolveStatus,
185    /// This clip's 1-based position within its lineage album, or `0` when
186    /// unnumbered. Assigned album-wide by
187    /// [`assign_track_numbers`](crate::assign_track_numbers); every constructor
188    /// here leaves it `0`, so a lone or fallback context is never numbered.
189    pub track: u32,
190    /// The album's track count paired with [`track`](Self::track), or `0` when
191    /// unnumbered.
192    pub track_total: u32,
193}
194
195impl LineageContext {
196    /// Build the context for `clip` from a whole-library [`Resolution`].
197    ///
198    /// Root id/title/status come from `resolution.roots[clip.id]`; when the clip
199    /// is absent (it was not part of the resolved set) it is treated as its own
200    /// resolved root. The parent id and edge come from [`immediate_parent`],
201    /// which is empty/`None` for a root. `root_date` is the clip's own
202    /// `created_at`: this store-less path has no window onto the root's date, so
203    /// [`year`](Self::year) falls back to the clip's own year.
204    pub fn for_clip(clip: &Clip, resolution: &Resolution) -> LineageContext {
205        let (root_id, root_title, status) = match resolution.roots.get(&clip.id) {
206            Some(info) => (info.root_id.clone(), info.root_title.clone(), info.status),
207            None => (clip.id.clone(), clip.title.clone(), ResolveStatus::Resolved),
208        };
209        let (parent_id, edge_type) = match immediate_parent(clip) {
210            Some((id, edge)) => (id, Some(edge)),
211            None => (String::new(), None),
212        };
213        LineageContext {
214            root_id,
215            root_title,
216            root_date: clip.created_at.clone(),
217            parent_id,
218            edge_type,
219            status,
220            track: 0,
221            track_total: 0,
222        }
223    }
224
225    /// A self-rooted context for `clip`: it is treated as its own resolved root
226    /// with no parent. Used as a defensive fallback where a resolved context is
227    /// unavailable (a clip absent from the current desired set). `root_date` is
228    /// the clip's own `created_at`, so it tags its own year.
229    pub fn own_root(clip: &Clip) -> LineageContext {
230        LineageContext {
231            root_id: clip.id.clone(),
232            root_title: clip.title.clone(),
233            root_date: clip.created_at.clone(),
234            parent_id: String::new(),
235            edge_type: None,
236            status: ResolveStatus::Resolved,
237            track: 0,
238            track_total: 0,
239        }
240    }
241
242    /// The album the clip folders under: the root ancestor's title when it is a
243    /// real, different root, otherwise `own_title`.
244    ///
245    /// A root (or an unresolved clip whose root title is empty, or a clip whose
246    /// root shares its title) folders under its own title; only a resolved,
247    /// differently-titled ancestor pulls the clip into the ancestor's album.
248    pub fn album(&self, own_title: &str) -> String {
249        let root_title = self.root_title.trim();
250        if !root_title.is_empty() && self.root_title != own_title {
251            self.root_title.clone()
252        } else {
253            own_title.to_owned()
254        }
255    }
256
257    /// The album's release year: the lineage root's creation year when known,
258    /// otherwise `own_created_at`'s year.
259    ///
260    /// The root anchors the year so an album whose tracks straddle a calendar
261    /// boundary (a December root with a January revision) groups under one year,
262    /// mirroring how [`album`](Self::album) anchors the folder on the root's
263    /// title. A root uses its own year; the fallback covers a root whose date is
264    /// outside the index.
265    pub fn year(&self, own_created_at: &str) -> String {
266        let root_year = year_of(&self.root_date);
267        if root_year.is_empty() {
268            year_of(own_created_at)
269        } else {
270            root_year
271        }
272    }
273}
274
275/// The 4-digit calendar year prefix of an ISO-8601 `created_at`, or empty when
276/// `created_at` is empty.
277fn year_of(created_at: &str) -> String {
278    created_at.chars().take(4).collect()
279}
280
281/// Classify a clip's relationship to its parent, purely from its structure.
282///
283/// Inspects only `task`, `type`, and the pointer fields; never `is_remix`.
284/// Returns `None` for a clip with no parent (a root, original, or upload). The
285/// first matching rule wins, so more specific operations take precedence over
286/// the generic `Derived` fallback.
287///
288/// A stitch is keyed on `type == "concat"` alone, never on a non-empty
289/// `concat_history`: Suno copies a parent's `concat_history` verbatim onto
290/// clips derived from a stitched track, so a cover or remaster *of* a stitch
291/// still carries it. Keying on the type keeps those classified by their own
292/// operation (and parented through their own pointer) instead of the stitch.
293pub fn edge_type(clip: &Clip) -> Option<EdgeType> {
294    let task = clip.task.as_str();
295    let clip_type = clip.clip_type.as_str();
296
297    if task == "infill" || task == "fixed_infill" {
298        Some(EdgeType::SectionReplace)
299    } else if task == "extend" {
300        Some(EdgeType::Extend)
301    } else if clip_type == "concat" {
302        Some(EdgeType::Stitch)
303    } else if clip_type == "edit_speed" {
304        Some(EdgeType::SpeedEdit)
305    } else if task == "cover" {
306        Some(EdgeType::Cover)
307    } else if clip_type == "upsample" || task == "upsample" {
308        Some(EdgeType::Remaster)
309    } else if clip_type == "edit_v3_export" {
310        Some(EdgeType::Edit)
311    } else if normalise_id(&clip.edited_clip_id).is_some() {
312        Some(EdgeType::Derived)
313    } else {
314        None
315    }
316}
317
318/// The clip's primary parent id and the edge that links them.
319///
320/// Applies the same precedence as [`edge_type`], then reads the parent pointer
321/// appropriate to that operation, falling through per-op candidates in order.
322/// Every id is normalised (a leading `m_` stripped, an empty or all-zero
323/// sentinel treated as absent). Returns `None` for a root or when no usable
324/// parent id is present.
325pub fn immediate_parent(clip: &Clip) -> Option<(String, EdgeType)> {
326    primary_parent(clip).map(|(id, edge, _field)| (id, edge))
327}
328
329/// Every parent link of a clip: the primary parent plus any secondaries.
330///
331/// The primary edge (from [`immediate_parent`]) is `Primary` with ordinal 0,
332/// when a primary parent id is present. A stitch also records
333/// `concat_history[1..]` as `Secondary` sources, and a section replace records
334/// its `override_future_clip_id` (when distinct) as a `Secondary`. When the
335/// primary pointer is absent but secondaries remain (for example a stitch whose
336/// base segment id is empty), the secondaries are still emitted with their own
337/// ordinals. All ids are normalised. A clip with no parent operation yields an
338/// empty vector.
339pub fn lineage_edges(clip: &Clip) -> Vec<Edge> {
340    let Some(edge_type) = edge_type(clip) else {
341        return Vec::new();
342    };
343
344    let mut edges = Vec::new();
345    if let Some((parent_id, _edge, source_field)) = primary_parent(clip) {
346        edges.push(Edge {
347            parent_id,
348            edge_type,
349            role: EdgeRole::Primary,
350            ordinal: 0,
351            source_field,
352        });
353    }
354
355    match edge_type {
356        EdgeType::Stitch => {
357            for (ordinal, entry) in clip.concat_history.iter().enumerate().skip(1) {
358                if let Some(id) = normalise_id(&entry.id) {
359                    edges.push(Edge {
360                        parent_id: id,
361                        edge_type,
362                        role: EdgeRole::Secondary,
363                        ordinal: ordinal as u32,
364                        source_field: "concat_history",
365                    });
366                }
367            }
368        }
369        EdgeType::SectionReplace => {
370            if let Some(future) = normalise_id(&clip.override_future_clip_id)
371                && edges
372                    .first()
373                    .is_none_or(|primary| primary.parent_id != future)
374            {
375                edges.push(Edge {
376                    parent_id: future,
377                    edge_type,
378                    role: EdgeRole::Secondary,
379                    ordinal: 1,
380                    source_field: "override_future_clip_id",
381                });
382            }
383        }
384        _ => {}
385    }
386
387    edges
388}
389
390/// An attribution edge derived from a clip's nested `clip_roots` list.
391///
392/// This is informational lineage the API states directly (the clip was remixed
393/// from these roots), NOT a structural parent. It is deliberately kept apart
394/// from the [`EdgeType`]-classified structural [`Edge`]s: it is NEVER read by
395/// [`immediate_parent`], [`primary_parent`], [`lineage_edges`], or the root
396/// walk in [`crate::roots`]. It feeds only the durable graph store (as a
397/// secondary edge carrying the open attribution slug) and, for a same-owner
398/// root, a bounded gap-fill seed. It can never fabricate a structural parent,
399/// an external boundary, or a download candidate.
400#[derive(Debug, Clone, PartialEq, Eq)]
401pub struct AttributionEdge {
402    /// The root clip id, normalised (`m_` stripped, sentinel dropped).
403    pub parent_id: String,
404    /// The raw attribution slug from `clip_attribution_type` (open, e.g.
405    /// `"remix"`); normalisation to a stored form happens at the graph layer,
406    /// never against the closed [`EdgeType`].
407    pub edge_slug: String,
408    /// Always [`EdgeRole::Secondary`]: attribution never supplants a clip's
409    /// structural primary parent.
410    pub role: EdgeRole,
411    /// Position within the clip's `clip_roots.clips[]` list (0..N).
412    pub ordinal: u32,
413    /// The field these came from (always `"clip_roots"`).
414    pub source_field: &'static str,
415    /// Whether the root shares the clip's owner handle (fail-closed): only a
416    /// same-owner root is ever gap-fill seeded.
417    pub same_owner: bool,
418}
419
420/// Every attribution edge from a clip's `clip_roots` (empty when absent).
421///
422/// One edge per root with a usable id, in list order. Emitted for EVERY root
423/// regardless of owner: a foreign-owned root still records its attribution. The
424/// `same_owner` flag gates only the later gap-fill seed, never the edge itself.
425pub fn attribution_edges(clip: &Clip) -> Vec<AttributionEdge> {
426    let mut edges = Vec::new();
427    for root in &clip.clip_roots {
428        let Some(parent_id) = normalise_id(&root.id) else {
429            continue;
430        };
431        let ordinal = edges.len() as u32;
432        edges.push(AttributionEdge {
433            parent_id,
434            edge_slug: clip.clip_attribution_type.clone(),
435            role: EdgeRole::Secondary,
436            ordinal,
437            source_field: "clip_roots",
438            same_owner: same_owner(clip, root),
439        });
440    }
441    edges
442}
443
444/// Whether a `clip_root` shares the clip's owner, by handle equality.
445///
446/// Fail-closed: an empty or missing handle on either side is a foreign owner,
447/// so a rename or an absent identity never folds a foreign remix source into
448/// the owner's album via the gap-fill seed. Never relax to substring or prefix
449/// matching.
450fn same_owner(clip: &Clip, root: &crate::model::ClipRoot) -> bool {
451    let clip_handle = clip.handle.trim();
452    let root_handle = root.handle.trim();
453    !clip_handle.is_empty() && !root_handle.is_empty() && clip_handle == root_handle
454}
455
456/// The clip's primary parent id, edge type, and the source field it came from.
457///
458/// Shared by [`immediate_parent`] and [`lineage_edges`] so the two never drift.
459fn primary_parent(clip: &Clip) -> Option<(String, EdgeType, &'static str)> {
460    let edge = edge_type(clip)?;
461    let history_head = clip.history.first().map_or("", |entry| entry.id.as_str());
462    let concat_head = clip
463        .concat_history
464        .first()
465        .map_or("", |entry| entry.id.as_str());
466
467    let candidates: Vec<(&str, &'static str)> = match edge {
468        EdgeType::SectionReplace => vec![
469            (
470                clip.override_history_clip_id.as_str(),
471                "override_history_clip_id",
472            ),
473            (
474                clip.override_future_clip_id.as_str(),
475                "override_future_clip_id",
476            ),
477            (history_head, "history"),
478            (clip.edited_clip_id.as_str(), "edited_clip_id"),
479        ],
480        EdgeType::Extend => vec![
481            (history_head, "history"),
482            (clip.edited_clip_id.as_str(), "edited_clip_id"),
483        ],
484        EdgeType::Stitch => vec![
485            (concat_head, "concat_history"),
486            (clip.edited_clip_id.as_str(), "edited_clip_id"),
487        ],
488        EdgeType::SpeedEdit => vec![
489            (clip.speed_clip_id.as_str(), "speed_clip_id"),
490            (clip.edited_clip_id.as_str(), "edited_clip_id"),
491        ],
492        EdgeType::Cover => vec![
493            (clip.cover_clip_id.as_str(), "cover_clip_id"),
494            (clip.edited_clip_id.as_str(), "edited_clip_id"),
495        ],
496        EdgeType::Remaster => vec![
497            (clip.upsample_clip_id.as_str(), "upsample_clip_id"),
498            (clip.remaster_clip_id.as_str(), "remaster_clip_id"),
499            (clip.edited_clip_id.as_str(), "edited_clip_id"),
500        ],
501        EdgeType::Edit | EdgeType::Derived => {
502            vec![(clip.edited_clip_id.as_str(), "edited_clip_id")]
503        }
504        EdgeType::Uploaded => vec![],
505    };
506
507    candidates
508        .into_iter()
509        .find_map(|(value, field)| normalise_id(value).map(|id| (id, edge, field)))
510}
511
512/// Normalise a raw pointer id: strip a leading `m_`, and treat an empty or
513/// all-zero sentinel value as absent.
514fn normalise_id(id: &str) -> Option<String> {
515    let id = id.strip_prefix("m_").unwrap_or(id);
516    if id.is_empty() || id == ZERO_UUID {
517        None
518    } else {
519        Some(id.to_string())
520    }
521}
522
523#[cfg(test)]
524mod tests;