Skip to content

Keep Balanced detours connected and add 5% look-back - #208

Merged
TacoRocket merged 2 commits into
mainfrom
feat/connected-discovery-lookback
Nov 3, 2026
Merged

TacoRocket merged 2 commits into
mainfrom
feat/connected-discovery-lookback

Conversation

@TacoRocket

@TacoRocket TacoRocket commented Nov 3, 2026 •

Copy link
Copy Markdown
Owner

What this changes

Balanced recommendations use original seeds and recorded viewing to steer related choices. An unwatched scheduled detour no longer admits or boosts its neighbors: the preceding scheduled title has no Balanced affinity contribution or neighbor retrieval. Metadata and Semantic modes retain their existing behavior.

Connection support applies within each seed/watched relationship before its history coefficient, rather than multiplying the assembled profile by another weighted history sum. A recorded start therefore applies the intended 25% EWMA update once. For non-seed watched anchors, semantic and supported metadata self-matches are capped at their strongest other catalog match. Older watched semantic look-back uses the same cap. Explicit seeds keep self-matches, and the separate Familiarity boost remains.

Connections can be genres, tags, cast, director, studio, exact nonempty content rating or Potion similarity. One broad tie is weaker; multiple ties strengthen metadata without requiring a genre. Connected exploration replaces uniform catalog sampling. These ties can overlap and do not guarantee a tonal match.

A fixed 5% look-back uses original seeds plus up to three distinct older watched sources, sampled by their older start counts. Current interests use the latest 50 available non-filler starts; older counts come from the existing lifetime aggregate minus that window. Independent random streams preserve title/episode draws. Default shares are 85.5% interests / 7.6% metadata / 1.9% connected exploration / 5% look-back. Empty pools redistribute their shares; no supported pool fails visibly.

Saved reasons, dialog and docs describe these contributions. Existing episode selection, availability and repeat rules remain. No migration, setting or runtime dependency is added. Existing Balanced channels use the policy on next generation/growth; saved lineup entries are not rewritten. Reverting this PR restores the previous generator.

Scope after review

The double-weighting correction and watched-self cap are included. The maintainer-directed revision also removes predecessor influence completely. The suggested fixed three-airings-per-twelve-hours rotation cap is not included: airtime allocation, a living candidate shortlist and a continuously advancing EPG belong to a separate scheduling change. This PR corrects recommendation influence; it does not establish whole-lineup balance. Existing playback-driven growth/looping behavior remains. Actual watch-duration capture and weighting remain separate.

Design issue

Not needed — maintainer-directed fork change following isolated trials and review.

AI use

Codex implemented the change, ran focused validation, and prepared the documentation. The earlier clean-context review assessed the original implementation against its trial formulas; it did not establish that those formulas encoded the intended viewing behavior. A separate clean-context reviewer assessed revised checkpoint 5db753080 and found no implementation-vs-plan drift. It inspected the validation record and harness without rerunning the test suites.

Testing

  • Two new regression cases fail on the previous PR scorer and pass on the revision: per-anchor history weighting and older watched self-match. The current-history case also checks semantic/metadata self caps and preservation of explicit seeds. Four scorer tests pass on Bun 1.4.4 and Node.
  • Existing history and service regression tests pass on Node (seven focused tests total across three files). Server typecheck, changed-file lint, full pnpm exec prettier --check . and git diff --check pass.
  • Three first-draw ten-pick replays use the native scorer/mixer with a captured 1,733-title real catalog and cached Potion-base-8M neighbors. Two random seed shows per theme, random episodes, two simulated starts after the first five picks, then five further picks. Identical-random-stream no-watch controls and previous-PR comparisons are retained privately.
  • Revised samples contain 8–9 distinct titles, no more than two picks from one title, and 19–22% maximum single-title airtime. These tiny samples are qualitative review material, not an optimal-preset or long-run balance claim. Weak tonal matches remain visible in the samples.
  • The replay uses a local sampling harness; it does not prove endpoint/container playback, growth publication, EPG behavior, or live model/index latency. No production change or deployment occurred. Hosted Build remains the full CI gate.

@TacoRocket

Copy link
Copy Markdown
Owner Author

Changes requested at 087f3c78: three items. All three address one failure: a title that airs once or twice can take over the channel.

What we saw on our test server. Two seeds: a baking competition and a food-travel documentary. With conservative settings, the lineup filled with one long-running sitcom. That sitcom is not close to either seed. It is missing from the baking seed's 49 Potion neighbours and is 19th on the documentary's. On a fresh draw it has about a 1% chance.

Two paths lead from there to a takeover. This PR already closes the first: the preceding title's one-third share, which let sitcoms chain into each other. The second remains, and this PR makes it stronger:

  • Every program that airs while someone is tuned in is recorded as a start, whether or not anyone watched it.
  • A title's Potion match to itself is 1.0, twice the value of its closest neighbour (rank 2 = 0.5). Its metadata match to itself shares every field. So once a title airs, it becomes the strongest current interest.
  • The only repeat rules are "not the same show back-to-back" and "not one of the last 20 episodes", so nothing stops it from taking every other slot.

