> ## Documentation Index
> Fetch the complete documentation index at: https://docs.kaireonai.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Engine Architecture

> How the decisioning engine processes a Recommend API request end-to-end — from tenant authentication through pipeline execution to response assembly.

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 16 node types across 3 phases. The pipeline shares the formula engine, scoring engines, and portfolio optimization logic across all flows.

|                   | Composable Pipeline                        |
| ----------------- | ------------------------------------------ |
| **Structure**     | 16 node types across 3 phases              |
| **Config format** | Ordered `nodes` array with `"version": 2`  |
| **Flexibility**   | Add, remove, reorder nodes freely          |
| **Status**        | Stable, all 16 node types fully functional |

## Request Lifecycle

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

```mermaid theme={null}
sequenceDiagram
    participant Client
    participant API as Recommend API
    participant Auth as Tenant Auth
    participant Router as Flow Router
    participant Engine as Decision Engine
    participant Pipeline as Composable Pipeline
    participant DB as PostgreSQL

    Client->>API: POST /api/v1/recommend
    API->>Auth: requireTenant(request)
    Auth-->>API: tenantId

    API->>API: Rate limit + decision quota check

    alt decisionFlowKey provided
        API->>DB: Lookup flow by key + tenantId
    else No key — auto-resolve
        API->>Router: resolveFlowRoute(tenantId, channel, placement)
        Router->>DB: Match FlowRoute (channel+placement > channel > default)
        Router-->>API: decisionFlowId
        API->>DB: Lookup flow by ID
    end

    API->>Engine: executeDecisionFlow(flowKey, context)
    Engine->>DB: Load flow config (publishedConfig or draftConfig)
    Engine->>Pipeline: executePipeline(nodes, context, flowConfig)
    Pipeline->>Pipeline: Validate pipeline structure
    Pipeline->>Pipeline: Execute nodes sequentially
    Pipeline-->>Engine: Ranked offers + personalization + trace

    Engine-->>API: Ranked offers + personalization + trace
    API->>API: Auto-record impressions (implicit channels)
    API->>API: Enqueue decision.made event (outbox)
    API-->>Client: JSON response with interactionId
```

### 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 a legacy decisioning path that applies decisioning gates, 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.

```mermaid theme={null}
graph LR
    subgraph P1["Phase 1 — Narrow"]
        A["inventory"] --> B["match_creatives"]
        B --> C["enrich"]
        C --> D["qualify"]
        D --> E["contact_policy"]
        E --> F["filter"]
    end

    subgraph P2["Phase 2 — Score & Rank"]
        G["score"] --> G2["optimize"]
        G2 --> H["rank"]
        H --> I["group"]
    end

    subgraph P3["Phase 3 — Output"]
        J["compute"] --> K["set_properties"]
        K --> L["response"]
    end

    F --> G
    I --> J

    M["call_flow<br/>(cross-phase)"] -.-> P1
    M -.-> P2
    M -.-> P3

    style P1 fill:#1e1b4b,stroke:#6366f1,color:#e0e7ff
    style P2 fill:#1e1b4b,stroke:#818cf8,color:#e0e7ff
    style P3 fill:#1e1b4b,stroke:#a78bfa,color:#e0e7ff
    style A fill:#312e81,stroke:#6366f1,color:#e0e7ff
    style B fill:#312e81,stroke:#6366f1,color:#e0e7ff
    style C fill:#312e81,stroke:#6366f1,color:#e0e7ff
    style D fill:#312e81,stroke:#6366f1,color:#e0e7ff
    style E fill:#312e81,stroke:#6366f1,color:#e0e7ff
    style F fill:#312e81,stroke:#6366f1,color:#e0e7ff
    style G fill:#312e81,stroke:#818cf8,color:#e0e7ff
    style G2 fill:#312e81,stroke:#818cf8,color:#e0e7ff
    style H fill:#312e81,stroke:#818cf8,color:#e0e7ff
    style I fill:#312e81,stroke:#818cf8,color:#e0e7ff
    style J fill:#312e81,stroke:#a78bfa,color:#e0e7ff
    style K fill:#312e81,stroke:#a78bfa,color:#e0e7ff
    style L fill:#312e81,stroke:#a78bfa,color:#e0e7ff
    style M fill:#f59e0b,stroke:#d97706,color:#000
```

<Note>
  All 16 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 decisioning gates with AND/OR logic trees.
</Note>

