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