# In-network and out-of-network

> Thunder followed-author lookup versus Phoenix retrieval, enable predicates, and how in_network_only changes sourcing and served_type.

- 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/sources/thunder_source.rs`
- `home-mixer/sources/phoenix_source.rs`
- `thunder/posts/post_store.rs`
- `thunder/thunder_service.rs`
- `phoenix/recsys_retrieval_model.py`
- `home-mixer/scorers/oon_scorer.rs`

---

---
title: "In-network and out-of-network"
description: "Thunder followed-author lookup versus Phoenix retrieval, enable predicates, and how in_network_only changes sourcing and served_type."
---

`PhoenixCandidatePipeline` mixes two organic retrieval paths: `ThunderSource` looks up recent posts from the viewer's followed authors via Thunder `GetInNetworkPosts`, and `PhoenixSource` (plus topic / MoE variants) retrieves from a global Phoenix corpus. After fetch, `InNetworkCandidateHydrator` stamps `PostCandidate.in_network` from the following list. `ScoredPostsQuery.in_network_only` turns off every out-of-network source, retags Thunder posts as `RankedFollowing`, and switches Phoenix scoring to `HomeTimelineRankedFollowing`.

<Info>
This checkout can run Phoenix retrieval locally (`phoenix/run_pipeline.py` against `sports_corpus.npz`). Home Mixer, Thunder, and the production Phoenix retrieval RPC depend on unpublished crates and services. See [Runtime boundaries](/runtime-boundaries).
</Info>

## Two retrieval paths

```mermaid
flowchart TB
  subgraph hm["Home Mixer — PhoenixCandidatePipeline"]
    Q["ScoredPostsQuery<br/>in_network_only, followed_user_ids,<br/>retrieval_sequence, seen_ids"]
    EN["Source.enable predicates"]
    TH["ThunderSource"]
    PH["PhoenixSource / PhoenixTopicsSource / PhoenixMOESource"]
    TM["TweetMixerSource"]
    HY["InNetworkCandidateHydrator"]
    RS["PhoenixScorer + RankingScorer"]
    Q --> EN
    EN -->|"!has_cached_posts"| TH
    EN -->|"!in_network_only && !has_cached_posts"| PH
    EN -->|"!in_network_only && !has_cached_posts"| TM
    TH --> HY
    PH --> HY
    TM --> HY
    HY --> RS
  end

  subgraph thunder["Thunder — InNetworkPostsService"]
    GPS["get_in_network_posts"]
    PS["PostStore timelines<br/>original + secondary + video"]
    GPS --> PS
  end

  subgraph phoenix["Phoenix retrieval"]
    RET["retrieve(user sequence, corpus)"]
    TOWER["user representation × corpus embeddings"]
    TOPK["jax.lax.top_k"]
    RET --> TOWER --> TOPK
  end

  TH -->|"GetInNetworkPostsRequest<br/>following_user_ids, exclude_tweet_ids"| GPS
  PH -->|"retrieval_sequence required"| RET
