feat(recommendation): For You exploration attribution — taste vs fresh picks
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:
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user