Recommend by episode-weighted shared people

Decision 04

Counting shared people equally made two shows look alike when they had cycled through the same one-episode guest actors. Each person is now weighted by the share of a show they actually made, and a shared person counts for the weaker of their two sides.

Context

The first version of the recommender counted. Two shows share an actor, that is one connection; share a director, that is two. Rank by the count. It was the simplest thing that could work, and for a while it did.

Then the ingest pulled TMDb’s full per-episode credits (ADR-02) and 84% of the cast entries turned out to be for a single episode.

Counting every shared person the same now meant two shows looked similar because they had cycled through the same pool of one-episode guests. That says nothing about whether the shows are alike. The results stopped being useful the day the data got better, which is a strange way to fail and a clear signal: counting is not enough. How much of a show a person actually made has to matter.

Decision

Weight each shared person by how much of each show they were really in, then add the weights up. That total is the similarity between two shows.

A person’s share of a show is their episode count divided by the show’s total episodes. A lead in all 62 episodes scores 1.0. A guest in 1 of 62 scores about 0.02. Using a share rather than a raw count means the lead of a 6-episode mini-series and the lead of a 62-episode drama both score 1.0, so a short show is not punished for being short.

A shared person has a share on each of the two shows, and the score takes the smaller one. If someone starred in one show and turned up in three episodes of another, that link should count for the weaker side.

score = sum, over each shared person, of min(share_on_A, share_on_B)
share = episode_count / number_of_episodes

The smaller rules:

Three approaches lost:

After Action Review

It worked. For Breaking Bad the top match becomes Better Call Saul, and the show that only looked similar because the two share a long list of mostly-minor people drops far down. 96 of 100 shows come back with at least one match, and tests lock the rules in so a later change cannot quietly undo them.

Two things I would improve. The code works the score out after pulling the rows from the database, because the per-show share does not fit neatly into a single query. That is fine at 100 shows and does not scale; the answer is a table of connections computed ahead of time, which ADR-07 later builds.

And the weighting does nothing at all for a show with no episodes recorded yet. Every score comes out zero, which needs its own answer: ADR-05.

Amendment, 2026-08-26: the weight is superlinear

The weight above is linear, and that is not enough. A person in 3 of 100 episodes contributes 0.03, which is correct as a description of their involvement but wrong as a vote. The problem is arithmetic: 217,622 of the catalog’s 278,632 cast rows carry episode_count = 1, and the “no floor” promise means every one of them counts. Three hundred one-episode guests sum to 3.0 and beat a genuine full-run co-lead at 1.0. Mass beats quality, catalog-wide, and 463 pairs in the stored top-12 lists were ordered that way.

The rule becomes:

score(A, B) = sum over shared people of
    min(share on A, share on B) ** INVOLVEMENT_EXPONENT

with INVOLVEMENT_EXPONENT = 1.375. The same exponent applies to the estimate accumulator feeding ADR-05’s middle rung, because that rung already claims to rank by how much of a show a person is, and weighting only the top rung would leave the two disagreeing about what involvement means. It also applies to shared_connections, which recomputes the same arithmetic for the callout.

This is not the hard cutoff rejected above. That was rejected for creating a cliff, throwing away thin shows, and hurting short-form content. A continuous reweighting does none of those: every shared person still contributes, and no edge is thrown away. Measured across all 37,950 candidate edges in the catalog, not one reaches exactly 0.0, and the smallest is 9.6e-06, so ADR-05’s requirement that the candidate set never change is untouched.

The shape follows SIDE_QUEST_CENTRALITY_EXPONENT in personalization.py, whose comment makes the same argument in the same form: being reached twice should cost something, but it should not disqualify.

Why 1.375, which is the part that needs the numbers

1.375 reads as a compromise where 0.5 reads as a principle, so the defense has to be the measurement rather than the shape of the number.

The acceptance case. Three hundred guests at one episode of a hundred, against one co-lead who carried the whole run of both shows. Break-even is ln(300) / ln(100) = 1.24.

exponent the crowd totals the co-lead wins by
1.0 3.0000 loses
1.25 0.9487 1.05x
1.375 0.5335 1.87x
1.5 0.3000 3.33x

1.25 clears the case by five percent, which puts the decision on a coin edge. 1.375 clears it with room. Against the real catalog, 1.25 fixes 75% of the 441 remaining mass-beats-quality pairs, 1.375 fixes 83%, and 1.5 fixes 90%.

