| Composable Pipeline | |
|---|---|
| Structure | 14 node types across 3 phases |
| Config format | Ordered nodes array with "version": 2 |
| Flexibility | Add, remove, reorder nodes freely |
| Status | Stable, all 14 node types fully functional |
Request Lifecycle
Every call toPOST /api/v1/recommend follows this lifecycle.
Flow Resolution
When the request does not include adecisionFlowKey, the engine resolves the flow automatically using FlowRoute records. Resolution uses most-specific-match-wins:
- Channel + Placement — exact match on both
- Channel only — matches any placement for that channel
- Tenant default — fallback when no channel/placement match exists
runDecision() path, which applies qualification rules, contact policies, and priority-weighted scoring directly without a flow config.
Multi-Placement Requests
When the request body includes aplacements array, the engine resolves each placement independently. With deduplicate: true, placements are resolved sequentially and each subsequent placement excludes offers already selected by previous placements. Without deduplication, all placements resolve in parallel.
Composable Pipeline
The composable pipeline uses an ordered array of nodes. Each node has atype, id, and config object. The runner validates the pipeline structure before execution, then processes nodes sequentially.
Three Phases
Nodes are organized into three logical phases. The pipeline validator enforces that Phase 1 nodes appear before Phase 2, and Phase 2 before Phase 3.All 14 node types are fully functional — no stubs. The
call_flow node (shown in amber) can be inserted at any phase to invoke a sub-flow, with a max nesting depth of 2 and circular reference detection. The enrich node queries schema tables with Redis caching, the qualify node evaluates qualification rules with AND/OR logic trees.14 Node Types
| Node | Phase | Description | Key config fields |
|---|---|---|---|
inventory | 1 — Narrow | Load offers from DB. Scope by all, category, or manual. Builds candidate pairs (Offer x Creative). | scope, categoryIds, offerIds, includeStatuses |
match_creatives | 1 — Narrow | Filter candidates by creative availability and placement match mode (exact, any, none). | requireCreative, placementMatchMode |
enrich | 1 — Narrow | Load customer data from schema tables. Queries with configurable lookup key, caches via Redis, supports multiple sources in sequence. | sources[].schemaId, fields, prefix, cacheTtlSeconds |
qualify | 1 — Narrow | Evaluate qualification rules with AND/OR logic trees. Loads rules from database, supports nested groups. | mode, qualificationRuleIds, logic |
contact_policy | 1 — Narrow | Apply contact policy filters (frequency caps, cooldowns, mutual exclusion). | mode, rules |
filter | 1 — Narrow | Generic attribute-based filtering using condition expressions. Evaluates against offer fields, enriched data, and request attributes. | conditions |
score | 2 — Score & Rank | Assign scores using priority_weighted, propensity (model-based), formula (weighted composite of propensity, context, value, lever), or external model endpoints. Supports channel overrides and champion/challenger experiments. | method, modelKey, formula, channelOverrides, championChallenger |
optimize | 2 — Score & Rank | Apply multi-objective portfolio optimization using saved profiles or inline weight sliders. Balances revenue, margin, propensity, engagement, and custom objectives. | profileId, weights, customObjectives |
rank | 2 — Score & Rank | Sort by score descending using one of 4 methods (topN, diversity, round_robin, explore_exploit). Apply maxCandidates, maxPerCategory, and maxPerChannel limits. | method, maxCandidates, maxPerCategory, maxPerChannel, explorationRate |
group | 2 — Score & Rank | Allocate candidates to named placements using optimal (Hungarian algorithm) or greedy strategy. Enables the grouped response format. | placements, allocationStrategy, allowPartial |
compute | 3 — Output | Evaluate formulas to produce personalized values. Supports overrides (replace existing fields) and extras (add new fields). Formulas can chain — each result is available to subsequent formulas. | overrides, extras |
set_properties | 3 — Output | Attach key-value properties to candidates. Values can be static or formula-driven. | properties[].key, value, formula |
call_flow | Cross-phase | Invoke a sub-flow and merge its results. Max nesting depth of 2, circular reference detection. Supports fail-open (optional) and fail-closed modes. | flowId, passContext, mergeMode, optional |
response | 3 — Output | Terminal node. Assembles the final DecisionFlowResult with ranks, personalization, properties, and optional debug trace. Controls response format (flat or grouped). | responseFormat, includeDebugTrace |
Execution Model
The pipeline runner maintains a mutablecandidates array and a groupResult map as shared pipeline state. Each node reads from and writes to these structures:
- Validation —
validatePipeline(nodes)checks phase ordering and required node presence before any execution. - Sequential loop — Nodes execute in array order via a
for...ofloop with aswitchonnode.type. - Early return — The
responsenode returns the assembled result immediately, terminating the loop. - Trace accumulation —
traceSummarycounters are updated at key nodes (inventory,qualify,contact_policy,rank).
Formula Engine
The formula engine (lib/formula-engine.ts) provides safe expression evaluation with no dynamic code execution. Every formula goes through three stages:
Pipeline
NUMBER, STRING, IDENT, operators (+, -, *, /, %, >, <, >=, <=, ==, !=), punctuation ((, ), ,, ?, :), and EOF.
2. Parser — Recursive-descent parser that builds an AST respecting operator precedence:
| Precedence (low → high) | Operators |
|---|---|
| Ternary | condition ? consequent : alternate |
| Comparison | >, <, >=, <=, ==, != |
| Additive | +, - |
| Multiplicative | *, /, % |
| Unary | - (negation) |
| Primary | Numbers, strings, identifiers, function calls, grouped expressions |
Built-in Functions
| Function | Signature | Description |
|---|---|---|
min | min(a, b) | Minimum of two numbers |
max | max(a, b) | Maximum of two numbers |
round | round(x) or round(x, decimals) | Round to nearest integer or specified decimal places |
abs | abs(x) | Absolute value |
coalesce | coalesce(a, b, ...) | First non-null value |
concat | concat(a, b, ...) | String concatenation (null propagates) |
Variable Namespaces
Formulas resolve identifiers from three namespaces:| Namespace | Example | Source |
|---|---|---|
| Bare field names | base_rate, discount_pct | Offer custom field values |
customer.* | customer.loan_amount, customer.tenure | Enriched data from schema tables (Enrich stage) |
attributes.* | attributes.tier, attributes.device | Request-time attributes from the Recommend body |
Scoring
The engine supports multiple scoring methods, selected per flow configuration.Scoring Methods
| Method | Description | When to use |
|---|---|---|
| priority_weighted | score = (priority / 100) * (weight / 100) * fitMultiplier | Default. Good for rule-based decisioning without ML models. |
| propensity | Uses a registered scoring model (scorecard, Bayesian, gradient boosted, etc.). Falls back to priority_weighted if the model is missing or the circuit breaker is open. | When you have trained propensity models. |
| formula | Weighted composite: score = wP * propensity + wC * context + wV * value + wL * lever. Weights are normalized to sum to 1.0. | When you want explicit control over score composition. |
Model Resolution Hierarchy
Whenmethod is propensity or formula, the engine resolves the scoring model through this hierarchy:
- Active experiment — If an experiment references the configured
modelKey, the engine uses experiment-aware traffic splitting to select champion vs. challenger models. Assignment is tracked via theexperimentAssignmentTotalcounter. - Direct model lookup — Falls back to
algorithmModel.findFirst({ key: modelKey, status: "active" }). - Pre-computed propensity scores — If no model is found, checks
attributes.propensityScores[modelKey]from the request body. - Priority-weighted fallback — Last resort: uses
priority / 100as the score.
Circuit Breaker
The scoring stage includes an in-memory circuit breaker per model key:- Threshold: 5 consecutive failures
- Cooldown: 60 seconds
- Behavior: When the circuit is open, the engine skips the model entirely and uses the fallback score (default
0.5, configurable viaSCORING_FALLBACK_SCOREenv var). ThedegradedScoringflag is set on the response.
When
explain=true is passed to the Recommend API, each decision includes a degraded boolean that surfaces whether that specific offer was scored using the fallback due to a circuit breaker trip. The response also includes an arbitrationScores breakdown (propensity, relevance, impact, emphasis, composite) so you can inspect exactly how the PRIE formula was evaluated per offer. See Recommend API — Decision Explanations for the full response shape.Portfolio Optimization
Multi-objective portfolio optimization computes a weighted composite score across five dimensions:Dimensions
| Dimension | What it measures | Range |
|---|---|---|
| conversion | Predicted conversion probability (propensity score) | 0 – 1 |
| margin | Expected margin or revenue contribution | 0 – 1 |
| fatigue | Inverse of contact frequency — penalizes over-contacted customers | 0 – 1 |
| fairness | Distribution fairness — prevents offer concentration | 0 – 1 |
| recency | Time since last interaction — favors fresh offers | 0 – 1 |
Default Weights
By default, onlyconversion has a non-zero weight (1.0), making the optimized score equal to the conversion probability. Configure weights via Portfolio Optimization profiles in Studio or via the Optimize pipeline node to enable multi-objective optimization.
If the total weight sums to zero, the engine falls back to the raw conversion score.
Decision Traces
The engine produces atraceSummary on every decision for lightweight observability, plus an optional debugTrace with full diagnostic detail.
Trace Summary (always captured)
| Field | Description |
|---|---|
totalCandidates | Offers loaded from inventory |
afterQualification | Candidates surviving qualification rules |
afterContactPolicy | Candidates surviving contact policy filters |
topScores | Top 3–10 scored candidates with offer IDs and scores |
policyVersion | SHA-256 hash of all active policy configs (for forensic replay) |
Debug Trace (when debug=true)
Includes everything in the trace summary, plus:
qualificationReasons— Per-offer disqualification details (rule ID, reason)contactPolicyReasons— Per-offer/creative suppression details (policy ID, reason)featureContributions— Per-result score explainability (model type, base score, top factors)afterConsent,afterGuardrails— Additional stage counters
Sampling and Storage
Decision traces are persisted to theDecisionTrace table. The tenant settings decisionTraceEnabled and decisionTraceSampleRate control whether and how often traces are written.
Policy Snapshots
On every decision, the engine persists a policy snapshot (fire-and-forget) containing the current qualification rules, contact policies, and guardrail rules along with apolicyVersionHash. This enables forensic replay: given a decision trace, you can reconstruct the exact policy state that was active when the decision was made.
Worked Example
This example traces a pipeline executing a Recommend request for a banking cross-sell use case. The flow is configured with 10 nodes.Pipeline Config
Execution Trace
| Step | Node | Candidates In | Candidates Out | What happened |
|---|---|---|---|---|
| 1 | inventory | 0 | 10 | Loaded 10 credit card offers (active status, category = credit_cards) |
| 2 | match_creatives | 10 | 8 | Dropped 2 offers with no creative matching the requested placement |
| 3 | filter | 8 | 8 | All 8 pass the status=active condition (already filtered by inventory) |
| 4 | qualify | 8 | 6 | 2 offers disqualified — customer not in required segments |
| 5 | contact_policy | 6 | 5 | 1 offer suppressed — frequency cap exceeded (3 impressions in 7 days) |
| 6 | score | 5 | 5 | Propensity scores assigned via cc_propensity_v3 model: 0.82, 0.71, 0.65, 0.44, 0.31 |
| 7 | rank | 5 | 3 | Top 3 by score selected (maxCandidates=3): scores 0.82, 0.71, 0.65 |
| 8 | group | 3 | 3 | Allocated to placements — hero: 1 offer (score 0.82), sidebar: 2 offers (scores 0.71, 0.65) |
| 9 | compute | 3 | 3 | monthly_payment calculated for each offer using enriched customer.loan_amount |
| 10 | response | 3 | 3 | Assembled grouped response with 2 placements, ranks, personalization, and debug trace |
Response Shape
Next Steps
Recommend API
Full request/response reference for the Recommend endpoint.
Decision Flows
Configure Decision Flows in Studio.
Composable Pipeline
Build flows from 14 modular node blocks.
Algorithms & Models
Scoring engines used in the Score stage.