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 |
/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 applyRealtimeRanking helper at
lib/ranking/realtime-wire.ts. Both share the same
tenantSettings.aiAnalyzerSettings.ranking.lagrangianEnabled flag
and noOp/solverFailed semantics.
Configuration
The flag lives insideaiAnalyzerSettings:
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:
| 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) |
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
- The batch executor reads
tenantSettings.aiAnalyzerSettingsonce per run to check whether Lagrangian ranking is enabled. - On every customer iteration, after the hard-constraint filter and before ranking, the Lagrangian adjustment runs against the surviving candidates.
- The helper builds one Lagrangian constraint per offer with non-zero remaining budget AND/OR non-zero remaining inventory.
solveLagrangianruns dual ascent (200 iterations max, tolerance 1e-4, step size 0.1).- Returned
adjustedScoresoverwritecandidate.scorefor ranking.
Result shape
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:
platform/perf/baselines/2026-04-28-lagrangian-bench.json with the
following honest result:
Operational checklist
- Enable for one tenant first via the
aiAnalyzerSettings.ranking.lagrangianEnabledflag. - Watch the per-run
lagrangian.appliedlog line. Specifically:solverFailed > 0→ check the per-customerlagrangian adjustment threwerrors and file an issue.nonConverged > 0→ likely fine on small batches; investigate if it stays > 5 % of attempts.malformedConfigOffersCount > 0→ fix the offer’sbudget/inventoryJSON 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 |
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:
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. |
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.