feat(metrics): provenance as standard — pick_kind for all system mixes
test-go / test (push) Successful in 31s
test-web / test (push) Successful in 38s
test-go / integration (push) Successful in 4m36s

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:
2026-07-02 23:26:52 -04:00
parent fb4431207d
commit 5faa57634b
14 changed files with 410 additions and 156 deletions
+4 -2
View File
@@ -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[];
};
+65 -35
View File
@@ -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>
+14 -3
View File
@@ -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();
});
});