### 16 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 decisioning gates 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`                                                                           |
| `conditional`     | 1 — Narrow       | Split candidates by a condition set (AND/OR), optionally routing the matching and non-matching groups into sub-flows. In filter mode (default) non-matching candidates are dropped; set `keepNonMatching` to keep them.                | `conditions`, `combinator`, `trueBranchFlowId`, `falseBranchFlowId`, `keepNonMatching` |
| `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`                                       |
| `extension_point` | Any phase        | Named hook that delegates to a linked sub-flow when configured; a no-op passthrough until `configured` is set. Shares `call_flow`'s depth limit and circular-reference detection.                                                      | `hookName`, `configured`, `subFlowId`, `label`                                         |
| `response`        | 3 — Output       | Terminal node. Assembles the final decision result — ranks, personalization, properties, and optional debug trace — and chooses the response shape. 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. **Validation** — Before any node runs, the runner checks that the pipeline has the required nodes and that phase ordering is respected. A bad pipeline is rejected up front rather than failing partway through 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 accumulation** — `traceSummary` 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 for numbers, string literals, identifiers, operators (`+`, `-`, `*`, `/`, `%`, `>`, `<`, `>=`, `<=`, `==`, `!=`), punctuation (`(`, `)`, `,`, `?`, `:`), and end-of-input.

**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 |

**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

The engine ships six built-in functions. See the [Formula Reference](/tutorials/formula-reference#functions) for full signatures and worked examples.

* **min** — minimum of two numbers
* **max** — maximum of two numbers
* **round** — round to nearest integer, or to *N* decimal places when a second argument is supplied
* **abs** — absolute value of a number
* **coalesce** — first non-null value (minimum two arguments)
* **concat** — 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 |

**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

| 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 to a neutral default score if scoring raises an error. | 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

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.

### Scoring Failure Fallback

The scoring stage never lets a model error break a decision. When a model raises an error (or an external scoring endpoint fails), the engine catches it and applies a fallback:

* **External endpoints:** the candidate score becomes `0.5 * fitMultiplier` (the `0.5` default is a constant in the scoring stage, not an environment variable).
* **Missing model:** scoring falls through the model-resolution hierarchy to `priority_weighted` (`priority / 100`).
* **Flag:** `degradedScoring` is set to `true` on the response (and on each affected candidate) so callers can tell the decision ran in a degraded state.

There is no stateful per-model circuit breaker or cooldown window in the scoring stage — failures are handled per request via this fallback. (A separate general-purpose circuit breaker guards outbound integrations such as webhooks, connector tests, and audit forwarding — see [Operations](/self-host/architecture/operations#circuit-breakers).)

<Info>
  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. The response also includes a `rankingScores` breakdown with `propensity`, `relevance`, `impact`, `emphasis`, `composite` so you can inspect exactly how the PRIE formula was evaluated per offer. See [Recommend API — Decision Explanations](/api-reference/recommend#decision-explanations) for the full response shape.
</Info>

***

## 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

| 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, 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)

| Field                | Description                                                     |
| -------------------- | --------------------------------------------------------------- |
| `totalCandidates`    | Offers loaded from inventory                                    |
| `afterQualification` | Candidates surviving decisioning gates                          |
| `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 the decision-trace store and surfaced in the Runs and Decision Health views. The tenant settings `decisionTraceEnabled` and `decisionTraceSampleRate` control whether and how often traces are written.

<Tip>
  Set `decisionTraceSampleRate` to `1.0` (100%) during development and testing. In production, reduce to `0.01` – `0.05` (1–5%) to balance observability with storage costs.
</Tip>

### Policy Snapshots

On every decision, the engine persists a policy snapshot (fire-and-forget) containing the current decisioning gates, 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

```json theme={null}
{
  "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

| 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

```json theme={null}
{
  "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

<CardGroup cols={2}>
  <Card title="Recommend API" icon="bolt" href="/api-reference/recommend">
    Full request/response reference for the Recommend endpoint.
  </Card>

  <Card title="Decision Flows" icon="sitemap" href="/decisioning/decision-flows">
    Configure Decision Flows in Studio.
  </Card>

  <Card title="Composable Pipeline" icon="puzzle-piece" href="/data/transforms/composable-pipeline">
    Build flows from 16 modular node blocks.
  </Card>

  <Card title="Algorithms & Models" icon="brain" href="/ai-ml/algorithms">
    Scoring engines used in the Score stage.
  </Card>
</CardGroup>
