Skip to main content
The decisioning engine is the core runtime that powers every Recommend API call. It takes a customer context, resolves the right Decision Flow, executes a pipeline of filtering, scoring, and ranking stages, and returns a personalized set of ranked offers with computed values. KaireonAI uses a composable pipeline model with 14 node types across 3 phases. The pipeline shares the formula engine, scoring engines, and portfolio optimization logic across all flows.
Composable Pipeline
Structure14 node types across 3 phases
Config formatOrdered nodes array with "version": 2
FlexibilityAdd, remove, reorder nodes freely
StatusStable, all 14 node types fully functional

Request Lifecycle

Every call to POST /api/v1/recommend follows this lifecycle.

Flow Resolution

When the request does not include a decisionFlowKey, the engine resolves the flow automatically using FlowRoute records. Resolution uses most-specific-match-wins:
  1. Channel + Placement — exact match on both
  2. Channel only — matches any placement for that channel
  3. Tenant default — fallback when no channel/placement match exists
If no flow is resolved and no key is provided, the engine falls back to the legacy 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 a placements 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 a type, 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

NodePhaseDescriptionKey config fields
inventory1 — NarrowLoad offers from DB. Scope by all, category, or manual. Builds candidate pairs (Offer x Creative).scope, categoryIds, offerIds, includeStatuses
match_creatives1 — NarrowFilter candidates by creative availability and placement match mode (exact, any, none).requireCreative, placementMatchMode
enrich1 — NarrowLoad customer data from schema tables. Queries with configurable lookup key, caches via Redis, supports multiple sources in sequence.sources[].schemaId, fields, prefix, cacheTtlSeconds
qualify1 — NarrowEvaluate qualification rules with AND/OR logic trees. Loads rules from database, supports nested groups.mode, qualificationRuleIds, logic
contact_policy1 — NarrowApply contact policy filters (frequency caps, cooldowns, mutual exclusion).mode, rules
filter1 — NarrowGeneric attribute-based filtering using condition expressions. Evaluates against offer fields, enriched data, and request attributes.conditions
score2 — Score & RankAssign 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
optimize2 — Score & RankApply multi-objective portfolio optimization using saved profiles or inline weight sliders. Balances revenue, margin, propensity, engagement, and custom objectives.profileId, weights, customObjectives
rank2 — Score & RankSort 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
group2 — Score & RankAllocate candidates to named placements using optimal (Hungarian algorithm) or greedy strategy. Enables the grouped response format.placements, allocationStrategy, allowPartial
compute3 — OutputEvaluate 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_properties3 — OutputAttach key-value properties to candidates. Values can be static or formula-driven.properties[].key, value, formula
call_flowCross-phaseInvoke 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
response3 — OutputTerminal 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 mutable candidates array and a groupResult map as shared pipeline state. Each node reads from and writes to these structures:
  1. ValidationvalidatePipeline(nodes) checks phase ordering and required node presence before any execution.
  2. Sequential loop — Nodes execute in array order via a for...of loop with a switch on node.type.
  3. Early return — The response node returns the assembled result immediately, terminating the loop.
  4. Trace accumulationtraceSummary counters 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

Formula string → Tokenizer → Token[] → Parser (recursive descent) → AST → Evaluator → Value
1. Tokenizer — Scans the input string character by character, producing typed tokens: NUMBER, STRING, IDENT, operators (+, -, *, /, %, >, <, >=, <=, ==, !=), punctuation ((, ), ,, ?, :), and EOF. 2. Parser — Recursive-descent parser that builds an AST respecting operator precedence:
Precedence (low → high)Operators
Ternarycondition ? consequent : alternate
Comparison>, <, >=, <=, ==, !=
Additive+, -
Multiplicative*, /, %
Unary- (negation)
PrimaryNumbers, strings, identifiers, function calls, grouped expressions
3. Evaluator — Tree-walks the AST, resolving identifiers from a variable map. Null propagation: if any operand resolves to null/undefined, the result is null. Division by zero returns null.

Built-in Functions

FunctionSignatureDescription
minmin(a, b)Minimum of two numbers
maxmax(a, b)Maximum of two numbers
roundround(x) or round(x, decimals)Round to nearest integer or specified decimal places
absabs(x)Absolute value
coalescecoalesce(a, b, ...)First non-null value
concatconcat(a, b, ...)String concatenation (null propagates)

Variable Namespaces

Formulas resolve identifiers from three namespaces:
NamespaceExampleSource
Bare field namesbase_rate, discount_pctOffer custom field values
customer.*customer.loan_amount, customer.tenureEnriched data from schema tables (Enrich stage)
attributes.*attributes.tier, attributes.deviceRequest-time attributes from the Recommend body
Example formula:
customer.loan_amount > 50000 ? base_rate - 0.5 : base_rate
This returns a discounted rate for high-value loans, falling back to the base rate otherwise.

Scoring

The engine supports multiple scoring methods, selected per flow configuration.

Scoring Methods

