Loosebag commited on
Commit
30bdd62
·
1 Parent(s): f792510

fix: stabilize deployment, action safety, and benchmark reproducibility

Browse files
.gitignore CHANGED
@@ -3,6 +3,10 @@ lora_adapter/
3
  __pycache__/
4
  *.pyc
5
  venv/
 
 
 
 
6
  .env
7
  .pytest_cache/
8
  pytest-cache-files-*/
 
3
  __pycache__/
4
  *.pyc
5
  venv/
6
+ .venv/
7
+ .pydeps/
8
+ .uv-venv/
9
+ .uv-python/
10
  .env
11
  .pytest_cache/
12
  pytest-cache-files-*/
Dockerfile CHANGED
@@ -1,23 +1,27 @@
1
- # Stage 1: Runtime
2
  FROM python:3.10-slim
3
 
4
  WORKDIR /app
5
 
6
- # Install uv
7
- RUN pip install uv
8
 
9
- # Install dependencies using uv
 
 
 
 
 
 
10
  COPY pyproject.toml uv.lock ./
11
- RUN uv sync
12
 
13
- # Copy application code
14
  COPY . .
 
15
 
16
- # Expose server port (Hugging Face Spaces default)
17
  EXPOSE 7860
18
 
19
- # Health check
20
- HEALTHCHECK CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:7860/health', timeout=5).read()" || exit 1
21
 
22
- # Run the FastAPI server via uv
23
- CMD ["uv", "run", "--project", ".", "server", "--port", "7860", "--host", "0.0.0.0"]
 
 
1
  FROM python:3.10-slim
2
 
3
  WORKDIR /app
4
 
5
+ # Stable uv install for lock-based, reproducible sync
6
+ RUN pip install --no-cache-dir uv
7
 
8
+ ENV UV_COMPILE_BYTECODE=1 \
9
+ UV_LINK_MODE=copy \
10
+ UV_PROJECT_ENVIRONMENT=/opt/venv \
11
+ PYTHONDONTWRITEBYTECODE=1 \
12
+ PYTHONUNBUFFERED=1
13
+
14
+ # Install only locked runtime deps first for layer caching
15
  COPY pyproject.toml uv.lock ./
16
+ RUN uv sync --frozen --no-dev --no-install-project
17
 
18
+ # Copy source and install project itself
19
  COPY . .
20
+ RUN uv sync --frozen --no-dev
21
 
 
22
  EXPOSE 7860
23
 
24
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
25
+ CMD /opt/venv/bin/python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:7860/health', timeout=3).read()" || exit 1
26
 
27
+ CMD ["/opt/venv/bin/python", "-m", "server.app"]
 
