Patterna Docs

Surfaces and flows

A surface is a place in your app; a flow is its versioned recipe. Both move through a lifecycle you control.

Everyone 7 min read View as Markdown

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#

FieldMeaning
keystable id used in API calls, e.g. home_articles
namelabel in the panel
item_kindswhich kinds may appear: document, product, community, event, tip
slotshow many items a slate has; default 6. A request may ask for 1 to 50 with slots.
fallbackthe 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_sharedefault share of explored requests when the flow sets none; default 0.2, at most 0.5
statusshadow (default), testers, live or off
min_slotsset 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_resortset 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 editorial and fallback: 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 from context.control_item_ids (logged control_source: request), else the surface's list for this anchor from fallback.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, and exclude_item_ids, joined communities, candidate kinds, the premium policy and locale apply. Newest items that pass the rules fill the rest. context.control_item_ids needs 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#

code
shadow ──▶ testers ──▶ live ──▶ off
StatusWhat Patterna returnsArmWhat you check
shadowthe ranked slate; your app keeps showing your own listnonerequests arrive, latency, fallback rate, slates look sane in the preview
testersthe ranked slate for ids on the testers list, the editorial list for everyone elsenonereasons render on device, seen and click reach the trace view by request id
livethe ranked slate; with a running holdout, control gets the editorial listtreatment/control during a holdout, else nonethe ledger, SRM, fallback rate
offnothing: 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:

JSON
{
  "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 silent time_fit and format_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_floor hides 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) or gbdt (gradient-boosted trees the jobs fit nightly, evaluated in the engine). lambdamart is 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_slots on 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 carries shown: false and no items: hide the block. No recommendations means no block, so 1 is the usual value; 0 means always shown. The slate is logged not_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 answers 409 not_shown.
  • context.measured on the request (default true). Set it to false when 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 live surface with the two-person rule on, a direct activation answers 409 approval_required and 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
curl -sS "https://patterna-api.orven.am/v1/surfaces/home_articles/flows" \
  -H "Authorization: Bearer $PATTERNA_ADMIN_KEY"
curl
curl -sS -X POST "https://patterna-api.orven.am/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.kind is not linear, learned_linear or gbdt;
  • filters.diversity is outside 0 to 1;
  • exploration.kind is not none or plackett_luce, or exploration.share is 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.

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