feat(tuning): scoring weights → DB-backed admin tuning lab
test-go / test (push) Failing after 14s
test-web / test (push) Successful in 34s
test-go / integration (push) Successful in 4m42s

The recommendation scoring knobs move out of YAML (radio profile) and
out of the systemMixWeights hard-code (daily_mix profile) into
DB-backed settings with live effect (#1250) — the defaults-discovery
lab per decision #1247: the operator turns knobs to find good values,
which then get baked back into shipped defaults; end users and other
operators should never need the card.

- Migration 0040: recommendation_weight_profiles (radio / daily_mix,
  8 weight columns), taste_tuning singleton (engagement half-life +
  completion-curve points), recommendation_tuning_audit (one row per
  change with a {field, old, new} diff — the trend view's markers,
  #1251).
- internal/recsettings: boot reconcile seeds shipped defaults without
  clobbering tuned rows (coverart SettingsService pattern), validates
  patches (bounds, curve ordering), writes audit rows, and pushes
  daily_mix weights + taste config into package playlists. No-op
  patches write no audit row.
- playlists gains SetSystemMixWeights / SetTasteConfig swap points
  under a RWMutex — no signature threading through the producers; the
  scheduler's taste rebuild reads the pushed config.
- Radio reads its weight profile from the service per request; the 8
  weight fields leave config.RecommendationConfig (YAML keeps only
  RecentlyPlayedHours / RadioSize / RadioSizeMax).
- Admin API: GET/PATCH/reset under /api/admin/recommendation-tuning,
  echoing current + shipped values.
- Web: new admin Tuning tab — two weight profiles side by side, taste
  card, per-scope save (changed fields only) + reset, deviation dots
  against shipped defaults.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
This commit is contained in:
2026-07-03 09:22:03 -04:00
parent 9e02878b61
commit 0d0a8f46b1
26 changed files with 2006 additions and 61 deletions
+114
View File
@@ -0,0 +1,114 @@
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import type { TuningSnapshot } from '$lib/api/tuning';
vi.mock('$lib/api/tuning', async (orig) => {
const actual = (await orig()) as Record<string, unknown>;
return {
...actual,
getTuning: vi.fn(),
patchTuning: vi.fn(),
resetTuning: vi.fn()
};
});
const pushToast = vi.fn();
vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: (...a: unknown[]) => pushToast(...a) }));
import TuningPage from './+page.svelte';
import { getTuning, patchTuning, resetTuning } from '$lib/api/tuning';
const weights = (over: Partial<Record<string, number>> = {}) => ({
base_weight: 1,
like_boost: 2,
recency_weight: 1,
skip_penalty: 2,
jitter_magnitude: 0.1,
context_weight: 0.5,
similarity_weight: 1.5,
taste_weight: 1.5,
...over
});
const taste = (over: Partial<Record<string, number>> = {}) => ({
half_life_days: 75,
engagement_hard_skip: 0.05,
engagement_neutral: 0.3,
engagement_full: 0.9,
...over
});
function snapshot(over: Partial<TuningSnapshot> = {}): TuningSnapshot {
return {
profiles: { radio: weights({ taste_weight: 1 }), daily_mix: weights() },
taste: taste(),
shipped: {
profiles: { radio: weights({ taste_weight: 1 }), daily_mix: weights() },
taste: taste()
},
...over
} as TuningSnapshot;
}
beforeEach(() => {
vi.clearAllMocks();
});
describe('Admin tuning page', () => {
test('renders both profiles and the taste card with current values', async () => {
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
render(TuningPage);
await waitFor(() => expect(screen.getByText('Radio')).toBeInTheDocument());
expect(screen.getByText('Daily mixes')).toBeInTheDocument();
expect(screen.getByText('Taste profile build')).toBeInTheDocument();
const radioTaste = screen.getByLabelText(/taste weight/i, {
selector: '#radio-taste_weight'
}) as HTMLInputElement;
expect(radioTaste.value).toBe('1');
const halfLife = document.getElementById('taste-half_life_days') as HTMLInputElement;
expect(halfLife.value).toBe('75');
});
test('save sends only the changed fields for the scope', async () => {
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
(patchTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
render(TuningPage);
await waitFor(() => expect(screen.getByText('Radio')).toBeInTheDocument());
const input = document.getElementById('radio-taste_weight') as HTMLInputElement;
await fireEvent.input(input, { target: { value: '2.5' } });
await fireEvent.click(screen.getByRole('button', { name: /save radio/i }));
await waitFor(() =>
expect(patchTuning).toHaveBeenCalledWith('radio', { taste_weight: 2.5 })
);
});
test('save with no changes calls nothing and says so', async () => {
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
render(TuningPage);
await waitFor(() => expect(screen.getByText('Radio')).toBeInTheDocument());
await fireEvent.click(screen.getByRole('button', { name: /save radio/i }));
expect(patchTuning).not.toHaveBeenCalled();
await waitFor(() => expect(pushToast).toHaveBeenCalledWith('Nothing changed.'));
});
test('reset calls resetTuning for the scope', async () => {
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
(resetTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snapshot());
render(TuningPage);
await waitFor(() => expect(screen.getByText('Taste profile build')).toBeInTheDocument());
const resetButtons = screen.getAllByRole('button', { name: /reset to defaults/i });
await fireEvent.click(resetButtons[resetButtons.length - 1]);
await waitFor(() => expect(resetTuning).toHaveBeenCalledWith('taste'));
});
test('marks knobs that deviate from shipped defaults', async () => {
const snap = snapshot();
snap.profiles.daily_mix = weights({ similarity_weight: 4 });
(getTuning as ReturnType<typeof vi.fn>).mockResolvedValue(snap);
render(TuningPage);
await waitFor(() => expect(screen.getByText('Daily mixes')).toBeInTheDocument());
expect(
screen.getByTitle(/deviates from the shipped default \(1\.5\)/i)
).toBeInTheDocument();
});
});