# Flow config reference

> Every field of a flow, its default, its valid range and what the engine does with it.

Source: https://staging.patterna.nl/docs/reference-flow-config

A flow is the versioned recipe that ranks a surface: which generators propose items and how much each one weighs, which filters apply, which ranker combines the scores, whether to explore, and the reason texts. You create a version with `POST /surfaces/{key}/flows` and activate it with `POST /surfaces/{key}/flows/{version}/activate` ([Admin](https://staging.patterna.nl/docs/api-admin)). This page lists every field the engine reads.

## Minimal and full example

The smallest valid config is one generator. Everything else has a default.

```json
{
  "generators": [ { "kind": "trending", "weight": 1 } ]
}
```

A full config for a home feed:

```json
{
  "generators": [
    { "kind": "content_affinity", "weight": 0.5 },
    { "kind": "persona_bayes", "weight": 0.2 },
    { "kind": "trending", "weight": 0.2 },
    { "kind": "curated", "weight": 0.1, "params": { "item_ids": ["doc_1842", "doc_0977"] } }
  ],
  "filters": {
    "exclude_seen_days": 7,
    "premium_policy": "cap",
    "premium_cap": 2,
    "per_collection_cap": 2,
    "freshness_days": 365,
    "diversity": 0.3
  },
  "ranker": { "kind": "linear" },
  "exploration": { "kind": "plackett_luce", "share": 0.2, "temperature": 0.7 },
  "reasons": {
    "matches_goals": { "nl": "Past bij wat je leest", "en": "Fits what you read" }
  }
}
```

## How scoring works

For every candidate item, each generator returns a raw score or stays silent. The engine z-normalises each generator's scores across the candidates (mean 0, standard deviation 1), multiplies them by the generator's `weight` and adds them up. Because of the normalisation, weights compare directly: a weight of 0.6 against 0.3 means "twice the say", whatever the scale of the raw signal. A silent generator contributes nothing for that item; a generator that is silent for every item drops out of the request.

The generator with the largest contribution to an item "wins" it and decides the item's reason key. The contributions per generator are logged with the impression and shown in the trace.

## generators

A list of at least one generator. Order does not matter.

| Field | Type | Default | Range | Notes |
|---|---|---|---|---|
| `kind` | string | required | one of the twelve kinds below | an unknown kind is a `400` |
| `weight` | number | 0 | any; normally 0 to 1 | a weight of 0 switches the generator off; negative weights push items down |
| `params` | object | none | | `curated` reads `item_ids`; `session_items` reads `sequence` (`both`, `next` or `neighbours`, see below); `user_to_user` reads `recent_items` and `half_life_days`; `vector_retrieval` reads `k`; `sequence_model` reads `top_items` (1 to 2000, default 100: how many of its best items carry a score); any other kind rejects these keys with a `400` and ignores the rest |

| Kind | What it reads | Silent when | Reason key it leaves |
|---|---|---|---|
| `trending` | the smoothed 7-day tap rate of each item on this surface ((taps + 1) / (slates + 20), a tap followed by an engaged view counting three), all locales together; the request's locale only decides which items are candidates. A surface without its own log uses 30 days of tenant-wide engagement instead. The same for everyone on the surface, not per segment or persona | an item has no data at all | `trending` from the surface's own log; `popular` from the tenant-wide prior |
| `curated` | `params.item_ids` in order; without `item_ids`, the surface's curated lists from the panel, as scheduled. A boost by rank (the first item +2, less for each place down the list), not a z-score, so a short list never drowns a large catalog | the item is not on the list | `editors_pick` |
| `item_to_item` | the anchor in `context.anchor_item_id`: the nightly behavioural and embedding neighbours, or tag cosine before they exist | the request has no anchor, or the anchor is not in the catalog | `similar_to_read` with an anchor |
| `content_affinity` | the user's tag affinities: behaviour in `personalized` mode, declared goals in `cohort` mode, against the item's approved tags | `non_profiled` requests, users without a profile, items without tags | `matches_goals` |
| `persona_bayes` | the nightly per-persona model; users without a persona get the `cold` persona | no model trained yet | `people_like_you` |
| `user_bayes` | the user's nightly shortlist from matrix factorisation | anything but `personalized` mode, or no shortlist for the user yet | `people_like_you` |
| `surface_specific` | freshness (newer is better, scaled over 180 days); in `personalized` mode communities the user already joined are pushed down hard | never | `new` for items under 30 days old, else `recommended` |
| `session_items` | what this session just looked at (`context.session_items` and the session's own reports), recency-weighted, an engaged view three times and a tap twice a plain view; every mode. `params.sequence` picks the relation to the candidates: `both` (the default) reads the path where it is known and the topic where it is not: the directed next-item shares ("what people open after this", learned nightly from the order of each person's positives) for a session item that has them, its undirected neighbours ("what goes with this") otherwise; `next` reads the path only; `neighbours` the topic only (the behaviour before round 7). It helps within a visit: on the public benchmark the path alone put the next film in the top 10 for 20% of users against 8% for the topic alone, and on a return visit more than an hour later it added nothing | the session has no history | `similar` |
| `user_to_user` | what the person's 200 nearest users did last: the nearest users are learned nightly from the same matrix factorisation as `user_bayes` (or from shared items for people the model has no row for yet), each neighbour's latest 10 completions, pins, taps and engaged views (`params.recent_items`) count by similarity and fade with a 30-day half-life (`params.half_life_days`), and the person's own items are left out. Personalized mode only. Next to `session_items` the engine blends the two by the gap since the person's previous visit: in the same visit the configured weights hold; after hours, days or a week away `session_items` is faded (×0.5, ×0.25, ×0.1) and `user_to_user`, `user_bayes` and `content_affinity` raised (×1.25, ×1.5, ×1.5), the weights used are logged with the slate and the gap bucket in the trace (`gap`). On the public benchmark it is the signal for the daily reopen, see the status page | not `personalized`, or no neighbours for the person yet | `people_like_you`, only when the neighbours did engage with the item |
| `vector_retrieval` | the k items nearest by content to what the person looked at (since engine 0.3.3). Per request the engine builds one query vector, the recency-weighted mean of the text embeddings of the session's items (`context.session_items` first, then the session's own reports; every mode) and, in `personalized` mode, the person's recent positives, with `session_items`' weights (a view 1, a tap 2, an engaged view 3, each step back ×0.85), and asks the vector index for the `params.k` nearest published items of the surface's kinds (default 100, at most 500). Those items join the candidates even when no other generator proposed them, so a new item or one far down the catalog can be recommended; their score is the cosine similarity, everything else reads 0. Filters, caps and exclusions apply as to every other candidate. With Redis configured an interaction is reflected in the next slate of the session; without it the session is read from the log. On the public benchmark see the status page | nothing looked at yet (no session items, no recent positives) | `similar`, only on a positive cosine |
| `sequence_model` | the nightly next-item model: a small neural network (a GRU) trained every night on the order in which each person or session opened items, exported to ONNX and evaluated inside the engine with ONNX Runtime. It reads what this session looked at (`context.session_items` and the session's own reports, like `session_items`) and, in `personalized` mode, the person's last 50 positives, and scores its best `top_items` (default 100) as the likely next ones, a shortlist that sits above every other item like `user_bayes`'s; the session's own items get no score. Every mode. The jobs activate a new model only when its hit@10 on held-out data beats the directed path rule that `session_items` reads; without an active model, or without the ONNX Runtime library on the engine host, the generator scores nothing and the flow serves as if it were absent. On the public benchmark see the status page | no active model, no ONNX Runtime library, or nothing in the session and no history | `similar`, only for an item above the sequence's average score |
| `time_fit` | the item's tap rate in the request's time bucket (weekday or weekend × morning, day, evening, night, from `context.hour_bucket` and `context.weekday`), learned nightly; before an item has a profile, a duration rule: short items fit the day, long ones the evening and the weekend. Every mode. It helps a feed whose items differ in length or in when people read them; on a surface with one kind of short item it adds little | no profile and no `duration_min` or `reading_time_min` | none: silent |
| `format_fit` | the user's learned format preference (video, audio, text, from engaged views and completions) against the item's `format` attribute; personalized mode only. It helps where a catalog mixes formats and people clearly prefer one; with one format it does nothing | no preference learned yet, the item has no `format`, or not `personalized` | none: silent |

`time_fit` and `format_fit` are **silent**: they move scores, show up in the trace, and never win a reason. Start them at a small weight (0.1 to 0.2) next to the generators that carry the slate.

`vector_retrieval.params`:

| Field | Type | Default | Notes |
|---|---|---|---|
| `k` | integer, 1 to 500 | 100 | how many nearest items the index returns per request; more widens the candidate pool and costs a little latency |

`curated.params`:

| Field | Type | Default | Notes |
|---|---|---|---|
| `item_ids` | array of external ids | none (use the panel's curated lists) | ids not in the catalog or not published are skipped |

`context.anchor_item_id` is resolved against the whole catalog, any kind. An article can anchor a product surface. A slot `item_to_item` wins across kinds carries `pairs_with` only for a recipe or a workout with a product, or a community with a document, each with its own default text; any other cross-kind anchor carries `more_about_tag`. `content_affinity` leaves `matches_goals` for a declared goal and `more_about_tag` for behaviour.

## filters

Filters remove candidates before scoring, or cap the slate while it is built.

| Field | Type | Default | Range | What it does |
|---|---|---|---|---|
| `exclude_seen_days` | integer | 0 (off) | 0 or more | drops items served to this user on this surface in the last N days (from the last 50 slates). Not applied in `non_profiled` mode: there is no user to remember. |
| `locales` | array of strings | none | | **accepted and stored, but not read by the engine.** The request's `context.locale` decides the locale. |
| `premium_policy` | string | none (no rule) | `cap`, `exclude` or absent | `cap`: at most `premium_cap` premium items for non-premium users, no cap for premium users. `exclude`: no premium items for non-premium users. |
| `premium_cap` | integer | 2 when `premium_policy` is `cap` and this is 0 | 1 or more | |
| `per_collection_cap` | integer | 0 (no cap) | 0 or more | at most N items from one collection in a slate |
| `freshness_days` | integer or null | null (no limit) | 0 or more | drops items published more than N days ago |
| `diversity` | number | 0 (off) | 0 to 1 | an MMR penalty: at each position an item's score is lowered by `diversity` times its highest tag similarity to the items already picked. It is applied inside the sampler, so logged propensities stay exact. |
| `region_boost` | number | 0.3 | 0 to 1 | added to the score of items whose region is the request's `context.region`; other items are never hidden. Logged as the contribution `region_match`; never a reason |
| `exclude_dismissed_days` | integer | 30 | 0 to 365 | items this person dismissed on any surface, or unpinned, stay out for N days (by user id, or by session id without one). Helps every surface: nobody wants to see a thing twice after saying no. 0 = off |
| `exclude_consumed_days` | integer | 30 | 0 to 3650 | items the user consumed (`item_completed`, `event_attended`) stay out for N days; personalized mode. Read from the user's whole history (the nightly `user_consumed` set plus the last two days of events), so a heavy user's completed items never come back inside the window. **0 means ever**, not off: nothing the user ever consumed is served again. Helps for workouts, protocols and articles read once; a surface where people repeat items on purpose sets a short window such as 1 |
| `paywall_cooldown_days` | integer | 7 | 0 to 365 | an item that showed this free user a paywall (`paywall_viewed`) stays out for N days; applies when `context.subscription_tier` is `free`. Helps a free user not hit the same wall twice. 0 = off |
| `repurchase_days` | integer | 30 | 0 to 365 | a product the user bought (from `order_placed`) is not offered again inside N days; the item's own `repurchase_days` attribute wins. Helps durable products; give consumables a short attribute instead of lowering this for the whole surface |
| `hide_unavailable` | boolean | true | | products whose `available` (or `in_stock`) attribute is `false` are never served. A hard rule, the same in both arms. Turn it off only if your app shows sold-out products on purpose |
| `relevance_floor` | number | 0 (off) | 0 to 1 | on a request with `context.anchor_item_id`, only items whose similarity to the anchor (precomputed neighbours, else tag cosine) reaches the floor count toward `min_slots`. With `min_slots: 1` a block then hides when nothing is related to the page, in both arms alike. Helps anchored blocks such as products under an article; leave it at 0 on unanchored feeds, where it does nothing. Pick the value from the trace's similarities, then check the hidden share in Simulate |

`diversity` is the only diversity setting. Older contract versions had a top-level `diversity: {mmr_lambda}` object; it was never read, and the engine drops it on save.

Two kinds of rule. **Hard rules** (an item past `expires_at`, an event past `starts_at`, an unavailable product, the request's exclusions, joined communities, kinds, premium, freshness) are decided before the arm and apply in both arms; `min_slots` counts on them. **Personal exclusions** (dismissed, completed, paywalled, bought) are preferences: when they would leave the slate short, they are relaxed, and the fallback fill honours them too.

Caps and diversity apply in the deterministic top-k and inside the exploration sampler alike. The request's `exclude_item_ids` and the anchor item are always removed. So are the communities the user already joined: `context.member_of`, in every mode, plus `community_joined` / `community_left` events in `personalized` and `cohort` mode. When no candidate survives, the request answers `422 no_candidates`.

## ranker

| Field | Type | Default | Range | Notes |
|---|---|---|---|---|
| `kind` | string | `linear` | `linear`, `learned_linear`, `gbdt` | `lambdamart` is refused (`400`): no model is trained for it |
| `bandit` | boolean | false | | a Thompson-sampling mixture: per request, each generator's weight is multiplied by a draw from its nightly posterior for this surface. The drawn weights are logged with the impression, so propensities stay exact. Never in the control arm |
| `gap_blend` | boolean | true | | the return-visit blend: when the flow has `session_items` next to a long-term generator (`content_affinity`, `user_bayes`, `user_to_user`), a personalized request after a pause fades the session weight (×0.5 after hours, ×0.25 after days, ×0.1 after weeks) and raises the long-term ones (×1.25, ×1.5, ×1.5). The blended weights are logged with the slate. `false` keeps the configured weights on every visit (a course or a playlist) |
| `policy_version` | string | empty | | informational; the slate's `policy_version` says which artifact actually ran |

- `linear`: the weighted sum above, with your weights.
- `learned_linear`: your weights replaced by the nightly per-generator weights fitted from logged taps (examination and inverse-propensity weighted). Generators without a learned weight keep yours. Until the first model exists it behaves like `linear`. It trains on slates in arm `treatment` and arm `none` (never control), from the interactions you report on them; filled positions are never ranking evidence.
- `gbdt`: the nightly gradient-boosted trees rescore the candidates from the generators' contributions, item age, premium and kind, the item features (`engaged_rate`, `pullback_rate`, `activity_rate`, `purchase_rate`, the time-bucket tap rate) and the request's hour, weekend and platform. Older models without the item and context features keep scoring. Until a model exists it behaves like `linear`.

Simulate a ranker change first (`POST /surfaces/{key}/simulate`) and look at the effective sample size.

## exploration

| Field | Type | Default | Range | Notes |
|---|---|---|---|---|
| `kind` | string | none | `none`, `plackett_luce` | anything else is a `400` |
| `share` | number | the surface's `exploration_share` when 0 or absent | 0 to 0.5 | the share of eligible requests that sample instead of taking the top-k |
| `temperature` | number | 0.7 when 0 or absent | above 0 | higher spreads picks further down the ranking |

With `plackett_luce`, a sampled slate draws positions one by one from a softmax over the scores, with caps and diversity applied inside the draw, and logs the exact probability of every pick. See [Exploration and propensities](https://staging.patterna.nl/docs/exploration-and-propensities).

**Where exploration runs.** On treatment slates during a holdout, and on slates outside any holdout (arm `none`). The control arm is never explored.

## reasons

A map from a **reason key** or a **generator kind** to a map from locale to text.

```json
{
  "reasons": {
    "similar_to_read": { "nl": "Lees ook", "en": "Read next" },
    "editors_pick": { "nl": "Uitgekozen voor jou", "en": "Picked for you" }
  }
}
```

An entry replaces the default for that key in every locale; give each locale you serve. A reason-key entry wins over a generator-kind entry, which wins over the default. The engine fills `{anchor_title}`, `{tag}` and `{segment}`, and rejects unknown keys, locales and placeholders with `400 invalid_reason_template`. The keys and defaults: [Reason keys](https://staging.patterna.nl/docs/reference-reason-keys).

## Surface fields that interact with the flow

These live on the surface, not the flow ([Admin](https://staging.patterna.nl/docs/api-admin)).

| Field | Default | Notes |
|---|---|---|
| `slots` | 6 | slate length; a request's `slots` (1 to 50) overrides it |
| `item_kinds` | required | candidates are only items of these kinds; `context.candidate_kinds` narrows it per request |
| `fallback` | `{ "kind": "newest" }` | the editorial list: `newest`, `curated` with `item_ids`, or `popular` (7-day trending); `newest` fills any gap. Served to the control arm and, flagged `fallback`, as the safety net. Optional `by_anchor`: an object from an anchor item id to a list of item ids, served to control on that anchor's page. A request's `context.control_item_ids` comes first, then `by_anchor`, then this list; the request's rules apply to all of them. A treatment slate short after its filters is filled from the same list, reported as `fill_count`. |
| `exploration_share` | 0.2 | used when the flow's `exploration.share` is 0 or absent |
| `status` | `shadow` | `shadow`, `testers`, `live`, `off`. `shadow`: the flow ranks every request, logged with arm `none` and marked as not shown (your app shows its own list). `testers`: testers get the flow; everyone else gets the editorial list with arm `none`. `live`: everyone gets the flow, in arms when a holdout runs. `off` answers `409 surface_off`. |
| `min_slots` | 0 | 0 to 50; when fewer items than this pass the request's rules (counted before the arm), the slate comes back with `shown: false` and no items, is logged as not shown and counts in its arm as served with zero success. 0 = always shown; 1 hides a block with nothing in it |
| `device_resort` | false | the app may re-sort the served slate under a holdout; interactions then need `displayed_position` (`422 displayed_position_required`) |

A surface without an active flow version serves the editorial list, flagged `fallback`.

## Validation

`POST /surfaces/{key}/flows` checks the config and answers `400 bad_request` with one of these messages:

| Message | Fix |
|---|---|
| `config must be an object: …` | send a JSON object under `config` |
| `config.generators must hold at least one generator` | add a generator |
| `unknown generator kind X` | use one of the twelve kinds |
| `unknown config key X (generators, filters, ranker, exploration, reasons)` | remove the key; a top-level `diversity` from older clients is tolerated and dropped |
| `ranker.kind lambdamart is not available yet: …` | use `linear`, `learned_linear` or `gbdt` |
| `ranker.kind must be linear, learned_linear or gbdt` | fix the kind |
| `filters.diversity must be between 0 and 1` | |
| `filters.relevance_floor must be between 0 and 1` | |
| `filters.region_boost must be between 0 and 1` | |
| `filters.exclude_dismissed_days must be between 0 and 365 days` (also `exclude_consumed_days`, `paywall_cooldown_days`, `repurchase_days`) | |
| `exploration.kind must be none or plackett_luce` | |
| `exploration.share must be between 0 and 0.5` | |
| `invalid_reason_template` | use known reason keys and placeholders |

Unknown fields inside `filters`, `ranker` or `exploration` are stored but have no effect; an unknown top-level key is refused. Check a new version with the panel's preview or `simulate` before you activate it.
