The payoff slice. Until now a candidate's only claim on a slot was "some
artist you play is adjacent to it in a similarity graph" — a fact that says
nothing about whether the music sounds like anything you like. Now the
candidate's own folksonomy tags (cached by slice 5) are compared against
the user's taste-profile tags, so the deck ranks on taste and can say WHY.
The blend is MULTIPLICATIVE — score × (1 + weight × overlap) — and that
choice carries the whole safety argument:
- An untagged candidate has overlap 0, so its score is EXACTLY unchanged.
Tag coverage is permanently partial (#2376); it must cost a candidate
nothing, not sink it (rule #131).
- Nothing can leapfrog on tags alone. An additive term with a large
weight would let a near-zero-similarity artist outrank a strong match
for sharing one popular tag, which reads as noise.
- Weight 0 restores pure similarity order bit-for-bit, so the operator's
knob has a real off position.
overlap = Σ(shared) candWeight × normalizedTasteWeight ÷ Σ(all) candWeight.
Normalizing the taste side by the user's strongest tag makes the score
comparable across users (taste weights accumulate with listening, so a
heavy listener's raw numbers dwarf a new user's while meaning the same
thing). Dividing by the candidate's own mass makes it comparable across
candidates, so a densely-tagged artist can't win on tag count alone.
Applied to the whole over-fetched pool BEFORE selectSuggestions, so the
rotation and diversity rules operate on blended scores — boosting only the
twelve already chosen by similarity would leave the re-ranking undone.
A query failure is returned, NOT degraded past. Graceful degradation is
for expected absence (no taste profile, no cached tags) and both are
handled explicitly as empty inputs; swallowing a real error would hide a
broken DB behind a subtly worse ranking that nothing reports.
Migration 0051 adds a FOURTH tuning scope rather than columns on
taste_tuning, because snooze_days lives here too and a snooze must never
be read as taste signal (#2374) — filing it under 'taste' would put it one
careless join from the leak that design forbids. Expanding
recommendation_tuning_audit's CHECK is in the same migration per rule #36,
and a test asserts the audit row lands, which is what would catch its
absence.
snooze_days moves out of a Go constant onto the tuning card (rule #25),
closing the deferral from #2374.
Tag-overlap tests use deliberately SKEWED fixtures: an evenly-matching pool
cannot exercise a re-ranking, since every candidate gets the same
multiplier and the order is unchanged whether the blend works or not.
Admin UI + client attribution follow in this batch — rule #27.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
501 lines
16 KiB
Go
501 lines
16 KiB
Go
// Package recsettings is the DB-backed home of the recommendation
|
||
// tuning knobs (#1250): the two scoring-weight profiles (radio /
|
||
// daily_mix) and the taste-profile build settings (engagement
|
||
// half-life + completion curve). It follows the coverart
|
||
// SettingsService pattern — boot reconcile seeds shipped defaults for
|
||
// missing rows, values are cached under a RWMutex, and every change
|
||
// takes effect live (the daily_mix profile + taste config are pushed
|
||
// into package playlists; radio reads the cache per request).
|
||
//
|
||
// Framing (decision #1247): this is the defaults-discovery lab. The
|
||
// operator turns knobs to FIND good values; found-good values get
|
||
// baked back into the Shipped* functions below as new shipped
|
||
// defaults. End users and other operators should never need the card.
|
||
package recsettings
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"log/slog"
|
||
"sort"
|
||
"sync"
|
||
|
||
"github.com/jackc/pgx/v5/pgxpool"
|
||
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
||
"git.fabledsword.com/bvandeusen/minstrel/internal/taste"
|
||
)
|
||
|
||
// Scope names — the three tunable groups. radio + daily_mix are weight
|
||
// profiles; taste is the profile-build settings singleton.
|
||
const (
|
||
ScopeRadio = "radio"
|
||
ScopeDailyMix = "daily_mix"
|
||
ScopeTaste = "taste"
|
||
// ScopeDiscover is the Discover request surface (#2377). Its own scope
|
||
// rather than columns on taste: SnoozeDays lives here, and a snooze must
|
||
// never be read as taste signal (#2374) — filing it under taste would put
|
||
// it one careless join from the leak that design forbids.
|
||
ScopeDiscover = "discover"
|
||
)
|
||
|
||
// TasteTuning is the tunable subset of taste.Config: the engagement
|
||
// half-life, the completion→engagement curve points, the enriched-tag
|
||
// weight (how much folksonomy tags count vs raw ID3 genre, #1520), and the
|
||
// era-facet weight (how strongly a decade-play imprints, #1530).
|
||
type TasteTuning struct {
|
||
HalfLifeDays float64
|
||
EngagementHardSkip float64
|
||
EngagementNeutral float64
|
||
EngagementFull float64
|
||
EnrichedTagScale float64
|
||
EraScale float64
|
||
MoodScale float64
|
||
}
|
||
|
||
// ShippedRadioWeights are the shipped radio-profile defaults (moved
|
||
// here from config.RecommendationConfig — YAML is bootstrap-only,
|
||
// rule: config in UI). Radio is seed-directed (the user picked a
|
||
// direction), so taste is a lighter nudge than in the daily mixes.
|
||
// ContextTimeWeight starts uniform (1.0) across both profiles pending
|
||
// trend data (#1531); split them once the metrics view justifies it.
|
||
func ShippedRadioWeights() recommendation.ScoringWeights {
|
||
return recommendation.ScoringWeights{
|
||
BaseWeight: 1.0,
|
||
LikeBoost: 2.0,
|
||
RecencyWeight: 1.0,
|
||
SkipPenalty: 1.0,
|
||
JitterMagnitude: 0.1,
|
||
ContextWeight: 2.0,
|
||
SimilarityWeight: 2.0,
|
||
TasteWeight: 1.0,
|
||
ContextTimeWeight: 1.0,
|
||
}
|
||
}
|
||
|
||
// ShippedDailyMixWeights are the shipped daily_mix-profile defaults.
|
||
// Must stay in sync with the pre-push literal in playlists/system.go.
|
||
func ShippedDailyMixWeights() recommendation.ScoringWeights {
|
||
return recommendation.ScoringWeights{
|
||
BaseWeight: 1.0,
|
||
LikeBoost: 2.0,
|
||
RecencyWeight: 1.0,
|
||
SkipPenalty: 2.0,
|
||
JitterMagnitude: 0.1,
|
||
ContextWeight: 0.5,
|
||
SimilarityWeight: 1.5,
|
||
TasteWeight: 1.5,
|
||
ContextTimeWeight: 1.0,
|
||
}
|
||
}
|
||
|
||
// DiscoverTuning is the tunable set for the Discover request surface (#2377).
|
||
type DiscoverTuning struct {
|
||
// TagOverlapWeight scales the taste-tag term: score × (1 + w × overlap).
|
||
// 0 disables it and restores pure similarity ranking.
|
||
TagOverlapWeight float64
|
||
// SnoozeDays is the default "not right now" duration (#2374).
|
||
SnoozeDays float64
|
||
}
|
||
|
||
// ShippedDiscoverTuning are the shipped Discover defaults.
|
||
//
|
||
// TagOverlapWeight 1.0 lets a perfect tag match at most double a candidate's
|
||
// similarity score — enough to reorder the deck meaningfully, not enough for a
|
||
// popular-tag coincidence to beat a genuinely strong similarity match. It is a
|
||
// starting point for the tuning lab, not a tuned value: the honest way to pick
|
||
// it is the metrics trend view after some real use.
|
||
//
|
||
// SnoozeDays 90 matches the operator's approved shape: long enough that a
|
||
// parked suggestion stops nagging, short enough that a taste shift brings it
|
||
// back on its own.
|
||
func ShippedDiscoverTuning() DiscoverTuning {
|
||
return DiscoverTuning{
|
||
TagOverlapWeight: 1.0,
|
||
SnoozeDays: 90,
|
||
}
|
||
}
|
||
|
||
// ShippedTasteTuning mirrors taste.DefaultConfig's tunable subset.
|
||
func ShippedTasteTuning() TasteTuning {
|
||
d := taste.DefaultConfig()
|
||
return TasteTuning{
|
||
HalfLifeDays: d.HalfLifeDays,
|
||
EngagementHardSkip: d.Engagement.HardSkip,
|
||
EngagementNeutral: d.Engagement.NeutralCompletion,
|
||
EngagementFull: d.Engagement.FullCompletion,
|
||
EnrichedTagScale: d.EnrichedTagScale,
|
||
EraScale: d.EraScale,
|
||
MoodScale: d.MoodScale,
|
||
}
|
||
}
|
||
|
||
// Service caches the tuning values and owns their DB persistence +
|
||
// audit trail. Construct with New at boot.
|
||
type Service struct {
|
||
pool *pgxpool.Pool
|
||
logger *slog.Logger
|
||
|
||
mu sync.RWMutex
|
||
profiles map[string]recommendation.ScoringWeights
|
||
taste TasteTuning
|
||
discover DiscoverTuning
|
||
}
|
||
|
||
// New boots the service: seeds shipped defaults for missing rows,
|
||
// loads the current values, and pushes the daily_mix weights + taste
|
||
// config into package playlists so the daily builds pick them up.
|
||
func New(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger) (*Service, error) {
|
||
s := &Service{
|
||
pool: pool,
|
||
logger: logger,
|
||
profiles: map[string]recommendation.ScoringWeights{},
|
||
}
|
||
if err := s.reconcile(ctx); err != nil {
|
||
return nil, fmt.Errorf("recsettings boot: %w", err)
|
||
}
|
||
return s, nil
|
||
}
|
||
|
||
// reconcile seeds missing rows with shipped defaults, reads everything
|
||
// back into the cache, and pushes the playlist-side values.
|
||
func (s *Service) reconcile(ctx context.Context) error {
|
||
q := dbq.New(s.pool)
|
||
for profile, w := range map[string]recommendation.ScoringWeights{
|
||
ScopeRadio: ShippedRadioWeights(),
|
||
ScopeDailyMix: ShippedDailyMixWeights(),
|
||
} {
|
||
if err := q.UpsertWeightProfileDefaults(ctx, upsertParams(profile, w)); err != nil {
|
||
return fmt.Errorf("seed profile %q: %w", profile, err)
|
||
}
|
||
}
|
||
st := ShippedTasteTuning()
|
||
if err := q.UpsertTasteTuningDefaults(ctx, dbq.UpsertTasteTuningDefaultsParams{
|
||
HalfLifeDays: st.HalfLifeDays,
|
||
EngagementHardSkip: st.EngagementHardSkip,
|
||
EngagementNeutral: st.EngagementNeutral,
|
||
EngagementFull: st.EngagementFull,
|
||
EnrichedTagScale: st.EnrichedTagScale,
|
||
EraScale: st.EraScale,
|
||
MoodScale: st.MoodScale,
|
||
}); err != nil {
|
||
return fmt.Errorf("seed taste tuning: %w", err)
|
||
}
|
||
sd := ShippedDiscoverTuning()
|
||
if err := q.UpsertDiscoverTuningDefaults(ctx, dbq.UpsertDiscoverTuningDefaultsParams{
|
||
TagOverlapWeight: sd.TagOverlapWeight,
|
||
SnoozeDays: sd.SnoozeDays,
|
||
}); err != nil {
|
||
return fmt.Errorf("seed discover tuning: %w", err)
|
||
}
|
||
|
||
rows, err := q.ListWeightProfiles(ctx)
|
||
if err != nil {
|
||
return fmt.Errorf("list weight profiles: %w", err)
|
||
}
|
||
tt, err := q.GetTasteTuning(ctx)
|
||
if err != nil {
|
||
return fmt.Errorf("get taste tuning: %w", err)
|
||
}
|
||
dt, err := q.GetDiscoverTuning(ctx)
|
||
if err != nil {
|
||
return fmt.Errorf("get discover tuning: %w", err)
|
||
}
|
||
|
||
s.mu.Lock()
|
||
s.profiles = map[string]recommendation.ScoringWeights{}
|
||
for _, r := range rows {
|
||
s.profiles[r.Profile] = weightsFromRow(r)
|
||
}
|
||
s.taste = TasteTuning{
|
||
HalfLifeDays: tt.HalfLifeDays,
|
||
EngagementHardSkip: tt.EngagementHardSkip,
|
||
EngagementNeutral: tt.EngagementNeutral,
|
||
EngagementFull: tt.EngagementFull,
|
||
EnrichedTagScale: tt.EnrichedTagScale,
|
||
EraScale: tt.EraScale,
|
||
MoodScale: tt.MoodScale,
|
||
}
|
||
s.discover = DiscoverTuning{
|
||
TagOverlapWeight: dt.TagOverlapWeight,
|
||
SnoozeDays: dt.SnoozeDays,
|
||
}
|
||
s.mu.Unlock()
|
||
|
||
s.push()
|
||
return nil
|
||
}
|
||
|
||
// push installs the daily-build values into package playlists (the
|
||
// coverart Configure() pattern). Radio needs no push — its handler
|
||
// reads Weights(ScopeRadio) per request.
|
||
func (s *Service) push() {
|
||
playlists.SetSystemMixWeights(s.Weights(ScopeDailyMix))
|
||
playlists.SetTasteConfig(s.TasteConfig())
|
||
}
|
||
|
||
// Weights returns the cached weights for a profile scope. Unknown
|
||
// scopes return the shipped radio defaults (defensive; callers pass
|
||
// the Scope* constants).
|
||
func (s *Service) Weights(profile string) recommendation.ScoringWeights {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
if w, ok := s.profiles[profile]; ok {
|
||
return w
|
||
}
|
||
return ShippedRadioWeights()
|
||
}
|
||
|
||
// Taste returns the cached taste-tuning values.
|
||
func (s *Service) Taste() TasteTuning {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
return s.taste
|
||
}
|
||
|
||
// Discover returns the cached Discover-tuning values. Read per request by the
|
||
// suggestions handler, so an admin change takes effect on the next refresh
|
||
// with no restart (rule #25).
|
||
func (s *Service) Discover() DiscoverTuning {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
return s.discover
|
||
}
|
||
|
||
// TasteConfig assembles the full taste.Config the profile builder
|
||
// consumes: shipped non-tunable knobs (like bonuses, floors, caps)
|
||
// plus the tuned half-life and curve. WindowDays scales with the
|
||
// half-life at the shipped ratio (270/75 = 3.6 half-lives) — the
|
||
// window is a query-cost bound, not an independent knob.
|
||
func (s *Service) TasteConfig() taste.Config {
|
||
t := s.Taste()
|
||
cfg := taste.DefaultConfig()
|
||
cfg.HalfLifeDays = t.HalfLifeDays
|
||
cfg.WindowDays = t.HalfLifeDays * 3.6
|
||
cfg.Engagement = taste.EngagementParams{
|
||
HardSkip: t.EngagementHardSkip,
|
||
NeutralCompletion: t.EngagementNeutral,
|
||
FullCompletion: t.EngagementFull,
|
||
}
|
||
cfg.EnrichedTagScale = t.EnrichedTagScale
|
||
cfg.EraScale = t.EraScale
|
||
cfg.MoodScale = t.MoodScale
|
||
return cfg
|
||
}
|
||
|
||
// fieldChange is one entry of an audit row's changes array.
|
||
type fieldChange struct {
|
||
Field string `json:"field"`
|
||
Old float64 `json:"old"`
|
||
New float64 `json:"new"`
|
||
}
|
||
|
||
// UpdateProfile applies a partial update to one weight profile.
|
||
// Unknown fields and out-of-range values reject the whole patch. A
|
||
// no-op patch (all values equal to current) writes no audit row.
|
||
func (s *Service) UpdateProfile(ctx context.Context, profile string, patch map[string]float64) error {
|
||
if profile != ScopeRadio && profile != ScopeDailyMix {
|
||
return fmt.Errorf("%w: %q", ErrUnknownScope, profile)
|
||
}
|
||
current := s.Weights(profile)
|
||
next, changes, err := applyWeightPatch(current, patch)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if len(changes) == 0 {
|
||
return nil
|
||
}
|
||
return s.persistProfile(ctx, profile, next, "update", changes)
|
||
}
|
||
|
||
// UpdateTaste applies a partial update to the taste tuning singleton.
|
||
func (s *Service) UpdateTaste(ctx context.Context, patch map[string]float64) error {
|
||
current := s.Taste()
|
||
next, changes, err := applyTastePatch(current, patch)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if len(changes) == 0 {
|
||
return nil
|
||
}
|
||
return s.persistTaste(ctx, next, "update", changes)
|
||
}
|
||
|
||
// UpdateDiscover applies a partial update to the Discover tuning singleton.
|
||
func (s *Service) UpdateDiscover(ctx context.Context, patch map[string]float64) error {
|
||
current := s.Discover()
|
||
next, changes, err := applyDiscoverPatch(current, patch)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if len(changes) == 0 {
|
||
return nil
|
||
}
|
||
return s.persistDiscover(ctx, next, "update", changes)
|
||
}
|
||
|
||
// Reset restores a scope to its shipped defaults, with one audit row
|
||
// carrying the full diff. A scope already at defaults is a no-op.
|
||
func (s *Service) Reset(ctx context.Context, scope string) error {
|
||
switch scope {
|
||
case ScopeRadio, ScopeDailyMix:
|
||
shipped := ShippedRadioWeights()
|
||
if scope == ScopeDailyMix {
|
||
shipped = ShippedDailyMixWeights()
|
||
}
|
||
changes := diffWeights(s.Weights(scope), shipped)
|
||
if len(changes) == 0 {
|
||
return nil
|
||
}
|
||
return s.persistProfile(ctx, scope, shipped, "reset", changes)
|
||
case ScopeTaste:
|
||
shipped := ShippedTasteTuning()
|
||
changes := diffTaste(s.Taste(), shipped)
|
||
if len(changes) == 0 {
|
||
return nil
|
||
}
|
||
return s.persistTaste(ctx, shipped, "reset", changes)
|
||
case ScopeDiscover:
|
||
shipped := ShippedDiscoverTuning()
|
||
changes := diffDiscover(s.Discover(), shipped)
|
||
if len(changes) == 0 {
|
||
return nil
|
||
}
|
||
return s.persistDiscover(ctx, shipped, "reset", changes)
|
||
default:
|
||
return fmt.Errorf("%w: %q", ErrUnknownScope, scope)
|
||
}
|
||
}
|
||
|
||
// persistProfile writes the profile row + audit entry, refreshes the
|
||
// cache, and pushes daily-build values.
|
||
func (s *Service) persistProfile(
|
||
ctx context.Context, profile string, w recommendation.ScoringWeights,
|
||
action string, changes []fieldChange,
|
||
) error {
|
||
q := dbq.New(s.pool)
|
||
if _, err := q.UpdateWeightProfile(ctx, updateParams(profile, w)); err != nil {
|
||
return fmt.Errorf("update profile %q: %w", profile, err)
|
||
}
|
||
if err := s.audit(ctx, q, profile, action, changes); err != nil {
|
||
return err
|
||
}
|
||
s.mu.Lock()
|
||
s.profiles[profile] = w
|
||
s.mu.Unlock()
|
||
s.push()
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) persistTaste(
|
||
ctx context.Context, t TasteTuning, action string, changes []fieldChange,
|
||
) error {
|
||
q := dbq.New(s.pool)
|
||
if _, err := q.UpdateTasteTuning(ctx, dbq.UpdateTasteTuningParams{
|
||
HalfLifeDays: t.HalfLifeDays,
|
||
EngagementHardSkip: t.EngagementHardSkip,
|
||
EngagementNeutral: t.EngagementNeutral,
|
||
EngagementFull: t.EngagementFull,
|
||
EnrichedTagScale: t.EnrichedTagScale,
|
||
EraScale: t.EraScale,
|
||
MoodScale: t.MoodScale,
|
||
}); err != nil {
|
||
return fmt.Errorf("update taste tuning: %w", err)
|
||
}
|
||
if err := s.audit(ctx, q, ScopeTaste, action, changes); err != nil {
|
||
return err
|
||
}
|
||
s.mu.Lock()
|
||
s.taste = t
|
||
s.mu.Unlock()
|
||
s.push()
|
||
return nil
|
||
}
|
||
|
||
// persistDiscover writes the discover row + audit entry and refreshes the
|
||
// cache. No push(): unlike taste and daily_mix, nothing precomputes from these
|
||
// — the suggestions handler reads Discover() per request.
|
||
func (s *Service) persistDiscover(
|
||
ctx context.Context, d DiscoverTuning, action string, changes []fieldChange,
|
||
) error {
|
||
q := dbq.New(s.pool)
|
||
if _, err := q.UpdateDiscoverTuning(ctx, dbq.UpdateDiscoverTuningParams{
|
||
TagOverlapWeight: d.TagOverlapWeight,
|
||
SnoozeDays: d.SnoozeDays,
|
||
}); err != nil {
|
||
return fmt.Errorf("update discover tuning: %w", err)
|
||
}
|
||
if err := s.audit(ctx, q, ScopeDiscover, action, changes); err != nil {
|
||
return err
|
||
}
|
||
s.mu.Lock()
|
||
s.discover = d
|
||
s.mu.Unlock()
|
||
return nil
|
||
}
|
||
|
||
// audit writes one recommendation_tuning_audit row. Changes are
|
||
// sorted by field so rows are deterministic and diff-friendly.
|
||
func (s *Service) audit(
|
||
ctx context.Context, q *dbq.Queries, scope, action string, changes []fieldChange,
|
||
) error {
|
||
sort.Slice(changes, func(i, j int) bool { return changes[i].Field < changes[j].Field })
|
||
payload, err := json.Marshal(changes)
|
||
if err != nil {
|
||
return fmt.Errorf("marshal audit changes: %w", err)
|
||
}
|
||
if err := q.InsertTuningAudit(ctx, dbq.InsertTuningAuditParams{
|
||
Scope: scope, Action: action, Changes: payload,
|
||
}); err != nil {
|
||
return fmt.Errorf("insert audit row: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func upsertParams(profile string, w recommendation.ScoringWeights) dbq.UpsertWeightProfileDefaultsParams {
|
||
return dbq.UpsertWeightProfileDefaultsParams{
|
||
Profile: profile,
|
||
BaseWeight: w.BaseWeight,
|
||
LikeBoost: w.LikeBoost,
|
||
RecencyWeight: w.RecencyWeight,
|
||
SkipPenalty: w.SkipPenalty,
|
||
JitterMagnitude: w.JitterMagnitude,
|
||
ContextWeight: w.ContextWeight,
|
||
SimilarityWeight: w.SimilarityWeight,
|
||
TasteWeight: w.TasteWeight,
|
||
ContextTimeWeight: w.ContextTimeWeight,
|
||
}
|
||
}
|
||
|
||
func updateParams(profile string, w recommendation.ScoringWeights) dbq.UpdateWeightProfileParams {
|
||
return dbq.UpdateWeightProfileParams{
|
||
Profile: profile,
|
||
BaseWeight: w.BaseWeight,
|
||
LikeBoost: w.LikeBoost,
|
||
RecencyWeight: w.RecencyWeight,
|
||
SkipPenalty: w.SkipPenalty,
|
||
JitterMagnitude: w.JitterMagnitude,
|
||
ContextWeight: w.ContextWeight,
|
||
SimilarityWeight: w.SimilarityWeight,
|
||
TasteWeight: w.TasteWeight,
|
||
ContextTimeWeight: w.ContextTimeWeight,
|
||
}
|
||
}
|
||
|
||
func weightsFromRow(r dbq.RecommendationWeightProfile) recommendation.ScoringWeights {
|
||
return recommendation.ScoringWeights{
|
||
BaseWeight: r.BaseWeight,
|
||
LikeBoost: r.LikeBoost,
|
||
RecencyWeight: r.RecencyWeight,
|
||
SkipPenalty: r.SkipPenalty,
|
||
JitterMagnitude: r.JitterMagnitude,
|
||
ContextWeight: r.ContextWeight,
|
||
SimilarityWeight: r.SimilarityWeight,
|
||
TasteWeight: r.TasteWeight,
|
||
ContextTimeWeight: r.ContextTimeWeight,
|
||
}
|
||
}
|