Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- README.md +19 -6
- inference.py +86 -60
- models.py +1 -0
- openenv.yaml +3 -3
- server/token_optimiser_environment.py +177 -7
README.md
CHANGED
|
@@ -6,6 +6,8 @@ colorTo: purple
|
|
| 6 |
sdk: docker
|
| 7 |
app_port: 8000
|
| 8 |
pinned: false
|
|
|
|
|
|
|
| 9 |
base_path: /web
|
| 10 |
---
|
| 11 |
|
|
@@ -192,14 +194,25 @@ uv run inference.py
|
|
| 192 |
Expected output:
|
| 193 |
```
|
| 194 |
[START] task=token_optimization env=token_optimiser model=Qwen/Qwen2.5-72B-Instruct
|
| 195 |
-
[STEP] step=1 action='
|
| 196 |
-
[STEP] step=
|
| 197 |
-
[STEP] step=
|
| 198 |
-
[
|
| 199 |
-
[STEP] step=5 action='Energy trends 2013-2023: solar, wind...' reward=0.81 done=true error=null
|
| 200 |
-
[END] success=true steps=5 score=0.780 rewards=0.62,0.81,0.84,0.82,0.81
|
| 201 |
```
|
| 202 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
### Step 3 — (Optional) Run via Docker
|
| 204 |
|
| 205 |
```bash
|
|
|
|
| 6 |
sdk: docker
|
| 7 |
app_port: 8000
|
| 8 |
pinned: false
|
| 9 |
+
tags:
|
| 10 |
+
- openenv
|
| 11 |
base_path: /web
|
| 12 |
---
|
| 13 |
|
|
|
|
| 194 |
Expected output:
|
| 195 |
```
|
| 196 |
[START] task=token_optimization env=token_optimiser model=Qwen/Qwen2.5-72B-Instruct
|
| 197 |
+
[STEP] step=1 action='...' reward=0.71 done=false error=null
|
| 198 |
+
[STEP] step=4 action='...' reward=0.70 done=false error=null
|
| 199 |
+
[STEP] step=7 action='...' reward=0.17 done=false error=null
|
| 200 |
+
[END] success=false steps=9 score=0.487 rewards=0.71,0.66,0.60,0.70,0.68,0.64,0.17,0.14,0.10
|
|
|
|
|
|
|
| 201 |
```
|
| 202 |
|
| 203 |
+
### 📊 Baseline Performance
|
| 204 |
+
|
| 205 |
+
Current reproducible local baseline, measured with `HF_TOKEN` unset and the deterministic fallback path against the three-task cycle:
|
| 206 |
+
|
| 207 |
+
| Task | Score |
|
| 208 |
+
|------|-------|
|
| 209 |
+
| Easy | 0.657 |
|
| 210 |
+
| Medium | 0.673 |
|
| 211 |
+
| Hard | 0.137 |
|
| 212 |
+
| Aggregate | 0.487 |
|
| 213 |
+
|
| 214 |
+
When `HF_TOKEN` is available, `inference.py` uses the OpenAI client against the Hugging Face router and can be rerun to regenerate model-backed scores.
|
| 215 |
+
|
| 216 |
### Step 3 — (Optional) Run via Docker
|
| 217 |
|
| 218 |
```bash
|
inference.py
CHANGED
|
@@ -17,6 +17,7 @@ Environment variables required:
|
|
| 17 |
import asyncio
|
| 18 |
import logging
|
| 19 |
import os
|
|
|
|
| 20 |
import textwrap
|
| 21 |
from typing import List, Optional
|
| 22 |
|
|
@@ -31,7 +32,7 @@ from token_optimiser import TokenOptimiserEnv, TokenOptimiserAction
|
|
| 31 |
logger = logging.getLogger("TokenOptimiserFrontend")
|
| 32 |
logger.setLevel(logging.INFO)
|
| 33 |
if not logger.handlers:
|
| 34 |
-
handler = logging.StreamHandler()
|
| 35 |
formatter = logging.Formatter('\033[96m%(asctime)s\033[0m | \033[93m%(levelname)-7s\033[0m | \033[1mCLIENT\033[0m | %(message)s', datefmt='%H:%M:%S')
|
| 36 |
handler.setFormatter(formatter)
|
| 37 |
logger.addHandler(handler)
|
|
@@ -47,7 +48,8 @@ HF_TOKEN: Optional[str] = os.getenv("HF_TOKEN")
|
|
| 47 |
TASK_NAME: str = "token_optimization"
|
| 48 |
BENCHMARK: str = "token_optimiser"
|
| 49 |
MAX_STEPS: int = 5
|
| 50 |
-
|
|
|
|
| 51 |
MAX_TOKENS: int = 200
|
| 52 |
SUCCESS_THRESHOLD: float = 0.6
|
| 53 |
|
|
@@ -92,11 +94,11 @@ def log_start(task: str, env: str, model: str) -> None:
|
|
| 92 |
|
| 93 |
|
| 94 |
def log_step(step: int, action: str, reward: float, done: bool, error: Optional[str]) -> None:
|
| 95 |
-
#
|
| 96 |
-
action_short = action.replace("\n", " ")
|
| 97 |
error_val = error if error else "null"
|
| 98 |
print(
|
| 99 |
-
f"[STEP] step={step} action={action_short
|
| 100 |
f"reward={reward:.2f} done={str(done).lower()} error={error_val}",
|
| 101 |
flush=True,
|
| 102 |
)
|
|
@@ -105,8 +107,7 @@ def log_step(step: int, action: str, reward: float, done: bool, error: Optional[
|
|
| 105 |
def log_end(success: bool, steps: int, score: float, rewards: List[float]) -> None:
|
| 106 |
rewards_str = ",".join(f"{r:.2f}" for r in rewards)
|
| 107 |
print(
|
| 108 |
-
f"[END] success={str(success).lower()} steps={steps} "
|
| 109 |
-
f"score={score:.3f} rewards={rewards_str}",
|
| 110 |
flush=True,
|
| 111 |
)
|
| 112 |
|
|
@@ -139,13 +140,16 @@ def _build_user_message(original_prompt: str, step: int,
|
|
| 139 |
|
| 140 |
|
| 141 |
def get_optimized_prompt(
|
| 142 |
-
llm: OpenAI,
|
| 143 |
original_prompt: str,
|
| 144 |
step: int,
|
| 145 |
prev_reward: float,
|
| 146 |
prev_response: str,
|
| 147 |
history: List[str],
|
| 148 |
) -> str:
|
|
|
|
|
|
|
|
|
|
| 149 |
user_msg = _build_user_message(original_prompt, step, prev_reward, prev_response, history)
|
| 150 |
try:
|
| 151 |
completion = llm.chat.completions.create(
|
|
@@ -196,11 +200,71 @@ def _rule_based_compress(original_prompt: str, step: int = 1) -> str:
|
|
| 196 |
# Main episode loop
|
| 197 |
# ---------------------------------------------------------------------------
|
| 198 |
|
| 199 |
-
async def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
rewards: List[float] = []
|
| 201 |
steps_taken = 0
|
| 202 |
-
score = 0.0
|
| 203 |
success = False
|
|
|
|
| 204 |
|
| 205 |
log_start(task=TASK_NAME, env=BENCHMARK, model=MODEL_NAME)
|
| 206 |
|
|
@@ -212,55 +276,16 @@ async def run_episode(llm: OpenAI) -> None:
|
|
| 212 |
await env.connect()
|
| 213 |
|
| 214 |
try:
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
original_prompt: str = env_state.original_prompt or "Explain machine learning briefly."
|
| 221 |
-
|
| 222 |
-
logger.info(f"Task connected. Difficulty: {env_state.task_difficulty.upper()}")
|
| 223 |
-
logger.info(f"Original prompt ({len(original_prompt.split())} words): {original_prompt[:80]}...")
|
| 224 |
-
|
| 225 |
-
prev_reward = 0.0
|
| 226 |
-
prev_response = ""
|
| 227 |
-
history: List[str] = []
|
| 228 |
-
|
| 229 |
-
for step in range(1, MAX_STEPS + 1):
|
| 230 |
-
# Ask LLM to optimize the prompt
|
| 231 |
-
optimized = get_optimized_prompt(
|
| 232 |
-
llm, original_prompt, step, prev_reward, prev_response, history
|
| 233 |
)
|
|
|
|
|
|
|
| 234 |
|
| 235 |
-
|
| 236 |
-
error_msg: Optional[str] = None
|
| 237 |
-
reward = 0.0
|
| 238 |
-
done = False
|
| 239 |
-
try:
|
| 240 |
-
result = await env.step(TokenOptimiserAction(optimized_prompt=optimized))
|
| 241 |
-
obs = result.observation
|
| 242 |
-
reward = result.reward # server puts reward at top-level, not inside obs
|
| 243 |
-
done = result.done or (step >= MAX_STEPS)
|
| 244 |
-
prev_response = obs.llm_response
|
| 245 |
-
logger.info(f"Step {step} Tokens => Input: {obs.input_tokens}, Output: {obs.output_tokens}")
|
| 246 |
-
except Exception as exc:
|
| 247 |
-
error_msg = str(exc)
|
| 248 |
-
done = True
|
| 249 |
-
|
| 250 |
-
rewards.append(reward)
|
| 251 |
-
steps_taken = step
|
| 252 |
-
prev_reward = reward
|
| 253 |
-
history.append(f"step={step} prompt={optimized!r:.60} reward={reward:.2f}")
|
| 254 |
-
|
| 255 |
-
log_step(step=step, action=optimized, reward=reward, done=done, error=error_msg)
|
| 256 |
-
|
| 257 |
-
if done:
|
| 258 |
-
break
|
| 259 |
-
|
| 260 |
-
# Wait between steps to avoid rate limiting
|
| 261 |
-
await asyncio.sleep(2.5)
|
| 262 |
-
|
| 263 |
-
# Score = average reward across steps, clamped to [0, 1]
|
| 264 |
score = sum(rewards) / len(rewards) if rewards else 0.0
|
| 265 |
score = max(0.0, min(1.0, score))
|
| 266 |
success = score >= SUCCESS_THRESHOLD
|
|
@@ -277,10 +302,11 @@ async def run_episode(llm: OpenAI) -> None:
|
|
| 277 |
|
| 278 |
async def main() -> None:
|
| 279 |
if not HF_TOKEN:
|
| 280 |
-
logger.
|
| 281 |
-
|
|
|
|
|
|
|
| 282 |
|
| 283 |
-
llm = OpenAI(base_url=API_BASE_URL, api_key=HF_TOKEN)
|
| 284 |
await run_episode(llm)
|
| 285 |
|
| 286 |
|
|
|
|
| 17 |
import asyncio
|
| 18 |
import logging
|
| 19 |
import os
|
| 20 |
+
import sys
|
| 21 |
import textwrap
|
| 22 |
from typing import List, Optional
|
| 23 |
|
|
|
|
| 32 |
logger = logging.getLogger("TokenOptimiserFrontend")
|
| 33 |
logger.setLevel(logging.INFO)
|
| 34 |
if not logger.handlers:
|
| 35 |
+
handler = logging.StreamHandler(stream=sys.stderr)
|
| 36 |
formatter = logging.Formatter('\033[96m%(asctime)s\033[0m | \033[93m%(levelname)-7s\033[0m | \033[1mCLIENT\033[0m | %(message)s', datefmt='%H:%M:%S')
|
| 37 |
handler.setFormatter(formatter)
|
| 38 |
logger.addHandler(handler)
|
|
|
|
| 48 |
TASK_NAME: str = "token_optimization"
|
| 49 |
BENCHMARK: str = "token_optimiser"
|
| 50 |
MAX_STEPS: int = 5
|
| 51 |
+
TASK_EVAL_ROUNDS: int = 3
|
| 52 |
+
TEMPERATURE: float = 0.0
|
| 53 |
MAX_TOKENS: int = 200
|
| 54 |
SUCCESS_THRESHOLD: float = 0.6
|
| 55 |
|
|
|
|
| 94 |
|
| 95 |
|
| 96 |
def log_step(step: int, action: str, reward: float, done: bool, error: Optional[str]) -> None:
|
| 97 |
+
# Keep the action on one line, but do not change its content otherwise.
|
| 98 |
+
action_short = action.replace("\n", " ")
|
| 99 |
error_val = error if error else "null"
|
| 100 |
print(
|
| 101 |
+
f"[STEP] step={step} action={action_short} "
|
| 102 |
f"reward={reward:.2f} done={str(done).lower()} error={error_val}",
|
| 103 |
flush=True,
|
| 104 |
)
|
|
|
|
| 107 |
def log_end(success: bool, steps: int, score: float, rewards: List[float]) -> None:
|
| 108 |
rewards_str = ",".join(f"{r:.2f}" for r in rewards)
|
| 109 |
print(
|
| 110 |
+
f"[END] success={str(success).lower()} steps={steps} score={score:.3f} rewards={rewards_str}",
|
|
|
|
| 111 |
flush=True,
|
| 112 |
)
|
| 113 |
|
|
|
|
| 140 |
|
| 141 |
|
| 142 |
def get_optimized_prompt(
|
| 143 |
+
llm: Optional[OpenAI],
|
| 144 |
original_prompt: str,
|
| 145 |
step: int,
|
| 146 |
prev_reward: float,
|
| 147 |
prev_response: str,
|
| 148 |
history: List[str],
|
| 149 |
) -> str:
|
| 150 |
+
if llm is None:
|
| 151 |
+
return _rule_based_compress(original_prompt, step)
|
| 152 |
+
|
| 153 |
user_msg = _build_user_message(original_prompt, step, prev_reward, prev_response, history)
|
| 154 |
try:
|
| 155 |
completion = llm.chat.completions.create(
|
|
|
|
| 200 |
# Main episode loop
|
| 201 |
# ---------------------------------------------------------------------------
|
| 202 |
|
| 203 |
+
async def _run_single_task_episode(llm: Optional[OpenAI], env: TokenOptimiserEnv, step_offset: int) -> tuple[List[float], int]:
|
| 204 |
+
rewards: List[float] = []
|
| 205 |
+
steps_taken = 0
|
| 206 |
+
|
| 207 |
+
# Reset — get initial observation
|
| 208 |
+
reset_result = await env.reset()
|
| 209 |
+
|
| 210 |
+
# Fetch original prompt from server state
|
| 211 |
+
env_state = await env.state()
|
| 212 |
+
original_prompt: str = env_state.original_prompt or "Explain machine learning briefly."
|
| 213 |
+
task_label = getattr(env_state, "task_difficulty", "unknown")
|
| 214 |
+
|
| 215 |
+
logger.info(f"Task connected. Difficulty: {task_label.upper()}")
|
| 216 |
+
logger.info(f"Original prompt ({len(original_prompt.split())} words): {original_prompt[:80]}...")
|
| 217 |
+
|
| 218 |
+
prev_reward = 0.0
|
| 219 |
+
prev_response = ""
|
| 220 |
+
history: List[str] = []
|
| 221 |
+
|
| 222 |
+
for local_step in range(1, MAX_STEPS + 1):
|
| 223 |
+
global_step = step_offset + local_step
|
| 224 |
+
|
| 225 |
+
# Ask LLM to optimize the prompt
|
| 226 |
+
optimized = get_optimized_prompt(
|
| 227 |
+
llm, original_prompt, local_step, prev_reward, prev_response, history
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
# Step the environment with the optimized prompt
|
| 231 |
+
error_msg: Optional[str] = None
|
| 232 |
+
reward = 0.0
|
| 233 |
+
done = False
|
| 234 |
+
try:
|
| 235 |
+
result = await env.step(TokenOptimiserAction(optimized_prompt=optimized))
|
| 236 |
+
obs = result.observation
|
| 237 |
+
reward = result.reward # server puts reward at top-level, not inside obs
|
| 238 |
+
done = result.done or (local_step >= MAX_STEPS)
|
| 239 |
+
prev_response = obs.llm_response
|
| 240 |
+
logger.info(f"Step {global_step} Tokens => Input: {obs.input_tokens}, Output: {obs.output_tokens}")
|
| 241 |
+
if getattr(obs, "done_reason", ""):
|
| 242 |
+
logger.info(f"Step {global_step} DoneReason => {obs.done_reason}")
|
| 243 |
+
except Exception as exc:
|
| 244 |
+
error_msg = str(exc)
|
| 245 |
+
done = True
|
| 246 |
+
|
| 247 |
+
rewards.append(reward)
|
| 248 |
+
steps_taken = local_step
|
| 249 |
+
prev_reward = reward
|
| 250 |
+
history.append(f"step={global_step} prompt={optimized!r:.60} reward={reward:.2f}")
|
| 251 |
+
|
| 252 |
+
log_step(step=global_step, action=optimized, reward=reward, done=done, error=error_msg)
|
| 253 |
+
|
| 254 |
+
if done:
|
| 255 |
+
break
|
| 256 |
+
|
| 257 |
+
# Wait between steps to avoid rate limiting
|
| 258 |
+
await asyncio.sleep(2.5)
|
| 259 |
+
|
| 260 |
+
return rewards, steps_taken
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
async def run_episode(llm: Optional[OpenAI]) -> None:
|
| 264 |
rewards: List[float] = []
|
| 265 |
steps_taken = 0
|
|
|
|
| 266 |
success = False
|
| 267 |
+
score = 0.0
|
| 268 |
|
| 269 |
log_start(task=TASK_NAME, env=BENCHMARK, model=MODEL_NAME)
|
| 270 |
|
|
|
|
| 276 |
await env.connect()
|
| 277 |
|
| 278 |
try:
|
| 279 |
+
for episode_index in range(TASK_EVAL_ROUNDS):
|
| 280 |
+
episode_rewards, episode_steps = await _run_single_task_episode(
|
| 281 |
+
llm=llm,
|
| 282 |
+
env=env,
|
| 283 |
+
step_offset=steps_taken,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
)
|
| 285 |
+
rewards.extend(episode_rewards)
|
| 286 |
+
steps_taken += episode_steps
|
| 287 |
|
| 288 |
+
# Success is based on average reward across steps, clamped to [0, 1].
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 289 |
score = sum(rewards) / len(rewards) if rewards else 0.0
|
| 290 |
score = max(0.0, min(1.0, score))
|
| 291 |
success = score >= SUCCESS_THRESHOLD
|
|
|
|
| 302 |
|
| 303 |
async def main() -> None:
|
| 304 |
if not HF_TOKEN:
|
| 305 |
+
logger.warning("HF_TOKEN environment variable not set. Using deterministic fallback prompts.")
|
| 306 |
+
llm: Optional[OpenAI] = None
|
| 307 |
+
else:
|
| 308 |
+
llm = OpenAI(base_url=API_BASE_URL, api_key=HF_TOKEN)
|
| 309 |
|
|
|
|
| 310 |
await run_episode(llm)
|
| 311 |
|
| 312 |
|
models.py
CHANGED
|
@@ -25,6 +25,7 @@ class TokenOptimiserObservation(Observation):
|
|
| 25 |
input_tokens: int = Field(default=0, description="Number of tokens in the optimized prompt")
|
| 26 |
output_tokens: int = Field(default=0, description="Number of tokens in the LLM response")
|
| 27 |
reward: float = Field(default=0.0, description="Reward score for this step (0.0-1.0)")
|
|
|
|
| 28 |
|
| 29 |
|
| 30 |
class TokenOptimiserState(State):
|
|
|
|
| 25 |
input_tokens: int = Field(default=0, description="Number of tokens in the optimized prompt")
|
| 26 |
output_tokens: int = Field(default=0, description="Number of tokens in the LLM response")
|
| 27 |
reward: float = Field(default=0.0, description="Reward score for this step (0.0-1.0)")
|
| 28 |
+
done_reason: str = Field(default="", description="Why the episode terminated (if done=true)")
|
| 29 |
|
| 30 |
|
| 31 |
class TokenOptimiserState(State):
|
openenv.yaml
CHANGED
|
@@ -9,14 +9,14 @@ tasks:
|
|
| 9 |
name: "Redundancy Stripping"
|
| 10 |
description: "Strip politeness filler and redundancy"
|
| 11 |
difficulty: "easy"
|
| 12 |
-
grader: "server.token_optimiser_environment:
|
| 13 |
- id: "constraint_injection"
|
| 14 |
name: "Constraint Injection"
|
| 15 |
description: "Compress input AND inject exact bullet point constraints"
|
| 16 |
difficulty: "medium"
|
| 17 |
-
grader: "server.token_optimiser_environment:
|
| 18 |
- id: "multi_key_json_extraction"
|
| 19 |
name: "Multi-Key JSON Extraction"
|
| 20 |
description: "Force structured JSON output with 5 exact keys"
|
| 21 |
difficulty: "hard"
|
| 22 |
-
grader: "server.token_optimiser_environment:
|
|
|
|
| 9 |
name: "Redundancy Stripping"
|
| 10 |
description: "Strip politeness filler and redundancy"
|
| 11 |
difficulty: "easy"
|
| 12 |
+
grader: "server.token_optimiser_environment:grade_redundancy_stripping"
|
| 13 |
- id: "constraint_injection"
|
| 14 |
name: "Constraint Injection"
|
| 15 |
description: "Compress input AND inject exact bullet point constraints"
|
| 16 |
difficulty: "medium"
|
| 17 |
+
grader: "server.token_optimiser_environment:grade_constraint_injection"
|
| 18 |
- id: "multi_key_json_extraction"
|
| 19 |
name: "Multi-Key JSON Extraction"
|
| 20 |
description: "Force structured JSON output with 5 exact keys"
|
| 21 |
difficulty: "hard"
|
| 22 |
+
grader: "server.token_optimiser_environment:grade_multi_key_json_extraction"
|
server/token_optimiser_environment.py
CHANGED
|
@@ -14,7 +14,6 @@ and expected output responses to minimize total token usage while maintaining co
|
|
| 14 |
import json
|
| 15 |
import logging
|
| 16 |
import os
|
| 17 |
-
import random
|
| 18 |
import re
|
| 19 |
from uuid import uuid4
|
| 20 |
|
|
@@ -58,6 +57,9 @@ class TokenOptimiserEnvironment(Environment):
|
|
| 58 |
self._task_bank = self._load_task_bank()
|
| 59 |
self._current_task = None
|
| 60 |
self._reset_count = 0
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
# Hybrid LLM client — reads credentials from env vars at startup
|
| 63 |
api_key = os.getenv("HF_TOKEN") or os.getenv("API_KEY")
|
|
@@ -65,8 +67,10 @@ class TokenOptimiserEnvironment(Environment):
|
|
| 65 |
self._model = os.getenv("MODEL_NAME", "Qwen/Qwen2.5-7B-Instruct")
|
| 66 |
if OpenAI and api_key:
|
| 67 |
self._llm = OpenAI(base_url=api_base, api_key=api_key)
|
|
|
|
| 68 |
else:
|
| 69 |
self._llm = None
|
|
|
|
| 70 |
|
| 71 |
def _load_task_bank(self):
|
| 72 |
"""Load the bank of prompt optimization tasks."""
|
|
@@ -108,8 +112,9 @@ class TokenOptimiserEnvironment(Environment):
|
|
| 108 |
Returns:
|
| 109 |
TokenOptimiserObservation with initial state
|
| 110 |
"""
|
| 111 |
-
#
|
| 112 |
-
|
|
|
|
| 113 |
self._state = TokenOptimiserState(
|
| 114 |
episode_id=str(uuid4()),
|
| 115 |
step_count=0,
|
|
@@ -118,6 +123,9 @@ class TokenOptimiserEnvironment(Environment):
|
|
| 118 |
task_index=self._task_bank.index(self._current_task)
|
| 119 |
)
|
| 120 |
self._reset_count += 1
|
|
|
|
|
|
|
|
|
|
| 121 |
|
| 122 |
logger.info(f"------ ENVIRONMENT RESET ------")
|
| 123 |
logger.info(f"Loaded Task: [{self._current_task['difficulty'].upper()}] Index: {self._state.task_index}")
|
|
@@ -128,7 +136,9 @@ class TokenOptimiserEnvironment(Environment):
|
|
| 128 |
llm_response="",
|
| 129 |
input_tokens=0,
|
| 130 |
output_tokens=0,
|
| 131 |
-
reward=0.0
|
|
|
|
|
|
|
| 132 |
)
|
| 133 |
|
| 134 |
def step(self, action: TokenOptimiserAction) -> TokenOptimiserObservation: # type: ignore[override]
|
|
@@ -143,6 +153,8 @@ class TokenOptimiserEnvironment(Environment):
|
|
| 143 |
"""
|
| 144 |
self._state.step_count += 1
|
| 145 |
optimized_prompt = action.optimized_prompt
|
|
|
|
|
|
|
| 146 |
original_prompt = self._current_task["prompt"]
|
| 147 |
|
| 148 |
# 1. Call real LLM (or fallback) to get the actual response + token counts
|
|
@@ -191,20 +203,53 @@ class TokenOptimiserEnvironment(Environment):
|
|
| 191 |
+ format_score # 0.0 - 0.2 (format compliance)
|
| 192 |
+ length_penalty # 0.0 or -0.1 (penalty)
|
| 193 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
reward = max(0.0, min(1.0, reward))
|
| 195 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
logger.info(f"[STEP {self._state.step_count}] Optimized Prompt Length: {len(optimized_prompt.split())} words")
|
| 197 |
logger.info(f" └─ Tokens => In: {int(input_tokens)}, Out: {int(output_tokens)}")
|
| 198 |
logger.info(
|
| 199 |
f" └─ Reward => Tok_Eff:{token_efficiency:.2f} | Semantic:{semantic_score*0.3:.2f} | "
|
| 200 |
f"Fmt:{format_score:.2f} | Penalty:{length_penalty:.2f} || TOTAL: {reward:.3f}"
|
| 201 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
|
| 203 |
return TokenOptimiserObservation(
|
| 204 |
llm_response=llm_response,
|
| 205 |
input_tokens=int(input_tokens),
|
| 206 |
output_tokens=int(output_tokens),
|
| 207 |
-
reward=reward
|
|
|
|
|
|
|
| 208 |
)
|
| 209 |
|
| 210 |
def _call_llm(self, prompt: str) -> tuple[str, int, int]:
|
|
@@ -272,6 +317,7 @@ class TokenOptimiserEnvironment(Environment):
|
|
| 272 |
Returns a float 0.0-1.0.
|
| 273 |
"""
|
| 274 |
if self._llm is None:
|
|
|
|
| 275 |
return self._keyword_fallback_score(original_prompt, response)
|
| 276 |
|
| 277 |
judge_prompt = (
|
|
@@ -289,7 +335,8 @@ class TokenOptimiserEnvironment(Environment):
|
|
| 289 |
raw = (resp.choices[0].message.content or "5").strip()
|
| 290 |
score = int("".join(c for c in raw if c.isdigit())[:2] or "5")
|
| 291 |
return min(max(score / 10.0, 0.0), 1.0)
|
| 292 |
-
except Exception:
|
|
|
|
| 293 |
return self._keyword_fallback_score(original_prompt, response)
|
| 294 |
|
| 295 |
def _keyword_fallback_score(self, original_prompt: str, response: str) -> float:
|
|
@@ -298,9 +345,12 @@ class TokenOptimiserEnvironment(Environment):
|
|
| 298 |
"machine", "learning", "ai", "data", "predict", "solar", "wind",
|
| 299 |
"energy", "renewable", "sales", "customer", "product", "market",
|
| 300 |
"budget", "analysis", "trend", "growth", "json", "bullet",
|
|
|
|
| 301 |
}
|
| 302 |
orig = set(original_prompt.lower().split()) & key_concepts
|
| 303 |
resp = set(response.lower().split()) & key_concepts
|
|
|
|
|
|
|
| 304 |
raw = (len(resp) / len(orig)) if orig else 0.5
|
| 305 |
return min(raw, 1.0)
|
| 306 |
|
|
@@ -407,4 +457,124 @@ def grade(*args, **kwargs) -> float:
|
|
| 407 |
Entry point for OpenEnv offline task validation.
|
| 408 |
Returns a unified float. Core RL grading is dynamically calculated in TokenOptimiserEnvironment.step().
|
| 409 |
"""
|
| 410 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
import json
|
| 15 |
import logging
|
| 16 |
import os
|
|
|
|
| 17 |
import re
|
| 18 |
from uuid import uuid4
|
| 19 |
|
|
|
|
| 57 |
self._task_bank = self._load_task_bank()
|
| 58 |
self._current_task = None
|
| 59 |
self._reset_count = 0
|
| 60 |
+
self._last_prompt_norm = ""
|
| 61 |
+
self._best_reward = 0.0
|
| 62 |
+
self._stagnation_steps = 0
|
| 63 |
|
| 64 |
# Hybrid LLM client — reads credentials from env vars at startup
|
| 65 |
api_key = os.getenv("HF_TOKEN") or os.getenv("API_KEY")
|
|
|
|
| 67 |
self._model = os.getenv("MODEL_NAME", "Qwen/Qwen2.5-7B-Instruct")
|
| 68 |
if OpenAI and api_key:
|
| 69 |
self._llm = OpenAI(base_url=api_base, api_key=api_key)
|
| 70 |
+
logger.info(f"LLM backend enabled (model={self._model})")
|
| 71 |
else:
|
| 72 |
self._llm = None
|
| 73 |
+
logger.warning("LLM backend unavailable; using deterministic fallback simulation and keyword judge.")
|
| 74 |
|
| 75 |
def _load_task_bank(self):
|
| 76 |
"""Load the bank of prompt optimization tasks."""
|
|
|
|
| 112 |
Returns:
|
| 113 |
TokenOptimiserObservation with initial state
|
| 114 |
"""
|
| 115 |
+
# Cycle tasks in a fixed order so baseline runs are reproducible.
|
| 116 |
+
task_index = self._reset_count % len(self._task_bank)
|
| 117 |
+
self._current_task = self._task_bank[task_index]
|
| 118 |
self._state = TokenOptimiserState(
|
| 119 |
episode_id=str(uuid4()),
|
| 120 |
step_count=0,
|
|
|
|
| 123 |
task_index=self._task_bank.index(self._current_task)
|
| 124 |
)
|
| 125 |
self._reset_count += 1
|
| 126 |
+
self._last_prompt_norm = ""
|
| 127 |
+
self._best_reward = 0.0
|
| 128 |
+
self._stagnation_steps = 0
|
| 129 |
|
| 130 |
logger.info(f"------ ENVIRONMENT RESET ------")
|
| 131 |
logger.info(f"Loaded Task: [{self._current_task['difficulty'].upper()}] Index: {self._state.task_index}")
|
|
|
|
| 136 |
llm_response="",
|
| 137 |
input_tokens=0,
|
| 138 |
output_tokens=0,
|
| 139 |
+
reward=0.0,
|
| 140 |
+
done=False,
|
| 141 |
+
done_reason=""
|
| 142 |
)
|
| 143 |
|
| 144 |
def step(self, action: TokenOptimiserAction) -> TokenOptimiserObservation: # type: ignore[override]
|
|
|
|
| 153 |
"""
|
| 154 |
self._state.step_count += 1
|
| 155 |
optimized_prompt = action.optimized_prompt
|
| 156 |
+
prompt_norm = " ".join(optimized_prompt.strip().lower().split())
|
| 157 |
+
prompt_changed = bool(prompt_norm) and prompt_norm != self._last_prompt_norm
|
| 158 |
original_prompt = self._current_task["prompt"]
|
| 159 |
|
| 160 |
# 1. Call real LLM (or fallback) to get the actual response + token counts
|
|
|
|
| 203 |
+ format_score # 0.0 - 0.2 (format compliance)
|
| 204 |
+ length_penalty # 0.0 or -0.1 (penalty)
|
| 205 |
)
|
| 206 |
+
# Penalize no-op actions so each step requires substantive work.
|
| 207 |
+
if self._state.step_count > 1 and not prompt_changed:
|
| 208 |
+
reward -= 0.10
|
| 209 |
+
|
| 210 |
reward = max(0.0, min(1.0, reward))
|
| 211 |
|
| 212 |
+
# Done logic: terminate on strong convergence or repeated non-improving/no-op steps.
|
| 213 |
+
done = False
|
| 214 |
+
done_reason = ""
|
| 215 |
+
|
| 216 |
+
improved = reward > (self._best_reward + 0.01)
|
| 217 |
+
if improved:
|
| 218 |
+
self._best_reward = reward
|
| 219 |
+
self._stagnation_steps = 0
|
| 220 |
+
else:
|
| 221 |
+
self._stagnation_steps += 1
|
| 222 |
+
|
| 223 |
+
if reward >= 0.90:
|
| 224 |
+
done = True
|
| 225 |
+
done_reason = "converged_high_reward"
|
| 226 |
+
elif self._state.step_count >= 2 and self._stagnation_steps >= 2:
|
| 227 |
+
done = True
|
| 228 |
+
done_reason = "stagnated_no_improvement"
|
| 229 |
+
elif self._state.step_count >= 2 and not prompt_changed:
|
| 230 |
+
done = True
|
| 231 |
+
done_reason = "no_substantive_action_change"
|
| 232 |
+
|
| 233 |
+
self._last_prompt_norm = prompt_norm
|
| 234 |
+
|
| 235 |
logger.info(f"[STEP {self._state.step_count}] Optimized Prompt Length: {len(optimized_prompt.split())} words")
|
| 236 |
logger.info(f" └─ Tokens => In: {int(input_tokens)}, Out: {int(output_tokens)}")
|
| 237 |
logger.info(
|
| 238 |
f" └─ Reward => Tok_Eff:{token_efficiency:.2f} | Semantic:{semantic_score*0.3:.2f} | "
|
| 239 |
f"Fmt:{format_score:.2f} | Penalty:{length_penalty:.2f} || TOTAL: {reward:.3f}"
|
| 240 |
)
|
| 241 |
+
logger.info(
|
| 242 |
+
f" └─ Progress => PromptChanged:{str(prompt_changed).lower()} | "
|
| 243 |
+
f"Stagnation:{self._stagnation_steps} | Done:{str(done).lower()} | Reason:{done_reason or 'null'}"
|
| 244 |
+
)
|
| 245 |
|
| 246 |
return TokenOptimiserObservation(
|
| 247 |
llm_response=llm_response,
|
| 248 |
input_tokens=int(input_tokens),
|
| 249 |
output_tokens=int(output_tokens),
|
| 250 |
+
reward=reward,
|
| 251 |
+
done=done,
|
| 252 |
+
done_reason=done_reason
|
| 253 |
)
|
| 254 |
|
| 255 |
def _call_llm(self, prompt: str) -> tuple[str, int, int]:
|
|
|
|
| 317 |
Returns a float 0.0-1.0.
|
| 318 |
"""
|
| 319 |
if self._llm is None:
|
| 320 |
+
logger.warning("Semantic judge fallback: no LLM client available.")
|
| 321 |
return self._keyword_fallback_score(original_prompt, response)
|
| 322 |
|
| 323 |
judge_prompt = (
|
|
|
|
| 335 |
raw = (resp.choices[0].message.content or "5").strip()
|
| 336 |
score = int("".join(c for c in raw if c.isdigit())[:2] or "5")
|
| 337 |
return min(max(score / 10.0, 0.0), 1.0)
|
| 338 |
+
except Exception as exc:
|
| 339 |
+
logger.warning(f"Semantic judge fallback: judge call failed ({exc}).")
|
| 340 |
return self._keyword_fallback_score(original_prompt, response)
|
| 341 |
|
| 342 |
def _keyword_fallback_score(self, original_prompt: str, response: str) -> float:
|
|
|
|
| 345 |
"machine", "learning", "ai", "data", "predict", "solar", "wind",
|
| 346 |
"energy", "renewable", "sales", "customer", "product", "market",
|
| 347 |
"budget", "analysis", "trend", "growth", "json", "bullet",
|
| 348 |
+
"python", "javascript", "typing", "performance", "syntax", "ecosystem",
|
| 349 |
}
|
| 350 |
orig = set(original_prompt.lower().split()) & key_concepts
|
| 351 |
resp = set(response.lower().split()) & key_concepts
|
| 352 |
+
if not orig:
|
| 353 |
+
logger.warning("Keyword fallback is using neutral score because no tracked concepts were found in original prompt.")
|
| 354 |
raw = (len(resp) / len(orig)) if orig else 0.5
|
| 355 |
return min(raw, 1.0)
|
| 356 |
|
|
|
|
| 457 |
Entry point for OpenEnv offline task validation.
|
| 458 |
Returns a unified float. Core RL grading is dynamically calculated in TokenOptimiserEnvironment.step().
|
| 459 |
"""
|
| 460 |
+
return _grade_redundancy_stripping(*args, **kwargs)
|
| 461 |
+
|
| 462 |
+
|
| 463 |
+
def grade_redundancy_stripping(*args, **kwargs) -> float:
|
| 464 |
+
"""Task-specific grader for redundancy_stripping."""
|
| 465 |
+
return _grade_redundancy_stripping(*args, **kwargs)
|
| 466 |
+
|
| 467 |
+
|
| 468 |
+
def grade_constraint_injection(*args, **kwargs) -> float:
|
| 469 |
+
"""Task-specific grader for constraint_injection."""
|
| 470 |
+
return _grade_constraint_injection(*args, **kwargs)
|
| 471 |
+
|
| 472 |
+
|
| 473 |
+
def grade_multi_key_json_extraction(*args, **kwargs) -> float:
|
| 474 |
+
"""Task-specific grader for multi_key_json_extraction."""
|
| 475 |
+
return _grade_multi_key_json_extraction(*args, **kwargs)
|
| 476 |
+
|
| 477 |
+
|
| 478 |
+
def _extract_action_observation(*args, **kwargs) -> tuple[str, str, float, str]:
|
| 479 |
+
"""
|
| 480 |
+
Extract optimized prompt, llm response, and base reward from flexible grader args.
|
| 481 |
+
|
| 482 |
+
Supports object-style and dict-style inputs since validation harnesses can vary.
|
| 483 |
+
"""
|
| 484 |
+
action = kwargs.get("action")
|
| 485 |
+
observation = kwargs.get("observation") or kwargs.get("obs")
|
| 486 |
+
|
| 487 |
+
if action is None and len(args) >= 1:
|
| 488 |
+
action = args[0]
|
| 489 |
+
if observation is None and len(args) >= 2:
|
| 490 |
+
observation = args[1]
|
| 491 |
+
|
| 492 |
+
optimized_prompt = ""
|
| 493 |
+
llm_response = ""
|
| 494 |
+
reward = 0.0
|
| 495 |
+
done_reason = ""
|
| 496 |
+
|
| 497 |
+
if isinstance(action, dict):
|
| 498 |
+
optimized_prompt = str(action.get("optimized_prompt", ""))
|
| 499 |
+
elif action is not None:
|
| 500 |
+
optimized_prompt = str(getattr(action, "optimized_prompt", "") or "")
|
| 501 |
+
|
| 502 |
+
if isinstance(observation, dict):
|
| 503 |
+
llm_response = str(observation.get("llm_response", ""))
|
| 504 |
+
reward = float(observation.get("reward", 0.0) or 0.0)
|
| 505 |
+
done_reason = str(observation.get("done_reason", "") or "")
|
| 506 |
+
elif observation is not None:
|
| 507 |
+
llm_response = str(getattr(observation, "llm_response", "") or "")
|
| 508 |
+
reward = float(getattr(observation, "reward", 0.0) or 0.0)
|
| 509 |
+
done_reason = str(getattr(observation, "done_reason", "") or "")
|
| 510 |
+
|
| 511 |
+
return optimized_prompt, llm_response, max(0.0, min(1.0, reward)), done_reason
|
| 512 |
+
|
| 513 |
+
|
| 514 |
+
def _grade_redundancy_stripping(*args, **kwargs) -> float:
|
| 515 |
+
optimized_prompt, llm_response, base_reward, done_reason = _extract_action_observation(*args, **kwargs)
|
| 516 |
+
|
| 517 |
+
# Reward concise rewrites that still produce plain, brief answers.
|
| 518 |
+
prompt_tokens = len(optimized_prompt.split()) if optimized_prompt else 0
|
| 519 |
+
concision_bonus = 0.2 if 1 <= prompt_tokens <= 30 else 0.0
|
| 520 |
+
|
| 521 |
+
sentences = len([s for s in re.split(r"[.!?]+", llm_response) if s.strip()])
|
| 522 |
+
plain_text_bonus = 0.2 if sentences <= 2 and not any(c in llm_response for c in ("•", "-", "*")) else 0.0
|
| 523 |
+
|
| 524 |
+
noop_penalty = 0.15 if done_reason in ("no_substantive_action_change", "stagnated_no_improvement") else 0.0
|
| 525 |
+
|
| 526 |
+
if optimized_prompt or llm_response:
|
| 527 |
+
return max(0.0, min(1.0, base_reward * 0.6 + concision_bonus + plain_text_bonus - noop_penalty))
|
| 528 |
+
return base_reward
|
| 529 |
+
|
| 530 |
+
|
| 531 |
+
def _grade_constraint_injection(*args, **kwargs) -> float:
|
| 532 |
+
optimized_prompt, llm_response, base_reward, done_reason = _extract_action_observation(*args, **kwargs)
|
| 533 |
+
|
| 534 |
+
# Enforce the medium-task structure: exactly 5 bullet points.
|
| 535 |
+
bullet_count = llm_response.count("•")
|
| 536 |
+
if bullet_count == 0:
|
| 537 |
+
# Fallback for hyphen or asterisk bullets
|
| 538 |
+
lines = [line.strip() for line in llm_response.splitlines() if line.strip()]
|
| 539 |
+
bullet_count = sum(1 for line in lines if line.startswith("-") or line.startswith("*"))
|
| 540 |
+
|
| 541 |
+
format_bonus = 0.25 if bullet_count == 5 else (0.1 if 3 <= bullet_count <= 4 else 0.0)
|
| 542 |
+
brevity_bonus = 0.15 if len(optimized_prompt.split()) <= 45 and optimized_prompt else 0.0
|
| 543 |
+
|
| 544 |
+
noop_penalty = 0.15 if done_reason in ("no_substantive_action_change", "stagnated_no_improvement") else 0.0
|
| 545 |
+
|
| 546 |
+
if optimized_prompt or llm_response:
|
| 547 |
+
return max(0.0, min(1.0, base_reward * 0.6 + format_bonus + brevity_bonus - noop_penalty))
|
| 548 |
+
return base_reward
|
| 549 |
+
|
| 550 |
+
|
| 551 |
+
def _grade_multi_key_json_extraction(*args, **kwargs) -> float:
|
| 552 |
+
optimized_prompt, llm_response, base_reward, done_reason = _extract_action_observation(*args, **kwargs)
|
| 553 |
+
|
| 554 |
+
required_keys = {
|
| 555 |
+
"top_categories",
|
| 556 |
+
"growth_regions",
|
| 557 |
+
"responsive_segments",
|
| 558 |
+
"budget_allocation",
|
| 559 |
+
"risks_watch",
|
| 560 |
+
}
|
| 561 |
+
|
| 562 |
+
key_bonus = 0.0
|
| 563 |
+
parse_bonus = 0.0
|
| 564 |
+
if llm_response:
|
| 565 |
+
try:
|
| 566 |
+
parsed = json.loads(llm_response.strip())
|
| 567 |
+
if isinstance(parsed, dict):
|
| 568 |
+
present = len(required_keys & set(parsed.keys()))
|
| 569 |
+
key_bonus = 0.3 * (present / len(required_keys))
|
| 570 |
+
parse_bonus = 0.15
|
| 571 |
+
except Exception:
|
| 572 |
+
key_bonus = 0.0
|
| 573 |
+
|
| 574 |
+
compression_bonus = 0.15 if len(optimized_prompt.split()) <= 65 and optimized_prompt else 0.0
|
| 575 |
+
|
| 576 |
+
noop_penalty = 0.15 if done_reason in ("no_substantive_action_change", "stagnated_no_improvement") else 0.0
|
| 577 |
+
|
| 578 |
+
if optimized_prompt or llm_response:
|
| 579 |
+
return max(0.0, min(1.0, base_reward * 0.4 + parse_bonus + key_bonus + compression_bonus - noop_penalty))
|
| 580 |
+
return base_reward
|