AI-Pricing-Agent/ARCHITECTURE-DETAIL.md

470 lines
24 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# Utopia Pricing Agent — Architecture (engineering detail)
The technical reference: data sources, decision cascade, formulas, file map, backtests.
For the plain-English overview see **[ARCHITECTURE.md](ARCHITECTURE.md)**.
A one-page Streamlit dashboard (Utopia/CRAI design system) that turns **live COSMOS
data** into price recommendations a human can Approve / Modify / Reject. Read-only:
nothing is written back to COSMOS or Amazon.
---
## 1. High-level view
```mermaid
flowchart LR
U["🧑 User<br/>(same-network browser)"] --> APP
subgraph APP["app.py — presentation (Streamlit, CRAI theme)"]
SIDE["Sidebar<br/>Single product / Product line<br/>+ filters, kill switch"]
QUEUE["Recommendation queue<br/>tiles · pills · rows"]
SECT["Per-SKU sections<br/>price · inventory · scenarios<br/>competitors · PPC · costs · AI"]
end
subgraph DASH["dashboard/ package"]
THEME["theme.py<br/>CRAI tokens + plotly template"]
LIVE["live_data.py<br/>adapter + decision engine<br/>+ scenario economics"]
end
subgraph CORE["src/pricing_agent — analysis core"]
AN["analyze.py"]
MARGIN["margin_engine.py"]
ELAST["elasticity.py"]
PERF["performance.py"]
SVC["cosmos/service.py"]
CLIENT["cosmos/client.py"]
end
COSMOS[("COSMOS API")]
APIFY[("Apify — optional")]
APP --> THEME
APP --> LIVE
LIVE --> AN
AN --> MARGIN & ELAST & PERF
AN --> SVC --> CLIENT --> COSMOS
LIVE --> SVC
AN -.optional.-> APIFY
```
---
## 2. Layers
| Layer | Files | Responsibility |
|---|---|---|
| **Presentation** | `app.py` | All rendering, zero pricing logic. Session state (approve/modify/reject, filters, per-SKU section + window), staged progress loader, session-state cache. |
| **Design system** | `dashboard/theme.py`, `.streamlit/config.toml` | CRAI palette (cream `#f4f0e8`, teal `#0c8276`, coral `#df4f33`, navy `#22304e`), Inter font, plotly template. |
| **Adapter + engine** | `dashboard/live_data.py` | Builds the per-SKU dict; **decides** the action; computes **scenario economics** (elasticity projection → bulk reconciliation → calibration); exposes `scenarios_for_window()`. |
| **Competitive state** | `src/pricing_agent/competitive_state.py` | The one competitive fact the cascade may read. Adapts either scraper into a typed `WON`/`LOST_PRICE`/`LOST_ELIGIBILITY`/`SUPPRESSED` state with a source and a timestamp, gates it on age, and logs disagreement between sources. |
| **Analysis core** | `src/pricing_agent/analyze.py` | Orchestrates one SKU: fees → trend → bulk → ad cost → elasticity → actual-profit evidence. |
| **Money math** | `tools/margin_engine.py` | Pure: break-even, MAP, contribution margin, suggested price. |
| **Statistics** | `elasticity.py`, `performance.py` | Log-log elasticity fit, profit-optimal sweep, actual-profit aggregation. |
| **Data access** | `cosmos/{client,service,models}.py` | Auth + retry client; endpoint calls + response flattening; typed pydantic models. |
---
## 3. Data sources — what each COSMOS endpoint feeds
```mermaid
flowchart TB
subgraph COSMOS["COSMOS API"]
TH["/sales-insight/takehome-calculator<br/>nested fees.breakdown · cost.breakdown"]
INVP["/invp-insight<br/>trend + inventory + dateMap PROJECTIONS"]
BULK["/sales-insight/bulk-calculator<br/>storage + total take-home"]
SI["/sales-insight (daily, 6-month)<br/>price · units · revenue · profit · ad spend"]
PROD["/products<br/>brand · marketplace"]
CAMP["/api/campaigns · /adsApi<br/>budget · ACoS · ad sales (not yet wired)"]
end
TH -->|"_flatten_takehome()"| FEES["Fee model<br/>referral% · FBA · landed · returns"]
INVP --> TREND["Velocity + cover days"]
INVP --> INVPROJ["Inventory Outlook tab<br/>real weekly units/value/cover/arrivals"]
BULK --> STORAGE["Storage + take-home (scenarios)"]
SI --> HIST["180-day daily series<br/>(window filter + calibration)"]
SI --> ADS["Ad spend / TACoS (PPC tab)"]
PROD --> META["Brand / marketplace"]
```
**Three response quirks handled:**
- **Fees come nested** (`fees.breakdown["Referral Fee"]`, `"$ 9.28"` strings).
`service._flatten_takehome()` normalises them — without it every fee parsed to 0 (the
old "$0.99 / break-even $0" bug).
- **INVP `dateMap`** holds COSMOS's own **forward inventory projection** (weekly units,
value, cover days, warehouse arrivals). The Inventory tab renders this directly — not
a locally-invented forecast.
- **SKU lookups are CONTAINS/relevance searches, not equality.** See §3.1 — this one
silently bound the wrong product's data to a SKU.
### 3.1 Exact-SKU joins (`service._exact_row`)
Neither `/api/products` nor `/api/invp-insight` has an equality filter, and both used to
fall back to `data[0]` "if COSMOS returned a fuzzy match set". That bound one product's
ASIN, cost, brand and inventory to a **different product's SKU**.
Observed live: `get_product("UBCFKFITTEDSHEETWHITECALKING")` — a SKU COSMOS does not carry
at all — returned `UBMICROFIBERGUSSETPILLOWWHITEQUEEN` / `B08DTH86Q2`.
Two independent problems, and each guard is necessary:
1. **Wrong parameter.** `get_product` queried `q=` (relevance across the whole catalogue).
For `UBMICROFIBERDUVETTWINWHITE` the correct row sat on **page 2 of 100-row pages**,
behind 100 unrelated products — a 20-row lookup never saw it. `sku=` returns it first.
2. **`sku=` is still a CONTAINS filter.** `sku=UBCFKMATTRESSPROTECTORTWIN88` returns three
rows: the real one (`B00MRH9NCK`), the `...BOX` variant (`B09K7HXJ4M`), and a
`WAL...` Walmart row whose "ASIN" (`8946709597`) is not an ASIN. **All three are
`marketplace: AMAZON_USA`**, so the marketplace check alone does not separate them —
only the exact SKU test does.
`_exact_row()` requires an exact SKU match **and** the right marketplace, and returns
`None` otherwise, logging what it rejected. Blast radius of the old behaviour:
`analyze_price` assigns `asin = product.asin` whenever INVP has none, so the competitive
scrape would have run against an unrelated listing; and `get_invp`'s `skuPrefix` matches
every colour variant, so a sibling's inventory and cover days fed `LOW_STOCK` /
`EXCESS_STOCK` directly — a sibling reading 12 units / 3 days would fire a stockout raise
on a SKU holding 4,000 units.
Genuinely **not** in this COSMOS integration → shown as "—", never faked: ad-attributed
sales / ACoS / campaign budget (live in `/api/campaigns` + `/adsApi`, not yet wired), and
historical competitor prices (Apify gives a current snapshot only).
**Competitor data is no longer display-only.** COSMOS has no Buy Box, no rival price and no
third-party offer anywhere in it — that gap is filled by a scrape, and two of its facts
(our Buy Box being suppressed, and a rival materially undercutting us) reach the verdict.
They are the only two, they are bounded by the guardrails, and their absence changes
nothing. See §6.
---
## 4. Per-SKU pipeline (one "Analyze")
```mermaid
sequenceDiagram
participant U as User
participant A as app.py
participant L as live_data.build_live_sku
participant AN as analyze.py
participant S as CosmosService
U->>A: Single product / Product line
A->>L: get_live_data(skus, progress_cb)
Note over A: staged progress bar (3→10→45→82→94→100%)
L->>S: get_current_price
L->>S: get_sales_history (180d, parallel windows)
L->>AN: analyze_price (fees, trend, bulk, elasticity, evidence)
L->>S: get_invp (real inventory projection)
L->>S: bulk_quote (storage)
L->>L: decide action + scenario economics + 30d/6mo calibration
L-->>A: {summary, details, errors} (session-cached)
A-->>U: queue + expandable per-SKU analysis
```
---
## 5. Scenario economics (the heart of the Scenarios tab)
For each candidate price, one consistent chain:
```mermaid
flowchart LR
W["Window filter<br/>7/14/30/90d · 6mo"] --> BASE["Baseline velocity<br/>= avg units/day in window"]
BASE --> DEMAND["Units(p) = units × (p/cur)^elasticity"]
DEMAND --> REV["Revenue = units × p × 30"]
REV --> AD["Ad spend = TACoS × revenue"]
DEMAND --> TH["Take-home (bulk calculator fee model)"]
TH --> GROSS["Gross = take-home storage ad"]
GROSS --> CAL["× realization factor<br/>(actual booked ÷ modeled at current)"]
CAL --> NET["Net profit / 30d"]
```
Key rules:
- **Current row = FACT**, not a projection: real units, real revenue, real ad spend, real
booked profit. Its price is the **average price sold** (revenue ÷ units) so
`price × units × 30 = revenue` reconciles — this is *below* list when promos ran, and
changes with the window because the avg selling price differed period to period. The
**list price is fixed**.
- **Calibration:** raw bulk-calculator profit over-states reality (prices at list, ignores
real returns/promos). A **realization factor** = actual booked profit ÷ modeled profit at
the current price scales every projected row.
- **The elasticity used for projections is gated** (`projection_elasticity()`). It honours
the same `actionable` test the decision does: a slope whose 95% CI spans zero — or a
**positive** slope, which `estimate_elasticity` can return since `actionable` requires
`e < 0` but the value is not clamped — falls back to `FALLBACK_ELASTICITY`. Ungated, a
positive slope projected that *raising* price sells *more*, and that number drives the
30-day impact tile, the portfolio opportunity total and the queue sort.
`elasticity_is_fitted` records which was used.
- **⭐** marks the highest-net-profit price in the current view.
---
## 6. Decision engine (deterministic, first match wins)
```mermaid
flowchart TD
S([signals]) --> R0{cost data = 0?}
R0 -- yes --> INV["🔍 INVESTIGATE · NO_COST_DATA"]
R0 -- no --> RB{our Buy Box suppressed?}
RB -- yes --> INVB["🔍 INVESTIGATE · BUYBOX_SUPPRESSED"]
RB -- no --> R1{price < break-even?}
R1 -- yes --> UP1["↑ raise to safe floor · BELOW_BREAK_EVEN"]
R1 -- no --> R2{losing money after ads?}
R2 -- yes --> UP2["↑ raise · LOSING_MONEY"]
R2 -- no --> R3{cover ≤ 35d?}
R3 -- yes --> UP3["↑ +5% · LOW_STOCK"]
R3 -- no --> R4{30% sales, no cause?}
R4 -- yes --> INV2["🔍 INVESTIGATE · UNEXPLAINED_DROP"]
R4 -- no --> R5{cover ≥ 90d?}
R5 -- yes --> DN["↓ 5% · EXCESS_STOCK"]
R5 -- no --> RC{rival ≥3% below AND corroborated?}
RC -- yes --> DNC["↓ toward rival · COMPETITOR_UNDERCUT"]
RC -- no --> R6{profit-optimal ≠ current?}
R6 -- yes --> MOVE["↑/↓ toward optimal · PROFIT_OPTIMAL"]
R6 -- no --> R7{a price we ran booked more?}
R7 -- yes --> BO["↑/↓ toward it · BEST_OBSERVED"]
R7 -- no --> HOLD["→ MAINTAIN · NO_SIGNALS"]
```
Guardrails: floor = highest of four break-evens × 1.02, ceiling = current × 1.25; the
recommended move is capped at ±5% per step; the kill switch pauses all approvals.
### AD_SPIRAL — applied after the cascade
When ad cost per unit climbs almost as fast as price, each extra $1 of price buys only
cents of contribution and **no** price reaches break-even. That verdict (`AD_SPIRAL`,
Investigate-and-hold) is applied *after* the cascade and overrides whatever fired — so
what it must **not** override is named explicitly:
```python
AD_SPIRAL_YIELDS_TO = frozenset({"NO_COST_DATA", "BUYBOX_SUPPRESSED", "LOW_STOCK"})
```
| Yields to | Why |
|---|---|
| `NO_COST_DATA` | With `costPerUnit`/`fbaFee` at 0, `fixed` is understated, which makes the `contribution <= 0` test **easier** to hit. A missing-COGS SKU would be sent to the ad console when the fix is a data-entry field. |
| `BUYBOX_SUPPRESSED` | A listing nobody can buy from has no meaningful ad economics. |
| `LOW_STOCK` | The only one that changes a **price**, not just a label. A shelf about to empty gets +5% whatever the ad slope does — those units sell regardless, so the only question is what we get for them. Holding sells the last of the stock cheap. |
Deliberately narrow: `EXCESS_STOCK` does **not** outrank it, because cutting price to clear
stock is exactly the move that cannot work when ads eat the contribution.
### Competitor rules — the two that can move a price, and what bounds them
Both branches read a single `CompetitiveState`
([competitive_state.py](src/pricing_agent/competitive_state.py)), never a raw scrape:
| Rule | Fires when | Effect |
|---|---|---|
| `BUYBOX_SUPPRESSED` | Amazon is not showing our offer | **Investigate, hold.** Placed directly under `NO_COST_DATA`: the only two states where the answer is "go and fix something" rather than "set a price". |
| `COMPETITOR_UNDERCUT` | cheapest rival ≥ `competitor_undercut_material_pct` below us, **and the basis qualifies** (below) | **Decrease toward the rival**, floored and step-capped like every other branch. |
**Basis decides what "qualifies" means**, because `competitor_min` can be a price of two
different things:
- `same-asin-buybox` (Apify) — another seller's offer on **our own listing**. Only
`LOST_PRICE` fires; a rival holding the Buy Box *above* us is `LOST_ELIGIBILITY`, where
cutting donates margin. Losing the Buy Box on price **is** the corroboration.
- `like-for-like-sheet` — a **rival brand's** equivalent variant, matched on size + colour.
This additionally requires **corroboration**: either demand has materially dropped, or we
are not actually winning the Buy Box.
*Why:* on its own a sheet row says only "a different brand is cheaper". True, reportable,
but not evidence the gap is costing us anything — we can sit 3% dearer, hold our own Buy
Box and sell fine on brand, reviews or the Prime badge. And because this branch sits
**above** `PROFIT_OPTIMAL`, an uncorroborated cut could overrule an elasticity fit that
wanted a *raise*. Switch: `competitor_sheet_requires_corroboration` (default `true`).
The documented `UNEXPLAINED_DROP` fall-through is unaffected —
`UBMICROFIBERDUVETKINGPURPLE` and `UBMICROFIBERBS4PCFULLGREY` arrive here *with* a
velocity drop, which is the first form of corroboration.
An uncorroborated material undercut is still **reported** in the root cause ("Competitor
undercut not acted on"), so it never looks like missing data.
A third signal, a **competitor premium** while we hold the Buy Box, is a narrative note
only. It never sets a price and never changes an action.
Ordering is deliberate: **inventory risk outranks competitor position, which outranks
profit-optimal.** Chasing a rival down while the shelf is emptying pays margin to sell out
faster.
Three properties make this safe to ship:
1. **Fail-safe.** Absent, failed, stale (> `competitor_state_max_age_hours`) and
"ownership unknown" all collapse to one flag, and the cascade then computes exactly the
verdict it computed before competitor data existed. Competitor data can only ever *add*
a verdict.
2. **Never below break-even.** The rival price is a *candidate* (`comp_match`), not a
decision.
3. **One named reason per verdict.** No blended scores — every fired rule is traceable to a
single reason code, and `logger.info` names the SKU, the rule, the state and the source.
Thresholds live in [config/pricing_rules.yaml](config/pricing_rules.yaml)
(`competitor_undercut_material_pct: 0.03`, `competitor_premium_material_pct: 0.10`,
`competitor_state_max_age_hours: 6.0`, `competitor_sheet_requires_corroboration: true`),
not in code. The 3% floor sits above the ~2% band our own realized price already swings
through as coupons toggle.
### Inventory cover matches COSMOS Inventory Planning
`cover_days` **is COSMOS's own `coverDays`**, so the dashboard and the INVP grid never quote
two different numbers for one SKU. COSMOS counts **inbound** stock against a **7-day**
velocity, so it reads longer than what is on the shelf — `UBMICROFIBERDUVETTWINWHITE` is
78 days on (3,999 on hand + 1,030 inbound) ÷ 64/day, against 63 on-hand-only. Both are
reported: the tile leads with the matched figure and appends `63 d on hand, rest inbound`.
The on-hand figure remains the **fallback**, because COSMOS returns `coverDays: 0` on some
very low-velocity SKUs that hold months of stock (`UBMICROFIBERBS4PCFULLGREY`: 167 units,
334 real days, COSMOS said `0`). Zero satisfies neither inventory rule, so taken literally it
silences both. The reconciled figure is computed **before** `_decide` and passed in, so the
tile and the rule that fired read the same number by construction.
**Trade-off, accepted deliberately:** stockout risk is now judged partly on stock that has not
landed. Measured over the 56-SKU covered line, matching COSMOS moved 7 verdicts —
`LOW_STOCK` 7 → 4, `EXCESS_STOCK` 18 → 22. The one to watch is
`UBMICROFIBERBS4PCKINGWHITE`: **12 days on the shelf, 84 with inbound**, so it no longer
raises. If that shipment slips, nothing protects it.
Display bands are COSMOS's Alpha/Beta scheme (`theme.COVER_BANDS`, Alpha 20/40/70/100). The
pricing **triggers** are separate and live in `pricing_rules.yaml`
(`low_cover_days: 35`, `high_cover_days: 90`) — COSMOS's pink at 70 days is a *replenishment*
warning, while crossing a trigger here spends margin on a 5% move.
### Coverage: the comparison sheet gates competitor data, one product line at a time
The competitor workbook currently covers **one product line**, so the engine reads it as the
first competitor source and **gates on coverage**:
| SKU | Competitor state |
|---|---|
| In the sheet | Priced from the sheet — real like-for-like rival prices, `basis=like-for-like-sheet` |
| Not in the sheet | **`N/A`**, naming what the sheet *does* cover. No rule fires; the verdict is byte-identical to the competitor-blind one |
**Coverage is the exact SKU set in the sheet, not a line prefix.** Measured against the real
workbook, a prefix gate would be wrong in both directions: the `UBMICROFIBERDUVET` run contains
49 `UBMICROFIBERDUVET*` SKUs **and 7 `UBMICROFIBERBS4PC*`** ones, while the line has 139 SKUs
in COSMOS of which only 56 reached a comparison row. So the sheet's own SKU list is the
authority, and "not in the sheet" is a **coverage hole**, never a claim that the SKU has no
competitors.
`competitor_sheet_only: true` (default while one line is under test) means an uncovered SKU gets
N/A rather than falling through to a per-ASIN Apify scrape. Config: `competitor_sheet_path`
(blank = auto-discover the newest `Competitor_Price_Comparison_*.xlsx`), `competitor_sheet_dirs`,
`competitor_sheet_max_age_hours: 168` (the sheet is a 2535 min batch run, not a live feed).
Two sheet-driven refinements, both from real rows:
- **A rival whose own Buy Box is suppressed is excluded from the band.** Their price is not
buyable, so undercutting it donates margin for nothing.
- **A material undercut can explain a velocity drop** (subject to the corroboration rule
above), converting an `UNEXPLAINED_DROP` Investigate into an actionable verdict — exactly
as the existing stockout branch already did.
### Two scrapers, one state
| Source | Authoritative for | Why |
|---|---|---|
| **Apify** (`tools/amazon/apify.py`) | **Buy Box state read by the engine** | The only source carrying a seller id, so the only one that can tell `WON` from `LOST_PRICE` from `LOST_ELIGIBILITY`. Those lead to opposite actions. |
| **Playwright** (`../scraper/`) | The workbook: like-for-like size/colour matching, BSR, demand buckets, SKU gaps | Apify cannot produce any of it. Its Buy Box field knows only whether a price *rendered*, not whose it was. |
The split is by **question**, not preference. `reconcile()` cross-checks the authoritative
state against the Playwright run's own cache (`scraper/.scrape_cache.json`, keyed `ASIN@ZIP`)
and **logs any disagreement**. A disagreement never changes the verdict.
---
## 6.1 Verdict backtest
`scripts/backtest_competitor_rules.py` re-runs the **captured real cascade inputs** (real
`AnalysisResult`, real 180-day history, real fee stack, real scenario grid) through the same
`_decide`, varying only the arm. Nothing is reconstructed by hand.
Competitor rules blind vs live (7 requested SKUs, 6 analysed — see §9):
| Arm | Verdicts changed |
|---|---|
| Real competitor state | **0 / 6** — the fail-safe working |
| Counterfactual 8% undercut | 4 / 6 — the others blocked by `LOW_STOCK` / `LOSING_MONEY` |
| Counterfactual suppression | 6 / 6 → Investigate |
Policy delta, old vs new (the two cascade changes), same SKUs:
| Case | Changed | Notes |
|---|---|---|
| `SHEET_UNDERCUT_WE_WIN` | 2 / 6 | Both were being **cut while holding the Buy Box with flat demand**: `UBMICROFIBERDUVETTWINWHITE` Decrease $17.06 → **Increase $17.94** (`BEST_OBSERVED`), `UBMICROFIBERGUSSETPILLOWWHITEQUEEN` Decrease $25.01 → **Increase $27.37**. The two corroborated SKUs were unchanged. |
| `AD_SPIRAL_LOW_STOCK` | 4 / 6 | `Investigate/AD_SPIRAL``Increase/LOW_STOCK` |
| `AD_SPIRAL_NO_COST` | 6 / 6 | `Investigate/AD_SPIRAL``Investigate/NO_COST_DATA` |
| **Targets below break-even, any arm** | **0** | |
**The invariant is measured on the TARGET, not the step-capped first move.** A SKU already
selling under its own ad-inclusive floor cannot be lifted over it in one 5% step, and
reporting that deliberate multi-cycle climb as a breach buries any real one. Two SKUs are
below floor and stepping up by design; both are reported separately.
---
## 7. Key formulas
| Quantity | Formula |
|---|---|
| Take-home / unit | `p·(1 referral% returns%) landed FBA other` |
| Break-even | `(landed + FBA + returns + other) / (1 referral%)` |
| Elasticity | OLS on `ln(units/day) = a + e·ln(price)` over 6 months |
| Scenario demand | `units × (p / p₀)^e` |
| Realization factor | `actual booked profit (window) ÷ modeled net at current price` |
| TACoS | `ad spend ÷ total revenue` (window) |
| Avg sold price | `revenue ÷ units` (window) — reconciles the Current row |
| Best observed price | `avg_price` of the best-earning band — **the price actually charged**, never the $0.50-rounded `price_band` key |
---
## 8. Repository map
```
pricing_agent/
├── app.py # dashboard (presentation only)
├── legacy_app.py # previous analyst UI (still runnable)
├── dashboard/
│ ├── theme.py # CRAI design tokens + plotly template
│ └── live_data.py # COSMOS adapter, decision + scenario engine
├── src/pricing_agent/
│ ├── analyze.py # per-SKU orchestration → AnalysisResult
│ ├── competitive_state.py # canonical Buy Box state + two-scraper reconciliation
│ ├── elasticity.py # demand model + profit optimizer
│ ├── performance.py # actual-profit evidence
│ ├── tools/margin_engine.py # pure fee/break-even math (golden-tested)
│ └── cosmos/
│ ├── client.py # auth + retry HTTP
│ ├── service.py # endpoints, _exact_row, _flatten_takehome, INVP projections
│ └── models.py # typed COSMOS responses (pydantic)
├── config/ # settings + pricing_rules.yaml (incl. competitor thresholds)
├── scripts/
│ └── backtest_competitor_rules.py # verdict delta + old-vs-new policy delta
├── .streamlit/config.toml # CRAI theme
└── tests/ # margin-engine golden values, test_competitor_rules.py
# (fail-safe + ordering invariants), test_pricing_safety.py,
# test_cosmos.py (exact-SKU joins)
```
---
## 9. Known data gaps
- **Ad-attributed sales / ACoS / campaign budget** — live in `/api/campaigns` + `/adsApi`,
not yet wired. Shown as "—".
- **Historical competitor prices** — Apify returns a current snapshot only.
- **`UBCFKFITTEDSHEETWHITECALKING` is not in COSMOS at all.** `sku=` returns zero rows on
any marketplace and the fee endpoint replies "Product not found". Likely delisted or
renamed. It now fails loudly (`get_product` → `None`) rather than adopting another
product's identity. A catalogue question, not a code one.
---
## 10. Principles
1. **Deterministic core, narrative shell** — every number is a formula over COSMOS data; language models only phrase explanations.
2. **Read-only** — the agent proposes; a human approves; nothing writes back. `submit_price_approval` remains a stub with no callers.
3. **Honest gaps** — missing upstream data shows "—" or an explicit investigation, never a fabricated number. A wrong number is worse than a blank one.
4. **Facts vs projections are labeled** — the Current row is real booked history; other prices are clearly modeled.
5. **Everything reconciles** — one averaging window drives units, revenue, ads and profit so `price × units = revenue` always holds.