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:
@@ -12,8 +12,10 @@ 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.
|
||||
// Present when the surface's builder stamps pick-kind provenance and
|
||||
// the window holds attributed plays (#1249, generalized #1270): For
|
||||
// You's taste/fresh split, Discover's candidate buckets, the tiered
|
||||
// mixes' tier1-3 — plus pre-attribution plays.
|
||||
breakdown?: SurfaceMetric[];
|
||||
};
|
||||
|
||||
|
||||
@@ -59,6 +59,18 @@
|
||||
function completionDeltaClass(m: SurfaceMetric, baseline: SurfaceMetric): string {
|
||||
return m.avg_completion < baseline.avg_completion ? 'text-danger' : 'text-text-secondary';
|
||||
}
|
||||
|
||||
// Pick-kind breakdowns are collapsed by default (#1270): with every
|
||||
// system mix stamping provenance, always-open sub-rows would triple
|
||||
// the table height and bury the surface-level comparison.
|
||||
let expandedBreakdowns = $state(new Set<string>());
|
||||
|
||||
function toggleBreakdown(key: string) {
|
||||
const next = new Set(expandedBreakdowns);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
expandedBreakdowns = next;
|
||||
}
|
||||
const tokenMutation = createTokenMutation(queryClient);
|
||||
const enabledMutation = createEnabledMutation(queryClient);
|
||||
|
||||
@@ -352,7 +364,21 @@
|
||||
: undefined}
|
||||
>
|
||||
<td class="py-1">
|
||||
{m.label}{#if m.low_confidence}<span class="ml-1 text-xs text-text-secondary">· low data</span>{/if}
|
||||
{#if (m.breakdown ?? []).length > 0}
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 rounded outline-none focus-visible:ring-1 focus-visible:ring-accent"
|
||||
aria-expanded={expandedBreakdowns.has(m.key)}
|
||||
onclick={() => toggleBreakdown(m.key)}
|
||||
>
|
||||
<span class="text-xs text-text-secondary" aria-hidden="true">
|
||||
{expandedBreakdowns.has(m.key) ? '▾' : '▸'}
|
||||
</span>
|
||||
{m.label}
|
||||
</button>
|
||||
{:else}
|
||||
{m.label}
|
||||
{/if}{#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">
|
||||
@@ -372,40 +398,44 @@
|
||||
{/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}
|
||||
<!-- Pick-kind provenance sub-rows (#1249/#1270): each
|
||||
surface splits into the populations its builder
|
||||
stamped — For You's taste vs freshness injection,
|
||||
Discover's buckets, the tiered mixes' tiers — the
|
||||
numbers that say WHERE a surface's skips come from.
|
||||
Toggled per surface to keep the default view flat. -->
|
||||
{#if expandedBreakdowns.has(m.key)}
|
||||
{#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}
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -198,7 +198,7 @@ describe('Settings page — Recommendation metrics card', () => {
|
||||
...over
|
||||
});
|
||||
|
||||
test('renders the For You taste/fresh breakdown rows (#1249)', async () => {
|
||||
test('breakdown rows are collapsed by default and expand on toggle (#1249/#1270)', async () => {
|
||||
setupPage();
|
||||
metricsMock.data = {
|
||||
window_days: 30,
|
||||
@@ -225,13 +225,23 @@ describe('Settings page — Recommendation metrics card', () => {
|
||||
]
|
||||
};
|
||||
render(SettingsPage);
|
||||
await waitFor(() => expect(screen.getByText('For You')).toBeInTheDocument());
|
||||
const toggle = await screen.findByRole('button', { name: /for you/i });
|
||||
// Collapsed by default: every stamping mix now carries a breakdown,
|
||||
// so always-open sub-rows would swamp the surface-level view.
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(screen.queryByText(/Taste picks/)).not.toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(toggle);
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(screen.getByText(/Taste picks/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Fresh picks/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Earlier plays/)).toBeInTheDocument();
|
||||
|
||||
await fireEvent.click(toggle);
|
||||
expect(screen.queryByText(/Taste picks/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('surfaces without a breakdown render no sub-rows', async () => {
|
||||
test('surfaces without a breakdown render no toggle and no sub-rows', async () => {
|
||||
setupPage();
|
||||
metricsMock.data = {
|
||||
window_days: 30,
|
||||
@@ -246,6 +256,7 @@ describe('Settings page — Recommendation metrics card', () => {
|
||||
};
|
||||
render(SettingsPage);
|
||||
await waitFor(() => expect(screen.getByText('Radio')).toBeInTheDocument());
|
||||
expect(screen.queryByRole('button', { name: /radio/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Taste picks/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user