package recommendations import "sort" // Default co-watch matrix parameters. const ( DefaultMinWatchers = 5 DefaultMinShared = 3 DefaultTopN = 50 DefaultMaxWatchesPerUser = 500 ) // computeCowatchMatrix builds a co-watch similarity matrix from per-item watcher // lists. For every pair of items that each have at least minWatchers viewers, // it computes the Jaccard similarity of their watcher sets. Pairs with fewer // than minShared shared watchers are discarded. For each item only the topN // most similar neighbours (by Jaccard score) are retained. func computeCowatchMatrix(watchers map[string][]string, minWatchers, minShared, topN int) []CowatchPair { // Collect item IDs that meet the minimum-watchers threshold. eligible := make([]string, 0, len(watchers)) for itemID, users := range watchers { if len(users) >= minWatchers { eligible = append(eligible, itemID) } } // Sort for deterministic iteration order. sort.Strings(eligible) // Pre-build watcher sets for O(1) membership tests. watcherSets := make(map[string]map[string]struct{}, len(eligible)) for _, itemID := range eligible { set := make(map[string]struct{}, len(watchers[itemID])) for _, uid := range watchers[itemID] { set[uid] = struct{}{} } watcherSets[itemID] = set } // Per-item neighbour lists, keyed by itemID. type neighbour struct { similarID string score float64 shared int } neighbours := make(map[string][]neighbour, len(eligible)) // Compare every pair once (i < j), then record from both sides. for i := 0; i < len(eligible); i++ { a := eligible[i] setA := watcherSets[a] for j := i + 1; j < len(eligible); j++ { b := eligible[j] setB := watcherSets[b] // Count intersection. shared := 0 // Iterate over the smaller set for efficiency. small, big := setA, setB if len(setA) > len(setB) { small, big = setB, setA } for uid := range small { if _, ok := big[uid]; ok { shared++ } } if shared < minShared { continue } union := len(setA) + len(setB) - shared if union == 0 { continue } jaccard := float64(shared) / float64(union) neighbours[a] = append(neighbours[a], neighbour{similarID: b, score: jaccard, shared: shared}) neighbours[b] = append(neighbours[b], neighbour{similarID: a, score: jaccard, shared: shared}) } } // For each item, keep only the topN neighbours by Jaccard score descending. var result []CowatchPair for _, itemID := range eligible { nbrs := neighbours[itemID] if len(nbrs) == 0 { continue } sort.Slice(nbrs, func(i, j int) bool { if nbrs[i].score != nbrs[j].score { return nbrs[i].score > nbrs[j].score } return nbrs[i].similarID < nbrs[j].similarID }) limit := topN if limit > len(nbrs) { limit = len(nbrs) } for _, n := range nbrs[:limit] { result = append(result, CowatchPair{ ItemID: itemID, SimilarItemID: n.similarID, JaccardScore: n.score, CowatchCount: n.shared, }) } } return result } // blendScores merges embedding-based item scores with co-watch Jaccard scores // using the given weights (typically 0.7 embedding, 0.3 co-watch). Items that // appear in only one source still surface with their single weighted score. // The result is sorted by blended score descending. func blendScores(embeddingItems []ScoredItem, cowatchItems map[string]float64, embeddingWeight, cowatchWeight float64) []ScoredItem { blended := make(map[string]ScoredItem, len(embeddingItems)+len(cowatchItems)) // Start with embedding items. for _, item := range embeddingItems { score := embeddingWeight * item.Score if jaccardScore, ok := cowatchItems[item.MediaItemID]; ok { score += cowatchWeight * jaccardScore } blended[item.MediaItemID] = ScoredItem{ MediaItemID: item.MediaItemID, Score: score, Reason: item.Reason, } } // Add co-watch-only items that were not in the embedding set. for itemID, jaccardScore := range cowatchItems { if _, exists := blended[itemID]; exists { continue } blended[itemID] = ScoredItem{ MediaItemID: itemID, Score: cowatchWeight * jaccardScore, Reason: "cowatch", } } // Flatten to a slice and sort by score descending. result := make([]ScoredItem, 0, len(blended)) for _, item := range blended { result = append(result, item) } sort.Slice(result, func(i, j int) bool { if result[i].Score != result[j].Score { return result[i].Score > result[j].Score } return result[i].MediaItemID < result[j].MediaItemID }) return result } // jaccardSimilarity computes the Jaccard index between two string slices, // defined as |intersection| / |union|. Returns 0 if both slices are empty. func jaccardSimilarity(setA, setB []string) float64 { if len(setA) == 0 && len(setB) == 0 { return 0 } members := make(map[string]struct{}, len(setA)) for _, v := range setA { members[v] = struct{}{} } intersection := 0 for _, v := range setB { if _, ok := members[v]; ok { intersection++ } } union := len(members) + len(setB) - intersection if union == 0 { return 0 } return float64(intersection) / float64(union) }