Feed arrangement
Feed arrangement changes the order of a ranked feed without changing any scores. Ranking decides how good each activity is. Arrangement decides where each activity goes, for placement rules that a score can't express, such as "show 4 sports activities for every 1 news activity".
You configure it with arrange on the ranking config of a feed group or feed view. Each arrange config uses exactly one mode. The only mode available today is blend, and more modes will be added later.
Arrangement works on feeds with expression or interest ranking. It is not supported together with aggregation.
Blend mode
Blend mode interleaves activities from several sources at target ratios. A source is all activities that share a value of the key field, for example all activities of type sports.
The config below serves 4 sports activities for every 1 news activity, and keeps at least 2 other activities between two news activities:
{
"ranking": {
"type": "expression",
"score": "decay_linear(time) * popularity",
"arrange": {
"mode": "blend",
"key": "type",
"blend": {
"weights": { "sports": 80, "news": 20 },
"min_gap": { "news": 2 }
}
}
}
}The feed then reads like this, where the exact positions vary from feed to feed:
sports, sports, news, sports, sports, sports, news, sports, sports, news, ...Within each source, activities keep their ranking order: the best sports activity comes first among the sports activities, and so on. Scores are never changed.
How activities are placed
- Weights are relative. They are normalized, so
80/20,0.8/0.2and4/1produce the same order. - Shares hold across the feed. While every source still has activities, the whole feed follows the weights, and in any run of 10 or more consecutive activities each source is typically within 2 activities of its target count.
- The pattern is not fixed. Positions are chosen with controlled randomness, so the feed doesn't repeat a predictable pattern, and different feeds get different patterns.
min_gapis exact while every weighted source still has activities. Amin_gapof2means at least 2 other activities sit between two activities of that source.- When a source runs out, the remaining sources share the rest of the feed according to their weights. From that point on, ratios and gaps are best effort, and the feed never has empty slots or repeats.
- A source without any activities takes no part, and the sources that are present split the feed by their weights. As when a source runs out, gaps among the remaining sources are then best effort.
Activities without a weight
Activities whose key value has no entry in weights are placed after the blended activities, in score order.
To blend them too, add the catch-all source "*". It collects every key value without its own weight and blends them as one source. This is useful with "key": "user_id", where you can't list every author:
{
"arrange": {
"mode": "blend",
"key": "user_id",
"blend": {
"weights": { "editorial-team": 30, "*": 70 },
"min_gap": { "editorial-team": 1 }
}
}
}Here about 30% of the feed comes from the editorial-team user, never twice in a row, and the remaining 70% from everyone else.
If you add a new activity type, add it to weights (or use "*") before you start writing it. Otherwise its activities are placed after all blended activities and are hard to reach on the first pages.
Giving every source enough candidates
Blending works on the candidates that your activity selectors return, after ranking. Each selector returns up to 1000 activities. If one source writes far more activities than another, the newest 1000 can hold only a few activities of the smaller source, which then runs out early.
To give each source its own pool of candidates, use one selector per source, for example by tagging activities with filter_tags when you write them:
{
"activity_selectors": [
{
"type": "current_feed",
"filter": { "filter_tags": { "$in": ["sports"] } }
},
{ "type": "current_feed", "filter": { "filter_tags": { "$in": ["news"] } } }
]
}Moving low-scoring activities to the end
Set tail_below to move activities that score strictly below a value to the very end of the feed, in score order. They are not blended, and they come after everything else. 0 is a valid value.
This works well together with exclude_below. For example, a score expression can return -2 for activities to hide and -1 for activities to show last:
{
"ranking": {
"type": "expression",
"score": "...",
"exclude_below": -1.5,
"arrange": {
"mode": "blend",
"key": "type",
"tail_below": 0,
"blend": {
"weights": { "sports": 80, "news": 20 }
}
}
}
}The steps run in this order:
- Activities are scored and sorted.
- Activities below
exclude_beloware removed. - Activities below
tail_beloware set aside for the end. - The remaining activities are blended.
The final feed is the blended activities, then the activities without a weight, then the activities below tail_below.
tail_below must be greater than exclude_below when both are set, because otherwise nothing would ever reach the end section.
Configuration reference
arrange
| Field | Type | Required | Description |
|---|---|---|---|
mode |
string | Yes | The arrangement mode. Must be blend. |
key |
string | Yes | The activity field that defines a source: type or user_id. |
tail_below |
number | No | Activities scoring strictly below this value move to the end of the feed. Must be greater than exclude_below. |
blend |
object | For blend |
Blend mode settings, see below. |
blend
| Field | Type | Required | Description |
|---|---|---|---|
weights |
map of string to number | Yes | Relative weight per key value. 1 to 8 entries, keys of 1 to 128 characters, and every weight greater than 0. "*" is the catch-all. |
min_gap |
map of string to integer | No | Minimum number of other activities between two activities of a source, from 0 to 50. Every key needs an entry in weights. |
default_min_gap |
integer | No | Minimum gap, from 0 to 50, for every source without its own min_gap entry. |
Combining weights and gaps
A source with a gap of g needs g other activities between two of its own, so it can fill at most 1 in g + 1 positions. Each source therefore has to satisfy the rule below, where min_gap is the source's own entry or default_min_gap:
weight × (min_gap + 1) ≤ sum of all weightsFor example, { "a": 2, "b": 1 } with min_gap: { "b": 2 } is valid (1 × 3 ≤ 3), but { "a": 1, "b": 1 } with the same gap is not (1 × 3 > 2).
Gaps on several sources can also conflict with each other even when every source passes this check on its own. Stream checks the whole combination when you save the feed group or view, and rejects combinations that can't be met.
Scores, pagination and caching
- The
scorereturned for each activity is still its ranking score. An arranged feed is therefore not sorted byscore. - The order is stable for a given set of activities, so paginating through the feed is consistent. The order changes when activities are added or removed.
- After you add or remove
arrange, a pagination cursor from before the change can be rejected withpagination token expired, please refresh the feed, or the next page can repeat or skip activities. Restart from the first page. Changing weights or gaps doesn't invalidate cursors and takes effect when cached results refresh. - After changing the config, cached ranked results can take up to about 60 seconds to reflect it, or up to 10 minutes for some shared ranked feeds.
- Arranged feeds can be served from the hot feed cache, including with external ranking.
Validation errors
Creating or updating a feed group or feed view with an invalid arrange config returns a 400 error:
| Cause | Error message |
|---|---|
| Used together with aggregation | ranking.arrange is not supported with aggregation |
Unknown mode |
arrange mode "x" is unknown, expected "blend" |
Unsupported key |
arrange key "x" is not supported, expected "type" or "user_id" |
tail_below not greater than exclude_below |
arrange tail_below must be greater than ranking.exclude_below, otherwise the tail is always empty |
blend missing |
arrange.blend is required when mode is "blend" |
weights empty or with more than 8 entries |
arrange.blend.weights must not be empty, arrange.blend.weights has 9 entries, at most 8 are supported |
A weights key that is empty or longer than 128 characters |
arrange.blend.weights keys must be 1 to 128 characters |
| A weight that is 0 or negative | arrange.blend.weights["x"] must be a finite number greater than 0 |
A min_gap key without a weight |
arrange.blend.min_gap key "x" has no entry in arrange.blend.weights |
min_gap["*"] without weights["*"] |
arrange.blend.min_gap["*"] requires arrange.blend.weights["*"] |
min_gap with more than 8 entries |
arrange.blend.min_gap has 9 entries, at most 8 are supported |
| A gap outside 0 to 50 | arrange.blend.min_gap["x"] must be between 0 and 50 |
default_min_gap outside 0 to 50 |
arrange.blend.default_min_gap must be between 0 and 50 |
| A source's weight too large for its gap | arrange.blend: source "x" with weight 1 and min gap 2 needs a share of at most 1/3, which its weight exceeds |
| Gaps that can't be met together | arrange.blend: weights and min_gap cannot be satisfied together |