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

# Lagrangian Ranking

> W5.1 soft-constraint shadow-price adjustment that runs after the hard-constraint filter. Default off; gated by tenantSettings.aiAnalyzerSettings.ranking.lagrangianEnabled.

## What it does

The hard-constraint filter (`lib/ranking/constraints.ts`) drops offers
that fully violate a budget, inventory, or frequency cap. **Lagrangian
ranking** runs after that filter and softly penalizes the survivors
that are *close* to a cap, so traffic naturally rotates toward
less-saturated offers. The penalty is computed from the dual variables
(shadow prices λ\_k) of a relaxed resource-allocation problem.

## When it adds value

| Scenario                       | Hard filter alone                        | Lagrangian on top                                                              |
| ------------------------------ | ---------------------------------------- | ------------------------------------------------------------------------------ |
| All caps with ample slack      | All offers eligible, ranked by baseScore | Same — λ ≈ 0, scores unchanged                                                 |
| One offer at 95 % of daily cap | Eligible until cap hits, then drops      | Slight score penalty as it approaches cap → other offers rotate in             |
| Cross-offer / channel quotas   | Not modeled                              | **Modeled** (W12, hot-path wire shipped) — see "Cross-offer constraints" below |

W12 ships the cross-offer wire on the realtime `/api/v1/recommend` hot
path: the rank node now combines per-offer Lagrangian constraints
(budget + inventory) with cross-offer constraints (channel\_quota,
portfolio\_budget, category\_cap) in a **single solve**.

The hot-path realtime wire is the `applyRealtimeRanking` helper at
`lib/ranking/realtime-wire.ts`; the batch path (`lib/batch-executor.ts`)
uses the `applyLagrangianAdjustment` helper from W5.1. Both share the
same `tenantSettings.aiAnalyzerSettings.ranking.lagrangianEnabled` flag
and noOp/solverFailed semantics. The batch path also folds in cross-offer
constraints via `resolveCrossOfferConstraints` (gated by a separate
`ranking.crossOfferEnabled` flag): it models `portfolio_budget` and
`category_cap` but skips `channel_quota`, which has no per-candidate
channel context in the batch loop. The realtime wire models all three
cross-offer types under the single `lagrangianEnabled` flag.

## Configuration

The flag lives inside `aiAnalyzerSettings`:

```json theme={null}
{
  "aiAnalyzerSettings": {
    "ranking": {
      "lagrangianEnabled": true
    }
  }
}
```

Default: **off** for every tenant. There is no global env-var kill
switch yet — flipping the tenant flag back to `false` is the
incident-response path.

## What gets logged

Per batch run with the flag on, `lib/batch-executor.ts` emits a single
aggregated log line at completion:

```
INFO lagrangian.applied {
  runId, tenantId,
  attempted, noOp, solverFailed, nonConverged,
  defaultedCostOffersCount, malformedConfigOffersCount,
  defaultedCostOffers: [<offerIds>],
  malformedConfigOffers: [<offerIds>]
}
```

| Field                   | Meaning                                                                                                |
| ----------------------- | ------------------------------------------------------------------------------------------------------ |
| `attempted`             | Customers where Lagrangian was invoked (post-contact-policy survivors > 0)                             |
| `noOp`                  | Calls that returned without modeling any constraint (no offers had budget/inventory)                   |
| `solverFailed`          | Calls where the solver threw and we fell back to base scores                                           |
| `nonConverged`          | Calls where the solver ran but did not converge within tolerance                                       |
| `defaultedCostOffers`   | Offers with budget configured but no per-impression cost — solver ran against a synthetic 1¢ unit cost |
| `malformedConfigOffers` | Offers whose `budget` or `inventory` JSON had wrong types (string-encoded numbers, NaN, negatives)     |

If `solverFailed > 0` or the tenant-settings lookup itself failed, the
line is logged at `error` level instead of `info`.

When the flag is off, **nothing** is logged for Lagrangian — no
volume noise on tenants that haven't opted in.

## How the wire works

1. The batch executor reads `tenantSettings.aiAnalyzerSettings` once
   per run to check whether Lagrangian ranking is enabled.
2. On every customer iteration, after the hard-constraint filter and
   before ranking, the Lagrangian adjustment runs against the surviving
   candidates.
3. The helper builds one Lagrangian constraint per offer with non-zero
   remaining budget AND/OR non-zero remaining inventory.
4. `solveLagrangian` runs dual ascent (200 iterations max, tolerance
   1e-4, step size 0.1).
5. Returned `adjustedScores` overwrite `candidate.score` for ranking.

## Result shape

```ts theme={null}
interface ApplyLagrangianResult {
  adjustedScores: Record<string, number>;
  shadowPrices: Record<string, number>;
  usage: Record<string, { used: number; rhs: number; slack: number }>;
  iterations: number;
  converged: boolean;
  /** Legitimate skip — no constraints to model. NOT set when the solver crashed. */
  noOp: boolean;
  /** Solver threw; baseScores were preserved. Distinct from noOp on purpose. */
  solverFailed: boolean;
  candidateCount: number;
  constraintCount: number;
  defaultedCostOfferIds: string[];
  malformedConfigOfferIds: string[];
}
```

