By Feel: A Mood-Based Recommendation Engine
For years, this was my evening ritual: wrap up work after a long day, step into the kitchen, and cook something great. Seared salmon with a miso glaze, maybe. Roasted vegetables. The kind of meal you want to eat hot, right now, while it's perfect. I'd carry the plate to the couch, sit down, open Plex, and then... stare at my library for thirty minutes while my dinner got cold.
The problem wasn't a lack of options. I had thousands of options. Too many options? The problem was that none of the tools available could answer the question I was actually asking: What do I feel like watching right now?
Every recommendation system I've used wants to know what I like in general. My star ratings, my watch history, my genre preferences. And sure, I generally like sci-fi and thrillers. But right now, after this particular day, I might want something completely different. Something light. Something weird. Something with swords. The general-purpose systems can't help because my mood tonight has nothing to do with my mood last Tuesday.
ShowShark's "By Feel" mode is my answer to the cold dinner problem. It's fully offline. It doesn't call any external API. It doesn't care what you watched last month. Instead, it shows you a handful of movies, asks a simple question ("which of these appeal to you right now?"), and uses your answers to zero in on what you're in the mood for. The whole process takes about a minute.
This post covers how it works, what went wrong along the way, and how Apple's Accelerate framework turned a 15-second-per-round disaster into something that feels instantaneous.
The Core Idea: Vote Propagation
The engine treats your movie library as a graph. Every movie is a node. Edges connect movies that are similar to each other, weighted by how similar they are. When you tell the engine "yes, that one looks interesting," the engine doesn't just remember that movie. It propagates your vote through the graph, boosting similar movies and their neighbors, rippling outward like dropping a stone into a pond.
Vote Propagation Through the Neighbor Graph
You vote +1 on "Sabotage"
│
▼
┌───────────────┐
│ Sabotage │ tally += 1.0 (direct vote)
│ (Action/ │
│ Crime) │
└──────┬────────┘
│
│ Top-30 most similar (1st generation)
│ contribution = vote × similarity × 0.5
│
┌──────┴──────────────────────────────────────────────┐
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────────┐ ┌──────────────┐
│ Gangster │ +0.45 │ Jack │ +0.43 │ Street │ +0.37
│ Squad │ │ Reacher │ │ Kings │
└────┬─────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
│ 2nd generation │ │
│ contribution = vote × sim1 × sim2 × 0.25 │
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────────┐ ┌──────────────┐
│ The Town │ +0.08 │ Mission │ +0.07 │ Training │ +0.06
│ │ │ Impossible │ │ Day │
└──────────┘ └──────────────┘ └──────────────┘
... repeated for all 30 neighbors × their 30 neighbors = ~930 movies touched
A single vote on one movie colors roughly 930 others. Positive votes make similar movies rise. Negative votes (or "none of these" rejections) push similar movies down. After several rounds of this, the entire library has been tinted by your expressed preferences, and the movies floating to the top are the ones that best match the pattern of what you said yes and no to.
The key insight: this isn't a profile of your general taste. It's a snapshot of your mood right now, built from scratch in real time, discarded when you're done.
Similarity: Six Factors, One Score
Two movies are "similar" according to a weighted combination of six factors:
| Factor | Weight | Comparison |
|---|---|---|
| Keywords | 30% | Jaccard overlap of TMDB keyword sets |
| Embeddings | 25% | Cosine similarity of 768-dim vectors |
| Genres | 20% | Jaccard overlap of genre tags |
| Cast | 10% | Jaccard overlap of credited cast |
| Year | 10% | Proximity (1.0 when same year, 0.0 at 100 years apart) |
| Rating | 5% | Proximity of audience rating (1.0 when equal, 0.0 at 10 apart) |
Keywords carry the most weight because they capture thematic content that genres miss. "Heist," "time travel," "dystopia," and "road trip" tell you more about what watching a movie feels like than "Action" or "Drama." The 768-dimensional embedding vectors come from TMDB and encode semantic relationships that no metadata field captures explicitly.
func computeSimilarity(seed: CandidateRecord, candidate: CandidateRecord) -> SimilarityScore {
let keywordOverlap = computeSetOverlap(seed.keywordsSet, candidate.keywordsSet)
let genreOverlap = computeSetOverlap(seed.genresSet, candidate.genresSet)
let castOverlap = computeSetOverlap(seed.castSet, candidate.castSet)
let yearProx = computeYearProximity(seed.year, candidate.year)
let ratingProx = computeRatingProximity(seed.voteAverage, candidate.voteAverage)
let embeddingCos = computeCosineSimilarity(seed: seed, candidate: candidate)
return SimilarityScore(
keywordOverlap: keywordOverlap,
embeddingCosine: embeddingCos,
genreOverlap: genreOverlap,
castOverlap: castOverlap,
yearProximity: yearProx,
ratingProximity: ratingProx
)
}
When a movie has no embedding vector (about 2% of my library), the embedding weight gets redistributed proportionally across keywords and genres. The score still works; it just leans harder on the metadata it has.
What to Show Each Round
Here's where things get interesting. Each round shows you several movies. How do you choose which movies?
The naive approach is random selection. That's terrible. You'd waste rounds showing movies from the same corner of your library, learning nothing new each time.
The second attempt was pure diversity: pick three movies that are as different from each other as possible (farthest-first traversal). Better, but it had a blind spot. A movie with no genres, no keywords, and no embedding vector is "maximally different" from everything, so it kept getting selected. But when you vote on a metadata-poor movie, the vote barely propagates because all its neighbor connections are weak. The round is wasted.
The solution combines diversity with what I call metadata richness: a pre-computed score estimating how well-connected a movie is in the similarity graph.
let metadataRichness =
min(Double(keywordsSet.count), 10) / 10.0 * 0.30 // keywords (capped at 10)
+ (embeddingMagnitude > 0 ? 1.0 : 0.0) * 0.25 // has embedding?
+ min(Double(genresSet.count), 5) / 5.0 * 0.20 // genres (capped at 5)
+ min(Double(castSet.count), 10) / 10.0 * 0.15 // cast (capped at 10)
+ (year > 0 ? 1.0 : 0.0) * 0.05 // has year?
+ (voteAverage != nil ? 1.0 : 0.0) * 0.05 // has rating?
The weights intentionally mirror the similarity weights. If a movie is missing what matters most for similarity computation, its richness score drops proportionally.
Selection works in two phases:
- First pick: Weighted random, where probability is proportional to metadata richness. A movie with richness 0.8 is sixteen times more likely to be picked than one with richness 0.05.
- Remaining picks: Farthest-first traversal, but the selection criterion is
minDissimilarity × metadataRichnessinstead of justminDissimilarity. Among candidates that are equally far from the already-selected set, the one with richer metadata wins.
Candidate Selection: Diversity × Reach
Pool of unrated movies (tally near zero, never shown before)
┌──────────────────────────────────────────────────────────┐
│ ○ Alpha richness=0.85 │
│ ○ Bunea Vista richness=0.05 (no genres, no keywords) │
│ ○ Caveman richness=0.72 │
│ ○ Dog Soldiers richness=0.78 │
│ ○ El jardin richness=0.03 (no metadata at all) │
│ ...2000 more │
└──────────────────────────────────────────────────────────┘
│
│ Pick 1: weighted random → Alpha (richness=0.85)
│
│ Pick 2: max(dissimilarity_to_Alpha × richness)
│ Dog Soldiers wins (dissim=0.91, richness=0.78
│ score=0.71) over Bunea Vista (dissim=0.97,
│ richness=0.05, score=0.05)
│
│ Pick 3: max(min_dissimilarity_to_{Alpha, Dog Soldiers}
│ × richness) → Caveman
│
▼
Selected: [Alpha, Dog Soldiers, Caveman]
All three have strong neighbor connections = high-impact round
This is the difference between asking good questions and asking random questions. Every round of user feedback now propagates through strong neighbor connections, coloring a large, meaningful portion of the library.
The Neighbor Table: Lazy and Cached
Computing the top-30 most similar movies for a given movie requires comparing it against every other movie in the library. For 3,000 movies, that's 3,000 similarity computations per lookup, each involving three Jaccard overlaps, one cosine similarity on 768-dimensional vectors, and two scalar comparisons.
The first implementation tried to precompute the entire neighbor table at session creation. That's 3,000 × 3,000 = 9 million similarity computations. The CPU pegged at 100% and the request timed out.
The fix: lazy computation with caching.
private func ensureNeighbors(for mediaId: Int64, session: inout PreferenceSession) {
guard session.neighborTable[mediaId] == nil else { return }
guard let seedIdx = session.candidateIndex[mediaId] else { return }
let seed = session.allCandidates[seedIdx]
var scored: [(mediaId: Int64, similarity: Double)] = []
for candidate in session.allCandidates where candidate.mediaId != mediaId {
let sim = computeSimilarity(seed: seed, candidate: candidate)
.total(hasEmbeddings: session.hasEmbeddings)
scored.append((mediaId: candidate.mediaId, similarity: sim))
}
scored.sort { $0.similarity > $1.similarity }
session.neighborTable[mediaId] = Array(scored.prefix(k))
}
Neighbors are computed the first time a movie needs them (when it's voted on or when it's a first-generation neighbor of a voted movie). Once computed, the result is cached in the session's neighborTable dictionary. A movie's neighbors never change during a session, so every lookup after the first is a dictionary hit.
Over 10 rounds, the table grows from 0 to about 800 entries. That's 800 × 3,000 = 2.4 million similarity computations across the entire session, spread across rounds. Much better than 9 million up front.
The Performance Wall (and How vDSP Demolished It)
With lazy computation working, the algorithm was correct. The results were good. There was just one problem: each round took 10 to 15 seconds.
Per round, about 90 new movies need neighbor computation (3 shown movies × 30 first-gen neighbors that trigger second-gen lookups, with some cache hits). Each new lookup runs 3,000 similarity comparisons. Each comparison calls computeCosineSimilarity, which was implemented like this:
// The slow version
func computeCosineSimilarity(_ a: [Double]?, _ b: [Double]?) -> Double {
guard let vecA = a, let vecB = b, vecA.count == vecB.count else { return 0.0 }
var dot = 0.0
var magA = 0.0
var magB = 0.0
for i in 0..<vecA.count {
dot += vecA[i] * vecB[i]
magA += vecA[i] * vecA[i]
magB += vecB[i] * vecB[i]
}
let magnitude = (magA.squareRoot() * magB.squareRoot())
guard magnitude > 0 else { return 0.0 }
return dot / magnitude
}
Three loops over 768 elements (dot product, magnitude A, magnitude B), running 185,000 times per round. That's 425 million floating-point operations in a serial loop. Worse, the magnitude of each vector was being recomputed every single time it appeared in a comparison. The same movie's magnitude got calculated thousands of times per round.
The fix had three parts.
1. Pre-computed Magnitudes
The magnitude of an embedding vector never changes. Compute it once when the CandidateRecord is created:
import Accelerate
// In CandidateRecord.init:
if let emb = embedding, !emb.isEmpty {
self.embeddingMagnitude = sqrt(vDSP.sumOfSquares(emb))
} else {
self.embeddingMagnitude = 0
}
vDSP.sumOfSquares computes the sum of squares of the vector in a single SIMD-accelerated pass. This replaces the for i in 0..<768 { magA += vecA[i] * vecA[i] } loop with a hardware-optimized operation, and it only runs once per movie instead of thousands of times.
2. SIMD Dot Product
The dot product itself replaced the scalar loop with vDSP.dot:
func computeCosineSimilarity(seed: CandidateRecord, candidate: CandidateRecord) -> Double {
guard let vecA = seed.embedding, let vecB = candidate.embedding,
!vecA.isEmpty, vecA.count == vecB.count,
seed.embeddingMagnitude > 0, candidate.embeddingMagnitude > 0 else {
return 0.0
}
let dot = vDSP.dot(vecA, vecB)
return dot / (seed.embeddingMagnitude * candidate.embeddingMagnitude)
}
vDSP.dot uses ARM NEON SIMD instructions on Apple Silicon, processing multiple double-precision elements per clock cycle. The function went from three O(768) scalar loops to one SIMD pass plus two cached lookups.
3. Arithmetic Jaccard
A smaller win, but it added up across 555,000 Jaccard calls per round. The original code allocated a new Set for the union on every call:
// Before: allocates a union set every call
let unionCount = setA.union(setB).count
return Double(setA.intersection(setB).count) / Double(unionCount)
The fix uses the inclusion-exclusion identity to skip the allocation:
// After: zero allocations for the union
let intersectionCount = setA.intersection(setB).count
let unionCount = setA.count + setB.count - intersectionCount
return Double(intersectionCount) / Double(unionCount)
The Result
Per-Round Propagation Time (3,079 movie library)
Before optimizations:
██████████████████████████████████████████████████ 13,785 ms
After optimizations:
████ 754 ms
Speedup: ~18×
The session went from three painful minutes of waiting to something that feels like a conversation. You pick, the engine thinks for under a second, and the next set of movies appears.
Termination: When to Stop Asking
The engine can't ask forever. It needs to decide when it has learned enough about your mood to produce useful recommendations.
Every movie starts with a tally of zero. As votes propagate, tallies drift positive or negative. A movie is "unrated" if its tally is still within ±0.001 of zero, meaning no vote has meaningfully reached it.
After each round, the engine counts how many unshown movies are still unrated. When that count drops below 20 (or below one-third of the library size, whichever is smaller), and at least 3 rounds have passed, the session terminates and the top 20 movies by tally become your recommendations.
Coverage Over 10 Rounds (3,079 movies, 30 neighbors/movie)
Round Unrated Touched Coverage
1 1,945 1,134 37%
2 1,214 1,865 61%
3 954 2,125 69%
4 766 2,313 75%
5 585 2,499 81%
6 498 2,588 84%
7 387 2,702 88%
8 356 2,732 89%
9 313 2,775 90%
10 285 2,802 91%
In this session, the user only selected 2 movies out of 30 shown (mostly hitting "none of these"). Even with that sparse positive signal, the engine colored 91% of the library by round 10. The 9% that remain unrated are movies so dissimilar to everything discussed that they're effectively in a different universe. That's fine. The top 20 don't come from the unrated pool; they come from the movies with the highest positive tallies.
Score Normalization
Tallies are unbounded. A movie that sits at the intersection of two positive votes, receiving contributions from both, can easily accumulate a tally of 1.7 or higher. Displayed raw, that would show as "170% match," which makes no sense to a user.
Before returning results, the engine normalizes scores to [0, 1] by dividing every result's tally by the highest tally in the set. The top recommendation shows as 100%, and everything else is relative:
Gangster Squad ████████████████████████████████████████ 100%
Jack Reacher ███████████████████████████████████████ 98%
Need for Speed ████████████████████████████████████ 94%
Street Kings ██████████████████████████████████ 87%
Black Site █████████████████████████████████ 86%
Contraband ████████████████████████████████ 85%
The Dark Knight ████████████████████████████████ 84%
Fast & Furious ███████████████████████████████ 84%
What Makes This Different
Most recommendation systems are backward-looking. They study your history and predict what you'll like next. That works well for music, where your taste is relatively stable, but it works poorly for the "what do I watch tonight" problem. Your mood is transient. It's influenced by your day, your energy level, whether you're eating alone or with someone. No amount of watch history captures that.
By Feel is forward-looking. It starts from zero every time. The only input is how you react to a handful of movies right now. The entire session state is ephemeral: created when you start, discarded when you're done, never persisted. Your mood at 8 PM on a Thursday doesn't contaminate your mood at 10 PM on Saturday.
It's also entirely offline. The similarity graph is built from locally stored metadata and embedding vectors. No API calls, no internet requirement, no privacy concerns. Your viewing mood is nobody's business.
And it's fast. The meal stays hot.