Learn whether a reader is tied to shows by cast or by crew, and name accordingly

Decision 15

Layer 2 gains a third dimension, alongside genre and tag: the average rating signal that cast connections and crew connections have each earned from one reader, measured on the Layer 1 edges that fall inside that reader’s own rated set. It orders the people a callout names, and only that. It never scores a show, and it stays silent unless three gates say the ratings have actually earned it.

Accepted 2026-08-29. Shipped in #7 before approval; the record caught up after.

Context

Layer 1 scores every shared person the same way whatever they did on the show (ADR-04). A creator and a lead actor are both worth their episode share, and that is right for ranking: the graph should not decide in advance that a showrunner matters more than a star.

Reading is a different act from ranking. “They share three of the same actors” and “the same showrunner made both” are two different pitches, and readers do not weigh them the same way. Patricio put it plainly on 2026-08-26:

if I rate 10 shows and some of them that I have watched are top recommendations of others, if the connection is more “cast overlap heavy” instead of “crew overlap heavy” then maybe I prefer one over the other

That is a question the existing profile could not answer. Layer 2 knew a reader’s genres and tags; it knew nothing about the shape of the connections between the shows they liked. Issue #7 had sat in the backlog since 2026-08-19 describing itself as “the display side of Layer 2’s learned weights”, which was not buildable, because no per-connection-type signal existed to display. The shaping turned it into one piece of work: compute the signal first, then order by it.

The hard part was never the arithmetic. It was that the database has two users, one with ten ratings and one with none, and a recommender that asserts a taste it cannot support is worse than one that says nothing.

Decision

Where the evidence comes from

Only the Layer 1 edges whose two ends the reader has both rated. That is the one place where a connection and a verdict on it are visible at the same time: the reader saw show A, saw show B, said what they thought of each, and the graph already knows what ties them together. Every other edge in the catalog is a connection with no verdict attached.

This follows the shaping exactly, and it has a consequence worth stating out loud. The gate is not “rate more shows”, it is “rate shows that are actually connected”. Measured on the catalog: ten shows picked at random have zero edges between them, while the real ten-rating user, whose ratings cluster by taste, has twelve. A reader who rates broadly and shallowly will never earn a lean, and should not.

How the two numbers are computed

For each such edge, the existing shared_connections gives the same episode-share contributions that ranked the show. Each contribution is filed under cast or crew (marquee crew and ordinary crew collapse together, because the split a reader feels is on-screen versus behind it). The edge carries the mean of its two ends’ rating signals, on the same score - 3.0 scale the rest of Layer 2 uses.

Each type’s affinity is then the contribution-weighted mean of those signals: the average rating signal a cast connection earned from you. This is the identical shape ShowTag relevance already uses in build_profile, which is the point. Two shows tied by one whole-run shared lead weigh more than two tied by a guest. A reader who rates everything the same gets two equal affinities and therefore no lean, which is the correct answer rather than a failure of the method.

The lean is the signed difference, cast-positive, in stars.

The three gates

The lean is 0.0 unless all three pass:

The real ten-rating user in the database today fails the third gate. Their twelve inner edges give cast 1.690 and crew 1.645, a gap of 0.044, because they rated every one of those shows between 4.0 and 5.0. There is nothing to learn from a reader who liked everything, and the feature says so by doing nothing. That is the design working, not the demo being thin.

What the lean does

name_connections multiplies each shared person’s contribution by their type’s multiplier before it cuts and orders the named few. A full one-star lean is a 3:1 tilt: 1.5 for the preferred type, 0.5 for the other. Enough to reorder the middle of a callout and push a marginal credit out of the named five; not enough to unseat a dominant shared lead. Same instinct as the re-ranking in ADR-08, where a blowout Layer 1 edge resists personalization while the near-tied tail moves freely.

Measured across all 4,014 stored edges, a full lean changes who is named on 5.8% of edges (cast-leaning) and 7.4% (crew-leaning), and changes the order of the named on 20.1%. The “and N others” count never changes: the tail collapse is issue #2’s decision and this does not touch it.

This amends issue #2’s “pitch by cast”

compose_callout used to open the sentence on cast whenever any cast was named, whatever order name_connections had produced. So the first cut of this work could change who was named but never what the sentence opened on, and a crew-leaning reader still heard about an actor first.

Patricio decided on 2026-08-26 to let the lean reorder the prose blocks, amending that decision. The amendment is recorded as a comment on issue #2 so the closed record does not contradict the code.

The important half is the default. “Fall back to cast-first when the lean is absent or weak” is not an error path here, because there is nothing to detect: cold start, insufficient signal, anonymous, and a sub-half-star gap all already arrive as a lean of exactly 0.0, and lean < 0 is false for all of them. Those readers take the same branch they always took. That is the same discipline the multipliers use, where a 0.0 lean makes both of them 1.0, and it is why the fallback cannot drift away from the default over time: it is the default.

With a full crew lean, 1,496 of 4,014 edges (37.3%) open on crew. With no lean, none do.

What it deliberately does not do

Alternatives that lost

