Skip to main content

suno_core/
lineage.rs

1//! Pure lineage resolution: classify a clip's parent edge and walk a library
2//! back to its root ancestor.
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`], the full set of
8//! parent [`Edge`]s for the later graph store ([`lineage_edges`]), and a
9//! root-ancestor map for a whole library ([`resolve_roots`]).
10//!
11//! Classification is deliberately blind to `is_remix`: that flag is a UI hint,
12//! not a structural fact, so it never changes an edge. All resolution stays
13//! pure; the only IO is the [`Http`] port reached through [`SunoClient`], used
14//! solely to gap-fill ancestors that are missing from the caller's listing.
15
16use std::collections::{HashMap, HashSet};
17
18use crate::client::SunoClient;
19use crate::clock::Clock;
20use crate::error::Result;
21use crate::http::Http;
22use crate::model::Clip;
23
24/// The all-zero UUID Suno uses as a "no clip" sentinel in pointer fields.
25const ZERO_UUID: &str = "00000000-0000-0000-0000-000000000000";
26
27/// How one clip relates to its immediate parent.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum EdgeType {
30    /// A cover: re-performed audio from a source clip.
31    Cover,
32    /// A remaster or upsample to higher fidelity.
33    Remaster,
34    /// A playback-speed edit.
35    SpeedEdit,
36    /// A studio edit export.
37    Edit,
38    /// An extension appended after a source clip.
39    Extend,
40    /// A section (infill) replaced within a source clip.
41    SectionReplace,
42    /// A stitch (concatenation) of two or more segments.
43    Stitch,
44    /// A derived clip with a parent pointer but no more specific marker.
45    Derived,
46    /// An external upload with no Suno parent.
47    Uploaded,
48}
49
50impl EdgeType {
51    /// A human label describing the relationship to the parent.
52    pub fn label(self) -> &'static str {
53        match self {
54            EdgeType::Cover => "Cover of",
55            EdgeType::Remaster => "Remaster of",
56            EdgeType::SpeedEdit => "Speed-edited from",
57            EdgeType::Edit => "Edited from",
58            EdgeType::Extend => "Extended from",
59            EdgeType::SectionReplace => "Section replaced from",
60            EdgeType::Stitch => "Stitched from",
61            EdgeType::Derived => "Derived from",
62            EdgeType::Uploaded => "Uploaded",
63        }
64    }
65}
66
67/// Whether an [`Edge`] is the clip's primary parent or a supporting one.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum EdgeRole {
70    /// The single lineage parent used for root resolution and album grouping.
71    Primary,
72    /// An additional source (an extra stitch segment, an infill's future half).
73    Secondary,
74}
75
76/// One parent link of a clip, for the later lineage graph store.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct Edge {
79    /// The parent clip id, normalised (`m_` stripped, sentinel dropped).
80    pub parent_id: String,
81    /// How the clip relates to this parent.
82    pub edge_type: EdgeType,
83    /// Whether this is the primary parent or a secondary source.
84    pub role: EdgeRole,
85    /// Position within its role (0 for the primary, then secondaries in order).
86    pub ordinal: u32,
87    /// The metadata field this parent id was read from.
88    pub source_field: &'static str,
89}
90
91/// Tunables bounding how hard [`resolve_roots`] works per call.
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub struct ResolveOpts {
94    /// Maximum number of missing ancestor ids to fetch from the network.
95    pub max_gap_fills: u32,
96    /// Maximum hops to walk up a single chain before giving up.
97    pub hop_cap: u32,
98}
99
100impl Default for ResolveOpts {
101    fn default() -> Self {
102        Self {
103            max_gap_fills: 200,
104            hop_cap: 64,
105        }
106    }
107}
108
109/// The outcome of resolving a clip's root ancestor.
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum ResolveStatus {
112    /// The root was reached: a clip present in the index with no parent.
113    Resolved,
114    /// Resolution stopped at an ancestor outside the index (gap-fill budget
115    /// exhausted, or the API reported it has no parent of its own).
116    External,
117    /// The root could not be determined within the hop cap.
118    Unresolved,
119    /// A cycle was detected while walking (pathological data).
120    Cycle,
121}
122
123/// The resolved root ancestor of a clip.
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct RootInfo {
126    /// The root (or boundary) ancestor id.
127    pub root_id: String,
128    /// The root clip's title, if it is present in the index (else empty).
129    pub root_title: String,
130    /// How resolution terminated.
131    pub status: ResolveStatus,
132}
133
134/// The outcome of [`resolve_roots`]: a root for every input clip, plus the
135/// ancestor clips fetched to bridge gaps.
136///
137/// `gap_filled` is kept structurally separate from `roots` on purpose. Those
138/// ancestors (often trashed) exist only so lineage could be walked; a later
139/// phase persists them to the graph store so a trashed ancestor is archived
140/// before Suno's purge, but they must never be treated as download candidates.
141#[derive(Debug, Clone, PartialEq)]
142pub struct Resolution {
143    /// The resolved root for every clip passed to [`resolve_roots`], keyed by
144    /// clip id.
145    pub roots: HashMap<String, RootInfo>,
146    /// Ancestor clips fetched during gap-fill, sorted by id. Not download
147    /// candidates: they were pulled solely to complete the lineage walk.
148    pub gap_filled: Vec<Clip>,
149}
150
151/// The resolved lineage of a single clip, threaded into naming, tagging, and
152/// change detection.
153///
154/// This is the bridge between the pure resolver ([`Resolution`]) and the parts
155/// of the engine that turn a clip into files: it carries exactly the resolved
156/// values that get embedded in a path or a tag (the root the clip folders
157/// under, the immediate parent and how it derives from it), so those consumers
158/// never re-read the now-defunct `root_ancestor_id`/`album_title` feed fields.
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct LineageContext {
161    /// The resolved root ancestor id (the clip's own id when it is a root).
162    pub root_id: String,
163    /// The root ancestor's title (empty when the root is outside the index).
164    ///
165    /// When built via the lineage store ([`context_for`]/[`album_for_id`]) this
166    /// carries the *effective* album title: a manual override supplants the
167    /// derived title here, so the folder path, `ALBUM` tag, and change hash all
168    /// reflect it from one source. Contexts built without the store (e.g.
169    /// [`own_root`]) carry the raw title.
170    ///
171    /// [`context_for`]: crate::LineageStore::context_for
172    /// [`album_for_id`]: crate::LineageStore::album_for_id
173    /// [`own_root`]: LineageContext::own_root
174    pub root_title: String,
175    /// The root ancestor's creation timestamp (its raw `created_at`), or empty
176    /// when the root is outside the index.
177    ///
178    /// Surfaced so the Year tag can group an album under its lineage root's
179    /// year: a later revision that crosses a calendar boundary still carries the
180    /// root's year. Contexts built without the store ([`own_root`]/[`for_clip`])
181    /// carry the clip's own `created_at`, so [`year`] falls back to the clip's
182    /// own year when the root's date is unavailable.
183    ///
184    /// [`own_root`]: LineageContext::own_root
185    /// [`for_clip`]: LineageContext::for_clip
186    /// [`year`]: LineageContext::year
187    pub root_date: String,
188    /// The immediate parent id ([`immediate_parent`]); empty for a root.
189    pub parent_id: String,
190    /// How the clip derives from its parent; `None` for a root.
191    pub edge_type: Option<EdgeType>,
192    /// How root resolution terminated.
193    pub status: ResolveStatus,
194}
195
196impl LineageContext {
197    /// Build the context for `clip` from a whole-library [`Resolution`].
198    ///
199    /// Root id/title/status come from `resolution.roots[clip.id]`; when the clip
200    /// is absent (it was not part of the resolved set) it is treated as its own
201    /// resolved root. The parent id and edge come from [`immediate_parent`],
202    /// which is empty/`None` for a root. `root_date` is the clip's own
203    /// `created_at`: this store-less path has no window onto the root's date, so
204    /// [`year`](Self::year) falls back to the clip's own year.
205    pub fn for_clip(clip: &Clip, resolution: &Resolution) -> LineageContext {
206        let (root_id, root_title, status) = match resolution.roots.get(&clip.id) {
207            Some(info) => (info.root_id.clone(), info.root_title.clone(), info.status),
208            None => (clip.id.clone(), clip.title.clone(), ResolveStatus::Resolved),
209        };
210        let (parent_id, edge_type) = match immediate_parent(clip) {
211            Some((id, edge)) => (id, Some(edge)),
212            None => (String::new(), None),
213        };
214        LineageContext {
215            root_id,
216            root_title,
217            root_date: clip.created_at.clone(),
218            parent_id,
219            edge_type,
220            status,
221        }
222    }
223
224    /// A self-rooted context for `clip`: it is treated as its own resolved root
225    /// with no parent. Used as a defensive fallback where a resolved context is
226    /// unavailable (a clip absent from the current desired set). `root_date` is
227    /// the clip's own `created_at`, so it tags its own year.
228    pub fn own_root(clip: &Clip) -> LineageContext {
229        LineageContext {
230            root_id: clip.id.clone(),
231            root_title: clip.title.clone(),
232            root_date: clip.created_at.clone(),
233            parent_id: String::new(),
234            edge_type: None,
235            status: ResolveStatus::Resolved,
236        }
237    }
238
239    /// The album the clip folders under: the root ancestor's title when it is a
240    /// real, different root, otherwise `own_title`.
241    ///
242    /// A root (or an unresolved clip whose root title is empty, or a clip whose
243    /// root shares its title) folders under its own title; only a resolved,
244    /// differently-titled ancestor pulls the clip into the ancestor's album.
245    pub fn album(&self, own_title: &str) -> String {
246        let root_title = self.root_title.trim();
247        if !root_title.is_empty() && self.root_title != own_title {
248            self.root_title.clone()
249        } else {
250            own_title.to_owned()
251        }
252    }
253
254    /// The album's release year: the lineage root's creation year when known,
255    /// otherwise `own_created_at`'s year.
256    ///
257    /// The root anchors the year so an album whose tracks straddle a calendar
258    /// boundary (a December root with a January revision) groups under one year,
259    /// mirroring how [`album`](Self::album) anchors the folder on the root's
260    /// title. A root uses its own year; the fallback covers a root whose date is
261    /// outside the index.
262    pub fn year(&self, own_created_at: &str) -> String {
263        let root_year = year_of(&self.root_date);
264        if root_year.is_empty() {
265            year_of(own_created_at)
266        } else {
267            root_year
268        }
269    }
270}
271
272/// The 4-digit calendar year prefix of an ISO-8601 `created_at`, or empty when
273/// `created_at` is empty.
274fn year_of(created_at: &str) -> String {
275    created_at.chars().take(4).collect()
276}
277
278/// Classify a clip's relationship to its parent, purely from its structure.
279///
280/// Inspects only `task`, `type`, and the pointer fields; never `is_remix`.
281/// Returns `None` for a clip with no parent (a root, original, or upload). The
282/// first matching rule wins, so more specific operations take precedence over
283/// the generic `Derived` fallback.
284///
285/// A stitch is keyed on `type == "concat"` alone, never on a non-empty
286/// `concat_history`: Suno copies a parent's `concat_history` verbatim onto
287/// clips derived from a stitched track, so a cover or remaster *of* a stitch
288/// still carries it. Keying on the type keeps those classified by their own
289/// operation (and parented through their own pointer) instead of the stitch.
290pub fn edge_type(clip: &Clip) -> Option<EdgeType> {
291    let task = clip.task.as_str();
292    let clip_type = clip.clip_type.as_str();
293
294    if task == "infill" || task == "fixed_infill" {
295        Some(EdgeType::SectionReplace)
296    } else if task == "extend" {
297        Some(EdgeType::Extend)
298    } else if clip_type == "concat" {
299        Some(EdgeType::Stitch)
300    } else if clip_type == "edit_speed" {
301        Some(EdgeType::SpeedEdit)
302    } else if task == "cover" {
303        Some(EdgeType::Cover)
304    } else if clip_type == "upsample" || task == "upsample" {
305        Some(EdgeType::Remaster)
306    } else if clip_type == "edit_v3_export" {
307        Some(EdgeType::Edit)
308    } else if normalise_id(&clip.edited_clip_id).is_some() {
309        Some(EdgeType::Derived)
310    } else {
311        None
312    }
313}
314
315/// The clip's primary parent id and the edge that links them.
316///
317/// Applies the same precedence as [`edge_type`], then reads the parent pointer
318/// appropriate to that operation, falling through per-op candidates in order.
319/// Every id is normalised (a leading `m_` stripped, an empty or all-zero
320/// sentinel treated as absent). Returns `None` for a root or when no usable
321/// parent id is present.
322pub fn immediate_parent(clip: &Clip) -> Option<(String, EdgeType)> {
323    primary_parent(clip).map(|(id, edge, _field)| (id, edge))
324}
325
326/// Every parent link of a clip: the primary parent plus any secondaries.
327///
328/// The primary edge (from [`immediate_parent`]) is `Primary` with ordinal 0,
329/// when a primary parent id is present. A stitch also records
330/// `concat_history[1..]` as `Secondary` sources, and a section replace records
331/// its `override_future_clip_id` (when distinct) as a `Secondary`. When the
332/// primary pointer is absent but secondaries remain (for example a stitch whose
333/// base segment id is empty), the secondaries are still emitted with their own
334/// ordinals. All ids are normalised. A clip with no parent operation yields an
335/// empty vector.
336pub fn lineage_edges(clip: &Clip) -> Vec<Edge> {
337    let Some(edge_type) = edge_type(clip) else {
338        return Vec::new();
339    };
340
341    let mut edges = Vec::new();
342    if let Some((parent_id, _edge, source_field)) = primary_parent(clip) {
343        edges.push(Edge {
344            parent_id,
345            edge_type,
346            role: EdgeRole::Primary,
347            ordinal: 0,
348            source_field,
349        });
350    }
351
352    match edge_type {
353        EdgeType::Stitch => {
354            for (ordinal, entry) in clip.concat_history.iter().enumerate().skip(1) {
355                if let Some(id) = normalise_id(&entry.id) {
356                    edges.push(Edge {
357                        parent_id: id,
358                        edge_type,
359                        role: EdgeRole::Secondary,
360                        ordinal: ordinal as u32,
361                        source_field: "concat_history",
362                    });
363                }
364            }
365        }
366        EdgeType::SectionReplace => {
367            if let Some(future) = normalise_id(&clip.override_future_clip_id)
368                && edges
369                    .first()
370                    .is_none_or(|primary| primary.parent_id != future)
371            {
372                edges.push(Edge {
373                    parent_id: future,
374                    edge_type,
375                    role: EdgeRole::Secondary,
376                    ordinal: 1,
377                    source_field: "override_future_clip_id",
378                });
379            }
380        }
381        _ => {}
382    }
383
384    edges
385}
386
387/// Resolve the root ancestor of every clip in `clips`.
388///
389/// Walks each clip up its [`immediate_parent`] chain to a root. Chains that
390/// stay within `clips` resolve with no network access. When a parent is absent
391/// from the index it is gap-filled: missing ids are fetched in a batch through
392/// [`SunoClient::get_clips_by_ids`], and any id that cannot be retrieved that
393/// way falls back to [`SunoClient::get_clip_parent`], which yields one ancestor
394/// hop to keep walking (never assumed to be the absolute root).
395///
396/// Gap-filled clips (which may be trashed) are held in an index that is kept
397/// structurally separate from the caller's `clips`; they exist only to resolve
398/// ancestry and must never be treated as download candidates by later phases.
399///
400/// Bounded by [`ResolveOpts`]: at most `max_gap_fills` ancestor ids are fetched
401/// (exhaustion yields [`ResolveStatus::External`] at the last reachable
402/// ancestor), and each chain walks at most `hop_cap` hops. A cycle yields
403/// [`ResolveStatus::Cycle`]. The returned [`Resolution`] has a root entry for
404/// every input clip, plus the gap-filled ancestor clips it fetched.
405pub async fn resolve_roots(
406    clips: &[Clip],
407    client: &mut SunoClient<impl Clock>,
408    http: &impl Http,
409    opts: ResolveOpts,
410) -> Result<Resolution> {
411    let mut resolver = Resolver::new(clips, opts);
412    resolver.run(client, http).await?;
413    Ok(resolver.into_resolution(clips))
414}
415
416/// The clip's primary parent id, edge type, and the source field it came from.
417///
418/// Shared by [`immediate_parent`] and [`lineage_edges`] so the two never drift.
419fn primary_parent(clip: &Clip) -> Option<(String, EdgeType, &'static str)> {
420    let edge = edge_type(clip)?;
421    let history_head = clip.history.first().map_or("", |entry| entry.id.as_str());
422    let concat_head = clip
423        .concat_history
424        .first()
425        .map_or("", |entry| entry.id.as_str());
426
427    let candidates: Vec<(&str, &'static str)> = match edge {
428        EdgeType::SectionReplace => vec![
429            (
430                clip.override_history_clip_id.as_str(),
431                "override_history_clip_id",
432            ),
433            (
434                clip.override_future_clip_id.as_str(),
435                "override_future_clip_id",
436            ),
437            (history_head, "history"),
438            (clip.edited_clip_id.as_str(), "edited_clip_id"),
439        ],
440        EdgeType::Extend => vec![
441            (history_head, "history"),
442            (clip.edited_clip_id.as_str(), "edited_clip_id"),
443        ],
444        EdgeType::Stitch => vec![
445            (concat_head, "concat_history"),
446            (clip.edited_clip_id.as_str(), "edited_clip_id"),
447        ],
448        EdgeType::SpeedEdit => vec![
449            (clip.speed_clip_id.as_str(), "speed_clip_id"),
450            (clip.edited_clip_id.as_str(), "edited_clip_id"),
451        ],
452        EdgeType::Cover => vec![
453            (clip.cover_clip_id.as_str(), "cover_clip_id"),
454            (clip.edited_clip_id.as_str(), "edited_clip_id"),
455        ],
456        EdgeType::Remaster => vec![
457            (clip.upsample_clip_id.as_str(), "upsample_clip_id"),
458            (clip.remaster_clip_id.as_str(), "remaster_clip_id"),
459            (clip.edited_clip_id.as_str(), "edited_clip_id"),
460        ],
461        EdgeType::Edit | EdgeType::Derived => {
462            vec![(clip.edited_clip_id.as_str(), "edited_clip_id")]
463        }
464        EdgeType::Uploaded => vec![],
465    };
466
467    candidates
468        .into_iter()
469        .find_map(|(value, field)| normalise_id(value).map(|id| (id, edge, field)))
470}
471
472/// Normalise a raw pointer id: strip a leading `m_`, and treat an empty or
473/// all-zero sentinel value as absent.
474fn normalise_id(id: &str) -> Option<String> {
475    let id = id.strip_prefix("m_").unwrap_or(id);
476    if id.is_empty() || id == ZERO_UUID {
477        None
478    } else {
479        Some(id.to_string())
480    }
481}
482
483/// The result of walking one chain as far as the current index allows.
484enum Walk {
485    /// The start clip's root is now recorded in the memo.
486    Resolved,
487    /// The walk stalled needing this ancestor id gap-filled.
488    Blocked(String),
489}
490
491/// Working state for one [`resolve_roots`] call.
492///
493/// `index` holds the input clips plus any gap-filled ancestors so the walk can
494/// read their pointers; `gap_filled` records which ids were fetched here so
495/// later phases can tell ancestors apart from download candidates. `bridges`
496/// maps a missing id to the known parent that the parent endpoint returned in
497/// its place, and `external` records ids the API reported as parentless roots.
498struct Resolver {
499    index: HashMap<String, Clip>,
500    gap_filled: HashSet<String>,
501    bridges: HashMap<String, String>,
502    external: HashSet<String>,
503    memo: HashMap<String, RootInfo>,
504    targets: Vec<String>,
505    budget: u32,
506    hop_cap: u32,
507}
508
509impl Resolver {
510    fn new(clips: &[Clip], opts: ResolveOpts) -> Self {
511        let index = clips
512            .iter()
513            .map(|clip| (clip.id.clone(), clip.clone()))
514            .collect();
515        let targets = clips.iter().map(|clip| clip.id.clone()).collect();
516        Self {
517            index,
518            gap_filled: HashSet::new(),
519            bridges: HashMap::new(),
520            external: HashSet::new(),
521            memo: HashMap::new(),
522            targets,
523            budget: opts.max_gap_fills,
524            hop_cap: opts.hop_cap,
525        }
526    }
527
528    /// Resolve every target, gap-filling missing ancestors until the whole set
529    /// is settled or the budget runs out.
530    async fn run(&mut self, client: &mut SunoClient<impl Clock>, http: &impl Http) -> Result<()> {
531        let targets = self.targets.clone();
532        loop {
533            let mut frontier: Vec<String> = Vec::new();
534            let mut seen: HashSet<String> = HashSet::new();
535            let mut blocked: Vec<(String, String)> = Vec::new();
536
537            for target in &targets {
538                if self.memo.contains_key(target) {
539                    continue;
540                }
541                if let Walk::Blocked(missing) = self.walk(target) {
542                    if seen.insert(missing.clone()) {
543                        frontier.push(missing.clone());
544                    }
545                    blocked.push((target.clone(), missing));
546                }
547            }
548
549            if blocked.is_empty() {
550                break;
551            }
552            if self.budget == 0 || !self.gap_fill(client, http, &frontier).await? {
553                self.finalise_external(&blocked);
554                break;
555            }
556        }
557        Ok(())
558    }
559
560    /// Walk `start` up its parent chain within the current index, memoising the
561    /// root for every node reached. Returns [`Walk::Blocked`] with the first
562    /// ancestor id that is missing and needs gap-filling.
563    fn walk(&mut self, start: &str) -> Walk {
564        if self.memo.contains_key(start) {
565            return Walk::Resolved;
566        }
567        let mut chain: Vec<String> = Vec::new();
568        let mut visited: HashSet<String> = HashSet::new();
569        let mut current = start.to_string();
570        let mut hops = 0u32;
571
572        loop {
573            if let Some(info) = self.memo.get(&current).cloned() {
574                self.assign(&chain, &info);
575                return Walk::Resolved;
576            }
577            if visited.contains(&current) {
578                let info = self.terminal(&current, ResolveStatus::Cycle);
579                self.assign(&chain, &info);
580                self.memo.insert(current, info);
581                return Walk::Resolved;
582            }
583            if hops >= self.hop_cap {
584                let info = self.terminal(&current, ResolveStatus::Unresolved);
585                self.assign(&chain, &info);
586                self.memo.insert(current, info);
587                return Walk::Resolved;
588            }
589
590            let (parent, title) = match self.index.get(&current) {
591                Some(clip) => (immediate_parent(clip), clip.title.clone()),
592                None => return Walk::Blocked(current),
593            };
594
595            let Some((parent_id, _edge)) = parent else {
596                let info = RootInfo {
597                    root_id: current.clone(),
598                    root_title: title,
599                    status: ResolveStatus::Resolved,
600                };
601                self.assign(&chain, &info);
602                self.memo.insert(current, info);
603                return Walk::Resolved;
604            };
605
606            visited.insert(current.clone());
607            chain.push(current);
608
609            if self.index.contains_key(&parent_id) {
610                current = parent_id;
611            } else if let Some(bridged) = self.bridges.get(&parent_id).cloned() {
612                visited.insert(parent_id);
613                current = bridged;
614            } else if self.external.contains(&parent_id) {
615                let info = self.terminal(&parent_id, ResolveStatus::External);
616                self.assign(&chain, &info);
617                self.memo.insert(parent_id, info);
618                return Walk::Resolved;
619            } else {
620                return Walk::Blocked(parent_id);
621            }
622            hops += 1;
623        }
624    }
625
626    /// Fetch missing `frontier` ancestors, batching by id and falling back to
627    /// the parent endpoint. Returns whether the index (or bridges/externals)
628    /// grew, so the caller can detect a stalled resolution.
629    async fn gap_fill(
630        &mut self,
631        client: &mut SunoClient<impl Clock>,
632        http: &impl Http,
633        frontier: &[String],
634    ) -> Result<bool> {
635        let mut want: Vec<String> = frontier
636            .iter()
637            .filter(|id| !self.known(id))
638            .cloned()
639            .collect();
640        if want.is_empty() {
641            return Ok(false);
642        }
643        want.sort();
644        let take = (self.budget as usize).min(want.len());
645        let batch: Vec<String> = want.into_iter().take(take).collect();
646        self.budget -= batch.len() as u32;
647
648        let refs: Vec<&str> = batch.iter().map(String::as_str).collect();
649        let fetched = client.get_clips_by_ids(http, &refs).await?;
650
651        let mut returned: HashSet<String> = HashSet::new();
652        let mut progressed = false;
653        for clip in fetched {
654            returned.insert(clip.id.clone());
655            if self.insert_ancestor(clip) {
656                progressed = true;
657            }
658        }
659
660        for id in &batch {
661            if returned.contains(id) {
662                continue;
663            }
664            match client.get_clip_parent(http, id).await? {
665                Some(parent) => {
666                    let parent_id = parent.id.clone();
667                    self.insert_ancestor(parent);
668                    self.bridges.insert(id.clone(), parent_id);
669                    progressed = true;
670                }
671                None => {
672                    self.external.insert(id.clone());
673                    progressed = true;
674                }
675            }
676        }
677
678        Ok(progressed)
679    }
680
681    /// Add a gap-filled ancestor to the index, tracking it as an ancestor-only
682    /// clip. Returns whether it was newly added.
683    fn insert_ancestor(&mut self, clip: Clip) -> bool {
684        if clip.id.is_empty() || self.index.contains_key(&clip.id) {
685            return false;
686        }
687        self.gap_filled.insert(clip.id.clone());
688        self.index.insert(clip.id.clone(), clip);
689        true
690    }
691
692    /// Whether an id is already resolvable without another fetch.
693    fn known(&self, id: &str) -> bool {
694        self.index.contains_key(id) || self.bridges.contains_key(id) || self.external.contains(id)
695    }
696
697    /// Mark every still-unresolved blocked target as external at the ancestor it
698    /// stalled on.
699    fn finalise_external(&mut self, blocked: &[(String, String)]) {
700        for (target, missing) in blocked {
701            if self.memo.contains_key(target) {
702                continue;
703            }
704            let info = self.terminal(missing, ResolveStatus::External);
705            self.memo.insert(target.clone(), info);
706        }
707    }
708
709    /// Build a [`RootInfo`] rooted at `id`, titled from the index when present.
710    fn terminal(&self, id: &str, status: ResolveStatus) -> RootInfo {
711        RootInfo {
712            root_id: id.to_string(),
713            root_title: self.title_of(id),
714            status,
715        }
716    }
717
718    /// The title of an indexed clip, or empty when it is not in the index.
719    fn title_of(&self, id: &str) -> String {
720        self.index
721            .get(id)
722            .map_or_else(String::new, |clip| clip.title.clone())
723    }
724
725    /// Record `info` as the root for every node on `chain`.
726    fn assign(&mut self, chain: &[String], info: &RootInfo) {
727        for id in chain {
728            self.memo.insert(id.clone(), info.clone());
729        }
730    }
731
732    /// Project the memo onto the input clips (so every one has a root entry) and
733    /// collect the gap-filled ancestors, sorted by id for a deterministic order.
734    fn into_resolution(self, clips: &[Clip]) -> Resolution {
735        let mut roots = HashMap::with_capacity(clips.len());
736        for clip in clips {
737            let info = self
738                .memo
739                .get(&clip.id)
740                .cloned()
741                .unwrap_or_else(|| RootInfo {
742                    root_id: clip.id.clone(),
743                    root_title: clip.title.clone(),
744                    status: ResolveStatus::Unresolved,
745                });
746            roots.insert(clip.id.clone(), info);
747        }
748
749        let mut gap_filled: Vec<Clip> = self
750            .gap_filled
751            .iter()
752            .filter_map(|id| self.index.get(id).cloned())
753            .collect();
754        gap_filled.sort_by(|a, b| a.id.cmp(&b.id));
755
756        Resolution { roots, gap_filled }
757    }
758}
759
760#[cfg(test)]
761mod tests {
762    use super::*;
763    use crate::auth::ClerkAuth;
764    use crate::model::HistoryEntry;
765    use crate::testutil::{RecordingClock, Reply, ScriptedHttp};
766
767    fn history(id: &str) -> HistoryEntry {
768        HistoryEntry {
769            id: id.to_owned(),
770            ..Default::default()
771        }
772    }
773
774    // A clean six-clip chain modelled on the real `chain1` grounding data:
775    // upsample -> cover -> upsample -> cover -> edit -> root. For every hop the
776    // op pointer and `edited_clip_id` agree, as they do in the live shape.
777    fn chain1_clips() -> Vec<Clip> {
778        vec![
779            Clip {
780                id: "40068b49".into(),
781                title: "Zac and the Sea Eagles (Lullaby Version)".into(),
782                clip_type: "upsample".into(),
783                task: "upsample".into(),
784                is_remix: true,
785                upsample_clip_id: "52962dae".into(),
786                edited_clip_id: "52962dae".into(),
787                ..Default::default()
788            },
789            Clip {
790                id: "52962dae".into(),
791                title: "Zac and the Sea Eagles (Edit) (Remastered)".into(),
792                clip_type: "gen".into(),
793                task: "cover".into(),
794                is_remix: true,
795                cover_clip_id: "536e1b92".into(),
796                edited_clip_id: "536e1b92".into(),
797                ..Default::default()
798            },
799            Clip {
800                id: "536e1b92".into(),
801                title: "Zac and the Sea Eagles (Edit) (Remastered)".into(),
802                clip_type: "upsample".into(),
803                task: "upsample".into(),
804                is_remix: true,
805                upsample_clip_id: "b9f27ee1".into(),
806                edited_clip_id: "b9f27ee1".into(),
807                ..Default::default()
808            },
809            Clip {
810                id: "b9f27ee1".into(),
811                title: "Zac and the Sea Eagles (Edit)".into(),
812                clip_type: "gen".into(),
813                task: "cover".into(),
814                is_remix: true,
815                cover_clip_id: "c1997d52".into(),
816                edited_clip_id: "c1997d52".into(),
817                ..Default::default()
818            },
819            Clip {
820                id: "c1997d52".into(),
821                title: "Zac and the Sea Eagles (Rework)".into(),
822                clip_type: "edit_v3_export".into(),
823                edited_clip_id: "dfb59a04".into(),
824                ..Default::default()
825            },
826            Clip {
827                id: "dfb59a04".into(),
828                title: "Zac and the Sea Eagles".into(),
829                clip_type: "gen".into(),
830                ..Default::default()
831            },
832        ]
833    }
834
835    fn authed_client(http: &ScriptedHttp) -> SunoClient<RecordingClock> {
836        let mut auth = ClerkAuth::new("eyJtoken");
837        pollster::block_on(auth.authenticate(http)).unwrap();
838        SunoClient::new(auth, RecordingClock::new())
839    }
840
841    #[test]
842    fn edge_type_labels_read_naturally() {
843        assert_eq!(EdgeType::Cover.label(), "Cover of");
844        assert_eq!(EdgeType::Remaster.label(), "Remaster of");
845        assert_eq!(EdgeType::SpeedEdit.label(), "Speed-edited from");
846        assert_eq!(EdgeType::Edit.label(), "Edited from");
847        assert_eq!(EdgeType::Extend.label(), "Extended from");
848        assert_eq!(EdgeType::SectionReplace.label(), "Section replaced from");
849        assert_eq!(EdgeType::Stitch.label(), "Stitched from");
850        assert_eq!(EdgeType::Derived.label(), "Derived from");
851        assert_eq!(EdgeType::Uploaded.label(), "Uploaded");
852    }
853
854    #[test]
855    fn classifies_remaster_cover_edit_and_root_across_chain1() {
856        let clips = chain1_clips();
857
858        assert_eq!(edge_type(&clips[0]), Some(EdgeType::Remaster));
859        assert_eq!(
860            immediate_parent(&clips[0]),
861            Some(("52962dae".into(), EdgeType::Remaster))
862        );
863
864        assert_eq!(edge_type(&clips[1]), Some(EdgeType::Cover));
865        assert_eq!(
866            immediate_parent(&clips[1]),
867            Some(("536e1b92".into(), EdgeType::Cover))
868        );
869
870        assert_eq!(edge_type(&clips[4]), Some(EdgeType::Edit));
871        assert_eq!(
872            immediate_parent(&clips[4]),
873            Some(("dfb59a04".into(), EdgeType::Edit))
874        );
875
876        assert_eq!(edge_type(&clips[5]), None);
877        assert_eq!(immediate_parent(&clips[5]), None);
878    }
879
880    #[test]
881    fn classifies_speed_edit_from_speed_pointer_without_edited() {
882        // Real `chain2` shape: edit_speed carries speed_clip_id and no edited_clip_id.
883        let clip = Clip {
884            id: "6e5193b1".into(),
885            title: "Go Xavi Go, Fast. (Drum n' Bass Version)".into(),
886            clip_type: "edit_speed".into(),
887            is_remix: true,
888            speed_clip_id: "2b69882c".into(),
889            ..Default::default()
890        };
891        assert_eq!(edge_type(&clip), Some(EdgeType::SpeedEdit));
892        assert_eq!(
893            immediate_parent(&clip),
894            Some(("2b69882c".into(), EdgeType::SpeedEdit))
895        );
896    }
897
898    #[test]
899    fn empty_task_gen_is_a_root() {
900        // Real `chain2` root: gen with an empty task string.
901        let clip = Clip {
902            id: "b4f16694".into(),
903            title: "Go Xavi Go, Fast.".into(),
904            clip_type: "gen".into(),
905            task: String::new(),
906            ..Default::default()
907        };
908        assert_eq!(edge_type(&clip), None);
909        assert_eq!(immediate_parent(&clip), None);
910    }
911
912    #[test]
913    fn classifies_extend_from_history_head() {
914        let clip = Clip {
915            id: "9a3dcb67".into(),
916            title: "Extended".into(),
917            clip_type: "gen".into(),
918            task: "extend".into(),
919            edited_clip_id: "0a3c311a".into(),
920            history: vec![HistoryEntry {
921                id: "0a3c311a".into(),
922                continue_at: Some(115.35),
923                ..Default::default()
924            }],
925            ..Default::default()
926        };
927        assert_eq!(edge_type(&clip), Some(EdgeType::Extend));
928        assert_eq!(
929            immediate_parent(&clip),
930            Some(("0a3c311a".into(), EdgeType::Extend))
931        );
932    }
933
934    #[test]
935    fn classifies_infill_with_override_history_precedence() {
936        // Real infill shape: override_history wins over future, history, and edited.
937        let clip = Clip {
938            id: "c0ce5c48".into(),
939            title: "Section replaced".into(),
940            clip_type: "gen".into(),
941            task: "infill".into(),
942            edited_clip_id: "cf37e05f".into(),
943            override_history_clip_id: "d3d28e59".into(),
944            override_future_clip_id: "ea88571e".into(),
945            history: vec![HistoryEntry {
946                id: "cf37e05f".into(),
947                infill: true,
948                infill_start_s: Some(20.4),
949                infill_end_s: Some(24.92),
950                ..Default::default()
951            }],
952            ..Default::default()
953        };
954        assert_eq!(edge_type(&clip), Some(EdgeType::SectionReplace));
955        assert_eq!(
956            immediate_parent(&clip),
957            Some(("d3d28e59".into(), EdgeType::SectionReplace))
958        );
959    }
960
961    #[test]
962    fn fixed_infill_is_also_section_replace() {
963        let clip = Clip {
964            task: "fixed_infill".into(),
965            override_history_clip_id: "past".into(),
966            edited_clip_id: "edited".into(),
967            ..Default::default()
968        };
969        assert_eq!(edge_type(&clip), Some(EdgeType::SectionReplace));
970        assert_eq!(
971            immediate_parent(&clip),
972            Some(("past".into(), EdgeType::SectionReplace))
973        );
974    }
975
976    #[test]
977    fn classifies_stitch_from_concat_base() {
978        // Real concat shape: type=concat, base segment first in concat_history.
979        let clip = Clip {
980            id: "43ba1ce3".into(),
981            title: "Stitched".into(),
982            clip_type: "concat".into(),
983            concat_history: vec![
984                HistoryEntry {
985                    id: "ead64fbe".into(),
986                    continue_at: Some(149.19),
987                    ..Default::default()
988                },
989                history("da47b824"),
990            ],
991            ..Default::default()
992        };
993        assert_eq!(edge_type(&clip), Some(EdgeType::Stitch));
994        assert_eq!(
995            immediate_parent(&clip),
996            Some(("ead64fbe".into(), EdgeType::Stitch))
997        );
998    }
999
1000    #[test]
1001    fn inherited_concat_history_without_concat_type_is_not_a_stitch() {
1002        // Suno copies a parent stitch's concat_history onto derived clips. A
1003        // plain `gen` that merely carries it (no type=concat, no other marker)
1004        // must NOT be read as a stitch; here it has no parent pointer, so it is
1005        // a root.
1006        let clip = Clip {
1007            clip_type: "gen".into(),
1008            concat_history: vec![history("base"), history("second")],
1009            ..Default::default()
1010        };
1011        assert_eq!(edge_type(&clip), None);
1012        assert_eq!(immediate_parent(&clip), None);
1013    }
1014
1015    #[test]
1016    fn cover_of_a_stitch_classifies_as_cover_not_stitch() {
1017        // A cover OF a stitched track inherits the parent's concat_history but is
1018        // itself a cover: it must classify as Cover and parent via cover_clip_id,
1019        // never as a Stitch pointing at an inherited concat segment.
1020        let clip = Clip {
1021            id: "cov".into(),
1022            title: "Cover of a stitch".into(),
1023            clip_type: "gen".into(),
1024            task: "cover".into(),
1025            cover_clip_id: "stitch-parent".into(),
1026            edited_clip_id: "stitch-parent".into(),
1027            concat_history: vec![history("inherited-base"), history("inherited-seg")],
1028            ..Default::default()
1029        };
1030        assert_eq!(edge_type(&clip), Some(EdgeType::Cover));
1031        assert_eq!(
1032            immediate_parent(&clip),
1033            Some(("stitch-parent".into(), EdgeType::Cover))
1034        );
1035    }
1036
1037    #[test]
1038    fn upload_is_a_root() {
1039        let clip = Clip {
1040            id: "4770ef56".into(),
1041            title: "Uploaded audio".into(),
1042            clip_type: "upload".into(),
1043            ..Default::default()
1044        };
1045        assert_eq!(edge_type(&clip), None);
1046        assert_eq!(immediate_parent(&clip), None);
1047    }
1048
1049    #[test]
1050    fn edited_only_clip_is_derived() {
1051        // A task the resolver has no specific rule for, but a parent pointer.
1052        let clip = Clip {
1053            clip_type: "gen".into(),
1054            task: "chop_sample_condition".into(),
1055            edited_clip_id: "parent-x".into(),
1056            ..Default::default()
1057        };
1058        assert_eq!(edge_type(&clip), Some(EdgeType::Derived));
1059        assert_eq!(
1060            immediate_parent(&clip),
1061            Some(("parent-x".into(), EdgeType::Derived))
1062        );
1063    }
1064
1065    #[test]
1066    fn unmarked_clip_without_pointer_is_a_root() {
1067        let clip = Clip {
1068            clip_type: "gen".into(),
1069            task: "chop_sample_condition".into(),
1070            ..Default::default()
1071        };
1072        assert_eq!(edge_type(&clip), None);
1073        assert_eq!(immediate_parent(&clip), None);
1074    }
1075
1076    #[test]
1077    fn is_remix_does_not_change_classification() {
1078        let base = Clip {
1079            clip_type: "gen".into(),
1080            task: "cover".into(),
1081            cover_clip_id: "root-1".into(),
1082            edited_clip_id: "root-1".into(),
1083            ..Default::default()
1084        };
1085        let mut with_flag = base.clone();
1086        with_flag.is_remix = true;
1087        let mut without_flag = base;
1088        without_flag.is_remix = false;
1089
1090        assert_eq!(edge_type(&with_flag), edge_type(&without_flag));
1091        assert_eq!(
1092            immediate_parent(&with_flag),
1093            immediate_parent(&without_flag)
1094        );
1095        assert_eq!(edge_type(&with_flag), Some(EdgeType::Cover));
1096        assert_eq!(
1097            immediate_parent(&with_flag),
1098            Some(("root-1".into(), EdgeType::Cover))
1099        );
1100    }
1101
1102    #[test]
1103    fn zero_uuid_cover_falls_back_to_edited() {
1104        let clip = Clip {
1105            clip_type: "gen".into(),
1106            task: "cover".into(),
1107            cover_clip_id: ZERO_UUID.into(),
1108            edited_clip_id: "real-parent".into(),
1109            ..Default::default()
1110        };
1111        assert_eq!(
1112            immediate_parent(&clip),
1113            Some(("real-parent".into(), EdgeType::Cover))
1114        );
1115    }
1116
1117    #[test]
1118    fn m_prefix_is_stripped_from_history_and_concat_ids() {
1119        let extend = Clip {
1120            clip_type: "gen".into(),
1121            task: "extend".into(),
1122            history: vec![history("m_abc123")],
1123            ..Default::default()
1124        };
1125        assert_eq!(
1126            immediate_parent(&extend),
1127            Some(("abc123".into(), EdgeType::Extend))
1128        );
1129
1130        let stitch = Clip {
1131            clip_type: "concat".into(),
1132            concat_history: vec![history("m_base"), history("m_second")],
1133            ..Default::default()
1134        };
1135        let edges = lineage_edges(&stitch);
1136        assert_eq!(edges[0].parent_id, "base");
1137        assert_eq!(edges[1].parent_id, "second");
1138        assert_eq!(edges[1].role, EdgeRole::Secondary);
1139    }
1140
1141    #[test]
1142    fn lineage_edges_of_a_root_is_empty() {
1143        let clip = Clip {
1144            clip_type: "gen".into(),
1145            ..Default::default()
1146        };
1147        assert!(lineage_edges(&clip).is_empty());
1148    }
1149
1150    #[test]
1151    fn lineage_edges_records_stitch_secondaries_in_order() {
1152        let clip = Clip {
1153            clip_type: "concat".into(),
1154            concat_history: vec![history("base"), history("seg1"), history("seg2")],
1155            ..Default::default()
1156        };
1157        let edges = lineage_edges(&clip);
1158        assert_eq!(
1159            edges,
1160            vec![
1161                Edge {
1162                    parent_id: "base".into(),
1163                    edge_type: EdgeType::Stitch,
1164                    role: EdgeRole::Primary,
1165                    ordinal: 0,
1166                    source_field: "concat_history",
1167                },
1168                Edge {
1169                    parent_id: "seg1".into(),
1170                    edge_type: EdgeType::Stitch,
1171                    role: EdgeRole::Secondary,
1172                    ordinal: 1,
1173                    source_field: "concat_history",
1174                },
1175                Edge {
1176                    parent_id: "seg2".into(),
1177                    edge_type: EdgeType::Stitch,
1178                    role: EdgeRole::Secondary,
1179                    ordinal: 2,
1180                    source_field: "concat_history",
1181                },
1182            ]
1183        );
1184    }
1185
1186    #[test]
1187    fn lineage_edges_emits_secondaries_when_the_primary_is_absent() {
1188        // A stitch whose base segment id is empty still has real secondary
1189        // segments: they must be emitted (with their own ordinals) rather than
1190        // dropped for want of a primary.
1191        let clip = Clip {
1192            clip_type: "concat".into(),
1193            concat_history: vec![history(""), history("seg1"), history("seg2")],
1194            ..Default::default()
1195        };
1196        let edges = lineage_edges(&clip);
1197        assert_eq!(
1198            edges,
1199            vec![
1200                Edge {
1201                    parent_id: "seg1".into(),
1202                    edge_type: EdgeType::Stitch,
1203                    role: EdgeRole::Secondary,
1204                    ordinal: 1,
1205                    source_field: "concat_history",
1206                },
1207                Edge {
1208                    parent_id: "seg2".into(),
1209                    edge_type: EdgeType::Stitch,
1210                    role: EdgeRole::Secondary,
1211                    ordinal: 2,
1212                    source_field: "concat_history",
1213                },
1214            ],
1215            "secondaries survive an empty primary base segment"
1216        );
1217    }
1218
1219    #[test]
1220    fn lineage_edges_records_infill_future_as_secondary() {
1221        let clip = Clip {
1222            task: "infill".into(),
1223            override_history_clip_id: "past".into(),
1224            override_future_clip_id: "future".into(),
1225            ..Default::default()
1226        };
1227        let edges = lineage_edges(&clip);
1228        assert_eq!(edges[0].parent_id, "past");
1229        assert_eq!(edges[0].role, EdgeRole::Primary);
1230        assert_eq!(edges[0].source_field, "override_history_clip_id");
1231        assert_eq!(
1232            edges[1],
1233            Edge {
1234                parent_id: "future".into(),
1235                edge_type: EdgeType::SectionReplace,
1236                role: EdgeRole::Secondary,
1237                ordinal: 1,
1238                source_field: "override_future_clip_id",
1239            }
1240        );
1241    }
1242
1243    #[test]
1244    fn resolve_roots_walks_a_connected_chain_with_no_http() {
1245        let http = ScriptedHttp::new();
1246        let mut client = SunoClient::new(ClerkAuth::new("eyJtoken"), RecordingClock::new());
1247        let clips = chain1_clips();
1248
1249        let roots = pollster::block_on(resolve_roots(
1250            &clips,
1251            &mut client,
1252            &http,
1253            ResolveOpts::default(),
1254        ))
1255        .unwrap()
1256        .roots;
1257
1258        assert!(
1259            http.calls().is_empty(),
1260            "a fully-connected chain must never touch the network"
1261        );
1262        assert_eq!(roots.len(), clips.len());
1263        for clip in &clips {
1264            let info = &roots[&clip.id];
1265            assert_eq!(info.status, ResolveStatus::Resolved);
1266            assert_eq!(info.root_id, "dfb59a04");
1267            assert_eq!(info.root_title, "Zac and the Sea Eagles");
1268        }
1269    }
1270
1271    #[test]
1272    fn resolve_roots_gap_fills_a_missing_ancestor_by_id() {
1273        let cover = Clip {
1274            id: "child".into(),
1275            title: "Cover".into(),
1276            clip_type: "gen".into(),
1277            task: "cover".into(),
1278            cover_clip_id: "root".into(),
1279            edited_clip_id: "root".into(),
1280            ..Default::default()
1281        };
1282        let root_clip = serde_json::json!({
1283            "id": "root", "title": "Original", "status": "complete",
1284            "metadata": {"type": "gen"}
1285        })
1286        .to_string();
1287        let http = ScriptedHttp::new()
1288            .with_auth()
1289            .route("/api/clip/root", Reply::json(&root_clip));
1290        let mut client = authed_client(&http);
1291
1292        let roots = pollster::block_on(resolve_roots(
1293            &[cover],
1294            &mut client,
1295            &http,
1296            ResolveOpts::default(),
1297        ))
1298        .unwrap()
1299        .roots;
1300
1301        let info = &roots["child"];
1302        assert_eq!(info.status, ResolveStatus::Resolved);
1303        assert_eq!(info.root_id, "root");
1304        assert_eq!(info.root_title, "Original");
1305        assert_eq!(http.count("/api/clip/root"), 1);
1306        assert_eq!(
1307            http.count("/api/clips/parent"),
1308            0,
1309            "the parent endpoint must not be used when the per-id fetch succeeds"
1310        );
1311    }
1312
1313    #[test]
1314    fn resolve_roots_returns_gap_filled_ancestors_for_archival() {
1315        // The fetched (often trashed) ancestor is handed back so a later phase
1316        // can archive it before Suno's purge (HARDENING H4). It resolves the
1317        // child's root yet stays out of the roots (download) set.
1318        let cover = Clip {
1319            id: "child".into(),
1320            title: "Cover".into(),
1321            clip_type: "gen".into(),
1322            task: "cover".into(),
1323            cover_clip_id: "root".into(),
1324            edited_clip_id: "root".into(),
1325            ..Default::default()
1326        };
1327        let root_clip = serde_json::json!({
1328            "id": "root", "title": "Trashed Original", "status": "complete",
1329            "metadata": {"type": "gen"}
1330        })
1331        .to_string();
1332        let http = ScriptedHttp::new()
1333            .with_auth()
1334            .route("/api/clip/root", Reply::json(&root_clip));
1335        let mut client = authed_client(&http);
1336
1337        let resolution = pollster::block_on(resolve_roots(
1338            &[cover],
1339            &mut client,
1340            &http,
1341            ResolveOpts::default(),
1342        ))
1343        .unwrap();
1344
1345        assert_eq!(resolution.gap_filled.len(), 1);
1346        assert_eq!(resolution.gap_filled[0].id, "root");
1347        assert_eq!(resolution.gap_filled[0].title, "Trashed Original");
1348        assert_eq!(resolution.roots["child"].root_id, "root");
1349        assert!(
1350            !resolution.roots.contains_key("root"),
1351            "gap-filled ancestors must never enter the roots set"
1352        );
1353    }
1354
1355    #[test]
1356    fn resolve_roots_falls_back_to_the_parent_endpoint() {
1357        let cover = Clip {
1358            id: "child".into(),
1359            title: "Cover".into(),
1360            clip_type: "gen".into(),
1361            task: "cover".into(),
1362            cover_clip_id: "missing".into(),
1363            edited_clip_id: "missing".into(),
1364            ..Default::default()
1365        };
1366        // The per-id fetch of `missing` 404s; the parent endpoint yields its
1367        // parent (the root), which the walk then bridges over `missing` to.
1368        let parent_body = serde_json::json!({
1369            "id": "root", "title": "Original", "status": "complete",
1370            "metadata": {"type": "gen"}
1371        })
1372        .to_string();
1373        let http = ScriptedHttp::new()
1374            .with_auth()
1375            .route("/api/clip/missing", Reply::status(404))
1376            .route("/api/clips/parent", Reply::json(&parent_body));
1377        let mut client = authed_client(&http);
1378
1379        let roots = pollster::block_on(resolve_roots(
1380            &[cover],
1381            &mut client,
1382            &http,
1383            ResolveOpts::default(),
1384        ))
1385        .unwrap()
1386        .roots;
1387
1388        let info = &roots["child"];
1389        assert_eq!(info.status, ResolveStatus::Resolved);
1390        assert_eq!(info.root_id, "root");
1391        assert_eq!(info.root_title, "Original");
1392        assert!(
1393            http.count("/api/clips/parent?clip_id=missing") >= 1,
1394            "the missing ancestor must be resolved via the parent endpoint"
1395        );
1396    }
1397
1398    #[test]
1399    fn resolve_roots_detects_a_cycle_without_looping() {
1400        let a = Clip {
1401            id: "a".into(),
1402            title: "A".into(),
1403            clip_type: "gen".into(),
1404            task: "cover".into(),
1405            cover_clip_id: "b".into(),
1406            edited_clip_id: "b".into(),
1407            ..Default::default()
1408        };
1409        let b = Clip {
1410            id: "b".into(),
1411            title: "B".into(),
1412            clip_type: "gen".into(),
1413            task: "cover".into(),
1414            cover_clip_id: "a".into(),
1415            edited_clip_id: "a".into(),
1416            ..Default::default()
1417        };
1418        let http = ScriptedHttp::new();
1419        let mut client = SunoClient::new(ClerkAuth::new("eyJtoken"), RecordingClock::new());
1420
1421        let roots = pollster::block_on(resolve_roots(
1422            &[a, b],
1423            &mut client,
1424            &http,
1425            ResolveOpts::default(),
1426        ))
1427        .unwrap()
1428        .roots;
1429
1430        assert_eq!(roots["a"].status, ResolveStatus::Cycle);
1431        assert_eq!(roots["b"].status, ResolveStatus::Cycle);
1432        assert!(http.calls().is_empty());
1433    }
1434
1435    #[test]
1436    fn resolve_roots_marks_external_when_the_budget_is_exhausted() {
1437        // child -> m1 (missing) -> m2 (missing) -> ...; only one gap-fill allowed.
1438        let child = Clip {
1439            id: "child".into(),
1440            title: "Child".into(),
1441            clip_type: "gen".into(),
1442            task: "cover".into(),
1443            cover_clip_id: "m1".into(),
1444            edited_clip_id: "m1".into(),
1445            ..Default::default()
1446        };
1447        let m1_clip = serde_json::json!({
1448            "id": "m1", "title": "Middle", "status": "complete",
1449            "metadata": {"type": "gen", "task": "cover", "cover_clip_id": "m2", "edited_clip_id": "m2"}
1450        })
1451        .to_string();
1452        let http = ScriptedHttp::new()
1453            .with_auth()
1454            .route("/api/clip/m1", Reply::json(&m1_clip));
1455        let mut client = authed_client(&http);
1456        let opts = ResolveOpts {
1457            max_gap_fills: 1,
1458            hop_cap: 64,
1459        };
1460
1461        let roots = pollster::block_on(resolve_roots(&[child], &mut client, &http, opts))
1462            .unwrap()
1463            .roots;
1464
1465        let info = &roots["child"];
1466        assert_eq!(info.status, ResolveStatus::External);
1467        assert_eq!(
1468            info.root_id, "m2",
1469            "resolution stops at the first ancestor it could not fetch"
1470        );
1471        assert_eq!(http.count("/api/clip/m1"), 1);
1472        assert_eq!(
1473            http.count("/api/clip/m2"),
1474            0,
1475            "the gap-fill budget must not be exceeded"
1476        );
1477    }
1478
1479    #[test]
1480    fn resolve_roots_external_root_endpoint_stops_the_walk() {
1481        // The parent endpoint reporting no parent means an external root: the
1482        // ancestor exists on Suno but is outside the caller's library.
1483        let cover = Clip {
1484            id: "child".into(),
1485            title: "Cover".into(),
1486            clip_type: "gen".into(),
1487            task: "cover".into(),
1488            cover_clip_id: "outside".into(),
1489            edited_clip_id: "outside".into(),
1490            ..Default::default()
1491        };
1492        let http = ScriptedHttp::new()
1493            .with_auth()
1494            .route("/api/clip/outside", Reply::status(404))
1495            .route("/api/clips/parent", Reply::status(404));
1496        let mut client = authed_client(&http);
1497
1498        let roots = pollster::block_on(resolve_roots(
1499            &[cover],
1500            &mut client,
1501            &http,
1502            ResolveOpts::default(),
1503        ))
1504        .unwrap()
1505        .roots;
1506
1507        let info = &roots["child"];
1508        assert_eq!(info.status, ResolveStatus::External);
1509        assert_eq!(info.root_id, "outside");
1510    }
1511
1512    fn resolution_with(roots: Vec<(&str, RootInfo)>) -> Resolution {
1513        Resolution {
1514            roots: roots
1515                .into_iter()
1516                .map(|(id, info)| (id.to_owned(), info))
1517                .collect(),
1518            gap_filled: Vec::new(),
1519        }
1520    }
1521
1522    #[test]
1523    fn context_for_a_root_uses_its_own_id_and_title() {
1524        let root = Clip {
1525            id: "root-1".into(),
1526            title: "Original".into(),
1527            ..Default::default()
1528        };
1529        let resolution = resolution_with(vec![(
1530            "root-1",
1531            RootInfo {
1532                root_id: "root-1".into(),
1533                root_title: "Original".into(),
1534                status: ResolveStatus::Resolved,
1535            },
1536        )]);
1537
1538        let ctx = LineageContext::for_clip(&root, &resolution);
1539        assert_eq!(ctx.root_id, "root-1");
1540        assert_eq!(ctx.root_title, "Original");
1541        assert_eq!(ctx.parent_id, "");
1542        assert_eq!(ctx.edge_type, None);
1543        // A root folders under its own title.
1544        assert_eq!(ctx.album("Original"), "Original");
1545    }
1546
1547    #[test]
1548    fn context_for_a_remix_carries_root_and_parent() {
1549        let child = Clip {
1550            id: "child-1".into(),
1551            title: "Remix".into(),
1552            clip_type: "gen".into(),
1553            task: "cover".into(),
1554            cover_clip_id: "root-1".into(),
1555            edited_clip_id: "root-1".into(),
1556            ..Default::default()
1557        };
1558        let resolution = resolution_with(vec![(
1559            "child-1",
1560            RootInfo {
1561                root_id: "root-1".into(),
1562                root_title: "Original".into(),
1563                status: ResolveStatus::Resolved,
1564            },
1565        )]);
1566
1567        let ctx = LineageContext::for_clip(&child, &resolution);
1568        assert_eq!(ctx.root_id, "root-1");
1569        assert_eq!(ctx.root_title, "Original");
1570        assert_eq!(ctx.parent_id, "root-1");
1571        assert_eq!(ctx.edge_type, Some(EdgeType::Cover));
1572        // A remix folders under the root's album title, not its own.
1573        assert_eq!(ctx.album("Remix"), "Original");
1574    }
1575
1576    #[test]
1577    fn context_absent_from_resolution_is_its_own_root() {
1578        let clip = Clip {
1579            id: "lonely".into(),
1580            title: "Solo".into(),
1581            ..Default::default()
1582        };
1583        let ctx = LineageContext::for_clip(&clip, &resolution_with(vec![]));
1584        assert_eq!(ctx.root_id, "lonely");
1585        assert_eq!(ctx.root_title, "Solo");
1586        assert_eq!(ctx.status, ResolveStatus::Resolved);
1587        assert_eq!(ctx.album("Solo"), "Solo");
1588    }
1589
1590    #[test]
1591    fn album_falls_back_to_own_title_when_root_title_is_empty() {
1592        let ctx = LineageContext {
1593            root_id: "outside".into(),
1594            root_title: String::new(),
1595            root_date: String::new(),
1596            parent_id: "outside".into(),
1597            edge_type: Some(EdgeType::Cover),
1598            status: ResolveStatus::External,
1599        };
1600        assert_eq!(ctx.album("My Title"), "My Title");
1601    }
1602
1603    #[test]
1604    fn own_root_has_no_parent() {
1605        let clip = Clip {
1606            id: "solo".into(),
1607            title: "Solo".into(),
1608            ..Default::default()
1609        };
1610        let ctx = LineageContext::own_root(&clip);
1611        assert_eq!(ctx.root_id, "solo");
1612        assert_eq!(ctx.parent_id, "");
1613        assert_eq!(ctx.edge_type, None);
1614    }
1615
1616    #[test]
1617    fn year_prefers_the_root_year_over_the_clips_own() {
1618        // A December root with a January revision: the child tags the root's
1619        // year so the album groups under one year across the boundary.
1620        let ctx = LineageContext {
1621            root_id: "root-1".into(),
1622            root_title: "Origin".into(),
1623            root_date: "2023-12-30T23:00:00Z".into(),
1624            parent_id: "root-1".into(),
1625            edge_type: Some(EdgeType::Extend),
1626            status: ResolveStatus::Resolved,
1627        };
1628        assert_eq!(ctx.year("2024-01-02T08:00:00Z"), "2023");
1629    }
1630
1631    #[test]
1632    fn year_falls_back_to_own_when_the_root_date_is_unavailable() {
1633        let ctx = LineageContext {
1634            root_id: "outside".into(),
1635            root_title: String::new(),
1636            root_date: String::new(),
1637            parent_id: "outside".into(),
1638            edge_type: Some(EdgeType::Cover),
1639            status: ResolveStatus::External,
1640        };
1641        assert_eq!(ctx.year("2024-07-01T00:00:00Z"), "2024");
1642    }
1643
1644    #[test]
1645    fn own_root_tags_its_own_year() {
1646        let clip = Clip {
1647            id: "solo".into(),
1648            title: "Solo".into(),
1649            created_at: "2022-05-06T12:00:00Z".into(),
1650            ..Default::default()
1651        };
1652        let ctx = LineageContext::own_root(&clip);
1653        assert_eq!(ctx.root_date, "2022-05-06T12:00:00Z");
1654        assert_eq!(ctx.year(&clip.created_at), "2022");
1655    }
1656
1657    #[test]
1658    fn year_is_empty_when_no_date_is_known() {
1659        let clip = Clip::default();
1660        let ctx = LineageContext::own_root(&clip);
1661        assert_eq!(ctx.year(&clip.created_at), "");
1662    }
1663}