# recommend_actions

Staging-only optimization tool (`connectors/recommend/`). Caller sends an **intent enum**, never SQL. Output is always **advisory** (`causal: false`) — not a reportable metric. Numbers come from `evidence[]`. Prod is gated (`prod_gated`); do not add a prod path.

Solvers never query future SQL dates. Projection horizons (`next_month`, `next_3_months`) are calendar metadata. Evidence windows end today and are capped at **92 days**. There is **no YoY** (the cap cannot cover last September).

## P1 — operational heuristics (`recommend_heuristics_v1`)

Pace, diagnose, allocate, scale, publisher_priority, health. Deterministic reads of approved Cake/MNGT tools plus simple ranking/clipping. Scale uses `spend_opportunity` (always with `advertiser_id`). It does not invent incremental revenue: modeled `$` is attached only when a campaign daily series exists (`half_window_saturation_v1`).

**Unscoped scale / incrementality (Q3, omit `advertiser_id`):** one `get_advertiser_report` `{ group_by: "advertiser", sort_by: "sales_revenue", limit: 3 }`, keep `sales_revenue > 0` (skip CPA / $0 like StateFarm), cap at 3 advertisers, then run the **scoped** solver per id. Merge, rank, cap at **8** campaigns. `summary` totals extra spend and modeled incremental revenue. Limitation: `unscoped scale/incrementality limited to top 3 advertisers by sales_revenue`. Do **not** N× the full inventory. Do **not** add `spend_opportunity` to cake unscoped analysis modes — the recommend solver picks advertisers by revenue, then calls `spend_opportunity` **with** `advertiser_id`. Unscoped incrementality keeps **revenue-mode only** (drops CPA/conversion-mode). ChatGPT should omit `advertiser_id` and prefer this over `analyst_agent`.

## P2 — forecast + incrementality (still not causal)

| Intent | `model_id` | What it is |
| --- | --- | --- |
| `forecast` | `holt_damped_v1` | Holt **spend** only (not independent Holt on sales_revenue). Day-of-week seasonality (no YoY). Conversion lag trim (trailing spend-with-no-conversions/revenue, up to 7 days). Revenue/conversions derived as spend × complete-window ROAS / CVR. Unscoped: top 50 advertisers by spend (not lowest adv_id). CPA → revenue n/a, ROAS n/a; ROAS with no conversion value → revenue n/a (not reported), not 0. Inactive last-7d spend → projected spend 0. Campaign budget clip is scoped-only (do not clip unscoped next-month spend to remaining wallet). |
| `incrementality` | `half_window_saturation_v1` | First-half vs second-half ROAS (or conversion rate for CPA). Extra spend is damped when second-half efficiency is worse. Wide ±50% band. Unscoped: top 3 by `sales_revenue`, revenue-mode only, max 8 actions. |

Both may return `unsupported: true` with **history-only** evidence when the series is missing, all-zero, or too short. Empty series is not a crash.

**This is not geo-lift and not MMM.** Half-window saturation is an operational heuristic on the same daily Cake views as campaign analysis. It cannot identify incremental lift vs a holdout.

## Open decision — true geo-lift / MMM

A causal incrementality product needs a **new data product**: geo (or time) holdouts, spend experiments, and an MMM/lift pipeline. That is out of scope for `recommend_actions`. Do not implement it here; do not treat P2 numbers as incrementality research.

## Data

Advertiser-daily and campaign-daily series reuse `lucos_ro_advertiser_stats_daily` + `lucos_ro_adv_campaign_performance_daily` (`GROUP BY adv_id[, camp_id], stat_date`). No new MCP tool. No `get_affiliate_report`. Max 92 days; `date_to` never in the future.

See also playbook §6 (`docs/ADCENTER-QUESTION-PLAYBOOK.md`).
