Skip to main content

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.

What it does

The hard-constraint filter (lib/arbitration/constraints.ts) drops offers that fully violate a budget, inventory, or frequency cap. Lagrangian arbitration 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

ScenarioHard filter aloneLagrangian on top
All caps with ample slackAll offers eligible, ranked by baseScoreSame — λ ≈ 0, scores unchanged
One offer at 95 % of daily capEligible until cap hits, then dropsSlight score penalty as it approaches cap → other offers rotate in
Cross-offer / channel quotasNot modeledModeled (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 batch path (lib/batch-executor.ts) still uses the per-offer-only applyLagrangianAdjustment helper from W5.1; the hot-path realtime wire is the new applyRealtimeArbitration helper at lib/arbitration/realtime-wire.ts. Both share the same tenantSettings.aiAnalyzerSettings.arbitration.lagrangianEnabled flag and noOp/solverFailed semantics.

Configuration

The flag lives inside aiAnalyzerSettings:
{
  "aiAnalyzerSettings": {
    "arbitration": {
      "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>]
}
FieldMeaning
attemptedCustomers where Lagrangian was invoked (post-contact-policy survivors > 0)
noOpCalls that returned without modeling any constraint (no offers had budget/inventory)
solverFailedCalls where the solver threw and we fell back to base scores
nonConvergedCalls where the solver ran but did not converge within tolerance
defaultedCostOffersOffers with budget configured but no per-impression cost — solver ran against a synthetic 1¢ unit cost
malformedConfigOffersOffers 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. batch-executor.ts reads tenantSettings.aiAnalyzerSettings once per run via isLagrangianEnabled().
  2. On every customer iteration, after the hard-constraint filter and before ranking, applyLagrangianAdjustment(candidates, offers) is called.
  3. The helper builds one LagrangianConstraint 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

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:
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:
{
  "delta": {
    "revenueDeltaPct": 0,
    "giniDelta": 0,
    "latencyOverheadMs": 70.16
  }
}
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 (~70 µ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.arbitration.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)

The CrossOfferConstraint Prisma model stores rules that span multiple offers. Three rule types ship today:
ruleTypeCap meaningCost vector
channel_quotaAt most N picks per channel in a window1 per pick of an offer whose channel is in config.channels
portfolio_budgetAt most $X spend across config.offerIdscostPerAction for offers in scope, 0 otherwise
category_capAt most M picks from offers in config.categories1 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 arbitration applied log line at the rank node:
INFO realtime arbitration 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)

ConditionBehavior
tenantSettings lookup failsWire stays OFF; error-level log surfaces the lookup failure.
Cross-offer rows lookup failsloadCrossOfferConstraints already returns [] on Prisma error and logs at warn; wire continues with per-offer constraints only.
Solver throwssolverFailed=true, adjustedScores === baseScores, error-level log. Ranking proceeds with original scores.
Wire helper itself throwsOuter try/catch in pipeline-runner falls through with original scores; error-level log surfaces the bug.
Flag offWire 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.arbitration.* 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.