Measure every edge out of every rated show, not just the ones landing back inside the set. This gives all ten of a ten-rating user’s shows a composition instead of six, so it looks like more signal. It is not: the far end of those edges is a show the reader has never judged, so the rating on the near end is being credited to a connection the reader never evaluated. It also costs an order of magnitude more work, indexing about 130 shows instead of a dozen.

Correlate rating against cast share directly. Same numbers in the limit, but it introduces a second statistical idiom into a module whose whole argument is that it is one linear, inspectable model. The weighted mean already in use for tags answers the question and reads the same way as its neighbors.

Let the reader set it themselves. Out of scope by decision, deferred to v2. A control is a different product question from a learned signal, and shipping the control first would have made the learned version unnecessary rather than informing it.

Amendment, 2026-08-30: the gate is a permutation test, not a threshold

Shipped with three gates: MIN_CONNECTION_TYPE_EDGES = 4, MIN_CONNECTION_TYPE_MASS = 1.0, MIN_CONNECTION_TYPE_LEAN = 0.5, read over at most CONNECTION_TYPE_MAX_EDGES = 60 edges. It never fired, and then it fired on noise. Both failures had the same cause and neither was the threshold.

The cap was starving the estimator

Seven synthetic profiles from 2 to 249 ratings produced no lean at all, including one built to lean by rating cast-tied neighbors 5.0 and crew-tied ones 1.0. It reached 0.358 against a required 0.5.

Twenty pure-noise profiles showed why: at a 60-edge cap their leans ran to 0.864 with a median of 0.42, and three of twenty already cleared 0.5. Signal sat below noise. 53% of edges in this catalog are mixed, carrying both cast and crew, so both affinities draw on the same edges and differ mostly by sampling noise, which averages out with more edges while a real lean does not:

cap signal (built) noise ceiling
60 0.358 0.864
150 0.712 0.461
400 0.809 0.339
1000 0.847 0.307

CONNECTION_TYPE_MAX_EDGES is now 400. Affordable only because ADR-07 moved the cast/crew split onto the edge; before that, more edges meant more graph walked.

But no constant could have worked

Raising the cap made the gate fire correctly, for a day. Then the catalog was pruned from 464 shows to 248 and a random 60-rating profile started firing at 0.607. The constant had been fitted to a catalog that no longer existed.

The deeper problem is that noise depends on how many edges a user happens to have, and no single number tracks that:

ratings inner edges false leans at 0.5
20 20 27%
40 72 27%
60 164 3%
100+ 400 0%

So the bar is computed per profile. The user’s own ratings are reassigned across their own rated shows 200 times and the lean recomputed; it is reported only if it beats 95% of those shuffles. Twenty edges earns a high bar automatically, four hundred a low one, and a catalog change cannot invalidate it.

The shuffle must be over shows, not edges. An edge’s signal is the mean of its two ends, so edges sharing a show share signal. Permuting edge signals breaks that correlation, tightens the null, and lets noise through 22% to 40% of the time, worse than the constant it replaced. That version was written and measured before this one. Permuting ratings across shows keeps the graph intact and asks the question worth asking: would these same ratings, spread differently over this same graph, still look like a preference?

Measured on pure noise after the correction:

ratings 20 40 60 100 150 248
false leans 2% 5% 8% 0% 5% 5%

Flat, and at the 5% a 95% bar should produce. The profile built to lean fires at 1.047.

MIN_CONNECTION_TYPE_LEAN drops to 0.25 and changes job: no longer the test, only the judgment that a difference under a quarter-star is not worth reporting however statistically real it is.

The unit fixture grew from two rated pairs to five a side. Four shows split six ways, so its most extreme possible result was p = 0.167 and could never clear a 95% bar. It was asserting a preference read off four shows.

After Action Review

It works, and on the live database it correctly declines to do anything. The ten-rating user gets no lean because they rated everything highly; the ratingless user gets none because they have no ratings. Both are the honest answer. Of seven synthetic profiles only the one built to lean fires, at 1.047, and it survives a catalog prune that invalidated every hand-fitted constant.

Cost is bounded rather than proportional. The split rides on the edge (ADR-07), so reading a reader’s edges is 400 rows and no graph work, and 200 shuffles is 200 passes over those rows with no queries behind them. A reader who has rated every show in the catalog builds their whole home page in 49ms across 22 queries, down from 579ms when this record was first written.

Tested in ConnectionTypePreferenceTests and ConnectionTypeNamingTests: cold start, anonymous, a cast-leaning reader, a crew-leaning reader, a low rating signing the lean negative, and each gate refusing separately. CalloutOrderingTests freezes which block opens the sentence, including that an unearned lean produces the byte-identical default. RoleIndexesBulkTests freezes the bulk equivalence and the query count. The suite is at 307.

What this record cost, and the lesson in it. Three passes to get one gate right: a cap too small to measure with, a constant that could not survive a catalog change, and a permutation test permuting the wrong thing. Every one was caught by measurement rather than review, and none by the 305 tests, which pass in all four versions. A gate that decides whether to say something is not something unit tests can validate; it needs a null distribution.

Read this record in the tvlens repo →

← All decisions