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 removed `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/// An attribution edge derived from a clip's nested `clip_roots` list.
406///
407/// This is informational lineage the API states directly (the clip was remixed
408/// from these roots), NOT a structural parent. It is deliberately kept apart
409/// from the [`EdgeType`]-classified structural [`Edge`]s: it is NEVER read by
410/// [`immediate_parent`], [`primary_parent`], [`lineage_edges`], or the root
411/// [`walk`](Resolver::walk). It feeds only the durable graph store (as a
412/// secondary edge carrying the open attribution slug) and, for a same-owner
413/// root, a bounded gap-fill seed. It can never fabricate a structural parent,
414/// an external boundary, or a download candidate.
415#[derive(Debug, Clone, PartialEq, Eq)]
416pub struct AttributionEdge {
417    /// The root clip id, normalised (`m_` stripped, sentinel dropped).
418    pub parent_id: String,
419    /// The raw attribution slug from `clip_attribution_type` (open, e.g.
420    /// `"remix"`); normalisation to a stored form happens at the graph layer,
421    /// never against the closed [`EdgeType`].
422    pub edge_slug: String,
423    /// Always [`EdgeRole::Secondary`]: attribution never supplants a clip's
424    /// structural primary parent.
425    pub role: EdgeRole,
426    /// Position within the clip's `clip_roots.clips[]` list (0..N).
427    pub ordinal: u32,
428    /// The field these came from (always `"clip_roots"`).
429    pub source_field: &'static str,
430    /// Whether the root shares the clip's owner handle (fail-closed): only a
431    /// same-owner root is ever gap-fill seeded.
432    pub same_owner: bool,
433}
434
435/// Every attribution edge from a clip's `clip_roots` (empty when absent).
436///
437/// One edge per root with a usable id, in list order. Emitted for EVERY root
438/// regardless of owner: a foreign-owned root still records its attribution. The
439/// `same_owner` flag gates only the later gap-fill seed, never the edge itself.
440pub fn attribution_edges(clip: &Clip) -> Vec<AttributionEdge> {
441    let mut edges = Vec::new();
442    for root in &clip.clip_roots {
443        let Some(parent_id) = normalise_id(&root.id) else {
444            continue;
445        };
446        let ordinal = edges.len() as u32;
447        edges.push(AttributionEdge {
448            parent_id,
449            edge_slug: clip.clip_attribution_type.clone(),
450            role: EdgeRole::Secondary,
451            ordinal,
452            source_field: "clip_roots",
453            same_owner: same_owner(clip, root),
454        });
455    }
456    edges
457}
458
459/// Whether a `clip_root` shares the clip's owner, by handle equality.
460///
461/// Fail-closed: an empty or missing handle on either side is a foreign owner,
462/// so a rename or an absent identity never folds a foreign remix source into
463/// the owner's album via the gap-fill seed. Never relax to substring or prefix
464/// matching.
465fn same_owner(clip: &Clip, root: &crate::model::ClipRoot) -> bool {
466    let clip_handle = clip.handle.trim();
467    let root_handle = root.handle.trim();
468    !clip_handle.is_empty() && !root_handle.is_empty() && clip_handle == root_handle
469}
470
471/// Resolve the root ancestor of every clip in `clips`.
472///
473/// Walks each clip up its [`immediate_parent`] chain to a root. Chains that
474/// stay within `clips` resolve with no network access. When a parent is absent
475/// from the index it is gap-filled: missing ids are fetched in a batch through
476/// [`SunoClient::get_clips_by_ids`], and any id that cannot be retrieved that
477/// way falls back to [`SunoClient::get_clip_parent`], which yields one ancestor
478/// hop to keep walking (never assumed to be the absolute root).
479///
480/// Gap-filled clips (which may be trashed) are held in an index that is kept
481/// structurally separate from the caller's `clips`; they exist only to resolve
482/// ancestry and must never be treated as download candidates by later phases.
483///
484/// Bounded by [`ResolveOpts`]: at most `max_gap_fills` ancestor ids are fetched
485/// (exhaustion yields [`ResolveStatus::External`] at the last reachable
486/// ancestor), and each chain walks at most `hop_cap` hops. A cycle yields
487/// [`ResolveStatus::Cycle`]. The returned [`Resolution`] has a root entry for
488/// every input clip, plus the gap-filled ancestor clips it fetched.
489pub async fn resolve_roots(
490    clips: &[Clip],
491    archived_parents: &HashMap<String, String>,
492    client: &SunoClient<impl Clock>,
493    http: &impl Http,
494    opts: ResolveOpts,
495) -> Result<Resolution> {
496    let mut resolver = Resolver::new(clips, opts, archived_parents);
497    resolver.run(client, http).await?;
498    Ok(resolver.into_resolution(clips))
499}
500
501/// The clip's primary parent id, edge type, and the source field it came from.
502///
503/// Shared by [`immediate_parent`] and [`lineage_edges`] so the two never drift.
504fn primary_parent(clip: &Clip) -> Option<(String, EdgeType, &'static str)> {
505    let edge = edge_type(clip)?;
506    let history_head = clip.history.first().map_or("", |entry| entry.id.as_str());
507    let concat_head = clip
508        .concat_history
509        .first()
510        .map_or("", |entry| entry.id.as_str());
511
512    let candidates: Vec<(&str, &'static str)> = match edge {
513        EdgeType::SectionReplace => vec![
514            (
515                clip.override_history_clip_id.as_str(),
516                "override_history_clip_id",
517            ),
518            (
519                clip.override_future_clip_id.as_str(),
520                "override_future_clip_id",
521            ),
522            (history_head, "history"),
523            (clip.edited_clip_id.as_str(), "edited_clip_id"),
524        ],
525        EdgeType::Extend => vec![
526            (history_head, "history"),
527            (clip.edited_clip_id.as_str(), "edited_clip_id"),
528        ],
529        EdgeType::Stitch => vec![
530            (concat_head, "concat_history"),
531            (clip.edited_clip_id.as_str(), "edited_clip_id"),
532        ],
533        EdgeType::SpeedEdit => vec![
534            (clip.speed_clip_id.as_str(), "speed_clip_id"),
535            (clip.edited_clip_id.as_str(), "edited_clip_id"),
536        ],
537        EdgeType::Cover => vec![
538            (clip.cover_clip_id.as_str(), "cover_clip_id"),
539            (clip.edited_clip_id.as_str(), "edited_clip_id"),
540        ],
541        EdgeType::Remaster => vec![
542            (clip.upsample_clip_id.as_str(), "upsample_clip_id"),
543            (clip.remaster_clip_id.as_str(), "remaster_clip_id"),
544            (clip.edited_clip_id.as_str(), "edited_clip_id"),
545        ],
546        EdgeType::Edit | EdgeType::Derived => {
547            vec![(clip.edited_clip_id.as_str(), "edited_clip_id")]
548        }
549        EdgeType::Uploaded => vec![],
550    };
551
552    candidates
553        .into_iter()
554        .find_map(|(value, field)| normalise_id(value).map(|id| (id, edge, field)))
555}
556
557/// Normalise a raw pointer id: strip a leading `m_`, and treat an empty or
558/// all-zero sentinel value as absent.
559fn normalise_id(id: &str) -> Option<String> {
560    let id = id.strip_prefix("m_").unwrap_or(id);
561    if id.is_empty() || id == ZERO_UUID {
562        None
563    } else {
564        Some(id.to_string())
565    }
566}
567
568/// The result of walking one chain as far as the current index allows.
569enum Walk {
570    /// The start clip's root is now recorded in the memo.
571    Resolved,
572    /// The walk stalled needing this ancestor id gap-filled.
573    Blocked(String),
574}
575
576/// Working state for one [`resolve_roots`] call.
577///
578/// `index` holds the input clips plus any gap-filled ancestors so the walk can
579/// read their pointers; `gap_filled` records which ids were fetched here so
580/// later phases can tell ancestors apart from download candidates. `bridges`
581/// maps a missing id to the known parent that the parent endpoint returned in
582/// its place, and `external` records ids the API reported as parentless roots.
583struct Resolver<'a> {
584    index: HashMap<String, Clip>,
585    /// Persisted `child_id -> parent_id` links from the durable store's primary
586    /// edges. Consulted before any network gap-fill so a walk can hop through an
587    /// ancestor whose clip is absent (e.g. an intermediate remix, or one Suno
588    /// has since purged) using data captured on an earlier run.
589    archived_parents: &'a HashMap<String, String>,
590    gap_filled: HashSet<String>,
591    bridges: HashMap<String, String>,
592    external: HashSet<String>,
593    /// Clip-root ids already attempted as a gap-fill seed, so a root that the
594    /// batch never returns is tried once and then left alone (never re-seeded,
595    /// never bridged, never external).
596    seeded: HashSet<String>,
597    memo: HashMap<String, RootInfo>,
598    targets: Vec<String>,
599    budget: u32,
600    hop_cap: u32,
601    concurrency: u32,
602}
603
604impl<'a> Resolver<'a> {
605    fn new(
606        clips: &[Clip],
607        opts: ResolveOpts,
608        archived_parents: &'a HashMap<String, String>,
609    ) -> Self {
610        let index = clips
611            .iter()
612            .map(|clip| (clip.id.clone(), clip.clone()))
613            .collect();
614        let targets = clips.iter().map(|clip| clip.id.clone()).collect();
615        Self {
616            index,
617            archived_parents,
618            gap_filled: HashSet::new(),
619            bridges: HashMap::new(),
620            external: HashSet::new(),
621            seeded: HashSet::new(),
622            memo: HashMap::new(),
623            targets,
624            budget: opts.max_gap_fills,
625            hop_cap: opts.hop_cap,
626            concurrency: opts.concurrency,
627        }
628    }
629
630    /// Resolve every target, gap-filling missing ancestors until the whole set
631    /// is settled or the budget runs out.
632    async fn run(&mut self, client: &SunoClient<impl Clock>, http: &impl Http) -> Result<()> {
633        let targets = self.targets.clone();
634        loop {
635            let mut frontier: Vec<String> = Vec::new();
636            let mut seen: HashSet<String> = HashSet::new();
637            let mut blocked: Vec<(String, String)> = Vec::new();
638
639            for target in &targets {
640                if self.memo.contains_key(target) {
641                    continue;
642                }
643                if let Walk::Blocked(missing) = self.walk(target) {
644                    if seen.insert(missing.clone()) {
645                        frontier.push(missing.clone());
646                    }
647                    blocked.push((target.clone(), missing));
648                }
649            }
650
651            if blocked.is_empty() {
652                break;
653            }
654            if self.budget == 0 || !self.gap_fill(client, http, &frontier).await? {
655                self.finalise_external(&blocked);
656                break;
657            }
658        }
659        Ok(())
660    }
661
662    /// Walk `start` up its parent chain within the current index, memoising the
663    /// root for every node reached. Returns [`Walk::Blocked`] with the first
664    /// ancestor id that is missing and needs gap-filling.
665    fn walk(&mut self, start: &str) -> Walk {
666        if self.memo.contains_key(start) {
667            return Walk::Resolved;
668        }
669        let mut chain: Vec<String> = Vec::new();
670        let mut visited: HashSet<String> = HashSet::new();
671        let mut current = start.to_string();
672        let mut hops = 0u32;
673
674        loop {
675            if let Some(info) = self.memo.get(&current).cloned() {
676                self.assign(&chain, &info);
677                return Walk::Resolved;
678            }
679            if visited.contains(&current) {
680                let info = self.terminal(&current, ResolveStatus::Cycle);
681                self.assign(&chain, &info);
682                self.memo.insert(current, info);
683                return Walk::Resolved;
684            }
685            if hops >= self.hop_cap {
686                let info = self.terminal(&current, ResolveStatus::Unresolved);
687                self.assign(&chain, &info);
688                self.memo.insert(current, info);
689                return Walk::Resolved;
690            }
691
692            // The parent of `current` comes from its live/fetched clip, or from
693            // a persisted archived edge when the clip itself is not in hand. An
694            // id known through neither is unknown locally and must be gap-filled
695            // (this is the guard: an edgeless archived node is fetched, never
696            // assumed a root, so a not-yet-persisted remix still gets its real
697            // parent).
698            let parent_id = if let Some(clip) = self.index.get(&current) {
699                immediate_parent(clip).map(|(id, _edge)| id)
700            } else if let Some(parent) = self.archived_parents.get(&current) {
701                Some(parent.clone())
702            } else {
703                return Walk::Blocked(current);
704            };
705
706            let Some(parent_id) = parent_id else {
707                let info = RootInfo {
708                    root_id: current.clone(),
709                    root_title: self.title_of(&current),
710                    status: ResolveStatus::Resolved,
711                };
712                self.assign(&chain, &info);
713                self.memo.insert(current, info);
714                return Walk::Resolved;
715            };
716
717            visited.insert(current.clone());
718            chain.push(current);
719
720            if self.index.contains_key(&parent_id) || self.archived_parents.contains_key(&parent_id)
721            {
722                current = parent_id;
723            } else if let Some(bridged) = self.bridges.get(&parent_id).cloned() {
724                visited.insert(parent_id);
725                current = bridged;
726            } else if self.external.contains(&parent_id) {
727                let info = self.terminal(&parent_id, ResolveStatus::External);
728                self.assign(&chain, &info);
729                self.memo.insert(parent_id, info);
730                return Walk::Resolved;
731            } else {
732                return Walk::Blocked(parent_id);
733            }
734            hops += 1;
735        }
736    }
737
738    /// Fetch missing `frontier` ancestors, batching by id and falling back to
739    /// the parent endpoint. Same-owner `clip_roots` are additionally seeded as
740    /// best-effort root candidates. Returns whether the index (or
741    /// bridges/externals) grew, so the caller can detect a stalled resolution.
742    async fn gap_fill(
743        &mut self,
744        client: &SunoClient<impl Clock>,
745        http: &impl Http,
746        frontier: &[String],
747    ) -> Result<bool> {
748        // Structural frontier: ancestors a walk is blocked on. They get the full
749        // treatment (batch fetch, then a parent-endpoint fallback that may bridge
750        // one hop or mark the id external).
751        let mut want: Vec<String> = frontier
752            .iter()
753            .filter(|id| !self.known(id))
754            .cloned()
755            .collect();
756        want.sort();
757        want.dedup();
758
759        // Same-owner clip_root seeds: an OPTIONAL extra root candidate. They ride
760        // the batch and its per-id fallback, but never the parent-endpoint path
761        // below, so a seed the fetch omits is simply dropped, never bridged,
762        // externalised, or forced to a root: clip_roots can neither fabricate a
763        // parent link nor arm a delete. Foreign-owned roots are excluded
764        // (fail-closed by handle), and each seed is attempted at most once.
765        let mut seeds: Vec<String> = self
766            .clip_root_seeds()
767            .into_iter()
768            .filter(|id| !self.known(id) && !self.seeded.contains(id) && !want.contains(id))
769            .collect();
770        seeds.sort();
771        seeds.dedup();
772
773        if want.is_empty() && seeds.is_empty() {
774            return Ok(false);
775        }
776
777        // Frontier ids take budget priority so a blocked walk is never starved
778        // by a best-effort seed.
779        let frontier_take = (self.budget as usize).min(want.len());
780        let frontier_batch: Vec<String> = want.into_iter().take(frontier_take).collect();
781        self.budget -= frontier_batch.len() as u32;
782
783        let seed_take = (self.budget as usize).min(seeds.len());
784        let seed_batch: Vec<String> = seeds.into_iter().take(seed_take).collect();
785        self.budget -= seed_batch.len() as u32;
786        for id in &seed_batch {
787            self.seeded.insert(id.clone());
788        }
789
790        // One batch call covers frontier + seeds; the parent-endpoint fallback
791        // below is confined to the structural frontier.
792        let all: Vec<&str> = frontier_batch
793            .iter()
794            .chain(seed_batch.iter())
795            .map(String::as_str)
796            .collect();
797        let fetched = client
798            .get_clips_by_ids(http, &all, self.concurrency as usize)
799            .await?;
800
801        let mut returned: HashSet<String> = HashSet::new();
802        let mut progressed = false;
803        for clip in fetched {
804            returned.insert(clip.id.clone());
805            if self.insert_ancestor(clip) {
806                progressed = true;
807            }
808        }
809
810        for id in &frontier_batch {
811            if returned.contains(id) {
812                continue;
813            }
814            match client.get_clip_parent(http, id).await? {
815                Some(parent) => {
816                    let parent_id = parent.id.clone();
817                    self.insert_ancestor(parent);
818                    self.bridges.insert(id.clone(), parent_id);
819                    progressed = true;
820                }
821                None => {
822                    self.external.insert(id.clone());
823                    progressed = true;
824                }
825            }
826        }
827
828        Ok(progressed)
829    }
830
831    /// Same-owner `clip_root` ids across the current index, as extra root
832    /// candidates for gap-fill. Foreign-owned roots are excluded (fail-closed by
833    /// handle) so a foreign remix source is never folded into the owner's album.
834    fn clip_root_seeds(&self) -> Vec<String> {
835        let mut seeds = Vec::new();
836        for clip in self.index.values() {
837            for edge in attribution_edges(clip) {
838                if edge.same_owner {
839                    seeds.push(edge.parent_id);
840                }
841            }
842        }
843        seeds
844    }
845
846    /// Add a gap-filled ancestor to the index, tracking it as an ancestor-only
847    /// clip. Returns whether it was newly added.
848    fn insert_ancestor(&mut self, clip: Clip) -> bool {
849        if clip.id.is_empty() || self.index.contains_key(&clip.id) {
850            return false;
851        }
852        self.gap_filled.insert(clip.id.clone());
853        self.index.insert(clip.id.clone(), clip);
854        true
855    }
856
857    /// Whether an id is already resolvable without another fetch.
858    fn known(&self, id: &str) -> bool {
859        self.index.contains_key(id)
860            || self.archived_parents.contains_key(id)
861            || self.bridges.contains_key(id)
862            || self.external.contains(id)
863    }
864
865    /// Mark every still-unresolved blocked target as external at the ancestor it
866    /// stalled on.
867    fn finalise_external(&mut self, blocked: &[(String, String)]) {
868        for (target, missing) in blocked {
869            if self.memo.contains_key(target) {
870                continue;
871            }
872            let info = self.terminal(missing, ResolveStatus::External);
873            self.memo.insert(target.clone(), info);
874        }
875    }
876
877    /// Build a [`RootInfo`] rooted at `id`, titled from the index when present.
878    fn terminal(&self, id: &str, status: ResolveStatus) -> RootInfo {
879        RootInfo {
880            root_id: id.to_string(),
881            root_title: self.title_of(id),
882            status,
883        }
884    }
885
886    /// The title of an indexed clip, or empty when it is not in the index.
887    fn title_of(&self, id: &str) -> String {
888        self.index
889            .get(id)
890            .map_or_else(String::new, |clip| clip.title.clone())
891    }
892
893    /// Record `info` as the root for every node on `chain`.
894    fn assign(&mut self, chain: &[String], info: &RootInfo) {
895        for id in chain {
896            self.memo.insert(id.clone(), info.clone());
897        }
898    }
899
900    /// Project the memo onto the input clips (so every one has a root entry) and
901    /// collect the gap-filled ancestors, sorted by id for a deterministic order.
902    fn into_resolution(self, clips: &[Clip]) -> Resolution {
903        let mut roots = HashMap::with_capacity(clips.len());
904        for clip in clips {
905            let info = self
906                .memo
907                .get(&clip.id)
908                .cloned()
909                .unwrap_or_else(|| RootInfo {
910                    root_id: clip.id.clone(),
911                    root_title: clip.title.clone(),
912                    status: ResolveStatus::Unresolved,
913                });
914            roots.insert(clip.id.clone(), info);
915        }
916
917        let mut gap_filled: Vec<Clip> = self
918            .gap_filled
919            .iter()
920            .filter_map(|id| self.index.get(id).cloned())
921            .collect();
922        gap_filled.sort_by(|a, b| a.id.cmp(&b.id));
923
924        let mut bridges: Vec<(String, String)> = self
925            .bridges
926            .iter()
927            .map(|(child, parent)| (child.clone(), parent.clone()))
928            .collect();
929        bridges.sort();
930
931        Resolution {
932            roots,
933            gap_filled,
934            bridges,
935        }
936    }
937}
938
939#[cfg(test)]
940mod tests {
941    use super::*;
942    use crate::auth::ClerkAuth;
943    use crate::model::HistoryEntry;
944    use crate::testutil::{RecordingClock, Reply, ScriptedHttp};
945
946    fn history(id: &str) -> HistoryEntry {
947        HistoryEntry {
948            id: id.to_owned(),
949            ..Default::default()
950        }
951    }
952
953    // A clean six-clip chain modelled on the real `chain1` grounding data:
954    // upsample -> cover -> upsample -> cover -> edit -> root. For every hop the
955    // op pointer and `edited_clip_id` agree, as they do in the live shape.
956    fn chain1_clips() -> Vec<Clip> {
957        vec![
958            Clip {
959                id: "40068b49".into(),
960                title: "Zac and the Sea Eagles (Lullaby Version)".into(),
961                clip_type: "upsample".into(),
962                task: "upsample".into(),
963                is_remix: true,
964                upsample_clip_id: "52962dae".into(),
965                edited_clip_id: "52962dae".into(),
966                ..Default::default()
967            },
968            Clip {
969                id: "52962dae".into(),
970                title: "Zac and the Sea Eagles (Edit) (Remastered)".into(),
971                clip_type: "gen".into(),
972                task: "cover".into(),
973                is_remix: true,
974                cover_clip_id: "536e1b92".into(),
975                edited_clip_id: "536e1b92".into(),
976                ..Default::default()
977            },
978            Clip {
979                id: "536e1b92".into(),
980                title: "Zac and the Sea Eagles (Edit) (Remastered)".into(),
981                clip_type: "upsample".into(),
982                task: "upsample".into(),
983                is_remix: true,
984                upsample_clip_id: "b9f27ee1".into(),
985                edited_clip_id: "b9f27ee1".into(),
986                ..Default::default()
987            },
988            Clip {
989                id: "b9f27ee1".into(),
990                title: "Zac and the Sea Eagles (Edit)".into(),
991                clip_type: "gen".into(),
992                task: "cover".into(),
993                is_remix: true,
994                cover_clip_id: "c1997d52".into(),
995                edited_clip_id: "c1997d52".into(),
996                ..Default::default()
997            },
998            Clip {
999                id: "c1997d52".into(),
1000                title: "Zac and the Sea Eagles (Rework)".into(),
1001                clip_type: "edit_v3_export".into(),
1002                edited_clip_id: "dfb59a04".into(),
1003                ..Default::default()
1004            },
1005            Clip {
1006                id: "dfb59a04".into(),
1007                title: "Zac and the Sea Eagles".into(),
1008                clip_type: "gen".into(),
1009                ..Default::default()
1010            },
1011        ]
1012    }
1013
1014    fn authed_client(http: &ScriptedHttp) -> SunoClient<RecordingClock> {
1015        let auth = ClerkAuth::new("eyJtoken");
1016        pollster::block_on(auth.authenticate(http)).unwrap();
1017        SunoClient::new(auth, RecordingClock::new())
1018    }
1019
1020    #[test]
1021    fn edge_type_labels_read_naturally() {
1022        assert_eq!(EdgeType::Cover.label(), "Cover of");
1023        assert_eq!(EdgeType::Remaster.label(), "Remaster of");
1024        assert_eq!(EdgeType::SpeedEdit.label(), "Speed-edited from");
1025        assert_eq!(EdgeType::Edit.label(), "Edited from");
1026        assert_eq!(EdgeType::Extend.label(), "Extended from");
1027        assert_eq!(EdgeType::SectionReplace.label(), "Section replaced from");
1028        assert_eq!(EdgeType::Stitch.label(), "Stitched from");
1029        assert_eq!(EdgeType::Derived.label(), "Derived from");
1030        assert_eq!(EdgeType::Uploaded.label(), "Uploaded");
1031    }
1032
1033    #[test]
1034    fn classifies_remaster_cover_edit_and_root_across_chain1() {
1035        let clips = chain1_clips();
1036
1037        assert_eq!(edge_type(&clips[0]), Some(EdgeType::Remaster));
1038        assert_eq!(
1039            immediate_parent(&clips[0]),
1040            Some(("52962dae".into(), EdgeType::Remaster))
1041        );
1042
1043        assert_eq!(edge_type(&clips[1]), Some(EdgeType::Cover));
1044        assert_eq!(
1045            immediate_parent(&clips[1]),
1046            Some(("536e1b92".into(), EdgeType::Cover))
1047        );
1048
1049        assert_eq!(edge_type(&clips[4]), Some(EdgeType::Edit));
1050        assert_eq!(
1051            immediate_parent(&clips[4]),
1052            Some(("dfb59a04".into(), EdgeType::Edit))
1053        );
1054
1055        assert_eq!(edge_type(&clips[5]), None);
1056        assert_eq!(immediate_parent(&clips[5]), None);
1057    }
1058
1059    #[test]
1060    fn classifies_speed_edit_from_speed_pointer_without_edited() {
1061        // Real `chain2` shape: edit_speed carries speed_clip_id and no edited_clip_id.
1062        let clip = Clip {
1063            id: "6e5193b1".into(),
1064            title: "Go Xavi Go, Fast. (Drum n' Bass Version)".into(),
1065            clip_type: "edit_speed".into(),
1066            is_remix: true,
1067            speed_clip_id: "2b69882c".into(),
1068            ..Default::default()
1069        };
1070        assert_eq!(edge_type(&clip), Some(EdgeType::SpeedEdit));
1071        assert_eq!(
1072            immediate_parent(&clip),
1073            Some(("2b69882c".into(), EdgeType::SpeedEdit))
1074        );
1075    }
1076
1077    #[test]
1078    fn empty_task_gen_is_a_root() {
1079        // Real `chain2` root: gen with an empty task string.
1080        let clip = Clip {
1081            id: "b4f16694".into(),
1082            title: "Go Xavi Go, Fast.".into(),
1083            clip_type: "gen".into(),
1084            task: String::new(),
1085            ..Default::default()
1086        };
1087        assert_eq!(edge_type(&clip), None);
1088        assert_eq!(immediate_parent(&clip), None);
1089    }
1090
1091    #[test]
1092    fn classifies_extend_from_history_head() {
1093        let clip = Clip {
1094            id: "9a3dcb67".into(),
1095            title: "Extended".into(),
1096            clip_type: "gen".into(),
1097            task: "extend".into(),
1098            edited_clip_id: "0a3c311a".into(),
1099            history: vec![HistoryEntry {
1100                id: "0a3c311a".into(),
1101                continue_at: Some(115.35),
1102                ..Default::default()
1103            }],
1104            ..Default::default()
1105        };
1106        assert_eq!(edge_type(&clip), Some(EdgeType::Extend));
1107        assert_eq!(
1108            immediate_parent(&clip),
1109            Some(("0a3c311a".into(), EdgeType::Extend))
1110        );
1111    }
1112
1113    #[test]
1114    fn classifies_infill_with_override_history_precedence() {
1115        // Real infill shape: override_history wins over future, history, and edited.
1116        let clip = Clip {
1117            id: "c0ce5c48".into(),
1118            title: "Section replaced".into(),
1119            clip_type: "gen".into(),
1120            task: "infill".into(),
1121            edited_clip_id: "cf37e05f".into(),
1122            override_history_clip_id: "d3d28e59".into(),
1123            override_future_clip_id: "ea88571e".into(),
1124            history: vec![HistoryEntry {
1125                id: "cf37e05f".into(),
1126                infill: true,
1127                infill_start_s: Some(20.4),
1128                infill_end_s: Some(24.92),
1129                ..Default::default()
1130            }],
1131            ..Default::default()
1132        };
1133        assert_eq!(edge_type(&clip), Some(EdgeType::SectionReplace));
1134        assert_eq!(
1135            immediate_parent(&clip),
1136            Some(("d3d28e59".into(), EdgeType::SectionReplace))
1137        );
1138    }
1139
1140    #[test]
1141    fn fixed_infill_is_also_section_replace() {
1142        let clip = Clip {
1143            task: "fixed_infill".into(),
1144            override_history_clip_id: "past".into(),
1145            edited_clip_id: "edited".into(),
1146            ..Default::default()
1147        };
1148        assert_eq!(edge_type(&clip), Some(EdgeType::SectionReplace));
1149        assert_eq!(
1150            immediate_parent(&clip),
1151            Some(("past".into(), EdgeType::SectionReplace))
1152        );
1153    }
1154
1155    #[test]
1156    fn classifies_stitch_from_concat_base() {
1157        // Real concat shape: type=concat, base segment first in concat_history.
1158        let clip = Clip {
1159            id: "43ba1ce3".into(),
1160            title: "Stitched".into(),
1161            clip_type: "concat".into(),
1162            concat_history: vec![
1163                HistoryEntry {
1164                    id: "ead64fbe".into(),
1165                    continue_at: Some(149.19),
1166                    ..Default::default()
1167                },
1168                history("da47b824"),
1169            ],
1170            ..Default::default()
1171        };
1172        assert_eq!(edge_type(&clip), Some(EdgeType::Stitch));
1173        assert_eq!(
1174            immediate_parent(&clip),
1175            Some(("ead64fbe".into(), EdgeType::Stitch))
1176        );
1177    }
1178
1179    #[test]
1180    fn inherited_concat_history_without_concat_type_is_not_a_stitch() {
1181        // Suno copies a parent stitch's concat_history onto derived clips. A
1182        // plain `gen` that merely carries it (no type=concat, no other marker)
1183        // must NOT be read as a stitch; here it has no parent pointer, so it is
1184        // a root.
1185        let clip = Clip {
1186            clip_type: "gen".into(),
1187            concat_history: vec![history("base"), history("second")],
1188            ..Default::default()
1189        };
1190        assert_eq!(edge_type(&clip), None);
1191        assert_eq!(immediate_parent(&clip), None);
1192    }
1193
1194    #[test]
1195    fn cover_of_a_stitch_classifies_as_cover_not_stitch() {
1196        // A cover OF a stitched track inherits the parent's concat_history but is
1197        // itself a cover: it must classify as Cover and parent via cover_clip_id,
1198        // never as a Stitch pointing at an inherited concat segment.
1199        let clip = Clip {
1200            id: "cov".into(),
1201            title: "Cover of a stitch".into(),
1202            clip_type: "gen".into(),
1203            task: "cover".into(),
1204            cover_clip_id: "stitch-parent".into(),
1205            edited_clip_id: "stitch-parent".into(),
1206            concat_history: vec![history("inherited-base"), history("inherited-seg")],
1207            ..Default::default()
1208        };
1209        assert_eq!(edge_type(&clip), Some(EdgeType::Cover));
1210        assert_eq!(
1211            immediate_parent(&clip),
1212            Some(("stitch-parent".into(), EdgeType::Cover))
1213        );
1214    }
1215
1216    #[test]
1217    fn upload_is_a_root() {
1218        let clip = Clip {
1219            id: "4770ef56".into(),
1220            title: "Uploaded audio".into(),
1221            clip_type: "upload".into(),
1222            ..Default::default()
1223        };
1224        assert_eq!(edge_type(&clip), None);
1225        assert_eq!(immediate_parent(&clip), None);
1226    }
1227
1228    #[test]
1229    fn edited_only_clip_is_derived() {
1230        // A task the resolver has no specific rule for, but a parent pointer.
1231        let clip = Clip {
1232            clip_type: "gen".into(),
1233            task: "chop_sample_condition".into(),
1234            edited_clip_id: "parent-x".into(),
1235            ..Default::default()
1236        };
1237        assert_eq!(edge_type(&clip), Some(EdgeType::Derived));
1238        assert_eq!(
1239            immediate_parent(&clip),
1240            Some(("parent-x".into(), EdgeType::Derived))
1241        );
1242    }
1243
1244    #[test]
1245    fn unmarked_clip_without_pointer_is_a_root() {
1246        let clip = Clip {
1247            clip_type: "gen".into(),
1248            task: "chop_sample_condition".into(),
1249            ..Default::default()
1250        };
1251        assert_eq!(edge_type(&clip), None);
1252        assert_eq!(immediate_parent(&clip), None);
1253    }
1254
1255    #[test]
1256    fn is_remix_does_not_change_classification() {
1257        let base = Clip {
1258            clip_type: "gen".into(),
1259            task: "cover".into(),
1260            cover_clip_id: "root-1".into(),
1261            edited_clip_id: "root-1".into(),
1262            ..Default::default()
1263        };
1264        let mut with_flag = base.clone();
1265        with_flag.is_remix = true;
1266        let mut without_flag = base;
1267        without_flag.is_remix = false;
1268
1269        assert_eq!(edge_type(&with_flag), edge_type(&without_flag));
1270        assert_eq!(
1271            immediate_parent(&with_flag),
1272            immediate_parent(&without_flag)
1273        );
1274        assert_eq!(edge_type(&with_flag), Some(EdgeType::Cover));
1275        assert_eq!(
1276            immediate_parent(&with_flag),
1277            Some(("root-1".into(), EdgeType::Cover))
1278        );
1279    }
1280
1281    #[test]
1282    fn zero_uuid_cover_falls_back_to_edited() {
1283        let clip = Clip {
1284            clip_type: "gen".into(),
1285            task: "cover".into(),
1286            cover_clip_id: ZERO_UUID.into(),
1287            edited_clip_id: "real-parent".into(),
1288            ..Default::default()
1289        };
1290        assert_eq!(
1291            immediate_parent(&clip),
1292            Some(("real-parent".into(), EdgeType::Cover))
1293        );
1294    }
1295
1296    #[test]
1297    fn m_prefix_is_stripped_from_history_and_concat_ids() {
1298        let extend = Clip {
1299            clip_type: "gen".into(),
1300            task: "extend".into(),
1301            history: vec![history("m_abc123")],
1302            ..Default::default()
1303        };
1304        assert_eq!(
1305            immediate_parent(&extend),
1306            Some(("abc123".into(), EdgeType::Extend))
1307        );
1308
1309        let stitch = Clip {
1310            clip_type: "concat".into(),
1311            concat_history: vec![history("m_base"), history("m_second")],
1312            ..Default::default()
1313        };
1314        let edges = lineage_edges(&stitch);
1315        assert_eq!(edges[0].parent_id, "base");
1316        assert_eq!(edges[1].parent_id, "second");
1317        assert_eq!(edges[1].role, EdgeRole::Secondary);
1318    }
1319
1320    #[test]
1321    fn lineage_edges_of_a_root_is_empty() {
1322        let clip = Clip {
1323            clip_type: "gen".into(),
1324            ..Default::default()
1325        };
1326        assert!(lineage_edges(&clip).is_empty());
1327    }
1328
1329    #[test]
1330    fn lineage_edges_records_stitch_secondaries_in_order() {
1331        let clip = Clip {
1332            clip_type: "concat".into(),
1333            concat_history: vec![history("base"), history("seg1"), history("seg2")],
1334            ..Default::default()
1335        };
1336        let edges = lineage_edges(&clip);
1337        assert_eq!(
1338            edges,
1339            vec![
1340                Edge {
1341                    parent_id: "base".into(),
1342                    edge_type: EdgeType::Stitch,
1343                    role: EdgeRole::Primary,
1344                    ordinal: 0,
1345                    source_field: "concat_history",
1346                },
1347                Edge {
1348                    parent_id: "seg1".into(),
1349                    edge_type: EdgeType::Stitch,
1350                    role: EdgeRole::Secondary,
1351                    ordinal: 1,
1352                    source_field: "concat_history",
1353                },
1354                Edge {
1355                    parent_id: "seg2".into(),
1356                    edge_type: EdgeType::Stitch,
1357                    role: EdgeRole::Secondary,
1358                    ordinal: 2,
1359                    source_field: "concat_history",
1360                },
1361            ]
1362        );
1363    }
1364
1365    #[test]
1366    fn lineage_edges_emits_secondaries_when_the_primary_is_absent() {
1367        // A stitch whose base segment id is empty still has real secondary
1368        // segments: they must be emitted (with their own ordinals) rather than
1369        // dropped for want of a primary.
1370        let clip = Clip {
1371            clip_type: "concat".into(),
1372            concat_history: vec![history(""), history("seg1"), history("seg2")],
1373            ..Default::default()
1374        };
1375        let edges = lineage_edges(&clip);
1376        assert_eq!(
1377            edges,
1378            vec![
1379                Edge {
1380                    parent_id: "seg1".into(),
1381                    edge_type: EdgeType::Stitch,
1382                    role: EdgeRole::Secondary,
1383                    ordinal: 1,
1384                    source_field: "concat_history",
1385                },
1386                Edge {
1387                    parent_id: "seg2".into(),
1388                    edge_type: EdgeType::Stitch,
1389                    role: EdgeRole::Secondary,
1390                    ordinal: 2,
1391                    source_field: "concat_history",
1392                },
1393            ],
1394            "secondaries survive an empty primary base segment"
1395        );
1396    }
1397
1398    #[test]
1399    fn lineage_edges_records_infill_future_as_secondary() {
1400        let clip = Clip {
1401            task: "infill".into(),
1402            override_history_clip_id: "past".into(),
1403            override_future_clip_id: "future".into(),
1404            ..Default::default()
1405        };
1406        let edges = lineage_edges(&clip);
1407        assert_eq!(edges[0].parent_id, "past");
1408        assert_eq!(edges[0].role, EdgeRole::Primary);
1409        assert_eq!(edges[0].source_field, "override_history_clip_id");
1410        assert_eq!(
1411            edges[1],
1412            Edge {
1413                parent_id: "future".into(),
1414                edge_type: EdgeType::SectionReplace,
1415                role: EdgeRole::Secondary,
1416                ordinal: 1,
1417                source_field: "override_future_clip_id",
1418            }
1419        );
1420    }
1421
1422    #[test]
1423    fn resolve_roots_walks_a_connected_chain_with_no_http() {
1424        let http = ScriptedHttp::new();
1425        let client = SunoClient::new(ClerkAuth::new("eyJtoken"), RecordingClock::new());
1426        let clips = chain1_clips();
1427
1428        let roots = pollster::block_on(resolve_roots(
1429            &clips,
1430            &HashMap::new(),
1431            &client,
1432            &http,
1433            ResolveOpts::default(),
1434        ))
1435        .unwrap()
1436        .roots;
1437
1438        assert!(
1439            http.calls().is_empty(),
1440            "a fully-connected chain must never touch the network"
1441        );
1442        assert_eq!(roots.len(), clips.len());
1443        for clip in &clips {
1444            let info = &roots[&clip.id];
1445            assert_eq!(info.status, ResolveStatus::Resolved);
1446            assert_eq!(info.root_id, "dfb59a04");
1447            assert_eq!(info.root_title, "Zac and the Sea Eagles");
1448        }
1449    }
1450
1451    #[test]
1452    fn resolve_roots_gap_fills_a_missing_ancestor_by_id() {
1453        let cover = Clip {
1454            id: "child".into(),
1455            title: "Cover".into(),
1456            clip_type: "gen".into(),
1457            task: "cover".into(),
1458            cover_clip_id: "root".into(),
1459            edited_clip_id: "root".into(),
1460            ..Default::default()
1461        };
1462        let root_clip = serde_json::json!({
1463            "id": "root", "title": "Original", "status": "complete",
1464            "metadata": {"type": "gen"}
1465        })
1466        .to_string();
1467        let http = ScriptedHttp::new()
1468            .with_auth()
1469            .route("/api/clip/root", Reply::json(&root_clip));
1470        let client = authed_client(&http);
1471
1472        let roots = pollster::block_on(resolve_roots(
1473            &[cover],
1474            &HashMap::new(),
1475            &client,
1476            &http,
1477            ResolveOpts::default(),
1478        ))
1479        .unwrap()
1480        .roots;
1481
1482        let info = &roots["child"];
1483        assert_eq!(info.status, ResolveStatus::Resolved);
1484        assert_eq!(info.root_id, "root");
1485        assert_eq!(info.root_title, "Original");
1486        assert_eq!(http.count("/api/clip/root"), 1);
1487        assert_eq!(
1488            http.count("/api/clips/parent"),
1489            0,
1490            "the parent endpoint must not be used when the per-id fetch succeeds"
1491        );
1492    }
1493
1494    #[test]
1495    fn resolve_roots_hops_through_a_purged_ancestor_via_the_archive() {
1496        // A cover whose parent (an intermediate remix) is absent from this run's
1497        // clips AND unfetchable from the network (Suno purged it), but whose
1498        // parent link was persisted on an earlier run. The archived edge lets
1499        // the walk hop through the purged intermediate to the true root, with no
1500        // network call, instead of self-rooting into a duplicate album.
1501        let child = Clip {
1502            id: "child".into(),
1503            title: "Neue Deutsche Harte".into(),
1504            clip_type: "gen".into(),
1505            task: "cover".into(),
1506            cover_clip_id: "mid".into(),
1507            edited_clip_id: "mid".into(),
1508            ..Default::default()
1509        };
1510        let root = Clip {
1511            id: "root".into(),
1512            title: "Original".into(),
1513            clip_type: "gen".into(),
1514            ..Default::default()
1515        };
1516        // "mid" is neither a live clip nor routed on the network double.
1517        let archived: HashMap<String, String> = [("mid".to_owned(), "root".to_owned())]
1518            .into_iter()
1519            .collect();
1520        let http = ScriptedHttp::new().with_auth();
1521        let client = authed_client(&http);
1522
1523        let resolution = pollster::block_on(resolve_roots(
1524            &[child, root],
1525            &archived,
1526            &client,
1527            &http,
1528            ResolveOpts::default(),
1529        ))
1530        .unwrap();
1531
1532        let info = &resolution.roots["child"];
1533        assert_eq!(info.status, ResolveStatus::Resolved);
1534        assert_eq!(
1535            info.root_id, "root",
1536            "hopped through the purged intermediate"
1537        );
1538        assert_eq!(info.root_title, "Original");
1539        assert_eq!(
1540            http.count("/api/clip/mid"),
1541            0,
1542            "the purged intermediate is never fetched: the archived edge bridges it"
1543        );
1544        assert!(
1545            resolution.gap_filled.is_empty(),
1546            "an archived hop must not add a download candidate"
1547        );
1548    }
1549
1550    #[test]
1551    fn resolve_roots_prefers_a_live_pointer_over_a_stale_archived_edge() {
1552        // When a clip is present live, its own (fresh) pointer wins; a stale
1553        // archived edge for that same clip is ignored (index before archive).
1554        let child = Clip {
1555            id: "child".into(),
1556            title: "Cover".into(),
1557            clip_type: "gen".into(),
1558            task: "cover".into(),
1559            cover_clip_id: "live_root".into(),
1560            edited_clip_id: "live_root".into(),
1561            ..Default::default()
1562        };
1563        let live_root = Clip {
1564            id: "live_root".into(),
1565            title: "Live Root".into(),
1566            clip_type: "gen".into(),
1567            ..Default::default()
1568        };
1569        let archived: HashMap<String, String> = [("child".to_owned(), "stale_root".to_owned())]
1570            .into_iter()
1571            .collect();
1572        let http = ScriptedHttp::new().with_auth();
1573        let client = authed_client(&http);
1574
1575        let info = pollster::block_on(resolve_roots(
1576            &[child, live_root],
1577            &archived,
1578            &client,
1579            &http,
1580            ResolveOpts::default(),
1581        ))
1582        .unwrap()
1583        .roots["child"]
1584            .clone();
1585
1586        assert_eq!(
1587            info.root_id, "live_root",
1588            "the live pointer wins over a stale archived edge"
1589        );
1590        assert_eq!(info.status, ResolveStatus::Resolved);
1591    }
1592
1593    #[test]
1594    fn resolve_roots_terminates_on_a_cycle_through_archived_edges() {
1595        // Archived edges form a cycle a -> b -> a; the walk must terminate via
1596        // the visited guard, never loop.
1597        let child = Clip {
1598            id: "child".into(),
1599            title: "Cover".into(),
1600            clip_type: "gen".into(),
1601            task: "cover".into(),
1602            cover_clip_id: "a".into(),
1603            edited_clip_id: "a".into(),
1604            ..Default::default()
1605        };
1606        let archived: HashMap<String, String> = [
1607            ("a".to_owned(), "b".to_owned()),
1608            ("b".to_owned(), "a".to_owned()),
1609        ]
1610        .into_iter()
1611        .collect();
1612        let http = ScriptedHttp::new().with_auth();
1613        let client = authed_client(&http);
1614
1615        let info = pollster::block_on(resolve_roots(
1616            &[child],
1617            &archived,
1618            &client,
1619            &http,
1620            ResolveOpts::default(),
1621        ))
1622        .unwrap()
1623        .roots["child"]
1624            .clone();
1625
1626        assert_eq!(
1627            info.status,
1628            ResolveStatus::Cycle,
1629            "an archived cycle terminates as a cycle, not an infinite loop"
1630        );
1631    }
1632
1633    #[test]
1634    fn resolve_roots_respects_the_hop_cap_through_archived_edges() {
1635        // A long archived chain past the hop cap terminates as unresolved,
1636        // without any network fetch.
1637        let child = Clip {
1638            id: "child".into(),
1639            title: "Cover".into(),
1640            clip_type: "gen".into(),
1641            task: "cover".into(),
1642            cover_clip_id: "a".into(),
1643            edited_clip_id: "a".into(),
1644            ..Default::default()
1645        };
1646        let archived: HashMap<String, String> = [("a", "b"), ("b", "c"), ("c", "d"), ("d", "e")]
1647            .iter()
1648            .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
1649            .collect();
1650        let opts = ResolveOpts {
1651            max_gap_fills: 0,
1652            hop_cap: 2,
1653            concurrency: 4,
1654        };
1655        let http = ScriptedHttp::new().with_auth();
1656        let client = authed_client(&http);
1657
1658        let info = pollster::block_on(resolve_roots(&[child], &archived, &client, &http, opts))
1659            .unwrap()
1660            .roots["child"]
1661            .clone();
1662
1663        assert_eq!(
1664            info.status,
1665            ResolveStatus::Unresolved,
1666            "a chain past the hop cap terminates as unresolved"
1667        );
1668        assert_eq!(
1669            http.count("/api/clip"),
1670            0,
1671            "archived hops need no clip fetch"
1672        );
1673    }
1674
1675    #[test]
1676    fn resolve_roots_without_archive_self_roots_a_purged_intermediate() {
1677        // The same clip WITHOUT the archived edge: the intermediate is missing
1678        // and unfetchable, so resolution stalls at it (external) rather than
1679        // reaching the true root. This is the pre-fix behaviour the archive
1680        // prevents.
1681        let child = Clip {
1682            id: "child".into(),
1683            title: "Neue Deutsche Harte".into(),
1684            clip_type: "gen".into(),
1685            task: "cover".into(),
1686            cover_clip_id: "mid".into(),
1687            edited_clip_id: "mid".into(),
1688            ..Default::default()
1689        };
1690        let root = Clip {
1691            id: "root".into(),
1692            title: "Original".into(),
1693            clip_type: "gen".into(),
1694            ..Default::default()
1695        };
1696        let http = ScriptedHttp::new()
1697            .with_auth()
1698            .route("/api/clip/mid", Reply::status(404))
1699            .route("/api/clips/parent", Reply::status(404));
1700        let client = authed_client(&http);
1701
1702        let info = pollster::block_on(resolve_roots(
1703            &[child, root],
1704            &HashMap::new(),
1705            &client,
1706            &http,
1707            ResolveOpts::default(),
1708        ))
1709        .unwrap()
1710        .roots["child"]
1711            .clone();
1712
1713        assert_ne!(
1714            info.root_id, "root",
1715            "without the archive, resolution cannot reach the true root"
1716        );
1717        assert_ne!(
1718            info.status,
1719            ResolveStatus::Resolved,
1720            "the purged intermediate cannot be cleanly resolved without the archive"
1721        );
1722    }
1723
1724    #[test]
1725    fn resolve_roots_returns_gap_filled_ancestors_for_archival() {
1726        // The fetched (often trashed) ancestor is handed back so a later phase
1727        // can archive it before Suno's purge (HARDENING H4). It resolves the
1728        // child's root yet stays out of the roots (download) set.
1729        let cover = Clip {
1730            id: "child".into(),
1731            title: "Cover".into(),
1732            clip_type: "gen".into(),
1733            task: "cover".into(),
1734            cover_clip_id: "root".into(),
1735            edited_clip_id: "root".into(),
1736            ..Default::default()
1737        };
1738        let root_clip = serde_json::json!({
1739            "id": "root", "title": "Trashed Original", "status": "complete",
1740            "metadata": {"type": "gen"}
1741        })
1742        .to_string();
1743        let http = ScriptedHttp::new()
1744            .with_auth()
1745            .route("/api/clip/root", Reply::json(&root_clip));
1746        let client = authed_client(&http);
1747
1748        let resolution = pollster::block_on(resolve_roots(
1749            &[cover],
1750            &HashMap::new(),
1751            &client,
1752            &http,
1753            ResolveOpts::default(),
1754        ))
1755        .unwrap();
1756
1757        assert_eq!(resolution.gap_filled.len(), 1);
1758        assert_eq!(resolution.gap_filled[0].id, "root");
1759        assert_eq!(resolution.gap_filled[0].title, "Trashed Original");
1760        assert_eq!(resolution.roots["child"].root_id, "root");
1761        assert!(
1762            !resolution.roots.contains_key("root"),
1763            "gap-filled ancestors must never enter the roots set"
1764        );
1765    }
1766
1767    #[test]
1768    fn resolve_roots_falls_back_to_the_parent_endpoint() {
1769        let cover = Clip {
1770            id: "child".into(),
1771            title: "Cover".into(),
1772            clip_type: "gen".into(),
1773            task: "cover".into(),
1774            cover_clip_id: "missing".into(),
1775            edited_clip_id: "missing".into(),
1776            ..Default::default()
1777        };
1778        // The per-id fetch of `missing` 404s; the parent endpoint yields its
1779        // parent (the root), which the walk then bridges over `missing` to.
1780        let parent_body = serde_json::json!({
1781            "id": "root", "title": "Original", "status": "complete",
1782            "metadata": {"type": "gen"}
1783        })
1784        .to_string();
1785        let http = ScriptedHttp::new()
1786            .with_auth()
1787            .route("/api/clip/missing", Reply::status(404))
1788            .route("/api/clips/parent", Reply::json(&parent_body));
1789        let client = authed_client(&http);
1790
1791        let roots = pollster::block_on(resolve_roots(
1792            &[cover],
1793            &HashMap::new(),
1794            &client,
1795            &http,
1796            ResolveOpts::default(),
1797        ))
1798        .unwrap()
1799        .roots;
1800
1801        let info = &roots["child"];
1802        assert_eq!(info.status, ResolveStatus::Resolved);
1803        assert_eq!(info.root_id, "root");
1804        assert_eq!(info.root_title, "Original");
1805        assert!(
1806            http.count("/api/clips/parent?clip_id=missing") >= 1,
1807            "the missing ancestor must be resolved via the parent endpoint"
1808        );
1809    }
1810
1811    #[test]
1812    fn resolve_roots_detects_a_cycle_without_looping() {
1813        let a = Clip {
1814            id: "a".into(),
1815            title: "A".into(),
1816            clip_type: "gen".into(),
1817            task: "cover".into(),
1818            cover_clip_id: "b".into(),
1819            edited_clip_id: "b".into(),
1820            ..Default::default()
1821        };
1822        let b = Clip {
1823            id: "b".into(),
1824            title: "B".into(),
1825            clip_type: "gen".into(),
1826            task: "cover".into(),
1827            cover_clip_id: "a".into(),
1828            edited_clip_id: "a".into(),
1829            ..Default::default()
1830        };
1831        let http = ScriptedHttp::new();
1832        let client = SunoClient::new(ClerkAuth::new("eyJtoken"), RecordingClock::new());
1833
1834        let roots = pollster::block_on(resolve_roots(
1835            &[a, b],
1836            &HashMap::new(),
1837            &client,
1838            &http,
1839            ResolveOpts::default(),
1840        ))
1841        .unwrap()
1842        .roots;
1843
1844        assert_eq!(roots["a"].status, ResolveStatus::Cycle);
1845        assert_eq!(roots["b"].status, ResolveStatus::Cycle);
1846        assert!(http.calls().is_empty());
1847    }
1848
1849    #[test]
1850    fn resolve_roots_marks_external_when_the_budget_is_exhausted() {
1851        // child -> m1 (missing) -> m2 (missing) -> ...; only one gap-fill allowed.
1852        let child = Clip {
1853            id: "child".into(),
1854            title: "Child".into(),
1855            clip_type: "gen".into(),
1856            task: "cover".into(),
1857            cover_clip_id: "m1".into(),
1858            edited_clip_id: "m1".into(),
1859            ..Default::default()
1860        };
1861        let m1_clip = serde_json::json!({
1862            "id": "m1", "title": "Middle", "status": "complete",
1863            "metadata": {"type": "gen", "task": "cover", "cover_clip_id": "m2", "edited_clip_id": "m2"}
1864        })
1865        .to_string();
1866        let http = ScriptedHttp::new()
1867            .with_auth()
1868            .route("/api/clip/m1", Reply::json(&m1_clip));
1869        let client = authed_client(&http);
1870        let opts = ResolveOpts {
1871            max_gap_fills: 1,
1872            hop_cap: 64,
1873            concurrency: 4,
1874        };
1875
1876        let roots = pollster::block_on(resolve_roots(
1877            &[child],
1878            &HashMap::new(),
1879            &client,
1880            &http,
1881            opts,
1882        ))
1883        .unwrap()
1884        .roots;
1885
1886        let info = &roots["child"];
1887        assert_eq!(info.status, ResolveStatus::External);
1888        assert_eq!(
1889            info.root_id, "m2",
1890            "resolution stops at the first ancestor it could not fetch"
1891        );
1892        assert_eq!(http.count("/api/clip/m1"), 1);
1893        assert_eq!(
1894            http.count("/api/clip/m2"),
1895            0,
1896            "the gap-fill budget must not be exceeded"
1897        );
1898    }
1899
1900    #[test]
1901    fn resolve_roots_external_root_endpoint_stops_the_walk() {
1902        // The parent endpoint reporting no parent means an external root: the
1903        // ancestor exists on Suno but is outside the caller's library.
1904        let cover = Clip {
1905            id: "child".into(),
1906            title: "Cover".into(),
1907            clip_type: "gen".into(),
1908            task: "cover".into(),
1909            cover_clip_id: "outside".into(),
1910            edited_clip_id: "outside".into(),
1911            ..Default::default()
1912        };
1913        let http = ScriptedHttp::new()
1914            .with_auth()
1915            .route("/api/clip/outside", Reply::status(404))
1916            .route("/api/clips/parent", Reply::status(404));
1917        let client = authed_client(&http);
1918
1919        let roots = pollster::block_on(resolve_roots(
1920            &[cover],
1921            &HashMap::new(),
1922            &client,
1923            &http,
1924            ResolveOpts::default(),
1925        ))
1926        .unwrap()
1927        .roots;
1928
1929        let info = &roots["child"];
1930        assert_eq!(info.status, ResolveStatus::External);
1931        assert_eq!(info.root_id, "outside");
1932    }
1933
1934    fn clip_root(id: &str, handle: &str) -> crate::model::ClipRoot {
1935        crate::model::ClipRoot {
1936            id: id.to_owned(),
1937            handle: handle.to_owned(),
1938            ..Default::default()
1939        }
1940    }
1941
1942    #[test]
1943    fn attribution_edges_map_clip_roots_in_order() {
1944        let clip = Clip {
1945            id: "child".into(),
1946            handle: "me".into(),
1947            clip_attribution_type: "remix".into(),
1948            clip_roots: vec![
1949                clip_root("own-root", "me"),
1950                clip_root("foreign-root", "stranger"),
1951            ],
1952            ..Default::default()
1953        };
1954        let edges = attribution_edges(&clip);
1955        assert_eq!(edges.len(), 2);
1956        assert_eq!(
1957            edges[0],
1958            AttributionEdge {
1959                parent_id: "own-root".into(),
1960                edge_slug: "remix".into(),
1961                role: EdgeRole::Secondary,
1962                ordinal: 0,
1963                source_field: "clip_roots",
1964                same_owner: true,
1965            }
1966        );
1967        assert_eq!(edges[1].parent_id, "foreign-root");
1968        assert_eq!(edges[1].ordinal, 1);
1969        assert!(
1970            !edges[1].same_owner,
1971            "a differently-handled root is foreign, and still emits an edge"
1972        );
1973    }
1974
1975    #[test]
1976    fn attribution_edges_are_empty_without_clip_roots() {
1977        let clip = Clip {
1978            id: "child".into(),
1979            handle: "me".into(),
1980            ..Default::default()
1981        };
1982        assert!(attribution_edges(&clip).is_empty());
1983    }
1984
1985    #[test]
1986    fn attribution_edges_same_owner_is_fail_closed() {
1987        // Matching non-empty handles are same-owner; an empty handle on either
1988        // side, or a mismatch, is foreign (never fold a foreign remix in).
1989        let matched = Clip {
1990            handle: "me".into(),
1991            clip_roots: vec![clip_root("r", "me")],
1992            ..Default::default()
1993        };
1994        assert!(attribution_edges(&matched)[0].same_owner);
1995
1996        let clip_blank = Clip {
1997            handle: "".into(),
1998            clip_roots: vec![clip_root("r", "me")],
1999            ..Default::default()
2000        };
2001        assert!(
2002            !attribution_edges(&clip_blank)[0].same_owner,
2003            "an empty clip handle is fail-closed to foreign"
2004        );
2005
2006        let root_blank = Clip {
2007            handle: "me".into(),
2008            clip_roots: vec![clip_root("r", "   ")],
2009            ..Default::default()
2010        };
2011        assert!(
2012            !attribution_edges(&root_blank)[0].same_owner,
2013            "a whitespace-only root handle is fail-closed to foreign"
2014        );
2015    }
2016
2017    #[test]
2018    fn attribution_edges_skip_a_root_with_no_id_and_keep_contiguous_ordinals() {
2019        let clip = Clip {
2020            handle: "me".into(),
2021            clip_attribution_type: "remix".into(),
2022            clip_roots: vec![
2023                clip_root("", "me"),
2024                clip_root(ZERO_UUID, "me"),
2025                clip_root("real-root", "me"),
2026            ],
2027            ..Default::default()
2028        };
2029        let edges = attribution_edges(&clip);
2030        assert_eq!(edges.len(), 1, "empty and sentinel root ids are dropped");
2031        assert_eq!(edges[0].parent_id, "real-root");
2032        assert_eq!(edges[0].ordinal, 0, "ordinals stay contiguous after a skip");
2033    }
2034
2035    #[test]
2036    fn resolve_roots_seeds_a_same_owner_clip_root_but_not_a_foreign_one() {
2037        // A clip whose structural parent is missing triggers gap-fill. Its
2038        // same-owner clip_root is seeded into the same batch (an extra root
2039        // candidate), while its foreign-owned clip_root is NEVER fetched. The
2040        // structural walk alone still decides the root.
2041        let child = Clip {
2042            id: "child".into(),
2043            title: "Remix".into(),
2044            clip_type: "gen".into(),
2045            task: "cover".into(),
2046            cover_clip_id: "struct-parent".into(),
2047            edited_clip_id: "struct-parent".into(),
2048            handle: "me".into(),
2049            clip_attribution_type: "remix".into(),
2050            clip_roots: vec![
2051                clip_root("own-root", "me"),
2052                clip_root("foreign-root", "stranger"),
2053            ],
2054            ..Default::default()
2055        };
2056        let struct_parent = serde_json::json!({
2057            "id": "struct-parent", "title": "Structural Root", "status": "complete",
2058            "metadata": {"type": "gen"}
2059        })
2060        .to_string();
2061        let own_root = serde_json::json!({
2062            "id": "own-root", "title": "Attribution Root", "status": "complete",
2063            "metadata": {"type": "gen"}
2064        })
2065        .to_string();
2066        // The batch returns both the structural parent and the seeded same-owner
2067        // root in one request; the per-id routes remain only as the fallback.
2068        let batch = format!(r#"{{"clips":[{struct_parent},{own_root}]}}"#);
2069        let http = ScriptedHttp::new()
2070            .with_auth()
2071            .route("get_songs_by_ids", Reply::json(&batch))
2072            .route("/api/clip/struct-parent", Reply::json(&struct_parent))
2073            .route("/api/clip/own-root", Reply::json(&own_root));
2074        let client = authed_client(&http);
2075
2076        let resolution = pollster::block_on(resolve_roots(
2077            &[child],
2078            &HashMap::new(),
2079            &client,
2080            &http,
2081            ResolveOpts::default(),
2082        ))
2083        .unwrap();
2084
2085        // The structural walk (not clip_roots) decides the root.
2086        let info = &resolution.roots["child"];
2087        assert_eq!(info.status, ResolveStatus::Resolved);
2088        assert_eq!(info.root_id, "struct-parent");
2089
2090        assert_eq!(
2091            http.count("own-root"),
2092            1,
2093            "the same-owner clip_root is seeded and fetched exactly once"
2094        );
2095        assert_eq!(
2096            http.count("foreign-root"),
2097            0,
2098            "a foreign-owned clip_root is NEVER seeded or fetched"
2099        );
2100    }
2101
2102    #[test]
2103    fn resolve_roots_clip_root_seed_is_best_effort_never_bridges_or_retries() {
2104        // A same-owner clip_root that the batch never returns (trashed/404) is
2105        // simply dropped: it is never bridged, never external, never re-seeded,
2106        // and the structural resolution is unaffected.
2107        let child = Clip {
2108            id: "child".into(),
2109            title: "Remix".into(),
2110            clip_type: "gen".into(),
2111            task: "cover".into(),
2112            cover_clip_id: "mid".into(),
2113            edited_clip_id: "mid".into(),
2114            handle: "me".into(),
2115            clip_attribution_type: "remix".into(),
2116            clip_roots: vec![clip_root("gone-root", "me")],
2117            ..Default::default()
2118        };
2119        // "mid" resolves to "root" over two gap-fill rounds, so the seed would be
2120        // re-scanned on the second round if the attempted-set did not suppress it.
2121        let mid = serde_json::json!({
2122            "id": "mid", "title": "Mid", "status": "complete",
2123            "metadata": {"type": "gen", "task": "cover", "cover_clip_id": "root"}
2124        })
2125        .to_string();
2126        let root = serde_json::json!({
2127            "id": "root", "title": "Root", "status": "complete",
2128            "metadata": {"type": "gen"}
2129        })
2130        .to_string();
2131        let http = ScriptedHttp::new()
2132            .with_auth()
2133            .route("/api/clip/mid", Reply::json(&mid))
2134            .route("/api/clip/root", Reply::json(&root))
2135            .route("/api/clip/gone-root", Reply::status(404));
2136        let client = authed_client(&http);
2137
2138        let resolution = pollster::block_on(resolve_roots(
2139            &[child],
2140            &HashMap::new(),
2141            &client,
2142            &http,
2143            ResolveOpts::default(),
2144        ))
2145        .unwrap();
2146
2147        let info = &resolution.roots["child"];
2148        assert_eq!(info.status, ResolveStatus::Resolved);
2149        assert_eq!(
2150            info.root_id, "root",
2151            "the structural chain resolves normally"
2152        );
2153        assert!(
2154            resolution.bridges.is_empty(),
2155            "a dropped seed must never become a bridge"
2156        );
2157        assert!(
2158            !resolution.gap_filled.iter().any(|c| c.id == "gone-root"),
2159            "a seed the batch omits is never added"
2160        );
2161        assert_eq!(
2162            http.count("/api/clip/gone-root"),
2163            1,
2164            "the seed is attempted once, never retried across rounds"
2165        );
2166        assert_eq!(
2167            http.count("/api/clips/parent"),
2168            0,
2169            "a seed never falls through to the parent endpoint"
2170        );
2171    }
2172
2173    fn resolution_with(roots: Vec<(&str, RootInfo)>) -> Resolution {
2174        Resolution {
2175            roots: roots
2176                .into_iter()
2177                .map(|(id, info)| (id.to_owned(), info))
2178                .collect(),
2179            gap_filled: Vec::new(),
2180            bridges: Vec::new(),
2181        }
2182    }
2183
2184    #[test]
2185    fn context_for_a_root_uses_its_own_id_and_title() {
2186        let root = Clip {
2187            id: "root-1".into(),
2188            title: "Original".into(),
2189            ..Default::default()
2190        };
2191        let resolution = resolution_with(vec![(
2192            "root-1",
2193            RootInfo {
2194                root_id: "root-1".into(),
2195                root_title: "Original".into(),
2196                status: ResolveStatus::Resolved,
2197            },
2198        )]);
2199
2200        let ctx = LineageContext::for_clip(&root, &resolution);
2201        assert_eq!(ctx.root_id, "root-1");
2202        assert_eq!(ctx.root_title, "Original");
2203        assert_eq!(ctx.parent_id, "");
2204        assert_eq!(ctx.edge_type, None);
2205        // A root folders under its own title.
2206        assert_eq!(ctx.album("Original"), "Original");
2207    }
2208
2209    #[test]
2210    fn context_for_a_remix_carries_root_and_parent() {
2211        let child = Clip {
2212            id: "child-1".into(),
2213            title: "Remix".into(),
2214            clip_type: "gen".into(),
2215            task: "cover".into(),
2216            cover_clip_id: "root-1".into(),
2217            edited_clip_id: "root-1".into(),
2218            ..Default::default()
2219        };
2220        let resolution = resolution_with(vec![(
2221            "child-1",
2222            RootInfo {
2223                root_id: "root-1".into(),
2224                root_title: "Original".into(),
2225                status: ResolveStatus::Resolved,
2226            },
2227        )]);
2228
2229        let ctx = LineageContext::for_clip(&child, &resolution);
2230        assert_eq!(ctx.root_id, "root-1");
2231        assert_eq!(ctx.root_title, "Original");
2232        assert_eq!(ctx.parent_id, "root-1");
2233        assert_eq!(ctx.edge_type, Some(EdgeType::Cover));
2234        // A remix folders under the root's album title, not its own.
2235        assert_eq!(ctx.album("Remix"), "Original");
2236    }
2237
2238    #[test]
2239    fn context_absent_from_resolution_is_its_own_root() {
2240        let clip = Clip {
2241            id: "lonely".into(),
2242            title: "Solo".into(),
2243            ..Default::default()
2244        };
2245        let ctx = LineageContext::for_clip(&clip, &resolution_with(vec![]));
2246        assert_eq!(ctx.root_id, "lonely");
2247        assert_eq!(ctx.root_title, "Solo");
2248        assert_eq!(ctx.status, ResolveStatus::Resolved);
2249        assert_eq!(ctx.album("Solo"), "Solo");
2250    }
2251
2252    #[test]
2253    fn album_falls_back_to_own_title_when_root_title_is_empty() {
2254        let ctx = LineageContext {
2255            root_id: "outside".into(),
2256            root_title: String::new(),
2257            root_date: String::new(),
2258            parent_id: "outside".into(),
2259            edge_type: Some(EdgeType::Cover),
2260            status: ResolveStatus::External,
2261        };
2262        assert_eq!(ctx.album("My Title"), "My Title");
2263    }
2264
2265    #[test]
2266    fn own_root_has_no_parent() {
2267        let clip = Clip {
2268            id: "solo".into(),
2269            title: "Solo".into(),
2270            ..Default::default()
2271        };
2272        let ctx = LineageContext::own_root(&clip);
2273        assert_eq!(ctx.root_id, "solo");
2274        assert_eq!(ctx.parent_id, "");
2275        assert_eq!(ctx.edge_type, None);
2276    }
2277
2278    #[test]
2279    fn year_prefers_the_root_year_over_the_clips_own() {
2280        // A December root with a January revision: the child tags the root's
2281        // year so the album groups under one year across the boundary.
2282        let ctx = LineageContext {
2283            root_id: "root-1".into(),
2284            root_title: "Origin".into(),
2285            root_date: "2023-12-30T23:00:00Z".into(),
2286            parent_id: "root-1".into(),
2287            edge_type: Some(EdgeType::Extend),
2288            status: ResolveStatus::Resolved,
2289        };
2290        assert_eq!(ctx.year("2024-01-02T08:00:00Z"), "2023");
2291    }
2292
2293    #[test]
2294    fn year_falls_back_to_own_when_the_root_date_is_unavailable() {
2295        let ctx = LineageContext {
2296            root_id: "outside".into(),
2297            root_title: String::new(),
2298            root_date: String::new(),
2299            parent_id: "outside".into(),
2300            edge_type: Some(EdgeType::Cover),
2301            status: ResolveStatus::External,
2302        };
2303        assert_eq!(ctx.year("2024-07-01T00:00:00Z"), "2024");
2304    }
2305
2306    #[test]
2307    fn own_root_tags_its_own_year() {
2308        let clip = Clip {
2309            id: "solo".into(),
2310            title: "Solo".into(),
2311            created_at: "2022-05-06T12:00:00Z".into(),
2312            ..Default::default()
2313        };
2314        let ctx = LineageContext::own_root(&clip);
2315        assert_eq!(ctx.root_date, "2022-05-06T12:00:00Z");
2316        assert_eq!(ctx.year(&clip.created_at), "2022");
2317    }
2318
2319    #[test]
2320    fn year_is_empty_when_no_date_is_known() {
2321        let clip = Clip::default();
2322        let ctx = LineageContext::own_root(&clip);
2323        assert_eq!(ctx.year(&clip.created_at), "");
2324    }
2325}