Files

231 lines
6.0 KiB
Go
Raw Permalink Normal View History

2026-08-02 22:10:19 +12:00
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
}