feat(metrics): provenance as standard — pick_kind for all system mixes
The #1249 mechanism (stamp WHY a track is in the snapshot at build time, freeze it onto the play at ingestion, break it down in metrics) generalizes from a For You one-off to the standard for every system mix (#1270): - Migration 0039 widens both pick_kind CHECKs (drop + re-add in the same change) to taste/fresh + Discover's dormant/cross_user/random + tier1-3 for the rule-#131 eligibility ladders. - GetForYouPickKindForTrack becomes GetSystemPickKindForTrack (user, variant, track); ingestion stamps any systemPlaylistSources play from its own variant's live snapshot, live + offline paths. - Discover stamps its candidate bucket on discoverTrack before the interleave, making the 40/30/30 allocation measurable; dedup keeps the taking bucket's stamp. - Metrics replace the for_you special-case with one pick-kind vocabulary — any family with attributed plays gets a breakdown, future stamping mixes need no metrics change. - Web: breakdown sub-rows are now toggled per surface (collapsed by default) so eight stamping mixes don't swamp the card. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
This commit is contained in:
@@ -39,10 +39,12 @@ type surfaceMetric struct {
|
||||
SkipRate float64 `json:"skip_rate"` // skips / plays, [0,1]
|
||||
AvgCompletion float64 `json:"avg_completion"` // mean completion ratio, [0,1]
|
||||
LowConfidence bool `json:"low_confidence"` // plays < recMetricsLowVolume
|
||||
// Breakdown splits the family into sub-populations. Only For You
|
||||
// carries one (#1249): taste picks vs fresh picks (the deliberate
|
||||
// freshness injection), plus earlier plays that predate attribution.
|
||||
// The parent row remains the sum of its breakdown.
|
||||
// Breakdown splits the family into the pick-kind populations its
|
||||
// builder stamped (#1249, generalized #1270): For You's taste/fresh,
|
||||
// Discover's buckets, tier1-3 for tiered mixes — plus earlier plays
|
||||
// that predate attribution. Present only when the family has at
|
||||
// least one attributed play; the parent row remains the sum of its
|
||||
// breakdown.
|
||||
Breakdown []surfaceMetric `json:"breakdown,omitempty"`
|
||||
}
|
||||
|
||||
@@ -168,22 +170,50 @@ func (h *handlers) handleGetRecommendationMetrics(w http.ResponseWriter, r *http
|
||||
writeJSON(w, http.StatusOK, bucketMetricsResponse(days, rows))
|
||||
}
|
||||
|
||||
// forYouPickFamilies defines the For You breakdown rows (#1249), keyed
|
||||
// by play_events.pick_kind. "" (NULL pick_kind) = plays recorded before
|
||||
// attribution shipped, or whose track had already rotated out of the
|
||||
// snapshot at ingestion — kept visible so the parent row's sums stay
|
||||
// transparent instead of silently shrinking.
|
||||
var forYouPickFamilies = map[string]recFamily{
|
||||
"taste": {"for_you_taste", "Taste picks", intentGoTo},
|
||||
"fresh": {"for_you_fresh", "Fresh picks", intentGoTo},
|
||||
"": {"for_you_unattributed", "Earlier plays", intentGoTo},
|
||||
// pickKindLabels is the display vocabulary for play_events.pick_kind
|
||||
// values (mirrors the CHECK in migration 0039). Every system mix that
|
||||
// stamps provenance gets its breakdown from this one map — adding a
|
||||
// stamping mix needs no metrics change.
|
||||
var pickKindLabels = map[string]string{
|
||||
"taste": "Taste picks",
|
||||
"fresh": "Fresh picks",
|
||||
"dormant": "Dormant artists",
|
||||
"cross_user": "Liked by others",
|
||||
"random": "Random unheard",
|
||||
"tier1": "Tier 1 (exact)",
|
||||
"tier2": "Tier 2 (relaxed)",
|
||||
"tier3": "Tier 3 (stretched)",
|
||||
}
|
||||
|
||||
// forYouBreakdown folds pick-kind accums into the parent metric's
|
||||
// Breakdown. Attached only when at least one attributed (taste/fresh)
|
||||
// play exists — an all-unattributed breakdown would just repeat the
|
||||
// parent row.
|
||||
func forYouBreakdown(picks map[string]*familyAccum) []surfaceMetric {
|
||||
// pickKindOrder fixes breakdown row order; unattributed ("", i.e. NULL
|
||||
// pick_kind — plays recorded before the mix stamped provenance, or
|
||||
// whose track had rotated out of the snapshot at ingestion) renders
|
||||
// last, kept visible so the parent row's sums stay transparent instead
|
||||
// of silently shrinking. The DB CHECK gates pick_kind to exactly this
|
||||
// vocabulary, so iterating the list is exhaustive.
|
||||
var pickKindOrder = []string{
|
||||
"taste", "fresh", "dormant", "cross_user", "random",
|
||||
"tier1", "tier2", "tier3", "",
|
||||
}
|
||||
|
||||
// pickKindFamily derives the sub-family for one (family, pick_kind)
|
||||
// population, e.g. ("for_you", "taste") → for_you_taste "Taste picks".
|
||||
func pickKindFamily(parent recFamily, kind string) recFamily {
|
||||
if kind == "" {
|
||||
return recFamily{parent.key + "_unattributed", "Earlier plays", parent.intent}
|
||||
}
|
||||
label, ok := pickKindLabels[kind]
|
||||
if !ok {
|
||||
label = kind
|
||||
}
|
||||
return recFamily{parent.key + "_" + kind, label, parent.intent}
|
||||
}
|
||||
|
||||
// pickKindBreakdown folds a family's per-pick-kind accums into its
|
||||
// Breakdown rows. Attached only when at least one attributed play
|
||||
// exists — an all-unattributed breakdown would just repeat the parent
|
||||
// row, and families that never stamp (radio, direct plays) stay flat.
|
||||
func pickKindBreakdown(picks map[string]*familyAccum) []surfaceMetric {
|
||||
attributed := int64(0)
|
||||
for kind, acc := range picks {
|
||||
if kind != "" {
|
||||
@@ -194,7 +224,7 @@ func forYouBreakdown(picks map[string]*familyAccum) []surfaceMetric {
|
||||
return nil
|
||||
}
|
||||
out := make([]surfaceMetric, 0, len(picks))
|
||||
for _, kind := range []string{"taste", "fresh", ""} {
|
||||
for _, kind := range pickKindOrder {
|
||||
if acc, ok := picks[kind]; ok && acc.plays > 0 {
|
||||
out = append(out, acc.metric())
|
||||
}
|
||||
@@ -210,7 +240,7 @@ func bucketMetricsResponse(
|
||||
) recommendationMetricsResp {
|
||||
baseline := &familyAccum{fam: recFamily{"manual", "Manual library plays", ""}}
|
||||
families := map[string]*familyAccum{}
|
||||
forYouPicks := map[string]*familyAccum{}
|
||||
picks := map[string]map[string]*familyAccum{}
|
||||
for _, row := range rows {
|
||||
if row.Source == nil || *row.Source == "" {
|
||||
baseline.add(row)
|
||||
@@ -223,18 +253,23 @@ func bucketMetricsResponse(
|
||||
families[fam.key] = acc
|
||||
}
|
||||
acc.add(row)
|
||||
if fam.key == "for_you" {
|
||||
kind := ""
|
||||
if row.PickKind != nil {
|
||||
kind = *row.PickKind
|
||||
}
|
||||
pick, ok := forYouPicks[kind]
|
||||
if !ok {
|
||||
pick = &familyAccum{fam: forYouPickFamilies[kind]}
|
||||
forYouPicks[kind] = pick
|
||||
}
|
||||
pick.add(row)
|
||||
// Accumulate the pick-kind population unconditionally; families
|
||||
// that never stamp end up all-unattributed and get no breakdown.
|
||||
kind := ""
|
||||
if row.PickKind != nil {
|
||||
kind = *row.PickKind
|
||||
}
|
||||
byKind, ok := picks[fam.key]
|
||||
if !ok {
|
||||
byKind = map[string]*familyAccum{}
|
||||
picks[fam.key] = byKind
|
||||
}
|
||||
pick, ok := byKind[kind]
|
||||
if !ok {
|
||||
pick = &familyAccum{fam: pickKindFamily(fam, kind)}
|
||||
byKind[kind] = pick
|
||||
}
|
||||
pick.add(row)
|
||||
}
|
||||
|
||||
resp := recommendationMetricsResp{WindowDays: days, Groups: []surfaceGroup{}}
|
||||
@@ -251,9 +286,7 @@ func bucketMetricsResponse(
|
||||
for _, acc := range families {
|
||||
if acc.fam.intent == g.intent {
|
||||
m := acc.metric()
|
||||
if acc.fam.key == "for_you" {
|
||||
m.Breakdown = forYouBreakdown(forYouPicks)
|
||||
}
|
||||
m.Breakdown = pickKindBreakdown(picks[acc.fam.key])
|
||||
group.Surfaces = append(group.Surfaces, m)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +168,48 @@ func TestBucketMetricsResponse_ForYouBreakdown(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBucketMetricsResponse_DiscoverBucketBreakdown(t *testing.T) {
|
||||
// Provenance is standard (#1270): Discover's bucket stamps surface as
|
||||
// a breakdown exactly like For You's taste/fresh — this is what makes
|
||||
// the 40/30/30 allocation judgeable instead of a guess.
|
||||
src := func(s string) *string { return &s }
|
||||
kind := func(s string) *string { return &s }
|
||||
rows := []dbq.RecommendationSourceMetricsForUserRow{
|
||||
{Source: src("discover"), PickKind: kind("dormant"), Plays: 8, Skips: 2,
|
||||
CompletionN: 8, AvgCompletion: 0.8},
|
||||
{Source: src("discover"), PickKind: kind("cross_user"), Plays: 6, Skips: 3,
|
||||
CompletionN: 6, AvgCompletion: 0.6},
|
||||
{Source: src("discover"), PickKind: kind("random"), Plays: 4, Skips: 3,
|
||||
CompletionN: 4, AvgCompletion: 0.4},
|
||||
// NULL pick_kind = plays that predate bucket stamping.
|
||||
{Source: src("discover"), Plays: 2, Skips: 0, CompletionN: 2, AvgCompletion: 0.9},
|
||||
}
|
||||
resp := bucketMetricsResponse(recMetricsDefaultDays, rows)
|
||||
|
||||
d := findSurface(resp, "discover")
|
||||
if d == nil {
|
||||
t.Fatal("discover family missing")
|
||||
}
|
||||
if d.Plays != 20 || d.Skips != 8 {
|
||||
t.Errorf("discover plays/skips = %d/%d, want 20/8", d.Plays, d.Skips)
|
||||
}
|
||||
wantKeys := []string{
|
||||
"discover_dormant", "discover_cross_user", "discover_random",
|
||||
"discover_unattributed",
|
||||
}
|
||||
if len(d.Breakdown) != len(wantKeys) {
|
||||
t.Fatalf("breakdown rows = %d, want %d", len(d.Breakdown), len(wantKeys))
|
||||
}
|
||||
for i, k := range wantKeys {
|
||||
if d.Breakdown[i].Key != k {
|
||||
t.Errorf("breakdown[%d].key = %s, want %s", i, d.Breakdown[i].Key, k)
|
||||
}
|
||||
}
|
||||
if b := d.Breakdown[0]; b.Label != "Dormant artists" || b.Plays != 8 {
|
||||
t.Errorf("dormant row = %+v, want label=Dormant artists plays=8", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendationMetrics_BucketsWithBaseline(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
|
||||
Reference in New Issue
Block a user