feat(metrics): bucketed surface families + manual-plays baseline (#1248, milestone 127)
test-go / test (push) Successful in 31s
test-web / test (push) Successful in 37s
test-go / integration (push) Successful in 4m30s

The recommendation metrics table was observable but not actionable: raw
source strings (album:<uuid> one-offs) drowned the stable surfaces, and
manual plays were excluded so skip rates had no control group.

- SQL: include NULL-source rows (the baseline) and carry completion_n
  so family merges can weight avg_completion correctly.
- Handler buckets raw sources into stable families (radio:<uuid> →
  Radio, album:/artist: → direct plays, etc.) grouped by surface
  intent: go-to / discovery / direct — each band judged against its
  job, since discovery mixes are expected to skip hotter. Families
  under 20 plays are flagged low-confidence, not hidden.
- Settings card renders the baseline row and per-surface deltas in
  percentage points vs baseline (worse-than-baseline deltas in danger
  color), intent hint copy per group, low-data rows dimmed.
- Pure-unit test for the bucketing/merge; DB test updated to the new
  contract (baseline included, radio:<uuid> collapse).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
This commit is contained in:
2026-07-02 18:00:40 -04:00
parent 4b150a277e
commit 60533073ad
8 changed files with 391 additions and 111 deletions
+17 -20
View File
@@ -1,18 +1,31 @@
import { createQuery } from '@tanstack/svelte-query';
import { api } from './client';
// Mirrors internal/api/me_recommendation_metrics.go.
export type RecommendationMetric = {
source: string;
// Mirrors internal/api/me_recommendation_metrics.go: raw play sources are
// bucketed server-side into stable surface families, grouped by intent, and
// anchored by the manual-plays baseline (milestone #127).
export type SurfaceMetric = {
key: string;
label: string;
plays: number;
skips: number;
skip_rate: number;
avg_completion: number;
low_confidence: boolean;
};
export type SurfaceIntent = 'go_to' | 'discovery' | 'direct';
export type SurfaceGroup = {
intent: SurfaceIntent;
label: string;
surfaces: SurfaceMetric[];
};
export type RecommendationMetrics = {
window_days: number;
sources: RecommendationMetric[];
baseline: SurfaceMetric | null;
groups: SurfaceGroup[];
};
export function getRecommendationMetrics(): Promise<RecommendationMetrics> {
@@ -28,19 +41,3 @@ export function createRecommendationMetricsQuery() {
staleTime: 60_000
});
}
// Friendly labels for the system-playlist source keys (play_events.source).
const SOURCE_LABELS: Record<string, string> = {
for_you: 'For You',
discover: 'Discover',
deep_cuts: 'Deep cuts',
rediscover: 'Rediscover',
new_for_you: 'New for you',
on_this_day: 'On this day',
first_listens: 'First listens',
songs_like_artist: 'Songs like…'
};
export function sourceLabel(source: string): string {
return SOURCE_LABELS[source] ?? source;
}
+97 -26
View File
@@ -10,8 +10,9 @@
} from '$lib/api/listenbrainz';
import {
createRecommendationMetricsQuery,
sourceLabel,
type RecommendationMetrics
type RecommendationMetrics,
type SurfaceIntent,
type SurfaceMetric
} from '$lib/api/metrics';
import { theme, setTheme, type ThemePreference } from '$lib/stores/theme.svelte';
import { player, setCrossfade } from '$lib/player/store.svelte';
@@ -30,6 +31,34 @@
const status = createLBStatusQuery() as CreateQueryResult<LBStatus>;
const metrics = createRecommendationMetricsQuery() as CreateQueryResult<RecommendationMetrics>;
// Per-intent expectation copy: each surface band is judged against its
// job — discovery mixes are supposed to run hotter skip rates.
const intentHints: Record<SurfaceIntent, string> = {
go_to: 'Everyday surfaces — these should beat your own picking.',
discovery: 'Exploration surfaces — higher skip rates here are expected and healthy.',
direct: 'Music you chose yourself — naturally close to the baseline.'
};
function pct(v: number): string {
return `${(v * 100).toFixed(0)}%`;
}
// Delta in percentage points vs the baseline, signed ("+12" / "5").
function deltaPts(value: number, baseline: number): string {
const pts = Math.round((value - baseline) * 100);
return pts > 0 ? `+${pts}` : `${pts}`;
}
// A surface's skip delta is "worse" when it skips more than the
// baseline; completion delta is "worse" when it completes less.
function skipDeltaClass(m: SurfaceMetric, baseline: SurfaceMetric): string {
return m.skip_rate > baseline.skip_rate ? 'text-danger' : 'text-text-secondary';
}
function completionDeltaClass(m: SurfaceMetric, baseline: SurfaceMetric): string {
return m.avg_completion < baseline.avg_completion ? 'text-danger' : 'text-text-secondary';
}
const tokenMutation = createTokenMutation(queryClient);
const enabledMutation = createEnabledMutation(queryClient);
@@ -278,37 +307,79 @@
<h2 class="text-lg font-semibold">Recommendation metrics</h2>
<p class="text-sm text-text-secondary">
How plays launched from each recommendation surface land, over the last
{$metrics.data?.window_days ?? 30} days. Lower skip rate and higher average
completion mean the surface is hitting.
{$metrics.data?.window_days ?? 30} days — compared against the baseline of
music you picked yourself. Deltas are percentage points vs that baseline.
</p>
{#if $metrics.isPending}
<p class="text-sm text-text-secondary">Loading…</p>
{:else if $metrics.isError}
<p class="text-sm text-text-secondary">Couldn't load metrics.</p>
{:else if $metrics.data && $metrics.data.sources.length > 0}
<table class="w-full text-sm">
<thead>
<tr class="text-left text-text-secondary">
<th class="py-1 font-medium">Surface</th>
<th class="py-1 text-right font-medium">Plays</th>
<th class="py-1 text-right font-medium">Skip rate</th>
<th class="py-1 text-right font-medium">Avg completion</th>
</tr>
</thead>
<tbody>
{#each $metrics.data.sources as m (m.source)}
<tr class="border-t border-border">
<td class="py-1">{sourceLabel(m.source)}</td>
<td class="py-1 text-right tabular-nums">{m.plays}</td>
<td class="py-1 text-right tabular-nums">{(m.skip_rate * 100).toFixed(0)}%</td>
<td class="py-1 text-right tabular-nums">{(m.avg_completion * 100).toFixed(0)}%</td>
</tr>
{/each}
</tbody>
</table>
{:else if $metrics.data && ($metrics.data.groups.length > 0 || $metrics.data.baseline)}
{@const baseline = $metrics.data.baseline}
{#if baseline}
<p class="rounded bg-background px-3 py-2 text-sm">
<span class="font-medium">Baseline — manual plays:</span>
<span class="text-text-secondary">
{baseline.plays} plays · {pct(baseline.skip_rate)} skip ·
{pct(baseline.avg_completion)} completion
</span>
</p>
{:else}
<p class="text-sm text-text-secondary">
No manual plays in this window yet, so deltas are hidden — raw rates only.
</p>
{/if}
{#each $metrics.data.groups as group (group.intent)}
<div class="space-y-1">
<h3 class="text-sm font-medium">{group.label}</h3>
<p class="text-xs text-text-secondary">{intentHints[group.intent]}</p>
<table class="w-full text-sm">
<thead>
<tr class="text-left text-text-secondary">
<th class="py-1 font-medium">Surface</th>
<th class="py-1 text-right font-medium">Plays</th>
<th class="py-1 text-right font-medium">Skip rate</th>
<th class="py-1 text-right font-medium">Avg completion</th>
</tr>
</thead>
<tbody>
{#each group.surfaces as m (m.key)}
<tr
class="border-t border-border"
class:opacity-60={m.low_confidence}
title={m.low_confidence
? 'Fewer than 20 plays — treat these rates as anecdote, not signal.'
: undefined}
>
<td class="py-1">
{m.label}{#if m.low_confidence}<span class="ml-1 text-xs text-text-secondary">· low data</span>{/if}
</td>
<td class="py-1 text-right tabular-nums">{m.plays}</td>
<td class="py-1 text-right tabular-nums">
{pct(m.skip_rate)}
{#if baseline}
<span class="ml-1 text-xs {skipDeltaClass(m, baseline)}">
{deltaPts(m.skip_rate, baseline.skip_rate)}
</span>
{/if}
</td>
<td class="py-1 text-right tabular-nums">
{pct(m.avg_completion)}
{#if baseline}
<span class="ml-1 text-xs {completionDeltaClass(m, baseline)}">
{deltaPts(m.avg_completion, baseline.avg_completion)}
</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/each}
{:else}
<p class="text-sm text-text-secondary">
No recommendation plays yet. Play something from For You, Discover, or a mix.
No plays recorded yet. Play something from For You, Discover, or a mix.
</p>
{/if}
</section>
+2 -3
View File
@@ -28,11 +28,10 @@ vi.mock('$lib/api/listenbrainz', () => {
vi.mock('$lib/api/metrics', () => ({
createRecommendationMetricsQuery: () => ({
subscribe: (run: (v: unknown) => void) => {
run({ isPending: false, isError: false, data: { window_days: 30, sources: [] } });
run({ isPending: false, isError: false, data: { window_days: 30, baseline: null, groups: [] } });
return () => {};
}
}),
sourceLabel: (s: string) => s
})
}));
beforeEach(() => {
+2 -3
View File
@@ -23,11 +23,10 @@ vi.mock('$lib/api/me', () => ({
vi.mock('$lib/api/metrics', () => ({
createRecommendationMetricsQuery: () => ({
subscribe: (run: (v: unknown) => void) => {
run({ isPending: false, isError: false, data: { window_days: 30, sources: [] } });
run({ isPending: false, isError: false, data: { window_days: 30, baseline: null, groups: [] } });
return () => {};
}
}),
sourceLabel: (s: string) => s
})
}));
import SettingsPage from './+page.svelte';