`noOp` and `solverFailed` are deliberately separate. A "solver crashed"
result is **not** a "no-op" — callers that conflate them will silently
mask production failures.

## Benchmark

`platform/perf/lagrangian-vs-weighted.ts` compares the Lagrangian path
against a baseline weighted-composite ranker on 1000 synthetic
customers × 10 offers (5 inventory-constrained, 5 unconstrained).

Run:

```bash theme={null}
cd platform
npx tsx perf/lagrangian-vs-weighted.ts
```

The first baseline shipped 2026-04-28 at
`platform/perf/baselines/2026-04-28-lagrangian-bench.json` with the
following honest result:

```json theme={null}
{
  "delta": {
    "revenueDeltaPct": 0,
    "giniDelta": 0,
    "latencyOverheadMs": 73.26
  }
}
```

**Why zero deltas:** the synthetic fixture only models per-offer
constraints. Both paths reach the same selection because the hard
filter already correctly handles "stock = 0" before the Lagrangian
re-rank is consulted. The latency overhead (\~73 µs per decision) is
the cost of the dual-ascent loop on 5 binding constraints.

This is a baseline, not a victory lap. The Lagrangian wire's
structural advantage will show when the offer model is extended to
include channel-quota / portfolio / cross-offer constraints — those
constraints couple multiple offers and the hard filter cannot
correctly handle them in a single pass.

## Operational checklist

* Enable for one tenant first via the `aiAnalyzerSettings.ranking.lagrangianEnabled` flag.
* Watch the per-run `lagrangian.applied` log line. Specifically:
  * `solverFailed > 0` → check the per-customer `lagrangian adjustment threw` errors and file an issue.
  * `nonConverged > 0` → likely fine on small batches; investigate if it stays > 5 % of attempts.
  * `malformedConfigOffersCount > 0` → fix the offer's `budget`/`inventory` JSON shape (numbers, not strings).
  * `defaultedCostOffersCount > 0` → solver ran but per-impression cost was unknown; results trustworthy only directionally.
* If a regression appears, flip the flag back to `false`. The wire is
  designed so that flag-off behavior is bit-identical to pre-W5.1.

## Cross-offer constraints (W12, realtime hot path)

A dedicated cross-offer-constraint table stores rules that span multiple
offers. Three rule types ship today:

| `ruleType`         | Cap meaning                                        | Cost vector                                                  |
| ------------------ | -------------------------------------------------- | ------------------------------------------------------------ |
| `channel_quota`    | At most N picks per channel in a window            | 1 per pick of an offer whose channel is in `config.channels` |
| `portfolio_budget` | At most \$X spend across `config.offerIds`         | `costPerAction` for offers in scope, 0 otherwise             |
| `category_cap`     | At most M picks from offers in `config.categories` | 1 per pick in the category, 0 otherwise                      |

**Per-pick semantics.** Cross-offer cost rows are assessed per
candidate (per creative-pick), matching the `cross-offer.ts` test
contract. Two creatives of the same offer each pay the cost — the
intent is "n picks toward the cap when n creatives are picked." A
"count by offer" rule type is on the roadmap but not in the W12 scope.

### Hot-path wire log

Per realtime recommend call with the flag on, `lib/pipeline-runner.ts`
emits a single `realtime ranking applied` log line at the rank node:

```
INFO realtime ranking applied {
  tenantId, customerId,
  candidateCount,
  perOfferConstraintCount,
  crossOfferConstraintCount,
  noOp, solverFailed, converged, iterations,
  defaultedCostOfferIds, malformedConfigOfferIds
}
```

Field semantics match the batch-path `lagrangian.applied` line, with two
additions: `perOfferConstraintCount` and `crossOfferConstraintCount`
let you distinguish "no constraints to model" from "only cross-offer
constraints active."

### Failure modes (fail-CLOSED)

| Condition                     | Behavior                                                                                                                             |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `tenantSettings` lookup fails | Wire stays OFF; `error`-level log surfaces the lookup failure.                                                                       |
| Cross-offer rows lookup fails | `loadCrossOfferConstraints` already returns `[]` on Prisma error and logs at `warn`; wire continues with per-offer constraints only. |
| Solver throws                 | `solverFailed=true`, `adjustedScores === baseScores`, error-level log. Ranking proceeds with original scores.                        |
| Wire helper itself throws     | Outer `try/catch` in pipeline-runner falls through with original scores; `error`-level log surfaces the bug.                         |
| Flag off                      | Wire is fully skipped — no settings read on the hot path.                                                                            |

The realtime hot path NEVER returns a 5xx because of the wire — the
worst case is degraded mode where scores are unchanged.

## Roadmap

* W5.2 ships the EXP3-IX online-weights / budget-pacing / goal-seek
  flags. They live in the same `aiAnalyzerSettings.ranking.*` namespace.
* A future iteration may add a "count by offer" cross-offer rule
  variant for tenants who want offer-pick semantics rather than
  creative-pick semantics. Today's workaround is to limit candidates
  to one creative per offer upstream of the rank node.
