# What the engine uses, and what it refuses

> Every input the recommender reads, where it acts, and the data it turns away on purpose.

Source: https://staging.patterna.nl/docs/signals

This page lists every input Patterna's recommender reads, in the form it accepts it, and where each one acts. Anything not on this page is not used. The proof rules hold for all of it: every served slot keeps its exact logged propensity, caps live inside the sampler, and the control arm never reads a personal signal.

Where a signal acts:

| Code | Meaning |
|---|---|
| **G** | a generator score (it proposes and scores items) |
| **F** | a filter or a cap (it keeps items out) |
| **R** | a feature of the nightly `gbdt` ranker (read when a flow uses `ranker.kind: gbdt`) |
| **L** | a ledger metric |
| **E** | the reason text |

## 1. Request context

Sent per call in `context` on [POST /recommendations](https://staging.patterna.nl/docs/api-serve). It is stored only with that request's impression, never on the user.

| Field | Form | Acts in | Notes |
|---|---|---|---|
| `locale` | `nl`, `en`; `nl-NL` counts as `nl` | F | matches the item's locales; a locale no item carries widens to all items |
| `platform` | `ios`, `android`, `web` | R | |
| `app_version` | a version string | logged | stored with the impression; not ranked on |
| `hour_bucket` | 0 to 23, from the device clock | G `time_fit`, R | with the weekday it picks one of eight time buckets: weekday or weekend × morning (06–11), day (12–17), evening (18–22), night |
| `weekday` | 0 (Monday) to 6, from the device clock | G `time_fit`, R | when absent the engine uses its own clock in UTC, so send it |
| `region` | one of the region names you configured (a city or a province) | G `region_match` | never coordinates; a name outside your list is `400 unknown_region` |
| `subscription_tier` | `free`, `premium` or a tier you define | F | for `free`, an item that showed this user a paywall in the last 7 days is left out. The premium cap follows `context.premium` or the profile, not this field |
| `tenure_days` | days since the account was created | logged | bucketed by the engine to new (<7), settled (<90) and long, and stored with the impression as that bucket; not a ranker feature yet. You may send it already rounded (0, 7 or 90); the exact day count is never stored |
| `anchor_item_id` | an external id of any kind | G `item_to_item`, E | resolved against the whole catalog: an article can anchor a product list |
| `session_items` | up to 20 item ids this session just saw or tapped | G `session_items`, G `vector_retrieval` | merged with what the session reported through `/interactions` and its signed events; works in every mode. An interaction is reflected in the next slate of the session: with Redis the engine keeps the session's last two hours and its running content vector there and reads them first; without Redis, or with Redis down, it reads the log |
| `member_of` | community ids | F | joined communities are never offered |
| `control_item_ids` | your editorial list for this page | control arm | logged as `control_source: request` |
| `measured`, `deadline_ms` | proof and timing controls | | see [Timeouts and the ledger](https://staging.patterna.nl/docs/timeouts-and-the-ledger) |

A bad value for `weekday`, `subscription_tier` or `tenure_days` answers `400`. The TypeScript, Go and Python clients (SDK 0.4.1) carry typed fields for them.

## 2. Catalog attributes

Sent in `attributes` on [PUT /catalog/items](https://staging.patterna.nl/docs/api-ingest). They are editorial data about the item, never about a person. Unknown keys are kept for the trace and ignored.

| Attribute | Form | Acts in |
|---|---|---|
| `format` | `text`, `video`, `audio`, `recipe`, `workout`, `tool`, `product` | G `format_fit` (against the user's learned format preference) |
| `duration_min` or `reading_time_min` | number | G `time_fit` pairs short items with the day and long ones with the evening and the weekend until the item has its own time profile |
| `difficulty` | `easy`, `medium`, `hard` | read into the item model; not ranked on yet |
| `author` | string | read into the item model; not ranked on yet |
| `available` or `in_stock` | boolean (products) | F: `false` is never served, in both arms (`filters.hide_unavailable`, on by default) |
| `expires_at` | RFC 3339 | F: never served after it, in both arms |
| `starts_at` | RFC 3339 (events) | F: a past event is never served, in both arms |
| `repurchase_days` | integer (products) | F: a product the user bought is not offered again inside this many days; set a small number for consumables |
| `region` (or the item's `region` field) | one of your regions | G `region_match` |
| `phase_fit` | a list of phases | returned only when the tenant turned `phase_tags` on; it never ranks |

Kept for the panel and your own cards, not ranked on: `price_cents`, `compare_at_cents`, `currency`, `categories[]`, `image_url`, `url`, `product_type`.

## 3. Events

Sent with [POST /events](https://staging.patterna.nl/docs/api-ingest). The weights are the nightly affinity learner's defaults. They are fixed in the learner today, not set per tenant. Every weight decays with a 30-day half-life, and the learners read slates of arm `treatment` and `none` only, never control. The full field list: [Event types](https://staging.patterna.nl/docs/reference-event-types).

| Event | Teaches | Weight | Also acts in |
|---|---|---|---|
| `rec_click`, `rec_dismiss` (or the SDK's interaction report) | what was taken or turned away on a slate | click +1.0, dismiss −1.0 | L `tap_rate`; F: a dismissed item stays out for `exclude_dismissed_days` (30) |
| `rec_seen` | what was shown | | L |
| `item_viewed` | weak interest | +0.2 | G `trending` (cold start) |
| `item_engaged` | real interest | +0.6 when 20 s or more, or 60 % scrolled | G `trending` counts an engaged view three times; R `engaged_rate`; the user's format preference |
| `item_completed` | strong interest | +1.5 | F: stays out for `exclude_consumed_days` (30, personalized); the user's format preference |
| `item_pinned`, `item_unpinned` | explicit preference | +2.0 / −1.0 | F: unpinned counts as dismissed |
| `community_joined`, `community_left` | membership | +1.5 / −1.0 | F: joined communities are never offered; L `join_rate` |
| `community_post_created`, `community_reply`, `community_reaction` | active membership | +1.5 on the community, +0.5 on its tags | R `activity_rate` per community |
| `community_post_viewed` | weak interest in a community | +0.2 | |
| `search_performed` with `clicked_item_id` | intent right now | +0.8 on the clicked item | the query arrives only as a hash |
| `push_opened` | content that pulls people back | +1.0 | R `pullback_rate` per item |
| `product_viewed` | shop interest | +0.5 | |
| `order_placed` | a purchase | +2.0 per product | L `purchase_rate`; F: the repurchase window; co-purchase pairs for product neighbours |
| `paywall_viewed` | a free user hit the wall | | F: not offered to that free user again for `paywall_cooldown_days` (7) |
| `subscription_started`, `subscription_cancelled` | a tier change | | |
| `event_rsvp`, `event_attended` | event interest | +1.0 on `event_rsvp` | |
| `coach_product_shown`, `coach_product_tapped` | assistant outcomes | | reported, no holdout |
| `consent_changed`, `user_deleted` | identity lifecycle | | the opt-out and deletion paths |

## 4. What the nightly jobs learn from them

| Artifact | From | Used by |
|---|---|---|
| item features: `engaged_rate`, `pullback_rate`, `activity_rate`, `purchase_rate` | events and impressions | R |
| item time profile: tap rate in each of the eight time buckets, smoothed toward the surface's own rate | impressions with their context and the taps on them | G `time_fit` |
| the user's format preference and active time buckets (personalized only) | `item_engaged`, `item_completed`, taps | G `format_fit` |
| purchases per user and product | `order_placed` | F: the repurchase window; deleted with the user |
| embeddings and item neighbours | item text, embedded by a local multilingual model on Patterna's own host | G `item_to_item`, cold start |
| the vector index over the embeddings (HNSW, cosine) | the same embeddings, indexed in the database | G `vector_retrieval`: the k items nearest to what the person looked at join the candidates per request; a new item counts from the night it was embedded |
| the person's 200 nearest users (user ids and similarities only, never items) | the same matrix factorisation as `user_bayes`, or shared items for people without a row | G `user_to_user`; deleted with the user |
| the next-item model (a small neural network over the order in which people open items, exported to ONNX; one per tenant, replaced only when it beats the plain path rule on held-out data) | the same positives the collaborative learners read, each person's or session's items in order | G `sequence_model` |
| product co-purchase pairs | `order_placed`, or a shop's orders export read as aggregates | product neighbours |
| generator posteriors per surface | logged contributions and taps | the Thompson mixture when `ranker.bandit` is on |

## 5. Refused on purpose

Patterna does not accept: cycle phase or symptoms, mood, conversations with an assistant, precise location, names, email addresses, IP addresses, free-text search queries (only their hash), and anything tied to a user id in `non_profiled` mode. An event that carries a field like this is rejected with `pii_rejected` and the field's name, never stored with the field quietly removed. The catalog importers do not read health or conversation tables. See [Data we never store](https://staging.patterna.nl/docs/data-we-never-store).

## 6. How a flow turns them on

Each signal is opt-in per surface through its flow ([Flow config reference](https://staging.patterna.nl/docs/reference-flow-config)):

```json
{
  "generators": [
    { "kind": "item_to_item", "weight": 0.5 },
    { "kind": "session_items", "weight": 0.3 },
    { "kind": "time_fit", "weight": 0.15 },
    { "kind": "format_fit", "weight": 0.15 },
    { "kind": "trending", "weight": 0.2 },
    { "kind": "surface_specific", "weight": 0.1 }
  ],
  "filters": {
    "diversity": 0.2,
    "exclude_dismissed_days": 30,
    "paywall_cooldown_days": 7,
    "region_boost": 0.3
  },
  "ranker": { "kind": "gbdt", "bandit": false }
}
```

Every generator is optional. A surface without the signal gets zero from it and still serves. The personal exclusions (dismissed, completed, paywalled, bought) are on by default and relaxed when nothing else would be left; the hard rules (expired, past events, out of stock) are never relaxed and apply in both arms. Simulate and the ledger read the logged contributions, so adding a signal never changes what was already proven.

## 7. What a reason may say

A slot's reason names the generator that won it, and only when that is true:

- the winner's own signal must be positive for the item (a similarity, an affinity or a model score above zero);
- "trending" needs this surface's own log; before it has one, the slot says "popular";
- "people like you" needs a real persona, not the cold-start one;
- "new" means published in the last 30 days.

Otherwise the slot gets the reason key `recommended` ("Aanbevolen" / "Recommended") and nothing more. `time_fit`, `format_fit` and `region_match` never name a reason: "because it is 9 pm" is not a reason a person would accept. `curated` is a boost by rank (the first item +2), so an editor's list of three items never drowns a catalog of five hundred. See [Reasons](https://staging.patterna.nl/docs/reasons).
