package recommend import ( "math" "strings" "time" ) // ContextAffinityProfile captures what a viewer tends to watch in broad local-time // windows. It is intentionally compact: seven weekdays by four day parts is enough to // learn household routines without pretending that a small history is a precise model. type ContextAffinityProfile struct { Slots map[string]ContextAffinityBucket `json:"slots,omitempty"` } type ContextAffinityBucket struct { Samples int `json:"samples"` GenreWeights map[string]float64 `json:"genres,omitempty"` StudioWeights map[string]float64 `json:"studios,omitempty"` } func NewContextAffinityProfile() ContextAffinityProfile { return ContextAffinityProfile{Slots: map[string]ContextAffinityBucket{}} } // Add records one matched Tracearr session. Completion and recency determine how much // taste evidence it contributes, while Samples controls confidence separately. func (p *ContextAffinityProfile) Add( item Item, started time.Time, completion float64, recencyPosition int, location *time.Location, ) { if started.IsZero() { return } if p.Slots == nil { p.Slots = map[string]ContextAffinityBucket{} } if location == nil { location = time.Local } local := started.In(location) key := contextSlotKey(local.Weekday(), dayPart(local.Hour())) bucket := p.Slots[key] if bucket.GenreWeights == nil { bucket.GenreWeights = map[string]float64{} } if bucket.StudioWeights == nil { bucket.StudioWeights = map[string]float64{} } bucket.Samples++ weight := (0.2 + clamp01(completion)) * math.Pow(0.985, float64(recencyPosition)) for _, genre := range item.Genres { if genre = strings.TrimSpace(genre); genre != "" { bucket.GenreWeights[genre] += weight } } for _, studio := range item.Studios { if name := strings.TrimSpace(studio.Name); name != "" { bucket.StudioWeights[name] += weight * 0.4 } } p.Slots[key] = bucket } // Score returns a bounded contextual affinity source score and its confidence. Exact // weekday/time behavior matters most; neighboring time windows and the same time on // other days provide progressively weaker fallbacks. No match simply returns zero. func (p ContextAffinityProfile) Score( item Item, now time.Time, location *time.Location, ) (float64, float64) { if len(p.Slots) == 0 { return 0, 0 } if location == nil { location = time.Local } local := now.In(location) part := dayPart(local.Hour()) var score, sampleWeight float64 for key, bucket := range p.Slots { weekday, bucketPart, ok := parseContextSlotKey(key) if !ok { continue } weight := contextSlotSimilarity(local.Weekday(), part, weekday, bucketPart) if weight == 0 { continue } var affinity float64 for _, genre := range item.Genres { affinity += weightFold(bucket.GenreWeights, genre) } if n := len(item.Genres); n > 1 { affinity /= math.Sqrt(float64(n)) } for _, studio := range item.Studios { affinity += weightFold(bucket.StudioWeights, studio.Name) } score += affinity * weight sampleWeight += float64(bucket.Samples) * weight } if sampleWeight == 0 { return 0, 0 } // Five effective sessions are enough for the full (still bounded) contextual nudge. return score / sampleWeight, math.Min(1, sampleWeight/5) } // Contextualized returns a copy of the base profile with a modest current-time taste // nudge. It is used by dynamic shelves; the prepared For You pool applies the same // signal directly to candidate placement. func (p ContextAffinityProfile) Contextualized( base Profile, now time.Time, location *time.Location, ) Profile { out := base out.GenreWeights = cloneWeights(base.GenreWeights) out.StudioWeights = cloneWeights(base.StudioWeights) probe := Item{Genres: keysFromWeights(out.GenreWeights)} _, confidence := p.Score(probe, now, location) if confidence == 0 { return out } local := now if location != nil { local = now.In(location) } part := dayPart(local.Hour()) for key, bucket := range p.Slots { weekday, bucketPart, ok := parseContextSlotKey(key) if !ok { continue } similarity := contextSlotSimilarity(local.Weekday(), part, weekday, bucketPart) if similarity == 0 { continue } scale := 0.35 * confidence * similarity / math.Max(1, float64(bucket.Samples)) for genre, weight := range bucket.GenreWeights { out.GenreWeights[genre] += weight * scale } for studio, weight := range bucket.StudioWeights { out.StudioWeights[studio] += weight * scale } } return out } func contextSlotSimilarity(currentDay time.Weekday, currentPart int, day time.Weekday, part int) float64 { dayDistance := int(currentDay) - int(day) if dayDistance < 0 { dayDistance = -dayDistance } if dayDistance > 3 { dayDistance = 7 - dayDistance } partDistance := currentPart - part if partDistance < 0 { partDistance = -partDistance } switch { case dayDistance == 0 && partDistance == 0: return 1 case dayDistance == 0 && partDistance == 1: return 0.35 case dayDistance == 1 && partDistance == 0: return 0.25 case partDistance == 0: return 0.12 default: return 0 } } func dayPart(hour int) int { switch { case hour >= 5 && hour < 11: return 0 case hour >= 11 && hour < 17: return 1 case hour >= 17 && hour < 22: return 2 default: return 3 } } func contextSlotKey(day time.Weekday, part int) string { return string(rune('0'+day)) + ":" + string(rune('0'+part)) } func parseContextSlotKey(key string) (time.Weekday, int, bool) { if len(key) != 3 || key[1] != ':' || key[0] < '0' || key[0] > '6' || key[2] < '0' || key[2] > '3' { return 0, 0, false } return time.Weekday(key[0] - '0'), int(key[2] - '0'), true } func clamp01(value float64) float64 { if value < 0 { return 0 } if value > 1 { return 1 } return value } func cloneWeights(source map[string]float64) map[string]float64 { out := make(map[string]float64, len(source)) for key, value := range source { out[key] = value } return out } func keysFromWeights(source map[string]float64) []string { out := make([]string, 0, len(source)) for key := range source { out = append(out, key) } return out }