README.md CHANGED
@@ -1,173 +1,126 @@
1
- ---
2
- title: EcoGrid OpenEnv
3
- emoji: 🌍
4
- colorFrom: green
5
- colorTo: blue
6
- sdk: docker
7
- app_port: 7860
8
- ---
9
- # ⚡ EcoGrid-OpenEnv
10
- ![Hackathon](https://img.shields.io/badge/Scaler_SoT_×_Meta_PyTorch-Finale-blue)
11
-
12
- **EcoGrid-OpenEnv** is a production-grade Reinforcement Learning environment for the OpenEnv framework. It simulates sustainable energy grid management where an AI agent must balance renewable sources, fossil fuels, and battery storage to meet demand while minimising cost and carbon emissions.
13
-
14
- Built for the **Theme #3: World Modeling** track (Mercor Sub-theme).
15
-
16
- ---
17
-
18
- ## 🌍 The Problem
19
-
20
- Modern power grids are facing unprecedented volatility. The transition to renewable energy introduces extreme supply variance (the sun doesn't always shine, the wind doesn't always blow), while electrification of transport causes unpredictable demand spikes.
21
-
22
- Grid operators must solve a continuous, multi-objective optimization problem:
23
- 1. **Prevent Blackouts:** Meet demand perfectly.
24
- 2. **Minimise Cost:** Avoid expensive fossil fuels and spot-market emergency purchases.
25
- 3. **Cut Emissions:** Stay within strict carbon budgets.
26
-
27
- This environment models that exact problem as a Reinforcement Learning Markov Decision Process (MDP).
28
-
29
- ---
30
-
31
- ## 🏗️ Environment Design
32
-
33
- ### State Space (Observation)
34
- The agent receives a rich, dense state vector at every step:
35
- ```text
36
- ┌───────────────────────┐
37
- │ GridState │
38
- │ ├─ demand (MWh) │ ─> Varies wildly (morning/evening peaks)
39
- │ ├─ solar_capacity │ ─> Predictable daytime curve + cloud noise
40
- │ ├─ wind_capacity │ ─> Mean-reverting random walk + noise
41
- │ ├─ battery_level │ ─> State of charge [0,1]
42
- │ ├─ grid_stability │ ─> Momentum-based frequency indicator
43
- │ ├─ carbon_budget │ ─> Remaining kgCO₂
44
- │ ├─ price_signal │ ─> Surges when supply < demand
45
- │ └─ time_step │
46
- └───────────────────────┘
47
- ```
48
-
49
- ### Action Space
50
- At each step, the agent outputs a continuous action vector:
51
- ```text
52
- ┌───────────────────────┐
53
- │ GridAction │
54
- │ ├─ renewable_ratio │ ─> [0, 1] Fraction of demand met by renewables
55
- │ ├─ fossil_ratio │ ─> [0, 1] Fraction of demand met by fossil
56
- │ └─ battery_action │ ─> [-1, 1] Discharge(-1) to Charge(+1)
57
- └───────────────────────┘
58
- ```
59
- *Constraint: `renewable_ratio + fossil_ratio <= 1.0`*
60
 
61
- ---
62
 
63
- ## 📈 Reward Function
 
 
 
 
 
64
 
65
- The reward is a dense scalar in `[0, 1]` calculated at every step. This provides immediate, continuous feedback to the agent, making it highly trainable via algorithms like GRPO or PPO.
66
 
67
- | Component | Weight | Description |
68
- |-----------|--------|-------------|
69
- | **Cost Savings** | 0.30 | `1 - normalised(fossil_cost + grid_cost)` |
70
- | **Carbon Score** | 0.30 | `1 - normalised(carbon_emission)` |
71
- | **Stability** | 0.25 | `1 - blackout_risk` |
72
- | **Green Bonus** | 0.15 | `renewable_ratio * stability_score` |
73
 
74
- **Penalties:**
75
- - `-0.5` applied if >20% of demand is unmet (blackout).
76
- - `-0.8` applied if carbon budget is exceeded (Hard task only).
77
 
78
- ---
79
 
80
- ## 🎯 Tasks
 
81
 
82
- | Task | Difficulty | Episode | Conditions | Goal | Grader |
83
- |------|------------|---------|------------|------|--------|
84
- | `easy` | 1 | 48 steps | Stable solar, flat demand, no battery | Minimise Cost | `BasicGridBalance` |
85
- | `medium` | 2 | 96 steps | Noisy renewables, demand spikes, small battery | Avoid Blackouts | `RenewableVariability` |
86
- | `hard` | 3 | 96 steps | Strict carbon cap, 2x noise, limited storage | Survive within Carbon Cap | `CarbonConstrained` |
87
 
88
- ---
89
 
90
- ## 🚀 Quickstart
91
-
92
- ### 1. Installation
93
  ```bash
94
- git clone https://github.com/dooti2325/EcoGrid.git
95
- cd EcoGrid
96
- python -m venv venv
97
- source venv/bin/activate
98
  pip install -r requirements.txt
99
  ```
100
 
101
- ### 2. Verify OpenEnv Compatibility
102
  ```bash
103
- openenv validate openenv.yaml
104
- # Output: ✅ Environment spec valid.
105
  ```
106
 
107
- ### 3. Run Baseline Agents
108
- Run the deterministic heuristic baseline:
109
- ```bash
110
- python baseline.py --task easy --agent heuristic
111
- python baseline.py --task hard --agent heuristic
112
- ```
113
 
114
- Run the LLM (OpenAI) agent:
115
  ```bash
116
- export OPENAI_API_KEY="sk-..."
117
- python baseline.py --task medium --agent llm
118
  ```
119
 
120
- ---
121
-
122
- ## 🧠 Training with Unsloth (GRPO)
 
123
 
124
- We provide a full training pipeline using Unsloth and Hugging Face `trl` to train a small LLM (`Qwen2.5-1.5B-Instruct`) to play the environment.
 
 
 
125
 
126
- The training script uses the environment itself as the reward function for **Group Relative Policy Optimization (GRPO)**.
127
 
 
128
  ```bash
129
- # Requires unsloth, trl, torch
130
- python train_unsloth.py --task hard --epochs 3 --samples 500
131
  ```
132
- This saves a LoRA adapter to `./lora_adapter/` and reward curves to `./logs/`.
133
-
134
- ---
135
-
136
- ## 📊 Training Evidence
137
-
138
- The repository includes reward and loss plots from the GRPO training run:
139
 
140
- ![Reward curve](docs/reward_curve.png)
141
-
142
- ![Loss curve](docs/loss_curve.png)
143
-
144
- ## 📊 Baseline Scores
145
-
146
- *Averaged over 5 random seeds.*
147
-
148
- | Task | Random Agent | Heuristic Agent | Trained LLM |
149
- |------|-------------|-----------------|------------------------|
150
- | `easy` | 0.21 | 0.72 | 0.81 |
151
- | `medium` | 0.16 | 0.58 | 0.75 |
152
- | `hard` | 0.00 (fail) | 0.41 | 0.62 |
153
-
154
- ---
155
 
156
- ## 🖥️ Live Dashboard (Hugging Face Space)
 
 
 
157
 
158
- We've deployed an interactive Streamlit dashboard allowing you to run episodes and visualize live grid state, reward curves, and carbon emissions.
159
 
160
- **[View the Live Demo on Hugging Face Spaces](https://huggingface.co/spaces/Loosebag/EcoGrid)**
 
 
161
 
162
- **[GitHub Repository](https://github.com/dooti2325/EcoGrid)**
 
 
 
 
 
163
 
164
- **Training notebook:** [`colab_training.ipynb`](colab_training.ipynb)
165
 
166
- **Mini-blog:** [`BLOG.md`](BLOG.md)
 
 
 
167
 
168
- ### Local Docker Build
169
  ```bash
170
- docker build -t ecogrid .
171
- docker run -p 7860:7860 ecogrid
172
- # Open http://localhost:7860
173
  ```
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # EcoGrid OpenEnv
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
+ Production-ready RL environment and API for sustainable grid control.
4
 
5
+ This repo now ships with:
6
+ - Stable OpenEnv API server (`/health`, `/reset`, `/step`, `/schema`)
7
+ - Deterministic environment behavior for fixed seeds
8
+ - Action safety guards that prevent invalid action sums after rounding
9
+ - Hardened deployment path for Hugging Face Spaces (Docker + `uv.lock`)
10
+ - Reproducible benchmark script with before/after comparison output
11
 
12
+ ## 1) What The Agent Controls
13
 
14
+ At each step the agent picks:
15
+ - `renewable_ratio` in `[0, 1]`
16
+ - `fossil_ratio` in `[0, 1]`
17
+ - `battery_action` in `[-1, 1]`
 
 
18
 
19
+ Safety constraint:
20
+ - `renewable_ratio + fossil_ratio <= 1.0` (enforced with normalization guards)
 
21
 
22
+ ## 2) Runtime Modes
23
 
24
+ - API mode (default deployment): `python -m server.app`
25
+ - Dashboard mode (optional local demo): `streamlit run app.py`
26
 
27
+ HF Space Docker deployment uses API mode.
 
 
 
 
28
 
29
+ ## 3) Install
30
 
31
+ ### Runtime only
 
 
32
  ```bash
 
 
 
 
33
  pip install -r requirements.txt
34
  ```
35
 
36
+ ### Runtime + training stack
37
  ```bash
38
+ pip install -r requirements-train.txt
 
39
  ```
40
 
41
+ ## 4) Reproducible Benchmarks
 
 
 
 
 
42
 
43
+ Run:
44
  ```bash
45
+ python scripts/benchmark.py --seeds 1,2,3,4,5 --out logs/benchmark_results.json
 
46
  ```
47
 
48
+ Current post-fix means (5 seeds):
49
+ - easy: random `0.2721`, heuristic `0.7595`
50
+ - medium: random `0.2545`, heuristic `0.7847`
51
+ - hard: random `0.0010`, heuristic `0.4000`
52
 
53
+ Reference pre-fix means used for delta tracking:
54
+ - easy: random `0.269`, heuristic `0.748`
55
+ - medium: random `0.251`, heuristic `0.376`
56
+ - hard: random `0.001`, heuristic `0.001`
57
 
58
+ ## 5) API Smoke Test
59
 
60
+ Start server:
61
  ```bash
62
+ python -m server.app
 
63
  ```
 
 
 
 
 
 
 
64
 
65
+ Then:
66
+ ```bash
67
+ python scripts/smoke_api.py --base-url http://127.0.0.1:7860
68
+ ```
 
 
 
 
 
 
 
 
 
 
 
69
 
70
+ This checks:
71
+ - `GET /health`
72
+ - `POST /reset`
73
+ - `POST /step` (direct action payload compatibility)
74
 
75
+ ## 6) Test Suite
76
 
77
+ ```bash
78
+ pytest -q
79
+ ```
80
 
81
+ Coverage includes:
82
+ - environment unit tests
83
+ - reward/grader tests
84
+ - action normalization regression tests
85
+ - API smoke/integration tests
86
+ - reproducibility checks (same seed => same trajectory)
87
 
88
+ ## 7) Hugging Face Space Deployment
89
 
90
+ ### Lock strategy
91
+ - Runtime dependencies live in `pyproject.toml` default deps.
92
+ - Heavy training deps are optional (`[project.optional-dependencies].train`).
93
+ - `uv.lock` is committed and used with `--frozen`.
94
 
95
+ Regenerate lock file:
96
  ```bash
97
+ uv lock
 
 
98
  ```
99
+
100
+ ### Docker (Space) build path
101
+ The committed `Dockerfile` does:
102
+ 1. `uv sync --frozen --no-dev --no-install-project`
103
+ 2. copy source
104
+ 3. `uv sync --frozen --no-dev`
105
+ 4. run `/opt/venv/bin/python -m server.app`
106
+
107
+ Healthcheck:
108
+ - container-level health probe hits `http://127.0.0.1:7860/health`
109
+
110
+ ## 8) Minimal Deployment Checklist
111
+
112
+ - `uv lock` succeeds locally
113
+ - `uv sync --frozen --no-dev` succeeds locally
114
+ - `pytest -q` passes
115
+ - `python scripts/smoke_api.py` passes against local server
116
+ - Docker build succeeds
117
+ - Space runtime reports healthy and serves `/health`, `/schema`, `/docs`
118
+
119
+ ## 9) Project Structure
120
+
121
+ - `env/` core environment dynamics, rewards, action safety helpers
122
+ - `server/` OpenEnv/FastAPI serving layer
123
+ - `baseline.py` heuristic + optional LLM baseline runner
124
+ - `train_unsloth.py` deterministic training pipeline and metric logging
125
+ - `scripts/benchmark.py` reproducible benchmark runner
126
+ - `scripts/smoke_api.py` deployment smoke test runner
baseline.py CHANGED
@@ -11,19 +11,32 @@ import os
11
  import time
12
  from typing import Literal
13
 
14
- # Try importing litellm for OpenEnv proxy validation
15
- try:
16
- import litellm
17
- HAS_LITELLM = True
18
- except ImportError:
19
- HAS_LITELLM = False
20
 
21
  from env.environment import EcoGridEnv
22
  from env.tasks import BasicGridBalanceGrader, RenewableVariabilityGrader, CarbonConstrainedGrader
 
23
  from models.schemas import GridAction, GridState
24
 
25
  _trained_model = None
26
  _trained_tokenizer = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
  def load_trained_model():
29
  """Lazily load the LoRA model if available."""
@@ -93,56 +106,95 @@ def local_llm_agent(state: GridState, task_name: str) -> GridAction:
93
  content = content[3:-3]
94
 
95
  data = json.loads(content)
96
- return GridAction(**data)
 
 
 
 
97
  except Exception as e:
98
  print(f"Local LLM Error: {e}. Falling back to heuristic.")
99
  return heuristic_agent(state, task_name)
100
 
101
 
102
 
103
- def heuristic_agent(state: GridState, task_name: str) -> GridAction:
104
- """A hardcoded baseline agent that performs reasonably well."""
105
- # Always max out renewables available
106
  avg_renewable_cap = (state.solar_capacity + state.wind_capacity) / 2.0
107
-
108
- # Try to meet demand with renewables first
 
 
 
109
  if state.demand > 0:
110
- renewable_ratio = min(1.0, avg_renewable_cap / max(0.01, state.demand/100))
111
- renewable_ratio = min(renewable_ratio, 1.0)
 
112
  else:
113
- renewable_ratio = 1.0
114
-
115
- # Fill remaining with fossil if necessary, but keep a small buffer
116
- fossil_ratio = max(0.0, 1.0 - renewable_ratio)
117
-
118
- # In hard mode, conserve carbon budget if it's getting low
119
- if task_name == "hard" and state.carbon_budget_remaining < 200:
120
- fossil_ratio = min(fossil_ratio, 0.4) # Take the blackout risk to save carbon
121
-
122
- # Total can't exceed 1.0
123
- total = renewable_ratio + fossil_ratio
124
- if total > 1.0:
125
- if renewable_ratio > fossil_ratio:
126
- fossil_ratio = 1.0 - renewable_ratio
127
- else:
128
- renewable_ratio = 1.0 - fossil_ratio
129
-
130
- # Simple battery logic
131
- battery_action = 0.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  if state.demand > 100 and state.battery_level > 0.2:
133
- battery_action = -0.8 # Discharge during high demand
134
- elif state.demand < 60 and state.battery_level < 0.8:
135
- battery_action = 0.8 # Charge during low demand
136
-
137
- return GridAction(
138
- renewable_ratio=round(renewable_ratio, 3),
139
- fossil_ratio=round(fossil_ratio, 3),
140
- battery_action=round(battery_action, 3)
 
 
 
 
 
 
141
  )
142
 
143
 
144
  def llm_agent(state: GridState, task_name: str) -> GridAction:
145
  """An agent that uses an LLM to make decisions via Chain-of-Thought."""
 
 
 
146
 
147
  prompt = f"""
148
  You are an expert energy grid operator managing a power grid.
@@ -182,7 +234,11 @@ Then, output ONLY a valid JSON object matching this schema, with no markdown fen
182
  content = content[3:-3]
183
 
184
  data = json.loads(content)
185
- return GridAction(**data)
 
 
 
 
186
 
187
  except Exception as e:
188
  print(f"LLM Error: {e}. Falling back to heuristic.")
@@ -202,7 +258,7 @@ def main():
202
  parser.add_argument("--agent", type=str, choices=["heuristic", "llm"], default="heuristic")
203
  args = parser.parse_args()
204
 
205
- if args.agent == "llm" and not HAS_LITELLM:
206
  console.print("[bold red]Error:[/bold red] litellm package not installed. Run: pip install litellm")
207
  return
208
 
 
11
  import time
12
  from typing import Literal
13
 
14
+ HAS_LITELLM = None
 
 
 
 
 
15
 
16
  from env.environment import EcoGridEnv
17
  from env.tasks import BasicGridBalanceGrader, RenewableVariabilityGrader, CarbonConstrainedGrader
18
+ from env.action_utils import safe_grid_action
19
  from models.schemas import GridAction, GridState
20
 
21
  _trained_model = None
22
  _trained_tokenizer = None
23
+ TASK_EPISODE_LENGTH = {"easy": 48, "medium": 96, "hard": 96}
24
+ FOSSIL_EMISSION_FACTOR = 0.5
25
+
26
+
27
+ def _get_litellm():
28
+ """Lazily import litellm to avoid startup-time network side effects."""
29
+ global HAS_LITELLM
30
+ if HAS_LITELLM is False:
31
+ return None
32
+ try:
33
+ import litellm
34
+
35
+ HAS_LITELLM = True
36
+ return litellm
37
+ except ImportError:
38
+ HAS_LITELLM = False
39
+ return None
40
 
41
  def load_trained_model():
42
  """Lazily load the LoRA model if available."""
 
106
  content = content[3:-3]
107
 
108
  data = json.loads(content)
109
+ return safe_grid_action(
110
+ renewable_ratio=data.get("renewable_ratio", 0.5),
111
+ fossil_ratio=data.get("fossil_ratio", 0.5),
112
+ battery_action=data.get("battery_action", 0.0),
113
+ )
114
  except Exception as e:
115
  print(f"Local LLM Error: {e}. Falling back to heuristic.")
116
  return heuristic_agent(state, task_name)
117
 
118
 
119
 
120
+ def _constraint_aware_hard_controller(state: GridState) -> GridAction:
121
+ """Hard-mode controller that enforces carbon budget pacing."""
122
+ remaining_steps = max(1, TASK_EPISODE_LENGTH["hard"] - state.time_step)
123
  avg_renewable_cap = (state.solar_capacity + state.wind_capacity) / 2.0
124
+ avg_renewable_cap = min(1.0, max(0.0, avg_renewable_cap))
125
+
126
+ # Budget-aware fossil cap:
127
+ # carbon_per_step = fossil_ratio * demand * emission_factor
128
+ # => fossil_ratio <= carbon_budget_remaining / (remaining_steps * demand * emission_factor)
129
  if state.demand > 0:
130
+ budget_fossil_cap = state.carbon_budget_remaining / (
131
+ remaining_steps * state.demand * FOSSIL_EMISSION_FACTOR
132
+ )
133
  else:
134
+ budget_fossil_cap = 0.0
135
+
136
+ # Keep a safety margin to avoid late-episode budget collapse.
137
+ budget_fossil_cap = max(0.0, min(0.14, budget_fossil_cap * 0.92))
138
+ future_floor = remaining_steps * max(state.demand, 1.0) * FOSSIL_EMISSION_FACTOR * 0.08
139
+ if state.grid_stability < 0.75 and state.carbon_budget_remaining > future_floor:
140
+ budget_fossil_cap = min(0.18, budget_fossil_cap + 0.03)
141
+
142
+ renewable_ratio = min(0.9, max(0.62, avg_renewable_cap + 0.12))
143
+ fossil_ratio = min(max(0.02, 1.0 - renewable_ratio), budget_fossil_cap)
144
+
145
+ # Battery dispatch policy:
146
+ # - discharge on high demand or low stability
147
+ # - charge when demand is light and stability is healthy
148
+ if (state.demand > 100 or state.grid_stability < 0.8) and state.battery_level > 0.12:
149
+ battery_action = -0.9
150
+ elif state.demand < 78 and state.battery_level < 0.7 and avg_renewable_cap > 0.4:
151
+ battery_action = 0.6
152
+ else:
153
+ battery_action = 0.0
154
+
155
+ return safe_grid_action(
156
+ renewable_ratio=renewable_ratio,
157
+ fossil_ratio=fossil_ratio,
158
+ battery_action=battery_action,
159
+ )
160
+
161
+
162
+ def heuristic_agent(state: GridState, task_name: str) -> GridAction:
163
+ """Constraint-aware baseline agent with strict action validity guarantees."""
164
+ if task_name == "hard":
165
+ return _constraint_aware_hard_controller(state)
166
+
167
+ avg_renewable_cap = (state.solar_capacity + state.wind_capacity) / 2.0
168
+ avg_renewable_cap = min(1.0, max(0.0, avg_renewable_cap))
169
+ renewable_ratio = min(0.95, max(0.05, avg_renewable_cap))
170
+ fossil_ratio = max(0.0, 1.0 - avg_renewable_cap)
171
+
172
+ if task_name == "medium" and (state.grid_stability < 0.8 or state.demand > 105):
173
+ fossil_ratio = min(1.0, fossil_ratio + 0.05)
174
+
175
  if state.demand > 100 and state.battery_level > 0.2:
176
+ battery_action = -0.9
177
+ if task_name == "medium":
178
+ fossil_ratio = max(0.0, fossil_ratio - 0.05)
179
+ elif state.demand < 70 and state.battery_level < 0.8 and avg_renewable_cap > 0.5:
180
+ battery_action = 0.7
181
+ if task_name == "medium":
182
+ fossil_ratio = min(1.0, fossil_ratio + 0.03)
183
+ else:
184
+ battery_action = 0.0
185
+
186
+ return safe_grid_action(
187
+ renewable_ratio=renewable_ratio,
188
+ fossil_ratio=fossil_ratio,
189
+ battery_action=battery_action,
190
  )
191
 
192
 
193
  def llm_agent(state: GridState, task_name: str) -> GridAction:
194
  """An agent that uses an LLM to make decisions via Chain-of-Thought."""
195
+ litellm = _get_litellm()
196
+ if litellm is None:
197
+ return heuristic_agent(state, task_name)
198
 
199
  prompt = f"""
200
  You are an expert energy grid operator managing a power grid.
 
234
  content = content[3:-3]
235
 
236
  data = json.loads(content)
237
+ return safe_grid_action(
238
+ renewable_ratio=data.get("renewable_ratio", 0.5),
239
+ fossil_ratio=data.get("fossil_ratio", 0.5),
240
+ battery_action=data.get("battery_action", 0.0),
241
+ )
242
 
243
  except Exception as e:
244
  print(f"LLM Error: {e}. Falling back to heuristic.")
 
258
  parser.add_argument("--agent", type=str, choices=["heuristic", "llm"], default="heuristic")
259
  args = parser.parse_args()
260
 
261
+ if args.agent == "llm" and _get_litellm() is None:
262
  console.print("[bold red]Error:[/bold red] litellm package not installed. Run: pip install litellm")
263
  return
264
 
env/__init__.py CHANGED
@@ -1,5 +1,6 @@
1
  """EcoGrid-OpenEnv environment package."""
2
 
3
  from env.environment import EcoGridEnv
 
4
 
5
- __all__ = ["EcoGridEnv"]
 
1
  """EcoGrid-OpenEnv environment package."""
2
 
3
  from env.environment import EcoGridEnv
4
+ from env.action_utils import safe_grid_action, coerce_grid_action
5
 
6
+ __all__ = ["EcoGridEnv", "safe_grid_action", "coerce_grid_action"]
env/action_utils.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Action utility helpers for safety and normalization.
3
+
4
+ These helpers keep action generation robust across:
5
+ - model output parsing
6
+ - heuristic controllers
7
+ - API payload conversion
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Any, Optional, Tuple
13
+
14
+ from models.schemas import GridAction
15
+
16
+
17
+ def normalize_action_components(
18
+ renewable_ratio: float,
19
+ fossil_ratio: float,
20
+ battery_action: float,
21
+ ndigits: int = 3,
22
+ ) -> tuple[float, float, float]:
23
+ """Clamp + normalize action components and keep sum <= 1 after rounding."""
24
+ ren = max(0.0, min(1.0, float(renewable_ratio)))
25
+ fos = max(0.0, min(1.0, float(fossil_ratio)))
26
+ bat = max(-1.0, min(1.0, float(battery_action)))
27
+
28
+ total = ren + fos
29
+ if total > 1.0 and total > 0:
30
+ ren /= total
31
+ fos /= total
32
+
33
+ # Round for consistent logging/UI while preserving constraints.
34
+ ren = round(ren, ndigits)
35
+ fos = round(fos, ndigits)
36
+ bat = round(bat, ndigits)
37
+
38
+ total_rounded = ren + fos
39
+ if total_rounded > 1.0:
40
+ overflow = round(total_rounded - 1.0, ndigits + 2)
41
+ # Remove overflow from fossil first, then renewable.
42
+ reduce_fos = min(fos, overflow)
43
+ fos = round(fos - reduce_fos, ndigits)
44
+ overflow = round(overflow - reduce_fos, ndigits + 2)
45
+ if overflow > 0:
46
+ ren = round(max(0.0, ren - overflow), ndigits)
47
+
48
+ return ren, fos, bat
49
+
50
+
51
+ def safe_grid_action(
52
+ renewable_ratio: float,
53
+ fossil_ratio: float,
54
+ battery_action: float,
55
+ ndigits: int = 3,
56
+ ) -> GridAction:
57
+ """Build a validated GridAction after normalization."""
58
+ ren, fos, bat = normalize_action_components(
59
+ renewable_ratio=renewable_ratio,
60
+ fossil_ratio=fossil_ratio,
61
+ battery_action=battery_action,
62
+ ndigits=ndigits,
63
+ )
64
+ return GridAction(
65
+ renewable_ratio=ren,
66
+ fossil_ratio=fos,
67
+ battery_action=bat,
68
+ )
69
+
70
+
71
+ def coerce_grid_action(
72
+ action_like: Any,
73
+ default_action: Optional[GridAction] = None,
74
+ ) -> Tuple[GridAction, Optional[str]]:
75
+ """Convert action payloads to a valid GridAction with graceful fallback."""
76
+ try:
77
+ if isinstance(action_like, GridAction):
78
+ return action_like, None
79
+
80
+ if isinstance(action_like, dict):
81
+ payload = action_like.get("action", action_like)
82
+ return safe_grid_action(
83
+ renewable_ratio=payload.get("renewable_ratio", 0.5),
84
+ fossil_ratio=payload.get("fossil_ratio", 0.5),
85
+ battery_action=payload.get("battery_action", 0.0),
86
+ ), None
87
+ except Exception as exc:
88
+ if default_action is not None:
89
+ return default_action, f"invalid_action_payload: {type(exc).__name__}"
90
+ raise
91
+
92
+ if default_action is not None:
93
+ return default_action, "invalid_action_type"
94
+
95
+ raise ValueError("Unable to coerce action payload into GridAction.")
env/dynamics.py CHANGED
@@ -92,12 +92,20 @@ def demand_curve(
92
  # Evening peak (around hour 18)
93
  evening = 40.0 * np.exp(-((hour - 18) ** 2) / 8.0)
94
 
 
 
 
 
 
 
 
 
95
  # Random spike (occurs with probability proportional to volatility)
96
  spike = 0.0
97
  if volatility > 0 and rng.random() < 0.05 * volatility:
98
  spike = rng.uniform(10, 40) * volatility
99
 
100
- demand = base_demand + morning + evening + spike
101
  return float(np.clip(demand, 0.0, 200.0))
102
 
103
 
@@ -106,6 +114,8 @@ def update_battery(
106
  action: float,
107
  capacity: float,
108
  charge_rate: float = 0.15,
 
 
109
  ) -> float:
110
  """Update battery state of charge.
111
 
@@ -120,7 +130,12 @@ def update_battery(
120
  """
121
  if capacity <= 0:
122
  return 0.0
123
- delta = action * charge_rate * capacity
 
 
 
 
 
124
  return float(np.clip(level + delta, 0.0, 1.0))
125
 
126
 
@@ -168,7 +183,10 @@ def compute_supply(
168
  battery_level: float,
169
  battery_capacity: float,
170
  demand: float,
171
- ) -> tuple[float, float, float, float]:
 
 
 
172
  """Compute total energy supply from all sources.
173
 
174
  Args:
@@ -188,14 +206,28 @@ def compute_supply(
188
  avg_renewable_cap = (solar_cap + wind_cap) / 2.0
189
  renewable_supply = action_renewable * demand * min(1.0, avg_renewable_cap / max(action_renewable, 0.01))
190
 
191
- # Fossil supply (always available, just costs more)
192
- fossil_supply = action_fossil * demand
 
 
 
 
 
 
 
193
 
194
  # Battery can supplement supply when discharging
195
  battery_supply = 0.0
196
  if battery_action < 0 and battery_capacity > 0:
197
  # Discharging: supply is proportional to discharge rate and level
198
- battery_supply = abs(battery_action) * battery_level * battery_capacity * demand * 0.2
 
 
 
 
 
 
 
199
 
200
  total = renewable_supply + fossil_supply + battery_supply
201
  return (
@@ -203,6 +235,7 @@ def compute_supply(
203
  float(fossil_supply),
204
  float(battery_supply),
205
  float(total),
 
206
  )
207
 
208
 
 
92
  # Evening peak (around hour 18)
93
  evening = 40.0 * np.exp(-((hour - 18) ** 2) / 8.0)
94
 
95
+ # Weekly operational cycle (business-day effect)
96
+ day_of_week = (time_step // 24) % 7
97
+ weekday_multiplier = 1.0 if day_of_week < 5 else 0.92
98
+
99
+ # Correlated weather/event noise on demand.
100
+ # Deterministic under the seeded RNG.
101
+ stochastic_component = rng.normal(0, 4.0 * max(0.2, volatility))
102
+
103
  # Random spike (occurs with probability proportional to volatility)
104
  spike = 0.0
105
  if volatility > 0 and rng.random() < 0.05 * volatility:
106
  spike = rng.uniform(10, 40) * volatility
107
 
108
+ demand = (base_demand + morning + evening + stochastic_component + spike) * weekday_multiplier
109
  return float(np.clip(demand, 0.0, 200.0))
110
 
111
 
 
114
  action: float,
115
  capacity: float,
116
  charge_rate: float = 0.15,
117
+ charge_efficiency: float = 0.94,
118
+ discharge_efficiency: float = 0.94,
119
  ) -> float:
120
  """Update battery state of charge.
121
 
 
130
  """
131
  if capacity <= 0:
132
  return 0.0
133
+ if action >= 0:
134
+ delta = action * charge_rate * capacity * charge_efficiency
135
+ else:
136
+ # Discharging removes more SoC than delivered energy because of losses.
137
+ eff = max(discharge_efficiency, 1e-6)
138
+ delta = action * charge_rate * capacity / eff
139
  return float(np.clip(level + delta, 0.0, 1.0))
140
 
141
 
 
183
  battery_level: float,
184
  battery_capacity: float,
185
  demand: float,
186
+ previous_fossil_ratio: float | None = None,
187
+ fossil_ramp_limit: float | None = None,
188
+ discharge_efficiency: float = 0.94,
189
+ ) -> tuple[float, float, float, float, float]:
190
  """Compute total energy supply from all sources.
191
 
192
  Args:
 
206
  avg_renewable_cap = (solar_cap + wind_cap) / 2.0
207
  renewable_supply = action_renewable * demand * min(1.0, avg_renewable_cap / max(action_renewable, 0.01))
208
 
209
+ # Fossil ramp-rate constraints emulate thermal plant limitations.
210
+ effective_fossil_ratio = action_fossil
211
+ if previous_fossil_ratio is not None and fossil_ramp_limit is not None:
212
+ lower = max(0.0, previous_fossil_ratio - fossil_ramp_limit)
213
+ upper = min(1.0, previous_fossil_ratio + fossil_ramp_limit)
214
+ effective_fossil_ratio = float(np.clip(action_fossil, lower, upper))
215
+
216
+ # Fossil supply (dispatchable but ramp-limited when configured)
217
+ fossil_supply = effective_fossil_ratio * demand
218
 
219
  # Battery can supplement supply when discharging
220
  battery_supply = 0.0
221
  if battery_action < 0 and battery_capacity > 0:
222
  # Discharging: supply is proportional to discharge rate and level
223
+ battery_supply = (
224
+ abs(battery_action)
225
+ * battery_level
226
+ * battery_capacity
227
+ * demand
228
+ * 0.2
229
+ * discharge_efficiency
230
+ )
231
 
232
  total = renewable_supply + fossil_supply + battery_supply
233
  return (
 
235
  float(fossil_supply),
236
  float(battery_supply),
237
  float(total),
238
+ float(effective_fossil_ratio),
239
  )
240
 
241
 
env/environment.py CHANGED
@@ -11,6 +11,7 @@ import numpy as np
11
  from typing import Literal, Optional
12
 
13
  from models.schemas import GridState, GridAction, StepResult, TaskConfig
 
14
  from env.dynamics import (
15
  solar_output,
16
  wind_output,
@@ -38,6 +39,9 @@ TASK_CONFIGS = {
38
  demand_volatility=0.2,
39
  carbon_strict=False,
40
  volatility_multiplier=1.0,
 
 
 
41
  description="Stable solar, flat demand, no battery. Goal: minimise cost.",
42
  ),
43
  "medium": TaskConfig(
@@ -50,6 +54,9 @@ TASK_CONFIGS = {
50
  demand_volatility=1.0,
51
  carbon_strict=False,
52
  volatility_multiplier=1.0,
 
 
 
53
  description="Noisy solar+wind, demand spikes, small battery. Goal: avoid blackouts.",
54
  ),
55
  "hard": TaskConfig(
@@ -62,6 +69,9 @@ TASK_CONFIGS = {
62
  demand_volatility=1.5,
63
  carbon_strict=True, # Episode ends on overrun
64
  volatility_multiplier=2.0, # 2× noise on renewables
 
 
 
65
  description="Strict carbon cap, high volatility, limited storage. Episode ends on overrun.",
66
  ),
67
  }
@@ -95,6 +105,7 @@ class EcoGridEnv:
95
  self._episode_log: list[StepResult] = []
96
  self._previous_wind: float = 0.4
97
  self._previous_stability: float = 0.9
 
98
 
99
  def reset(
100
  self,
@@ -117,6 +128,7 @@ class EcoGridEnv:
117
  self._episode_log = []
118
  self._previous_wind = 0.4
119
  self._previous_stability = 0.9
 
120
 
121
  # Generate initial state
122
  config = self._task_config
@@ -143,7 +155,7 @@ class EcoGridEnv:
143
  )
144
  return self._state
145
 
146
- def step(self, action: GridAction) -> StepResult:
147
  """Execute one timestep of the environment.
148
 
149
  Args:
@@ -164,6 +176,15 @@ class EcoGridEnv:
164
  assert config is not None
165
  assert self._rng is not None
166
 
 
 
 
 
 
 
 
 
 
167
  self._step_count += 1
168
  prev_state = self._state
169
  effective_noise = config.noise_level * config.volatility_multiplier
@@ -180,7 +201,13 @@ class EcoGridEnv:
180
  )
181
 
182
  # ── Compute supply from agent's action ──
183
- renewable_supply, fossil_supply, battery_supply, total_supply = compute_supply(
 
 
 
 
 
 
184
  action.renewable_ratio,
185
  action.fossil_ratio,
186
  action.battery_action,
@@ -189,20 +216,26 @@ class EcoGridEnv:
189
  prev_state.battery_level,
190
  config.battery_capacity,
191
  prev_state.demand,
 
 
 
192
  )
 
193
 
194
  # ── Update battery ──
195
  new_battery = update_battery(
196
  prev_state.battery_level,
197
  action.battery_action,
198
  config.battery_capacity,
 
 
199
  )
200
 
201
  # ── Compute blackout risk ──
202
  blackout = compute_blackout_risk(prev_state.demand, total_supply)
203
 
204
  # ── Compute carbon emissions ──
205
- emissions = carbon_emission(action.fossil_ratio, prev_state.demand)
206
  new_carbon = prev_state.carbon_budget_remaining - emissions
207
 
208
  # ── Compute grid stability ──
@@ -233,7 +266,19 @@ class EcoGridEnv:
233
 
234
  # ── Compute reward ──
235
  reward, breakdown = compute_reward(
236
- prev_state, action, next_state, config.model_dump()
 
 
 
 
 
 
 
 
 
 
 
 
237
  )
238
 
239
  # ── Check termination conditions ──
@@ -258,7 +303,10 @@ class EcoGridEnv:
258
  "blackout_risk": round(blackout, 4),
259
  "carbon_emitted_step": round(emissions, 2),
260
  "termination_reason": termination_reason,
 
261
  }
 
 
262
 
263
  result = StepResult(
264
  observation=next_state,
 
11
  from typing import Literal, Optional
12
 
13
  from models.schemas import GridState, GridAction, StepResult, TaskConfig
14
+ from env.action_utils import coerce_grid_action
15
  from env.dynamics import (
16
  solar_output,
17
  wind_output,
 
39
  demand_volatility=0.2,
40
  carbon_strict=False,
41
  volatility_multiplier=1.0,
42
+ fossil_ramp_limit=1.0,
43
+ battery_charge_efficiency=0.95,
44
+ battery_discharge_efficiency=0.95,
45
  description="Stable solar, flat demand, no battery. Goal: minimise cost.",
46
  ),
47
  "medium": TaskConfig(
 
54
  demand_volatility=1.0,
55
  carbon_strict=False,
56
  volatility_multiplier=1.0,
57
+ fossil_ramp_limit=0.35,
58
+ battery_charge_efficiency=0.94,
59
+ battery_discharge_efficiency=0.94,
60
  description="Noisy solar+wind, demand spikes, small battery. Goal: avoid blackouts.",
61
  ),
62
  "hard": TaskConfig(
 
69
  demand_volatility=1.5,
70
  carbon_strict=True, # Episode ends on overrun
71
  volatility_multiplier=2.0, # 2× noise on renewables
72
+ fossil_ramp_limit=0.22,
73
+ battery_charge_efficiency=0.93,
74
+ battery_discharge_efficiency=0.93,
75
  description="Strict carbon cap, high volatility, limited storage. Episode ends on overrun.",
76
  ),
77
  }
 
105
  self._episode_log: list[StepResult] = []
106
  self._previous_wind: float = 0.4
107
  self._previous_stability: float = 0.9
108
+ self._previous_fossil_ratio: float = 0.0
109
 
110
  def reset(
111
  self,
 
128
  self._episode_log = []
129
  self._previous_wind = 0.4
130
  self._previous_stability = 0.9
131
+ self._previous_fossil_ratio = 0.0
132
 
133
  # Generate initial state
134
  config = self._task_config
 
155
  )
156
  return self._state
157
 
158
+ def step(self, action: GridAction | dict) -> StepResult:
159
  """Execute one timestep of the environment.
160
 
161
  Args:
 
176
  assert config is not None
177
  assert self._rng is not None
178
 
179
+ action, action_warning = coerce_grid_action(
180
+ action_like=action,
181
+ default_action=GridAction(
182
+ renewable_ratio=0.5,
183
+ fossil_ratio=0.5,
184
+ battery_action=0.0,
185
+ ),
186
+ )
187
+
188
  self._step_count += 1
189
  prev_state = self._state
190
  effective_noise = config.noise_level * config.volatility_multiplier
 
201
  )
202
 
203
  # ── Compute supply from agent's action ──
204
+ (
205
+ renewable_supply,
206
+ fossil_supply,
207
+ battery_supply,
208
+ total_supply,
209
+ effective_fossil_ratio,
210
+ ) = compute_supply(
211
  action.renewable_ratio,
212
  action.fossil_ratio,
213
  action.battery_action,
 
216
  prev_state.battery_level,
217
  config.battery_capacity,
218
  prev_state.demand,
219
+ previous_fossil_ratio=self._previous_fossil_ratio,
220
+ fossil_ramp_limit=config.fossil_ramp_limit,
221
+ discharge_efficiency=config.battery_discharge_efficiency,
222
  )
223
+ self._previous_fossil_ratio = effective_fossil_ratio
224
 
225
  # ── Update battery ──
226
  new_battery = update_battery(
227
  prev_state.battery_level,
228
  action.battery_action,
229
  config.battery_capacity,
230
+ charge_efficiency=config.battery_charge_efficiency,
231
+ discharge_efficiency=config.battery_discharge_efficiency,
232
  )
233
 
234
  # ── Compute blackout risk ──
235
  blackout = compute_blackout_risk(prev_state.demand, total_supply)
236
 
237
  # ── Compute carbon emissions ──
238
+ emissions = carbon_emission(effective_fossil_ratio, prev_state.demand)
239
  new_carbon = prev_state.carbon_budget_remaining - emissions
240
 
241
  # ── Compute grid stability ──
 
266
 
267
  # ── Compute reward ──
268
  reward, breakdown = compute_reward(
269
+ prev_state,
270
+ action,
271
+ next_state,
272
+ config.model_dump(),
273
+ actual_supply=(
274
+ renewable_supply,
275
+ fossil_supply,
276
+ battery_supply,
277
+ total_supply,
278
+ effective_fossil_ratio,
279
+ ),
280
+ actual_blackout_risk=blackout,
281
+ actual_emissions=emissions,
282
  )
283
 
284
  # ── Check termination conditions ──
 
303
  "blackout_risk": round(blackout, 4),
304
  "carbon_emitted_step": round(emissions, 2),
305
  "termination_reason": termination_reason,
306
+ "effective_fossil_ratio": round(effective_fossil_ratio, 4),
307
  }
308
+ if action_warning:
309
+ info["action_warning"] = action_warning
310
 
311
  result = StepResult(
312
  observation=next_state,
env/reward.py CHANGED
@@ -20,6 +20,9 @@ def compute_reward(
20
  action: GridAction,
21
  next_state: GridState,
22
  task_config: Dict[str, Any],
 
 
 
23
  ) -> Tuple[float, Dict[str, float]]:
24
  """Compute dense reward for a single timestep.
25
 
@@ -35,16 +38,31 @@ def compute_reward(
35
  # ── 1. Base Components ──
36
 
37
  # Recompute supply to get exact fossil/renewable usage for this step
38
- renewable_supply, fossil_supply, battery_supply, total_supply = compute_supply(
39
- action.renewable_ratio,
40
- action.fossil_ratio,
41
- action.battery_action,
42
- state.solar_capacity,
43
- state.wind_capacity,
44
- state.battery_level,
45
- task_config["battery_capacity"],
46
- state.demand,
47
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
 
49
  # Cost Score (Weight: 0.30)
50
  # Fossil fuels are expensive. Grid purchases (for unmet demand) are very expensive.
@@ -62,7 +80,11 @@ def compute_reward(
62
 
63
  # Carbon Score (Weight: 0.30)
64
  # Penalise carbon emissions relative to total demand.
65
- emissions = carbon_emission(action.fossil_ratio, state.demand)
 
 
 
 
66
  if state.demand > 0:
67
  # Normalise by worst case (100% fossil generation)
68
  worst_case_emissions = carbon_emission(1.0, state.demand)
@@ -73,7 +95,11 @@ def compute_reward(
73
 
74
  # Stability Score (Weight: 0.25)
75
  # Directly inversely proportional to blackout risk
76
- blackout_risk = compute_blackout_risk(state.demand, total_supply)
 
 
 
 
77
  stability_score = 1.0 - blackout_risk
78
 
79
  # Renewable Bonus (Weight: 0.15)
 
20
  action: GridAction,
21
  next_state: GridState,
22
  task_config: Dict[str, Any],
23
+ actual_supply: Tuple[float, float, float, float, float] | None = None,
24
+ actual_blackout_risk: float | None = None,
25
+ actual_emissions: float | None = None,
26
  ) -> Tuple[float, Dict[str, float]]:
27
  """Compute dense reward for a single timestep.
28
 
 
38
  # ── 1. Base Components ──
39
 
40
  # Recompute supply to get exact fossil/renewable usage for this step
41
+ if actual_supply is None:
42
+ (
43
+ renewable_supply,
44
+ fossil_supply,
45
+ battery_supply,
46
+ total_supply,
47
+ effective_fossil_ratio,
48
+ ) = compute_supply(
49
+ action.renewable_ratio,
50
+ action.fossil_ratio,
51
+ action.battery_action,
52
+ state.solar_capacity,
53
+ state.wind_capacity,
54
+ state.battery_level,
55
+ task_config["battery_capacity"],
56
+ state.demand,
57
+ )
58
+ else:
59
+ (
60
+ renewable_supply,
61
+ fossil_supply,
62
+ battery_supply,
63
+ total_supply,
64
+ effective_fossil_ratio,
65
+ ) = actual_supply
66
 
67
  # Cost Score (Weight: 0.30)
68
  # Fossil fuels are expensive. Grid purchases (for unmet demand) are very expensive.
 
80
 
81
  # Carbon Score (Weight: 0.30)
82
  # Penalise carbon emissions relative to total demand.
83
+ emissions = (
84
+ actual_emissions
85
+ if actual_emissions is not None
86
+ else carbon_emission(effective_fossil_ratio, state.demand)
87
+ )
88
  if state.demand > 0:
89
  # Normalise by worst case (100% fossil generation)
90
  worst_case_emissions = carbon_emission(1.0, state.demand)
 
95
 
96
  # Stability Score (Weight: 0.25)
97
  # Directly inversely proportional to blackout risk
98
+ blackout_risk = (
99
+ actual_blackout_risk
100
+ if actual_blackout_risk is not None
101
+ else compute_blackout_risk(state.demand, total_supply)
102
+ )
103
  stability_score = 1.0 - blackout_risk
104
 
105
  # Renewable Bonus (Weight: 0.15)
inference.py CHANGED
@@ -19,6 +19,7 @@ from typing import List, Optional
19
  from openai import OpenAI
20
 
21
  from env.environment import EcoGridEnv
 
22
  from env.tasks import BasicGridBalanceGrader, RenewableVariabilityGrader, CarbonConstrainedGrader
23
  from models.schemas import GridAction, GridState
24
 
@@ -100,10 +101,10 @@ def _fallback_action(task_name: str, state: GridState) -> GridAction:
100
  elif state.demand < 60 and state.battery_level < 0.8:
101
  battery_action = 0.8
102
 
103
- return GridAction(
104
- renewable_ratio=round(renewable_ratio, 3),
105
- fossil_ratio=round(fossil_ratio, 3),
106
- battery_action=round(battery_action, 3)
107
  )
108
 
109
 
@@ -156,7 +157,11 @@ Then, output ONLY a valid JSON object matching this schema, with no markdown fen
156
  content = content[3:-3]
157
 
158
  data = json.loads(content)
159
- return GridAction(**data)
 
 
 
 
160
 
161
 
162
  # ---------------------------------------------------------------------------
 
19
  from openai import OpenAI
20
 
21
  from env.environment import EcoGridEnv
22
+ from env.action_utils import safe_grid_action
23
  from env.tasks import BasicGridBalanceGrader, RenewableVariabilityGrader, CarbonConstrainedGrader
24
  from models.schemas import GridAction, GridState
25
 
 
101
  elif state.demand < 60 and state.battery_level < 0.8:
102
  battery_action = 0.8
103
 
104
+ return safe_grid_action(
105
+ renewable_ratio=renewable_ratio,
106
+ fossil_ratio=fossil_ratio,
107
+ battery_action=battery_action,
108
  )
109
 
110
 
 
157
  content = content[3:-3]
158
 
159
  data = json.loads(content)
160
+ return safe_grid_action(
161
+ renewable_ratio=data.get("renewable_ratio", preferred.renewable_ratio),
162
+ fossil_ratio=data.get("fossil_ratio", preferred.fossil_ratio),
163
+ battery_action=data.get("battery_action", preferred.battery_action),
164
+ )
165
 
166
 
167
  # ---------------------------------------------------------------------------
models/schemas.py CHANGED
@@ -177,4 +177,22 @@ class TaskConfig(BaseModel):
177
  ge=0,
178
  description="Multiplier for renewable noise (2x for hard task)",
179
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
  description: str = Field(default="", description="Human-readable task description")
 
177
  ge=0,
178
  description="Multiplier for renewable noise (2x for hard task)",
179
  )
180
+ fossil_ramp_limit: float = Field(
181
+ default=0.35,
182
+ ge=0,
183
+ le=1,
184
+ description="Max allowed step-to-step change in fossil ratio",
185
+ )
186
+ battery_charge_efficiency: float = Field(
187
+ default=0.94,
188
+ ge=0.5,
189
+ le=1.0,
190
+ description="Battery charge efficiency",
191
+ )
192
+ battery_discharge_efficiency: float = Field(
193
+ default=0.94,
194
+ ge=0.5,
195
+ le=1.0,
196
+ description="Battery discharge efficiency",
197
+ )
198
  description: str = Field(default="", description="Human-readable task description")
pyproject.toml CHANGED
@@ -18,12 +18,17 @@ dependencies = [
18
  "plotly>=5.18.0",
19
  "openai>=1.10.0",
20
  "litellm>=1.0.0",
21
- "rich>=13.0.0",
 
 
 
 
 
22
  "transformers>=4.40.0",
23
  "peft>=0.11.0",
24
- "accelerate>=0.30.0"
 
25
  ]
26
- requires-python = ">=3.10"
27
 
28
  [project.scripts]
29
  server = "server.app:main"
 
18
  "plotly>=5.18.0",
19
  "openai>=1.10.0",
20
  "litellm>=1.0.0",
21
+ "rich>=13.0.0"
22
+ ]
23
+ requires-python = ">=3.10"
24
+
25
+ [project.optional-dependencies]
26
+ train = [
27
  "transformers>=4.40.0",
28
  "peft>=0.11.0",
29
+ "accelerate>=0.30.0",
30
+ "trl>=0.24.0",
31
  ]
 
32
 
33
  [project.scripts]
34
  server = "server.app:main"
requirements-train.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ -r requirements.txt
2
+ transformers>=4.40.0
3
+ peft>=0.11.0
4
+ accelerate>=0.30.0
5
+ trl>=0.24.0
requirements.txt CHANGED
@@ -7,8 +7,3 @@ plotly>=5.18.0
7
  openai>=1.10.0
8
  litellm>=1.0.0
9
  rich>=13.0.0
10
- # trl, unsloth, torch are heavy and omitted for the web dashboard deployment
11
- # they should be installed locally for training
12
- transformers>=4.40.0
13
- peft>=0.11.0
14
- accelerate>=0.30.0
 
7
  openai>=1.10.0
8
  litellm>=1.0.0
9
  rich>=13.0.0
 
 
 
 
 
scripts/benchmark.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic benchmark runner for EcoGrid policies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import random
8
+ from pathlib import Path
9
+ from statistics import mean
10
+
11
+ from baseline import heuristic_agent
12
+ from env.environment import EcoGridEnv
13
+ from env.tasks import (
14
+ BasicGridBalanceGrader,
15
+ CarbonConstrainedGrader,
16
+ RenewableVariabilityGrader,
17
+ )
18
+ from models.schemas import GridAction
19
+
20
+
21
+ TASKS = ("easy", "medium", "hard")
22
+ AGENTS = ("random", "heuristic")
23
+
24
+ # Historical reference numbers from pre-fix evaluation snapshot.
25
+ REFERENCE_MEAN = {
26
+ "easy": {"random": 0.269, "heuristic": 0.748},
27
+ "medium": {"random": 0.251, "heuristic": 0.376},
28
+ "hard": {"random": 0.001, "heuristic": 0.001},
29
+ }
30
+
31
+
32
+ def grade_episode(task: str, episode_log):
33
+ if task == "easy":
34
+ return BasicGridBalanceGrader.grade(episode_log).score
35
+ if task == "medium":
36
+ return RenewableVariabilityGrader.grade(episode_log).score
37
+ return CarbonConstrainedGrader.grade(episode_log).score
38
+
39
+
40
+ def choose_action(agent: str, task: str, state, rng: random.Random) -> GridAction:
41
+ if agent == "heuristic":
42
+ return heuristic_agent(state, task)
43
+
44
+ renewable_ratio = rng.random()
45
+ fossil_ratio = rng.random() * (1.0 - renewable_ratio)
46
+ battery_action = rng.uniform(-1.0, 1.0)
47
+ return GridAction(
48
+ renewable_ratio=renewable_ratio,
49
+ fossil_ratio=fossil_ratio,
50
+ battery_action=battery_action,
51
+ )
52
+
53
+
54
+ def run_episode(task: str, agent: str, seed: int) -> float:
55
+ rng = random.Random(seed)
56
+ env = EcoGridEnv()
57
+ state = env.reset(task=task, seed=seed)
58
+ while not env.is_done:
59
+ action = choose_action(agent, task, state, rng)
60
+ result = env.step(action)
61
+ state = result.observation
62
+ return grade_episode(task, env.get_episode_log())
63
+
64
+
65
+ def run_benchmarks(seeds: list[int]) -> dict:
66
+ out = {
67
+ "metadata": {"seeds": seeds, "agents": list(AGENTS), "tasks": list(TASKS)},
68
+ "results": {},
69
+ }
70
+ for task in TASKS:
71
+ out["results"][task] = {}
72
+ for agent in AGENTS:
73
+ scores = [run_episode(task, agent, seed) for seed in seeds]
74
+ avg = float(mean(scores))
75
+ ref = REFERENCE_MEAN[task][agent]
76
+ out["results"][task][agent] = {
77
+ "scores": [round(x, 6) for x in scores],
78
+ "mean": round(avg, 6),
79
+ "reference_mean": ref,
80
+ "delta_vs_reference": round(avg - ref, 6),
81
+ }
82
+ return out
83
+
84
+
85
+ def main():
86
+ parser = argparse.ArgumentParser(description="Run EcoGrid reproducible benchmark suite.")
87
+ parser.add_argument("--seeds", default="1,2,3,4,5", help="Comma-separated integer seeds")
88
+ parser.add_argument(
89
+ "--out",
90
+ default="logs/benchmark_results.json",
91
+ help="Path to save benchmark results JSON",
92
+ )
93
+ args = parser.parse_args()
94
+
95
+ seeds = [int(x.strip()) for x in args.seeds.split(",") if x.strip()]
96
+ results = run_benchmarks(seeds)
97
+
98
+ out_path = Path(args.out)
99
+ out_path.parent.mkdir(parents=True, exist_ok=True)
100
+ out_path.write_text(json.dumps(results, indent=2), encoding="utf-8")
101
+ print(json.dumps(results, indent=2))
102
+ print(f"\nSaved benchmark report to {out_path}")
103
+
104
+
105
+ if __name__ == "__main__":
106
+ main()
scripts/smoke_api.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deployment smoke checks for EcoGrid OpenEnv server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import time
8
+
9
+ import requests
10
+
11
+
12
+ def check(base_url: str):
13
+ t0 = time.perf_counter()
14
+ health = requests.get(f"{base_url}/health", timeout=5)
15
+ cold_start_ms = (time.perf_counter() - t0) * 1000.0
16
+ health.raise_for_status()
17
+
18
+ reset = requests.post(f"{base_url}/reset", json={"task": "easy", "seed": 42}, timeout=10)
19
+ reset.raise_for_status()
20
+
21
+ step = requests.post(
22
+ f"{base_url}/step",
23
+ json={"renewable_ratio": 0.6, "fossil_ratio": 0.35, "battery_action": 0.0},
24
+ timeout=10,
25
+ )
26
+ step.raise_for_status()
27
+
28
+ return {
29
+ "health_status": health.status_code,
30
+ "reset_status": reset.status_code,
31
+ "step_status": step.status_code,
32
+ "cold_start_ms": round(cold_start_ms, 2),
33
+ }
34
+
35
+
36
+ def main():
37
+ parser = argparse.ArgumentParser(description="Run API smoke checks against EcoGrid server.")
38
+ parser.add_argument("--base-url", default="http://127.0.0.1:7860")
39
+ args = parser.parse_args()
40
+ result = check(args.base_url.rstrip("/"))
41
+ print(json.dumps(result, indent=2))
42
+
43
+
44
+ if __name__ == "__main__":
45
+ main()
server/app.py CHANGED
@@ -1,8 +1,14 @@
 
 
 
1
  try:
2
  from openenv.core.env_server.http_server import create_app
3
  except ImportError as e:
4
  raise ImportError("openenv-core>=0.2.0 is required for the server.") from e
5
 
 
 
 
6
  from models.schemas import GridAction
7
  from server.ecogrid_environment import ServerEcoGridEnv, ServerObservation
8
 
@@ -14,17 +20,57 @@ app = create_app(
14
  max_concurrent_envs=10,
15
  )
16
 
17
- def main(host: str = "0.0.0.0", port: int = 7860):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  import uvicorn
 
 
 
19
  uvicorn.run(app, host=host, port=port)
20
 
21
- if __name__ == '__main__':
22
- import argparse
23
- parser = argparse.ArgumentParser()
24
- parser.add_argument("--port", type=int, default=7860)
25
- args = parser.parse_args()
26
-
27
- # Satisfy naive validator check for 'main()' string
28
- if False: main()
29
-
30
- main(port=args.port)
 
1
+ import json
2
+ import os
3
+
4
  try:
5
  from openenv.core.env_server.http_server import create_app
6
  except ImportError as e:
7
  raise ImportError("openenv-core>=0.2.0 is required for the server.") from e
8
 
9
+ from fastapi import Request
10
+ from fastapi.responses import JSONResponse
11
+
12
  from models.schemas import GridAction
13
  from server.ecogrid_environment import ServerEcoGridEnv, ServerObservation
14
 
 
20
  max_concurrent_envs=10,
21
  )
22
 
23
+
24
+ @app.middleware("http")
25
+ async def normalize_step_payload(request: Request, call_next):
26
+ """Allow /step payloads with either wrapped or direct action JSON."""
27
+ if request.method == "POST" and request.url.path == "/step":
28
+ body = await request.body()
29
+ if body:
30
+ try:
31
+ payload = json.loads(body)
32
+ except json.JSONDecodeError:
33
+ return JSONResponse(status_code=422, content={"detail": "Invalid JSON body"})
34
+
35
+ if isinstance(payload, dict) and "action" not in payload:
36
+ wrapped = json.dumps({"action": payload}).encode("utf-8")
37
+ request._body = wrapped
38
+
39
+ async def _receive():
40
+ return {"type": "http.request", "body": wrapped, "more_body": False}
41
+
42
+ request._receive = _receive
43
+
44
+ return await call_next(request)
45
+
46
+
47
+ @app.get("/", include_in_schema=False)
48
+ def root():
49
+ """Landing route for judges/operators."""
50
+ return JSONResponse(
51
+ {
52
+ "name": "eco-grid-openenv",
53
+ "status": "ok",
54
+ "docs": "/docs",
55
+ "health": "/health",
56
+ "schema": "/schema",
57
+ "version": "/version",
58
+ }
59
+ )
60
+
61
+
62
+ @app.get("/version", include_in_schema=False)
63
+ def version():
64
+ return {"version": "1.1.0-stabilized"}
65
+
66
+
67
+ def main():
68
  import uvicorn
69
+
70
+ host = os.getenv("HOST", "0.0.0.0")
71
+ port = int(os.getenv("PORT", "7860"))
72
  uvicorn.run(app, host=host, port=port)
73
 
74
+
75
+ if __name__ == "__main__":
76
+ main()
 
 
 
 
 
 
 
server/ecogrid_environment.py CHANGED
@@ -5,6 +5,7 @@ from pydantic import BaseModel, Field
5
  from openenv.core.env_server.interfaces import Environment
6
  from openenv.core.env_server.types import State
7
 
 
8
  from env.environment import EcoGridEnv
9
  from models.schemas import GridAction, GridState
10
 
@@ -37,14 +38,40 @@ class ServerEcoGridEnv(Environment):
37
  info={}
38
  )
39
 
40
- def step(self, action: GridAction) -> ServerObservation:
41
  self._oe_state.step_count += 1
42
- result = self._env.step(action)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  return ServerObservation(
44
  observation=result.observation,
45
  reward=result.reward,
46
  done=result.done,
47
- info=result.info
48
  )
49
 
50
  @property
 
5
  from openenv.core.env_server.interfaces import Environment
6
  from openenv.core.env_server.types import State
7
 
8
+ from env.action_utils import coerce_grid_action
9
  from env.environment import EcoGridEnv
10
  from models.schemas import GridAction, GridState
11
 
 
38
  info={}
39
  )