I replayed this with the real createBalancedSeedChannelScorer and the real neighbour ranks from our test server's index. The table compares the aired sitcom against the closest food documentary on interest weight:

Sitcom airings main (#206) this PR
0 food leads 9.5× food leads 9.5×
1 food leads 1.3× tied
2 sitcom leads 1.7× sitcom leads 3.0×
3 sitcom leads 2.8× sitcom leads 6.7×

1. Apply support inside each anchor's contribution, not as a second weighted sum.

interestAffinity and metadataAffinity multiply the profile, a weighted sum over the EWMA weights, by support, a second weighted sum over the same weights. That squares each anchor's weight, which has two effects:

A Potion tie already counts as full support. So in the interest pool the per-anchor factor is 1 wherever an anchor contributes, and the product's only other effect is the squaring.

Ask:

  • Multiply each anchor's metadata contribution by that anchor's own support, inside the profile sum. This keeps "a lone broad tie weighs 0.25".
  • Admit the 10% preceding-title term only when the candidate has seed or watched support.

The predecessor gate and the #206 fade rate then both hold.

2. A watched title doesn't outrank its own neighbours.

In the watched part of the profile, a title's match with itself should count no more than its best match with any other title:

  • Potion: use the rank-2 value, 0.5, instead of 1.0.
  • Metadata: use that title's best score against any other catalog title.

Seeds keep their self-match, because the seeds are an explicit choice; item 3 bounds them. The aired show stays in play, level with its closest neighbour, and watching it pulls in similar titles rather than more of itself.

3. A rotation cap.

  • At most 3 airings of one show in any 12-hour stretch, and at least 3 hours between them.
  • The count includes recent airings and anything already queued in the growth tail.
  • Make the numbers named constants.
  • When nothing else is eligible, relax the cap with the existing repeat warning, the same way the recent-episode exclusion relaxes.

The selection loop is shared, so the cap applies to Metadata and Semantic modes as well. In a model of the metadata scoring, a seed show that matches itself filled 4–5 hours of a 12-hour lineup.

The comparable is US internet radio, which runs under the statutory "sound recording performance complement" (17 U.S.C. § 114(j)(13)). In any 3 hours a station may play at most 4 tracks by one artist, and no more than 3 of them in a row. Radio scheduling tools build in the same idea as artist and title separation. It keeps a station sounding like a station, and none of it depends on how the recommender scores titles.

Proof in the PR description:

  • Rerun the replay for the two-seed case with one off-lane show aired 1–3 times, comparing the aired show against the top lane title.
  • Report the most airings of any one show in a 12-hour lineup.
  • Repeat the loosened-Discovery case from the trial (sequel chains from one off-lane pick).

A focused test for the cap is enough.

Everything else is approved as written:

  • Removing the uniform catalog share and gating the preceding title. That closes the loosened case, where one random kaiju film pulled in its sequel.
  • The look-back sampling.
  • The older-count math: lifetime starts minus the exact 50-start window, with the same keys and filters.
  • The separate random stream.
  • The mixer and dialog arithmetic.

Seed voting and the other direction notes are in the next comment. They are for a later PR and are not a condition of this review.

Build (exact head 087f3c78 on current main, 85e8397b): https://github.com/TacoRocket/tunarr/actions/runs/41551736648

@TacoRocket

Copy link
Copy Markdown
Owner Author

For a later PR, not a condition of this review. The operator asked for direction and prior art alongside the kickback. Which of these get built, and when, is the operator's call.

The middle ground isn't a Discovery setting. Today, low Discovery means "the closest neighbours of whichever seed likes a title most". High Discovery means "more weight on broad pools". Neither one describes the lane the seeds share. The proposal: Discovery measures distance from what the seeds agree on.

1. Seeds vote.

  • Core pool: titles that at least two seeds list among their neighbours. With one seed, that seed's own list.

  • Discovery widens step by step: first to titles only one seed lists, then to metadata cousins. Never to uniform picks.

  • Measured on our test server, with the two seeds from the kickback. Their top-49 Potion lists share 15 titles, and 14 of those carry the Food genre. How often the first pick lands on a Food title, at medium Variety:

    Rule Food share
    Today's best-single-seed rule 65%
    Sum of both seeds, no agreement required 69%
    Core pool alone 98%

    Agreement does the work; summing alone doesn't.

  • Prior art:

    • CombSUM and CombMNZ (Fox & Shaw, "Combination of Multiple Searches", TREC-2, 1994). CombMNZ multiplies a document's summed score by the number of lists that returned it, so agreement wins.
    • Reciprocal Rank Fusion (Cormack, Clarke & Büttcher, SIGIR 2009) combines lists as Σ 1/(k + rank), which suits Potion's rank-only neighbours.
    • Pandora stations widen the same way: "Add Variety" adds another seed to a station. The small-pool note ("add another seed") is the same move.

2. Keep "more of it" separate from "more like it".
Related-items lists leave out the item itself. Examples are Amazon's similar-items table (Linden, Smith & York, "Amazon.com Recommendations: Item-to-Item Collaborative Filtering", 2003) and Netflix's "Because you watched X" rows. Coming back to X is a separate mechanism (Continue Watching). Here, items 2 and 3 of the kickback do that split: similarity picks neighbours, and rotation plus Familiarity bring a liked show back.

3. Discount momentum, before and after watch time.
Hu, Koren & Volinsky ("Collaborative Filtering for Implicit Feedback Datasets", ICDM 2008) built a TV recommender from set-top-box data and hit this exact problem. A channel left on keeps logging shows; they call it the momentum effect. Their fix down-weights the t-th show after a tune-in by e^-(2t-6) / (1 + e^-(2t-6)):

Show after tune-in 1st 2nd 3rd 4th 5th
Weight 0.98 0.88 0.5 0.12 0.02
  • Watch time alone won't catch this, because a TV left on accumulates full watch time.
  • It may not need a migration. program_play_history has no session column. But a contiguous run of recorded plays, each starting where the previous one ended (filler included), approximates one sitting, so this could work on today's history.
  • Caution: a household viewer here often watches in the background. Tune the constants against real stayed/left data rather than copying them.

Related work: degenerate feedback loops (Jiang, Chiappa, Lattimore, György & Kohli, AIES 2019) and algorithmic confounding (Chaney, Stewart & Engelhardt, RecSys 2018). A recommender that learns from what it showed narrows toward its own output, and recorded starts are what the channel showed.

4. Look-back: "older" should mean "not current".
Older anchors are sampled by their starts before the 50-start window, but a show still in heavy rotation has plenty of those too. I replayed the PR's prepareLookback with one show that has 100 lifetime starts, 30 of them inside the window, plus ten dropped shows with 5 each. The current favourite held one of the three look-back slots in 94% of draws, leaving about two for dropped shows. Radio's rotation categories are exclusive: a song is either current or recurrent, never both. Excluding titles that appear in the current window would make all three slots old doors.

5. Optional: calibration.
Steck, "Calibrated Recommendations" (RecSys 2018): a lineup's mix should mirror the proportions of the viewer's interests, so a 70/30 history doesn't become a 100/0 lineup. With two seeds of different kinds, this would keep one seed's side from crowding out the other. The cap may make it unnecessary.

6. The planned watch-time PR.
Rank on time stayed, not on starts. YouTube moved from click-through to expected watch time for the same reason (Covington, Adams & Sargin, "Deep Neural Networks for YouTube Recommendations", RecSys 2016). Pair it with item 3.

@TacoRocket TacoRocket added the changes requested Review found corrections needed before merge label Nov 3, 2026
@TacoRocket TacoRocket removed the changes requested Review found corrections needed before merge label Nov 3, 2026
@TacoRocket

Copy link
Copy Markdown
Owner Author

Approved at 5db75308.

Ask 1 (support per anchor): done. Support now multiplies each anchor's own contribution, and the profile isn't multiplied by a second weighted sum. Removing the preceding-title term entirely goes further than the gate I asked for, and it closes the loosened case by construction: an unwatched pick can no longer pull in its sequels.

Ask 2 (watched self-match): done. A watched title's semantic and metadata self-match are capped at its best match with any other title. Seeds keep theirs, and look-back uses the same cap.

I reran the kickback replay on this head, using the real scorer and the same neighbour ranks. The table compares the aired sitcom with the closest food documentary on interest weight:

Sitcom airings main (#206) 087f3c78 5db75308
0 food leads 9.5× food leads 9.5× food leads 9.5×
1 food leads 1.3× tied food leads 2.3×
2 sitcom leads 1.7× sitcom leads 3.0× food leads 1.1×
3 sitcom leads 2.8× sitcom leads 6.7× sitcom leads 1.5×

Against its own closest neighbour, the aired sitcom now sits at 1.3× after one airing and 1.08× after three. Before the cap it was about 2×. Watching a show now brings in shows like it rather than more of it, and the lead arrives later than on main.

Ask 3 (rotation cap): withdrawn. It was a guard against the self-match snowball, and asks 1 and 2 removed that cause. What remains is drift toward whatever is recorded as watched: every airing while someone is tuned in counts as a start. That's an input question, and it belongs with the watch-time work rather than here. The PR's 10-pick replays (at most two picks from one title, 19–22% airtime for the top title) agree.

Checked:

  • 4/4 scorer tests pass on Bun 1.4.4.
  • Docs and the scheduling README match the code. The remaining preceding-title wording describes the Metadata and Semantic modes, which still use it.

Build (exact head 5db75308 on current main, 85e8397b): https://github.com/TacoRocket/tunarr/actions/runs/41576359628

@TacoRocket TacoRocket added the approved Reviewed and ready to merge label Nov 3, 2026
@TacoRocket
TacoRocket merged commit 6796032 into main Nov 3, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

approved Reviewed and ready to merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant