Files
silo-server/internal/recommendations/coldstart.go

163 lines
4.3 KiB
Go

package recommendations
import (
"sort"
"time"
)
// coldStartLevel returns the cold-start graduation level based on positive
// signal count. Higher levels indicate more personalization is appropriate.
//
// 0 signals → level 0 (100% non-personalized)
// 1-4 → level 1 (non-personalized + one personal row)
// 5-14 → level 2 (50/50 mix)
// 15+ → level 3 (fully personalized)
func coldStartLevel(positiveSignalCount int) int {
switch {
case positiveSignalCount >= ColdStartFullPersonalized:
return 3
case positiveSignalCount >= ColdStartMixed:
return 2
case positiveSignalCount >= ColdStartMinimal:
return 1
default:
return 0
}
}
// buildColdStartRows builds the set of non-personalized recommendation rows
// used during cold-start (and appended to warm profiles for discovery).
// Rows with empty item slices are omitted.
func buildColdStartRows(popular, recentlyAdded, topRated []ScoredItem, genreSamplers map[string][]ScoredItem) []ForYouRow {
var rows []ForYouRow
if len(popular) > 0 {
rows = append(rows, ForYouRow{
Type: RecTypePopular,
Label: "Popular on This Server",
Items: popular,
})
}
if len(recentlyAdded) > 0 {
rows = append(rows, ForYouRow{
Type: RecTypeRecentlyAdded,
Label: "Recently Added",
Items: recentlyAdded,
})
}
if len(topRated) > 0 {
rows = append(rows, ForYouRow{
Type: RecTypeTopRated,
Label: "Top Rated",
Items: topRated,
})
}
// Sort genre names for deterministic row order.
genres := make([]string, 0, len(genreSamplers))
for g := range genreSamplers {
genres = append(genres, g)
}
sort.Strings(genres)
for _, genre := range genres {
items := genreSamplers[genre]
if len(items) > 0 {
rows = append(rows, ForYouRow{
Type: "genre_sampler",
Label: "Top " + genre,
Items: items,
})
}
}
return rows
}
// mergePersonalizedAndColdStart combines personalized rows with cold-start rows
// according to the user's cold-start graduation level.
//
// Level 0: cold-start rows only
// Level 1: cold-start rows first, then up to 1 personal row
// Level 2: interleave personal and cold-start rows (alternating)
// Level 3: personal rows first, cold-start rows appended at the end
func mergePersonalizedAndColdStart(personalRows, coldStartRows []ForYouRow, level int) []ForYouRow {
switch level {
case 0:
return coldStartRows
case 1:
limited := personalRows
if len(limited) > 1 {
limited = limited[:1]
}
merged := make([]ForYouRow, 0, len(coldStartRows)+len(limited))
merged = append(merged, coldStartRows...)
merged = append(merged, limited...)
return merged
case 2:
merged := make([]ForYouRow, 0, len(personalRows)+len(coldStartRows))
pi, ci := 0, 0
for pi < len(personalRows) || ci < len(coldStartRows) {
if pi < len(personalRows) {
merged = append(merged, personalRows[pi])
pi++
}
if ci < len(coldStartRows) {
merged = append(merged, coldStartRows[ci])
ci++
}
}
return merged
default: // level 3
merged := make([]ForYouRow, 0, len(personalRows)+len(coldStartRows))
merged = append(merged, personalRows...)
merged = append(merged, coldStartRows...)
return merged
}
}
// applyRecencyBoost multiplies the score of recently added items by a boost
// factor that decays linearly from RecencyBoostMultiplier to 1.0 over
// RecencyBoostDays. Items not present in addedDates or older than the window
// are left unchanged. The returned slice is a new copy sorted by descending
// boosted score.
func applyRecencyBoost(items []ScoredItem, addedDates map[string]time.Time, now time.Time) []ScoredItem {
boostWindow := time.Duration(RecencyBoostDays) * 24 * time.Hour
boosted := make([]ScoredItem, len(items))
for i, item := range items {
boosted[i] = item
addedAt, ok := addedDates[item.MediaItemID]
if !ok {
continue
}
age := now.Sub(addedAt)
if age < 0 {
// Added in the future (clock skew) — apply full boost.
age = 0
}
if age >= boostWindow {
continue
}
// Linear decay: fraction goes from 1.0 (just added) to 0.0 (at window edge).
fraction := 1.0 - float64(age)/float64(boostWindow)
// Multiplier ranges from RecencyBoostMultiplier down to 1.0.
multiplier := 1.0 + (RecencyBoostMultiplier-1.0)*fraction
boosted[i].Score *= multiplier
}
sort.Slice(boosted, func(i, j int) bool {
return boosted[i].Score > boosted[j].Score
})
return boosted
}