CoolFace
Apppublic

RohanExploit/Meta-hackathon

sourceHugging Faceupdated 5mo agoView on Hugging Face
0likes
retail_env.py680 linesDownload Raw Back to environment
1"""Multi-channel retail environment with disruption recovery mechanics."""2from __future__ import annotations3 4import random5from typing import Any, Dict, List, Optional, Tuple6 7import numpy as np8 9from .grader import score_episode10from .models import (11    AllocateAction,12    ActionType,13    CompositeAction,14    DisruptionEvent,15    NoOpAction,16    OrderAction,17    PromoteAction,18    RetailAction,19    RetailObservation,20    RetailState,21    SetPriceAction,22)23 24 25def _dump_model(model: Any) -> Dict[str, Any]:26    """Convert Pydantic model to dict."""27    if hasattr(model, "model_dump"):28        return model.model_dump()29    return model.dict()30 31 32class MultiChannelRetailEnv:33    """34    Dynamic multi-channel retail with disruption recovery.35 36    Agents manage:37    - Multi-segment pricing (luxury/budget customers)38    - Inventory allocation across segments39    - Supply chain disruptions (lead time, demand shocks)40    - Recovery strategies (promotions, dynamic pricing)41    - Multi-action timesteps (CompositeAction)42    - Full pipeline visibility (pending_orders in observation)43    """44 45    def __init__(self, seed: Optional[int] = None):46        self.seed = seed47        if seed is not None:48            np.random.seed(seed)49            random.seed(seed)50 51        self.state: Optional[RetailState] = None52        self.initial_cash: float = 0.053        self._horizon: int = 3054        self.episode_metrics: Dict[str, float] = {}55 56        # Disruption parameters57        self._disruption_probability: float = 0.1558        self._disruption_recovery_days: int = 559 60    def reset(self, task_config: Dict[str, Any]) -> RetailObservation:61        """Reset environment with task configuration."""62        if self.seed is not None:63            np.random.seed(self.seed)64            random.seed(self.seed)65 66        products = list(task_config["products"])67        self._horizon = int(task_config.get("horizon", 30))68 69        # Initialize state70        inventory = {p: int(task_config["initial_inventory"].get(p, 5)) for p in products}71        cash = float(task_config["initial_cash"])72        self.initial_cash = cash73 74        # Demand patterns (hidden from agent)75        base_demand_luxury = {p: float(task_config.get("base_demand_luxury", {}).get(p, 1.5)) for p in products}76        base_demand_budget = {p: float(task_config.get("base_demand_budget", {}).get(p, 3.0)) for p in products}77 78        demand_elasticity = {p: float(task_config.get("demand_elasticity", {}).get(p, 1.2)) for p in products}79 80        # Economics81        product_costs = {p: float(task_config["product_costs"][p]) for p in products}82        holding_costs = {p: float(task_config.get("holding_costs", {}).get(p, 0.1)) for p in products}83        max_inventory = {p: float(task_config.get("max_inventory", {}).get(p, 50.0)) for p in products}84 85        # Pricing bounds86        price_bounds: Dict[str, Dict[str, float]] = {}87        for p in products:88            cost = product_costs[p]89            bounds_cfg = task_config.get("price_bounds", {}).get(p, {})90            price_bounds[p] = {91                "min": float(bounds_cfg.get("min", cost * 1.1)),92                "max": float(bounds_cfg.get("max", cost * 3.0)),93            }94 95        # Initial prices96        prices_luxury = {p: float(task_config.get("initial_prices_luxury", {}).get(p, product_costs[p] * 2.0)) for p in products}97        prices_budget = {p: float(task_config.get("initial_prices_budget", {}).get(p, product_costs[p] * 1.3)) for p in products}98        competitor_prices = {p: prices_budget[p] * 0.95 for p in products}99 100        # Supply chain101        lead_time_mean = int(task_config.get("lead_time_mean", 2))102        lead_time_variance = int(task_config.get("lead_time_variance", 1))103        supplier_reliability = float(task_config.get("supplier_reliability", 0.9))104 105        self.state = RetailState(106            day=0,107            cash=cash,108            inventory=inventory,109            base_demand_luxury=base_demand_luxury,110            base_demand_budget=base_demand_budget,111            demand_elasticity=demand_elasticity,112            prices_luxury=prices_luxury,113            prices_budget=prices_budget,114            competitor_prices=competitor_prices,115            price_bounds=price_bounds,116            product_costs=product_costs,117            holding_costs=holding_costs,118            max_inventory=max_inventory,119            lead_time_mean=lead_time_mean,120            lead_time_variance=lead_time_variance,121            pending_orders={p: 0 for p in products},122            pending_order_queue={},123            supplier_reliability=supplier_reliability,124            active_disruptions=[],125            disruption_history=[],126            next_disruption_day=None,127            cumulative_sales_luxury={p: 0.0 for p in products},128            cumulative_sales_budget={p: 0.0 for p in products},129            cumulative_revenue=0.0,130            cumulative_holding_cost=0.0,131            cumulative_stockouts=0,132            cumulative_demand_lost=0.0,133            cumulative_order_cost=0.0,134            demand_history_luxury={p: [] for p in products},135            demand_history_budget={p: [] for p in products},136            stockouts_per_product={p: 0 for p in products},137        )138 139        self.episode_metrics = {140            "horizon": float(self._horizon),141            "num_products": float(len(products)),142            "baseline_profit": float(task_config.get("baseline_profit", 100.0)),143            "total_steps": 0.0,144            "total_revenue": 0.0,145            "total_cost": 0.0,146            "total_holding_cost": 0.0,147            "total_order_cost": 0.0,148            "profit": 0.0,149            "total_demand": 0.0,150            "total_sales": 0.0,151            "fill_rate": 0.0,152            "stockout_count": 0,153            "disruption_events": 0,154            "recovery_success_rate": 0.0,155            "price_efficiency": 0.0,156            "max_possible_holding_cost": float(157                sum(float(max_inventory[p]) * float(holding_costs[p]) for p in products) * self._horizon158            ),159        }160 161        return self._get_observation()162 163    # ── Step (supports both single and composite actions) ───────────164 165    def step(self, action: RetailAction) -> Tuple[RetailObservation, float, bool, Dict[str, Any]]:166        """Execute one step in the environment.167 168        Supports both legacy single-action and new CompositeAction payloads.169        For CompositeAction, sub-actions are resolved in priority order:170            1. price_changes  (before demand calculation)171            2. orders         (supply chain)172            3. allocations    (inventory split)173            4. promotions     (demand boost)174        """175        if self.state is None:176            raise RuntimeError("Environment not initialized. Call reset() first.")177 178        info: Dict[str, Any] = {179            "action_taken": _dump_model(action),180            "valid_action": True,181            "disruption_event": None,182            "composite": isinstance(action, CompositeAction),183        }184 185        # Check for new disruptions186        self._check_disruptions()187 188        # Apply disruption effects to demand189        disruption_multiplier = self._compute_disruption_multiplier()190 191        # Update competitor prices192        for p in self.state.inventory.keys():193            my_bdg = self.state.prices_budget[p]194            if self.state.competitor_prices[p] > my_bdg:195                self.state.competitor_prices[p] = max(self.state.product_costs[p] * 1.05, my_bdg * 0.95)196            else:197                self.state.competitor_prices[p] *= random.uniform(0.95, 1.05)198 199        # ── Process action(s) ───────────────────────────────────────200        action_reward = 0.0201 202        if isinstance(action, CompositeAction):203            # Multi-action timestep: execute in priority order204            sub_infos: List[Dict[str, Any]] = []205 206            # 1. Price changes first (affect demand calculation)207            for sub in (action.price_changes or []):208                sub_info: Dict[str, Any] = {"valid_action": True}209                action_reward += self._apply_set_price(sub, sub_info)210                sub_infos.append({"type": "set_price", **sub_info})211 212            # 2. Orders (supply chain)213            for sub in (action.orders or []):214                sub_info = {"valid_action": True}215                action_reward += self._apply_order(sub, sub_info)216                sub_infos.append({"type": "order", **sub_info})217 218            # 3. Allocations219            for sub in (action.allocations or []):220                sub_info = {"valid_action": True}221                action_reward += self._apply_allocate(sub, sub_info)222                sub_infos.append({"type": "allocate", **sub_info})223 224            # 4. Promotions225            for sub in (action.promotions or []):226                sub_info = {"valid_action": True}227                action_reward += self._apply_promote(sub, sub_info)228                sub_infos.append({"type": "promote", **sub_info})229 230            info["sub_actions"] = sub_infos231        else:232            # Legacy single-action path233            if action.action == ActionType.ALLOCATE:234                action_reward = self._apply_allocate(action, info)235            elif action.action == ActionType.SET_PRICE:236                action_reward = self._apply_set_price(action, info)237            elif action.action == ActionType.ORDER:238                action_reward = self._apply_order(action, info)239            elif action.action == ActionType.PROMOTE:240                action_reward = self._apply_promote(action, info)241            elif action.action == ActionType.NOOP:242                pass243            else:244                info["valid_action"] = False245 246        # Process pending order arrivals247        self._realize_arrivals()248 249        # Simulate market (demand and sales)250        market_reward = self._simulate_market(disruption_multiplier)251 252        # Apply holding costs253        holding_cost = self._apply_holding_costs()254 255        # Total reward (step-wise signal aligned with profit)256        total_reward = action_reward + market_reward - holding_cost257 258        # Update state259        self.state.day += 1260        done = self.state.day >= self._horizon or self.state.cash < -100.0261 262        # Finalize metrics263        if done:264            self._finalize_episode()265            grader = score_episode(self.episode_metrics)266            info["grader"] = grader267            info["terminal_summary"] = {268                "cash": float(self.state.cash),269                "profit": float(self.episode_metrics.get("profit", 0.0)),270                "metrics": {k: float(v) for k, v in self.episode_metrics.items()},271                "grader": grader,272            }273 274        self.episode_metrics["total_steps"] += 1.0275 276        observation = self._get_observation()277        return observation, total_reward, done, info278 279    # ── Disruption mechanics ────────────────────────────────────────280 281    def _check_disruptions(self) -> None:282        """Randomly trigger disruptions (supply/demand shocks)."""283        assert self.state is not None284 285        # Check if any active disruptions should end286        for disp in self.state.active_disruptions[:]:287            if self.state.day >= disp.day_triggered + disp.duration_days:288                self.state.active_disruptions.remove(disp)289 290        # Chance of new disruption291        if random.random() < self._disruption_probability:292            event_types = ["demand_collapse", "supply_delay", "demand_spike"]293            event_type = random.choice(event_types)294            product = random.choice(list(self.state.inventory.keys()))295            severity = random.uniform(0.3, 0.9)296            duration = random.randint(2, 5)297 298            disruption = DisruptionEvent(299                event_type=event_type,300                product=product,301                severity=severity,302                duration_days=duration,303                day_triggered=self.state.day,304            )305            self.state.active_disruptions.append(disruption)306            self.state.disruption_history.append(disruption)307            self.episode_metrics["disruption_events"] += 1.0308 309    def _compute_disruption_multiplier(self) -> float:310        """Compute demand multiplier based on active disruptions."""311        if not self.state.active_disruptions:312            return 1.0313 314        # Demand collapses reduce demand, spikes increase it315        multiplier = 1.0316        for disp in self.state.active_disruptions:317            if disp.event_type == "demand_collapse":318                multiplier *= (1.0 - disp.severity * 0.8)319            elif disp.event_type == "demand_spike":320                multiplier *= (1.0 + disp.severity * 1.5)321 322        return max(0.1, min(3.0, multiplier))  # Clamp extremes323 324    # ── Action handlers ─────────────────────────────────────────────325 326    def _apply_allocate(self, action: RetailAction, info: Dict[str, Any]) -> float:327        """Allocate inventory to luxury and budget segments."""328        assert self.state is not None329 330        if action.product not in self.state.inventory:331            info["valid_action"] = False332            return -1.0333 334        total_allocated = action.luxury_units + action.budget_units335        if total_allocated > self.state.inventory[action.product]:336            info["valid_action"] = False337            return -1.0338 339        # Store allocation for market simulation340        info["allocation_luxury"] = action.luxury_units341        info["allocation_budget"] = action.budget_units342 343        return 0.5  # Small reward for proactive allocation344 345    def _apply_set_price(self, action: RetailAction, info: Dict[str, Any]) -> float:346        """Set price for a segment."""347        assert self.state is not None348 349        if action.product not in self.state.price_bounds:350            info["valid_action"] = False351            return -1.0352 353        bounds = self.state.price_bounds[action.product]354        if action.new_price < bounds["min"] or action.new_price > bounds["max"]:355            info["valid_action"] = False356            return -1.0357 358        if action.segment == "luxury":359            self.state.prices_luxury[action.product] = action.new_price360        elif action.segment == "budget":361            self.state.prices_budget[action.product] = action.new_price362        else:363            info["valid_action"] = False364            return -1.0365 366        info["price_set"] = action.new_price367        return 0.0  # Neutral reward; actual benefit comes from sales368 369    def _apply_order(self, action: RetailAction, info: Dict[str, Any]) -> float:370        """Place an order with supplier (subject to reliability and lead time)."""371        assert self.state is not None372 373        if action.product not in self.state.inventory:374            info["valid_action"] = False375            return -2.0376 377        supplier_choice = getattr(action, "supplier", "A").upper()378        if supplier_choice == "B":379            # Fast, reliable, expensive380            unit_cost = self.state.product_costs[action.product] * 1.25381            lead_time_mean = 1382            lead_time_variance = 0383            reliability = 1.0384        else:385            # Slow, cheap, unreliable386            unit_cost = self.state.product_costs[action.product]387            lead_time_mean = self.state.lead_time_mean388            lead_time_variance = self.state.lead_time_variance389            reliability = self.state.supplier_reliability390 391        cost = float(action.quantity) * unit_cost392        if cost > self.state.cash:393            info["valid_action"] = False394            return -2.0395 396        # Deduct cost immediately397        self.state.cash -= cost398        self.state.cumulative_order_cost += cost399        self.episode_metrics["total_order_cost"] = self.state.cumulative_order_cost400 401        # Stochastic lead time402        lead_time = max(0, int(np.random.normal(lead_time_mean, lead_time_variance)))403        arrival_day = self.state.day + lead_time404 405        # Chance order doesn't arrive (supplier unreliability)406        if random.random() > reliability:407            info["order_lost"] = True408            return -1.0  # Penalty for lost order409 410        arrival_bucket = self.state.pending_order_queue.setdefault(arrival_day, {})411        arrival_bucket[action.product] = int(arrival_bucket.get(action.product, 0)) + action.quantity412        self.state.pending_orders[action.product] += action.quantity413 414        info["order_enqueued"] = True415        info["arrival_day"] = arrival_day416        info["lead_time"] = lead_time417        info["supplier"] = supplier_choice418        return 0.0  # Neutral; benefit comes later419 420    def _apply_promote(self, action: RetailAction, info: Dict[str, Any]) -> float:421        """Run a promotional campaign (increases demand at cost)."""422        assert self.state is not None423 424        if action.budget_allocated > self.state.cash:425            info["valid_action"] = False426            return -1.0427 428        self.state.cash -= action.budget_allocated429        info["promotion_budget"] = action.budget_allocated430        info["promotion_multiplier"] = 1.0 + (action.budget_allocated / 100.0)  # ROI estimate431 432        return -action.budget_allocated / 10.0  # Small upfront cost433 434    # ── Supply chain pipeline ───────────────────────────────────────435 436    def _realize_arrivals(self) -> None:437        """Process pending orders that arrive today."""438        assert self.state is not None439 440        due = self.state.pending_order_queue.pop(self.state.day, {})441        for product, qty in due.items():442            self.state.inventory[product] += int(qty)443            self.state.pending_orders[product] = max(444                0, int(self.state.pending_orders[product]) - int(qty)445            )446 447    def _build_pending_orders_observation(self) -> Dict[str, list]:448        """Build agent-visible pending order pipeline from internal queue.449 450        Transforms the internal {arrival_day: {product: qty}} queue into a451        per-product list of {quantity, days_to_arrival} dicts that satisfy452        the Markov property — the agent can now see its own pipeline.453        """454        assert self.state is not None455 456        products = list(self.state.inventory.keys())457        result: Dict[str, list] = {p: [] for p in products}458 459        for arrival_day, bucket in sorted(self.state.pending_order_queue.items()):460            days_to_arrival = max(0, arrival_day - self.state.day)461            for product, qty in bucket.items():462                if product in result:463                    result[product].append({464                        "quantity": int(qty),465                        "days_to_arrival": days_to_arrival,466                    })467 468        return result469 470    # ── Market simulation ───────────────────────────────────────────471 472    _DEMAND_HISTORY_WINDOW = 3  # rolling window size for demand history473 474    def _simulate_market(self, disruption_multiplier: float) -> float:475        """Simulate demand, allocations, and sales for each segment."""476        assert self.state is not None477 478        total_revenue = 0.0479        total_demand = 0.0480        total_sales = 0.0481        step_unmet = 0  # Track unmet demand for THIS step only482 483        seasonality_trend = 1.0 + 0.5 * np.sin(self.state.day / self._horizon * np.pi)484 485        for product in self.state.inventory.keys():486            comp_price = self.state.competitor_prices[product]487            # Base demand488            demand_lux = self._sample_demand(489                self.state.base_demand_luxury[product],490                self.state.prices_luxury[product],491                self.state.product_costs[product],492                self.state.demand_elasticity[product],493                disruption_multiplier * seasonality_trend,494                comp_price * 1.3,495            )496            demand_bdg = self._sample_demand(497                self.state.base_demand_budget[product],498                self.state.prices_budget[product],499                self.state.product_costs[product],500                self.state.demand_elasticity[product],501                disruption_multiplier * seasonality_trend,502                comp_price,503            )504 505            # Record actual demand in rolling history for realistic observations506            hist_lux = self.state.demand_history_luxury.setdefault(product, [])507            hist_lux.append(float(demand_lux))508            if len(hist_lux) > self._DEMAND_HISTORY_WINDOW:509                hist_lux.pop(0)510 511            hist_bdg = self.state.demand_history_budget.setdefault(product, [])512            hist_bdg.append(float(demand_bdg))513            if len(hist_bdg) > self._DEMAND_HISTORY_WINDOW:514                hist_bdg.pop(0)515 516            total_demand += demand_lux + demand_bdg517 518            # Allocate inventory (simple heuristic: luxury gets priority if priced higher)519            available = int(self.state.inventory[product])520            lux_price = self.state.prices_luxury[product]521            bdg_price = self.state.prices_budget[product]522 523            # Luxury segment gets preference if higher margin524            if lux_price > bdg_price:525                sales_lux = min(int(demand_lux), available // 2)526                sales_bdg = min(int(demand_bdg), available - sales_lux)527            else:528                sales_bdg = min(int(demand_bdg), available // 2)529                sales_lux = min(int(demand_lux), available - sales_bdg)530 531            # Revenue532            revenue_lux = sales_lux * lux_price533            revenue_bdg = sales_bdg * bdg_price534            total_revenue += revenue_lux + revenue_bdg535 536            # Update inventory and metrics537            self.state.inventory[product] -= sales_lux + sales_bdg538            self.state.cumulative_sales_luxury[product] += sales_lux539            self.state.cumulative_sales_budget[product] += sales_bdg540            total_sales += sales_lux + sales_bdg541 542            # Stockout tracking — per product543            unmet_lux = max(0, int(demand_lux) - sales_lux)544            unmet_bdg = max(0, int(demand_bdg) - sales_bdg)545            unmet = unmet_lux + unmet_bdg546            step_unmet += unmet547 548            if unmet > 0:549                self.episode_metrics["stockout_count"] += 1.0550                self.state.cumulative_stockouts += 1551                self.state.cumulative_demand_lost += float(unmet)552                self.state.stockouts_per_product[product] = (553                    self.state.stockouts_per_product.get(product, 0) + 1554                )555 556        self.state.cash += total_revenue557        self.state.cumulative_revenue += total_revenue558 559        self.episode_metrics["total_revenue"] += total_revenue560        self.episode_metrics["total_demand"] += total_demand561        self.episode_metrics["total_sales"] += total_sales562 563        # Return reward signal: per-step revenue minus per-step stockout penalty564        stockout_penalty = step_unmet * 2.0565        return total_revenue - stockout_penalty566 567    def _sample_demand(568        self,569        base_demand: float,570        price: float,571        product_cost: float,572        elasticity: float,573        disruption_multiplier: float,574        competitor_price: float,575    ) -> int:576        """Sample demand with price elasticity and disruptions."""577        rel_price = price / max(0.01, competitor_price)578        price_effect = rel_price ** (-elasticity * 1.5)579 580        effective_demand = base_demand * price_effect * disruption_multiplier581        effective_demand = max(0.1, effective_demand)582 583        return int(np.random.poisson(effective_demand))584 585    def _apply_holding_costs(self) -> float:586        """Apply inventory holding costs."""587        assert self.state is not None588 589        total_cost = 0.0590        for product, qty in self.state.inventory.items():591            cost = float(qty) * self.state.holding_costs[product]592            total_cost += cost593 594        self.state.cash -= total_cost595        self.state.cumulative_holding_cost += total_cost596        self.episode_metrics["total_cost"] += total_cost597        self.episode_metrics["total_holding_cost"] += total_cost598 599        return total_cost600 601    def _finalize_episode(self) -> None:602        """Finalize episode metrics."""603        assert self.state is not None604 605        profit = self.state.cash - self.initial_cash606        self.episode_metrics["profit"] = profit607 608        if self.episode_metrics["total_demand"] > 0:609            self.episode_metrics["fill_rate"] = self.episode_metrics["total_sales"] / self.episode_metrics["total_demand"]610        else:611            self.episode_metrics["fill_rate"] = 1.0612 613        if self.episode_metrics["disruption_events"] > 0:614            recovery_count = len([d for d in self.state.disruption_history if d.duration_days < 5])615            self.episode_metrics["recovery_success_rate"] = recovery_count / self.episode_metrics["disruption_events"]616        else:617            self.episode_metrics["recovery_success_rate"] = 0.0618 619    # ── Observation builder ─────────────────────────────────────────620 621    def _get_observation(self) -> RetailObservation:622        """Get partial state observation for agent (Markov-compliant)."""623        if self.state is None:624            raise RuntimeError("Environment not initialized")625 626        # Use actual rolling demand history when available; fall back to base constants627        # on day 0 before any market simulation has run.628        recent_demand_luxury: Dict[str, float] = {}629        recent_demand_budget: Dict[str, float] = {}630        for p in self.state.inventory.keys():631            hist_lux = self.state.demand_history_luxury.get(p, [])632            recent_demand_luxury[p] = (633                float(sum(hist_lux) / len(hist_lux))634                if hist_lux635                else float(max(0.1, self.state.base_demand_luxury[p]))636            )637            hist_bdg = self.state.demand_history_budget.get(p, [])638            recent_demand_budget[p] = (639                float(sum(hist_bdg) / len(hist_bdg))640                if hist_bdg641                else float(max(0.1, self.state.base_demand_budget[p]))642            )643 644        # Per-product stockout counts (how many steps this product had unmet demand)645        recent_stockouts = {646            p: int(self.state.stockouts_per_product.get(p, 0))647            for p in self.state.inventory.keys()648        }649 650        disruption_active = len(self.state.active_disruptions) > 0651        disruption_severity = max([d.severity for d in self.state.active_disruptions], default=0.0)652        market_confidence = max(0.0, 0.9 - (self.state.day / self._horizon) * 0.3)653 654        # Build pipeline visibility (Markov property compliance)655        pending_orders = self._build_pending_orders_observation()656 657        return RetailObservation(658            day=self.state.day,659            cash=self.state.cash,660            inventory=self.state.inventory.copy(),661            pending_orders=pending_orders,662            recent_demand_luxury=recent_demand_luxury,663            recent_demand_budget=recent_demand_budget,664            recent_stockouts=recent_stockouts,665            prices_luxury=self.state.prices_luxury.copy(),666            prices_budget=self.state.prices_budget.copy(),667            competitor_prices=self.state.competitor_prices.copy(),668            disruption_active=disruption_active,669            disruption_severity=disruption_severity,670            market_confidence=market_confidence,671            seasonality_multiplier=1.0 + 0.5 * np.sin(self.state.day / self._horizon * np.pi),672        )673 674    def get_state(self) -> Dict[str, Any]:675        """Get full internal state."""676        if self.state is None:677            raise RuntimeError("Environment not initialized")678 679        return _dump_model(self.state)680