# Blend ads into the feed

> AdsSource intake, SafeGapAdsBlender versus PartitionOrganicAdsBlender, AdsBlenderType selection, and prompt / who-to-follow / push-to-home insertion.

- Repository: xai-org/x-algorithm
- GitHub: https://github.com/xai-org/x-algorithm
- Human docs: https://grok-wiki.com/public/docs/xai-org-x-algorithm-23c09c39074c
- Complete Markdown: https://grok-wiki.com/public/docs/xai-org-x-algorithm-23c09c39074c/llms-full.txt

## Source Files

- `home-mixer/ads/mod.rs`
- `home-mixer/ads/safe_gap_blender.rs`
- `home-mixer/ads/partition_organic_blender.rs`
- `home-mixer/ads/util.rs`
- `home-mixer/selectors/blender_selector.rs`
- `home-mixer/sources/ads_source.rs`
- `home-mixer/candidate_hydrators/ads_brand_safety_hydrator.rs`

---

---
title: "Blend ads into the feed"
description: "AdsSource intake, SafeGapAdsBlender versus PartitionOrganicAdsBlender, AdsBlenderType selection, and prompt / who-to-follow / push-to-home insertion."
---

`ForYouCandidatePipeline` assembles the Home timeline by fetching organic `ScoredPost`s, ads, prompts, Who to Follow, and push-to-home in parallel, then running `BlenderSelector`. That selector partitions the mixed `FeedItem` stream, chooses `SafeGapAdsBlender` or `PartitionOrganicAdsBlender` from `AdsBlenderType`, and inserts the remaining modules around the blended result.

<Note>
This checkout publishes the blender, source, hydrator, and selector logic. `crate::params` and `crate::clients` (including `AdIndexClient`) are production Home Mixer modules and are not in this snapshot. Local `phoenix/run_pipeline.py` does not run ads blending.
</Note>

```mermaid
flowchart TB
  subgraph Phoenix["PhoenixCandidatePipeline"]
    TopK["TopKScoreSelector"]
    BSH["AdsBrandSafetyHydrator or AdsBrandSafetyVfHydrator"]
    SP["ScoredPost.brand_safety_verdict"]
    TopK --> BSH --> SP
  end

  subgraph External["Unpublished production clients"]
    AdIdx["AdIndexClient.get_eligible_ads"]
    PromptsC["PromptsClient.get_injections"]
    WtfC["WhoToFollowClient.get_wtf_recommendations"]
    Tes["TESClient + ReplyMixerClient"]
  end

  subgraph ForYou["ForYouCandidatePipeline"]
    SPS["ScoredPostsSource"]
    ADS["AdsSource"]
    PR["PromptsSource"]
    WTF["WhoToFollowSource"]
    PTH["PushToHomeSource"]
    BS["BlenderSelector"]
    SPS --> BS
    ADS --> BS
    PR --> BS
    WTF --> BS
    PTH --> BS
  end

  SP --> SPS
  AdIdx --> ADS
  PromptsC --> PR
  WtfC --> WTF
  Tes --> PTH
  BS --> Feed["Vec FeedItem"]
```

`ForYouCandidatePipeline` has empty hydrator, filter, and scorer lists. Organic ranking and brand-safety labels happen in `PhoenixCandidatePipeline` before `ScoredPostsSource` wraps each `ScoredPost` as `feed_item::Item::Post`. After `BlenderSelector`, `CandidatePipeline::execute` truncates to `params::FOR_YOU_MAX_RESULT_SIZE`.

## Ads intake

`AdsSource` is enabled when `EnableAdsSource` is true and `ScoredPostsQuery.is_preview` is false. Preview requests skip ads entirely.

A failed `get_eligible_ads` call returns `Err("AdsSource: …")`. `fetch_candidates` flattens source results, so a failed ads fetch contributes nothing and organic sources still produce a feed.

<ParamField body="EnableAdsSource" type="bool">
Feature-switch key on `query.params`. Combined with `!query.is_preview`.
</ParamField>

<ParamField body="is_preview" type="bool">
Copied from the gRPC `ScoredPostsQuery`. When true, `AdsSource.enable` is false.
</ParamField>

The request sent to Ad Index:

<RequestExample>
```rust AdIndexRequest from AdsSource
AdIndexRequest {
    user_id: query.user_id as i64,
    product_surface: ProductSurface::HomeTimelineRanking as i32,
    client_context: Some(ClientContext {
        user_id: query.user_id as i64,
        app_id: query.client_app_id as i64,
        country_code: query.country_code.clone(),
        language_code: query.language_code.clone(),
        ip_address: query.ip_address.clone(),
        user_agent: query.user_agent.clone(),
        user_roles: query.user_roles.clone(),
        device_id: query.device_id.clone(),
        mobile_device_id: query.mobile_device_id.clone(),
        mobile_device_ad_id: query.mobile_device_ad_id.clone(),
        ..Default::default()
    }),
    ..Default::default()
}
```
</RequestExample>

