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