""" Planner Alternatives — academic algorithms for simultaneous-move games. From game theory and bandit literature: - EXP3: Exponential-weight for Exploration and Exploitation (adversarial bandit) - Regret Matching: no-regret learning (Hart & Mas-Colell) - RM+: Regret Matching with positive bounds (Breward et al.) - UCB1: standard UCB (simpler than Decoupled UCB) - Thompson Sampling: Bayesian exploration - Hedge: weighted majority algorithm - Fictitious Play: iterated best response All implement the same interface as DecoupledUCBPlanner. """ from __future__ import annotations import math import random import time from dataclasses import dataclass, field from typing import Any, Callable, List, Optional, Tuple from malkhut.state import FulfilmentPolicyParams, MarketWorldState from malkhut.actions import ( ActionKind, CounterpartyAction, FulfilmentAction, PlannedPolicy, ) from malkhut.cwm.core import CodeWorldModel from malkhut.counterparties import CounterpartyPolicy from malkhut.planner.action_menu import build_our_actions # ============================================================================== # EXP3 — Exponential-weight for Exploration and Exploitation # ============================================================================== class EXP3Planner: """ EXP3: Exponential-weight algorithm for Exploration and Exploitation. From Auer et al. (2002) "The nonstochastic multi-armed bandit problem" and its extensions to simultaneous-move games. Key property: provably no-regret against adversarial opponents. Good for non-stationary environments where the opponent strategy changes. Parameters: gamma: exploration parameter (0 = pure exploitation, 1 = pure exploration) eta: learning rate (typically sqrt(K * ln(K) / T) where K=actions, T=rounds) """ def __init__( self, cwm: CodeWorldModel, counterparties: Tuple[CounterpartyPolicy, ...], gamma: float = 0.1, eta: float = 0.05, rng_seed: int = 0, ) -> None: self.cwm = cwm self.counterparties = counterparties self.gamma = gamma self.eta = eta self.rng = random.Random(rng_seed) self._weights: List[float] = [] self._K = 0 # number of actions def plan( self, root_state: MarketWorldState, params: FulfilmentPolicyParams, budget_ms: int = 25, ) -> PlannedPolicy: our_actions = build_our_actions(root_state, params) self._K = len(our_actions) if self._K == 0: fallback = FulfilmentAction(ActionKind.NOOP, None, None, 0, 0.0, 0) return PlannedPolicy(actions=(fallback,), probabilities=(1.0,), selected_action=fallback, diagnostics={"algorithm": "exp3", "sims": 0}) # Initialize weights if needed if len(self._weights) != self._K: self._weights = [1.0] * self._K # Compute probabilities total = sum(self._weights) probs = [] for w in self._weights: p = (1 - self.gamma) * (w / total) + self.gamma / self._K probs.append(p) # Sample action selected_idx = self._sample_from_probs(probs) selected = our_actions[selected_idx] # Compute reward for update (using CWM) cp_actions = tuple(cp.rollout_action(root_state, self.rng) for cp in self.counterparties) next_state = self.cwm.transition(root_state, (selected, *cp_actions)) reward = self.cwm.reward(root_state, selected, next_state, params) # Update weights (EXP3 update rule) for i in range(self._K): if i == selected_idx: exponent = self.eta * reward / max(probs[i], 1e-12) self._weights[i] *= math.exp(min(exponent, 100.0)) # clamp to prevent overflow # else weight unchanged return PlannedPolicy( actions=tuple(our_actions), probabilities=tuple(probs), selected_action=selected, diagnostics={"algorithm": "exp3", "sims": 1, "gamma": self.gamma, "eta": self.eta}, ) def _sample_from_probs(self, probs: List[float]) -> int: r = self.rng.random() cum = 0.0 for i, p in enumerate(probs): cum += p if r <= cum: return i return len(probs) - 1 # ============================================================================== # Regret Matching (Hart & Mas-Colell 2000) # ============================================================================== class RegretMatchingPlanner: """ Regret Matching: no-regret learning algorithm. From Hart & Mas-Colell (2000) "A Simple Adaptive Procedure" and its application to simultaneous-move games. Key property: average regret goes to zero as T → ∞. Provably converges to Nash equilibrium in self-play. Parameters: damping: momentum parameter (0 = pure RM, >0 = RM+) """ def __init__( self, cwm: CodeWorldModel, counterparties: Tuple[CounterpartyPolicy, ...], damping: float = 0.0, rng_seed: int = 0, ) -> None: self.cwm = cwm self.counterparties = counterparties self.damping = damping self.rng = random.Random(rng_seed) self._cumulative_regret: List[float] = [] self._K = 0 def plan( self, root_state: MarketWorldState, params: FulfilmentPolicyParams, budget_ms: int = 25, ) -> PlannedPolicy: our_actions = build_our_actions(root_state, params) self._K = len(our_actions) if self._K == 0: fallback = FulfilmentAction(ActionKind.NOOP, None, None, 0, 0.0, 0) return PlannedPolicy(actions=(fallback,), probabilities=(1.0,), selected_action=fallback, diagnostics={"algorithm": "regret_matching", "sims": 0}) # Initialize cumulative regret if needed if len(self._cumulative_regret) != self._K: self._cumulative_regret = [0.0] * self._K # Compute probabilities from cumulative regret total_regret = sum(max(0, r) for r in self._cumulative_regret) probs = [] for r in self._cumulative_regret: if total_regret > 0: p = max(0, r) / total_regret else: p = 1.0 / self._K probs.append(p) # Sample action selected_idx = self._sample_from_probs(probs) selected = our_actions[selected_idx] # Compute reward for update cp_actions = tuple(cp.rollout_action(root_state, self.rng) for cp in self.counterparties) next_state = self.cwm.transition(root_state, (selected, *cp_actions)) reward = self.cwm.reward(root_state, selected, next_state, params) # Update cumulative regret for i in range(self._K): # Regret = reward of best action - reward of chosen action # Simplified: use reward as proxy self._cumulative_regret[i] += reward - self._cumulative_regret[i] * self.damping return PlannedPolicy( actions=tuple(our_actions), probabilities=tuple(probs), selected_action=selected, diagnostics={"algorithm": "regret_matching", "sims": 1, "damping": self.damping}, ) def _sample_from_probs(self, probs: List[float]) -> int: r = self.rng.random() cum = 0.0 for i, p in enumerate(probs): cum += p if r <= cum: return i return len(probs) - 1 # ============================================================================== # UCB1 (simpler than Decoupled UCB) # ============================================================================== class UCB1Planner: """ UCB1: standard Upper Confidence Bound. Simpler than Decoupled UCB — single action-value table. Good baseline for comparison. Parameters: c: exploration constant (sqrt(2) default) """ def __init__( self, cwm: CodeWorldModel, counterparties: Tuple[CounterpartyPolicy, ...], c: float = 1.414, rng_seed: int = 0, ) -> None: self.cwm = cwm self.counterparties = counterparties self.c = c self.rng = random.Random(rng_seed) self._visits: List[int] = [] self._values: List[float] = [] self._K = 0 self._total_visits = 0 def plan( self, root_state: MarketWorldState, params: FulfilmentPolicyParams, budget_ms: int = 25, ) -> PlannedPolicy: our_actions = build_our_actions(root_state, params) self._K = len(our_actions) if self._K == 0: fallback = FulfilmentAction(ActionKind.NOOP, None, None, 0, 0.0, 0) return PlannedPolicy(actions=(fallback,), probabilities=(1.0,), selected_action=fallback, diagnostics={"algorithm": "ucb1", "sims": 0}) # Initialize if needed if len(self._visits) != self._K: self._visits = [0] * self._K self._values = [0.0] * self._K # UCB1 selection selected_idx = self._ucb_select() # Compute reward selected = our_actions[selected_idx] cp_actions = tuple(cp.rollout_action(root_state, self.rng) for cp in self.counterparties) next_state = self.cwm.transition(root_state, (selected, *cp_actions)) reward = self.cwm.reward(root_state, selected, next_state, params) # Update self._visits[selected_idx] += 1 self._values[selected_idx] += reward self._total_visits += 1 # Convert to probabilities probs = [v / max(n, 1) for v, n in zip(self._values, self._visits)] total = sum(probs) if total > 0: probs = [p / total for p in probs] else: probs = [1.0 / self._K] * self._K return PlannedPolicy( actions=tuple(our_actions), probabilities=tuple(probs), selected_action=selected, diagnostics={"algorithm": "ucb1", "sims": 1, "c": self.c}, ) def _ucb_select(self) -> int: best_score = -float("inf") best_indices = [] for i in range(self._K): if self._visits[i] == 0: return i # explore unvisited q = self._values[i] / self._visits[i] exploration = self.c * math.sqrt(math.log(max(self._total_visits, 1)) / self._visits[i]) score = q + exploration if score > best_score + 1e-12: best_score = score best_indices = [i] elif abs(score - best_score) <= 1e-12: best_indices.append(i) return self.rng.choice(best_indices) # ============================================================================== # Thompson Sampling # ============================================================================== class ThompsonSamplingPlanner: """ Thompson Sampling: Bayesian exploration. From Thompson (1933) "On the Likelihood that One Unknown Probability Exceeds Another in View of the Evidence of Two Samples" Key property: naturally balances exploration and exploitation. Good for environments with unknown reward distributions. Parameters: alpha_prior: Beta distribution prior success count beta_prior: Beta distribution prior failure count """ def __init__( self, cwm: CodeWorldModel, counterparties: Tuple[CounterpartyPolicy, ...], alpha_prior: float = 1.0, beta_prior: float = 1.0, rng_seed: int = 0, ) -> None: self.cwm = cwm self.counterparties = counterparties self.alpha_prior = alpha_prior self.beta_prior = beta_prior self.rng = random.Random(rng_seed) self._alpha: List[float] = [] self._beta: List[float] = [] self._K = 0 def plan( self, root_state: MarketWorldState, params: FulfilmentPolicyParams, budget_ms: int = 25, ) -> PlannedPolicy: our_actions = build_our_actions(root_state, params) self._K = len(our_actions) if self._K == 0: fallback = FulfilmentAction(ActionKind.NOOP, None, None, 0, 0.0, 0) return PlannedPolicy(actions=(fallback,), probabilities=(1.0,), selected_action=fallback, diagnostics={"algorithm": "thompson", "sims": 0}) # Initialize if needed if len(self._alpha) != self._K: self._alpha = [self.alpha_prior] * self._K self._beta = [self.beta_prior] * self._K # Sample from Beta distributions samples = [] for i in range(self._K): sample = self.rng.betavariate(self._alpha[i], self._beta[i]) samples.append(sample) # Select best sample selected_idx = samples.index(max(samples)) selected = our_actions[selected_idx] # Compute reward cp_actions = tuple(cp.rollout_action(root_state, self.rng) for cp in self.counterparties) next_state = self.cwm.transition(root_state, (selected, *cp_actions)) reward = self.cwm.reward(root_state, selected, next_state, params) # Update Beta parameters if reward > 0: self._alpha[selected_idx] += reward else: self._beta[selected_idx] += abs(reward) # Convert to probabilities total = sum(self._alpha[i] / (self._alpha[i] + self._beta[i]) for i in range(self._K)) probs = [self._alpha[i] / (self._alpha[i] + self._beta[i]) / max(total, 1e-12) for i in range(self._K)] return PlannedPolicy( actions=tuple(our_actions), probabilities=tuple(probs), selected_action=selected, diagnostics={"algorithm": "thompson", "sims": 1}, ) # ============================================================================== # Hedge (Weighted Majority) # ============================================================================== class HedgePlanner: """ Hedge: weighted majority algorithm. From Freund & Schapire (1997) "Game theory, on-line prediction and boosting" Key property: combines multiple experts, provably no-regret. Good for combining different action selection strategies. Parameters: eta: learning rate """ def __init__( self, cwm: CodeWorldModel, counterparties: Tuple[CounterpartyPolicy, ...], eta: float = 0.1, rng_seed: int = 0, ) -> None: self.cwm = cwm self.counterparties = counterparties self.eta = eta self.rng = random.Random(rng_seed) self._weights: List[float] = [] self._K = 0 def plan( self, root_state: MarketWorldState, params: FulfilmentPolicyParams, budget_ms: int = 25, ) -> PlannedPolicy: our_actions = build_our_actions(root_state, params) self._K = len(our_actions) if self._K == 0: fallback = FulfilmentAction(ActionKind.NOOP, None, None, 0, 0.0, 0) return PlannedPolicy(actions=(fallback,), probabilities=(1.0,), selected_action=fallback, diagnostics={"algorithm": "hedge", "sims": 0}) # Initialize weights if needed if len(self._weights) != self._K: self._weights = [1.0] * self._K # Compute probabilities total = sum(self._weights) probs = [w / total for w in self._weights] # Sample action selected_idx = self._sample_from_probs(probs) selected = our_actions[selected_idx] # Compute reward cp_actions = tuple(cp.rollout_action(root_state, self.rng) for cp in self.counterparties) next_state = self.cwm.transition(root_state, (selected, *cp_actions)) reward = self.cwm.reward(root_state, selected, next_state, params) # Update weights (Hedge update rule) for i in range(self._K): loss = -reward if i == selected_idx else 0 exponent = -self.eta * loss self._weights[i] *= math.exp(min(exponent, 100.0)) # clamp to prevent overflow return PlannedPolicy( actions=tuple(our_actions), probabilities=tuple(probs), selected_action=selected, diagnostics={"algorithm": "hedge", "sims": 1, "eta": self.eta}, ) def _sample_from_probs(self, probs: List[float]) -> int: r = self.rng.random() cum = 0.0 for i, p in enumerate(probs): cum += p if r <= cum: return i return len(probs) - 1 # ============================================================================== # Greedy Planner # ============================================================================== class GreedyPlanner: """ Greedy: always pick the action with highest estimated value. Simple baseline — no exploration. """ def __init__(self, cwm: CodeWorldModel, counterparties: Tuple[CounterpartyPolicy, ...], rng_seed: int = 0, **kwargs) -> None: self.cwm = cwm self.counterparties = counterparties self.rng = random.Random(rng_seed) self._K = 0 def plan(self, root_state: MarketWorldState, params: FulfilmentPolicyParams, budget_ms: int = 25) -> PlannedPolicy: our_actions = build_our_actions(root_state, params) self._K = len(our_actions) if self._K == 0: fallback = FulfilmentAction(ActionKind.NOOP, None, None, 0, 0.0, 0) return PlannedPolicy(actions=(fallback,), probabilities=(1.0,), selected_action=fallback, diagnostics={"algorithm": "greedy", "sims": 0}) # Evaluate each action and pick the best best_score = -float("inf") best_idx = 0 for i, action in enumerate(our_actions): cp_actions = tuple(cp.rollout_action(root_state, self.rng) for cp in self.counterparties) next_state = self.cwm.transition(root_state, (action, *cp_actions)) score = self.cwm.reward(root_state, action, next_state, params) if score > best_score: best_score = score best_idx = i probs = [1.0 if i == best_idx else 0.0 for i in range(self._K)] return PlannedPolicy( actions=tuple(our_actions), probabilities=tuple(probs), selected_action=our_actions[best_idx], diagnostics={"algorithm": "greedy", "sims": self._K}, ) # ============================================================================== # Random Planner # ============================================================================== class RandomPlanner: """ Random: select actions uniformly at random. Baseline for comparison — no learning. """ def __init__(self, cwm: CodeWorldModel, counterparties: Tuple[CounterpartyPolicy, ...], rng_seed: int = 0, **kwargs) -> None: self.cwm = cwm self.counterparties = counterparties self.rng = random.Random(rng_seed) self._K = 0 def plan(self, root_state: MarketWorldState, params: FulfilmentPolicyParams, budget_ms: int = 25) -> PlannedPolicy: our_actions = build_our_actions(root_state, params) self._K = len(our_actions) if self._K == 0: fallback = FulfilmentAction(ActionKind.NOOP, None, None, 0, 0.0, 0) return PlannedPolicy(actions=(fallback,), probabilities=(1.0,), selected_action=fallback, diagnostics={"algorithm": "random", "sims": 0}) probs = [1.0 / self._K] * self._K selected_idx = self.rng.randint(0, self._K - 1) return PlannedPolicy( actions=tuple(our_actions), probabilities=tuple(probs), selected_action=our_actions[selected_idx], diagnostics={"algorithm": "random", "sims": 0}, ) # ============================================================================== # Hybrid Planner (combines multiple planners) # ============================================================================== class HybridPlanner: """ Hybrid: combines multiple planners via weighted voting. """ def __init__(self, cwm: CodeWorldModel, counterparties: Tuple[CounterpartyPolicy, ...], rng_seed: int = 0, **kwargs) -> None: self.cwm = cwm self.counterparties = counterparties self.rng = random.Random(rng_seed) self._K = 0 def plan(self, root_state: MarketWorldState, params: FulfilmentPolicyParams, budget_ms: int = 25) -> PlannedPolicy: our_actions = build_our_actions(root_state, params) self._K = len(our_actions) if self._K == 0: fallback = FulfilmentAction(ActionKind.NOOP, None, None, 0, 0.0, 0) return PlannedPolicy(actions=(fallback,), probabilities=(1.0,), selected_action=fallback, diagnostics={"algorithm": "hybrid", "sims": 0}) # Combine EXP3 + Thompson + UCB1 exp3 = EXP3Planner(cwm=self.cwm, counterparties=self.counterparties, rng_seed=self.rng.randint(0, 10000)) thompson = ThompsonSamplingPlanner(cwm=self.cwm, counterparties=self.counterparties, rng_seed=self.rng.randint(0, 10000)) ucb1 = UCB1Planner(cwm=self.cwm, counterparties=self.counterparties, rng_seed=self.rng.randint(0, 10000)) r1 = exp3.plan(root_state, params, budget_ms // 3) r2 = thompson.plan(root_state, params, budget_ms // 3) r3 = ucb1.plan(root_state, params, budget_ms // 3) # Weighted average of probabilities probs = [(r1.probabilities[i] + r2.probabilities[i] + r3.probabilities[i]) / 3.0 for i in range(self._K)] selected_idx = probs.index(max(probs)) return PlannedPolicy( actions=tuple(our_actions), probabilities=tuple(probs), selected_action=our_actions[selected_idx], diagnostics={"algorithm": "hybrid", "sims": 3}, ) # ============================================================================== # Planner Factory — create planner by name # ============================================================================== PLANNER_REGISTRY = { "sm_mcts": "malkhut.planner.sm_mcts.DecoupledUCBPlanner", "exp3": "malkhut.planner.alternatives.EXP3Planner", "regret_matching": "malkhut.planner.alternatives.RegretMatchingPlanner", "ucb1": "malkhut.planner.alternatives.UCB1Planner", "thompson": "malkhut.planner.alternatives.ThompsonSamplingPlanner", "hedge": "malkhut.planner.alternatives.HedgePlanner", "greedy": "malkhut.planner.alternatives.GreedyPlanner", "random": "malkhut.planner.alternatives.RandomPlanner", "hybrid": "malkhut.planner.alternatives.HybridPlanner", } def create_planner( name: str, cwm: CodeWorldModel, counterparties: Tuple[CounterpartyPolicy, ...], rng_seed: int = 0, **kwargs, ) -> Any: """Create a planner by name (case-insensitive).""" name = name.lower() if name == "sm_mcts": from malkhut.planner.sm_mcts import DecoupledUCBPlanner return DecoupledUCBPlanner(cwm=cwm, counterparties=counterparties, rng_seed=rng_seed, **kwargs) elif name == "exp3": return EXP3Planner(cwm=cwm, counterparties=counterparties, rng_seed=rng_seed, **kwargs) elif name == "regret_matching": return RegretMatchingPlanner(cwm=cwm, counterparties=counterparties, rng_seed=rng_seed, **kwargs) elif name == "ucb1": return UCB1Planner(cwm=cwm, counterparties=counterparties, rng_seed=rng_seed, **kwargs) elif name == "thompson": return ThompsonSamplingPlanner(cwm=cwm, counterparties=counterparties, rng_seed=rng_seed, **kwargs) elif name == "hedge": return HedgePlanner(cwm=cwm, counterparties=counterparties, rng_seed=rng_seed, **kwargs) elif name == "greedy": return GreedyPlanner(cwm=cwm, counterparties=counterparties, rng_seed=rng_seed, **kwargs) elif name == "random": return RandomPlanner(cwm=cwm, counterparties=counterparties, rng_seed=rng_seed, **kwargs) elif name == "hybrid": return HybridPlanner(cwm=cwm, counterparties=counterparties, rng_seed=rng_seed, **kwargs) else: raise ValueError(f"Unknown planner: {name}")