OMNIP0TENT/Syntax_Squad_FinopsENV
1
1"""FinOps RL Environment – Phase 2 Core Logic.2 3Implements FinOpsEnv with reset / step / state and full trajectory tracking.4All math follows the locked blueprint exactly, with the weekly-savings time-scale5bug fix applied (÷ 4.33).6"""7 8from __future__ import annotations9 10import copy11import random12import time13from typing import Optional14 15from schemas import (16 ActionEnvelope,17 BudgetState,18 LLMTierStats,19 ModifySaaSSeats,20 NoOp,21 Observation,22 RewardBreakdown,23 SaaSToolStats,24 StepResult,25 SwitchLLMRoutingTier,26)27 28# ── Reward constants ────────────────────────────────────────────────────────29SAVINGS_WEIGHT: float = 0.0130SLA_PENALTY: float = -75.031CHURN_PENALTY: float = -150.032INVALID_PENALTY: float = -50.033BANKRUPTCY_PENALTY: float = -500.034IDLE_PENALTY: float = -5.035 36# ── Operational constants ───────────────────────────────────────────────────37OVERHEAD_WEEKLY: float = 1_200.038MONTHLY_TO_WEEKLY: float = 4.3339SLA_THRESHOLD_MS: float = 800.040BUDGET_TOLERANCE: float = 0.0141 42 43class FinOpsEnv:44 """OpenEnv-compliant FinOps simulation environment."""45 46 def __init__(self) -> None:47 self.task: Optional[str] = None48 self.week: int = 149 self.saas_tools: list[SaaSToolStats] = []50 self.llm_tiers: list[LLMTierStats] = []51 self.budget: Optional[BudgetState] = None52 self.active_sla_breaches: int = 053 self.cumulative_savings: float = 0.054 self.episode_done: bool = False55 self.trajectory: list[dict] = []56 self._rng: Optional[random.Random] = None57 58 # ── helpers ──────────────────────────────────────────────────────────────59 60 def _compute_weekly_burn(self) -> float:61 saas = sum(t.monthly_cost_usd / MONTHLY_TO_WEEKLY for t in self.saas_tools)62 llm = sum(t.weekly_spend_usd for t in self.llm_tiers)63 return saas + llm + OVERHEAD_WEEKLY64 65 def _build_observation(self) -> Observation:66 burn = self._compute_weekly_burn()67 remaining_weeks = max(1, 52 - self.week + 1)68 remaining = self.budget.annual_budget_usd - self.budget.spent_to_date_usd69 projected = max(70 0.0,71 self.budget.spent_to_date_usd + burn * remaining_weeks - self.budget.annual_budget_usd,72 )73 self.budget.fiscal_week = self.week74 self.budget.weekly_burn_rate_usd = round(burn, 2)75 self.budget.remaining_budget_usd = round(remaining, 2)76 self.budget.projected_overrun_usd = round(projected, 2)77 78 return Observation(79 week=self.week,80 budget=self.budget.model_copy(),81 saas_tools=[t.model_copy() for t in self.saas_tools],82 llm_tiers=[t.model_copy() for t in self.llm_tiers],83 active_sla_breaches=self.active_sla_breaches,84 cumulative_savings_usd=round(self.cumulative_savings, 2),85 episode_done=self.episode_done,86 )87 88 def _count_sla_breaches(self) -> int:89 return sum(1 for t in self.llm_tiers if t.p95_latency_ms > t.sla_latency_threshold_ms)90 91 # ── reset ────────────────────────────────────────────────────────────────92 93 def reset(self, task: str) -> Observation:94 self.task = task95 self.week = 196 self.cumulative_savings = 0.097 self.active_sla_breaches = 098 self.episode_done = False99 self.trajectory = []100 self._rng = None101 102 if task == "easy":103 self._init_easy()104 elif task == "medium":105 self._init_medium()106 elif task == "hard":107 self._init_hard()108 else:109 raise ValueError(f"Unknown task: {task}")110 111 obs = self._build_observation()112 self.trajectory.append(113 {"step": 0, "action": None, "observation": obs, "reward": None, "reward_breakdown": None}114 )115 return obs116 117 # ── deterministic starting states ────────────────────────────────────────118 119 def _init_easy(self) -> None:120 self.budget = BudgetState(121 fiscal_week=1,122 annual_budget_usd=500_000.0,123 spent_to_date_usd=0.0,124 remaining_budget_usd=500_000.0,125 weekly_burn_rate_usd=0.0,126 projected_overrun_usd=0.0,127 )128 self.saas_tools = [129 SaaSToolStats(130 tool_name="linkedin_learning", total_seats=150, active_seats=50,131 inactive_seats=100, cost_per_seat_usd=30.0, monthly_cost_usd=4_500.0,132 ),133 SaaSToolStats(134 tool_name="slack", total_seats=200, active_seats=150,135 inactive_seats=50, cost_per_seat_usd=12.50, monthly_cost_usd=2_500.0,136 ),137 SaaSToolStats(138 tool_name="salesforce", total_seats=80, active_seats=80,139 inactive_seats=0, cost_per_seat_usd=75.0, monthly_cost_usd=6_000.0,140 ),141 ]142 self.llm_tiers = []143 144 def _init_medium(self) -> None:145 self.budget = BudgetState(146 fiscal_week=1,147 annual_budget_usd=200_000.0,148 spent_to_date_usd=180_000.0,149 remaining_budget_usd=20_000.0,150 weekly_burn_rate_usd=0.0,151 projected_overrun_usd=0.0,152 )153 self.saas_tools = []154 self.llm_tiers = [155 LLMTierStats(156 tier_name="premium", model_id="claude-opus-4",157 requests_this_week=50_000, cost_per_1k_tokens_usd=0.075,158 weekly_spend_usd=5_200.0, p95_latency_ms=420.0,159 sla_latency_threshold_ms=800.0,160 ),161 LLMTierStats(162 tier_name="standard", model_id="gpt-4o-mini",163 requests_this_week=30_000, cost_per_1k_tokens_usd=0.002,164 weekly_spend_usd=600.0, p95_latency_ms=210.0,165 sla_latency_threshold_ms=800.0,166 ),167 LLMTierStats(168 tier_name="opensource", model_id="llama-3-70b",169 requests_this_week=10_000, cost_per_1k_tokens_usd=0.0004,170 weekly_spend_usd=80.0, p95_latency_ms=680.0,171 sla_latency_threshold_ms=800.0,172 ),173 ]174 175 def _init_hard(self) -> None:176 self._rng = random.Random(time.time())177 self.budget = BudgetState(178 fiscal_week=1,179 annual_budget_usd=1_200_000.0,180 spent_to_date_usd=0.0,181 remaining_budget_usd=1_200_000.0,182 weekly_burn_rate_usd=0.0,183 projected_overrun_usd=0.0,184 )185 self.saas_tools = [186 SaaSToolStats(187 tool_name="salesforce", total_seats=300, active_seats=100,188 inactive_seats=200, cost_per_seat_usd=75.0, monthly_cost_usd=22_500.0,189 ),190 SaaSToolStats(191 tool_name="linkedin_learning", total_seats=1000, active_seats=500,192 inactive_seats=500, cost_per_seat_usd=30.0, monthly_cost_usd=500_000.0,193 ),194 SaaSToolStats(195 tool_name="slack", total_seats=1_000, active_seats=420,196 inactive_seats=580, cost_per_seat_usd=12.50, monthly_cost_usd=12_500.0,197 ),198 SaaSToolStats(199 tool_name="zoom", total_seats=400, active_seats=190,200 inactive_seats=210, cost_per_seat_usd=20.0, monthly_cost_usd=8_000.0,201 ),202 SaaSToolStats(203 tool_name="github", total_seats=250, active_seats=148,204 inactive_seats=102, cost_per_seat_usd=21.0, monthly_cost_usd=5_250.0,205 ),206 ]207 self.llm_tiers = [208 LLMTierStats(209 tier_name="premium", model_id="claude-opus-4",210 requests_this_week=80_000, cost_per_1k_tokens_usd=0.575,211 weekly_spend_usd=8_320.0, p95_latency_ms=430.0,212 sla_latency_threshold_ms=1000.0,213 ),214 LLMTierStats(215 tier_name="standard", model_id="gpt-4o-mini",216 requests_this_week=60_000, cost_per_1k_tokens_usd=0.042,217 weekly_spend_usd=1_200.0, p95_latency_ms=215.0,218 sla_latency_threshold_ms=1000.0,219 ),220 LLMTierStats(221 tier_name="opensource", model_id="llama-3-70b",222 requests_this_week=20_000, cost_per_1k_tokens_usd=0.0004,223 weekly_spend_usd=160.0, p95_latency_ms=690.0,224 sla_latency_threshold_ms=800.0,225 ),226 ]227 228 # ── hard-mode perturbation (deterministic via seeded RNG) ────────────────229 230 def _apply_perturbation(self) -> None:231 if self.task != "hard" or self._rng is None:232 return233 roll = self._rng.random()234 if roll < 0.30:235 tool = self._rng.choice(self.saas_tools)236 n = self._rng.randint(5, 25)237 tool.total_seats += n238 tool.inactive_seats += n239 tool.monthly_cost_usd = tool.total_seats * tool.cost_per_seat_usd240 elif roll < 0.50:241 for tier in self.llm_tiers:242 if tier.tier_name == "premium":243 old_reqs = tier.requests_this_week244 tier.requests_this_week += 10_000245 if old_reqs > 0:246 tier.weekly_spend_usd *= tier.requests_this_week / old_reqs247 break248 elif roll < 0.60:249 per_tool = 25 // len(self.saas_tools)250 remainder = 25 % len(self.saas_tools)251 for i, tool in enumerate(self.saas_tools):252 add = per_tool + (1 if i < remainder else 0)253 tool.total_seats += add254 tool.inactive_seats += add255 tool.monthly_cost_usd = tool.total_seats * tool.cost_per_seat_usd256 # else (≥0.60): no event – 40 %257 258 # ── step ─────────────────────────────────────────────────────────────────259 260 def step(self, action: ActionEnvelope) -> StepResult:261 if self.episode_done:262 raise ValueError("Episode is done. Call reset() to start a new episode.")263 264 # 1. Hard-mode perturbation at TOP of step265 self._apply_perturbation()266 267 # 2. Snapshot pre-action burn268 pre_burn = self._compute_weekly_burn()269 270 # 3. Process action271 weekly_savings = 0.0272 sla_breaches = 0273 churn_events = 0274 invalid = False275 276 act = action.action277 if isinstance(act, ModifySaaSSeats):278 r = self._handle_modify_saas(act)279 weekly_savings = r["savings"]280 churn_events = r["churn_events"]281 invalid = r["invalid"]282 elif isinstance(act, SwitchLLMRoutingTier):283 r = self._handle_switch_llm(act)284 sla_breaches = r["sla_breaches"]285 invalid = r["invalid"]286 # NoOp → nothing287 288 # 4. Clock: deduct weekly burn (pre-action) then credit savings289 self.budget.spent_to_date_usd += pre_burn290 self.budget.spent_to_date_usd -= weekly_savings291 self.cumulative_savings += weekly_savings292 self.budget.remaining_budget_usd = (293 self.budget.annual_budget_usd - self.budget.spent_to_date_usd294 )295 296 # 5. Advance week297 self.week += 1298 bankrupt = self.budget.remaining_budget_usd <= BUDGET_TOLERANCE299 if bankrupt or self.week > 52:300 self.week = min(self.week, 52)301 self.episode_done = True302 303 # 6. Recount SLA breaches for observation304 self.active_sla_breaches = self._count_sla_breaches()305 306 # 7. Reward307 rb = self._compute_reward(308 weekly_savings=weekly_savings,309 sla_breach_count=sla_breaches,310 churn_count=churn_events,311 invalid=invalid,312 bankrupt=bankrupt,313 is_noop=isinstance(act, NoOp),314 )315 316 obs = self._build_observation()317 self.trajectory.append(318 {319 "step": len(self.trajectory),320 "action": action,321 "observation": obs,322 "reward": rb.net_reward,323 "reward_breakdown": rb,324 }325 )326 return StepResult(327 observation=obs,328 reward=rb.net_reward,329 reward_breakdown=rb,330 done=self.episode_done,331 info={"week": self.week, "cumulative_savings": round(self.cumulative_savings, 2)},332 )333 334 # ── action handlers ──────────────────────────────────────────────────────335 336 def _handle_modify_saas(self, act: ModifySaaSSeats) -> dict:337 tool = next((t for t in self.saas_tools if t.tool_name == act.tool_name), None)338 if tool is None:339 return {"savings": 0.0, "churn_events": 0, "invalid": True}340 341 new_total = tool.total_seats + act.delta_seats342 churn = 0343 if new_total < tool.active_seats:344 churn = tool.active_seats - new_total345 new_total = tool.active_seats346 if new_total < 0:347 new_total = 0348 349 seats_removed = max(0, tool.total_seats - new_total)350 # BUG-FIX: monthly cost ÷ 4.33 to convert to weekly351 weekly_savings = (seats_removed * tool.cost_per_seat_usd) / MONTHLY_TO_WEEKLY352 353 tool.total_seats = new_total354 tool.inactive_seats = max(0, tool.total_seats - tool.active_seats)355 tool.monthly_cost_usd = tool.total_seats * tool.cost_per_seat_usd356 357 return {"savings": weekly_savings, "churn_events": 1 if churn > 0 else 0, "invalid": False}358 359 def _handle_switch_llm(self, act: SwitchLLMRoutingTier) -> dict:360 from_tier = next((t for t in self.llm_tiers if t.tier_name == act.from_tier), None)361 to_tier = next((t for t in self.llm_tiers if t.tier_name == act.to_tier), None)362 if from_tier is None or to_tier is None or from_tier.tier_name == to_tier.tier_name:363 return {"sla_breaches": 0, "invalid": True}364 365 requests_to_move = int(from_tier.requests_this_week * (act.traffic_shift_pct / 100.0))366 old_from_reqs = from_tier.requests_this_week367 old_to_reqs = to_tier.requests_this_week368 369 from_tier.requests_this_week -= requests_to_move370 to_tier.requests_this_week += requests_to_move371 372 # Proportional spend update373 if old_from_reqs > 0:374 from_tier.weekly_spend_usd *= from_tier.requests_this_week / old_from_reqs375 else:376 from_tier.weekly_spend_usd = 0.0377 if old_to_reqs > 0:378 to_tier.weekly_spend_usd *= to_tier.requests_this_week / old_to_reqs379 elif to_tier.requests_this_week > 0:380 to_tier.weekly_spend_usd = (381 to_tier.requests_this_week * to_tier.cost_per_1k_tokens_usd / 1_000.0382 )383 384 # Latency pressure on destination tier385 new_to_reqs = to_tier.requests_this_week386 if new_to_reqs > 0 and requests_to_move > 0:387 latency_pressure_factor = 1.0 + (requests_to_move / new_to_reqs) * 0.4388 to_tier.p95_latency_ms *= latency_pressure_factor389 390 sla_breaches = self._count_sla_breaches()391 self.active_sla_breaches = sla_breaches392 return {"sla_breaches": sla_breaches, "invalid": False}393 394 # ── reward ───────────────────────────────────────────────────────────────395 396 def _compute_reward(397 self,398 weekly_savings: float,399 sla_breach_count: int,400 churn_count: int,401 invalid: bool,402 bankrupt: bool,403 is_noop: bool,404 ) -> RewardBreakdown:405 sav = weekly_savings * SAVINGS_WEIGHT406 sla = sla_breach_count * SLA_PENALTY407 churn = churn_count * CHURN_PENALTY408 inv = INVALID_PENALTY if invalid else 0.0409 bank = BANKRUPTCY_PENALTY if bankrupt else 0.0410 idle = IDLE_PENALTY if is_noop else 0.0411 net = sav + sla + churn + inv + bank + idle412 return RewardBreakdown(413 savings_reward=round(sav, 4),414 sla_penalty=round(sla + bank + idle, 4),415 churn_penalty=round(churn, 4),416 invalid_action_penalty=round(inv, 4),417 net_reward=round(net, 4),418 )419 420 # ── state accessor ───────────────────────────────────────────────────────421 422 def state(self) -> Observation:423 return self._build_observation()424 