AI-Pricing-Agent/tests/test_inventory_reasons.py

172 lines
6.9 KiB
Python

"""The two inventory reason codes, and a guard that behaviour did not change.
`STOCKOUT_BEFORE_ARRIVAL` and `INBOUND_PO` had labels in REASON_LABELS and no
condition anywhere — dead codes. They are ADDITIVE by design: the low-stock
branch already recommends +5%, which is the step cap, so there is nothing to
escalate to. These tests pin that intent, so a later reader does not "fix" the
missing escalation and silently start moving prices further.
"""
from __future__ import annotations
from datetime import date
import pandas as pd
import pytest
from dashboard.live_data import (
HIGH_COVER_DAYS, LOW_COVER_DAYS, REASON_LABELS, _decide_raw,
)
TODAY = date(2026, 7, 29)
SCEN = pd.DataFrame([
{"scenario": "current", "price": 26.07}, {"scenario": "up_2", "price": 26.59},
{"scenario": "up_5", "price": 27.37}, {"scenario": "down_2", "price": 25.55},
{"scenario": "down_5", "price": 24.77},
])
FP = {"referral_pct": 0.15, "returns_pct": 0.02, "cost": 9.28, "fba": 8.97,
"variable": 0.11}
class _R:
"""Minimal AnalysisResult stand-in."""
def __init__(self, cover, avg_30d=100.0, avg_6m=100.0):
self.cover_days = cover
self.break_even = 22.19
self.is_losing_money = False
self.avg_30d, self.avg_6m = avg_30d, avg_6m
self.ad_cost_per_unit = 1.58
self.elasticity = None
self.profit_optimal_price = None
self.ad_curve_unrecoverable = False
self.contribution_per_dollar = 0.64
self.break_even_ad_curve = None
self.observed_price_max = 28.87
self.price_bands = []
self.best_observed_price = None
self.best_observed_profit_day = None
self.elasticity_actionable = False
self.profit_optimal_is_corner = False
def _hist(days=40, inventory=9_000.0, units=100.0):
return pd.DataFrame({
"price": [26.07] * days, "units": [units] * days,
"ad_spend": [150.0] * days, "revenue": [2_600.0] * days,
"profit": [50.0] * days, "inventory": [inventory] * days,
})
def _decide(r, outlook=None, hist=None):
action, rec, cons, aggr, reasons, root, obj = _decide_raw(
r, 26.07, hist if hist is not None else _hist(), FP, SCEN, outlook)
return {"action": action, "rec": rec, "reasons": reasons, "objective": obj,
"root": dict(root)}
def _outlook(stockout=None, arrival=None, horizon_days=63):
return {"stockout_date": stockout, "next_arrival_date": arrival,
"stockout_source": "projection" if stockout else None,
"days_to_stockout": (stockout - TODAY).days if stockout else None,
"next_arrival_units": 5_000.0 if arrival else 0.0,
"horizon_days": horizon_days, "horizon_end": None}
# ---------------------------------------------------------------- labels exist
def test_both_codes_have_labels_so_the_ui_can_render_them():
assert REASON_LABELS["STOCKOUT_BEFORE_ARRIVAL"]
assert REASON_LABELS["INBOUND_PO"]
# ---------------------------------------------------------------- the codes fire
def test_stockout_before_arrival_fires_when_the_dates_cross():
got = _decide(_R(cover=26),
_outlook(stockout=date(2026, 8, 12), arrival=date(2026, 8, 26)))
assert "STOCKOUT_BEFORE_ARRIVAL" in got["reasons"]
assert "14 days uncovered" in got["root"]["Stockout outlook"]
def test_it_does_not_fire_when_the_arrival_beats_the_stockout():
got = _decide(_R(cover=26),
_outlook(stockout=date(2026, 8, 26), arrival=date(2026, 8, 12)))
assert "STOCKOUT_BEFORE_ARRIVAL" not in got["reasons"]
assert "arrives in time" in got["root"]["Stockout outlook"]
def test_it_fires_when_no_arrival_is_scheduled_at_all():
got = _decide(_R(cover=26), _outlook(stockout=date(2026, 8, 12), arrival=None))
assert "STOCKOUT_BEFORE_ARRIVAL" in got["reasons"]
assert "no arrival scheduled" in got["root"]["Stockout outlook"]
def test_inbound_po_fires_on_a_scheduled_arrival():
got = _decide(_R(cover=26),
_outlook(stockout=date(2026, 8, 26), arrival=date(2026, 8, 12)))
assert "INBOUND_PO" in got["reasons"]
def test_inbound_po_also_reported_on_overstock():
got = _decide(_R(cover=120), _outlook(arrival=date(2026, 8, 12)))
assert got["action"] == "Decrease"
assert {"EXCESS_STOCK", "INBOUND_PO"} <= set(got["reasons"])
# ------------------------------------------------- additive, NOT escalating
def test_the_codes_do_not_change_the_action_or_the_rung():
"""+5% is already MAX_STEP_PCT — there is nothing to escalate to. If this
test fails, someone added escalation; that needs an explicit policy decision,
not a quiet code change."""
plain = _decide(_R(cover=26), _outlook())
urgent = _decide(_R(cover=26),
_outlook(stockout=date(2026, 8, 1), arrival=date(2026, 9, 30)))
assert plain["action"] == urgent["action"] == "Increase"
assert plain["rec"] == urgent["rec"] == "up_5"
assert plain["objective"] == urgent["objective"] == "low_stock_protection"
# ---------------------------------------------- stockout vs demand drop
def test_a_stockout_explained_drop_is_no_longer_an_unexplained_mystery():
"""Cutting price cannot refill a shelf. This used to land on Investigate."""
r = _R(cover=60, avg_30d=40.0, avg_6m=100.0) # a real velocity drop
starved = _hist(days=30, inventory=5.0, units=100.0) # ...while out of stock
got = _decide(r, _outlook(), hist=starved)
assert got["action"] == "Maintain"
assert "UNEXPLAINED_DROP" not in got["reasons"]
assert "STOCKOUT_BEFORE_ARRIVAL" in got["reasons"]
assert "enough to explain" in got["root"]["Days effectively out of stock (30d)"]
def test_a_drop_with_healthy_stock_is_still_investigated():
r = _R(cover=60, avg_30d=40.0, avg_6m=100.0)
got = _decide(r, _outlook(), hist=_hist(days=30, inventory=9_000.0))
assert got["action"] == "Investigate"
assert "UNEXPLAINED_DROP" in got["reasons"]
def test_a_drop_with_no_inventory_readings_stays_investigated():
"""Absence of data is not evidence of a stockout."""
r = _R(cover=60, avg_30d=40.0, avg_6m=100.0)
blind = _hist(days=30)
blind["inventory"] = None
got = _decide(r, _outlook(), hist=blind)
assert got["action"] == "Investigate"
# ---------------------------------------------- unchanged-behaviour guard
@pytest.mark.parametrize("cover", [LOW_COVER_DAYS + 1, 60, HIGH_COVER_DAYS - 1])
def test_healthy_cover_is_untouched_by_any_of_this(cover):
"""A SKU between the thresholds with no stockout must behave exactly as before."""
got = _decide(_R(cover=cover), _outlook())
assert got["action"] == "Maintain"
assert got["reasons"] == ["NO_SIGNALS"]
def test_thresholds_are_unchanged():
"""These were explicitly out of scope; pin them so a refactor cannot drift."""
assert (LOW_COVER_DAYS, HIGH_COVER_DAYS) == (35, 90)
def test_outlook_is_optional_so_older_callers_keep_working():
got = _decide(_R(cover=26), outlook=None)
assert got["action"] == "Increase" and "LOW_STOCK" in got["reasons"]