40
 
41
+ def step(self, action: GridAction | dict) -> ServerObservation:
42
  self._oe_state.step_count += 1
43
+ safe_default = GridAction(
44
+ renewable_ratio=0.5,
45
+ fossil_ratio=0.5,
46
+ battery_action=0.0,
47
+ )
48
+ parsed_action, action_warning = coerce_grid_action(
49
+ action_like=action,
50
+ default_action=safe_default,
51
+ )
52
+ try:
53
+ result = self._env.step(parsed_action)
54
+ except Exception as exc:
55
+ # Never crash the API on malformed/edge payloads.
56
+ fallback_state = self._env.state() if not self._env.is_done else self._env.reset()
57
+ return ServerObservation(
58
+ observation=fallback_state,
59
+ reward=0.001,
60
+ done=self._env.is_done,
61
+ info={
62
+ "error": f"step_failed:{type(exc).__name__}",
63
+ "detail": str(exc),
64
+ "action_warning": action_warning or "step_exception_fallback",
65
+ },
66
+ )
67
+ info = dict(result.info)
68
+ if action_warning:
69
+ info["action_warning"] = action_warning
70
  return ServerObservation(
71
  observation=result.observation,
72
  reward=result.reward,
73
  done=result.done,
74
+ info=info,
75
  )
