Skip to main content

What this solves

Operators consistently want a few related-but-different things:
  1. “Don’t over-contact this customer on a given offer/channel.” A per-day cap on how often one offer is shown.
  2. “Cap total touches to a customer,” or “cap a whole category.” These need different rule types — see the important note below.
The frequency_cap ruleType handles per-offer capping. Its scope (global, offer, creative, channel, category, subcategory, plus placement/segment at runtime) decides which candidates the rule is checked against — not which interaction rows are counted.
frequency_cap always counts the candidate’s own offer + channel. No matter the scope, the engine sums impressions for that specific (offer, channel) in the period — aggregateSummariesForScope filters by candidate.offerId and candidate.channelId. The scope is only an applicability gate. So a scope: global frequency_cap is not a “total touches across everything” cap — it caps each offer+channel independently. For a true total-across-all-offers cap use customer_total_cap; to cap one offer across channels use cross_channel_cap.

The pattern

A contact policy is {ruleType, scope, scopeId?, config}. For frequency_cap, config carries maxPerDay / maxPerWeek / maxPerMonth / maxTotal — any subset; a missing key means no cap on that window. There is no lookbackHours option: the windows are fixed calendar periods (daily = UTC date, weekly = ISO week, monthly = calendar month, maxTotal = alltime). The engine sums the candidate’s (offer, channel) impressions for the current period key and blocks the candidate when the count meets or exceeds the cap.

Recipe 1 — Global per-day cap (3 touches/day, all channels, all categories)

After three impression respond events for a given (offer, channel), the next recommend filters that offer — each offer+channel is capped independently (see the note above), so this is not a single “3 total touches/day” ceiling. Verify via the persisted decision trace: contactPolicyResults[*].reason reads "Daily frequency cap reached: 3/3".

Recipe 2 — Per-category cap (Cards category 5/week)

The scope: category gate means the rule is only checked against candidates whose offer is in the Cards category; Loans offers are never evaluated. Each Cards offer is still counted on its own (offer, channel) — this is not an aggregate across every Cards offer combined. (The denormalized interaction_summaries.offerCategory column from fix #155 is used by the separate offer_category_cap rule type, not by frequency_cap.)

Recipe 3 — Per-channel cap (Email 2/day, push unlimited)

Combine multiple per-channel caps to express “no more than 2 email impressions of any one offer, 1 push, 0 SMS per day” (remember the per-offer+channel counting from the note above).

Recipe 4 — Per-offer cooldown (don’t show the same offer twice in 24h)

This is a different ruleType — cooldown, not frequency_cap:
The engine reads the timestamp of the last impression for that (customer, offer) pair and suppresses if within cooldownHours.

What the trace will show

The persisted decision trace exposes contactPolicyResults (enriched from the engine’s contactPolicyReasons). Each row is { offerId, offerName?, blocked, eligible, reason, policyId } — there is no scope / scopeId / ruleType / decision field on the enriched row, and the reason uses the engine’s wording ("Daily/Weekly/Monthly/Total frequency cap reached: N/N"):

Gotchas

  • Caps only count impressions that are recorded. Channels default to impressionMode: "explicit", which means an impression is counted only when your client POSTs /respond. If you test a cap by hammering /recommend alone (no /respond), no impressions accrue and the cap looks like it’s being ignored — the same offer returns every call. Either set the channel to impressionMode: "implicit" (the engine auto-records an impression per returned result) or send /respond impression events.
  • Under implicit mode, impressions are per creative. The cap aggregates by (offer, channel), but each returned (offer, creative, channel) result records its own impression. If an offer has two active creatives on the same channel (e.g. an A/B creative test), a single /recommend records two impressions toward that offer×channel cap — a maxPerDay: 2 can trip in one call. Keep one active creative per offer per channel for exact cap arithmetic, or account for the creative count.
  • A scoped rule with a null scopeId matches nothing useful. If a frequency_cap has scope: "category" but scopeId: null, the scope check compares null to the candidate’s category id, so it only ever matches candidates that themselves have no category — it does not fall back to global. Always set scopeId when scope is anything other than global.
  • metric_condition is a different beast. That ruleType reads behavioral metricValue rows (e.g. impression_count_30d > 10), not raw interaction summaries. Use it when you want windowed-aggregate-driven caps.
  • Windows are fixed calendar periods, not rolling. maxPerDay counts within the current UTC calendar day (resets at UTC midnight), maxPerWeek uses the ISO week, maxPerMonth the calendar month, and maxTotal is alltime. There is no lookbackHours / rolling-window option on frequency_cap.

Proof reference

T4 (frequency_cap 3/day fires) and T128 (#155 offer_category_cap denormalization fix verified) in the proof bundle.