```

`CandidatePipeline.fetch_candidates` filters `sources()` with `Source.enable`, then `join_all`s the enabled `source()` calls. Enabled sources concatenate; there is no source-level merge by tweet id at this stage.

`ForYouCandidatePipeline` does not call Thunder or Phoenix itself. It sources `FeedItem`s through `ScoredPostsSource`, which runs `PhoenixCandidatePipeline`.

## How `in_network_only` is set

`HomeMixerServer` builds `ScoredPostsQuery.in_network_only` as:

```text
proto_query.in_network_only || viewer_data.allow_for_you_recommendations == Some(false)
```

Either the client asks for following-only, or the viewer is not allowed For You recommendations. `viewer_id == 0` fails earlier with `invalid_argument: viewer_id must be specified`.

<ParamField body="in_network_only" type="bool" required>
When true, Phoenix, Phoenix topics, Phoenix MoE, and Tweet Mixer do not run. Thunder still runs (unless a Redis cached-post hit replaces live sources). Thunder `served_type` becomes `RankedFollowing`. Phoenix scoring and request-cache product surface become `HomeTimelineRankedFollowing`.
</ParamField>

`CachedPostsQueryHydrator` includes `in_network_only` in the Redis key (`cached_posts_key(user_id, topic_ids, in_network_only, exclude_videos)`), so following-only and For You caches do not collide. A hit is applied only when the decoded list has at least `500` posts (`MIN_CACHED_POSTS_THRESHOLD`); then `has_cached_posts` is true and live sources disable.

`FollowedGrokTopicsQueryHydrator` also skips filling `new_user_topic_ids` when `in_network_only` is true, so the new-user topic retrieval branch cannot turn on during following-only.

## Source enable predicates

`PhoenixCandidatePipeline` source order:

| Source | `enable` | `served_type` |
|---|---|---|
| `ThunderSource` | `!query.has_cached_posts` | `ForYouInNetwork` when `!in_network_only`, else `RankedFollowing` |
| `TweetMixerSource` | `!in_network_only && !has_cached_posts` | `ForYouTweetMixer` |
| `PhoenixSource` | not a topic request, or bulk topic (`topic_ids.len() > 6`); and not (`EnableNewUserTopicRetrieval` and `has_new_user_topic_ids`); and `!in_network_only && !has_cached_posts` | `ForYouPhoenixRetrieval` |
| `PhoenixTopicsSource` | `(is_topic_request && !is_bulk_topic_request)` or (`EnableNewUserTopicRetrieval` and `has_new_user_topic_ids`); and `!in_network_only && !has_cached_posts` | `ForYouPhoenixRetrieval` |
| `PhoenixMOESource` | `EnablePhoenixMOESource` and (not a topic request, or bulk topic); and `!in_network_only && !has_cached_posts` | `ForYouPhoenixRetrievalMoe` |
| `CachedPostsSource` | `query.has_cached_posts` | preserved from cache |

`ScoredPostsQuery.is_topic_request()` is `!topic_ids.is_empty()`. `is_bulk_topic_request()` is `topic_ids.len() > 6`. A bulk topic request keeps `PhoenixSource` / `PhoenixMOESource` and turns `PhoenixTopicsSource` off. A non-bulk topic request is the inverse.

<Warning>
`ThunderSource.enable` does **not** inspect `in_network_only`. Following-only still fetches Thunder. Out-of-network is gated by the other sources' predicates.
</Warning>

## Thunder: followed-author lookup

`FollowedUserIdsQueryHydrator` fills `query.user_features.followed_user_ids` from Social Graph before sources run. `ThunderSource` copies that list into `GetInNetworkPostsRequest.following_user_ids` (cast to `u64`), excludes `query.seen_ids`, and picks a Thunder cluster from `ThunderClusterId` plus decider override.

<RequestExample>
```rust ThunderSource request
GetInNetworkPostsRequest {
    user_id: query.user_id,
    following_user_ids: query.user_features.followed_user_ids, // as u64
    max_results: query.params.get(ThunderMaxResults),
    exclude_tweet_ids: query.seen_ids,
    algorithm: query.params.get(ThunderAlgorithm),
    debug: false,
    is_video_request: false,
}
```
</RequestExample>

Thunder `get_in_network_posts` then:

1. Rejects immediately with `RESOURCE_EXHAUSTED` (`"Server at capacity, please retry"`) if the request semaphore cannot be acquired.
2. Caps `following_user_ids` and `exclude_tweet_ids` at `MAX_INPUT_LIST_SIZE`.
3. Fetches `PostStore.get_all_posts_by_users` (or `get_videos_by_users` when `is_video_request`).
4. Sorts by `created_at` descending (`score_recent`) and takes `max_results` (or `MAX_POSTS_TO_RETURN` / `MAX_VIDEOS_TO_RETURN` when `max_results == 0`).

`PostStore` keeps three per-author deques: original (non-reply, non-retweet), secondary (replies and retweets), and video. Originals are capped at `MAX_ORIGINAL_POSTS_PER_AUTHOR`; secondaries at `MAX_REPLY_POSTS_PER_AUTHOR`. Secondary lookup also filters reply threads so replies stay attached to followed conversations. Retweets of the requesting user are dropped.

<Note>
`ThunderSource` always sends `debug: false`. Thunder's Strato following-list fallback runs only when `following_user_ids` is empty **and** `debug` is true, so the Home Mixer path never uses that fallback. `algorithm` is forwarded on the request but `ThunderServiceImpl` does not read it; ranking inside Thunder is recency only.
</Note>

Each returned `LightPost` becomes a `PostCandidate` with `tweet_id`, `author_id`, reply / retweet ids, and ancestors (`in_reply_to_post_id`, then `conversation_id` if different). Reply rows are also written once into `query.in_network_replies` (`OnceLock<Vec<InNetworkReply>>`) for `FollowingRepliedUsersHydrator`.

## Phoenix: out-of-network retrieval

`PhoenixSource` does not use the following list. It requires `query.retrieval_sequence` (from `RetrievalSequenceQueryHydrator`) and fails with `"PhoenixSource: missing retrieval_sequence"` if it is absent. The same missing-sequence error exists on `PhoenixTopicsSource` and `PhoenixMOESource`.

Production retrieve call (PhoenixSource):

<ParamField body="cluster" type="PhoenixRetrievalCluster">
`PhoenixRetrievalInferenceClusterId`, remapped to `PhoenixRetrievalNewUserInferenceClusterId` when `retrieval_sequence.metadata.length` is below `PhoenixRetrievalNewUserHistoryThreshold` (threshold `0` disables the remap). Decider keys `enable_phoenix_retrieval_lap7_to_fou` / `enable_phoenix_retrieval_fou_to_lap7` can swap `Experiment1Lap7` ↔ `Experiment1Fou`.
</ParamField>

<ParamField body="max_results" type="param">
`PhoenixMaxResults` (MoE uses `PhoenixMOEMaxResults`).
</ParamField>

<ParamField body="exclude / topics" type="vec">
`PhoenixSource` passes an empty exclude list and no topic filter. `PhoenixTopicsSource` passes expanded topic entity ids plus a topic filter mode.
</ParamField>

Mapped fields: `tweet_id`, `author_id`, `in_reply_to_tweet_id`, `retweeted_tweet_id` (omitted when `0`), `served_type: ForYouPhoenixRetrieval`.

Locally, `PhoenixRetrievalModel.__call__` encodes a user representation from `RecsysBatch` + `RecsysEmbeddings`, scores `user_representation @ corpus_embeddings.T`, applies an optional `corpus_mask`, and returns `jax.lax.top_k`. That is corpus similarity, not followed-author lookup. The published demo corpus is `sports_corpus.npz` via `run_pipeline.py`.

## `in_network` versus `served_type`

These are independent fields on `PostCandidate`.

| Field | Who sets it | Meaning |
|---|---|---|
| `served_type` | The source, at fetch time | Provenance / product surface of the *source*. Thunder For You → `ForYouInNetwork`; Thunder following-only → `RankedFollowing`; Phoenix → `ForYouPhoenixRetrieval`; MoE → `ForYouPhoenixRetrievalMoe`; Tweet Mixer → `ForYouTweetMixer`. |
| `in_network` | `InNetworkCandidateHydrator`, after fetch | `author_id == viewer_id` **or** `author_id` is in `followed_user_ids`. |

`InNetworkCandidateHydrator.enable` is `!query.has_cached_posts`. Cached candidates keep the `in_network` value stored in Redis.

A Phoenix-retrieved post from an account the viewer already follows is still `served_type = ForYouPhoenixRetrieval` and becomes `in_network = Some(true)`. A Thunder post is `ForYouInNetwork` / `RankedFollowing` and is almost always in-network by construction (self posts also count as in-network).

`ScoredPostsServer` copies both onto proto `ScoredPost`:

```text
in_network: candidate.in_network.unwrap_or(false)
served_type: candidate.served_type.map(|t| t as i32).unwrap_or_default()
```

Request logs use a separate surface string: `ranked_following` when `in_network_only`, else `topics` if `topic_ids` is non-empty, else `for_you_with_snoozed_topics` if `excluded_topic_ids` is non-empty, else `for_you`.

## Scoring and filters that read `in_network`

`PhoenixScorer` still scores Thunder and Phoenix candidates together. The product surface on the prediction request is:

- `ProductSurface::HomeTimelineRankedFollowing` when `in_network_only`
- `ProductSurface::HomeTimelineRanking` otherwise

The same surface split appears on `PhoenixRequestCacheSideEffect`, `PhoenixExperimentsSideEffect`, and `RerankingKafkaSideEffect`. `CacheRequestInfoSideEffect` is enabled only when `APP_ENV == "prod"` **and** `!in_network_only`.

`RankingScorer` applies an out-of-network multiplier after author diversity:

```text
final = after_diversity * effective_oon_weight   if in_network == Some(false)
final = after_diversity                          otherwise
```

`effective_oon_weight`:

| Condition | Weight |
|---|---|
| `topic_ids` non-empty | `TopicOonWeightFactor` |
| User snowflake age `< NewUserAgeThresholdSecs` **and** `followed_user_ids.len() >= NEW_USER_MIN_FOLLOWING` | `NEW_USER_OON_WEIGHT_FACTOR` |
| Else | `OonWeightFactor` |

Those identifiers live in the unpublished `params` crate; this checkout does not publish numeric defaults.

`NewUserTopicIdsFilter` keeps a candidate when `in_network == Some(true)` **or** the candidate's `filtered_topic_ids` intersect the expanded `new_user_topic_ids`.

<Note>
`home-mixer/scorers/oon_scorer.rs` defines `OONScorer` (`base_score * OON_WEIGHT_FACTOR` when `in_network == Some(false)`), but `scorers/mod.rs` does not declare that module and `PhoenixCandidatePipeline` does not install it. Live OON downweight is `RankingScorer`.
</Note>

## Following-only versus For You

```text
in_network_only = false (For You)
  Thunder  → ForYouInNetwork
  TweetMixer / Phoenix / Topics / MoE as predicates allow
  PhoenixScorer surface = HomeTimelineRanking
  log surface = for_you | topics | for_you_with_snoozed_topics

