feat: Introduce random sampling for people recommendationsUnverified
2b8e65c parent: ecbbcf7 modified
internal/cluster/precompute.go +3 -6 | @@ -62,21 +62,18 @@ func (e *Engine) precomputeForUser(ctx context.Context, userDID string) error { | ||
| 62 | 62 | } |
| 63 | 63 | } |
| 64 | 64 | |
| 65 | - half := 3 | |
| 66 | - inNet, err := e.computePeopleByFollowStatus(ctx, userDID, true, half) | |
| 65 | + peoplePool := 10 | |
| 66 | + inNet, err := e.computePeopleByFollowStatus(ctx, userDID, true, peoplePool) | |
| 67 | 67 | if err != nil { |
| 68 | 68 | return err |
| 69 | 69 | } |
| 70 | - outNet, err := e.computePeopleByFollowStatus(ctx, userDID, false, half) | |
| 70 | + outNet, err := e.computePeopleByFollowStatus(ctx, userDID, false, peoplePool) | |
| 71 | 71 | if err != nil { |
| 72 | 72 | return err |
| 73 | 73 | } |
| 74 | 74 | peopleRecs := append(inNet, outNet...) |
| 75 | 75 | if len(peopleRecs) > 0 { |
| 76 | 76 | normalizePersonScores(peopleRecs) |
| 77 | - if len(peopleRecs) > 6 { | |
| 78 | - peopleRecs = peopleRecs[:6] | |
| 79 | - } | |
| 80 | 77 | } |
| 81 | 78 | |
| 82 | 79 | e.storeRecs(ctx, userDID, "feed", feedRecs) |
| @@ -62,21 +62,18 @@ func (e *Engine) precomputeForUser(ctx context.Context, userDID string) error { | |||
| 62 | } | 62 | } |
| 63 | } | 63 | } |
| 64 | 64 | ||
| 65 | - half := 3 | 65 | + peoplePool := 10 |
| 66 | - inNet, err := e.computePeopleByFollowStatus(ctx, userDID, true, half) | 66 | + inNet, err := e.computePeopleByFollowStatus(ctx, userDID, true, peoplePool) |
| 67 | if err != nil { | 67 | if err != nil { |
| 68 | return err | 68 | return err |
| 69 | } | 69 | } |
| 70 | - outNet, err := e.computePeopleByFollowStatus(ctx, userDID, false, half) | 70 | + outNet, err := e.computePeopleByFollowStatus(ctx, userDID, false, peoplePool) |
| 71 | if err != nil { | 71 | if err != nil { |
| 72 | return err | 72 | return err |
| 73 | } | 73 | } |
| 74 | peopleRecs := append(inNet, outNet...) | 74 | peopleRecs := append(inNet, outNet...) |
| 75 | if len(peopleRecs) > 0 { | 75 | if len(peopleRecs) > 0 { |
| 76 | normalizePersonScores(peopleRecs) | 76 | normalizePersonScores(peopleRecs) |
| 77 | - if len(peopleRecs) > 6 { | ||
| 78 | - peopleRecs = peopleRecs[:6] | ||
| 79 | - } | ||
| 80 | } | 77 | } |
| 81 | 78 | ||
| 82 | e.storeRecs(ctx, userDID, "feed", feedRecs) | 79 | e.storeRecs(ctx, userDID, "feed", feedRecs) |
modified
internal/cluster/scoring.go +44 -13 | @@ -5,6 +5,7 @@ import ( | ||
| 5 | 5 | "database/sql" |
| 6 | 6 | "encoding/json" |
| 7 | 7 | "fmt" |
| 8 | + "math/rand/v2" | |
| 8 | 9 | "time" |
| 9 | 10 | |
| 10 | 11 | "pkg.rbrt.fr/glean/internal/db" |
| @@ -104,36 +105,38 @@ func (e *Engine) GetFeedRecommendations(ctx context.Context, userDID string, lim | ||
| 104 | 105 | // limit come from the user's network (followed) and half from outside. When |
| 105 | 106 | // outside-network candidates are scarce, in-network fills the remaining slots. |
| 106 | 107 | func (e *Engine) GetPeopleRecommendations(ctx context.Context, userDID string, limit int) ([]*PersonRecommendation, error) { |
| 108 | + half := max(limit/2, 1) | |
| 109 | + | |
| 107 | 110 | if data, ok := e.getPrecomputed(ctx, userDID, "person"); ok { |
| 108 | - var recs []*PersonRecommendation | |
| 109 | - if err := json.Unmarshal([]byte(data), &recs); err == nil { | |
| 110 | - return recs, nil | |
| 111 | + var pool []*PersonRecommendation | |
| 112 | + if err := json.Unmarshal([]byte(data), &pool); err == nil { | |
| 113 | + return samplePeople(pool, half), nil | |
| 111 | 114 | } |
| 112 | 115 | } |
| 113 | 116 | |
| 114 | 117 | start := time.Now() |
| 115 | 118 | e.logger.Info("people recommendations cache miss, computing on-demand", "did", userDID) |
| 116 | 119 | |
| 117 | - half := max(limit/2, 1) | |
| 120 | + peoplePool := 10 | |
| 118 | 121 | |
| 119 | - inNet, err := e.computePeopleByFollowStatus(ctx, userDID, true, half) | |
| 122 | + inNet, err := e.computePeopleByFollowStatus(ctx, userDID, true, peoplePool) | |
| 120 | 123 | if err != nil { |
| 121 | 124 | return nil, err |
| 122 | 125 | } |
| 123 | 126 | |
| 124 | - outNet, err := e.computePeopleByFollowStatus(ctx, userDID, false, half) | |
| 127 | + outNet, err := e.computePeopleByFollowStatus(ctx, userDID, false, peoplePool) | |
| 125 | 128 | if err != nil { |
| 126 | 129 | return nil, err |
| 127 | 130 | } |
| 128 | 131 | |
| 129 | - var recs []*PersonRecommendation | |
| 130 | - recs = append(recs, inNet...) | |
| 131 | - recs = append(recs, outNet...) | |
| 132 | + var pool []*PersonRecommendation | |
| 133 | + pool = append(pool, inNet...) | |
| 134 | + pool = append(pool, outNet...) | |
| 132 | 135 | |
| 133 | - normalizePersonScores(recs) | |
| 134 | - e.storeRecs(ctx, userDID, "person", recs) | |
| 135 | - e.logger.Info("people recommendations computed (on-demand)", "did", userDID, "count", len(recs), "duration", time.Since(start)) | |
| 136 | - return recs, nil | |
| 136 | + normalizePersonScores(pool) | |
| 137 | + e.storeRecs(ctx, userDID, "person", pool) | |
| 138 | + e.logger.Info("people recommendations computed (on-demand)", "did", userDID, "count", len(pool), "duration", time.Since(start)) | |
| 139 | + return samplePeople(pool, half), nil | |
| 137 | 140 | } |
| 138 | 141 | |
| 139 | 142 | // GetArticleRecommendations returns article recommendations combining social |
| @@ -562,6 +565,34 @@ func (e *Engine) computePeopleByFollowStatus(ctx context.Context, userDID string | ||
| 562 | 565 | return results, rows.Err() |
| 563 | 566 | } |
| 564 | 567 | |
| 568 | +// samplePeople randomly selects up to half people from each group (followed/not) | |
| 569 | +// to ensure rotation across page loads. | |
| 570 | +func samplePeople(pool []*PersonRecommendation, half int) []*PersonRecommendation { | |
| 571 | + var inNet, outNet []*PersonRecommendation | |
| 572 | + for _, p := range pool { | |
| 573 | + if p.IsFollowed { | |
| 574 | + inNet = append(inNet, p) | |
| 575 | + } else { | |
| 576 | + outNet = append(outNet, p) | |
| 577 | + } | |
| 578 | + } | |
| 579 | + | |
| 580 | + rand.Shuffle(len(inNet), func(i, j int) { inNet[i], inNet[j] = inNet[j], inNet[i] }) | |
| 581 | + rand.Shuffle(len(outNet), func(i, j int) { outNet[i], outNet[j] = outNet[j], outNet[i] }) | |
| 582 | + | |
| 583 | + if len(inNet) > half { | |
| 584 | + inNet = inNet[:half] | |
| 585 | + } | |
| 586 | + if len(outNet) > half { | |
| 587 | + outNet = outNet[:half] | |
| 588 | + } | |
| 589 | + | |
| 590 | + result := make([]*PersonRecommendation, 0, len(inNet)+len(outNet)) | |
| 591 | + result = append(result, inNet...) | |
| 592 | + result = append(result, outNet...) | |
| 593 | + return result | |
| 594 | +} | |
| 595 | + | |
| 565 | 596 | func (e *Engine) ComputeSignalProfiles(ctx context.Context) error { |
| 566 | 597 | conn, err := e.db.Conn(ctx) |
| 567 | 598 | if err != nil { |
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | "database/sql" | 5 | "database/sql" |
| 6 | "encoding/json" | 6 | "encoding/json" |
| 7 | "fmt" | 7 | "fmt" |
| 8 | + "math/rand/v2" | ||
| 8 | "time" | 9 | "time" |
| 9 | 10 | ||
| 10 | "pkg.rbrt.fr/glean/internal/db" | 11 | "pkg.rbrt.fr/glean/internal/db" |
| @@ -104,36 +105,38 @@ func (e *Engine) GetFeedRecommendations(ctx context.Context, userDID string, lim | |||
| 104 | // limit come from the user's network (followed) and half from outside. When | 105 | // limit come from the user's network (followed) and half from outside. When |
| 105 | // outside-network candidates are scarce, in-network fills the remaining slots. | 106 | // outside-network candidates are scarce, in-network fills the remaining slots. |
| 106 | func (e *Engine) GetPeopleRecommendations(ctx context.Context, userDID string, limit int) ([]*PersonRecommendation, error) { | 107 | func (e *Engine) GetPeopleRecommendations(ctx context.Context, userDID string, limit int) ([]*PersonRecommendation, error) { |
| 108 | + half := max(limit/2, 1) | ||
| 109 | + | ||
| 107 | if data, ok := e.getPrecomputed(ctx, userDID, "person"); ok { | 110 | if data, ok := e.getPrecomputed(ctx, userDID, "person"); ok { |
| 108 | - var recs []*PersonRecommendation | 111 | + var pool []*PersonRecommendation |
| 109 | - if err := json.Unmarshal([]byte(data), &recs); err == nil { | 112 | + if err := json.Unmarshal([]byte(data), &pool); err == nil { |
| 110 | - return recs, nil | 113 | + return samplePeople(pool, half), nil |
| 111 | } | 114 | } |
| 112 | } | 115 | } |
| 113 | 116 | ||
| 114 | start := time.Now() | 117 | start := time.Now() |
| 115 | e.logger.Info("people recommendations cache miss, computing on-demand", "did", userDID) | 118 | e.logger.Info("people recommendations cache miss, computing on-demand", "did", userDID) |
| 116 | 119 | ||
| 117 | - half := max(limit/2, 1) | 120 | + peoplePool := 10 |
| 118 | 121 | ||
| 119 | - inNet, err := e.computePeopleByFollowStatus(ctx, userDID, true, half) | 122 | + inNet, err := e.computePeopleByFollowStatus(ctx, userDID, true, peoplePool) |
| 120 | if err != nil { | 123 | if err != nil { |
| 121 | return nil, err | 124 | return nil, err |
| 122 | } | 125 | } |
| 123 | 126 | ||
| 124 | - outNet, err := e.computePeopleByFollowStatus(ctx, userDID, false, half) | 127 | + outNet, err := e.computePeopleByFollowStatus(ctx, userDID, false, peoplePool) |
| 125 | if err != nil { | 128 | if err != nil { |
| 126 | return nil, err | 129 | return nil, err |
| 127 | } | 130 | } |
| 128 | 131 | ||
| 129 | - var recs []*PersonRecommendation | 132 | + var pool []*PersonRecommendation |
| 130 | - recs = append(recs, inNet...) | 133 | + pool = append(pool, inNet...) |
| 131 | - recs = append(recs, outNet...) | 134 | + pool = append(pool, outNet...) |
| 132 | 135 | ||
| 133 | - normalizePersonScores(recs) | 136 | + normalizePersonScores(pool) |
| 134 | - e.storeRecs(ctx, userDID, "person", recs) | 137 | + e.storeRecs(ctx, userDID, "person", pool) |
| 135 | - e.logger.Info("people recommendations computed (on-demand)", "did", userDID, "count", len(recs), "duration", time.Since(start)) | 138 | + e.logger.Info("people recommendations computed (on-demand)", "did", userDID, "count", len(pool), "duration", time.Since(start)) |
| 136 | - return recs, nil | 139 | + return samplePeople(pool, half), nil |
| 137 | } | 140 | } |
| 138 | 141 | ||
| 139 | // GetArticleRecommendations returns article recommendations combining social | 142 | // GetArticleRecommendations returns article recommendations combining social |
| @@ -562,6 +565,34 @@ func (e *Engine) computePeopleByFollowStatus(ctx context.Context, userDID string | |||
| 562 | return results, rows.Err() | 565 | return results, rows.Err() |
| 563 | } | 566 | } |
| 564 | 567 | ||
| 568 | +// samplePeople randomly selects up to half people from each group (followed/not) | ||
| 569 | +// to ensure rotation across page loads. | ||
| 570 | +func samplePeople(pool []*PersonRecommendation, half int) []*PersonRecommendation { | ||
| 571 | + var inNet, outNet []*PersonRecommendation | ||
| 572 | + for _, p := range pool { | ||
| 573 | + if p.IsFollowed { | ||
| 574 | + inNet = append(inNet, p) | ||
| 575 | + } else { | ||
| 576 | + outNet = append(outNet, p) | ||
| 577 | + } | ||
| 578 | + } | ||
| 579 | + | ||
| 580 | + rand.Shuffle(len(inNet), func(i, j int) { inNet[i], inNet[j] = inNet[j], inNet[i] }) | ||
| 581 | + rand.Shuffle(len(outNet), func(i, j int) { outNet[i], outNet[j] = outNet[j], outNet[i] }) | ||
| 582 | + | ||
| 583 | + if len(inNet) > half { | ||
| 584 | + inNet = inNet[:half] | ||
| 585 | + } | ||
| 586 | + if len(outNet) > half { | ||
| 587 | + outNet = outNet[:half] | ||
| 588 | + } | ||
| 589 | + | ||
| 590 | + result := make([]*PersonRecommendation, 0, len(inNet)+len(outNet)) | ||
| 591 | + result = append(result, inNet...) | ||
| 592 | + result = append(result, outNet...) | ||
| 593 | + return result | ||
| 594 | +} | ||
| 595 | + | ||
| 565 | func (e *Engine) ComputeSignalProfiles(ctx context.Context) error { | 596 | func (e *Engine) ComputeSignalProfiles(ctx context.Context) error { |
| 566 | conn, err := e.db.Conn(ctx) | 597 | conn, err := e.db.Conn(ctx) |
| 567 | if err != nil { | 598 | if err != nil { |