Each `AdIndexInfo` in `response.ad_info` becomes `FeedItem { position: 0, item: Some(Ad(ad)) }`. Fields the blender later reads:

| Field | Used for |
| --- | --- |
| `insert_position` | Spacing from the first four ads; first ideal gap for `SafeGapAdsBlender` |
| `ad_adjacency_control.brand_safety_risk` | BSR-low / IAS drop in `PartitionOrganicAdsBlender` |
| `ad_adjacency_control.handles` | Adjacent-author drop |
| `ad_adjacency_control.keywords` | Adjacent-text drop |
| `post_id`, `author_id`, `impression_id`, `account_id` | Kafka logging and served-candidate details |

## Brand-safety verdicts

Organic posts enter the blender as `ScoredPost`s. `PhoenixCandidatePipeline` hydrates verdicts after `TopKScoreSelector`, then `candidates_to_scored_posts` copies them. A missing verdict becomes `BrandSafetyVerdict::MediumRisk`.

Two hydrators share `EnableAdsBrandSafetyHydrator` and are mutually exclusive via the `vf_brand_safety_dark_traffic` decider:

| Hydrator | Runs when | Backend |
| --- | --- | --- |
| `AdsBrandSafetyHydrator` | flag on and decider **off** | `SafetyLabelStoreClient.batch_get_all_labels`, Moka cache of 1_000_000 keys |
| `AdsBrandSafetyVfHydrator` | flag on and decider **on** | `VfClient.get_safety_labels` |

Both look up `retweeted_tweet_id.unwrap_or(tweet_id)` plus `quoted_tweet_id`. A quote lookup failure raises the candidate to at least `MediumRisk`. A primary lookup `Err` skips `update`; the candidate keeps its previous (usually unset) verdict and later maps to `MediumRisk`.

`compute_verdict` in `home-mixer/models/brand_safety.rs`:

1. Any label in `MEDIUM_RISK_LABELS` → `MediumRisk`.
2. Missing `GROK_SFA` and `GROK_NSFA_LIMITED` → `MediumRisk`.
3. `tweet_id >= 2054275414225846272` without `PTOS_REVIEWED` → `MediumRisk`.
4. Any label in `LOW_RISK_LABELS` → `LowRisk`.
5. Otherwise → `Safe`.

The blender treats `MediumRisk` as **avoid**: `has_avoid` is true only for that verdict. `Safe` and `LowRisk` are eligible neighbors. `LowRisk` additionally blocks `BsrLow` / `BsrIas` ads in the partition blender.

Hydrators must return one result per input candidate and must not drop posts. Length mismatches become per-candidate errors and leave fields unchanged.

## AdsBlenderType

`BlenderSelector` reads `query.params.get(AdsBlenderType)` as a string:

| Value | Implementation |
| --- | --- |
| `"safe_gap"` | `SafeGapAdsBlender` |
| any other string | `PartitionOrganicAdsBlender` |

<ParamField body="AdsBlenderType" type="string">
Feature-switch string. Exact match `"safe_gap"` selects the gap placer. Every other value, including an unset or unexpected default, selects `PartitionOrganicAdsBlender`.
</ParamField>

The numeric default is not published in this checkout. `ForYouResponseStatsSideEffect` tags `ForYouFeed.response` with `blender=<that string>`.

## Shared blender rules

Both implementations go through `AdsBlender::blend`, which first records:

- `AdsBlender.post_brand_safety_verdict` per post (`verdict` = proto `as_str_name()`)
- `AdsBlender.ad_brand_safety_risk` per ad (`risk` from `ad_adjacency_control`, else `BsrUnknown`)

Constants in `home-mixer/ads/util.rs`:

| Identifier | Value | Role |
| --- | --- | --- |
| `MIN_POSTS_FOR_ADS` | `5` | If `ads` is empty or `scored_posts.len() < 5`, return organic-only `FeedItem`s |
| `MIN_REQUESTED_GAP` | `3` | Minimum accepted gap derived from ad `insert_position`s |
| `DEFAULT_SPACING` | `requested: 3`, `min: 2` | Used when fewer than two ads, or the min positive delta is `< 3` |

`compute_spacing` sorts the first four `insert_position`s and takes the minimum positive adjacent difference. If that value is `>= 3`, requested spacing is that delta and min spacing is `requested.div_ceil(2)`.

Both blenders then:

1. `truncate` the item list to `params::RESULT_SIZE` (same constant Phoenix uses as its pipeline `result_size`).
2. Pop a trailing `feed_item::Item::Ad` so the page never ends on an ad.
3. Rewrite `FeedItem.position` to `0..len` as `i32`.