Why not higher, which is the finding that decided it. Going up looks strictly safer and is not. Past roughly 1.4 the score collapses toward whoever holds the single strongest tie, and the mid-strength tail stops carrying weight. That tail is what encodes register and production milieu: a shared recurring cast, a shared director pool. Of the 16 sources whose top pick differs between 1.375 and 1.5, about ten are better at 1.375 and one is better at 1.5.

source at 1.375 at 1.5
The Pitt ER Lanterns
Pokémon Pokémon Horizons Detective Conan
Silent Witness Midsomer Murders Loki
Newsnight Sunday with Laura Kuenssberg Black Mirror
Horizon NOVA Sunday with Laura Kuenssberg
The Voice The Kelly Clarkson Show Gran hermano

1.375 is the top of that plateau, not a midpoint between two guesses.

What it moved

On the 464-show catalog of the time, since pruned to 248 (see MIN_VOTE_COUNT), together with the widened SERVICE_JOBS (ADR-01, amended the same day): 4,014 edges become 4,013 across the same 409 sources, 304 of those sources reorder, and 76 change their top pick. The distribution goes from min 0.0001, max 33.376, mean 0.8388, median 0.4572 to min 0.0000039, max 28.278, mean 0.4612, median 0.1351. The mean barely moves and the median collapses, which is the intended shape: blowout edges survive, the indifferent tail flattens.

Breaking Bad, which ADR-07’s review recorded, now reads: Better Call Saul 9.7473, The Blacklist 1.0204, Malcolm in the Middle 1.0133, Westworld 0.3775, The Mandalorian 0.3641, The Boys 0.3584, House of the Dragon 0.3552, Fargo 0.3418.

One consequence worth stating plainly. The Blacklist beats Malcolm in the Middle by 0.0071. Both rest on a single person at a full 1.0: Dave Porter, who scored every episode of both, and Bryan Cranston, who led both. They are separated only by tail size, so a composer ties a lead. That is the direct cost of keeping composition out of the widened SERVICE_JOBS, it was seen and accepted, and check_ground_truth prints the margin on every run rather than asserting on it, because it is too small to assert on.

Downstream

RANK_STEP in personalization.py needs no change, and the reasoning is worth recording because the obvious move is wrong. Preserving the mean would set it to 0.573. But the mean is dragged by blowouts, and the constant’s own comment asks that “a blowout edge resists personalization while the tail reorders freely to taste”. The median score falls from 0.44 to 0.14 while Better Call Saul still contributes 9.75, so the exponent serves that intent better than the linear weight did. Re-fitting RANK_STEP down would undo the improvement.

SIDE_QUEST_HOP_DECAY is tilted but deliberately left alone; see ADR-09.

Correction to the record

The first survey of which credits the exponent would promote classified “below the line” as “absent from MARQUEE_JOBS”. That was wrong. MARQUEE_JOBS decides who gets named in prose and was never a signal list, so the survey wrongly flagged Characters, Story, Comic Book and Original Series Creator, which are source-material credits and among the strongest show links in the catalog. It also produced a headline finding, “Producer, 19 pairs”, that did not survive re-derivation: bare Producer makes only four full-run pairs and every one of them is correct (Star Trek: The Next Generation to Voyager and to the original series, Frieren to Hunter x Hunter), while excluding it would have dropped the person entirely on 45% of its credits. The corrected method, counting only pairs where the job is the person’s sole credit on that show, is what the ADR-01 amendment is built on.

Tests

SimilarByPeopleTests fixture was rewritten, not just renumbered. It gave both shows 10 episodes and nearly every credit episode_count=10, so almost every share was exactly 1.0, and 1.0 ** anything is 1.0: only two assertions in 248 moved when the exponent went from 1.0 to 1.375. A fixture that cannot see the thing it exists to freeze is worse than no fixture. Shares now span 0.01 to 1.0.

InvolvementExponentTests adds the acceptance case, which nothing tested before. It asserts the 1.87x margin rather than only the ordering, so 1.25 fails it, and bounds the exponent inside the measured plateau, so 1.5 fails it too.

check_ground_truth is a new management command holding the catalog-dependent facts. They are not unit tests because the test database is empty and recreating Breaking Bad’s 75 shared people as a fixture would freeze a copy of the data rather than the data, which is the failure mode it exists to prevent. See ADR-07.

Read this record in the tvlens repo →

← All decisions