# Feed arrangement

Feed arrangement changes the order of a ranked feed without changing any scores. [Ranking](https://getstream.io/activity-feeds/docs/php/custom-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](https://getstream.io/activity-feeds/docs/php/feed-groups/) or [feed view](https://getstream.io/activity-feeds/docs/php/feed-views/). Each `arrange` config uses exactly one mode. The only mode available today is `blend`, and more modes will be added later.

>
> **Info:** Arrangement works on feeds with `expression` or `interest` ranking. It is not supported together with [aggregation](https://getstream.io/activity-feeds/docs/php/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:

```json
{
  "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:

```text
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.2` and `4`/`1` produce 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_gap` is exact** while every weighted source still has activities. A `min_gap` of `2` means 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:

```json
{
  "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.

>
> **Tip:** 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](https://getstream.io/activity-feeds/docs/php/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`](https://getstream.io/activity-feeds/docs/php/activity-selectors/#filters) when you write them:

```json
{
  "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`](https://getstream.io/activity-feeds/docs/php/custom-ranking/#excluding-low-scoring-activities). For example, a score expression can return `-2` for activities to hide and `-1` for activities to show last:

```json
{
  "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:

1. Activities are scored and sorted.
2. Activities below `exclude_below` are removed.
3. Activities below `tail_below` are set aside for the end.
4. 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`:

```text
weight × (min_gap + 1) ≤ sum of all weights
```

For 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 `score` returned for each activity is still its ranking score. An arranged feed is therefore not sorted by `score`.
- 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 with `pagination 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](https://getstream.io/activity-feeds/docs/php/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`                                              |

---

For the most recent version of this documentation, visit [https://getstream.io/activity-feeds/docs/php/feed-arrangement/](https://getstream.io/activity-feeds/docs/php/feed-arrangement/).