1use std::collections::{BTreeSet, VecDeque};
16
17use crate::model::{Catalog, Id};
18
19#[path = "shuffle_graph.rs"]
20mod graph;
21#[path = "shuffle_profiles.rs"]
22mod profiles;
23
24use graph::GenreGraph;
25use profiles::Profile;
26
27pub const ALGORITHM_VERSION: u32 = 1;
32
33pub const MAX_STYLE_STEP: u16 = 300;
38
39pub const NEIGHBORHOOD_WINDOW: u16 = 60;
45
46const CANDIDATE_LIMIT: usize = 96;
47const NEARBY_GENRES: usize = 12;
48const PER_GENRE_CANDIDATES: usize = 6;
49const RECENT_ARTISTS: usize = 5;
50const RECENT_ALBUMS: usize = 3;
51
52#[derive(Clone, Debug, Default, Eq, PartialEq)]
54pub struct SmartShuffleReport {
55 pub style_breaks: usize,
57 pub unknown_transitions: usize,
59 pub unknown_tracks: usize,
61 pub limited_tracks: usize,
63 pub omitted_genres: usize,
65}
66
67#[derive(Clone, Debug, Eq, PartialEq)]
69pub struct StyleBreak {
70 pub position: usize,
72 pub previous: usize,
74 pub next: usize,
76 pub distance: Option<u16>,
78}
79
80#[derive(Clone, Debug, Eq, PartialEq)]
82pub struct SmartOrder {
83 pub order: Vec<usize>,
85 pub report: SmartShuffleReport,
87 pub breaks: Vec<StyleBreak>,
89}
90
91#[derive(Clone, Debug)]
93pub struct SmartShuffle {
94 profiles: Vec<Profile>,
95 graph: GenreGraph,
96 report: SmartShuffleReport,
97}
98
99impl SmartShuffle {
100 pub fn from_catalog(catalog: &Catalog, tracks: &[Option<Id>]) -> Self {
110 let (mut profiles, contexts) = profiles::from_catalog(catalog, tracks);
111 let graph = GenreGraph::new(&mut profiles, &contexts);
112 let report = SmartShuffleReport {
113 unknown_tracks: profiles.iter().filter(|p| p.genres.is_empty()).count(),
114 limited_tracks: profiles.iter().filter(|p| p.limited).count(),
115 omitted_genres: graph.omitted_genres,
116 ..SmartShuffleReport::default()
117 };
118 Self {
119 profiles,
120 graph,
121 report,
122 }
123 }
124
125 pub fn len(&self) -> usize {
127 self.profiles.len()
128 }
129
130 pub fn is_empty(&self) -> bool {
132 self.profiles.is_empty()
133 }
134
135 pub fn transition_distance(&self, from: usize, to: usize) -> Option<u16> {
141 self.graph
142 .distance(self.profiles.get(from)?, self.profiles.get(to)?)
143 }
144
145 pub fn plan(
154 &self,
155 seed: u64,
156 start_index: Option<usize>,
157 previous_index: Option<usize>,
158 ) -> SmartOrder {
159 let remaining: Vec<usize> = (0..self.len()).collect();
160 self.plan_selection(seed, start_index, previous_index, &remaining)
161 }
162
163 pub fn plan_remaining(
171 &self,
172 seed: u64,
173 remaining: &[usize],
174 previous_index: Option<usize>,
175 ) -> Result<SmartOrder, String> {
176 let mut seen = BTreeSet::new();
177 for &index in remaining {
178 if index >= self.len() {
179 return Err("smart shuffle occurrence is outside the selection".into());
180 }
181 if !seen.insert(index) {
182 return Err("smart shuffle remainder repeats an occurrence index".into());
183 }
184 }
185 Ok(self.plan_selection(seed, None, previous_index, remaining))
186 }
187
188 fn plan_selection(
189 &self,
190 seed: u64,
191 start_index: Option<usize>,
192 previous_index: Option<usize>,
193 remaining: &[usize],
194 ) -> SmartOrder {
195 let mut priorities = remaining.to_vec();
196 super::shuffle(&mut priorities, seed);
197 let mut result = SmartOrder {
198 order: Vec::with_capacity(remaining.len()),
199 report: SmartShuffleReport {
200 unknown_tracks: remaining
201 .iter()
202 .filter(|&&i| self.profiles[i].genres.is_empty())
203 .count(),
204 limited_tracks: remaining
205 .iter()
206 .filter(|&&i| self.profiles[i].limited)
207 .count(),
208 omitted_genres: self.report.omitted_genres,
209 ..SmartShuffleReport::default()
210 },
211 breaks: Vec::new(),
212 };
213 if remaining.is_empty() {
214 return result;
215 }
216
217 let mut previous = previous_index.filter(|&i| i < self.len());
218 let mut pinned = start_index.filter(|i| remaining.contains(i));
219 if result.report.unknown_tracks == remaining.len() {
220 if let Some(pinned) = pinned
221 && let Some(position) = priorities.iter().position(|&i| i == pinned)
222 {
223 priorities.swap(0, position);
224 }
225 for next in priorities {
226 if let Some(previous) = previous {
227 self.record_transition(previous, next, &mut result);
228 }
229 result.order.push(next);
230 previous = Some(next);
231 }
232 return result;
233 }
234
235 let mut pools = Pools::new(&self.profiles, &priorities, self.graph.len());
236 let mut state = seed;
237 let mut history = VecDeque::new();
238 if let Some(previous) = previous {
239 history.push_back(previous);
240 }
241 while !pools.remaining.is_empty() {
242 let next = if let Some(index) = pinned.take() {
243 index
244 } else if let Some(previous) = previous {
245 let candidates =
246 pools.candidates(&self.profiles[previous], &self.graph, &mut state);
247 self.choose(previous, &candidates, &pools, &history, &mut state)
248 } else {
249 priorities[0]
251 };
252 if let Some(previous) = previous {
253 self.record_transition(previous, next, &mut result);
254 }
255 pools.remove(next, &self.profiles[next]);
256 result.order.push(next);
257 history.push_back(next);
258 if history.len() > RECENT_ARTISTS {
259 history.pop_front();
260 }
261 previous = Some(next);
262 }
263 result
264 }
265
266 fn record_transition(&self, previous: usize, next: usize, result: &mut SmartOrder) {
267 let distance = self.transition_distance(previous, next);
268 match distance {
269 Some(distance) if distance <= MAX_STYLE_STEP => return,
270 Some(_) => result.report.style_breaks += 1,
271 None => result.report.unknown_transitions += 1,
272 }
273 result.breaks.push(StyleBreak {
274 position: result.order.len(),
275 previous,
276 next,
277 distance,
278 });
279 }
280
281 fn choose(
282 &self,
283 previous: usize,
284 candidates: &[usize],
285 pools: &Pools,
286 history: &VecDeque<usize>,
287 state: &mut u64,
288 ) -> usize {
289 let radius = candidates
290 .iter()
291 .filter_map(|&i| self.transition_distance(previous, i))
292 .min()
293 .filter(|&d| d <= MAX_STYLE_STEP)
294 .map(|d| d.saturating_add(NEIGHBORHOOD_WINDOW).min(MAX_STYLE_STEP));
295 let has_known = candidates
296 .iter()
297 .any(|&i| self.transition_distance(previous, i).is_some());
298 let mut weighted = Vec::with_capacity(candidates.len());
299 let mut total = 0u64;
300 for &next in candidates {
301 let distance = self.transition_distance(previous, next);
302 if radius.is_some_and(|radius| distance.is_none_or(|d| d > radius)) {
303 continue;
304 }
305 if radius.is_none() && has_known && distance.is_none() {
306 continue;
307 }
308 let weight = self.weight(previous, next, distance, pools, history);
309 total += weight;
310 weighted.push((next, weight));
311 }
312 let mut draw = bounded_random(state, total);
315 for (index, weight) in weighted {
316 if draw < weight {
317 return index;
318 }
319 draw -= weight;
320 }
321 candidates.first().copied().unwrap_or(previous)
322 }
323
324 fn weight(
325 &self,
326 previous: usize,
327 next: usize,
328 distance: Option<u16>,
329 pools: &Pools,
330 history: &VecDeque<usize>,
331 ) -> u64 {
332 let profile = &self.profiles[next];
333 let before = &self.profiles[previous];
334 let distance = u64::from(distance.unwrap_or(600));
335 let closeness = 1_100 - distance;
336 let mut weight = closeness * closeness;
337
338 if intersects(&profile.labels, &before.labels) {
340 weight += weight / 10;
341 }
342 if let (Some(a), Some(b)) = (profile.year, before.year) {
343 let gap = a.abs_diff(b).min(50);
344 weight += weight * u64::from(50 - gap) / 500;
345 }
346 if self.graph.collaborators(profile, before) {
347 weight += weight / 8;
348 }
349
350 for (age, &recent) in history.iter().rev().enumerate() {
351 let recent = &self.profiles[recent];
352 if !profile.artists.is_empty() && intersects(&profile.artists, &recent.artists) {
353 weight = weight * (age as u64 + 1) / (age as u64 + 6);
354 }
355 if age < RECENT_ALBUMS && profile.album.is_some() && profile.album == recent.album {
356 weight = weight * (age as u64 + 1) / (age as u64 + 5);
357 }
358 }
359 if profile.identity.is_some()
360 && history
361 .iter()
362 .any(|&recent| self.profiles[recent].identity == profile.identity)
363 {
364 weight /= 16;
365 }
366
367 if pools.remaining.len() > 1
368 && pools.onward_distance(profile, next, &self.graph) > MAX_STYLE_STEP
369 {
370 weight /= 16;
374 }
375 weight.max(1)
376 }
377}
378
379fn intersects<T: Ord>(left: &[T], right: &[T]) -> bool {
380 let (mut a, mut b) = (0, 0);
381 while a < left.len() && b < right.len() {
382 match left[a].cmp(&right[b]) {
383 std::cmp::Ordering::Less => a += 1,
384 std::cmp::Ordering::Greater => b += 1,
385 std::cmp::Ordering::Equal => return true,
386 }
387 }
388 false
389}
390
391fn bounded_random(state: &mut u64, bound: u64) -> u64 {
392 if bound <= 1 {
393 return 0;
394 }
395 let threshold = bound.wrapping_neg() % bound;
396 loop {
397 let value = super::next_random(state);
398 if value >= threshold {
399 return value % bound;
400 }
401 }
402}
403
404struct Pools {
405 priorities: Vec<usize>,
406 ranks: Vec<usize>,
407 remaining: BTreeSet<usize>,
408 genres: Vec<BTreeSet<usize>>,
409 active_genres: BTreeSet<usize>,
410 unknown: BTreeSet<usize>,
411}
412
413impl Pools {
414 fn new(profiles: &[Profile], priorities: &[usize], genres: usize) -> Self {
415 let mut pools = Self {
416 priorities: priorities.to_vec(),
417 ranks: vec![0; profiles.len()],
418 remaining: (0..priorities.len()).collect(),
419 genres: vec![BTreeSet::new(); genres],
420 active_genres: BTreeSet::new(),
421 unknown: BTreeSet::new(),
422 };
423 for (rank, &index) in priorities.iter().enumerate() {
424 pools.ranks[index] = rank;
425 if profiles[index].genres.is_empty() {
426 pools.unknown.insert(rank);
427 }
428 for &genre in &profiles[index].genres {
429 pools.genres[genre].insert(rank);
430 pools.active_genres.insert(genre);
431 }
432 }
433 pools
434 }
435
436 fn remove(&mut self, index: usize, profile: &Profile) {
437 let rank = self.ranks[index];
438 self.remaining.remove(&rank);
439 self.unknown.remove(&rank);
440 for &genre in &profile.genres {
441 self.genres[genre].remove(&rank);
442 if self.genres[genre].is_empty() {
443 self.active_genres.remove(&genre);
444 }
445 }
446 }
447
448 fn candidates(&self, previous: &Profile, graph: &GenreGraph, state: &mut u64) -> Vec<usize> {
449 let mut ranks = BTreeSet::new();
450 let pivot = bounded_random(state, self.priorities.len() as u64) as usize;
451 if previous.genres.is_empty() {
452 sample_pool(&self.remaining, pivot, CANDIDATE_LIMIT, &mut ranks);
453 return ranks
454 .into_iter()
455 .map(|rank| self.priorities[rank])
456 .collect();
457 }
458 let mut nearby: Vec<(u16, usize)> = self
459 .active_genres
460 .iter()
461 .map(|&genre| (graph.to_genre(previous, genre), genre))
462 .collect();
463 nearby.sort_unstable();
464 for (_, genre) in nearby.into_iter().take(NEARBY_GENRES) {
465 sample_pool(&self.genres[genre], pivot, PER_GENRE_CANDIDATES, &mut ranks);
466 }
467 sample_pool(&self.unknown, pivot, 8, &mut ranks);
468 sample_pool(&self.remaining, pivot, 16, &mut ranks);
469 ranks
470 .into_iter()
471 .take(CANDIDATE_LIMIT)
472 .map(|rank| self.priorities[rank])
473 .collect()
474 }
475
476 fn onward_distance(&self, profile: &Profile, index: usize, graph: &GenreGraph) -> u16 {
477 let rank = self.ranks[index];
478 let mut best = 1_000;
479 for &source in &profile.genres {
480 for &genre in graph.nearest(source) {
481 let distance = graph.genre_distance(source, genre);
482 if distance >= best {
483 break;
484 }
485 let pool = &self.genres[genre];
486 if pool.len() > usize::from(pool.contains(&rank)) {
487 best = distance;
488 break;
489 }
490 }
491 }
492 best
493 }
494}
495
496fn sample_pool(pool: &BTreeSet<usize>, pivot: usize, limit: usize, output: &mut BTreeSet<usize>) {
497 for &rank in pool.range(pivot..).chain(pool.range(..pivot)).take(limit) {
498 output.insert(rank);
499 }
500}
501
502#[cfg(test)]
503#[path = "shuffle_tests.rs"]
504mod tests;