`RESULT_SIZE` itself is defined in the unpublished `params` module.

## SafeGapAdsBlender

Keeps organic order. Ads occupy **safe gaps**: index `g` in `1..n` where neither `posts[g-1]` nor `posts[g]` is `MediumRisk`. Gap `0` is never eligible, so an ad cannot sit before the first post.

Placement loop (`assign_ads_to_gaps`):

| Ad | Ideal gap | Minimum gap |
| --- | --- | --- |
| First | `ads[0].insert_position.max(0)` | `1` |
| Later | previous **ideal** + `spacing.requested` | `max(previous ideal + spacing.min, last actual + 2)` |

`find_best_gap` takes gaps `>= min` and picks the closest to ideal. A tie (`ideal - below <= above - ideal`) chooses the lower gap. If no remaining gap satisfies `min`, remaining ads are dropped.

`interleave_and_finalize` inserts each chosen ad **before** the post at that gap index, then applies the shared truncate / no-trailing-ad / reindex rules.

```text
organic order preserved
P0  P1  [ad]  P2  P3  [ad]  P4
         ^safe gap 2         ^safe gap 4
MediumRisk posts block the gaps on either side
```

## PartitionOrganicAdsBlender

Rebuilds the feed around `above / ad / below` triples taken from **safe** posts only.

1. `actual_ads = min(ads.len(), (n - 1) / spacing.requested, safe_count / 2)`. `spacing.requested == 0` yields zero ads.
2. Split posts into `safe` (`!has_avoid`) and `unsafe_posts` (`MediumRisk`), preserving relative order inside each bucket.
3. `group_size = num_safe / actual_ads` (at least 2 when `actual_ads > 0`).
4. For each ad, try the current group's first two remaining safe posts. Drops **do not** advance `group_idx`, so the next ad retries the same pair:

| Check | Drops the ad when |
| --- | --- |
| `should_drop_bsr_low` | Ad risk is `BsrLow` or `BsrIas` **and** above or below is `LowRisk` |
| `should_drop_handle` | `ad_adjacency_control.handles` contains `author_id` of above or below |
| `should_drop_keyword` | Tokenized `keywords` are a subsequence of `tweet_text` on above or below (`TweetTokenizer`) |

5. Leftover safe posts plus all unsafe posts are sorted by `score` descending and used as filler after each triple. Remainder filler goes to the last groups.
6. If zero ads survive, every leftover post is score-sorted and returned as organic-only.

Enforcement counters on `PartitionOrganic.enforcement`: `drop` (BSR), `ok` (BSR-low ad placed), `handle_drop`, `keyword_drop`.

```text
safe group 0          filler (score desc)     later triples
P_safe  [ad]  P_safe  P_fill …                P_safe  [ad]  P_safe …
MediumRisk posts only appear in filler, never as ad neighbors
```

## Prompt, who-to-follow, and push-to-home insertion

`BlenderSelector` partitions incoming `FeedItem`s, blends posts+ads, then inserts modules in this order:

1. `insert_prompts` — every prompt at index `0, 1, …` (order preserved at the front). `FeedItem.position` is `PROMPTS_POSITION`.
2. `insert_who_to_follow` — **first** `WhoToFollowModule` only, at `min(WHO_TO_FOLLOW_POSITION.saturating_sub(1), blended.len())`. `FeedItem.position` is `WHO_TO_FOLLOW_POSITION as i32`.
3. `pin_push_to_home` — at most one item, inserted at index `0` with `position: 0`.

Those helpers do **not** rewrite positions of already-blended items. Kafka ads-injection logging uses enumerate order; `ServedCandidatesKafkaSideEffect` uses `item.position`.

### PromptsSource

<ParamField body="EnablePrompts" type="bool">
Must be true or the source is skipped.
</ParamField>

Requests `GetInjectionsRequest` with `DisplayLocation::HOME_TIMELINE` and supported types `INLINE_PROMPT`, `FULL_COVER`, `HALF_COVER`, `RELEVANCE_PROMPT`. Each injection is Thrift-serialized into `Prompt.injection`. Serialization failure fails the source (`"PromptsSource: serialization failed: …"`).

### WhoToFollowSource

<ParamField body="EnableWhoToFollowModule" type="bool">
Must be true **and** `query.who_to_follow_eligible`.
</ParamField>

<ParamField body="who_to_follow_eligible" type="bool">
Starts `false` in `ScoredPostsQuery::new`. `ServedHistoryQueryHydrator` sets it when `EnableUrtMigrationComponents` is on: eligible if no prior `EntityIdType::WHO_TO_FOLLOW` entry, or last serve is at least `WhoToFollowFatigueHours` hours before `request_time_ms`.
</ParamField>