76
 
77
  @property
tests/test_action_utils.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from env.action_utils import coerce_grid_action, normalize_action_components, safe_grid_action
2
+ from models.schemas import GridAction
3
+
4
+
5
+ def test_normalize_action_components_rounding_never_exceeds_one():
6
+ # Regression case from baseline heuristic:
7
+ # 0.396 + 0.605 -> 1.001 after rounding.
8
+ renewable, fossil, battery = normalize_action_components(
9
+ renewable_ratio=0.396,
10
+ fossil_ratio=0.605,
11
+ battery_action=0.0,
12
+ )
13
+ assert renewable + fossil <= 1.0
14
+ assert -1.0 <= battery <= 1.0
15
+
16
+
17
+ def test_safe_grid_action_clamps_and_normalizes():
18
+ action = safe_grid_action(
19
+ renewable_ratio=1.7,
20
+ fossil_ratio=0.8,
21
+ battery_action=-1.7,
22
+ )
23
+ assert isinstance(action, GridAction)
24
+ assert 0.0 <= action.renewable_ratio <= 1.0
25
+ assert 0.0 <= action.fossil_ratio <= 1.0
26
+ assert action.renewable_ratio + action.fossil_ratio <= 1.0
27
+ assert -1.0 <= action.battery_action <= 1.0
28
+
29
+
30
+ def test_coerce_grid_action_invalid_payload_falls_back():
31
+ default = GridAction(renewable_ratio=0.5, fossil_ratio=0.5, battery_action=0.0)
32
+ action, warning = coerce_grid_action({"renewable_ratio": "invalid"}, default_action=default)
33
+ assert action == default
34
+ assert warning is not None
tests/test_environment.py CHANGED
@@ -82,3 +82,35 @@ def test_carbon_overrun_termination():
82
  break
83
 
84
  assert done is True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  break
83
 
84
  assert done is True
85
+
86
+
87
+ def test_step_accepts_dict_action_and_adds_warning_for_invalid_payload():
88
+ env = EcoGridEnv()
89
+ env.reset(task="medium", seed=42)
90
+
91
+ # Invalid dict payload should be coerced to safe fallback action
92
+ result = env.step({"renewable_ratio": "bad_value"})
93
+
94
+ assert result.done is False
95
+ assert "action_warning" in result.info
96
+ assert result.observation.time_step == 1
97
+
98
+
99
+ def test_full_episode_reproducibility_same_seed_same_trajectory():
100
+ env1 = EcoGridEnv()
101
+ env2 = EcoGridEnv()
102
+ env1.reset(task="hard", seed=123)
103
+ env2.reset(task="hard", seed=123)
104
+
105
+ action = GridAction(renewable_ratio=0.6, fossil_ratio=0.3, battery_action=0.0)
106
+ rewards_1 = []
107
+ rewards_2 = []
108
+
109
+ for _ in range(10):
110
+ r1 = env1.step(action)
111
+ r2 = env2.step(action)
112
+ rewards_1.append(r1.reward)
113
+ rewards_2.append(r2.reward)
114
+ assert r1.observation.model_dump() == r2.observation.model_dump()
115
+
116
+ assert rewards_1 == rewards_2
tests/test_server_api.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi.testclient import TestClient
2
+
3
+ from server.app import app
4
+
5
+
6
+ def test_health_endpoint():
7
+ client = TestClient(app)
8
+ response = client.get("/health")
9
+ assert response.status_code == 200
10
+ body = response.json()
11
+ assert body.get("status") in {"ok", "healthy"}
12
+
13
+
14
+ def test_reset_and_step_smoke_wrapped_payload():
15
+ client = TestClient(app)
16
+ reset_resp = client.post("/reset", json={"task": "easy", "seed": 42})
17
+ assert reset_resp.status_code == 200
18
+ reset_body = reset_resp.json()
19
+ assert "observation" in reset_body
20
+
21
+ step_resp = client.post(
22
+ "/step",
23
+ json={"action": {"renewable_ratio": 0.6, "fossil_ratio": 0.3, "battery_action": 0.0}},
24
+ )
25
+ assert step_resp.status_code == 200
26
+ step_body = step_resp.json()
27
+ assert "observation" in step_body
28
+ assert "reward" in step_body
29
+ assert "done" in step_body
30
+
31
+
32
+ def test_step_accepts_unwrapped_action_payload():
33
+ client = TestClient(app)
34
+ client.post("/reset", json={"task": "medium", "seed": 7})
35
+ step_resp = client.post(
36
+ "/step",
37
+ json={"renewable_ratio": 0.55, "fossil_ratio": 0.35, "battery_action": 0.0},
38
+ )
39
+ assert step_resp.status_code == 200
40
+ assert "observation" in step_resp.json()
41
+
42
+
43
+ def test_step_invalid_payload_returns_422_not_500():
44
+ client = TestClient(app)
45
+ client.post("/reset", json={"task": "easy", "seed": 11})
46
+ bad_resp = client.post(
47
+ "/step",
48
+ json={"renewable_ratio": 1.5, "fossil_ratio": 1.5, "battery_action": 2.0},
49
+ )
50
+ assert bad_resp.status_code == 422
51
+
52
+
53
+ def test_step_stress_multiple_interactions():
54
+ client = TestClient(app)
55
+ client.post("/reset", json={"task": "hard", "seed": 21})
56
+ for _ in range(20):
57
+ resp = client.post(
58
+ "/step",
59
+ json={"renewable_ratio": 0.6, "fossil_ratio": 0.3, "battery_action": 0.0},
60
+ )
61
+ assert resp.status_code == 200
train_unsloth.py CHANGED
@@ -11,6 +11,8 @@ import os
11
  import random
12
  from typing import List, Dict
13
 
 
 
14
  try:
15
  import torch
16
  from datasets import Dataset
@@ -87,15 +89,28 @@ Output ONLY a valid JSON object:
87
  ]
88
 
89
 
90
- def generate_training_data(num_samples: int, task: str) -> Dataset:
 
 
 
 
 
 
 
 
 
 
 
 
91
  """Generate a dataset of random grid states for training."""
92
  print(f"Generating {num_samples} training states for task '{task}'...")
93
  env = EcoGridEnv()
 
94
 
95
  prompts = []
96
  # We just run the environment randomly to generate a variety of states
97
  # Note: We don't need target actions because GRPO learns through trial and error!
98
- state = env.reset(task=task, seed=42)
99
 
100
  for _ in range(num_samples):
101
  state_dict = state.model_dump()
@@ -103,9 +118,9 @@ def generate_training_data(num_samples: int, task: str) -> Dataset:
103
 
104
  # Take a random valid action to advance the environment
105
  action = GridAction(
106
- renewable_ratio=random.uniform(0, 0.8),
107
- fossil_ratio=random.uniform(0, 0.2),
108
- battery_action=random.uniform(-1, 1)
109
  )
110
 
111
  try:
@@ -113,7 +128,7 @@ def generate_training_data(num_samples: int, task: str) -> Dataset:
113
  state = result.observation
114
  except Exception:
115
  # If done or errored, reset
116
- state = env.reset(task=task, seed=random.randint(0, 10000))
117
 
118
  return Dataset.from_dict({"prompt": prompts})
119
 
@@ -133,6 +148,7 @@ def main():
133
  return
134
 
135
  print(f"Initializing Unsloth GRPO training on {args.model}")
 
136
 
137
  # 1. Load Model
