Surfaces and flows
A surface is a place in your app; a flow is its versioned recipe. Both move through a lifecycle you control.
A surface is one placement in your app that shows recommendations, such as the article list on Home. A flow is the recipe that fills it: which generators run, which filters apply, which ranker and how much exploration. A surface has one active flow version at a time. You change a surface's behaviour by activating another flow version, never by editing the active one.
Surfaces#
| Field | Meaning |
|---|---|
key | stable id used in API calls, e.g. home_articles |
name | label in the panel |
item_kinds | which kinds may appear: document, product, community, event, tip |
slots | how many items a slate has; default 6. A request may ask for 1 to 50 with slots. |
fallback | the editorial list: { "kind": "newest" } (default), { "kind": "curated", "item_ids": [...] } or { "kind": "popular" } (7-day popular). Newest fills any gap. Optional by_anchor: an object from an anchor item id to the editor's list for that page, for example { "kind": "newest", "by_anchor": { "article-12": ["p-1", "p-2"] } }, see below |
exploration_share | default share of explored requests when the flow sets none; default 0.2, at most 0.5 |
status | shadow (default), testers, live or off |
min_slots | set with PATCH /surfaces/{key}: 0 to 50, 0 = always shown. A slate shorter than this is answered with shown: false. Use 1 to hide a block that has nothing to show |
device_resort | set with PATCH /surfaces/{key}: the app may re-sort the slate on the device, see below |
The editorial list has two jobs, and the flag on the slate tells them apart:
- Control. In a running holdout, the control arm gets the editorial list, logged with policy
editorialandfallback: false. That is the comparison, not a failure, and it never counts in the fallback rate. On an anchored surface the list can follow the page. In order, control serves: your own list for this page fromcontext.control_item_ids(loggedcontrol_source: request), else the surface's list for this anchor fromfallback.by_anchor(control_source: by_anchor), else the surface's own list (control_source: surface). Every source obeys the request's rules: the anchor is never served, andexclude_item_ids, joined communities, candidate kinds, the premium policy and locale apply. Newest items that pass the rules fill the rest.context.control_item_idsneeds no setting on the surface: send it on every request to the surface, in both arms, since you do not know the arm in advance; the treatment arm uses it only to fill a short slate. - Fallback. When a request that should have been ranked times out or hits storage trouble, or the surface has no active flow, Patterna answers with the same list and
fallback: true. That is the safety net, and it counts in the fallback rate.
The lifecycle#
shadow ──▶ testers ──▶ live ──▶ off| Status | What Patterna returns | Arm | What you check |
|---|---|---|---|
shadow | the ranked slate; your app keeps showing your own list | none | requests arrive, latency, fallback rate, slates look sane in the preview |
testers | the ranked slate for ids on the testers list, the editorial list for everyone else | none | reasons render on device, seen and click reach the trace view by request id |
live | the ranked slate; with a running holdout, control gets the editorial list | treatment/control during a holdout, else none | the ledger, SRM, fallback rate |
off | nothing: 409 surface_off |
The testers list holds Patterna user ids, or session ids in non_profiled mode. Your backend can also send the header X-Patterna-Tester: 1 for its own staff, so your roster decides and nobody needs to look up random ids.
A request can also set "shadow": true. Patterna then logs and returns the slate with arm none, marked as not shown. The panel's preview uses this.
off is enforced by the engine: requests get 409 surface_off, which your backend treats as "show your own list". Keep your own feature flag as well: it is the instant switch that needs no call at all.
Flows#
A flow config has these parts:
{
"generators": [
{ "kind": "content_affinity", "weight": 0.5 },
{ "kind": "trending", "weight": 0.3 },
{ "kind": "curated", "weight": 0.2 }
],
"filters": {
"exclude_seen_days": 7,
"premium_policy": "cap",
"premium_cap": 2,
"per_collection_cap": 3,
"freshness_days": null,
"diversity": 0.2
},
"ranker": { "kind": "linear" },
"exploration": { "kind": "plackett_luce", "temperature": 0.7, "share": 0.2 },
"reasons": {
"trending": { "nl": "Veel gelezen deze week", "en": "Read a lot this week" }
}
}- Generators score the candidates:
trending(the 7-day tap rate on this surface, all locales together),curated,item_to_item,session_items,content_affinity,persona_bayes,user_bayes,user_to_user,surface_specific, and the silenttime_fitandformat_fit. Each score is normalised across the candidates and multiplied by the weight. - Filters remove what must not show (expired, past events, out of stock, already seen, premium beyond the policy, too old), leave out what this person turned away or finished (dismissed, completed, paywalled, bought) and cap a slate (per collection, premium).
relevance_floorhides an anchored block when nothing is related to the page. See What the engine uses, and what it refuses.filters.diversity(0 to 1) is the one diversity setting: a penalty on items similar to the ones already picked, applied inside the sampler so propensities stay exact. The panel shows it on the ranker node. - Ranker is
linear(the configured weights),learned_linear(per-generator weights the jobs fit nightly from logged taps) orgbdt(gradient-boosted trees the jobs fit nightly, evaluated in the engine).lambdamartis refused. - Exploration runs on treatment slates and on slates outside a holdout (arm
none); control is never explored. See Exploration and propensities. - Reasons are keyed by reason key or generator kind, with placeholders: see Reasons.
Every field, default and valid range is in the Flow config reference. In the panel the flow is a node canvas; the JSON view shows the same config.
One slate, several blocks#
Sometimes one page shows the same kind of recommendation twice, for example one product near the top of an article and a carousel of six at the end. Ask for one slate of 7 and render position 0 in the first block and positions 1 to 6 in the second. Then the two blocks never repeat each other, and the propensities stay in one impression. Report each item with its slate position (0 to 6), whichever block it was in, and send the optional block name (up to 32 characters, for example top or end) on each interaction, so you can tell the blocks apart in the trace. The ledger can also give a row per block (by=block), over the slates reported seen in that block; the whole-slate row decides.
Two settings help here:
min_slotson the surface. When fewer items than this pass the request's rules (exclusions, the anchor, joined communities, candidate kinds, premium, freshness; counted before the arm, so both arms hide alike), the answer carriesshown: falseand no items: hide the block. No recommendations means no block, so 1 is the usual value; 0 means always shown. The slate is loggednot_shown, stays in its arm and counts as served with zero success in both arms (intention to treat). Never report it as abandoned: it is already counted, and abandon answers409 not_shown.context.measuredon the request (defaulttrue). Set it tofalsewhen the user did not allow usage analytics and your app will not report seen and taps. Such slates are served as usual but left out of the ledger, power and learning in both arms.
See Timeouts and what counts in the ledger.
Re-sorting on the device#
A surface under a holdout must not be re-sorted by your app unless the surface allows it: the ledger and the estimators read the position Patterna served.
Set device_resort: true on the surface (PATCH /surfaces/{key}). Your app may then re-sort the slate with a rule of its own, and must send displayed_position on every interaction; without it the interaction is refused with 422 displayed_position_required. The served position comes from the logged slate. The ledger stays intention to treat on served slates: both arms are re-sorted by the same rule, so the comparison holds. The estimators use displayed_position.
Versions, activation and rollback#
- Saving creates a version. Every save is a new, numbered, inactive version. Nothing changes on the surface.
- Simulate first. Run the version on logged traffic to see a SNIPS estimate and the twin curve.
- Activate. Activation is audited. A note is optional in the API and required in the panel. On a
livesurface with the two-person rule on, a direct activation answers409 approval_requiredand a second person approves the request. - Rollback is activation. The previous versions stay. Rolling back means activating an older version, one click in Flows.
curl -sS "https://api.patterna.nl/v1/surfaces/home_articles/flows" \
-H "Authorization: Bearer $PATTERNA_ADMIN_KEY"curl -sS -X POST "https://api.patterna.nl/v1/surfaces/home_articles/flows/3/activate" \
-H "Authorization: Bearer $PATTERNA_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{"note": "Back to v3: v4 raised the fallback rate"}'Every slate records its policy_version and flow_version, so the ledger and the trace view always know which recipe served which request.
Validation#
Saving a flow (POST /surfaces/{key}/flows) is refused with 400 bad_request when:
- there is no generator, or a generator kind is unknown;
ranker.kindis notlinear,learned_linearorgbdt;filters.diversityis outside 0 to 1;exploration.kindis notnoneorplackett_luce, orexploration.shareis outside 0 to 0.5.
Unknown reason keys, locales and placeholders are refused with 400 invalid_reason_template. The flow editor shows the same checks on the canvas, plus nodes that cannot be reached.