Empty recommendations return no items. Otherwise the source keeps at most `MAX_WHO_TO_FOLLOW_USERS` (`3`) users. Exclusions are up to `200` previously served Who-to-Follow `user_id`s from `served_history`.

### PushToHomeSource

Enabled only when `query.push_to_home_post_id` is `Some`. `QueryBuilder` copies a non-zero proto `push_to_home_post_id`.

- TES miss or `Ok(None)` → empty vec (not an error).
- TES error → `Err("PushToHomeSource: TES error for tweet …")`.
- Root posts (`in_reply_to_tweet_id` none) request up to `3` facepile repliers from `ReplyMixerClient`, excluding the author. Reply-mixer errors log a warning and leave the facepile empty.
- `served_type` is `ServedType::ForYouPushToHome`.

Final visual order after a full insert:

```text
[PushToHome] [Prompt…] …organic+ads… [WhoToFollow at WHO_TO_FOLLOW_POSITION-1, then shifted by PTH] …
```

## Dropped items and pipeline cap

`BlenderSelector` compares input post/ad counts to the blended output and emits `non_selected` **placeholders** (`ScoredPost::default()` / `AdIndexInfo::default()`) for the difference. Those placeholders exist so fetched-vs-response stats can recover dropped counts. They are not real ranked candidates.

`CandidatePipeline::execute` then `split_off`s anything beyond `FOR_YOU_MAX_RESULT_SIZE` into `non_selected` as well.

`ForYouFeedServer` short-circuits `params::TEST_USER_IDS` to an empty item list and never runs the blender.

## Side effects and stats

| Surface | Gate | Behavior |
| --- | --- | --- |
| `AdsInjectionLoggingSideEffect` | `is_prod()` and `EnableAdsInjectionLogging` | Publishes `AdsInjectedTimeline` to `ADS_INJECTION_TOPIC` on the Ads Kafka cluster. Counts `retrieved_*` from selected + non-selected, `response_*` from selected. `display_location` is `TimelineHome`. |
| `ForYouResponseStatsSideEffect` | always | Logs post/ad counts and increments `ForYouFeed.response` with `subscription`, `blender`, and country bucket. Empty / sufficient thresholds: `0` ads, `0` posts, `>= 5` ads, `>= 20` posts. |
| `ServedCandidatesKafkaSideEffect` | (its own enable) | Ads become `PROMOTED_TWEET` with `insert_position` and `impression_id`. |

## Failure modes

| Symptom | Cause |
| --- | --- |
| Organic-only feed, ads expected | `EnableAdsSource` off, `is_preview`, `AdsSource` RPC error, fewer than 5 posts, `actual_ads == 0`, or every ad dropped by adjacency |
| Feed never starts with an ad | Safe-gap minimum is 1; partition triples always start with a post; trailing ads are popped |
| Who to Follow missing | `EnableWhoToFollowModule` off, `who_to_follow_eligible` still false (`EnableUrtMigrationComponents` off or fatigue window), or empty recommendations |
| Prompts missing | `EnablePrompts` off or injection client / serialize error |
| Push-to-home missing | `push_to_home_post_id` unset/zero, or TES returned no core data |
| Ads sit next to `MediumRisk` | Brand-safety hydrator skipped or failed (`None` → `MediumRisk` only on the **organic** side). Ads themselves are not labeled by these hydrators |
| BSR-low ads never appear next to `LowRisk` | `PartitionOrganicAdsBlender` only; `SafeGapAdsBlender` does not run handle / keyword / BSR-low adjacency drops |
| Cannot compile or run Home Mixer locally | Unpublished `params`, `clients`, and proto crates. See runtime boundaries |

## Next

<CardGroup>
  <Card title="For You request lifecycle" href="/request-lifecycle">
    How CandidatePipeline.execute stages wrap ForYouCandidatePipeline around PhoenixCandidatePipeline.
  </Card>
  <Card title="Candidate sources" href="/candidate-sources">
    Enable predicates and served_type assignment for Phoenix, Thunder, ads, prompts, and Who to Follow.
  </Card>
  <Card title="Filters and hydrators" href="/filters-and-hydrators">
    Post-selection AdsBrandSafety hydrators and the length-match / no-drop hydrator contract.
  </Card>
  <Card title="Assemble a Home Mixer request" href="/assemble-home-mixer-request">
    QueryBuilder fields including is_preview and push_to_home_post_id.
  </Card>
  <Card title="Runtime boundaries" href="/runtime-boundaries">
    What this checkout can execute versus unpublished Home Mixer crates.
  </Card>
  <Card title="Troubleshooting" href="/troubleshooting">
    TEST_USER_IDS empty feeds, viewer_id validation, and unpublished-module failures.
  </Card>
</CardGroup>
