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
| 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 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>]
}
| 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
batch-executor.ts reads tenantSettings.aiAnalyzerSettings once
per run via isLagrangianEnabled().
- On every customer iteration, after the hard-constraint filter and
before ranking,
applyLagrangianAdjustment(candidates, offers) is
called.
- The helper builds one
LagrangianConstraint per offer with non-zero
remaining budget AND/OR non-zero remaining inventory.
solveLagrangian runs dual ascent (200 iterations max, tolerance
1e-4, step size 0.1).
- 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:
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 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)
| 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.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.