MethodDescriptionWhen to use
priority_weightedscore = (priority / 100) * (weight / 100) * fitMultiplierDefault. Good for rule-based decisioning without ML models.
propensityUses 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.
formulaWeighted 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

When method is propensity or formula, the engine resolves the scoring model through this hierarchy:
  1. 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 the experimentAssignmentTotal counter.
  2. Direct model lookup — Falls back to algorithmModel.findFirst({ key: modelKey, status: "active" }).
  3. Pre-computed propensity scores — If no model is found, checks attributes.propensityScores[modelKey] from the request body.
  4. Priority-weighted fallback — Last resort: uses priority / 100 as 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 via SCORING_FALLBACK_SCORE env var). The degradedScoring flag 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:
score = (w_conversion * conversion + w_margin * margin + w_fatigue * fatigue + w_fairness * fairness + w_recency * recency) / (w_conversion + w_margin + w_fatigue + w_fairness + w_recency)

Dimensions

DimensionWhat it measuresRange
conversionPredicted conversion probability (propensity score)0 – 1
marginExpected margin or revenue contribution0 – 1
fatigueInverse of contact frequency — penalizes over-contacted customers0 – 1
fairnessDistribution fairness — prevents offer concentration0 – 1
recencyTime since last interaction — favors fresh offers0 – 1

Default Weights

By default, only conversion 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 a traceSummary on every decision for lightweight observability, plus an optional debugTrace with full diagnostic detail.

Trace Summary (always captured)

FieldDescription
totalCandidatesOffers loaded from inventory
afterQualificationCandidates surviving qualification rules
afterContactPolicyCandidates surviving contact policy filters
topScoresTop 3–10 scored candidates with offer IDs and scores
policyVersionSHA-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 the DecisionTrace table. The tenant settings decisionTraceEnabled and decisionTraceSampleRate control whether and how often traces are written.
Set decisionTraceSampleRate to 1.0 (100%) during development and testing. In production, reduce to 0.010.05 (1–5%) to balance observability with storage costs.

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 a policyVersionHash. 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

{
  "version": 2,
  "nodes": [
    { "id": "n1", "type": "inventory",       "config": { "scope": "category", "categoryIds": ["credit_cards"] } },
    { "id": "n2", "type": "match_creatives",  "config": { "requireCreative": true, "placementMatchMode": "exact" } },
    { "id": "n3", "type": "filter",           "config": { "conditions": [{ "field": "offer.status", "op": "eq", "value": "active" }] } },
    { "id": "n4", "type": "qualify",          "config": { "mode": "standard" } },
    { "id": "n5", "type": "contact_policy",   "config": { "mode": "all" } },
    { "id": "n6", "type": "score",            "config": { "method": "propensity", "modelKey": "cc_propensity_v3" } },
    { "id": "n7", "type": "rank",             "config": { "maxCandidates": 3, "maxPerCategory": 2 } },
    { "id": "n8", "type": "group",            "config": { "placements": [{ "id": "hero", "limit": 1 }, { "id": "sidebar", "limit": 2 }] } },
    { "id": "n9", "type": "compute",          "config": { "extras": [{ "name": "monthly_payment", "formula": "round(customer.loan_amount / 12, 2)" }] } },
    { "id": "n10", "type": "response",        "config": { "responseFormat": "grouped", "includeDebugTrace": true } }
  ]
}

Execution Trace

StepNodeCandidates InCandidates OutWhat happened
1inventory010Loaded 10 credit card offers (active status, category = credit_cards)
2match_creatives108Dropped 2 offers with no creative matching the requested placement
3filter88All 8 pass the status=active condition (already filtered by inventory)
4qualify862 offers disqualified — customer not in required segments
5contact_policy651 offer suppressed — frequency cap exceeded (3 impressions in 7 days)
6score55Propensity scores assigned via cc_propensity_v3 model: 0.82, 0.71, 0.65, 0.44, 0.31
7rank53Top 3 by score selected (maxCandidates=3): scores 0.82, 0.71, 0.65
8group33Allocated to placements — hero: 1 offer (score 0.82), sidebar: 2 offers (scores 0.71, 0.65)
9compute33monthly_payment calculated for each offer using enriched customer.loan_amount
10response33Assembled grouped response with 2 placements, ranks, personalization, and debug trace

Response Shape

{
  "interactionId": "a1b2c3d4-...",
  "customerId": "cust_12345",
  "timestamp": "2026-03-16T14:30:00.000Z",
  "placements": {
    "hero": {
      "offers": [
        { "rank": 1, "score": 0.82, "offerId": "offer_premium_card", "personalization": { "monthly_payment": 458.33 } }
      ],
      "count": 1
    },
    "sidebar": {
      "offers": [
        { "rank": 1, "score": 0.71, "offerId": "offer_rewards_card", "personalization": { "monthly_payment": 291.67 } },
        { "rank": 2, "score": 0.65, "offerId": "offer_cashback_card", "personalization": { "monthly_payment": 333.33 } }
      ],
      "count": 2
    }
  },
  "meta": {
    "decisionFlowKey": "cc_cross_sell",
    "totalCandidates": 10
  }
}

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.