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;