Patterna Docs

Flow config reference

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

Developers 15 min read View as Markdown

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). 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.

FieldTypeDefaultRangeNotes
kindstringrequiredone of the twelve kinds belowan unknown kind is a 400
weightnumber0any; normally 0 to 1a weight of 0 switches the generator off; negative weights push items down
paramsobjectnonecurated 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
KindWhat it readsSilent whenReason key it leaves
trendingthe 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 personaan item has no data at alltrending from the surface's own log; popular from the tenant-wide prior
curatedparams.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 catalogthe item is not on the listeditors_pick
item_to_itemthe anchor in context.anchor_item_id: the nightly behavioural and embedding neighbours, or tag cosine before they existthe request has no anchor, or the anchor is not in the catalogsimilar_to_read with an anchor
content_affinitythe user's tag affinities: behaviour in personalized mode, declared goals in cohort mode, against the item's approved tagsnon_profiled requests, users without a profile, items without tagsmatches_goals
persona_bayesthe nightly per-persona model; users without a persona get the cold personano model trained yetpeople_like_you
user_bayesthe user's nightly shortlist from matrix factorisationanything but personalized mode, or no shortlist for the user yetpeople_like_you
surface_specificfreshness (newer is better, scaled over 180 days); in personalized mode communities the user already joined are pushed down hardnevernew for items under 30 days old, else recommended
session_itemswhat 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 nothingthe session has no historysimilar
user_to_userwhat 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 pagenot personalized, or no neighbours for the person yetpeople_like_you, only when the neighbours did engage with the item
vector_retrievalthe 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 pagenothing looked at yet (no session items, no recent positives)similar, only on a positive cosine
sequence_modelthe 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 pageno active model, no ONNX Runtime library, or nothing in the session and no historysimilar, only for an item above the sequence's average score
time_fitthe 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 littleno profile and no duration_min or reading_time_minnone: silent
format_fitthe 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 nothingno preference learned yet, the item has no format, or not personalizednone: 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:

FieldTypeDefaultNotes
kinteger, 1 to 500100how many nearest items the index returns per request; more widens the candidate pool and costs a little latency

curated.params:

FieldTypeDefaultNotes
item_idsarray of external idsnone (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.

FieldTypeDefaultRangeWhat it does
exclude_seen_daysinteger0 (off)0 or moredrops 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.
localesarray of stringsnoneaccepted and stored, but not read by the engine. The request's context.locale decides the locale.
premium_policystringnone (no rule)cap, exclude or absentcap: at most premium_cap premium items for non-premium users, no cap for premium users. exclude: no premium items for non-premium users.
premium_capinteger2 when premium_policy is cap and this is 01 or more
per_collection_capinteger0 (no cap)0 or moreat most N items from one collection in a slate
freshness_daysinteger or nullnull (no limit)0 or moredrops items published more than N days ago
diversitynumber0 (off)0 to 1an 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_boostnumber0.30 to 1added 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_daysinteger300 to 365items 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_daysinteger300 to 3650items 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_daysinteger70 to 365an 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_daysinteger300 to 365a 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_unavailablebooleantrueproducts 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_floornumber0 (off)0 to 1on 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#

FieldTypeDefaultRangeNotes
kindstringlinearlinear, learned_linear, gbdtlambdamart is refused (400): no model is trained for it
banditbooleanfalsea 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_blendbooleantruethe 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_versionstringemptyinformational; 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#

FieldTypeDefaultRangeNotes
kindstringnonenone, plackett_luceanything else is a 400
sharenumberthe surface's exploration_share when 0 or absent0 to 0.5the share of eligible requests that sample instead of taking the top-k
temperaturenumber0.7 when 0 or absentabove 0higher 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.

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.

Surface fields that interact with the flow#

These live on the surface, not the flow (Admin).

FieldDefaultNotes
slots6slate length; a request's slots (1 to 50) overrides it
item_kindsrequiredcandidates 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_share0.2used when the flow's exploration.share is 0 or absent
statusshadowshadow, 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_slots00 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_resortfalsethe 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:

MessageFix
config must be an object: …send a JSON object under config
config.generators must hold at least one generatoradd a generator
unknown generator kind Xuse 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 gbdtfix 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_templateuse 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.

esc
  • Type to search every page, heading and code sample.