feat(recommendation): For You exploration attribution — taste vs fresh picks
test-go / test (push) Successful in 31s
test-web / test (push) Successful in 37s
test-go / integration (push) Successful in 4m37s

Milestone #127 step 2 (#1249). For You deliberately blends two
populations — a head of top-scored taste picks and a tail sampled from
deeper ranking (the freshness injection) — but the metrics judged it as
one blob, so its skip rate couldn't distinguish "the taste engine is
missing" from "the freshness tax is too high". That number decides the
exploration share before we tune it.

- Migration 0038: nullable pick_kind ('taste'|'fresh') on both
  playlist_tracks (stamped at snapshot build) and play_events (frozen at
  play-ingestion — the snapshot rebuilds daily, so attribution cannot be
  reconstructed at read time).
- Builder: pickHeadAndTail marks head=taste / tail=fresh; the small-pool
  fallback is all taste (top-N-by-score IS the taste mechanism). Other
  variants persist NULL.
- Ingestion: for_you plays (live + offline replay) look the track up in
  the user's current snapshot; not found → unattributed, never guessed.
- Metrics: For You's row gains a breakdown (taste / fresh / earlier
  unattributed plays), parent row stays the sum; web card renders the
  sub-rows indented with the same baseline deltas + low-data dimming.

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 20:39:41 -04:00
parent 60533073ad
commit fb4431207d
18 changed files with 593 additions and 39 deletions
+3
View File
@@ -12,6 +12,9 @@ export type SurfaceMetric = {
skip_rate: number;
avg_completion: number;
low_confidence: boolean;
// Only For You carries a breakdown (#1249): taste picks vs fresh picks
// (the deliberate freshness injection), plus pre-attribution plays.
breakdown?: SurfaceMetric[];
};
export type SurfaceIntent = 'go_to' | 'discovery' | 'direct';
+34
View File
@@ -372,6 +372,40 @@
{/if}
</td>
</tr>
<!-- For You splits into taste picks vs the deliberate
freshness injection (#1249) — the number that says
whether skips come from the taste engine missing or
from the exploration tax. -->
{#each m.breakdown ?? [] as b (b.key)}
<tr
class="border-t border-border/50 text-text-secondary"
class:opacity-60={b.low_confidence}
title={b.low_confidence
? 'Fewer than 20 plays — treat these rates as anecdote, not signal.'
: undefined}
>
<td class="py-1 pl-4 text-xs">
{b.label}{#if b.low_confidence}<span class="ml-1">· low data</span>{/if}
</td>
<td class="py-1 text-right text-xs tabular-nums">{b.plays}</td>
<td class="py-1 text-right text-xs tabular-nums">
{pct(b.skip_rate)}
{#if baseline}
<span class="ml-1 {skipDeltaClass(b, baseline)}">
{deltaPts(b.skip_rate, baseline.skip_rate)}
</span>
{/if}
</td>
<td class="py-1 text-right text-xs tabular-nums">
{pct(b.avg_completion)}
{#if baseline}
<span class="ml-1 {completionDeltaClass(b, baseline)}">
{deltaPts(b.avg_completion, baseline.avg_completion)}
</span>
{/if}
</td>
</tr>
{/each}
{/each}
</tbody>
</table>
+75 -2
View File
@@ -20,10 +20,16 @@ vi.mock('$lib/api/me', () => ({
regenerateAPIToken: vi.fn()
}));
// Mutable holder so individual tests can inject populated metrics;
// vi.mock is hoisted, hence vi.hoisted for the shared reference.
const metricsMock = vi.hoisted(() => ({
data: { window_days: 30, baseline: null, groups: [] } as unknown
}));
vi.mock('$lib/api/metrics', () => ({
createRecommendationMetricsQuery: () => ({
subscribe: (run: (v: unknown) => void) => {
run({ isPending: false, isError: false, data: { window_days: 30, baseline: null, groups: [] } });
run({ isPending: false, isError: false, data: metricsMock.data });
return () => {};
}
})
@@ -52,7 +58,10 @@ function mockMutationStore(mutateFn: (vars: unknown) => void = vi.fn()) {
return readable({ isPending: false, mutate: mutateFn, mutateAsync: vi.fn() });
}
afterEach(() => vi.clearAllMocks());
afterEach(() => {
vi.clearAllMocks();
metricsMock.data = { window_days: 30, baseline: null, groups: [] };
});
describe('Settings page — ListenBrainz', () => {
test('token-not-set state renders input + Save button', async () => {
@@ -177,6 +186,70 @@ describe('Settings page — Password card', () => {
});
});
describe('Settings page — Recommendation metrics card', () => {
const metric = (key: string, label: string, over: Record<string, unknown> = {}) => ({
key,
label,
plays: 30,
skips: 3,
skip_rate: 0.1,
avg_completion: 0.9,
low_confidence: false,
...over
});
test('renders the For You taste/fresh breakdown rows (#1249)', async () => {
setupPage();
metricsMock.data = {
window_days: 30,
baseline: metric('manual', 'Manual library plays', { plays: 100 }),
groups: [
{
intent: 'go_to',
label: 'Go-to surfaces',
surfaces: [
metric('for_you', 'For You', {
plays: 45,
breakdown: [
metric('for_you_taste', 'Taste picks'),
metric('for_you_fresh', 'Fresh picks', {
plays: 10,
skip_rate: 0.4,
low_confidence: true
}),
metric('for_you_unattributed', 'Earlier plays', { plays: 5 })
]
})
]
}
]
};
render(SettingsPage);
await waitFor(() => expect(screen.getByText('For You')).toBeInTheDocument());
expect(screen.getByText(/Taste picks/)).toBeInTheDocument();
expect(screen.getByText(/Fresh picks/)).toBeInTheDocument();
expect(screen.getByText(/Earlier plays/)).toBeInTheDocument();
});
test('surfaces without a breakdown render no sub-rows', async () => {
setupPage();
metricsMock.data = {
window_days: 30,
baseline: null,
groups: [
{
intent: 'go_to',
label: 'Go-to surfaces',
surfaces: [metric('radio', 'Radio')]
}
]
};
render(SettingsPage);
await waitFor(() => expect(screen.getByText('Radio')).toBeInTheDocument());
expect(screen.queryByText(/Taste picks/)).not.toBeInTheDocument();
});
});
describe('Settings page — API Token card', () => {
test('first click on Regenerate shows "Click again to confirm"', async () => {
setupPage();