Skip to main content

suno_core/
lineage.rs

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