recMetricsLowVolume = 20 was doing two jobs. Twenty plays is enough to be worth displaying — below that a skip rate is anecdote — but the card then presented deltas as though it were also a decision floor. Those differ by an order of magnitude: detecting the ~13pp differences that matter needs ~133 plays per arm for 80% power at α=0.05.
So Discover's taste-matched (59 plays) and random-unheard (70) both rendered as full-confidence rows with a bold delta beside them, and that comparison sits at p ≈ 0.06. The card said "signal"; the arithmetic said "maybe". It produced a recommendation the data didn't support, and any reader with the same numbers would have made the same call.
Every surface and every breakdown row now carries skip_delta / completion_delta as {delta_pp, margin_pp, distinguishable}. Breakdown arms are the thinnest samples on screen and where the old card misled most.
Computed server-side so both clients read one implementation rather than each re-deriving it. Skip rate is a two-proportion difference; completion is Welch, which needs a variance — hence completion_sqsum in the query. It's the sum of squares rather than stddev_samp on purpose: raw source rows are merged into surface families in Go, and sums of squares combine across groups exactly whereas standard deviations cannot.
Edge cases that are refusals rather than numbers: an empty sample, and two degenerate rates (both 0 or both 1) whose zero SE would report a spuriously narrow margin — and therefore "distinguishable" — for a delta of exactly zero. sampleVariance is clamped at zero because floating-point cancellation can push it slightly negative when every observation is identical, which would become NaN in the square root.
recMetricsLowVolume is untouched. "Too thin to show" and "too thin to act on" are different questions and now have different answers.
Web: an indistinguishable delta renders dimmed and prefixed ≈, with its range on hover and a legend explaining the glyph. Colour is withheld unless the delta clears its margin — colouring noise red is what made the old card misleading.
/admin/tuning's "Latest skip"/"Latest completion" are one week while the adjacent Plays column is the whole window. I misread exactly that and briefly concluded Deep cuts was the worst surface, from ~17 plays in a single week; over 180 days it's one of the best.
Headers now read Plays (window) / Skip (last wk) / Completion (last wk), and the skip cell carries that week's play count so a rate drawn from a handful of listens reads as what it is.
resolveArtist now recognises an isUniqueViolation on SetArtistMbidIfNull as the expected condition it is, matching resolveAlbum. Two rows mapping to one MusicBrainz artist is a merge candidate, not a fault; without the branch it logged a generic warning plus a Postgres ERROR line on every scan, which teaches an operator to ignore database errors. Three lines, and isUniqueViolation already existed.
Tests
12 Go cases. The load-bearing one, TestProportionDelta_ReproducesTheDiscoverCase, asserts the exact 59-vs-70-plays / 13.3pp comparison comes back not distinguishable — the false confidence is now a regression test rather than a comment. Paired with a same-effect-at-10×-volume case proving the flag tracks sample size, not gap size.
2 web cases, targeting deltas by data-testid.
Three of these four commits are test fixes for my own new tests: testing-library queries that matched more elements than intended (a <td>'s own direct text nodes still match despite a child span; /≈/ matched the legend explaining the glyph; /^Skip/ matched the sparkline column too). Kept as separate commits because each records a distinct trap. The underlying cause was iterating against CI rather than locally — the web lane uses the same ci-go image, so npx vitest run works locally in ~7s, and note #2465 has been corrected to say so.
Verified locally before the final push: svelte-check 0 errors, 110 files / 786 tests.
Closes #2495 and #2524. Four commits, **no schema migration**. Server + web.
CI green on `304de88c` (web, run 3491). The Go lane was green at `481f9060` (run 3486) and nothing Go or SQL changed after it, so both lanes hold at HEAD.
## #2495 — the card invited a wrong conclusion
`recMetricsLowVolume = 20` was doing two jobs. Twenty plays is enough to be worth **displaying** — below that a skip rate is anecdote — but the card then presented deltas as though it were also a **decision** floor. Those differ by an order of magnitude: detecting the ~13pp differences that matter needs ~133 plays per arm for 80% power at α=0.05.
So Discover's taste-matched (59 plays) and random-unheard (70) both rendered as full-confidence rows with a bold delta beside them, and that comparison sits at p ≈ 0.06. The card said "signal"; the arithmetic said "maybe". It produced a recommendation the data didn't support, and any reader with the same numbers would have made the same call.
Every surface **and every breakdown row** now carries `skip_delta` / `completion_delta` as `{delta_pp, margin_pp, distinguishable}`. Breakdown arms are the thinnest samples on screen and where the old card misled most.
Computed server-side so both clients read one implementation rather than each re-deriving it. Skip rate is a two-proportion difference; completion is Welch, which needs a variance — hence `completion_sqsum` in the query. It's the **sum of squares** rather than `stddev_samp` on purpose: raw source rows are merged into surface families in Go, and sums of squares combine across groups exactly whereas standard deviations cannot.
Edge cases that are refusals rather than numbers: an empty sample, and two degenerate rates (both 0 or both 1) whose zero SE would report a spuriously narrow margin — and therefore "distinguishable" — for a delta of exactly zero. `sampleVariance` is clamped at zero because floating-point cancellation can push it slightly negative when every observation is identical, which would become NaN in the square root.
`recMetricsLowVolume` is untouched. "Too thin to show" and "too thin to act on" are different questions and now have different answers.
**Web:** an indistinguishable delta renders dimmed and prefixed `≈`, with its range on hover and a legend explaining the glyph. Colour is withheld unless the delta clears its margin — colouring noise red is what made the old card misleading.
## #2495 — the admin trends view had it worse
`/admin/tuning`'s "Latest skip"/"Latest completion" are one **week** while the adjacent Plays column is the whole window. I misread exactly that and briefly concluded Deep cuts was the worst surface, from ~17 plays in a single week; over 180 days it's one of the best.
Headers now read `Plays (window)` / `Skip (last wk)` / `Completion (last wk)`, and the skip cell carries that week's play count so a rate drawn from a handful of listens reads as what it is.
## #2524 — duplicate-MBID log noise
`resolveArtist` now recognises an `isUniqueViolation` on `SetArtistMbidIfNull` as the expected condition it is, matching `resolveAlbum`. Two rows mapping to one MusicBrainz artist is a merge candidate, not a fault; without the branch it logged a generic warning plus a Postgres `ERROR` line on every scan, which teaches an operator to ignore database errors. Three lines, and `isUniqueViolation` already existed.
## Tests
12 Go cases. The load-bearing one, `TestProportionDelta_ReproducesTheDiscoverCase`, asserts the exact 59-vs-70-plays / 13.3pp comparison comes back **not distinguishable** — the false confidence is now a regression test rather than a comment. Paired with a same-effect-at-10×-volume case proving the flag tracks sample size, not gap size.
2 web cases, targeting deltas by `data-testid`.
Three of these four commits are test fixes for my own new tests: testing-library queries that matched more elements than intended (a `<td>`'s own direct text nodes still match despite a child span; `/≈/` matched the legend explaining the glyph; `/^Skip/` matched the sparkline column too). Kept as separate commits because each records a distinct trap. The underlying cause was iterating against CI rather than locally — the web lane uses the same `ci-go` image, so `npx vitest run` works locally in ~7s, and note #2465 has been corrected to say so.
Verified locally before the final push: svelte-check 0 errors, 110 files / 786 tests.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01N6vZoJ4Se5YyaqdtGVkap5
The metrics card had one volume threshold doing two jobs.
recMetricsLowVolume = 20 is a DISPLAY floor — below that a skip rate is
anecdote — but the card then presented deltas as though it were also a
DECISION floor. Those differ by an order of magnitude: detecting the ~13pp
differences that matter needs ~133 plays per arm for 80% power at a=0.05.
So Discover's taste-matched (59 plays) and random-unheard (70) both rendered
as full-confidence rows with a bold delta beside them, and that comparison
sits at p ~ 0.06. The card said "signal"; the arithmetic said "maybe". It
produced a recommendation the data didn't support, and any reader with the
same numbers would have made the same call.
Deltas now carry a 95% margin of error and a `distinguishable` flag, computed
server-side so both clients read the same arithmetic instead of each
re-deriving it. Skip rate is a two-proportion difference; completion is
Welch, which needs a variance — hence completion_sqsum in the query. It is
the sum of squares rather than stddev_samp on purpose: raw source rows are
merged into surface families in Go, and sums of squares combine across groups
exactly whereas standard deviations cannot.
recMetricsLowVolume is untouched. "Too thin to show" and "too thin to act on"
are different questions.
Web renders an indistinguishable delta as dimmed and prefixed "≈", with the
range on hover and a legend explaining the glyph. Colour is withheld unless
the delta clears its margin — colouring noise red is what made the old card
misleading. Breakdown rows go through the same path; those are the thinnest
samples on screen and where the old card misled most.
Also fixes the admin trends view, which had the same problem worse: its
"Latest skip"/"Latest completion" columns are one WEEK while the adjacent
Plays column is the whole window. I misread exactly that and briefly
concluded Deep cuts was the worst surface, from ~17 plays in a single week —
over 180 days it is one of the best. Headers now name their period and the
skip cell carries that week's play count.
#2524: resolveArtist now recognises a duplicate-MBID unique violation as the
expected condition it is, matching resolveAlbum. Two rows mapping to one
MusicBrainz artist is a merge candidate, not a fault; without the branch it
logged a generic warning plus a Postgres ERROR line on every scan, which
teaches an operator to ignore database errors.
Two CI failures, both in my own new tests, both informative.
settings: queryByText(/≈/) matched the LEGEND explaining the glyph rather
than a delta, so the "no delta" case failed on the explanation being
present. Delta spans now carry data-testid so a test can name what it
means instead of pattern-matching prose that sits next to it.
tuning: getByText("40%") found two elements. testing-library matches an
element and its OWN direct text nodes, so the skip cell still matches
"40%" despite the trailing play-count span — and discover late-week
completion is also 40%. Genuinely ambiguous now; assert the count.
getByText(/Plays/) matched my own new caption as well as the header, since
the caption explains which columns cover the window. Query by columnheader
role instead, which is what the assertion actually means.
Also reordered the caption: prepending the clarification turned it into a
run-on that opened mid-explanation before saying what the chart was.
Third attempt at the same assertion, so I stopped guessing and got vitest
running locally instead: the web lane uses the same ci-go image, so
`docker run ... -w /src/web ci-go:1.26 npx vitest run` works and turns a
5-minute CI round trip into 7 seconds.
/^Skip/ matched the "Skip rate by week" sparkline column as well as
"Skip (last wk)", just as /Plays/ had matched the caption. Exact names
say what the assertion means and cannot drift onto a neighbour.
Verified locally before pushing: svelte-check 0 errors, 110 files /
786 tests pass.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes #2495 and #2524. Four commits, no schema migration. Server + web.
CI green on
304de88c(web, run 3491). The Go lane was green at481f9060(run 3486) and nothing Go or SQL changed after it, so both lanes hold at HEAD.#2495 — the card invited a wrong conclusion
recMetricsLowVolume = 20was doing two jobs. Twenty plays is enough to be worth displaying — below that a skip rate is anecdote — but the card then presented deltas as though it were also a decision floor. Those differ by an order of magnitude: detecting the ~13pp differences that matter needs ~133 plays per arm for 80% power at α=0.05.So Discover's taste-matched (59 plays) and random-unheard (70) both rendered as full-confidence rows with a bold delta beside them, and that comparison sits at p ≈ 0.06. The card said "signal"; the arithmetic said "maybe". It produced a recommendation the data didn't support, and any reader with the same numbers would have made the same call.
Every surface and every breakdown row now carries
skip_delta/completion_deltaas{delta_pp, margin_pp, distinguishable}. Breakdown arms are the thinnest samples on screen and where the old card misled most.Computed server-side so both clients read one implementation rather than each re-deriving it. Skip rate is a two-proportion difference; completion is Welch, which needs a variance — hence
completion_sqsumin the query. It's the sum of squares rather thanstddev_sampon purpose: raw source rows are merged into surface families in Go, and sums of squares combine across groups exactly whereas standard deviations cannot.Edge cases that are refusals rather than numbers: an empty sample, and two degenerate rates (both 0 or both 1) whose zero SE would report a spuriously narrow margin — and therefore "distinguishable" — for a delta of exactly zero.
sampleVarianceis clamped at zero because floating-point cancellation can push it slightly negative when every observation is identical, which would become NaN in the square root.recMetricsLowVolumeis untouched. "Too thin to show" and "too thin to act on" are different questions and now have different answers.Web: an indistinguishable delta renders dimmed and prefixed
≈, with its range on hover and a legend explaining the glyph. Colour is withheld unless the delta clears its margin — colouring noise red is what made the old card misleading.#2495 — the admin trends view had it worse
/admin/tuning's "Latest skip"/"Latest completion" are one week while the adjacent Plays column is the whole window. I misread exactly that and briefly concluded Deep cuts was the worst surface, from ~17 plays in a single week; over 180 days it's one of the best.Headers now read
Plays (window)/Skip (last wk)/Completion (last wk), and the skip cell carries that week's play count so a rate drawn from a handful of listens reads as what it is.#2524 — duplicate-MBID log noise
resolveArtistnow recognises anisUniqueViolationonSetArtistMbidIfNullas the expected condition it is, matchingresolveAlbum. Two rows mapping to one MusicBrainz artist is a merge candidate, not a fault; without the branch it logged a generic warning plus a PostgresERRORline on every scan, which teaches an operator to ignore database errors. Three lines, andisUniqueViolationalready existed.Tests
12 Go cases. The load-bearing one,
TestProportionDelta_ReproducesTheDiscoverCase, asserts the exact 59-vs-70-plays / 13.3pp comparison comes back not distinguishable — the false confidence is now a regression test rather than a comment. Paired with a same-effect-at-10×-volume case proving the flag tracks sample size, not gap size.2 web cases, targeting deltas by
data-testid.Three of these four commits are test fixes for my own new tests: testing-library queries that matched more elements than intended (a
<td>'s own direct text nodes still match despite a child span;/≈/matched the legend explaining the glyph;/^Skip/matched the sparkline column too). Kept as separate commits because each records a distinct trap. The underlying cause was iterating against CI rather than locally — the web lane uses the sameci-goimage, sonpx vitest runworks locally in ~7s, and note #2465 has been corrected to say so.Verified locally before the final push: svelte-check 0 errors, 110 files / 786 tests.
🤖 Generated with Claude Code
https://claude.ai/code/session_01N6vZoJ4Se5YyaqdtGVkap5
Two CI failures, both in my own new tests, both informative. settings: queryByText(/≈/) matched the LEGEND explaining the glyph rather than a delta, so the "no delta" case failed on the explanation being present. Delta spans now carry data-testid so a test can name what it means instead of pattern-matching prose that sits next to it. tuning: getByText("40%") found two elements. testing-library matches an element and its OWN direct text nodes, so the skip cell still matches "40%" despite the trailing play-count span — and discover late-week completion is also 40%. Genuinely ambiguous now; assert the count.