~learn.

Side quest 30For You feed

TikTok's For You Feed.

A Billion People a Month, a Feed With No Follow Graph, and a Model That Learns From Every Swipe Within Minutes

EXPERT52 MIN READ6 PHASES · 45 MIN INTERVIEW+120 XP

Mission brief

Design a short-video recommendation feed that learns from you within minutes

Quest log0/7 stages · 0/9 checkpoints · 0/8 quick checks
people a month (TikTok, September 2021)
1 billion+
sync of sparse embeddings to serving, day-level for dense weights, in ByteDance's Monolith system (Monolith paper, 2022; the paper doesn't name TikTok)
Minute-level
per production model; about 1,000 embedding tables in the one it tested (ByteDance, Monolith paper, 2022)
Several terabytes
of violative video removals automated (as shown on TikTok's Transparency Center in October 2026)
97%+

Company names and logos are trademarks of their owners, shown only to identify each company. This course isn't affiliated with or endorsed by any of them.

watch it first · newThe whole design in a 17-minute animated lecture4 quiz checkpoints · ask the AI about any moment · captions, chapters and speed
Optional · timed run · 6 phases · 45 min

Want it harder? Hide the model answer and work each phase in a scratchpad against its time budget, then reveal and score yourself. Or simply read on: the checkpoints below work either way.

Intro · 4 min

How Do You Learn What Someone Likes From Swipes That Last Less Than a Second?

It is a Thursday evening in October 2026 in Dubai. Aditi Menon, a pastry chef who moved from Kochi two years ago, installs TikTok for the first time. She never had it at home: the Government of India blocked TikTok, with 58 other apps, on 29 June 2020, and it has stayed blocked. Her colleagues at the bakery in Al Quoz have been telling her that half the city's dessert trends start there.

The app asks her to pick some interests. She taps Food and Travel, and the first video starts before she has put the phone down: a street vendor in Bangkok, then a dog on a skateboard, then a kunafa being pulled apart in slow motion. She watches the kunafa twice. She flicks the dance video after it away in under a second. Twenty minutes later her feed is half desserts, and three of them are from home bakers with a few hundred followers each. At 9 pm she posts her own first video, a cardamom kunafa, from an account nobody follows. By morning it has been shown to people who have never heard of her.

Two cold starts happened that evening, a new viewer and a new video from a new creator, and both were solved by the same machinery: a model that keeps training on what people do, minute by minute, while it serves them.

TikTok and ByteDance, from published numbers
  • 1 billion+ people come to TikTok every month (TikTok Newsroom, 27 September 2021)
  • "A strong indicator of interest, such as whether a user finishes watching a longer video from beginning to end, would receive greater weight than a weak indicator" (TikTok Newsroom, 18 June 2020)
  • Minute-level sparse and day-level dense parameter sync from training to serving; its worked example, 100,000 updated IDs a minute × 1,024 dimensions, is ≈ 400 MB a minute (ByteDance, Monolith paper, 2022)
  • Several terabytes per production model; about 1,000 embedding tables in the model it tested; parameter servers snapshotted once a day (ByteDance, Monolith paper, 2022)
  • Hundreds of millions of users and short videos in a ByteDance production system where a new retrieval method lifted video finish rate by 3.0% (relative) in an A/B test (ByteDance, Deep Retrieval paper, 2020; the platform isn't named)
  • 97%+ of violative video removals automated; 99%+ of violative content removed before anyone reported it (as shown on TikTok's Transparency Center in October 2026)

For context:
TikTok has been blocked in India since 29 June 2020; this quest's Indian character meets it abroad
ByteDance's papers describe its recommendation research and production systems, but don't say which ones the TikTok app runs: Monolith "landed in the BytePlus Recommend product", Deep Retrieval was tested on an unnamed short-video platform, and Next-User Retrieval on Douyin, ByteDance's Chinese short-video app
TikTok publishes the signals it uses and its safety rules, not its architecture; everything else here, and every number labelled as an assumption, is an illustrative design

This is the system we'll design today.

ExplainerThe whole quest: TikTok's For You feed

TikTok says more than a billion people come every month; on stated assumptions that is ~45 billion impressions a day, ~1 million a second at peak, ~175,000 feed requests and ~4 million events a second, and every impression is a label: watch time, finishes, loops and quick skips. ByteDance's Monolith paper, which doesn't name TikTok, describes models of several terabytes with about 1,000 embedding tables, sparse rows synced to serving every minute and dense weights daily. The serving loop retrieves ~2,000 candidates, a light model keeps ~300, a heavy ranker scores them and a re-rank keeps the same creator or sound from appearing twice in a row. Session features change the next page in seconds; a joiner pairs each impression's logged features with its labels for an online trainer, keeps 15 minutes of features in memory and late ones on disk, samples negatives and corrects the odds at serving. Embedding tables use cuckoo hashing with admission and expiry, so IDs never share a row, and only touched rows are synced. New users start on popular lists for their topics, region and language; new videos get a test pool and expand on evidence. The deep dives end with Aditi's first evening, a bad update spreading in minutes and a new song at 9 pm in Jakarta.

The Interview Begins#

You're interviewing at a short-video company. The interviewer holds up a phone showing a full-screen video with a heart, a comment bubble and a share arrow down the right edge, and swipes up three times in four seconds.

Interviewer

"Every one of those swipes is a label. Design the For You feed: what to show, how it learns from the swipes, and how it learns fast. I don't want a follower feed and I don't want video storage. I want the learning loop."

Design a short-video recommendation feed that learns in real time

Users open the app and get an endless vertical stream of short videos chosen for them from the whole catalogue, not just from accounts they follow. Every play, replay, skip, like, share, comment and follow is logged. The system ranks candidates with models trained continuously on those logs, and pushes what it learns back into serving within minutes. New users and new videos must get reasonable recommendations immediately. Videos must start instantly, and the feed must stay varied and keep unsafe content out.

Requirements
  • Serve a personalised stream of short videos, a few at a time, to a billion monthly users
  • Turn plays, replays, skips and taps into training labels
  • Train continuously on those labels, and serve what was learnt within minutes
  • Handle new users with no history and new videos with no views
  • Avoid repetitive feeds and keep ineligible content out of recommendations
  • Start each video without visible delay
Interviewer

"Ads, LIVE, search, Shop and the comment ranker are out. Upload and transcoding exist and you should place them, but assume a standard transcoding pipeline."

Phase 1 · Stage 1 of 7 · 5 min budget · 3 min read

Phase 1: Requirements Clarification (5 minutes)#

You

"Most of the hard decisions here depend on time: how quickly a signal must change what someone sees. So I'll ask about the signals first, then the loop, then the edges."

Your Questions#

Transcript
You

"What does the feed learn from?"

Interviewer

"TikTok's June 2020 list: user interactions (videos you like or share, accounts you follow, comments, content you create); video information (captions, sounds, hashtags); and device and account settings, which 'receive lower weight'. Its help centre today lists content you 'like, share, comment on, and watch in full or skip', and says interactions, which may include time spent watching, 'are generally weighted more heavily than others' for most users."

You

"So watch time and skips are first-class labels, not just likes. What about followers? Is it a social feed?"

Interviewer

"No. TikTok wrote that 'neither follower count nor whether the account has had previous high-performing videos are direct factors in the recommendation system'. The For You feed draws from everything eligible."

You

"How fast must it learn?"

Interviewer

"As fast as you can afford. ByteDance's Monolith paper (RecSys workshop, 2022) describes a system that keeps training after deployment and syncs updated embeddings to serving 'with a minute-level time interval'; in its experiments, shorter intervals always helped. The paper doesn't say whether TikTok runs it. Assume minutes."

You

"And for a brand-new user?"

Interviewer

"TikTok asks new users to pick categories like pets or travel. If they don't, it starts with 'a generalized feed of popular videos', which its help centre says is chosen for 'a broad audience and influenced by your location and language settings'. Then 'your first set of likes, comments, and replays will initiate an early round of recommendations'."

You

"What rules shape the feed beyond relevance?"

Interviewer

"The feed 'generally won't show two videos in a row made with the same sound or by the same creator', and skips duplicates, videos you've seen and spam. In December 2021 TikTok also said it was testing ways to avoid recommending a series of similar videos, such as extreme dieting or sadness, that 'may be fine as a single video but problematic if viewed in clusters'."

Functional Requirements#

Functional requirements
  • Feed: return the next few videos for a user on request, ranked for that user, from all eligible videos; never repeat a seen video; respect "Not interested", hidden creators and sounds, and keyword filters
  • Signals: log every impression with the features used to rank it, and every play, replay, skip, like, share, comment and follow, so each impression becomes a labelled example
  • Learning: train continuously on those examples; push updated embeddings to serving every minute or so and the rest of the model daily; retrain from history when the model changes
  • Cold start: seed new users from chosen interests or popular local videos and learn within the first session; give each eligible new video a first audience and expand it on evidence
  • Shaping and safety: no two consecutive videos from the same creator or sound; limit clusters of sensitive themes; only eligible, reviewed videos reach the feed
  • Playback: the next videos are already downloading while the current one plays

Non-Functional Requirements#

Non-functional requirements
Freshness (design targets)
  • A swipe changes the next page of the same session: seconds
  • A swipe is trained into the model and served: p99 under 5 minutes, following Monolith's minute-level sync
  • Dense weights: daily
Latency (design targets)
  • Feed request p99 < 150 ms server time
  • Next video plays with no visible start-up delay for 95% of swipes; first video of a cold app open shows its first frame in < 1 s
Scale (published and estimated)
  • 1 billion+ monthly users (published); ~1 million impressions a second at peak (estimated below)
Safety
  • Zero videos that failed review in anyone's feed; a sensitive theme never shown many times in a row

Phase 2 · Stage 2 of 7 · 5 min budget · 4 min read

Phase 2: Back of the Envelope Estimation (5 minutes)#

You

"TikTok publishes its monthly users, and ByteDance publishes sizes from its research systems. It doesn't publish daily users, watch time, uploads or traffic, so every rate below is an assumption, labelled, and the point is the shape."

Your turn · checkpoint 1not answered

Before the numbers: how many labels does a swipe-feed produce?

Assume half of a billion monthly users open the app on a given day, for 30 minutes each, and the average video is on screen for 20 seconds including the ones skipped in under one. How many impressions a day is that, and a second? If every impression becomes one training example, how does that compare with a feed like Instagram's, where a user scrolls past a post in a couple of seconds and only a like or a comment is a clear label?

Think it through, or jot it down. Then compare.

One strong answer

  • Impressions: 500 million × (30 × 60 ÷ 20) = 500 million × 90 = 45 billion a day, about 520,000 a second, ~1 million at a 2× peak.
  • Labels: every one of them carries a label for free: how long it was watched, whether it finished, whether it looped, whether it was skipped. A skip at 0.4 s is as informative as a like.
  • So a short-video feed produces dense, immediate labels at the rate it serves, which makes minute-level learning both possible and worth it.

Traffic Estimation#

WorksheetViewing, feed requests and events (every rate is an assumption: TikTok doesn't publish them)
Value
Monthly users (published, 2021) 1 billion+
Daily users (assumed, half of monthly) ~500 million
Minutes a day per user (assumed) 30
Seconds on screen per video, skips included (assumed) 20
Impressions a day 500M × 90 ≈ 45 billion
Impressions a second, average / 2× peak ~520,000 / ~1 million
Videos returned per feed request (assumed) 6
Feed requests a second, average / peak ~87,000 / ~175,000
Client events per impression (assumed: impression, play, progress, end or skip, sometimes a tap) ~4
Events a second, average / peak ~2 million / ~4 million
Concurrent viewers (500M × 30 min ÷ 1,440 min), average / peak ~10 million / ~20 million

Conclusion: the read path is moderate (175,000 feed requests a second), but the write path is large: millions of events a second that must become training examples within minutes.

Ranking Compute Estimation#

WorksheetOne feed request (sizes assumed; the funnel shape is from ByteDance's Deep Retrieval paper)
  • Catalogue: "hundreds of millions" of videos (ByteDance's Deep Retrieval platform, 2020); several retrieval sources return ~2,000 per request
  • Rank: a light model cuts 2,000 to ~300; the heavy multi-task model scores those: 175,000 × 300 ≈ 52 million heavy scores a second at peak
  • If one accelerator scores ~100,000 a second (assumed), that's ~520 accelerators busy at peak, before headroom

Conclusion: the heavy ranker is the serving fleet's main cost; candidate counts are the knob that trades quality against hardware.

The Learning Loop Estimation#

WorksheetExamples, the joiner and the embedding tables (sizes assumed unless marked)
  • Examples: one per impression, ~1 million a second at peak
  • Features logged per impression: IDs, counters and the session's recent history, ~1 KB (assumed) → ~1 GB/s into the joiner
  • Joiner memory: labels for a swipe arrive within seconds to a few minutes; holding 15 minutes of features in memory at peak is 1 GB/s × 900 s ≈ 900 GB across the joiner cluster; anything later goes to an on-disk store (the split Monolith describes)
  • Embeddings: ~1 billion user IDs × 64 floats × 4 bytes ≈ 256 GB; ~600 million live video IDs (20 million uploads a day, assumed, idle ones expiring after a month) ≈ 150 GB; then creators, sounds, hashtags and their crosses: Monolith's tested model had about 1,000 tables, and its production models are "several terabytes" (published)

Conclusion: the model is a few terabytes of mostly sparse embeddings, too big for one machine and changing every second. It lives on sharded parameter servers.

Sync and Delivery Estimation#

WorksheetShipping the model and the videos (assumed unless marked)
  • Sync: ~25 million touched rows a minute at peak × 64 floats × 2 bytes (fp16) ≈ 3.2 GB a minute per serving copy, ~53 MB/s; dense weights go daily (published)
  • Video: 45 billion views × ~3 MB watched (20 s at ~1.2 Mbit/s) ≈ 135 PB a day, ~12.5 Tbit/s on average, almost all from CDN caches

Conclusion: the learning loop is cheap to ship and expensive to compute; the videos are the reverse.

Key Metrics Summary#

Metric Value
Monthly users (TikTok, 2021) 1 billion+
Impressions (assumed) ~45 billion a day, ~1 million a second at peak
Feed requests (assumed) ~175,000 a second at peak
Training examples one per impression, ~1 million a second at peak
Model size (Monolith, published) several TB, ~1,000 embedding tables
Embedding sync (Monolith, published) sparse every minute, dense daily
Sync volume (estimated) ~3.2 GB a minute per serving copy
Video egress (estimated) ~12.5 Tbit/s average
Quick check3 questions · +5 XP each

Requirements and estimation

  1. Q1/3 500 million daily users watch for 30 minutes each, 20 seconds per video. Roughly how many impressions a second on average?

    Answer & explanation

    Answer: C

    500M × 90 = 45 billion a day; ÷ 86,400 ≈ 520,000 a second.

  2. Q2/3 TikTok said a strong indicator of interest gets more weight than a weak one. Which did it give as the strong example?

    Answer & explanation

    Answer: B

    Same country was its example of a weak indicator; follower count is not a direct factor at all.

  3. Q3/3 Why is the joiner's memory sized in hundreds of gigabytes?

    Answer & explanation

    Answer: B

    Features are logged when a video is served; labels come seconds to minutes later. The window between them is the joiner's memory.

Phase 3 · Stage 3 of 7 · 10 min budget · 4 min read

Phase 3: High-Level Design (10 minutes)#

Your turn · checkpoint 2not answered

Sketch it first: Aditi's kunafa and the dance video

Aditi looped the kunafa and skipped the dance video in 0.8 s, with three videos left in her buffer. What changes on her next feed request, and what makes that happen in seconds? What turns her actions into training examples, and what does it need besides the actions? How long until the model itself has learnt from them, and how does that reach the servers ranking her feed?

Think it through, or jot it down. Then compare.

One strong answer

  • Seconds, no model change: the app attaches her recent actions to the next request, and a session feature store updates from the event stream. The same model, given "desserts finished, dance skipped", ranks desserts higher.
  • Examples: a joiner pairs each impression's logged features, exactly what the ranker saw, with the labels that arrive later.
  • Minutes, the model changes: an online trainer updates the embeddings the examples touch and pushes only those rows to serving about once a minute; dense weights move daily.
  • The next video was already downloading; the request only changes what comes after the buffer.
You

"Two loops share one model. The serving loop is the familiar retrieve-rank-rerank funnel. The learning loop is what's different: every impression is logged with its features, joined with its labels, trained into the model and synced back. And there are three clocks: the session in seconds, the embeddings in minutes, the dense network in a day."

System Architecture#

DiagramTwo loops, one model: serve the feed, learn from every swipe
%% title: Two loops, one model: serve the feed, learn from every swipe
flowchart TD
  subgraph L1["Layer 1 · Viewers and creators"]
    direction LR
    APP@{ icon: "sd:phone", label: "App<br/>plays, swipes, uploads" }
    CDN@{ icon: "sd:cdn", label: "CDN<br/>video segments, prefetch" }
  end
  subgraph L2["Layer 2 · Serving loop"]
    direction LR
    GW@{ icon: "sd:gateway", label: "API gateway<br/>feed, events" }
    FEED["Feed service<br/>retrieve, rank, rerank"]:::hot
    SESS@{ icon: "sd:cache", label: "Session features<br/>last 50 actions, seconds old" }
  end
  subgraph L3["Layer 3 · The model"]
    direction LR
    RET@{ icon: "sd:index", label: "Candidate sources<br/>embeddings, paths, fresh pool" }
    SPS["Serving parameter servers<br/>embeddings minutes old"]:::core
  end
  subgraph L4["Layer 4 · Learning loop"]
    direction LR
    LOG@{ icon: "tech:kafka", label: "Kafka<br/>actions, served features" }
    JOIN@{ icon: "tech:flink", label: "Flink joiner<br/>features + labels" }
    TRAIN["Online trainer<br/>training PS, collisionless tables"]:::core
  end
  subgraph L5["Layer 5 · Videos"]
    direction LR
    UP["Upload and transcode<br/>renditions, safety scan"]:::service
    OBJ@{ icon: "sd:object-storage", label: "Video store<br/>renditions, history" }
  end
  APP ==> CDN
  APP ==> GW ==> FEED
  FEED --> SESS
  FEED --> RET
  FEED --> SPS
  GW -->|"swipes, taps"| LOG
  FEED -->|"features served"| LOG
  LOG -.-> SESS
  LOG ==> JOIN ==> TRAIN
  TRAIN -.->|"touched rows, every minute"| SPS
  APP --> UP --> OBJ --> CDN
  UP -.->|"eligible videos"| RET
  class APP client
  class CDN,GW edge
  class SESS cache
  class RET store
  class LOG queue
  class JOIN service
  class OBJ store

KEY INSIGHTS:

  • Three clocks. The session changes the next page in seconds (features, not weights); online training changes the embeddings in minutes; the dense network changes daily. Each fast clock covers what the slower one can't yet know
  • Log what you served, don't recompute it. The feature values used to rank a video are logged at serving time and joined with the labels later, so training sees exactly what serving saw
  • The model is a sparse key-value store. Most of its terabytes are embedding rows keyed by user, video, creator, sound and hashtag IDs; new IDs arrive constantly and old ones go idle. It's sharded and synced like a database, not loaded like a file

Data Flow: Aditi's Replay, Skip and Next Page#

DiagramFrom a replay and a skip to a changed feed and a changed model
%% title: From a replay and a skip to a changed feed and a changed model
sequenceDiagram
  autonumber
  participant A as Aditi's app
  participant G as Gateway
  participant F as Feed service
  participant S as Session features
  participant P as Serving PS
  participant K as Kafka
  participant J as Joiner and trainer
  A->>G: Events: kunafa looped twice, dance skipped at 0.8 s
  G->>K: Append, each with an event id
  K->>S: Session updated within seconds
  A->>G: Next page, 3 videos left, last 10 actions attached
  G->>F: Feed request r9
  F->>S: Read session features
  F->>P: Embeddings for Aditi and ~2,000 candidates
  F-->>A: 6 videos, mostly desserts, request id r9
  F->>K: Log the features used for r9
  K->>J: Join r9 features with r9 labels as they arrive
  J->>P: Touched embedding rows, next minute

MEANWHILE, IN PARALLEL:

  • The app is already downloading the first second of each new video from the CDN, so the swipe after the buffer runs out is instant
  • Each impression in r9 becomes a training example when its labels settle, and is also dumped to a batch store for retraining
Quick check3 questions · +5 XP each

High-level design

  1. Q1/3 Aditi skips a dance video. What changes her very next feed page within seconds?

    Answer & explanation

    Answer: C

    The fastest clock changes inputs, not weights. Online training changes the embeddings a minute or so later.

  2. Q2/3 Why does the feed service log the features it used, instead of the trainer recomputing them later?

    Answer & explanation

    Answer: B

    Recomputing later gives a different value (counters have moved on): training-serving skew.

  3. Q3/3 Where do most of the model's terabytes live?

    Answer & explanation

    Answer: B

    Monolith notes sparse parameters dominate model size, and only a small subset changes in any short window.

Phase 4 · Stage 4 of 7 · 20 min budget · 24 min read

Phase 4: Deep Dives (20 minutes)#

Deep Dive 1: Signals: Turning a Swipe Into a Label (Week 8 Concepts: Event Ingestion; Week 2: Idempotency)#

You

"In a follower feed, most impressions end in silence and a like is the label. Here every impression ends in a number: how long the thumb stayed still. It's the richest signal we have, and the easiest to get wrong."

This builds on Week 8, Day 1 · Event Ingestion at Scale and Week 2, Day 2 · Idempotency in Practice.

Your turn · checkpoint 3not answered

Four videos, four watch times

In Aditi's first ten minutes: a 15-second kunafa video watched for 30 seconds (it looped); a 45-second dance video skipped at 0.8 s; a 3-minute recipe watched for 60 s, then liked; a 9-second clip watched to the end. Which are positive examples, and how do you compare 30 s of a 15-second video with 60 s of a 3-minute one? On her flaky connection some progress events were sent twice, and the app was killed before the recipe's "ended" event. What does the pipeline do?

Think it through, or jot it down. Then compare.

One strong answer

  • Not one label, several: watch time, watch ratio (capped), finished, loops, quick skip (under ~2 s), and taps: like, share, comment, follow, "Not interested". The model predicts each; a weighted sum ranks.
  • Compare within a length. Raw seconds favour long videos and raw ratios short ones; judge watch time against what's typical for that length. 30 s on a 15 s video is far above typical; 60 s of a recipe is ordinary, but the like makes it positive.
  • The 9-second clip finished, but a finish on a very short video is weak evidence; the dance skip is a strong negative.
  • Duplicates: events carry an impression id and a sequence number, so a resent event changes nothing.
  • The missing "ended": the next impression's start closes the previous one; failing that, a time-out does.

What is published: TikTok wrote in June 2020 that signals are "weighted based on their value to a user": finishing a longer video is "a strong indicator of interest", the viewer and creator being in the same country "a weak indicator", and a new user's "first set of likes, comments, and replays" starts the early recommendations. Its help centre lists content you "like, share, comment on, and watch in full or skip". "Not interested" lets a viewer, in TikTok's December 2021 words, "automatically skip future videos from that same creator or using the same audio". In ByteDance's Deep Retrieval production experiment (2020), on a short-video platform the paper doesn't name, labels "indicate whether a user has finished watching the video or not", and its reported metrics are video finish rate, app view time and second-day retention.

DiagramFrom playback events to the labels of one impression
%% title: From playback events to the labels of one impression
flowchart TD
  EV@{ icon: "sd:phone", label: "Playback events<br/>impression id, sequence, position" }
  DED["Ingester<br/>keep highest sequence per impression"]:::service
  CLOSE{"Impression closed?<br/>next swipe or time-out"}
  WAIT["Keep open<br/>progress so far"]:::service
  LAB["Labels<br/>watch time, ratio, finish, loops, quick skip"]:::core
  TAP["Taps<br/>like, share, comment, follow, not interested"]:::core
  OUT@{ icon: "tech:kafka", label: "Kafka<br/>labels by impression id" }
  EV ==> DED ==> CLOSE
  CLOSE -->|"no"| WAIT
  WAIT -.-> CLOSE
  CLOSE ==>|"yes"| LAB ==> OUT
  TAP ==> OUT
  class EV client
  class OUT queue
  • The impression is the unit. The feed service assigns an impression id to each video it returns; every event and tap carries it, and labels join to features by it, never by (user, video).
  • Normalise by duration bucket. Keep a running percentile of watch time per duration bucket (0–10 s, 10–20 s, …) and label "watched more than typical for this length", so the model doesn't learn that long, or short, videos are simply better.
  • Quick skips are labels, not noise. A skip under a threshold is an explicit negative for the video, creator, sound and topic: the main way the system learns what someone doesn't want, since few people tap "Not interested".
  • Not interested is the strongest negative, and an immediate filter: the creator and sound join the session's exclusion list on the next request.
  • Late taps. A follow from the creator's profile later still carries the impression id that led there; Deep Dive 2 handles the timing.
pythonclose_impression · 30 lines
# labels/impression.py

"""
Labels for one impression from playback events (illustrative). Events carry an
impression id and a sequence number, so resends and reordering can't inflate watch
time; watch time is judged against videos of the same length.
"""

QUICK_SKIP_S = 2.0
MAX_RATIO = 3.0                         # cap loops so one stuck phone can't dominate


def close_impression(events: list, duration_s: float, typical_s_for_bucket: float) -> dict:
    latest = {}
    for e in events:                    # dedupe: keep the last state per sequence number
        latest[e["seq"]] = e
    ordered = [latest[k] for k in sorted(latest)]
    loops = sum(1 for e in ordered if e["type"] == "loop")
    last_pos = max((e["pos_s"] for e in ordered if "pos_s" in e), default=0.0)
    watched_s = loops * duration_s + last_pos
    taps = {e["type"] for e in ordered if e["type"] in ("like", "share", "comment", "follow", "not_interested")}
    return {
        "watch_s": watched_s,
        "watch_ratio": min(watched_s / duration_s, MAX_RATIO),
        "finished": loops > 0 or last_pos >= 0.95 * duration_s,
        "loops": loops,
        "quick_skip": watched_s < QUICK_SKIP_S and not taps,
        "above_typical": watched_s > typical_s_for_bucket,     # the duration-normalised label
        **{t: (t in taps) for t in ("like", "share", "comment", "follow", "not_interested")},
    }

Deep Dive 2: The Real-Time Training Loop (Week 8 Concepts: Streaming vs Batch, Late Data; Week 3: Backpressure)#

You

"Most recommendation systems train in batches: a day of logs, overnight training, a morning deploy. By then the people who loved a sound at 9 pm have moved on, and last night's new user is still a stranger. Here, training never stops: the deployed model keeps learning from the stream."

This builds on Week 8, Day 2 · Streaming vs Batch Processing, Week 8, Day 4 · Late Data and Week 3, Day 3 · Backpressure and Flow Control.

Your turn · checkpoint 4not answered

The follow that comes 45 minutes later

At 21:00:03 the feed serves Aditi a baker's video, impression r9/2, and logs its features. At 21:00:21 she swipes away after a full watch and a loop. At 21:45 she follows the baker from her history. Only a few per cent of impressions get a like. When should the joiner emit r9/2's example, and what happens to the follow? If you keep only one negative in ten, what happens to predicted probabilities? And when the trainer falls 20 minutes behind?

Think it through, or jot it down. Then compare.

One strong answer

  • Emit when the watch labels settle: at the swipe, seconds after serving. Waiting for every possible late label would make every example late.
  • The follow is a late label. Features waiting longer sit in an on-disk store, as in Monolith; the follow finds them there and emits a follow-only example, so the watch heads aren't counted twice.
  • Negative sampling biases the odds. Keeping 1 negative in 10 makes the odds of a positive look 10 times higher than they are. The fix is the log odds correction Monolith applies at serving: multiply the predicted odds by the sampling rate, so p = p′r ÷ (p′r + 1 − p′).
  • Behind by 20 minutes: Kafka buffers, so nothing is lost; the trainer may skip to the newest data, and the batch store keeps everything.

What is published: ByteDance's Monolith paper (which doesn't name TikTok) uses "one Kafka queue to log actions of users" and "another Kafka queue for features", joined by "a Flink streaming job for online feature Joiner" that "concatenates features with labels from user actions and produces training examples", written to a Kafka queue read directly by online training and dumped to HDFS for batch training. Actions and features arrive "without guarantee in time order", so each request has a unique key, and because a user may act days later, "an on-disk key-value storage is utilized to store features that are waiting for over certain time period", checked after the in-memory cache. The joiner does negative sampling, and serving applies "log odds correction" so that "the online model is an unbiased estimator of the original distribution". Batch training reads the data in "one pass", for when "we modify our model architecture and retrain the model". The open-source repository says real-time training captures "the latest hotspots".

DiagramThe streaming engine: from served features and actions to examples
%% title: The streaming engine: from served features and actions to examples
flowchart TD
  FEED["Feed service<br/>features served, by request key"]:::service
  APP@{ icon: "sd:phone", label: "App<br/>watch, loop, skip, taps" }
  FK@{ icon: "tech:kafka", label: "Kafka · features" }
  AK@{ icon: "tech:kafka", label: "Kafka · actions" }
  J@{ icon: "tech:flink", label: "Flink joiner<br/>in-memory cache" }
  KV@{ icon: "sd:database", label: "On-disk KV<br/>features waiting longer" }
  NS["Negative sampling<br/>keep rate r, recorded"]:::core
  EK@{ icon: "tech:kafka", label: "Kafka · training examples" }
  ON["Online trainer<br/>reads the stream"]:::core
  HD@{ icon: "sd:object-storage", label: "HDFS dump<br/>batch retraining" }
  FEED ==> FK ==> J
  APP ==> AK ==> J
  J -.->|"miss"| KV
  J ==> NS ==> EK
  EK ==> ON
  EK --> HD
  class APP client
  class FK,AK,EK queue
  class J service
  class KV,HD store
  • Two clocks inside the joiner. Fast labels (watch, loop, skip, like) settle when the impression closes: emit then. Slow labels (a follow from the profile, a later share) become separate, head-specific examples whenever they arrive, up to a horizon such as a day; each head's loss counts only examples carrying its label.
  • The joiner is a windowed stream join keyed by impression id: features in memory for 15 minutes (≈ 900 GB at peak, on the estimate), then on disk for a day, with a watermark per head. Labels that arrive before their features wait briefly, then drop.
  • Sample negatives, then correct. For sparse heads like "like", keep each negative with probability r, record r on the example, and multiply the predicted odds by r at serving. Watch-time heads have a value for every impression: don't sample them.
  • Freshness beats completeness online. If the trainer lags, read the newest data first and let the batch path pick up what was skipped.
  • Batch is still there. A new architecture is trained first in one pass over weeks of HDFS dumps, then switched to the stream where the dumps end.
pythonJoiner · 49 lines
# joiner/join.py

"""
A windowed join of served features and labels (illustrative). Features wait in
memory, then on disk; fast labels emit an example when the impression closes, slow
labels emit head-specific examples later. Negatives are sampled with a recorded
rate and corrected at serving.
"""

import random

MEMORY_S = 15 * 60
SLOW_HEADS = ("follow", "late_share", "not_interested")


class Joiner:
    def __init__(self, kv, out, keep_negative: float = 0.1):
        self.mem, self.kv, self.out, self.r = {}, kv, out, keep_negative

    def on_features(self, key: str, feats: dict, now: float):
        self.mem[key] = (feats, now)

    def evict(self, now: float):                          # waiting longer: to disk for a day
        for key, (feats, t) in list(self.mem.items()):
            if now - t > MEMORY_S:
                self.kv.put(key, self.mem.pop(key)[0], ttl_s=86_400)

    def _features(self, key: str):
        hit = self.mem.get(key)
        return hit[0] if hit else self.kv.get(key)       # memory first, then disk

    def on_closed_impression(self, key: str, labels: dict):
        feats = self._features(key)
        if feats is None:
            return "orphan"                                # held briefly elsewhere, then dropped
        if not labels["like"] and random.random() > self.r:
            labels = {**labels, "like": None}              # like head skips this example
        self.out.send({"key": key, "features": feats, "labels": labels, "keep_rate": self.r})

    def on_slow_label(self, key: str, head: str, value: bool):
        feats = self._features(key)
        if feats is not None and head in SLOW_HEADS:
            self.out.send({"key": key, "features": feats, "labels": {head: value}, "keep_rate": 1.0})


def corrected(p_sampled: float, keep_rate: float) -> float:
    """Log odds correction: undo the inflation from keeping only a fraction of negatives."""
    odds = p_sampled / (1 - p_sampled) * keep_rate
    return odds / (1 + odds)
Quick check3 questions · +5 XP each

Signals and the training loop

  1. Q1/3 A 45-second video is skipped at 0.8 seconds. What is it to the model?

    Answer & explanation

    Answer: B

    A sub-second skip is a decision. It's the main way the system learns what someone doesn't want.

  2. Q2/3 A follow arrives 45 minutes after the impression that led to it. What does the joiner do?

    Answer & explanation

    Answer: C

    Monolith moves features waiting longer to an on-disk store; slow labels train only their own head.

  3. Q3/3 Negatives are kept at rate 0.1 for the like head. The model predicts 0.5. What is the corrected probability?

    Answer & explanation

    Answer: C

    Odds 1 × 0.1 = 0.1; p = 0.1 ÷ 1.1 ≈ 0.09.

Deep Dive 3: Collisionless Embedding Tables (Week 1 Concepts: Partitioning, Hot Keys)#

You

"A recommendation model learns a vector per user, video, creator and sound. The usual trick hashes IDs into a fixed table and accepts that some share a row. With a catalogue growing every second, that quietly mixes a viral video with spam. ByteDance's Monolith paper stores embeddings in a hash table that never collides, and gets strict about what gets in and how long it stays."

This builds on Week 1, Day 1 · Partitioning Deep-Dive and Week 1, Day 4 · Hot Keys and Skew.

Your turn · checkpoint 5not answered

A billion IDs, one fixed table

A video table hashes IDs into 2²⁵ rows, about 33 million, for hundreds of millions of videos; the kunafa video shares its row with a spam video. What does the model learn for that row? If you key a table by the full ID, what stops it growing forever when most IDs appear a handful of times? What does Aditi's user ID map to on her first swipe? And when a viral video is in 50,000 examples a second, which machine suffers?

Think it through, or jot it down. Then compare.

One strong answer

  • Collisions blend. A shared row gets the gradients of everyone in it: the kunafa video inherits some of the spam video's poor watch time. The more IDs, the more sharing.
  • Admission and expiry: admit an ID only after a threshold number of occurrences (counted in a small probabilistic filter), and expire it after a per-table idle period.
  • Aditi's first swipe: her ID reads a default vector; her categories, language and session carry her until it earns a row, minutes later.
  • The viral video: its row's shard takes every update. Combine its gradients inside each worker's batch, and cache hot rows in the feed services.

What is published: In ByteDance's Monolith paper (which doesn't name TikTok), a first principle is "to avoid cramping information from different IDs into the same fixed-size embedding". Its HashTable uses a cuckoo hashmap, "which supports inserting new keys without colliding with existing ones", with worst-case O(1) lookups. Because "popular IDs may occur millions of times while the unpopular ones appear no more than ten times", and stale IDs such as "a short-video that is out-of-date" rarely help, IDs are admitted only after a per-model occurrence threshold plus "a probabilistic filter", and "set to expire after being inactive for a predefined period of time", tunable per table. Hashing MovieLens IDs into a smaller space collided 7.73% of user IDs and 2.86% of movie IDs, and the collisionless model had higher AUC from the first epoch; on a production model with "around 1000 embedding tables", whose baseline compressed a 2⁴⁸ ID space into 2²⁵ rows, the collisionless table scored higher online AUC over the days measured. Parameters live on parameter servers in TensorFlow's worker and parameter-server setting.

DiagramThe life of one ID in a collisionless table
%% title: The life of one ID in a collisionless table
stateDiagram-v2
  [*] --> UNSEEN
  UNSEEN --> COUNTING: first occurrence
  COUNTING --> COUNTING: seen again, below threshold
  COUNTING --> ADMITTED: threshold reached, cuckoo insert
  ADMITTED --> ADMITTED: trained, synced to serving
  ADMITTED --> IDLE: no examples for a while
  IDLE --> ADMITTED: seen again
  IDLE --> EXPIRED: table's expiry passed
  EXPIRED --> COUNTING: comes back later
  COUNTING --> [*]: never reaches threshold
  class ADMITTED ok
  class EXPIRED bad
  class COUNTING queue
  • Cuckoo hashing, briefly. Two arrays, two hash functions; a key lives in one of its two slots, and inserting into an occupied slot evicts the occupant to its other slot (a cycle triggers a rehash). Lookups check two places. The point isn't speed: two IDs never share a vector.
  • Admission is the memory budget. With a threshold of, say, 5, a video shown three times never costs a row; until admitted, its side features (creator, sound, hashtags, duration), themselves admitted IDs, describe it.
  • Expiry per table. Video rows can expire after a few idle weeks; user rows should last longer, since a person returning after two months is mostly the same person.
  • Partition by (table, ID). A hash of table and ID spreads rows evenly across parameter-server shards; huge tables (users, videos) span many shards, small ones live on one.
  • Hot rows are a skew problem. A viral video or trending sound is in a large share of examples: combine its gradients per worker batch before pushing, and keep an LRU of hot rows in the feed service, refreshed on each sync.
pythonCollisionlessTable · 32 lines
# embeddings/table.py

"""
A collisionless embedding table with admission and expiry (illustrative). Rows are
keyed by the full ID in a cuckoo-hash map; an ID earns a row only after it has been
seen enough times (counted in a count-min sketch), and loses it when idle too long.
"""

import time


class CollisionlessTable:
    def __init__(self, cuckoo_map, sketch, dim: int, admit_after: int, expire_s: float):
        self.rows, self.seen = cuckoo_map, sketch        # full 64-bit ID -> (vector, last_seen)
        self.dim, self.admit_after, self.expire_s = dim, admit_after, expire_s
        self.default = [0.0] * dim

    def lookup(self, id_: int, training: bool) -> list:
        row = self.rows.get(id_)
        if row is not None:
            self.rows.put(id_, (row[0], time.time()))
            return row[0]
        if training and self.seen.add_and_count(id_) >= self.admit_after:
            vec = [0.0] * self.dim                       # admitted: trained from here on
            self.rows.put(id_, (vec, time.time()))
            return vec
        return self.default                              # not yet: side features carry it

    def sweep(self, now: float):
        for id_, (_, last) in self.rows.items_snapshot():
            if now - last > self.expire_s:
                self.rows.delete(id_)

Deep Dive 4: From Trainer to Server: Parameter Sync and Feature Consistency (Week 5 Concepts: Consistency Models; Week 10: Deployment Strategies)#

You

"The trainer now knows that Aditi likes kunafa; the servers ranking her feed don't, until we copy what changed. Copying terabytes every minute is impossible and stopping serving is unacceptable. So we copy only what moved, and decide on purpose which parts of the model may be out of date with each other."

This builds on Week 5, Day 1 · Consistency Models and Week 10, Day 3 · Deployment Strategies.

Your turn · checkpoint 6not answered

Several terabytes, every minute?

The serving copy of the model is 3 TB, nearly all embedding rows; about 25 million rows were trained in the last minute, and every example nudges the dense weights. How do you get the new knowledge to serving every minute without stopping it or doubling its memory? Is it acceptable to serve embeddings from 21:04 with dense weights from last night? A training parameter server dies: what did you lose? And how do you make sure a feature means the same thing in training as it did in serving?

Think it through, or jot it down. Then compare.

One strong answer

  • Push only touched rows. Each training shard tracks the IDs it updated since the last sync and sends just those rows (about 3.2 GB a minute on the estimate) to serving, which overwrites them row by row while it keeps serving.
  • Mixed versions are a deliberate trade. Dense weights move slowly; one user's or video's embedding can change a lot in a minute. Sync sparse every minute and dense daily: Monolith reports no conspicuous loss.
  • Snapshot daily. A failed shard loses up to a day of updates for its IDs, which Monolith found tolerable.
  • Same feature, same value: training reads the values serving logged, never recomputes them.

What is published: The production models in ByteDance's Monolith paper (which doesn't name TikTok) are "usually several terabytes in size"; serving must not stop, and shipping the whole model "requires doubled model size of memory". Since sparse parameters dominate the model, only a few IDs are trained in a short window, and dense variables "move much slower than sparse embeddings", it keeps "a hash set of touched keys" and pushes those rows "with a minute-level time interval", while dense parameters sync at "day-level", "when the traffic is lowest (e.g. midnight)", with "no conspicuous loss" from the mismatch. On the Criteo ads dataset, a simulated online model's average AUC was 79.66 with a 5-hour sync, 79.78 with 1 hour and 79.80 with 30 minutes, against about 79.43 for batch training; a live A/B test on a production ads model showed "a significant bump in online serving AUC". Training parameter servers are snapshotted daily: at a 0.01% daily failure rate per machine across 1,000 servers, one fails about every 10 days, which in the paper's example of 15 million daily users loses a day's feedback from 15,000 of them; "a model from the previous day works embarrassingly well".

DiagramTouched rows every minute, dense weights every night
%% title: Touched rows every minute, dense weights every night
flowchart TD
  TW["Training workers<br/>read the example stream"]:::service
  TPS["Training PS<br/>rows + optimiser state"]:::core
  TK["Touched-key set<br/>IDs updated since last sync"]:::hot
  SPS@{ icon: "sd:servers", label: "Serving PS, each region<br/>rows overwritten in place" }
  DN@{ icon: "sd:cron", label: "Dense weights<br/>daily, quietest hour" }
  SNAP@{ icon: "sd:object-storage", label: "Daily snapshot<br/>restore a failed shard" }
  FS["Feed service<br/>hot-row cache"]:::service
  TW ==> TPS
  TPS ==> TK
  TK ==>|"every minute"| SPS
  TPS -.-> DN
  DN -.-> SPS
  TPS -.-> SNAP
  SPS ==> FS
  class SPS store
  class DN service
  class SNAP store
  • A sync is a stream of row upserts and deletes. Each push carries (table, ID, vector, sync sequence); expired IDs travel as deletes. Each row is replaced atomically, so a lookup gets the old vector or the new one, never half of each; a whole minute's batch needn't be atomic.
  • That's a consistency model, chosen. Within a row: atomic. Across rows of one sync: eventual, within seconds. Across sparse and dense: bounded staleness of a day. Across regions: the same stream applied in order, so all converge.
  • Read your own swipes. The model's minute-level sync can't promise that Aditi's next page reflects the swipe she made two seconds ago. The session can: the app attaches her last few actions to every feed request, so the newest signal never depends on the pipeline's lag.
  • One definition of every feature. Features are defined once in a shared library used by the feed service; the feed service logs the values it computed; training uses the logged values. Training-serving skew is a correctness bug, not a tuning problem.
  • Deploying a new model is a migration. Train it in batch over dumped examples, catch it up on the stream, serve it to a small slice beside the current one, compare finish rate, watch time and next-day return, then ramp. Rollback is cheap: the old model kept training online throughout.
pythonTrainingShard · 38 lines
# sync/delta.py

"""
Incremental parameter sync (illustrative). Training shards record which IDs they
updated; every minute only those rows go to serving, applied row by row while it
keeps serving. Dense weights go once a day, at the quietest hour.
"""


class TrainingShard:
    def __init__(self, rows, publisher):
        self.rows, self.pub = rows, publisher       # (table, id) -> (vector, optimiser_state)
        self.touched, self.seq = set(), 0

    def apply_gradient(self, key, grad, lr):
        self.rows[key] = optimiser_step(*self.rows[key], grad, lr)
        self.touched.add(key)

    def sync_sparse(self):                          # called every 60 s
        self.seq += 1
        upserts = [(k, to_fp16(self.rows[k][0])) for k in self.touched if k in self.rows]
        deletes = [k for k in self.touched if k not in self.rows]     # expired since
        self.pub.send({"seq": self.seq, "upserts": upserts, "deletes": deletes})
        self.touched.clear()


class ServingShard:
    def __init__(self):
        self.rows, self.applied_seq = {}, 0

    def on_sync(self, msg: dict):
        if msg["seq"] <= self.applied_seq:
            return                                  # a replay: already applied
        for k, vec in msg["upserts"]:
            self.rows[k] = vec                      # one atomic replace per row
        for k in msg["deletes"]:
            self.rows.pop(k, None)
        self.applied_seq = msg["seq"]
Quick check3 questions · +5 XP each

Embeddings and sync

  1. Q1/3 A video is shown three times and never again. What does it cost a collisionless table with an admission threshold of 5?

    Answer & explanation

    Answer: A

    Admission by occurrence count is the memory budget; most long-tail IDs never earn a row.

  2. Q2/3 Serving uses embeddings synced at 21:04 and dense weights from last night. Is that a bug?

    Answer & explanation

    Answer: B

    Monolith syncs sparse at minute level and dense at day level, and reports no conspicuous loss.

  3. Q3/3 Aditi skipped a video 2 seconds ago. What guarantees her next page knows?

    Answer & explanation

    Answer: C

    Read-your-own-writes comes from the session path, not from the model's sync.

Deep Dive 5: Cold Start: New Users and New Videos (Week 4 Concepts: Caching Patterns; Week 7: Relevance)#

You

"There are two cold starts with opposite shapes. A new user is one person meeting a huge catalogue: learn her fast. A new video is one item meeting a billion people: find its audience without wasting impressions. A new video from a new creator is where a follower feed is weakest and a For You feed wins."

This builds on Week 4, Day 1 · Caching Patterns and Week 7, Day 3 · Query Processing and Relevance.

Your turn · checkpoint 7not answered

Aditi's first page, and her kunafa's first hour

Aditi has just picked Food and Travel, and her user ID has no embedding row. What's on her first page, and how many swipes before it's hers? That evening she uploads a video from an account with no followers. Who sees it first, how many people, and what tells you to show it to more? What stops a spammer's 500 near-identical uploads getting the same chance?

Think it through, or jot it down. Then compare.

One strong answer

  • First page: cached lists of popular, broadly suitable videos per category, region and language, refreshed every few minutes, each video from a different topic so every swipe teaches something. Nothing depends on her ID.
  • Personal within a session: session features change the next request within seconds; after a handful of examples her ID is admitted, trained and synced within a minute or two.
  • The video's first audience: once eligible (processed, scanned, reviewed where needed), a few hundred viewers chosen by its content and by lookalikes of its first engagers.
  • Expand on evidence: if its labels beat what's normal for its category at that stage with enough confidence, a larger pool; if not, it rests. Follower count isn't the gate.
  • Spam: spam is ineligible, and near-identical uploads are matched by fingerprint and share one test budget.

What is published: TikTok invites new users "to select categories of interest, like pets or travel", otherwise starting with "a generalized feed of popular videos", which its help centre says is "appropriate for a broad audience and influenced by your location and language settings"; then "your first set of likes, comments, and replays will initiate an early round of recommendations". "Neither follower count nor whether the account has had previous high-performing videos are direct factors", videos "that have just been uploaded or are under review" may be ineligible, and the safety team reviews videos "as they rise in popularity". ByteDance's Deep Retrieval test, on a short-video platform the paper doesn't name, found the method "more friendly to less popular videos or less popular creators". Next-User Retrieval (2025) attacks item cold start on Douyin, "a platform with over 600 million daily active users": from the sequence of users who recently engaged with a new item, it generates "the next potential user who is most likely to interact with the item", with the item's features as a prompt so that "items with no interaction can generate the first user"; it reported +0.0142% daily active users and +0.1144% publications in A/B tests and was "integrated into Douyin's main recommendation system".

DiagramA new video, from upload to the open feed
%% title: A new video, from upload to the open feed
stateDiagram-v2
  [*] --> UPLOADED
  UPLOADED --> PROCESSING: transcode, fingerprint
  PROCESSING --> UNDER_REVIEW: automated scans
  UNDER_REVIEW --> REMOVED: violates guidelines
  UNDER_REVIEW --> NOT_RECOMMENDED: FYF ineligible
  UNDER_REVIEW --> TEST_POOL: eligible
  TEST_POOL --> WIDER_POOL: beats its category
  TEST_POOL --> RESTING: below its category
  WIDER_POOL --> OPEN: keeps beating it
  WIDER_POOL --> RESTING: falls off
  OPEN --> UNDER_REVIEW: rising fast, re-checked
  RESTING --> [*]
  REMOVED --> [*]
  class OPEN ok
  class REMOVED bad
  class UNDER_REVIEW queue
  • New users run on features, not IDs. Until her ID is admitted, Aditi is her categories, language, country and, above all, her session; the ranker has learnt from millions of other first sessions how to read a short history.
  • The seed lists are a cache. "Popular in Food, UAE, English" is the same for every new user there: compute it every few minutes, filter for broad suitability, and serve it cache-aside without touching the ranker.
  • Explore on purpose in the first minutes. Spread the first pages across topics so each swipe separates hypotheses.
  • New videos get a budget, not a guess. A small number of impressions with likely viewers; its rates are a Beta posterior compared with its category's prior, so promotion is a statistical decision.
  • Stages, not a cliff. Small pool, larger pool, open feed: more reliable evidence and a higher safety bar at each step, with a re-review before millions.
  • The video's own ID joins late. Until it's admitted (Deep Dive 3), its sound, hashtags, creator and content embedding stand in.
pythonp_beats · 29 lines
# coldstart/explore.py

"""
Staged exposure for new videos (illustrative). A video's "watched more than typical"
rate is a Beta posterior compared with its category's prior; it moves to a bigger
pool when it probably beats the category, and near-duplicates share one budget.
"""

import random

BUDGET = {"test": 300, "wider": 5_000}
NEXT = {"test": "wider", "wider": "review, then open"}


def p_beats(succ: int, n: int, a: float, b: float, draws: int = 2000) -> float:
    wins = sum(random.betavariate(a + succ, b + n - succ) > random.betavariate(a, b)
               for _ in range(draws))
    return wins / draws


def decide(video, stats, prior) -> str:
    if video.duplicate_of_one_already_tested:
        return "resting"
    if stats.impressions < BUDGET[video.stage]:
        return video.stage                          # still collecting evidence
    p = p_beats(stats.above_typical, stats.impressions, *prior[video.category])
    if p >= 0.8:
        return NEXT[video.stage]
    return "resting" if p < 0.3 else video.stage

Deep Dive 6: Serving the Feed: Funnel, Diversity, Safety and Instant Playback (Week 4 Concepts: Multi-Tier Caching, Thundering Herd; Week 10: Capacity Planning)#

You

"The funnel is the same shape as YouTube's or Instagram's. Three things differ in a full-screen swipe feed: pages are small and taste is moving, so we re-rank often; the order matters as much as the set, because of the diversity and safety rules; and the next video must already be on the phone."

This builds on Week 4, Day 5 · Multi-Tier Caching, Week 4, Day 3 · Thundering Herd and Week 10, Day 4 · Capacity Planning.

Your turn · checkpoint 8not answered

150 ms and a thumb

Aditi's app asks for 6 more videos with 3 still buffered; you have about 150 ms. The ranker's top 6: two from the same baker back to back, three weight-loss "what I eat in a day" videos, and one she watched last week. What do you return, and how do you enforce the rules without throwing relevance away? How does the video after the buffer start instantly? And what protects the CDN origin when a video goes viral in the Gulf?

Think it through, or jot it down. Then compare.

One strong answer

  • The 150 ms: candidate sources in parallel (embedding neighbours, learnt paths, follows, fresh in region, exploration, similar to her last loops), filters, a light pre-ranker to ~300, the heavy ranker (~50 ms on accelerators), then a re-rank (~5 ms).
  • The re-rank: pick greedily under constraints: never the same creator or sound as the previous video, at most one dieting video in a window of several, never a seen video, one exploration slot. The second baker video moves down; two dieting videos drop out; last week's is filtered.
  • Instant start: the app holds a few videos and downloads the first second of each while the current one plays.
  • The viral video: edges coalesce misses into one origin fetch, and fast risers are pushed to their region's edge tier early.

What is published: ByteDance's Deep Retrieval paper, about a short-video platform it doesn't name, says "fine ranking usually handles only hundreds of items for each user request" and "multiple candidate generation sources are used at the same time to produce a diverse set of candidates"; rebuilding its learnt structure over all items takes "about 10 minutes" on CPUs. TikTok's feed "generally won't show two videos in a row made with the same sound or by the same creator", and skips "duplicated content, content you've already seen before, or any content that's considered spam". Its For You feed eligibility standards (August 2025; the August 2026 update keeps the wording) aim "to prevent our systems from repeatedly recommending content that could lead to a negative experience, such as extreme fitness or dieting, sexual suggestiveness, sadness, or overgeneralized mental health advice", and note that videos left out may still be found through search or the creator's account. As shown on TikTok's Transparency Center in October 2026, 97%+ of violative video removals were automated, 99%+ of violative content removed was taken down before anyone reported it, and 96%+ of user-flagged violative videos were removed in under 2 hours. TikTok hasn't published how it stores, transcodes or delivers video; Bonus Problem 19 (YouTube) covers that pipeline.

DiagramOne feed request, from sources to a playable page
%% title: One feed request, from sources to a playable page
flowchart TD
  REQ@{ icon: "sd:phone", label: "Feed request<br/>3 buffered, last 10 actions" }
  SRC["Candidate sources, in parallel<br/>embedding ANN · learnt paths · follows · fresh · explore"]:::service
  FIL["Filters<br/>seen, blocked, ineligible, not interested"]:::service
  PRE@{ icon: "sd:ml", label: "Pre-ranker<br/>2,000 to 300" }
  RANK@{ icon: "sd:ml", label: "Multi-task ranker<br/>watch, finish, loop, like, follow, skip" }
  RR["Re-rank<br/>creator, sound, theme limits, explore slot"]:::hot
  PAGE@{ icon: "sd:feed", label: "Page of 6<br/>impression ids, logged features" }
  CDN@{ icon: "sd:cdn", label: "CDN<br/>first second prefetched" }
  REQ ==> SRC ==> FIL ==> PRE ==> RANK ==> RR ==> PAGE ==> CDN
  class REQ client
  class PRE,RANK service
  class PAGE service
  class CDN edge
  • One value per video: a weighted sum of the predicted labels, negative on quick skip and "Not interested", with weights set by experiments on long-run outcomes such as next-day return.
  • Greedy re-rank under constraints. Take the best candidate that breaks no rule given the page so far and her last videos; if nothing fits, relax the weakest rule first.
  • Theme limits need a theme. Classifiers and review label each video's topic and sensitivity; a window over her recent feed caps sensitive themes across pages, which is the "clusters" problem TikTok described.
  • Eligibility is a filter, not a feature. A video under review or failed isn't a low score, it's absent; removals reach filters and indexes in seconds and invalidate cached lists.
  • Seen means seen. A per-user Bloom filter of recently seen video IDs is checked in the filter stage.
  • Prefetch is a bandwidth bet. The first second of each buffered video costs a few hundred KB; prefetch more on Wi-Fi, less on metered or slow links, and start at a lower rendition when bandwidth is poor.
  • Uploads, briefly. An upload is transcoded into a few renditions with a small first segment, fingerprinted, scanned, and only then enters the eligibility state machine; segments are served from edge, regional and origin tiers.
  • Capacity follows the funnel. Ranker accelerators scale with requests × candidates (~52 million scores a second at peak, on the estimate), parameter servers with model size and lookups, the CDN with bytes watched; each gets its own forecast and headroom.
pythonrerank · 36 lines
# feed/rerank.py

"""
Greedy re-ranking under diversity and safety constraints (illustrative). The ranked
list keeps its order where it can; a candidate is skipped when it would repeat the
previous creator or sound, exceed a sensitive theme's cap in the recent window, or
fill a slot reserved for exploration.
"""

THEME_CAP = {"dieting": 1, "sadness": 1}      # at most this many per window
WINDOW = 8


def rerank(ranked: list, recent: list, page_size: int, explore: list) -> list:
    page, history = [], list(recent[-WINDOW:])
    explore_slot = page_size // 2                 # one slot for a new video, mid-page

    def ok(v) -> bool:
        prev = history[-1] if history else None
        if prev and (v.creator == prev.creator or v.sound == prev.sound):
            return False
        if v.theme in THEME_CAP:
            if sum(1 for h in history[-WINDOW:] if h.theme == v.theme) >= THEME_CAP[v.theme]:
                return False
        return True

    pool = list(ranked)
    while len(page) < page_size and pool:
        source = explore if len(page) == explore_slot and explore else pool
        pick = next((v for v in source if ok(v)), None)
        if pick is None:
            pick = next((v for v in pool if v.theme not in THEME_CAP), pool[0])  # relax the weakest rule
        source.remove(pick) if pick in source else pool.remove(pick)
        page.append(pick)
        history.append(pick)
    return page
Quick check3 questions · +5 XP each

Cold start and serving

  1. Q1/3 A new user picked no categories. What does TikTok say it starts with?

    Answer & explanation

    Answer: B

    Then the first likes, comments and replays start the personalised recommendations.

  2. Q2/3 A new video from an account with no followers performs well with its test audience. What decides whether it gets more?

    Answer & explanation

    Answer: B

    TikTok says follower count is not a direct factor; evidence from the test pool is.

  3. Q3/3 The top two ranked videos are by the same creator. What does the re-rank do?

    Answer & explanation

    Answer: C

    TikTok's feed generally won't show two videos in a row from the same creator or sound.

Phase 5 · Stage 5 of 7 · 5 min budget · 2 min read

Phase 5: Scaling and Edge Cases (5 minutes)#

Interviewer

"Walk me through Aditi's evening from the system's side, then the cases that bite."

Aditi's Evening#

Aditi's first evening and her first video (the new-user steps follow TikTok's published description; times and the rest are illustrative)
  • 19:02 · Install
    • She picks Food and Travel; her first page comes from cached popular lists for the UAE, each video from a different topic
  • 19:03 · Ten swipes
    • A double loop on kunafa, a 0.8 s skip on dance; page two leans to desserts within seconds
  • 19:05 · Her ID earns a row
    • Her user ID passes the admission threshold, is trained, and reaches serving in the next minute's sync
  • 21:00 · Upload
    • Her kunafa video is transcoded, fingerprinted and scanned, becomes eligible and enters a test pool
  • 21:10 to 22:30 · Test pool, then wider
    • A few hundred viewers loop it more than is typical for its length; it moves to the wider pool
  • Next morning · Open feed
    • After a re-check as it rises, it reaches people who have never heard of her

Edge Cases#

Edge cases
Edge case 1: The learning loop stalls

Problem: The joiner falls an hour behind, or the sync stops. The model being served stops learning.

Solution: Serving keeps running on the last synced model (Monolith found "a model from the previous day works embarrassingly well"), and the session path still personalises. Alert on sync age and trainer lag; on recovery, read newest data first and let batch backfill.

Edge case 2: A bad update spreads in minutes

Problem: A training bug produces exploding or NaN embeddings. With minute-level sync, every region serves them a minute later.

Solution: Validate every sync batch on the serving side: reject NaN rows and norms far outside the table's range, and pause a shard that sends many. Roll back from the daily snapshot plus the last good syncs. Speed of learning is also speed of breaking.

Edge case 3: Engagement that isn't real

Problem: Bots or click farms loop a video to inflate its watch time and push it out of the test pool.

Solution: Weight labels by account and device trust, cap loops per viewer, and drop bot-like accounts' labels before the joiner. TikTok lists videos "seeking to artificially increase traffic" as spam that may be ineligible.

Edge case 4: A feed that narrows

Problem: Aditi loops every sad breakup video for a week. Relevance alone gives her more.

Solution: Theme caps apply across pages, the exploration slot keeps other topics in view, and "Not interested", keyword filters and topic settings act on the next request. TikTok's eligibility standards (2025, kept in 2026) name sadness among the themes its systems aim not to recommend repeatedly; in a regional crisis the same caps can be tightened by a switch, as TikTok says it "may also interrupt repetitive recommendations".

Quick check3 questions · +5 XP each

Scaling and edge cases

  1. Q1/3 The sparse sync has been stopped for 40 minutes. What do users notice?

    Answer & explanation

    Answer: B

    The model goes stale gracefully; alert on sync age long before it matters.

  2. Q2/3 Why validate embedding rows on the serving side of every sync?

    Answer & explanation

    Answer: B

    Real-time learning shortens the time to break as well as the time to learn.

  3. Q3/3 A video's test-pool watch time comes mostly from 50 new accounts looping it. What should happen?

    Answer & explanation

    Answer: B

    Fake engagement is filtered before the joiner, so it neither trains the model nor wins exposure.

Phase 6 · Stage 6 of 7 · 2 min

Phase 6: Monitoring and Operations#

You

"Three questions, all the time: is the loop learning (fresh, joined, synced), is the model any good (online metrics, calibration), and is the feed working (latency, instant playback, rules held)? The first one is the new one."

Monitoring Dashboard#

For You dashboard — 21:30 GST, a weekday evening (illustrative)
The learning loop
Metric Now
Event to training example, p99 11 s
Joiner match rate (impressions with features and labels) 99.1%
Orphan labels dropped 0.3%
Online trainer lag 38 s
Age of last sparse sync, worst shard 71 s
Rows synced per minute 24.6M
Model quality
  • Like head calibration (predicted ÷ observed): 1.01
The feed
  • Feed request p99: 126 ms; ranker accelerators busy: 63%
  • Swipes with no visible start-up delay: 96.4%; cold-open first frame p95: 820 ms
  • New videos in test pools: 2.3M; promoted in the last hour: 41,000
  • Ineligible videos served: 0; same creator or sound back to back: 0

SLOs for the Platform#

SLOs for learning, serving, playback and safety
SLO 1: The model is fresh
  • Target: 99% of impressions trained into the model and synced to serving within 5 minutes; dense weights no older than 36 hours
SLO 2: The feed is fast
  • Target: feed request p99 < 150 ms; 99.9% of requests answered, falling back to cached seed lists rather than failing
SLO 3: Playback is instant
  • Target: 95% of swipes start with no visible delay; cold-open first frame p95 < 1 s
SLO 4: The rules hold
  • Target: zero ineligible videos served; zero back-to-back videos from the same creator or sound; sensitive-theme caps met in 99.99% of pages
Quick check3 questions · +5 XP each

Reading the dashboard

  1. Q1/3 Trainer lag is 38 s but the worst shard's sync age climbs to 25 minutes. Where's the problem?

    Answer & explanation

    Answer: B

    Freshness has two halves: trained, and synced. Watch both.

  2. Q2/3 After a deploy, the joiner match rate falls from 99% to 70%. What's likely wrong?

    Answer & explanation

    Answer: B

    A low match rate means the model is training on a biased minority of examples.

  3. Q3/3 Like-head calibration jumps from 1.01 to 1.9. What's a likely cause?

    Answer & explanation

    Answer: B

    Log odds correction must use the same keep rate the joiner used.

Final challenge · Stage 7 of 7 · 2 min

Final Challenge: A New Song at 9 pm in Jakarta#

The interviewer leans back. "One more."

Interviewer

"A big artist releases a song at 9 pm in Jakarta. Within three hours, two million videos use the sound. At 10 pm your joiner falls 25 minutes behind. At 11 pm a variant of the dance with a dangerous stunt starts spreading. Walk me through what your system does, and what you'd change."

The launch brief
  • A new sound ID at 21:00; ~2 million videos using it by midnight (assumption)
  • In the region, ~15% of all impressions involve the sound by 22:00 (assumption)
  • 22:00: joiner lag reaches 25 minutes; one feature-log partition is far busier than the rest
  • 23:00: a stunt variant appears in a few thousand videos; some are rising fast
  • Diversity rule: no two videos in a row with the same sound
Your turn · checkpoint 9not answered

What do you protect, and what do you change?

Decide how the new sound gets learnt, why the joiner fell behind and how to fix it, what the diversity rule does to everyone's feed tonight, and how the stunt variant is kept from spreading.

Think it through, or jot it down. Then compare.

One strong answer

  • The sound is learnt within minutes. Its ID is admitted in seconds, trains on a flood of examples, and minute-level sync puts it in serving almost at once. A nightly model would know nothing about it until tomorrow.
  • Its row is a hot key. 15% of the region's examples touch one row on one shard: combine its gradients inside each worker's batch, and keep its serving copy in the feed services' hot-row caches.
  • The joiner lag: the feature log was partitioned by video ID, so the viral videos' partition overflowed. Partition by impression ID; meanwhile the trainer reads newest first and batch backfills.
  • The rule holds: fans get the song every other video at most, the published rule doing its job on a night when relevance alone would show nothing else.
  • The stunt: fast-rising videos are re-checked before the open feed; flagged ones are removed or made ineligible, and removals reach candidate filters within seconds. Dangerous activities and challenges have their own section in TikTok's Community Guidelines.
  • Measure: time from the sound's first use to its row in serving, joiner lag per partition, back-to-back sound repeats (zero), and impressions the stunt got before it was stopped.
Quick check3 questions · +5 XP each

Final challenge · Quest check

  1. Q1/3 Why does a nightly-trained model struggle with a song released at 9 pm?

    Answer & explanation

    Answer: B

    Concept drift: the most valuable signals are the newest ones.

  2. Q2/3 One Kafka partition of the feature log is far busier than the rest. What's the fix?

    Answer & explanation

    Answer: B

    A key that follows popularity creates a hot partition; a key per event spreads evenly.

  3. Q3/3 Fans of the song want it in every slot. What does the feed do?

    Answer & explanation

    Answer: B

    The diversity rule is a constraint in the re-rank, applied however strong the relevance.

Wrap-up · 7 min

Interview Conclusion#

Transcript
Interviewer

"Why not train nightly on a bigger cluster? It's simpler."

You

"Because what people want moves faster than a day. A new song, a new creator, a new user: a nightly model has never seen any of them. Monolith's experiments found that a shorter sync interval always helped, and the paper's production models sync sparse parameters every minute. The session path covers the seconds, but only the model can learn that people who loop kunafa also loop knafeh tutorials. The batch path is still there: it's how a new model architecture learns the past."

Interviewer

"Memory is cheap. Why not hash IDs into a big fixed table?"

You

"Because the problem isn't the size, it's the sharing. In a fixed table the popular IDs, which train hardest and matter most, share rows with junk. A collisionless table keyed by the full ID, with admission by count and expiry by idleness, spends memory only on IDs that have earned it, and never mixes two of them."

Interviewer

"The single most important decision?"

You

"Logging exactly the features each impression was ranked with, and turning every impression into a training example as soon as its labels settle. That one stream feeds the online trainer, the batch retrains and the experiments, and it's what lets the model learn from Aditi's swipes within minutes instead of tomorrow."

Summary: Concepts Applied from 10-Week Course#

Concepts from the 10-week course in a real-time recommendation feed
  • Week 1: Data at scale
    • Partitioning and hot keys — rows sharded by (table, ID); viral rows combined per batch and cached in serving
  • Week 2: Failure-first design
    • Idempotency — events with impression ids and sequence numbers; sync messages applied once by sequence
  • Week 3: Messaging and async processing
    • Backpressure — Kafka buffers a lagging trainer; read newest first, backfill in batch
  • Week 4: Caching
    • Seed lists for new users; multi-tier CDN with coalesced misses for viral videos
  • Week 5: Consistency and coordination
    • Consistency models — atomic rows, eventual across a sync, bounded staleness between sparse and dense, read-your-writes from the session
  • Week 7: Search and relevance
    • Retrieve, rank, re-rank — multiple candidate sources, a multi-task ranker, a constrained re-rank
  • Week 8: Analytics pipelines
    • Event ingestion, streaming vs batch, late data — per-impression labels, online and batch training, slow labels from an on-disk store
  • Week 10: Production readiness
    • Deployment and capacity — models ramped beside the old; accelerators, parameter servers and CDN each sized from their own driver

Why TikTok Matters#

Why this is a different kind of hard problem
The feed isn't a graph
  • A follower feed asks "what did the people I follow post?". A For You feed asks "what, out of everything, will this person watch?". TikTok says follower count isn't a direct factor, so a new creator's first video can find an audience on its merits, and every impression is a decision the model makes
The model is never finished
  • User interests drift by the minute, IDs arrive by the second, and labels arrive by the million each second. ByteDance's answer, in its Monolith paper, was to stop treating the model as a nightly artifact and run it as a live, sharded store that is trained and synced continuously
The algorithm is a policy question
  • Governments now treat recommendation systems as something to regulate. India blocked TikTok with 58 other apps on 29 June 2020, and it remains unavailable there. In the United States, the TikTok USDS Joint Venture announced in January 2026 that it will "retrain, test, and update the content recommendation algorithm on U.S. user data", secured in Oracle's U.S. cloud (as reported by The Register). Designing the loop well now includes being able to say where it learns, from whom, and under which rules

"Learn from the session in seconds, from everyone in minutes, and from the past once a day."

Self-Assessment Checklist#

After studying this case study, you should be able to:

Signals and the loop:

Signals and the loop0/3 ready
  • Turn playback events into per-impression labels, judged against videos of the same length, safe against resends
  • Design a stream join of served features and labels with fast and slow labels, and explain why the features must be logged, not recomputed
  • Apply negative sampling and correct for it at serving with log odds

The model as a live store:

The model as a live store0/3 ready
  • Explain why hashed embedding tables degrade, and design a collisionless table with admission and expiry
  • Sync only touched rows every minute and dense weights daily, and state the consistency each part of the model gets
  • Handle hot rows from viral videos and new sounds on training and serving

Cold start, serving and safety:

Cold start, serving and safety0/3 ready
  • Start a new user from cached seed lists and session features before their ID has a row
  • Give new videos staged exposure based on evidence, not followers, and stop near-duplicates sharing it
  • Re-rank under diversity and safety constraints, and keep the next video already on the phone

Sources#

TikTok:

ByteDance research:

  • Paper

    Zhuoran Liu, Leqi Zou, Xuan Zou, Caihua Wang, Biao Zhang, Da Tang, Bolin Zhu, Yijie Zhu, Peng Wu, Ke Wang and Youlong Cheng — Monolith: Real Time Recommendation System With Collisionless Embedding Table (cuckoo-hash tables with occurrence filters and expiry; Kafka, Flink joiner, in-memory cache and on-disk KV; negative sampling and log odds correction; several-terabyte models; minute-level sparse and day-level dense sync; daily snapshots; MovieLens, production and Criteo results; landed in BytePlus Recommend), ORSUM workshop at ACM RecSys 2022, arXiv:2209.07663, September 2022: https://arxiv.org/abs/2209.07663

  • Read

    ByteDance — Monolith source code and README ("real time training captures the latest hotspots"): https://github.com/bytedance/monolith

  • Paper

    Weihao Gao, Xiangjun Fan, Chong Wang and others — Deep Retrieval: Learning A Retrievable Structure for Large-Scale Recommendations (ByteDance; hundreds of millions of users and short videos; finish-watching labels; multiple candidate sources, fine ranking of hundreds; A/B finish rate +3.0%; friendlier to less popular videos and creators; structure built in about 10 minutes), arXiv:2007.07203, July 2020: https://arxiv.org/abs/2007.07203

  • Paper

    Yu-Ting Lan, Yang Huo, Yi Shen, Xiao Yang and Zuotao Liu — Next-User Retrieval: Enhancing Cold-Start Recommendations via Generative Next-User Modeling (ByteDance; item cold start on Douyin, over 600 million daily active users; +0.0142% DAU, +0.1144% publications; in Douyin's main recommendation system), arXiv:2506.15267, June 2025: https://arxiv.org/abs/2506.15267

Reporting:

  • Read

    Simon Sharwood — Oracle, Michael Dell, named as investors in JV that will run TikTok's US operations (TikTok USDS Joint Venture LLC; will "retrain, test, and update the content recommendation algorithm on U.S. user data", secured in Oracle's U.S. cloud environment), The Register, 23 January 2026: https://www.theregister.com/2026/01/23/jv_announces_tiktok_us_acquisition/

Design note: the architecture, code, thresholds, dashboards and every number labelled as an assumption or estimate are an illustrative design for the problem, not a description of TikTok's internal systems. ByteDance's papers describe its research and production systems without saying which run inside the TikTok app; Next-User Retrieval was tested on Douyin, a separate app. Daily users, watch time, upload volume, request and event rates, candidate counts, table sizes, sync volumes, stage budgets, the dashboard and the final challenge's figures are assumptions.

Further Reading#

Related systems to study:

  • Read

    Instagram Feed and Stories (Bonus Problem 21 in this series): a follower feed with fan-out on write and staged ranking

  • Talk

    YouTube (Bonus Problem 19): uploads, transcoding, view counting and two-stage recommendations at 100 ms

  • Read

    Spotify (Bonus Problem 7): recommendations and streaming for audio

Foundational concepts (this course):

  • Read

    Week 1, Day 1 — Partitioning Deep-Dive

  • Read

    Week 1, Day 4 — Hot Keys and Skew

  • Read

    Week 2, Day 2 — Idempotency in Practice

  • Read

    Week 3, Day 3 — Backpressure and Flow Control

  • Read

    Week 4, Day 1 — Caching Patterns

  • Read

    Week 4, Day 3 — Thundering Herd

  • Read

    Week 4, Day 5 — Multi-Tier Caching

  • Read

    Week 5, Day 1 — Consistency Models

  • Read

    Week 7, Day 3 — Query Processing and Relevance

  • Read

    Week 8, Day 1 — Event Ingestion at Scale

  • Read

    Week 8, Day 2 — Streaming vs Batch Processing

  • Read

    Week 8, Day 4 — Late Data

  • Read

    Week 10, Day 3 — Deployment Strategies

  • Read

    Week 10, Day 4 — Capacity Planning

"Learn from the session in seconds, from everyone in minutes, and from the past once a day."

this lesson in shorts · 4

play them all →

One idea from this lesson in about a minute each, made for your phone.

End of Side quest 30 · TikTok's For You Feed
Clear the questFinish TikTok.Stages clear as you read them. Marking complete unlocks after ~8 more min of reading.
  • 0/7stages cleared
design sheet · 7 diagrams · pdfRevise the whole design in ten minutesThe numbers, architecture, sequence, class and state diagrams, decisions and failure modes of this quest on a few printable pages.open the sheet →

New lessons by email

New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.

Discussion · …

Public: everyone reading this lesson sees comments. Be kind and keep it on the material.

Sign in to ask a question or answer one. Reading stays open to everyone.
loading the discussion…