Skip to content

Report a play when the track is done with, not at the halfway mark - #57

Merged
jeffcrouse merged 1 commit into
mainfrom
fix/web-completion-ratio
Aug 1, 2026
Merged

Report a play when the track is done with, not at the halfway mark#57
jeffcrouse merged 1 commit into
mainfrom
fix/web-completion-ratio

Conversation

@jeffcrouse

Copy link
Copy Markdown
Member

Every web play was landing with a completion ratio of almost exactly 0.5, whatever the listener actually heard.

usePlayTracking delivered the play the moment listening crossed min(duration / 2, 4 min), sending completion_ratio as measured at that instant. Nothing revised it afterwards — so the figure recorded for hearing a track once through and for hearing half of it was the same number.

The evidence

Measured on the live database, 2026-08-01 (815 rows, first 2026-07-27):

outcome count avg completion
skipped 428 0.026
completed 357 0.485
errored 30 0.005

289 of the 357 completed events sit in the 0.5–0.6 bucket. Sample rows are exact — 166.0/331.4, 124.3/248.6, 120.6/241.0, 146.3/292.5. Rows from the native client, which reports at the end, correctly read 0.95–1.00.

Why it matters more than it looks

ADR-0004 exists so that ADR-0005's recommender has a taste signal that can only accumulate in wall-clock time, and completion is that signal. A constant carries none of it.

familiar #53 is parked waiting for roughly a month of play_events before ADR-0010's cache budget can come from measured locality. That clock started 2026-07-27 and lands around 27 August — so without this, the month being waited for would have been a month of noise. The failure is invisible in aggregate precisely because 0.5 is such a plausible-looking average.

What changes, and what does not

Which plays count does not change. The early delivery only decided when: a play past 30s but short of the half mark was already reported on track change, by the same rule that now reports all of them. The Last.fm half/4-minute threshold turned out to gate nothing, which is why it is deleted rather than moved.

Two things came with it:

  • A page-hide flush. The early delivery was quietly buying durability — close the tab mid-track and the play still counted, because it had already been sent. Reporting at the end alone would have dropped that silently, so it is restored on pagehide / visibilitychange, with the ratio actually heard. Best effort, and documented as such: a browser tearing down a page owes the request nothing.
  • The ratio is clamped. Played time accumulates from the engine's clock while the duration comes from track metadata, so a looping or mis-tagged track could send 1.1. The server clamps too, but agreeing with the native client — which has always clamped — beats leaving every stored figure a question about which end did it.

Tests

The three that asserted mid-playback delivery now assert delivery on track change. Two added:

  • one pinning the ratio for a track heard nearly to the end (the defect above, so it cannot come back quietly)
  • one covering the page-hide path, including that it cannot double-report if the page survives

940 frontend tests pass; lint clean on the changed file.

Not addressed here

Existing rows keep their frozen ratios. They are distinguishable — web completions cluster at 0.5 with context of other/null, native ones at 0.95–1.00 with library — so ADR-0005 can filter them, but it is worth deciding deliberately whether to exclude anything recorded before this lands.

…ay mark

Every web play was landing with a completion ratio of almost exactly 0.5,
whatever the listener actually heard.

`usePlayTracking` delivered the play the moment listening crossed
`min(duration / 2, 4 min)` and sent `completion_ratio` as measured *at that
instant*. Nothing revised it afterwards, so the figure recorded when someone
heard a track once through and when they heard half of it were the same number.

Measured on the live database, 2026-08-01: **289 of 357 completed events sit in
the 0.5–0.6 bucket**, while rows from the native client — which reports at the end
— correctly read 0.95–1.00. Sample rows are exact: 166.0/331.4, 124.3/248.6,
120.6/241.0.

This matters because of what the data is for. ADR-0004 exists so ADR-0005's
recommender has a taste signal that can only accumulate in wall-clock time, and
completion is that signal. A constant carries none of it, so the month of data
`familiar` #53 is parked waiting for would have been a month of noise — and the
error is invisible in aggregate, because 0.5 is a thoroughly plausible average.

**Nothing changes about which plays count.** The early delivery only decided
*when*: a play past 30s but short of the half mark was already reported on track
change, by the same rule that now reports all of them. The Last.fm half/4-minute
threshold turned out to gate nothing, which is why it is gone rather than moved.

Two things that came with it:

- **A page-hide flush**, because the early delivery was quietly buying durability:
  close the tab mid-track and the play still counted, since it had already been
  sent. Reporting at the end would have dropped that silently. Restored on
  `pagehide` and `visibilitychange`, with the ratio actually heard. Best effort and
  described as such — a browser tearing down a page owes the request nothing.
- **The ratio is clamped.** Played time accumulates from the engine's clock while
  the duration is track metadata, so a looping or mis-tagged track could send 1.1.
  The server clamps as well, but agreeing with the native client — which has always
  clamped — beats leaving every stored figure a question about which end did it.

Tests: the three that asserted mid-playback delivery now assert delivery on track
change, and two were added — one pinning the ratio of a track heard nearly to the
end, one covering the page-hide path and that it cannot double-report. 940 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014P9p2fvFnyiywBxGkv4gfW
@jeffcrouse
jeffcrouse merged commit 84b0aad into main Aug 1, 2026
11 of 12 checks passed
@jeffcrouse
jeffcrouse deleted the fix/web-completion-ratio branch August 1, 2026 17:52
jeffcrouse added a commit that referenced this pull request Aug 3, 2026
…ruction (#80)

Phase 1. Three decisions wait on ADR-0004's data — its own completion-ratio
threshold, ADR-0005's RADIO weights, and #53 — and the data does not start where
everyone assumed.

Until #57 the web client delivered a play the moment listening crossed
`min(duration / 2, 4 min)` and sent `completion_ratio` as measured at that
instant, never revising it. Verified again on the live database on 2026-08-02, by
day and context: **795 of 823 rows predate the fix**, and every context before
2026-08-01 shows completions averaging 0.42–0.50 — including `library`, because
the web derives context from the queue source and sends `library` for a library
queue exactly as the native app does. From 2026-08-01 the same context reads
0.972 and 1.000, with skips at ≤0.1.

So the clock restarted on 2026-08-01, not 2026-07-27: a month of trustworthy data
lands around 2026-09-01.

`FEEDBACK_TRUSTWORTHY_SINCE` and `trustworthy_feedback_only` make the boundary one
call away instead of a matter of discipline. A date, not a client filter, because
`play_events` records no client and `context` does not stand in for one — the good
native rows cannot be separated from the bad web rows beside them, and selecting
on the ratio would be circular.

Excluded rather than deleted, for a reason the investigation turned up: the damage
is not limited to the ratio. A track abandoned at 55% was recorded as a completion
at ~0.5 rather than as a skip, so `outcome` is unreliable before the cutoff and
skips are *under*-counted. What survives is that `skipped` and `rejected` rows
describe real abandonments. `ambient._negative_signal` counts exactly those two
over a rolling 90-day window — so **the live recommender was never poisoned**, it
has been running on a slightly weak negative signal that heals around 2026-10-30.

ADR-0005 also gains the `Implementation:` block it never had, which is why it
looked unbuilt in a survey while `RADIO` had been shipping for weeks.

Two follow-ups recorded: a `client` column, cheap now and worthless retroactively,
which is the only thing that would have made this separable; and that whether the
web still reports at all is unverified — every post-cutoff row carries the context
the native app hardcodes.


Claude-Session: https://claude.ai/code/session_014P9p2fvFnyiywBxGkv4gfW

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant