// Package taste builds a persistent, decaying per-user taste profile (#796). // engagement.go is the pure core: it turns a single play's completion ratio // into a graded taste signal in [-1, +1], replacing the binary was_skipped // for learning purposes (was_skipped stays as-is for History display). package taste import "math" // EngagementParams tune the completion→engagement curve. All in [0,1] and // ordered HardSkip < NeutralCompletion < FullCompletion. type EngagementParams struct { // HardSkip: completion at/below which a play is a strong reject (-1). HardSkip float64 // NeutralCompletion: completion at which a play is neutral (0). Set low // (~0.30) because people enjoy plenty of tracks they don't finish; only // genuinely-early abandons read as negative. NeutralCompletion float64 // FullCompletion: completion at/above which a play is a full positive // (+1). FullCompletion float64 } // DefaultEngagementParams returns the tuned starting curve. func DefaultEngagementParams() EngagementParams { return EngagementParams{HardSkip: 0.05, NeutralCompletion: 0.30, FullCompletion: 0.90} } // Engagement maps a play's completion ratio to a taste signal in [-1, +1] // via two linear ramps meeting at the neutral point: // // completion <= HardSkip → -1 // HardSkip < c < Neutral → ramp -1 → 0 // Neutral <= c < Full → ramp 0 → +1 // completion >= Full → +1 // // Completion is clamped to [0,1]. Degenerate params (non-increasing // thresholds) collapse the affected ramp to its endpoint rather than // dividing by zero. func Engagement(completion float64, p EngagementParams) float64 { c := clamp01(completion) if c <= p.HardSkip { return -1 } if c < p.NeutralCompletion { span := p.NeutralCompletion - p.HardSkip if span <= 0 { return 0 } return -1 + (c-p.HardSkip)/span } if c < p.FullCompletion { span := p.FullCompletion - p.NeutralCompletion if span <= 0 { return 1 } return (c - p.NeutralCompletion) / span } return 1 } // decay is the exponential time-decay applied to a play's engagement: // 1.0 at age 0, 0.5 at age == halfLifeDays. Negative ages clamp to 0. func decay(ageDays, halfLifeDays float64) float64 { if ageDays < 0 { ageDays = 0 } if halfLifeDays <= 0 { return 1 } return math.Exp(-math.Ln2 * ageDays / halfLifeDays) } func clamp01(x float64) float64 { if x < 0 { return 0 } if x > 1 { return 1 } return x }