138
  model, tokenizer = FastLanguageModel.from_pretrained(
@@ -207,7 +223,7 @@ def main():
207
  return rewards
208
 
209
  # 3. Prepare Dataset
210
- dataset = generate_training_data(args.samples, args.task)
211
 
212
  # 4. Configure Trainer
213
  training_args = GRPOConfig(
@@ -255,8 +271,25 @@ def main():
255
  os.makedirs("./logs", exist_ok=True)
256
  with open("./logs/reward_curve.json", "w") as f:
257
  json.dump(reward_curve, f, indent=2)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
258
 
259
  print("Saved reward curve to ./logs/reward_curve.json")
 
260
 
261
  if __name__ == "__main__":
262
  main()
 
11
  import random
12
  from typing import List, Dict
13
 
14
+ import numpy as np
15
+
16
  try:
17
  import torch
18
  from datasets import Dataset
 
89
  ]
90
 
91
 
92
+ def set_global_seed(seed: int) -> None:
93
+ """Set all available RNG seeds for reproducible training."""
94
+ random.seed(seed)
95
+ np.random.seed(seed)
96
+ os.environ["PYTHONHASHSEED"] = str(seed)
97
+ if HAS_UNSLOTH:
98
+ torch.manual_seed(seed)
99
+ if torch.cuda.is_available():
100
+ torch.cuda.manual_seed_all(seed)
101
+ torch.use_deterministic_algorithms(True, warn_only=True)
102
+
103
+
104
+ def generate_training_data(num_samples: int, task: str, seed: int) -> Dataset:
105
  """Generate a dataset of random grid states for training."""
106
  print(f"Generating {num_samples} training states for task '{task}'...")
107
  env = EcoGridEnv()
108
+ rng = random.Random(seed)
109
 
110
  prompts = []
111
  # We just run the environment randomly to generate a variety of states
112
  # Note: We don't need target actions because GRPO learns through trial and error!
113
+ state = env.reset(task=task, seed=seed)
114
 
115
  for _ in range(num_samples):
116
  state_dict = state.model_dump()
 
118
 
119
  # Take a random valid action to advance the environment
120
  action = GridAction(
121
+ renewable_ratio=rng.uniform(0, 0.8),
122
+ fossil_ratio=rng.uniform(0, 0.2),
123
+ battery_action=rng.uniform(-1, 1),
124
  )
125
 
126
  try:
 
128
  state = result.observation
129
  except Exception:
130
  # If done or errored, reset
131
+ state = env.reset(task=task, seed=rng.randint(0, 10000))
132
 
133
  return Dataset.from_dict({"prompt": prompts})
134
 
 
148
  return
149
 
150
  print(f"Initializing Unsloth GRPO training on {args.model}")
151
+ set_global_seed(args.seed)
152
 
153
  # 1. Load Model
154
  model, tokenizer = FastLanguageModel.from_pretrained(
 
223
  return rewards
224
 
225
  # 3. Prepare Dataset
226
+ dataset = generate_training_data(args.samples, args.task, args.seed)
227
 
228
  # 4. Configure Trainer
229
  training_args = GRPOConfig(
 
271
  os.makedirs("./logs", exist_ok=True)
272
  with open("./logs/reward_curve.json", "w") as f:
273
  json.dump(reward_curve, f, indent=2)
274
+
275
+ with open("./logs/training_metrics.json", "w") as f:
276
+ json.dump(
277
+ {
278
+ "task": args.task,
279
+ "seed": args.seed,
280
+ "epochs": args.epochs,
281
+ "samples": args.samples,
282
+ "model": args.model,
283
+ "reward_curve": reward_curve,
284
+ "log_history": log_history,
285
+ },
286
+ f,
287
+ indent=2,
288
+ default=str,
289
+ )
290
 
291
  print("Saved reward curve to ./logs/reward_curve.json")
292
+ print("Saved training metrics to ./logs/training_metrics.json")
293
 
294
  if __name__ == "__main__":
295
  main()
uv.lock CHANGED
@@ -812,6 +812,42 @@ wheels = [
812
  { url = "https://files.pythonhosted.org/packages/7c/37/197db187c260d24d4be1f09d427f59f3fb9a89bcf1354e23865c7bff7607/cyclopts-4.11.0-py3-none-any.whl", hash = "sha256:34318e3823b44b5baa754a5e37ec70a5c17dc81c65e4295ed70e17bc1aeae50d", size = 208494, upload-time = "2026-04-23T00:23:34.948Z" },
813
  ]
814
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
815
  [[package]]
816
  name = "distro"
817
  version = "1.9.0"
@@ -853,36 +889,44 @@ name = "eco-grid-openenv"
853
  version = "1.0.0"
854
  source = { editable = "." }
855
  dependencies = [
856
- { name = "accelerate" },
857
  { name = "litellm" },
858
  { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
859
  { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
860
  { name = "openai" },
861
  { name = "openenv-core" },
862
- { name = "pandas" },
863
- { name = "peft" },
864
  { name = "plotly" },
865
  { name = "pydantic" },
866
  { name = "rich" },
867
  { name = "streamlit" },
 
 
 
 
 
 
868
  { name = "transformers" },
 
869
  ]
870
 
871
  [package.metadata]
872
  requires-dist = [
873
- { name = "accelerate", specifier = ">=0.30.0" },
874
  { name = "litellm", specifier = ">=1.0.0" },
875
  { name = "numpy", specifier = ">=1.24.0" },
876
  { name = "openai", specifier = ">=1.10.0" },
877
  { name = "openenv-core", specifier = ">=0.2.3" },
878
  { name = "pandas", specifier = ">=2.0.0" },
879
- { name = "peft", specifier = ">=0.11.0" },
880
  { name = "plotly", specifier = ">=5.18.0" },
881
  { name = "pydantic", specifier = ">=2.0.0" },
882
  { name = "rich", specifier = ">=13.0.0" },
883
  { name = "streamlit", specifier = ">=1.30.0" },
884
- { name = "transformers", specifier = ">=4.40.0" },
 
885
  ]
 
886
 
887
  [[package]]
888
  name = "email-validator"
@@ -1153,11 +1197,16 @@ wheels = [
1153
 
1154
  [[package]]
1155
  name = "fsspec"
1156
- version = "2026.3.0"
1157
  source = { registry = "https://pypi.org/simple" }
1158
- sdist = { url = "https://files.pythonhosted.org/packages/e1/cf/b50ddf667c15276a9ab15a70ef5f257564de271957933ffea49d2cdbcdfb/fsspec-2026.3.0.tar.gz", hash = "sha256:1ee6a0e28677557f8c2f994e3eea77db6392b4de9cd1f5d7a9e87a0ae9d01b41", size = 313547, upload-time = "2026-03-27T19:11:14.892Z" }
1159
  wheels = [
1160
- { url = "https://files.pythonhosted.org/packages/d5/1f/5f4a3cd9e4440e9d9bc78ad0a91a1c8d46b4d429d5239ebe6793c9fe5c41/fsspec-2026.3.0-py3-none-any.whl", hash = "sha256:d2ceafaad1b3457968ed14efa28798162f1638dbb5d2a6868a2db002a5ee39a4", size = 202595, upload-time = "2026-03-27T19:11:13.595Z" },
 
 
 
 
 
1161
  ]
1162
 
1163
  [[package]]
@@ -1941,6 +1990,29 @@ wheels = [
1941
  { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" },
1942
  ]
1943
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1944
  [[package]]
1945
  name = "narwhals"
1946
  version = "2.20.0"
@@ -4100,6 +4172,22 @@ wheels = [
4100
  { url = "https://files.pythonhosted.org/packages/f6/56/6113c23ff46c00aae423333eb58b3e60bdfe9179d542781955a5e1514cb3/triton-3.6.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46bd1c1af4b6704e554cad2eeb3b0a6513a980d470ccfa63189737340c7746a7", size = 188397994, upload-time = "2026-01-20T16:01:14.236Z" },
4101
  ]
4102
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4103
  [[package]]
4104
  name = "typer"
4105
  version = "0.23.1"
@@ -4366,6 +4454,163 @@ wheels = [
4366
  { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
4367
  ]
4368
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4369
  [[package]]
4370
  name = "yarl"
4371
  version = "1.23.0"
 
812
  { url = "https://files.pythonhosted.org/packages/7c/37/197db187c260d24d4be1f09d427f59f3fb9a89bcf1354e23865c7bff7607/cyclopts-4.11.0-py3-none-any.whl", hash = "sha256:34318e3823b44b5baa754a5e37ec70a5c17dc81c65e4295ed70e17bc1aeae50d", size = 208494, upload-time = "2026-04-23T00:23:34.948Z" },
813
  ]
814
 
815
+ [[package]]
816
+ name = "datasets"
817
+ version = "4.8.4"
818
+ source = { registry = "https://pypi.org/simple" }
819
+ dependencies = [
820
+ { name = "dill" },
821
+ { name = "filelock" },
822
+ { name = "fsspec", extra = ["http"] },
823
+ { name = "httpx" },
824
+ { name = "huggingface-hub" },
825
+ { name = "multiprocess" },
826
+ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
827
+ { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
828
+ { name = "packaging" },
829
+ { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
830
+ { name = "pandas", version = "3.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
831
+ { name = "pyarrow" },
832
+ { name = "pyyaml" },
833
+ { name = "requests" },
834
+ { name = "tqdm" },
835
+ { name = "xxhash" },
836
+ ]
837
+ sdist = { url = "https://files.pythonhosted.org/packages/22/22/73e46ac7a8c25e7ef0b3bd6f10da3465021d90219a32eb0b4d2afea4c56e/datasets-4.8.4.tar.gz", hash = "sha256:a1429ed853275ce7943a01c6d2e25475b4501eb758934362106a280470df3a52", size = 604382, upload-time = "2026-03-23T14:21:17.987Z" }
838
+ wheels = [
839
+ { url = "https://files.pythonhosted.org/packages/b0/e5/247d094108e42ac26363ab8dc57f168840cf7c05774b40ffeb0d78868fcc/datasets-4.8.4-py3-none-any.whl", hash = "sha256:cdc8bee4698e549d78bf1fed6aea2eebc760b22b084f07e6fc020c6577a6ce6d", size = 526991, upload-time = "2026-03-23T14:21:15.89Z" },
840
+ ]
841
+
842
+ [[package]]
843
+ name = "dill"
844
+ version = "0.4.1"
845
+ source = { registry = "https://pypi.org/simple" }
846
+ sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" }
847
+ wheels = [
848
+ { url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" },
849
+ ]
850
+
851
  [[package]]
852
  name = "distro"
853
  version = "1.9.0"
 
889
  version = "1.0.0"
890
  source = { editable = "." }
891
  dependencies = [
 
892
  { name = "litellm" },
893
  { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
894
  { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
895
  { name = "openai" },
896
  { name = "openenv-core" },
897
+ { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
898
+ { name = "pandas", version = "3.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
899
  { name = "plotly" },
900
  { name = "pydantic" },
901
  { name = "rich" },
902
  { name = "streamlit" },
903
+ ]
904
+
905
+ [package.optional-dependencies]
906
+ train = [
907
+ { name = "accelerate" },
908
+ { name = "peft" },
909
  { name = "transformers" },
910
+ { name = "trl" },
911
  ]
912
 
913
  [package.metadata]
914
  requires-dist = [
915
+ { name = "accelerate", marker = "extra == 'train'", specifier = ">=0.30.0" },
916
  { name = "litellm", specifier = ">=1.0.0" },
917
  { name = "numpy", specifier = ">=1.24.0" },
918
  { name = "openai", specifier = ">=1.10.0" },
919
  { name = "openenv-core", specifier = ">=0.2.3" },
920
  { name = "pandas", specifier = ">=2.0.0" },
921
+ { name = "peft", marker = "extra == 'train'", specifier = ">=0.11.0" },
922
  { name = "plotly", specifier = ">=5.18.0" },
923
  { name = "pydantic", specifier = ">=2.0.0" },
924
  { name = "rich", specifier = ">=13.0.0" },
925
  { name = "streamlit", specifier = ">=1.30.0" },
926
+ { name = "transformers", marker = "extra == 'train'", specifier = ">=4.40.0" },
927
+ { name = "trl", marker = "extra == 'train'", specifier = ">=0.24.0" },
928
  ]
929
+ provides-extras = ["train"]
930
 
931
  [[package]]
932
  name = "email-validator"
 
1197
 
1198
  [[package]]
1199
  name = "fsspec"
1200
+ version = "2026.2.0"
1201
  source = { registry = "https://pypi.org/simple" }
1202
+ sdist = { url = "https://files.pythonhosted.org/packages/51/7c/f60c259dcbf4f0c47cc4ddb8f7720d2dcdc8888c8e5ad84c73ea4531cc5b/fsspec-2026.2.0.tar.gz", hash = "sha256:6544e34b16869f5aacd5b90bdf1a71acb37792ea3ddf6125ee69a22a53fb8bff", size = 313441, upload-time = "2026-02-05T21:50:53.743Z" }
1203
  wheels = [
1204
+ { url = "https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437", size = 202505, upload-time = "2026-02-05T21:50:51.819Z" },
1205
+ ]
1206
+
1207
+ [package.optional-dependencies]
1208
+ http = [
1209
+ { name = "aiohttp" },
1210
  ]
1211
 
1212
  [[package]]
 
1990
  { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" },
1991
  ]
1992
 
1993
+ [[package]]
1994
+ name = "multiprocess"
1995
+ version = "0.70.19"
1996
+ source = { registry = "https://pypi.org/simple" }
1997
+ dependencies = [
1998
+ { name = "dill" },
1999
+ ]
2000
+ sdist = { url = "https://files.pythonhosted.org/packages/a2/f2/e783ac7f2aeeed14e9e12801f22529cc7e6b7ab80928d6dcce4e9f00922d/multiprocess-0.70.19.tar.gz", hash = "sha256:952021e0e6c55a4a9fe4cd787895b86e239a40e76802a789d6305398d3975897", size = 2079989, upload-time = "2026-01-19T06:47:39.744Z" }
2001
+ wheels = [
2002
+ { url = "https://files.pythonhosted.org/packages/8b/b6/10832f96b499690854e574360be342a282f5f7dba58eff791299ff6c0637/multiprocess-0.70.19-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:02e5c35d7d6cd2bdc89c1858867f7bde4012837411023a4696c148c1bdd7c80e", size = 135131, upload-time = "2026-01-19T06:47:20.479Z" },
2003
+ { url = "https://files.pythonhosted.org/packages/99/50/faef2d8106534b0dc4a0b772668a1a99682696ebf17d3c0f13f2ed6a656a/multiprocess-0.70.19-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:79576c02d1207ec405b00cabf2c643c36070800cca433860e14539df7818b2aa", size = 135131, upload-time = "2026-01-19T06:47:21.879Z" },
2004
+ { url = "https://files.pythonhosted.org/packages/94/b1/0b71d18b76bf423c2e8ee00b31db37d17297ab3b4db44e188692afdca628/multiprocess-0.70.19-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c6b6d78d43a03b68014ca1f0b7937d965393a670c5de7c29026beb2258f2f896", size = 135134, upload-time = "2026-01-19T06:47:23.262Z" },
2005
+ { url = "https://files.pythonhosted.org/packages/7e/aa/714635c727dbfc251139226fa4eaf1b07f00dc12d9cd2eb25f931adaf873/multiprocess-0.70.19-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1bbf1b69af1cf64cd05f65337d9215b88079ec819cd0ea7bac4dab84e162efe7", size = 144743, upload-time = "2026-01-19T06:47:24.562Z" },
2006
+ { url = "https://files.pythonhosted.org/packages/0f/e1/155f6abf5e6b5d9cef29b6d0167c180846157a4aca9b9bee1a217f67c959/multiprocess-0.70.19-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5be9ec7f0c1c49a4f4a6fd20d5dda4aeabc2d39a50f4ad53720f1cd02b3a7c2e", size = 144738, upload-time = "2026-01-19T06:47:26.636Z" },
2007
+ { url = "https://files.pythonhosted.org/packages/af/cb/f421c2869d75750a4f32301cc20c4b63fab6376e9a75c8e5e655bdeb3d9b/multiprocess-0.70.19-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1c3dce098845a0db43b32a0b76a228ca059a668071cfeaa0f40c36c0b1585d45", size = 144741, upload-time = "2026-01-19T06:47:27.985Z" },
2008
+ { url = "https://files.pythonhosted.org/packages/e3/45/8004d1e6b9185c1a444d6b55ac5682acf9d98035e54386d967366035a03a/multiprocess-0.70.19-py310-none-any.whl", hash = "sha256:97404393419dcb2a8385910864eedf47a3cadf82c66345b44f036420eb0b5d87", size = 134948, upload-time = "2026-01-19T06:47:32.325Z" },
2009
+ { url = "https://files.pythonhosted.org/packages/86/c2/dec9722dc3474c164a0b6bcd9a7ed7da542c98af8cabce05374abab35edd/multiprocess-0.70.19-py311-none-any.whl", hash = "sha256:928851ae7973aea4ce0eaf330bbdafb2e01398a91518d5c8818802845564f45c", size = 144457, upload-time = "2026-01-19T06:47:33.711Z" },
2010
+ { url = "https://files.pythonhosted.org/packages/71/70/38998b950a97ea279e6bd657575d22d1a2047256caf707d9a10fbce4f065/multiprocess-0.70.19-py312-none-any.whl", hash = "sha256:3a56c0e85dd5025161bac5ce138dcac1e49174c7d8e74596537e729fd5c53c28", size = 150281, upload-time = "2026-01-19T06:47:35.037Z" },
2011
+ { url = "https://files.pythonhosted.org/packages/7f/74/d2c27e03cb84251dfe7249b8e82923643c6d48fa4883b9476b025e7dc7eb/multiprocess-0.70.19-py313-none-any.whl", hash = "sha256:8d5eb4ec5017ba2fab4e34a747c6d2c2b6fecfe9e7236e77988db91580ada952", size = 156414, upload-time = "2026-01-19T06:47:35.915Z" },
2012
+ { url = "https://files.pythonhosted.org/packages/a0/61/af9115673a5870fd885247e2f1b68c4f1197737da315b520a91c757a861a/multiprocess-0.70.19-py314-none-any.whl", hash = "sha256:e8cc7fbdff15c0613f0a1f1f8744bef961b0a164c0ca29bdff53e9d2d93c5e5f", size = 160318, upload-time = "2026-01-19T06:47:37.497Z" },
2013
+ { url = "https://files.pythonhosted.org/packages/7e/82/69e539c4c2027f1e1697e09aaa2449243085a0edf81ae2c6341e84d769b6/multiprocess-0.70.19-py39-none-any.whl", hash = "sha256:0d4b4397ed669d371c81dcd1ef33fd384a44d6c3de1bd0ca7ac06d837720d3c5", size = 133477, upload-time = "2026-01-19T06:47:38.619Z" },
2014
+ ]
2015
+
2016
  [[package]]
2017
  name = "narwhals"
2018
  version = "2.20.0"
 
4172
  { url = "https://files.pythonhosted.org/packages/f6/56/6113c23ff46c00aae423333eb58b3e60bdfe9179d542781955a5e1514cb3/triton-3.6.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46bd1c1af4b6704e554cad2eeb3b0a6513a980d470ccfa63189737340c7746a7", size = 188397994, upload-time = "2026-01-20T16:01:14.236Z" },
4173
  ]
4174
 
4175
+ [[package]]
4176
+ name = "trl"
4177
+ version = "1.2.0"
4178
+ source = { registry = "https://pypi.org/simple" }
4179
+ dependencies = [
4180
+ { name = "accelerate" },
4181
+ { name = "datasets" },
4182
+ { name = "jinja2" },
4183
+ { name = "packaging" },
4184
+ { name = "transformers" },
4185
+ ]
4186
+ sdist = { url = "https://files.pythonhosted.org/packages/0d/d6/2d3d876917d43537afea7b502abb318ae071295e4accac222741b548399e/trl-1.2.0.tar.gz", hash = "sha256:f5038b21295d2559992a087ea8d9ca10f74cde23e6760861def209811ab45d00", size = 583112, upload-time = "2026-04-17T01:04:17.706Z" }
4187
+ wheels = [
4188
+ { url = "https://files.pythonhosted.org/packages/02/82/bad1a22ff4b21d8080f7a64d8313c1ac0e791b455b98f2efca1ef3e14b8f/trl-1.2.0-py3-none-any.whl", hash = "sha256:f6ddfa162ac92d25973070d9e3f6cff71b32c52edc34539e4294722f9dc0a6d6", size = 697449, upload-time = "2026-04-17T01:04:16.007Z" },
4189
+ ]
4190
+
4191
  [[package]]
4192
  name = "typer"
4193
  version = "0.23.1"
 
4454
  { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
4455
  ]
4456
 
4457
+ [[package]]
4458
+ name = "xxhash"
4459
+ version = "3.7.0"
4460
+ source = { registry = "https://pypi.org/simple" }
4461
+ sdist = { url = "https://files.pythonhosted.org/packages/24/2f/e183a1b407002f5af81822bee18b61cdb94b8670208ef34734d8d2b8ebe9/xxhash-3.7.0.tar.gz", hash = "sha256:6cc4eefbb542a5d6ffd6d70ea9c502957c925e800f998c5630ecc809d6702bae", size = 82022, upload-time = "2026-04-25T11:10:32.553Z" }
4462
+ wheels = [
4463
+ { url = "https://files.pythonhosted.org/packages/92/49/e4b575b4ed170a7f640c8bd69cfadfa81c7b700191fde5e72228762b9f73/xxhash-3.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cd8ab85c916a58d5c8656ea15e3ce9df836fe2f120a74c296e01d69fab2614b4", size = 33426, upload-time = "2026-04-25T11:05:15.702Z" },
4464
+ { url = "https://files.pythonhosted.org/packages/07/61/40f0155b0b09988eb6cdbfc52652f2f371810b0c58163208cb05667757bd/xxhash-3.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:85f5c0e26d945b5bb475e0a3d95193117498130baa7619357bdc7869c2391b5a", size = 30859, upload-time = "2026-04-25T11:05:17.708Z" },
4465
+ { url = "https://files.pythonhosted.org/packages/12/bd/2902b7aad574e43cd85fd84849cfbce48c52cb02c7d6902b8a2b3f6e668e/xxhash-3.7.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b7ffeaada9f8699be63d639536b0b60dff73b7d3325b7475c5bc8fdbf4eed47f", size = 193839, upload-time = "2026-04-25T11:05:19.364Z" },
4466
+ { url = "https://files.pythonhosted.org/packages/48/df/343ce8fd09e47ba8fba43b3bad3283ddf0deca799d5a27b084c3aa2ce502/xxhash-3.7.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee88dfaa6b1b2bfadd3c031fa5f05584870e62fb05dc500942e9900c44fcfda", size = 212896, upload-time = "2026-04-25T11:05:21.131Z" },
4467
+ { url = "https://files.pythonhosted.org/packages/79/cf/703e8422a8b52407864281fb4eb52c605e9f33180413b4458f05de110eba/xxhash-3.7.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7426ff0dfa76eb47efc2cc59d4a717bfa9dc9938bff5e49e748bca749f6aa616", size = 235896, upload-time = "2026-04-25T11:05:22.988Z" },
4468
+ { url = "https://files.pythonhosted.org/packages/ed/bc/d4b039edbd426575add5f217abeeb2bf870e2c510d35445df81b4f457901/xxhash-3.7.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8ff6ec73110f610425caef3ea875afbfc34caa542f01df3a80f45aadeb9f906", size = 211665, upload-time = "2026-04-25T11:05:24.799Z" },
4469
+ { url = "https://files.pythonhosted.org/packages/42/24/c6f81361796814b92399a88bf079d3b65e617f531819128fcf1bd6ef0571/xxhash-3.7.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0d23fd49fdc5c8af61fb7104f1ad247954499140f6cb6045b3aa5c99dadbbf28", size = 444929, upload-time = "2026-04-25T11:05:26.245Z" },
4470
+ { url = "https://files.pythonhosted.org/packages/a4/db/268012153eb7f6bf2c8a0491fdcde11e093f166990821a2ab754fe95537d/xxhash-3.7.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12c249621af6d50a05d9f10af894b404157b15819878e18f75fcbb0213a77d07", size = 193271, upload-time = "2026-04-25T11:05:28.282Z" },
4471
+ { url = "https://files.pythonhosted.org/packages/0a/86/1d0d905d659850dad7f59c807c130249fdb204dc6f71f1fb36268f3f3e61/xxhash-3.7.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6741564a923f082f3c2941c8bb920462ed5b25eaebdd1e161f162233c9a10bc5", size = 284580, upload-time = "2026-04-25T11:05:30.116Z" },
4472
+ { url = "https://files.pythonhosted.org/packages/1f/52/fc01ca7ff425a9bdb38d9e3a17f2630447ce3b45d45a929a6cd94d469334/xxhash-3.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4fd8acc6e32596350619896feb372033c0920975992d29837c32853bb1feacd", size = 210193, upload-time = "2026-04-25T11:05:31.969Z" },
4473
+ { url = "https://files.pythonhosted.org/packages/ec/96/122e0c6a3537a54b30752031dca557182576bae1a4171c0be8c532c84496/xxhash-3.7.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:646a69b56d8145d85f7fd2289d14fba07880c8a5bda406aa256b407481a61f35", size = 241094, upload-time = "2026-04-25T11:05:33.651Z" },
4474
+ { url = "https://files.pythonhosted.org/packages/d8/17/92e33338db8c18add33a46b56c2b7d5dcc6cc2ac076c45389f6017b1bf37/xxhash-3.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:11dd69b1a34b7b9af29012f390825b0cdb0617c0966560e227ca74daa7478ba9", size = 197721, upload-time = "2026-04-25T11:05:35.387Z" },
4475
+ { url = "https://files.pythonhosted.org/packages/c7/04/fd4114a0820913f336bef5c82ef851bde8d06270982ebd7b2a859961bbf2/xxhash-3.7.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:01cf5c5333aed26cc8d5eea33b8d6398e085e365a704b7372fabdf7ab06441a9", size = 210073, upload-time = "2026-04-25T11:05:37.405Z" },
4476
+ { url = "https://files.pythonhosted.org/packages/dd/eb/a2472b8b81cd576a9af3a4889ad8ba5784e8c5a04592587056cdaededd6c/xxhash-3.7.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f1e65d52c2d526734abecb98372c256b7eacce8fdc42e0df8570417fb39e2772", size = 274960, upload-time = "2026-04-25T11:05:39.224Z" },
4477
+ { url = "https://files.pythonhosted.org/packages/3d/d3/493afc544aae50b5fb2844ceaeb3697283bb59695db1a7cb40448636de05/xxhash-3.7.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8ff00fcc3eb436617ed8556cf15daf76c2b501248361a065625a588af78a0a02", size = 413113, upload-time = "2026-04-25T11:05:40.669Z" },
4478
+ { url = "https://files.pythonhosted.org/packages/50/6a/002800845a22bff32bcf5fd09caceb4d3f5c3da6b754c46edb9743ce908b/xxhash-3.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b5cd29840505631c6f7dbb8a5d34b742b5e6bbda38fe0b9f54e825f3ea6b61dc", size = 190677, upload-time = "2026-04-25T11:05:42.403Z" },
4479
+ { url = "https://files.pythonhosted.org/packages/f4/0f/86ee514622a381c0dc49167c8d431a22aa93518a4063559c3e36e4b82bc8/xxhash-3.7.0-cp310-cp310-win32.whl", hash = "sha256:5bf2f1940499839b39fef1561b5ecb6ede9ac34ef4457474e1337fc7ef07c2f3", size = 30627, upload-time = "2026-04-25T11:05:44.022Z" },
4480
+ { url = "https://files.pythonhosted.org/packages/86/45/2ef2310803efb4a2d07844e8098d797e25702024793aa2e85858623a43b5/xxhash-3.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:d41fcda2fa8ca682ebca134a2f2dc02575ba549267585597e73061565795f475", size = 31463, upload-time = "2026-04-25T11:05:45.218Z" },
4481
+ { url = "https://files.pythonhosted.org/packages/9e/75/40dbf8f142baf8993c38cd988c8d8f51fe0c51e6c84c5769a3c0280a651d/xxhash-3.7.0-cp310-cp310-win_arm64.whl", hash = "sha256:a845a59664d5c531525a467470220f8edc37959e0a6f8e734ffb6654da5c4bee", size = 27747, upload-time = "2026-04-25T11:05:46.422Z" },
4482
+ { url = "https://files.pythonhosted.org/packages/3b/f4/7bd35089ff1f8e2c96baa2dce05775a122aacd2e3830a73165e27a4d0848/xxhash-3.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fdc7d06929ae28dda98297a18eef7b0fd38991a3b405d8d7b55c9ef24c296958", size = 33423, upload-time = "2026-04-25T11:05:47.628Z" },
4483
+ { url = "https://files.pythonhosted.org/packages/a3/26/4e00c88a6a2c8a759cfb77d2a9a405f901e8aa66e60ef1fd0aeb35edda48/xxhash-3.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea6daa712f4e094a30830cf01e9b47d03b24d05cc9dab8609f0d9a9db8454712", size = 30857, upload-time = "2026-04-25T11:05:49.189Z" },
4484
+ { url = "https://files.pythonhosted.org/packages/82/2f/eeb942c17a5a761a8f01cb9180a0b76bfb62a2c39e6f46b1f9001899027a/xxhash-3.7.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9e6c0d843f1daf85ea23aeb053579135552bde575b7b98af20bfc667b6e4548d", size = 194702, upload-time = "2026-04-25T11:05:50.457Z" },
4485
+ { url = "https://files.pythonhosted.org/packages/0e/fd/96f132c08b1e5951c68691d3b9ec351ec2edc028f6a01fcd294f46b9d9f0/xxhash-3.7.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:363c139bf15e1ac5f136b981d3c077eb551299b1effede7f12faa010b8590a60", size = 213613, upload-time = "2026-04-25T11:05:52.571Z" },
4486
+ { url = "https://files.pythonhosted.org/packages/82/89/d4e92b796c5ed052d29ed324dbfc1dc1188e0c4bf64bebbf0f8fc20698df/xxhash-3.7.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a778b25874cb0f862eaab5986bff4ca49ffb0def7c0a34c237b948b3c6c775b2", size = 236726, upload-time = "2026-04-25T11:05:54.395Z" },
4487
+ { url = "https://files.pythonhosted.org/packages/40/f1/81fc4361921dc6e557a9c60cb3712f36d244d06eeeb71cd2f4252ac42678/xxhash-3.7.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3e1860f1e43d40e9d904cf22d93e587ea42e010ebce4160877e46bcab4bc232a", size = 212443, upload-time = "2026-04-25T11:05:56.334Z" },
4488
+ { url = "https://files.pythonhosted.org/packages/6a/d0/afeddd4cff50a332f50d4b8a2e8857673153ab0564ef472fcdeb0b5430df/xxhash-3.7.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9122ad6f867c4a0f5e655f5c3bdf89103852009dbb442a3d23e688b9e699e800", size = 445793, upload-time = "2026-04-25T11:05:58.953Z" },
4489
+ { url = "https://files.pythonhosted.org/packages/f7/d0/3c91e4e6a05ca4d7df8e39ec3a75b713609258ec84705ab34be6430826a1/xxhash-3.7.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d9110d0c3fb02679972837a033251fd186c529aa62f19c132fc909c74052b8", size = 193937, upload-time = "2026-04-25T11:06:00.546Z" },
4490
+ { url = "https://files.pythonhosted.org/packages/4e/3a/a6b0772d9801dd4bea4ca4fd34734d6e9b51a711c8a611a24a79de26a878/xxhash-3.7.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:347a93f2b4ce67ce61959665e32a7447c380f8347e55e100daa23766baacf0e5", size = 285188, upload-time = "2026-04-25T11:06:01.96Z" },
4491
+ { url = "https://files.pythonhosted.org/packages/6c/f8/cf8e31fd7282230fe7367cd501a2e75b4b67b222bfc7eacccfc20d2652cb/xxhash-3.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:acbb48679ddf3852c45280c10ff10d52ca2cd1da2e552fb81db1ff786c75d0e4", size = 210966, upload-time = "2026-04-25T11:06:03.453Z" },
4492
+ { url = "https://files.pythonhosted.org/packages/cc/f0/fd36cc4a81bf52ee5633275daae2b93dd958aace67fd4f5d466ec83b5f35/xxhash-3.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:fe14c356f8b23ad811dc026077a6d4abccdaa7bce5ca98579605550657b6fcfb", size = 241994, upload-time = "2026-04-25T11:06:05.264Z" },
4493
+ { url = "https://files.pythonhosted.org/packages/08/e1/67f5d9c9369be42eaf99ba02c01bf14c5ecd67087b02567960bfcee43b63/xxhash-3.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f420ad3d41e38194353a498bbc9561fd5a9973a27b536ce46d8583479cf44335", size = 198707, upload-time = "2026-04-25T11:06:07.044Z" },
4494
+ { url = "https://files.pythonhosted.org/packages/50/17/a4c865ca22d2da6b1bc7d739bf88cab209533cf52ba06ca9da27c3039bee/xxhash-3.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:693d02c6dc7d1aa0a45921d54cd8c1ff629e09dfdc2238471507af1f7a1c6f04", size = 210917, upload-time = "2026-04-25T11:06:08.853Z" },
4495
+ { url = "https://files.pythonhosted.org/packages/49/8b/453b35810d697abac3c96bde3528bece685869227da274eb80a4a4d4a119/xxhash-3.7.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:14bf7a54e43825ec131ee7fe3c60e142e7c2c1e676ad0f93fc893432d15414af", size = 275772, upload-time = "2026-04-25T11:06:10.645Z" },
4496
+ { url = "https://files.pythonhosted.org/packages/b5/ad/4eed7eab07fd3ee6678f416190f0413d097ab5d7c1278906bf1e9549d789/xxhash-3.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ae3a39a4d96bdb6f8d154fd7f490c4ad06f0532fcd2bb656052a9a7762cf5d31", size = 414068, upload-time = "2026-04-25T11:06:12.511Z" },
4497
+ { url = "https://files.pythonhosted.org/packages/d3/4e/fd6f8a680ba248fdb83054fa71a8bfa3891225200de1708b888ef2c49829/xxhash-3.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1cc07c639e3a77ef1d32987464d3e408565b8a3be57b545d3542b191054d9923", size = 191459, upload-time = "2026-04-25T11:06:14.07Z" },
4498
+ { url = "https://files.pythonhosted.org/packages/50/7c/8cb34b3bed4f44ca6827a534d50833f9bc6c006e83b0eb410ac9fa0793bd/xxhash-3.7.0-cp311-cp311-win32.whl", hash = "sha256:3281ba1d1e60ee7a382a7b958513ba03c2c0d5fcbd9a6f7517c0a81251a23422", size = 30628, upload-time = "2026-04-25T11:06:15.802Z" },
4499
+ { url = "https://files.pythonhosted.org/packages/0b/47/a49767bd7b40782bedae9ff0721bfe1d7e4dd9dc1585dea684e57ba67c20/xxhash-3.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:a7f25baec4c5d851d40718d6fae52285b31683093d4ff5207e63ab306ccf14a5", size = 31461, upload-time = "2026-04-25T11:06:17.104Z" },
4500
+ { url = "https://files.pythonhosted.org/packages/7c/c6/3957bfacfb706bd687be246dfa8dd60f8df97c44186d229f7fd6e26c4b7e/xxhash-3.7.0-cp311-cp311-win_arm64.whl", hash = "sha256:4c2454448ce847c72635827bb75c15c5a3434b03ee1afd28cb6dc6fb2597d830", size = 27746, upload-time = "2026-04-25T11:06:18.716Z" },
4501
+ { url = "https://files.pythonhosted.org/packages/f2/8a/51a14cdef4728c6c2337db8a7d8704422cc65676d9199d77215464c880af/xxhash-3.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:082c87bfdd2b9f457606c7a4a53457f4c4b48b0cdc48de0277f4349d79bb3d7a", size = 33357, upload-time = "2026-04-25T11:06:20.44Z" },
4502
+ { url = "https://files.pythonhosted.org/packages/b9/1b/0c2c933809421ffd9bf42b59315552c143c755db5d9a816b2f1ae273e884/xxhash-3.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5e7ce913b61f35b0c1c839a49ac9c8e75dd8d860150688aed353b0ce1bf409d8", size = 30869, upload-time = "2026-04-25T11:06:21.989Z" },
4503
+ { url = "https://files.pythonhosted.org/packages/03/a8/89d5fdd6ee12d70ba99451de46dd0e8010167468dcd913ec855653f4dd50/xxhash-3.7.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3beb1de3b1e9694fcdd853e570ee64c631c7062435d2f8c69c1adf809bc086f0", size = 194100, upload-time = "2026-04-25T11:06:23.586Z" },
4504
+ { url = "https://files.pythonhosted.org/packages/87/ee/2f9f2ed993e77206d1e66991290a1ebe22e843351ca3ebec8e49e01ba186/xxhash-3.7.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3e7b689c3bce16699efcf736066f5c6cc4472c3840fe4b22bd8279daf4abdac", size = 212977, upload-time = "2026-04-25T11:06:25.019Z" },
4505
+ { url = "https://files.pythonhosted.org/packages/de/60/5a91644615a9e9d4e42c2e9925f1908e3a24e4e691d9de7340d565bea024/xxhash-3.7.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a6545e6b409e3d5cbafc850fb84c55a1ca26ed15a6b11e3bf07a0e0cd84517c8", size = 236373, upload-time = "2026-04-25T11:06:26.482Z" },
4506
+ { url = "https://files.pythonhosted.org/packages/22/c0/f3a9384eaaed9d14d4d062a5d953aa0da489bfe9747877aa994caa87cd0b/xxhash-3.7.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:31ab1461c77a11461d703c88eb949e132a1c6515933cf675d97ec680f4bd18de", size = 212229, upload-time = "2026-04-25T11:06:28.065Z" },
4507
+ { url = "https://files.pythonhosted.org/packages/2e/67/02f07a9fd79726804190f2172c4894c3ed9a4ebccaca05653c84beb58025/xxhash-3.7.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c4d596b7676f811172687ec567cbafb9e4dea2f9be1bbb4f622410cb7f40f40", size = 445462, upload-time = "2026-04-25T11:06:30.048Z" },
4508
+ { url = "https://files.pythonhosted.org/packages/40/37/558f5a90c0672fc9b4402dc25d87ac5b7406616e8969430c9ca4e52ee74d/xxhash-3.7.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13805f0461cba0a857924e70ff91ae6d52d2598f79a884e788db80532614a4a1", size = 193932, upload-time = "2026-04-25T11:06:31.857Z" },
4509
+ { url = "https://files.pythonhosted.org/packages/d5/90/aaa09cd58661d32044dbbad7df55bbe22a623032b810e7ed3b8c569a2a6f/xxhash-3.7.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d398f372496152f1c6933a33566373f8d1b37b98b8c9d608fa6edc0976f23b2", size = 284807, upload-time = "2026-04-25T11:06:33.697Z" },
4510
+ { url = "https://files.pythonhosted.org/packages/d6/f3/53df3719ab127a02c174f0c1c74924fcd110866e89c966bc7909cfa8fa84/xxhash-3.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d610aa62cdb7d4d497740741772a24a794903bf3e79eaa51d2e800082abe11e5", size = 210445, upload-time = "2026-04-25T11:06:35.488Z" },
4511
+ { url = "https://files.pythonhosted.org/packages/72/33/d219975c0e8b6fa2eb9ccd486fe47e21bf1847985b878dd2fbc3126e0d5c/xxhash-3.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:073c23900a9fbf3d26616c17c830db28af9803677cd5b33aea3224d824111514", size = 241273, upload-time = "2026-04-25T11:06:37.24Z" },
4512
+ { url = "https://files.pythonhosted.org/packages/3e/50/49b1afe610eb3964cedcb90a4d4c3d46a261ee8669cbd4f060652619ae3c/xxhash-3.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:418a463c3e6a590c0cdc890f8be19adb44a8c8acd175ca5b2a6de77e61d0b386", size = 197950, upload-time = "2026-04-25T11:06:39.148Z" },
4513
+ { url = "https://files.pythonhosted.org/packages/c6/75/5f42a1a4c78717d906a4b6a140c6dbf837ab1f547a54d23c4e2903310936/xxhash-3.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:03f8ff4474ee61c845758ce00711d7087a770d77efb36f7e74a6e867301000b8", size = 210709, upload-time = "2026-04-25T11:06:40.958Z" },
4514
+ { url = "https://files.pythonhosted.org/packages/8a/85/237e446c25abced71e9c53d269f2cef5bab8a82b3f88a12e00c5368e7368/xxhash-3.7.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:44fba4a5f1d179b7ddc7b3dc40f56f9209046421679b57025d4d8821b376fd8d", size = 275345, upload-time = "2026-04-25T11:06:42.525Z" },
4515
+ { url = "https://files.pythonhosted.org/packages/62/34/c2c26c0a6a9cc739bc2a5f0ae03ba8b87deb12b8bce35f7ac495e790dc6d/xxhash-3.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31e3516a0f829d06ded4a2c0f3c7c5561993256bfa1c493975fb9dc7bfa828a1", size = 414056, upload-time = "2026-04-25T11:06:44.343Z" },
4516
+ { url = "https://files.pythonhosted.org/packages/a0/aa/5c58e9bc8071b8afd8dcf297ff362f723c4892168faba149f19904132bf4/xxhash-3.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b59ee2ac81de57771a09ecad09191e840a1d2fae1ef684208320591055768f83", size = 191485, upload-time = "2026-04-25T11:06:46.262Z" },
4517
+ { url = "https://files.pythonhosted.org/packages/d4/69/a929cf9d1e2e65a48b818cdce72cb6b69eab2e6877f21436d0a1942aff43/xxhash-3.7.0-cp312-cp312-win32.whl", hash = "sha256:74bbd92f8c7fcc397ba0a11bfdc106bc72ad7f11e3a60277753f87e7532b4d81", size = 30671, upload-time = "2026-04-25T11:06:48.039Z" },
4518
+ { url = "https://files.pythonhosted.org/packages/b9/1b/104b41a8947f4e1d4a66ce1e628eea752f37d1890bfd7453559ca7a3d950/xxhash-3.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:7bd7bc82dd4f185f28f35193c2e968ef46131628e3cac62f639dadf321cba4d1", size = 31514, upload-time = "2026-04-25T11:06:49.279Z" },
4519
+ { url = "https://files.pythonhosted.org/packages/98/a0/1fd0ea1f1b886d9e7c73f0397571e22333a7d79e31da6d7127c2a4a71d75/xxhash-3.7.0-cp312-cp312-win_arm64.whl", hash = "sha256:7d7148180ec99ba36585b42c8c5de25e9b40191613bc4be68909b4d25a77a852", size = 27761, upload-time = "2026-04-25T11:06:50.448Z" },
4520
+ { url = "https://files.pythonhosted.org/packages/c1/ca/d5174b4c36d10f64d4ca7050563138c5a599efb01a765858ddefc9c1202a/xxhash-3.7.0-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:4b6d6b33f141158692bd4eafbb96edbc5aa0dabdb593a962db01a91983d4f8fa", size = 36813, upload-time = "2026-04-25T11:06:51.73Z" },
4521
+ { url = "https://files.pythonhosted.org/packages/41/d0/abc6c9d347ba1f1e1e1d98125d0881a0452c7f9a76a9dd03a7b5d2197f23/xxhash-3.7.0-cp313-cp313-android_21_x86_64.whl", hash = "sha256:845d347df254d6c619f616afa921331bada8614b8d373d58725c663ba97c3605", size = 35121, upload-time = "2026-04-25T11:06:53.048Z" },
4522
+ { url = "https://files.pythonhosted.org/packages/bf/11/4cc834eb3d79f2f2b3a6ef7324195208bcdfbdcf7534d2b17267aa5f3a8f/xxhash-3.7.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:fddbbb69a6fff4f421e7a0d1fa28f894b20112e9e3fab306af451e2dfd0e459b", size = 29624, upload-time = "2026-04-25T11:06:54.311Z" },
4523
+ { url = "https://files.pythonhosted.org/packages/23/83/e97d3e7b635fe73a1dfb1e91f805324dd6d930bb42041cbf18f183bc0b6d/xxhash-3.7.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:54876a4e45101cec2bf8f31a973cda073a23e2e108538dad224ba07f85f22487", size = 30638, upload-time = "2026-04-25T11:06:55.864Z" },
4524
+ { url = "https://files.pythonhosted.org/packages/f4/40/d84951d80c35db1f4c40a29a64a8520eea5d56e764c603906b4fe763580f/xxhash-3.7.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:0c72fe9c7e3d6dfd7f1e21e224a877917fa09c465694ba4e06464b9511b65544", size = 33323, upload-time = "2026-04-25T11:06:57.336Z" },
4525
+ { url = "https://files.pythonhosted.org/packages/89/cc/c7dc6558d97e9ab023f663d69ab28b340ed9bf4d2d94f2c259cf896bb354/xxhash-3.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a6d73a830b17ef49bc04e00182bd839164c1b3c59c127cd7c54fcb10c7ed8ee8", size = 33362, upload-time = "2026-04-25T11:06:58.656Z" },
4526
+ { url = "https://files.pythonhosted.org/packages/2a/6e/46b84017b1301d54091430353d4ad5901654a3e0871649877a416f7f1644/xxhash-3.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:91c3b07cf3362086d8f126c6aecd8e5e9396ad8b2f2219ea7e49a8250c318acd", size = 30874, upload-time = "2026-04-25T11:06:59.834Z" },
4527
+ { url = "https://files.pythonhosted.org/packages/df/5e/8f9158e3ab906ad3fec51e09b5ea0093e769f12207bfa42a368ca204e7ab/xxhash-3.7.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:50e879ebbac351c81565ca108db766d7832f5b8b6a5b14b8c0151f7190028e3d", size = 194185, upload-time = "2026-04-25T11:07:01.658Z" },
4528
+ { url = "https://files.pythonhosted.org/packages/f3/29/a804ded9f5d3d3758292678d23e7528b08fda7b7e750688d08b052322475/xxhash-3.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:921c14e93817842dd0dd9f372890a0f0c72e534650b6ab13c5be5cd0db11d47e", size = 213033, upload-time = "2026-04-25T11:07:03.606Z" },
4529
+ { url = "https://files.pythonhosted.org/packages/8b/91/1ce5a7d2fdc975267320e2c78fc1cecfe7ab735ccbcf6993ec5dd541cb2c/xxhash-3.7.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e64a7c9d7dfca3e0fafcbc5e455519090706a3e36e95d655cec3e04e79f95aaa", size = 236140, upload-time = "2026-04-25T11:07:05.396Z" },
4530
+ { url = "https://files.pythonhosted.org/packages/34/04/fd595a4fd8617b05fa27bd9b684ecb4985bfed27917848eea85d54036d06/xxhash-3.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2220af08163baf5fa36c2b8af079dc2cbe6e66ae061385267f9472362dfd53c6", size = 212291, upload-time = "2026-04-25T11:07:06.966Z" },
4531
+ { url = "https://files.pythonhosted.org/packages/03/fb/f1a379cbc372ae5b9f4ab36154c48a849ca6ebe3ac477067a57865bf3bc6/xxhash-3.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f14bb8b22a4a91325813e3d553b8963c10cf8c756cff65ee50c194431296c655", size = 445532, upload-time = "2026-04-25T11:07:08.525Z" },
4532
+ { url = "https://files.pythonhosted.org/packages/65/59/172424b79f8cfd4b6d8a122b2193e6b8ad4b11f7159bb3b6f9b3191329bb/xxhash-3.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:496736f86a9bedaf64b0dc70e3539d0766df01c71ea22032698e88f3f04a1ce9", size = 193990, upload-time = "2026-04-25T11:07:10.315Z" },
4533
+ { url = "https://files.pythonhosted.org/packages/b9/19/aeac22161d953f139f07ba5586cb4a17c5b7b6dff985122803bb12933500/xxhash-3.7.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0ff71596bd79816975b3de7130ab1ff4541410285a3c084584eeb1c8239996fd", size = 284876, upload-time = "2026-04-25T11:07:12.15Z" },
4534
+ { url = "https://files.pythonhosted.org/packages/77/d5/4fd0b59e7a02242953da05ff679fbb961b0a4368eac97a217e11dae110c1/xxhash-3.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1ad86695c19b1d46fe106925db3c7a37f16be37669dcf58dcc70a9dd6e324676", size = 210495, upload-time = "2026-04-25T11:07:13.952Z" },
4535
+ { url = "https://files.pythonhosted.org/packages/aa/fb/976a3165c728c7faf74aa1b5ab3cf6a85e6d731612894741840524c7d28c/xxhash-3.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:970f9f8c50961d639cbd0d988c96f80ddf66006de93641719282c4fe7a87c5e6", size = 241331, upload-time = "2026-04-25T11:07:15.557Z" },
4536
+ { url = "https://files.pythonhosted.org/packages/4a/2c/6763d5901d53ac9e6ba296e5717ae599025c9d268396e8faa8b4b0a8e0ac/xxhash-3.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5886ad85e9e347911783760a1d16cb6b393e8f9e3b52c982568226cb56927bdc", size = 198037, upload-time = "2026-04-25T11:07:17.563Z" },
4537
+ { url = "https://files.pythonhosted.org/packages/61/2b/876e722d533833f5f9a83473e6ba993e48745701096944e77bbecf29b2c3/xxhash-3.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:6e934bbae1e0ec74e27d5f0d7f37ef547ce5ff9f0a7e63fb39e559fc99526734", size = 210744, upload-time = "2026-04-25T11:07:19.055Z" },
4538
+ { url = "https://files.pythonhosted.org/packages/21/e6/d7e7baef7ce24166b4668d3c48557bb35a23b92ecadcac7e7718d099ab69/xxhash-3.7.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:3b6b3d28228af044ebcded71c4a3dd86e1dbd7e2f4645bf40f7b5da65bb5fb5a", size = 275406, upload-time = "2026-04-25T11:07:20.908Z" },
4539
+ { url = "https://files.pythonhosted.org/packages/92/fe/198b3763b2e01ca908f2154969a2352ec99bda892b574a11a9a151c5ede4/xxhash-3.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:6be4d70d9ab76c9f324ead9c01af6ff52c324745ea0c3731682a0cf99720f1fe", size = 414125, upload-time = "2026-04-25T11:07:23.037Z" },
4540
+ { url = "https://files.pythonhosted.org/packages/3a/6d/019a11affd5a5499137cacca53808659964785439855b5aa40dfd3412916/xxhash-3.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:151d7520838d4465461a0b7f4ae488b3b00de16183dd3214c1a6b14bf89d7fb6", size = 191555, upload-time = "2026-04-25T11:07:24.991Z" },
4541
+ { url = "https://files.pythonhosted.org/packages/76/21/b96d58568df2d01533244c3e0e5cbdd0c8b2b25c4bec4d72f19259a292d7/xxhash-3.7.0-cp313-cp313-win32.whl", hash = "sha256:d798c1e291bffb8e37b5bbe0dda77fc767cd19e89cadaf66e6ed5d0ff88c9fe6", size = 30668, upload-time = "2026-04-25T11:07:26.665Z" },
4542
+ { url = "https://files.pythonhosted.org/packages/99/57/d849a8d3afa1f8f4bc6a831cd89f49f9706fbbad94d2975d6140a171988c/xxhash-3.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:875811ba23c543b1a1c3143c926e43996eb27ebb8f52d3500744aa608c275aed", size = 31524, upload-time = "2026-04-25T11:07:27.92Z" },
4543
+ { url = "https://files.pythonhosted.org/packages/81/52/bacc753e92dee78b058af8dcef0a50815f5f860986c664a92d75f965b6a5/xxhash-3.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:54a675cb300dda83d71daae2a599389d22db8021a0f8db0dd659e14626eb3ecc", size = 27768, upload-time = "2026-04-25T11:07:29.113Z" },
4544
+ { url = "https://files.pythonhosted.org/packages/1c/47/ddbd683b7fc7e592c1a8d9d65f73ce9ab513f082b3967eee2baf549b8fc6/xxhash-3.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a3b19a42111c4057c1547a4a1396a53961dca576a0f6b82bfa88a2d1561764b2", size = 33576, upload-time = "2026-04-25T11:07:30.469Z" },
4545
+ { url = "https://files.pythonhosted.org/packages/07/f2/36d3310161db7f72efb4562aadde0ed429f1d0531782dd6345b12d2da527/xxhash-3.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8f4608a06e4d61b7a3425665a46d00e0579122e1a2fae97a0c52953a3aad9aa3", size = 31123, upload-time = "2026-04-25T11:07:31.989Z" },
4546
+ { url = "https://files.pythonhosted.org/packages/0d/3f/75937a5c69556ed213021e43cbedd84c8e0279d0d74e7d41a255d84ba4b1/xxhash-3.7.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ad37c7792479e49cf96c1ab25517d7003fe0d93687a772ba19a097d235bbe41e", size = 196491, upload-time = "2026-04-25T11:07:33.358Z" },
4547
+ { url = "https://files.pythonhosted.org/packages/22/29/f10d7ff8c7a733d4403a43b9de18c8fabc005f98cec054644f04418659ee/xxhash-3.7.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc026e3b89d98e30a8288c95cb696e77d150b3f0fb7a51f73dcd49ee6b5577fa", size = 215793, upload-time = "2026-04-25T11:07:34.919Z" },
4548
+ { url = "https://files.pythonhosted.org/packages/8b/fd/778f60aa295f58907938f030a8b514611f391405614a525cccd2ffc00eb5/xxhash-3.7.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c9b31ab1f28b078a6a1ac1a54eb35e7d5390deddd56870d0be3a0a733d1c321c", size = 237993, upload-time = "2026-04-25T11:07:36.638Z" },
4549
+ { url = "https://files.pythonhosted.org/packages/70/f5/736db5de387b4a540e37a05b84b40dc58a1ce974bfd2b4e5754ce29b68c3/xxhash-3.7.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3bb5fd680c038fd5229e44e9c493782f90df9bef632fd0499d442374688ff70b", size = 214887, upload-time = "2026-04-25T11:07:38.564Z" },
4550
+ { url = "https://files.pythonhosted.org/packages/4d/aa/09a095f22fdb9a27fbb716841fbff52119721f9ca4261952d07a912f7839/xxhash-3.7.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:030c0fd688fce3569fbb49a2feefd4110cbb0b650186fb4610759ecfac677548", size = 448407, upload-time = "2026-04-25T11:07:40.552Z" },
4551
+ { url = "https://files.pythonhosted.org/packages/74/8a/b745efeeca9e34a91c26fdc97ad8514c43d5a81ac78565cba80a1353870a/xxhash-3.7.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5b1bde10324f4c31812ae0d0502e92d916ae8917cad7209353f122b8b8f610c3", size = 196119, upload-time = "2026-04-25T11:07:42.101Z" },
4552
+ { url = "https://files.pythonhosted.org/packages/8a/5c/0cfceb024af90c191f665c7933b1f318ee234f4797858383bebd1881d52f/xxhash-3.7.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:503722d52a615f2604f5e7611de7d43878df010dc0053094ef91cb9a9ac3d987", size = 286751, upload-time = "2026-04-25T11:07:43.568Z" },
4553
+ { url = "https://files.pythonhosted.org/packages/0b/0a/0793e405dc3cf8f4ebe2c1acec1e4e4608cd9e7e50ea691dabbc2a95ccbb/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c72500a3b6d6c30ebfc135035bcace9eb5884f2dc220804efcaaba43e9f611dd", size = 212961, upload-time = "2026-04-25T11:07:45.388Z" },
4554
+ { url = "https://files.pythonhosted.org/packages/0c/7e/721118ffc63bfff94aa565bcf2555a820f9f4bdb0f001e0d609bdfad70de/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:43475925a766d01ca8cd9a857fd87f3d50406983c8506a4c07c4df12adcc867f", size = 243703, upload-time = "2026-04-25T11:07:47.053Z" },
4555
+ { url = "https://files.pythonhosted.org/packages/6e/18/16f6267160488b8276fd3d449d425712512add292ba545c1b6946bfdb7dd/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8d09dfd2ab135b985daf868b594315ebe11ad86cd9fea46e6c69f19b28f7d25a", size = 200894, upload-time = "2026-04-25T11:07:48.657Z" },
4556
+ { url = "https://files.pythonhosted.org/packages/2d/94/80ba841287fd97e3e9cac1d228788c8ef623746f570404961eec748ecb5c/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c50269d0055ac1faecfd559886d2cbe4b730de236585aba0e873f9d9dadbe585", size = 213357, upload-time = "2026-04-25T11:07:50.257Z" },
4557
+ { url = "https://files.pythonhosted.org/packages/a1/7e/106d4067130c59f1e18a55ffadcd876d8c68534883a1e02685b29d3d8153/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:1910df4756a5ab58cfad8744fc2d0f23926e3efcc346ee76e87b974abab922f4", size = 277600, upload-time = "2026-04-25T11:07:51.745Z" },
4558
+ { url = "https://files.pythonhosted.org/packages/c5/86/a081dd30da71d720b2612a792bfd55e45fa9a07ac76a0507f60487473c25/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:d006faf3b491957efcb433489be3c149efe4787b7063d5cddb8ddaefdc60e0c1", size = 416980, upload-time = "2026-04-25T11:07:53.504Z" },
4559
+ { url = "https://files.pythonhosted.org/packages/35/29/1a95221a029a3c1293773869e1ab47b07cbbdd82444a42809e8c60156626/xxhash-3.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:abb65b4e947e958f7b3b0d71db3ce447d1bc5f37f5eab871ce7223bda8768a04", size = 193840, upload-time = "2026-04-25T11:07:55.103Z" },
4560
+ { url = "https://files.pythonhosted.org/packages/c5/e0/db909dd0823285de2286f67e10ee4d81e96ad35d7d8e964ecb07fccd8af9/xxhash-3.7.0-cp313-cp313t-win32.whl", hash = "sha256:178959906cb1716a1ce08e0d69c82886c70a15a6f2790fc084fdd146ca30cd49", size = 30966, upload-time = "2026-04-25T11:07:56.524Z" },
4561
+ { url = "https://files.pythonhosted.org/packages/7b/ff/d705b15b22f21ee106adce239cb65d35067a158c630b240270f09b17c2e6/xxhash-3.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2524a1e20d4c231d13b50f7cf39e44265b055669a64a7a4b9a2a44faa03f19b6", size = 31784, upload-time = "2026-04-25T11:07:57.758Z" },
4562
+ { url = "https://files.pythonhosted.org/packages/a2/1f/b2cf83c3638fd0588e0b17f22e5a9400bdfb1a3e3755324ac0aee2250b88/xxhash-3.7.0-cp313-cp313t-win_arm64.whl", hash = "sha256:37d994d0ffe81ef087bb330d392caa809bb5853c77e22ea3f71db024a0543dba", size = 27932, upload-time = "2026-04-25T11:07:59.109Z" },
4563
+ { url = "https://files.pythonhosted.org/packages/0e/cc/431db584f6fbb9312e40a173af027644e5580d39df1f73603cbb9dca4d6b/xxhash-3.7.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:8c5fcfd806c335bfa2adf1cd0b3110a44fc7b6995c3a648c27489bae85801465", size = 36644, upload-time = "2026-04-25T11:08:00.658Z" },
4564
+ { url = "https://files.pythonhosted.org/packages/bc/01/255ec513e0a705d1f9a61413e78dfce4e3235203f0ed525a24c2b4b56345/xxhash-3.7.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:506a0b488f190f0a06769575e30caf71615c898ed93ab18b0dbcb6dec5c3713c", size = 35003, upload-time = "2026-04-25T11:08:02.338Z" },
4565
+ { url = "https://files.pythonhosted.org/packages/68/70/c55fc33c93445b44d8fc5a17b41ed99e3cebe92bcf8396809e63fc9a1165/xxhash-3.7.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:ec68dbba21532c0173a9872298e65c89749f7c9d21538c3a78b5bb6105871568", size = 29655, upload-time = "2026-04-25T11:08:03.701Z" },
4566
+ { url = "https://files.pythonhosted.org/packages/c2/72/ff8de73df000d74467d12a59ce6d6e2b2a368b978d41ab7b1fba5ed442be/xxhash-3.7.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:fa77e7ec1450d415d20129961814787c9abd9a07f98872f070b1fe96c5084611", size = 30664, upload-time = "2026-04-25T11:08:05.011Z" },
4567
+ { url = "https://files.pythonhosted.org/packages/b6/91/08416d9bd9bc3bf39d831abe8a5631ac2db5141dfd6fe81c3fe59a1f9264/xxhash-3.7.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:fe32736295ea38e43e7d9424053c8c47c9f64fecfc7c895fb3da9b30b131c9ee", size = 33317, upload-time = "2026-04-25T11:08:06.413Z" },
4568
+ { url = "https://files.pythonhosted.org/packages/0e/3b/86b1caa4dee10a99f4bf9521e623359341c5e50d05158fa10c275b2bd079/xxhash-3.7.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ab9dd2c83c4bbd63e422181a76f13502d049d3ddcac9a1bdc29196263d692bb8", size = 33457, upload-time = "2026-04-25T11:08:08.099Z" },
4569
+ { url = "https://files.pythonhosted.org/packages/ed/38/98ea14ad1517e1461292a65906951458d520689782bfbae111050145bdba/xxhash-3.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3afec3a336a2286601a437cb07562ab0227685e6fbb9ec17e8c18457ff348ecf", size = 30894, upload-time = "2026-04-25T11:08:09.429Z" },
4570
+ { url = "https://files.pythonhosted.org/packages/61/a2/074654d0b893606541199993c7db70067d9fc63b748e0d60020a52a1bd36/xxhash-3.7.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:565df64437a9390f84465dcca33e7377114c7ede8d05cd2cf20081f831ea788e", size = 194409, upload-time = "2026-04-25T11:08:10.91Z" },
4571
+ { url = "https://files.pythonhosted.org/packages/e2/26/6d2a1afc468189f77ca28c32e1c83e1b9da1178231e05641dbc1b350e332/xxhash-3.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12eca820a5d558633d423bf8bb78ce72a55394823f64089247f788a7e0ae691e", size = 213135, upload-time = "2026-04-25T11:08:12.575Z" },
4572
+ { url = "https://files.pythonhosted.org/packages/8e/0e/d8aecf95e09c42547453137be74d2f7b8b14e08f5177fa2fab6144a19061/xxhash-3.7.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f262b8f7599516567e070abf607b9af649052b2c4bd6f9be02b0cb41b7024805", size = 236379, upload-time = "2026-04-25T11:08:14.206Z" },
4573
+ { url = "https://files.pythonhosted.org/packages/f2/74/8140e8210536b3dd0cc816c4faaeb5ba6e63e8125ab25af4bcddd6a037b3/xxhash-3.7.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1598916cb197681e03e601901e4ab96a9a963de398c59d0964f8a6f44a2b361", size = 212447, upload-time = "2026-04-25T11:08:15.79Z" },
4574
+ { url = "https://files.pythonhosted.org/packages/a0/d2/462001d2903b4bee5a5689598a0a55e5e7cd1ac7f4247a5545cff10d3ebb/xxhash-3.7.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:322b2f0622230f526aeb1738149948a7ae357a9e2ceb1383c6fd1fdaecdafa16", size = 445660, upload-time = "2026-04-25T11:08:17.441Z" },
4575
+ { url = "https://files.pythonhosted.org/packages/23/09/2bd1ed7f8689b20e51727952cac8329d50c694dc32b2eba06ba5bc742b37/xxhash-3.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24cc22070880cc57b830a65cde4e65fa884c6d9b28ae4803b5ee05911e7bafba", size = 194076, upload-time = "2026-04-25T11:08:19.134Z" },
4576
+ { url = "https://files.pythonhosted.org/packages/c9/6e/692302cd0a5f4ac4e6289f37fa888dc2e1e07750b68fe3e4bfe939b8cea3/xxhash-3.7.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb5a888a968b2434abf9ecda357b5d43f10d7b5a6da6fdbbe036208473aff0e2", size = 284990, upload-time = "2026-04-25T11:08:20.618Z" },
4577
+ { url = "https://files.pythonhosted.org/packages/05/d9/e54b159b3d9df7999d2a7c676ce7b323d1b5588a64f8f51ed8172567bd87/xxhash-3.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a999771ff97bec27d18341be4f3a36b163bb1ac41ec17bef6d2dabd84acd33c7", size = 210590, upload-time = "2026-04-25T11:08:22.24Z" },
4578
+ { url = "https://files.pythonhosted.org/packages/50/93/0e0df1a3a196ced4ca71de76d65ead25d8e87bbfb87b64306ea47a40c00d/xxhash-3.7.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ed4a6efe2dee1655adb73e7ad40c6aa955a6892422b1e3b95de6a34de56e3cbb", size = 241442, upload-time = "2026-04-25T11:08:23.844Z" },
4579
+ { url = "https://files.pythonhosted.org/packages/9a/a9/d917a7a814e90b218f8a0d37967105eea91bf752c3303683c99a1f7bfc1f/xxhash-3.7.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9fd17f14ac0faa12126c2f9ca774a8cf342957265ec3c8669c144e5e6cdb478c", size = 198356, upload-time = "2026-04-25T11:08:25.99Z" },
4580
+ { url = "https://files.pythonhosted.org/packages/89/5e/f2ba1877c39469abbefc72991d6ebdcbd4c0880db01ae8cb1f553b0c537d/xxhash-3.7.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:05fd1254268c59b5cb2a029dfc204275e9fc52de2913f1e53aa8d01442c96b4d", size = 210898, upload-time = "2026-04-25T11:08:27.608Z" },
4581
+ { url = "https://files.pythonhosted.org/packages/90/c6/be56b58e73de531f39a10de1355bb77ceb663900dc4bf2d6d3002a9c3f9e/xxhash-3.7.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:a2eae53197c6276d5b317f75a1be226bbf440c20b58bf525f36b5d0e1f657ca6", size = 275519, upload-time = "2026-04-25T11:08:29.301Z" },
4582
+ { url = "https://files.pythonhosted.org/packages/92/e2/17ddc85d5765b9c709f192009ed8f5a1fc876f4eb35bba7c307b5b1169f9/xxhash-3.7.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:bfe6f92e3522dcbe8c4281efd74fa7542a336cb00b0e3272c4ec0edabeaeaf67", size = 414191, upload-time = "2026-04-25T11:08:31.16Z" },
4583
+ { url = "https://files.pythonhosted.org/packages/9c/42/85f5b79f4bf1ec7ba052491164adfd4f4e9519f5dc7246de4fbd64a1bd56/xxhash-3.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7ab9a49c410d8c6c786ab99e79c529938d894c01433130353dd0fe999111077a", size = 191604, upload-time = "2026-04-25T11:08:32.862Z" },
4584
+ { url = "https://files.pythonhosted.org/packages/b8/d0/6127b623aa4cca18d8b7743592b048d689fd6c6e37ff26a22cddf6cd9d7f/xxhash-3.7.0-cp314-cp314-win32.whl", hash = "sha256:040ea63668f9185b92bc74942df09c7e65703deed71431333678fc6e739a9955", size = 31271, upload-time = "2026-04-25T11:08:34.651Z" },
4585
+ { url = "https://files.pythonhosted.org/packages/64/4f/44fc4788568004c43921701cbc127f48218a1eede2c9aea231115323564d/xxhash-3.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:2a61e2a3fb23c892496d587b470dee7fa1b58b248a187719c65ea8e94ec13257", size = 32284, upload-time = "2026-04-25T11:08:35.987Z" },
4586
+ { url = "https://files.pythonhosted.org/packages/6d/77/18bb895eb60a49453d16e17d67990e5caff557c78eafc90ad4e2eabf4570/xxhash-3.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:c7741c7524961d8c0cb4d4c21b28957ff731a3fd5b5cd8b856dc80a40e9e5acc", size = 28701, upload-time = "2026-04-25T11:08:37.767Z" },
4587
+ { url = "https://files.pythonhosted.org/packages/45/a0/46f72244570c550fbbb7db1ef554183dd5ebe9136385f30e032b781ae8f6/xxhash-3.7.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:fc84bf7aa7592f31ec63a3e7b11d624f468a3f19f5238cec7282a42e838ab1d7", size = 33646, upload-time = "2026-04-25T11:08:39.109Z" },
4588
+ { url = "https://files.pythonhosted.org/packages/4a/3a/453846a7eceea11e75def361eed01ec6a0205b9822c19927ed364ccae7cc/xxhash-3.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9f1563fdc8abfc389748e6932c7e4e99c89a53e4ec37d4563c24fc06f5e5644b", size = 31125, upload-time = "2026-04-25T11:08:40.467Z" },
4589
+ { url = "https://files.pythonhosted.org/packages/bd/3e/49434aba738885d512f9e486db1bdd19db28dfa40372b56da26ef7a4e738/xxhash-3.7.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2d415f18becf6f153046ab6adc97da77e3643a0ee205dae61c4012604113a020", size = 196633, upload-time = "2026-04-25T11:08:41.943Z" },
4590
+ { url = "https://files.pythonhosted.org/packages/a4/e9/006cb6127baeb9f8abe6d15e62faa01349f09b34e2bfd65175b2422d026b/xxhash-3.7.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bb16aa13ed175bc9be5c2491ba031b85a9b51c4ed90e0b3d4ebe63cf3fb54f8e", size = 215899, upload-time = "2026-04-25T11:08:43.645Z" },
4591
+ { url = "https://files.pythonhosted.org/packages/27/e4/cc57d72e66df0ae29b914335f1c6dcf61e8f3746ddf0ae3c471aa4f15e00/xxhash-3.7.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f9fd595f1e5941b3d7863e4774e4b30caa6731fc34b9277da032295aa5656ee5", size = 238116, upload-time = "2026-04-25T11:08:45.698Z" },
4592
+ { url = "https://files.pythonhosted.org/packages/af/78/3531d4a3fd8a0038cc6be1f265a69c1b3587f557a10b677dd736de2202c1/xxhash-3.7.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1295325c5a98d552333fa53dc2b026b0ef0ec9c8e73ca3a952990b4c7d65d459", size = 215012, upload-time = "2026-04-25T11:08:47.355Z" },
4593
+ { url = "https://files.pythonhosted.org/packages/b4/f6/259fb1eaaec921f59b17203b0daee69829761226d3b980d5191d7723dd83/xxhash-3.7.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3573a651d146912da9daa9e29e5fbc45994420daaa9ef1e2fa5823e1dc485513", size = 448534, upload-time = "2026-04-25T11:08:49.149Z" },
4594
+ { url = "https://files.pythonhosted.org/packages/7b/16/a66d0eaf6a7e68532c07714361ddc904c663ec940f3b028c1ae4a21a7b9d/xxhash-3.7.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ec1e080a3d02d94ea9335bfab0e3374b877e25411422c18f51a943fa4b46381", size = 196217, upload-time = "2026-04-25T11:08:50.805Z" },
4595
+ { url = "https://files.pythonhosted.org/packages/8d/ef/d2efc7fc51756dc52509109d1a25cefc859d74bc4b19a167b12dbd8c2786/xxhash-3.7.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84415265192072d8638a3afc3c1bc5995e310570cd9acb54dc46d3939e364fe0", size = 286906, upload-time = "2026-04-25T11:08:52.418Z" },
4596
+ { url = "https://files.pythonhosted.org/packages/fc/67/25decd1d4a4018582ec4db2a868a2b7e40640f4adb20dfeb19ac923aa825/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d4dea659b57443989ef32f4295104fd6912c73d0bf26d1d148bb88a9f159b02", size = 213057, upload-time = "2026-04-25T11:08:54.105Z" },
4597
+ { url = "https://files.pythonhosted.org/packages/0d/5d/17651eb29d06786cdc40c60ae3d27d645aa5d61d2eca6237a7ba0b94789b/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:05ece0fe4d9c9c2728912d1981ae1566cfc83a011571b24732cbf76e1fb70dca", size = 243886, upload-time = "2026-04-25T11:08:56.109Z" },
4598
+ { url = "https://files.pythonhosted.org/packages/8a/d4/174d9cf7502243d586e6a9ae842b1ae23026620995114f85f1380e588bc9/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:fd880353cf1ffaf321bc18dd663e111976dbd0d3bbd8a66d58d2b470dfa7f396", size = 201015, upload-time = "2026-04-25T11:08:57.777Z" },
4599
+ { url = "https://files.pythonhosted.org/packages/91/8c/2254e2d06c3ac5e6fe22eaf3da791b87ea823ae9f2c17b4af66755c5752d/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4e15cc9e2817f6481160f930c62842b3ff419e20e13072bcbab12230943092bc", size = 213457, upload-time = "2026-04-25T11:08:59.826Z" },
4600
+ { url = "https://files.pythonhosted.org/packages/79/a2/e3daa762545921173e3360f3b4ff7fc63c2d27359f7230ec1a7a74e117f6/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:90b9d1a8bd37d768ffc92a1f651ec69afc532a96fa1ac2ea7abbed5d630b3237", size = 277738, upload-time = "2026-04-25T11:09:01.423Z" },
4601
+ { url = "https://files.pythonhosted.org/packages/e1/4c/e186da2c46b87f5204640e008d42730bf3c1ee9f0efb71ae1ebcdfeac681/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:157c49475b34ecea8809e51123d9769a534e139d1247942f7a4bc67710bb2533", size = 417127, upload-time = "2026-04-25T11:09:03.592Z" },
4602
+ { url = "https://files.pythonhosted.org/packages/17/28/3798e15007a3712d0da3d3fe70f8e11916569858b5cc371053bc26270832/xxhash-3.7.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5a6ddec83325685e729ca119d1f5c518ec39294212ecd770e60693cdc5f7eb79", size = 193962, upload-time = "2026-04-25T11:09:06.228Z" },
4603
+ { url = "https://files.pythonhosted.org/packages/ad/95/a26baa93b5241fd7630998816a4ec47a5a0bad193b3f8fc8f3593e1a4a67/xxhash-3.7.0-cp314-cp314t-win32.whl", hash = "sha256:a04a6cab47e2166435aaf5b9e5ee41d1532cc8300efdef87f2a4d0acb7db19ed", size = 31643, upload-time = "2026-04-25T11:09:08.153Z" },
4604
+ { url = "https://files.pythonhosted.org/packages/44/36/5454f13c447e395f9b06a3e91274c59f503d31fad84e1836efe3bdb71f6a/xxhash-3.7.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8653dd7c2eda020545bb2c71c7f7039b53fe7434d0fc1a0a9deb79ab3f1a4fc1", size = 32522, upload-time = "2026-04-25T11:09:09.534Z" },
4605
+ { url = "https://files.pythonhosted.org/packages/74/35/698e7e3ff38e22992ea24870a511d8762474fb6783627a2910ff22a185c2/xxhash-3.7.0-cp314-cp314t-win_arm64.whl", hash = "sha256:468f0fc114faaa4b36699f8e328bbc3bb11dc418ba94ac52c26dd736d4b6c637", size = 28807, upload-time = "2026-04-25T11:09:11.234Z" },
4606
+ { url = "https://files.pythonhosted.org/packages/54/c1/e57ac7317b1f58a92bab692da6d497e2a7ce44735b224e296347a7ecc754/xxhash-3.7.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ad3aa71e12ee634f22b39a0ff439357583706e50765f17f05550f92dbf128a23", size = 31232, upload-time = "2026-04-25T11:10:21.51Z" },
4607
+ { url = "https://files.pythonhosted.org/packages/4f/4e/075559bd712bc62e84915ea46bbee859f935d285659082c129bdbff679dd/xxhash-3.7.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5de686e73690cdaf72b96d4fa083c230ec9020bcc2627ce6316138e2cf2fe2d1", size = 28553, upload-time = "2026-04-25T11:10:23.1Z" },
4608
+ { url = "https://files.pythonhosted.org/packages/92/ca/a9c78cb384d4b033b0c58196bd5c8509873cabe76389e195127b0302a741/xxhash-3.7.0-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7fbec49f5341bbdea0c471f7d1e2fb41ae8925af9b6f28025c28defd8eb94274", size = 41109, upload-time = "2026-04-25T11:10:25.022Z" },
4609
+ { url = "https://files.pythonhosted.org/packages/bd/b1/dfe2629f7c77eb2fa234c72ff537cdd64939763df704e256446ed364a16d/xxhash-3.7.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48b542c347c2089f43dc5a6db31d2a6f3cdb04ee33505ec6e9f653834dbb0bde", size = 36307, upload-time = "2026-04-25T11:10:26.949Z" },
4610
+ { url = "https://files.pythonhosted.org/packages/e7/f7/5a484afce0f48dd8083208b42e4911f290a82c7b52458ef2927e4d421a45/xxhash-3.7.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a169a036bed0995e090d1493b283cc2cc8a6f5046821086b843abefff80643bc", size = 32534, upload-time = "2026-04-25T11:10:29.01Z" },
4611
+ { url = "https://files.pythonhosted.org/packages/0f/5f/4acfcd490db9780cf36c58534d828003c564cde5350220a1c783c4d10776/xxhash-3.7.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ec101643395d7f21405b640f728f6f627e6986557027d740f2f9b220955edafe", size = 31552, upload-time = "2026-04-25T11:10:30.727Z" },
4612
+ ]
4613
+
4614
  [[package]]
4615
  name = "yarl"
4616
  version = "1.23.0"