/recommend call when tenantSettings.aiAnalyzerSettings.ranking.lagrangianEnabled is true.
Cross-offer constraints work through the Lagrangian solver, not the hard-constraint filter. They apply a continuous shadow-price penalty that softly rotates traffic rather than hard-dropping offers. See Lagrangian Ranking for background.
Base path
List cross-offer constraints
Query parameters
Response 200
Error codes
Create a cross-offer constraint
201 with the created row.
Request body — shared fields
config shapes by ruleType
channel_quota
Caps the number of offers selected from the listed channels in a single solve.
Cost vector:
1 per pick of an offer whose channelId is in channels, 0 otherwise.
portfolio_budget
Caps total cost (summed costPerAction) across a set of offers.
Cost vector: the offer’s
costPerAction for offers in scope, 0 for all others.
category_cap
Caps the number of offers selected from the listed categories.
Cost vector:
1 per pick of an offer whose categoryName matches any entry in categories (case-insensitive), 0 otherwise.
Response 201
Returns the created constraint row.
Error codes
Update a cross-offer constraint
Request body
Response 200
Returns the updated constraint row.
Error codes
Delete a cross-offer constraint
status: "archived" to retain the row for audit purposes.
Query parameters
Response 200
Error codes
Role requirements
Runtime paths
Cross-offer constraints drive two execution paths that both read thecross_offer_constraints table. Since 2026-07-03 a single loader (resolveCrossOfferConstraints in src/lib/ranking/cross-offer.ts) translates the canonical rows written by this API — config.cap plus config.channels/config.offerIds/config.categories — for both the realtime and batch solvers, so rows created here feed both paths.
Realtime path — /recommend hot path
File: src/lib/ranking/cross-offer.ts (loadCrossOfferConstraints → buildCrossOfferConstraints)
Activated when tenantSettings.aiAnalyzerSettings.ranking.lagrangianEnabled is true. The solver loads all active rows, builds a per-candidate cost vector for each constraint, and runs a single Lagrangian solve that combines per-offer budget/inventory constraints with these cross-offer constraints. The crossOfferEnabled flag is not consulted on this path — enabling lagrangianEnabled is sufficient.
Constraints are silently skipped (not applied) when:
config.capis missing, non-finite, or negative.- All candidates produce a cost of
0for a given constraint (avoids diluting solver attention on irrelevant constraints).
Batch path — batch-executor.ts
File: src/lib/ranking/cross-offer.ts (resolveCrossOfferConstraints)
Activated when both lagrangianEnabled and crossOfferEnabled are true. This resolver reads the same canonical rows as the realtime path (config.cap + config.channels/config.offerIds/config.categories) — the former load-cross-offer.ts loader that expected a separate config.rhs sub-shape was removed on 2026-07-03 because it silently dropped every API-written row. Rule-type coverage differs slightly on the batch path:
portfolio_budget—applicableOfferIdsisconfig.offerIdsintersected with the candidate set. The batch solver models one scalar cost per selection, so it readsconfig.costPerSelection(default1= selection-count capping) and logs when it falls back to the default. Per-dollarcostPerActioncosts are a realtime-path feature.category_cap— offers in the candidate set whose category name (theCategoryrelation, falling back to the legacyOffer.categorystring) matchesconfig.categories, case-insensitive.channel_quota— skipped on the batch path (with awarnlog) because the per-offer batch model has no per-candidate channel context. Only the realtime path enforceschannel_quota.
Enable the realtime path
Operational notes
Per-pick cost semantics
Cross-offer costs are assessed per candidate (per creative-pick). If two creatives for the same offer are both ranked, each pays the full cost toward the cap. The intent is “N picks toward the cap when N creatives are picked.” A per-offer (rather than per-creative) rule type is on the roadmap. The current workaround is to limit candidates to one creative per offer upstream of the rank node.Monitoring
When the realtime wire runs,pipeline-runner.ts emits a realtime ranking applied log line that includes crossOfferConstraintCount. A value of 0 when you expect active constraints indicates either that the constraints were dropped (check the warn-level cross-offer constraint load failed log) or that all candidates produced zero cost for every constraint.
Failure behavior
The cross-offer loader (loadCrossOfferConstraints) returns [] on any Prisma error and logs at warn level. The solver then proceeds with per-offer constraints only. The realtime path never returns a 5xx because of a constraint-loading failure.
Related
- Lagrangian Ranking — how the solver uses these constraints.
- AI Analyzer Settings — enable
lagrangianEnabledandcrossOfferEnabledflags. - Ranking Profiles API — multi-objective weight configuration.
- Budgets API — per-offer budget and inventory hard constraints.