in_network_only = true  (Following / Ranked Following)
  Thunder  → RankedFollowing
  Phoenix*, TweetMixer disabled
  PhoenixScorer surface = HomeTimelineRankedFollowing
  log surface = ranked_following
```

Hydrators, pre-score filters, `PhoenixScorer`, `RankingScorer`, `VMRanker`, and `TopKScoreSelector` still run on the remaining Thunder (or cached) candidates.

## Errors and verification

| Symptom | Cause |
|---|---|
| `"PhoenixSource: missing retrieval_sequence"` (or Topics / MoE equivalent) | `RetrievalSequenceQueryHydrator` did not fill the sequence; Phoenix sources cannot run. |
| `"ThunderSource: no available channel"` | `ThunderClient.get_random_channel` returned `None` for the resolved cluster. |
| gRPC `RESOURCE_EXHAUSTED` / `"Server at capacity, please retry"` | Thunder semaphore saturated. |
| `"Failed to fetch following list: ..."` | Thunder debug path only (`debug == true` and empty following list). Home Mixer never sends that. |
| Empty or following-only feed when For You was expected | `allow_for_you_recommendations == Some(false)` forced `in_network_only`. |
| Unexpected OON posts on a following request | `in_network_only` is false; check proto query and viewer data. |
| Local `run_pipeline.py` has no Thunder posts | Expected. The Python pipeline retrieves from the published corpus only. |

To confirm a production-shaped request, inspect `PipelineResult.retrieved_candidates`: Thunder rows carry `ForYouInNetwork` or `RankedFollowing`; Phoenix rows carry `ForYouPhoenixRetrieval`. After hydration, `in_network` should be `Some(true)` for followed authors regardless of `served_type`.

## Next

<CardGroup>
  <Card title="Candidate sources" href="/candidate-sources">
    Full PhoenixCandidatePipeline and ForYouCandidatePipeline source list, cluster resolution, and served_type assignment.
  </Card>
  <Card title="Thunder GetInNetworkPosts" href="/thunder-in-network-posts">
    RPC fields, PostStore timelines and retention, Kafka ingest, semaphore capacity, Strato fallback.
  </Card>
  <Card title="Scorers and weights" href="/scorers-and-weights">
    RankingScorer OON factor, WeightedScorer, AuthorDiversityScorer, VM ranker, TopKScoreSelector.
  </Card>
  <Card title="For You request lifecycle" href="/request-lifecycle">
    CandidatePipeline.execute stages and how ForYouCandidatePipeline wraps PhoenixCandidatePipeline.
  </Card>
  <Card title="ScoredPostsQuery and gRPC" href="/scored-posts-query">
    Query fields, QueryBuilder defaults, and ScoredPost mapping including in_network and served_type.
  </Card>
  <Card title="Troubleshooting" href="/troubleshooting">
    viewer_id, TEST_USER_IDS empty feeds, Thunder RESOURCE_EXHAUSTED, missing retrieval_sequence.
  </Card>
</CardGroup>
