Spaces:
Sleeping
Sleeping
Merge branch 'fix/scoring-mismatch'
Browse files- .env.example +2 -2
- README.md +38 -2
- RevLogiXenv_v0/baseline.py +9 -98
- RevLogiXenv_v0/environment.py +26 -0
- RevLogiXenv_v0/grader.py +33 -6
- RevLogiXenv_v0/oracle.py +1 -1
- RevLogiXenv_v0/prompting.py +217 -0
- inference.py +6 -78
- openenv.yaml +9 -4
- tests/test_grader_edge_cases.py +161 -0
- tests/test_policies_improvements.py +36 -0
- tests/test_scoring_consistency.py +132 -0
.env.example
CHANGED
|
@@ -8,7 +8,7 @@
|
|
| 8 |
# Primary model and base URL
|
| 9 |
# Default is https://router.huggingface.co/v1
|
| 10 |
API_BASE_URL=https://router.huggingface.co/v1
|
| 11 |
-
MODEL_NAME=gpt-
|
| 12 |
|
| 13 |
# Your Hugging Face Token (used by inference.py and run_baseline.py)
|
| 14 |
HF_TOKEN=hf_your_token_here
|
|
@@ -27,5 +27,5 @@ ENV_BASE_URL=http://localhost:8000
|
|
| 27 |
# (Optional) Local Baseline Run Settings
|
| 28 |
# ---------------------------------------------------------------------------
|
| 29 |
baseline_provider=openai
|
| 30 |
-
baseline_model=gpt-
|
| 31 |
baseline_temp=0.1
|
|
|
|
| 8 |
# Primary model and base URL
|
| 9 |
# Default is https://router.huggingface.co/v1
|
| 10 |
API_BASE_URL=https://router.huggingface.co/v1
|
| 11 |
+
MODEL_NAME=gpt-4.1-mini
|
| 12 |
|
| 13 |
# Your Hugging Face Token (used by inference.py and run_baseline.py)
|
| 14 |
HF_TOKEN=hf_your_token_here
|
|
|
|
| 27 |
# (Optional) Local Baseline Run Settings
|
| 28 |
# ---------------------------------------------------------------------------
|
| 29 |
baseline_provider=openai
|
| 30 |
+
baseline_model=gpt-4.1-mini
|
| 31 |
baseline_temp=0.1
|
README.md
CHANGED
|
@@ -86,7 +86,7 @@ Use the `run_baseline.py` script for local testing and developer iteration.
|
|
| 86 |
uv run python run_baseline.py --mode local
|
| 87 |
|
| 88 |
# Run LLM-based baseline (requires OpenAI-compatible API)
|
| 89 |
-
uv run python run_baseline.py --mode llm --model gpt-
|
| 90 |
```
|
| 91 |
|
| 92 |
---
|
|
@@ -106,7 +106,7 @@ This environment adheres to the OpenEnv standard. You can interact with it via t
|
|
| 106 |
The `inference.py` script outputs the following standardized format for automated validation:
|
| 107 |
|
| 108 |
```text
|
| 109 |
-
[START] task=easy env=revlogixenv_v0 model=gpt-
|
| 110 |
[STEP] step=1 action=inspect reward=0.43 done=false error=null
|
| 111 |
...
|
| 112 |
[END] success=true steps=20 score=0.717 rewards=0.43,0.50,...
|
|
@@ -117,6 +117,42 @@ The `inference.py` script outputs the following standardized format for automate
|
|
| 117 |
|
| 118 |
---
|
| 119 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
## Documentation
|
| 121 |
|
| 122 |
- [Technical Details](RevLogiXenv_v0/README.md): Deep dive into environment dynamics and models.
|
|
|
|
| 86 |
uv run python run_baseline.py --mode local
|
| 87 |
|
| 88 |
# Run LLM-based baseline (requires OpenAI-compatible API)
|
| 89 |
+
uv run python run_baseline.py --mode llm --model gpt-4.1-mini
|
| 90 |
```
|
| 91 |
|
| 92 |
---
|
|
|
|
| 106 |
The `inference.py` script outputs the following standardized format for automated validation:
|
| 107 |
|
| 108 |
```text
|
| 109 |
+
[START] task=easy env=revlogixenv_v0 model=gpt-4.1-mini
|
| 110 |
[STEP] step=1 action=inspect reward=0.43 done=false error=null
|
| 111 |
...
|
| 112 |
[END] success=true steps=20 score=0.717 rewards=0.43,0.50,...
|
|
|
|
| 117 |
|
| 118 |
---
|
| 119 |
|
| 120 |
+
## Task Configurations
|
| 121 |
+
|
| 122 |
+
The environment supports three difficulty tiers plus legacy compatibility aliases:
|
| 123 |
+
|
| 124 |
+
| Task | Items | Fraud | Noise | Delay | Scoring |
|
| 125 |
+
|------|-------|-------|-------|-------|---------|
|
| 126 |
+
| `easy` | 20 | No | Medium | Fixed (3 steps) | Oracle-margin |
|
| 127 |
+
| `medium` | 30 | Yes | Hard | Variable | Oracle-margin |
|
| 128 |
+
| `hard` | 40 | Yes | Extra-hard | Variable | 0.6×margin + 0.4×fraud-F1 |
|
| 129 |
+
| `_legacy_easy` | 10 | No | Easy | Immediate | Oracle-margin |
|
| 130 |
+
|
| 131 |
+
The `_legacy_easy` task is preserved for backward compatibility with early experiments.
|
| 132 |
+
Internal environment code may also reference `_old_easy`, `_old_medium`, and `_old_hard`
|
| 133 |
+
keys — these are functionally identical to `easy`, `medium`, and `hard` respectively.
|
| 134 |
+
|
| 135 |
+
## Scoring Contexts
|
| 136 |
+
|
| 137 |
+
RevLogiXenv has **two independent scoring modes** that target different use cases:
|
| 138 |
+
|
| 139 |
+
1. **Inference mode** (hackathon Phase 2): `score = mean(step_rewards)`, each reward clamped to `(0.01, 0.99)`. This is what `inference.py` emits and what the hackathon validator parses.
|
| 140 |
+
|
| 141 |
+
2. **Grader mode** (local Python evaluation): Oracle-normalized margin score, with a fraud-F1 composite for `hard` tasks. Computed by the `Grader` class. This is used for local policy comparison and benchmarking.
|
| 142 |
+
|
| 143 |
+
Both modes enforce the same `(0.01, 0.99)` bounded range. The `openenv.yaml` manifest declares the inference-mode formula.
|
| 144 |
+
|
| 145 |
+
## Observation Fields
|
| 146 |
+
|
| 147 |
+
The `ReturnsObservation.reward_detail` field exposes a structured `Reward` payload with detailed economics:
|
| 148 |
+
|
| 149 |
+
- `value`: The shaped reward (clamped, used by inference)
|
| 150 |
+
- `raw_step_reward`: Unclamped raw economics in dollars
|
| 151 |
+
- `margin_component`: Shaped margin signal (0.01–0.99)
|
| 152 |
+
- `fraud_component`: Shaped fraud signal (0.01–0.99)
|
| 153 |
+
- `components`: Dict of line-item economics (`processing`, `revenue`, `fraud_recovery`, etc.)
|
| 154 |
+
- `penalties`: Dict of applied penalties and their strength
|
| 155 |
+
|
| 156 |
## Documentation
|
| 157 |
|
| 158 |
- [Technical Details](RevLogiXenv_v0/README.md): Deep dive into environment dynamics and models.
|
RevLogiXenv_v0/baseline.py
CHANGED
|
@@ -18,49 +18,11 @@ from typing import Iterable
|
|
| 18 |
from .environment import AutonomousReturnsEnv
|
| 19 |
from .grader import Grader
|
| 20 |
from .models import DispositionAction, ReturnsAction, ReturnsObservation
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
NOISE MODEL (CRITICAL):
|
| 27 |
-
- Items have HIDDEN TRUE CONDITION: perfect, lightly_used, damaged, fraudulent
|
| 28 |
-
- You observe NOISY condition scores (0-10), NOT ground truth
|
| 29 |
-
- Fraudulent items score HIGH (mean=7.8, std=0.70) to mimic quality items
|
| 30 |
-
- Perfect items score highest (mean=8.9), damaged score lowest (mean=3.0)
|
| 31 |
-
- High score + fraud history = SUSPICIOUS (may be fraudulent mimicking quality)
|
| 32 |
-
- High score + clean history = likely good
|
| 33 |
-
|
| 34 |
-
CUSTOMER PROFILE INTERPRETATION:
|
| 35 |
-
- RETURNS: number of previous returns (0-4+)
|
| 36 |
-
- HIST_FRAUD_FLAGS: past fraud indicators (0 = clean, 2+ = high risk)
|
| 37 |
-
- RECENT_ABUSE: True if customer shows abuse patterns
|
| 38 |
-
|
| 39 |
-
FRAUD INDICATORS (strongest to weakest):
|
| 40 |
-
1. 'wrong item' or 'missing parts' in return reason
|
| 41 |
-
2. high HIST_FRAUD_FLAGS (>0)
|
| 42 |
-
3. RECENT_ABUSE = True
|
| 43 |
-
4. 'mismatch'/'suspect'/'serial' in inspection note
|
| 44 |
-
5. 'missing' packaging
|
| 45 |
-
|
| 46 |
-
DISPOSITION VALUE GUIDE:
|
| 47 |
-
- RESELL_FULL: Best for PERFECT ($0.98x). Bad for damaged ($0.22x) or fraud ($0.10x)
|
| 48 |
-
- RESELL_DISCOUNT_15: Good for LIGHTLY_USED ($0.82x). Avoid on fraud ($0.22x)
|
| 49 |
-
- RESELL_DISCOUNT_30: General lightly-used fallback
|
| 50 |
-
- RESELL_DISCOUNT_50: Damaged items, low-value situations
|
| 51 |
-
- REFURBISH: High-price damaged items only ($18 fixed + 15% variable)
|
| 52 |
-
- DISPOSE: Low-price (<$90) damaged items
|
| 53 |
-
- FLAG_FRAUD: Only with 3+ fraud signals. Costs $6+$0.03x. Recovers $0.88x if correct
|
| 54 |
-
- INSPECT: Uncertain expensive items ($250+). Reduces noise 14x but costs $5
|
| 55 |
-
|
| 56 |
-
RULES:
|
| 57 |
-
- score > 8.0 AND no fraud history → likely PERFECT → RESELL_FULL or RESELL_DISCOUNT_15
|
| 58 |
-
- score 5.0-7.5 AND fraud signals → INSPECT before deciding
|
| 59 |
-
- score < 4.0 → likely damaged → REFURBISH (high$) or DISPOSE (low$)
|
| 60 |
-
- NEVER flag fraud with < 3 signals (causes expensive false positives)
|
| 61 |
-
|
| 62 |
-
Choose EXACTLY ONE action: RESELL_FULL, RESELL_DISCOUNT_15, RESELL_DISCOUNT_30, RESELL_DISCOUNT_50, REFURBISH, DISPOSE, FLAG_FRAUD, INSPECT, WAIT
|
| 63 |
-
Return ONLY the action name. No explanation. No punctuation."""
|
| 64 |
|
| 65 |
|
| 66 |
@dataclass(frozen=True)
|
|
@@ -94,7 +56,7 @@ class BaselineAgent:
|
|
| 94 |
base_url = os.environ.get("API_BASE_URL") or os.environ.get("OPENAI_API_BASE_URL")
|
| 95 |
self.client = OpenAI(api_key=api_key, base_url=base_url)
|
| 96 |
if not self.model:
|
| 97 |
-
self.model = os.environ.get("MODEL_NAME", "gpt-
|
| 98 |
|
| 99 |
def _call_llm(self, prompt: str) -> str:
|
| 100 |
response = self.client.chat.completions.create(
|
|
@@ -110,63 +72,12 @@ class BaselineAgent:
|
|
| 110 |
return text.strip()
|
| 111 |
|
| 112 |
def observation_to_prompt(self, obs: ReturnsObservation) -> str:
|
| 113 |
-
|
| 114 |
-
return (
|
| 115 |
-
"No current item is available. "
|
| 116 |
-
f"Pending resolutions: {obs.pending_resolution_count}. "
|
| 117 |
-
"Output exactly one action token: wait"
|
| 118 |
-
)
|
| 119 |
-
|
| 120 |
-
item = obs.current_item
|
| 121 |
-
lines: list[str] = [
|
| 122 |
-
f"Product: {item.product_title}",
|
| 123 |
-
f"Category: {item.category}",
|
| 124 |
-
f"Price: {item.price:.2f}",
|
| 125 |
-
f"Noisy condition score: {item.noisy_condition_score:.2f}/10",
|
| 126 |
-
f"Return reason: {item.stated_return_reason}",
|
| 127 |
-
f"Customer profile: RETURNS={item.customer_profile.total_returns}, "
|
| 128 |
-
f"HIST_FRAUD_FLAGS={item.customer_profile.historical_fraud_flags}, "
|
| 129 |
-
f"RECENT_ABUSE={item.customer_profile.recent_abuse_signals}",
|
| 130 |
-
f"Packaging: {item.packaging_condition}",
|
| 131 |
-
]
|
| 132 |
-
if item.damage_flags:
|
| 133 |
-
lines.append(f"Damage flags: {', '.join(item.damage_flags)}")
|
| 134 |
-
if item.inspection_note:
|
| 135 |
-
lines.append(f"Inspection note: {item.inspection_note}")
|
| 136 |
-
|
| 137 |
-
lines.append(f"Remaining items: {obs.remaining_items}")
|
| 138 |
-
lines.append(f"Pending resolutions: {obs.pending_resolution_count}")
|
| 139 |
-
lines.append(f"Running ledger: {obs.running_ledger:.4f}")
|
| 140 |
-
|
| 141 |
-
if obs.last_three_resolutions:
|
| 142 |
-
lines.append("Recent outcomes:")
|
| 143 |
-
for r in obs.last_three_resolutions:
|
| 144 |
-
lines.append(
|
| 145 |
-
f"- action={r.action.value} normalized_reward={r.normalized_reward:.4f}"
|
| 146 |
-
)
|
| 147 |
-
|
| 148 |
-
lines.append("")
|
| 149 |
-
lines.append("Choose one action token exactly from:")
|
| 150 |
-
lines.append(", ".join(ACTION_STRINGS))
|
| 151 |
-
return "\n".join(lines)
|
| 152 |
|
| 153 |
def parse_action(
|
| 154 |
self, response_text: str, obs: ReturnsObservation
|
| 155 |
) -> ReturnsAction:
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
if obs.current_item is None:
|
| 159 |
-
return ReturnsAction(action=DispositionAction.WAIT)
|
| 160 |
-
|
| 161 |
-
for act in DispositionAction:
|
| 162 |
-
if act.value == text:
|
| 163 |
-
return ReturnsAction(action=act)
|
| 164 |
-
|
| 165 |
-
for act in DispositionAction:
|
| 166 |
-
if act.value in text:
|
| 167 |
-
return ReturnsAction(action=act)
|
| 168 |
-
|
| 169 |
-
return ReturnsAction(action=DispositionAction.RESELL_DISCOUNT_30)
|
| 170 |
|
| 171 |
def act(self, obs: ReturnsObservation) -> ReturnsAction:
|
| 172 |
if obs.current_item is None:
|
|
@@ -251,7 +162,7 @@ class BaselineAgent:
|
|
| 251 |
|
| 252 |
|
| 253 |
def run_openai_baseline(
|
| 254 |
-
model: str = "gpt-
|
| 255 |
seeds: Iterable[int] = (42,),
|
| 256 |
verbose: bool = False,
|
| 257 |
) -> dict:
|
|
|
|
| 18 |
from .environment import AutonomousReturnsEnv
|
| 19 |
from .grader import Grader
|
| 20 |
from .models import DispositionAction, ReturnsAction, ReturnsObservation
|
| 21 |
+
from .prompting import (
|
| 22 |
+
FULL_SYSTEM_PROMPT as LLM_SYSTEM_PROMPT,
|
| 23 |
+
observation_to_prompt_baseline as observation_to_prompt,
|
| 24 |
+
parse_action_baseline as parse_action,
|
| 25 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
|
| 28 |
@dataclass(frozen=True)
|
|
|
|
| 56 |
base_url = os.environ.get("API_BASE_URL") or os.environ.get("OPENAI_API_BASE_URL")
|
| 57 |
self.client = OpenAI(api_key=api_key, base_url=base_url)
|
| 58 |
if not self.model:
|
| 59 |
+
self.model = os.environ.get("MODEL_NAME", "gpt-4.1-mini")
|
| 60 |
|
| 61 |
def _call_llm(self, prompt: str) -> str:
|
| 62 |
response = self.client.chat.completions.create(
|
|
|
|
| 72 |
return text.strip()
|
| 73 |
|
| 74 |
def observation_to_prompt(self, obs: ReturnsObservation) -> str:
|
| 75 |
+
return observation_to_prompt(obs)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
|
| 77 |
def parse_action(
|
| 78 |
self, response_text: str, obs: ReturnsObservation
|
| 79 |
) -> ReturnsAction:
|
| 80 |
+
return parse_action(response_text, obs)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
|
| 82 |
def act(self, obs: ReturnsObservation) -> ReturnsAction:
|
| 83 |
if obs.current_item is None:
|
|
|
|
| 162 |
|
| 163 |
|
| 164 |
def run_openai_baseline(
|
| 165 |
+
model: str = "gpt-4.1-mini",
|
| 166 |
seeds: Iterable[int] = (42,),
|
| 167 |
verbose: bool = False,
|
| 168 |
) -> dict:
|
RevLogiXenv_v0/environment.py
CHANGED
|
@@ -166,6 +166,12 @@ TASK_CONFIG = {
|
|
| 166 |
"noise_level": "extra_hard",
|
| 167 |
"delay_type": "variable",
|
| 168 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
"_old_easy": {
|
| 170 |
"num_items": 20,
|
| 171 |
"fraud_enabled": False,
|
|
@@ -417,6 +423,26 @@ class AutonomousReturnsEnv(Environment[ReturnsAction, ReturnsObservation, EnvSta
|
|
| 417 |
def close(self) -> None:
|
| 418 |
return None
|
| 419 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 420 |
def _set_task(self, task: str) -> None:
|
| 421 |
if not isinstance(task, str):
|
| 422 |
raise TypeError("Task override must be a string.")
|
|
|
|
| 166 |
"noise_level": "extra_hard",
|
| 167 |
"delay_type": "variable",
|
| 168 |
},
|
| 169 |
+
"_legacy_easy": {
|
| 170 |
+
"num_items": 10,
|
| 171 |
+
"fraud_enabled": False,
|
| 172 |
+
"noise_level": "easy",
|
| 173 |
+
"delay_type": "immediate",
|
| 174 |
+
},
|
| 175 |
"_old_easy": {
|
| 176 |
"num_items": 20,
|
| 177 |
"fraud_enabled": False,
|
|
|
|
| 423 |
def close(self) -> None:
|
| 424 |
return None
|
| 425 |
|
| 426 |
+
@property
|
| 427 |
+
def episode_snapshot(self) -> list[EpisodeRecord]:
|
| 428 |
+
"""
|
| 429 |
+
Read-only snapshot of ground-truth episode data captured at reset time.
|
| 430 |
+
|
| 431 |
+
This is used by the Grader and Oracle to evaluate agent performance
|
| 432 |
+
without relying on private-name access (_episode_snapshot).
|
| 433 |
+
"""
|
| 434 |
+
return self._episode_snapshot
|
| 435 |
+
|
| 436 |
+
@property
|
| 437 |
+
def full_resolution_history(self) -> list[ResolutionSummary]:
|
| 438 |
+
"""
|
| 439 |
+
Read-only list of all resolved item summaries across the full episode.
|
| 440 |
+
|
| 441 |
+
Unlike ``resolution_history`` which is capped at the last 3 for display,
|
| 442 |
+
this contains every resolved item and is used by the Grader for fraud metrics.
|
| 443 |
+
"""
|
| 444 |
+
return self._full_resolution_history
|
| 445 |
+
|
| 446 |
def _set_task(self, task: str) -> None:
|
| 447 |
if not isinstance(task, str):
|
| 448 |
raise TypeError("Task override must be a string.")
|
RevLogiXenv_v0/grader.py
CHANGED
|
@@ -33,10 +33,17 @@ class Grader:
|
|
| 33 |
Final scores are strictly clamped to the (0.01, 0.99) range to comply
|
| 34 |
with hackathon validation requirements.
|
| 35 |
|
| 36 |
-
Scoring:
|
| 37 |
-
- easy: final_score = margin_score
|
| 38 |
- medium: final_score = margin_score
|
| 39 |
-
- hard:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
"""
|
| 41 |
MIN_SCORE = 0.01
|
| 42 |
MAX_SCORE = 0.99
|
|
@@ -92,12 +99,24 @@ class Grader:
|
|
| 92 |
raise RuntimeError(
|
| 93 |
"Cannot score an incomplete episode. Roll out until observation.done == True."
|
| 94 |
)
|
| 95 |
-
if not
|
| 96 |
raise RuntimeError(
|
| 97 |
"Cannot score episode before reset. Call env.reset(...) and run at least one rollout."
|
| 98 |
)
|
| 99 |
|
| 100 |
def _compute_scores(self) -> dict:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
if self.oracle is None:
|
| 102 |
raise RuntimeError("Oracle not initialized. Call grade() first.")
|
| 103 |
|
|
@@ -128,6 +147,14 @@ class Grader:
|
|
| 128 |
"f1": fraud.f1,
|
| 129 |
},
|
| 130 |
"final_score": final_score,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
}
|
| 132 |
|
| 133 |
def _compute_fraud_metrics(self) -> FraudMetrics:
|
|
@@ -144,10 +171,10 @@ class Grader:
|
|
| 144 |
|
| 145 |
snapshot_by_item = {
|
| 146 |
int(record.item_id): record.hidden_condition
|
| 147 |
-
for record in
|
| 148 |
}
|
| 149 |
|
| 150 |
-
for resolution in self.env.
|
| 151 |
hidden = snapshot_by_item.get(int(resolution.item_id))
|
| 152 |
is_fraud = hidden == Condition.FRAUDULENT
|
| 153 |
flagged = resolution.action == DispositionAction.FLAG_FRAUD
|
|
|
|
| 33 |
Final scores are strictly clamped to the (0.01, 0.99) range to comply
|
| 34 |
with hackathon validation requirements.
|
| 35 |
|
| 36 |
+
Scoring (Grader mode — used by Python API):
|
| 37 |
+
- easy: final_score = margin_score (oracle-normalized profit)
|
| 38 |
- medium: final_score = margin_score
|
| 39 |
+
- hard: final_score = 0.6 * margin_score + 0.4 * fraud_f1
|
| 40 |
+
|
| 41 |
+
Note: There are TWO independent scoring contexts in RevLogiXenv:
|
| 42 |
+
1. Inference mode (hackathon Phase 2): score = mean(step_rewards), clamped to (0.01, 0.99).
|
| 43 |
+
This is what inference.py outputs and is parsed by the hackathon validator.
|
| 44 |
+
2. Grader mode (local Python evaluation): oracle-margin + fraud-F1 composite, above.
|
| 45 |
+
This is what the Grader class computes and is used for local policy comparison.
|
| 46 |
+
The openenv.yaml manifest declares the inference-mode formula (mean step rewards).
|
| 47 |
"""
|
| 48 |
MIN_SCORE = 0.01
|
| 49 |
MAX_SCORE = 0.99
|
|
|
|
| 99 |
raise RuntimeError(
|
| 100 |
"Cannot score an incomplete episode. Roll out until observation.done == True."
|
| 101 |
)
|
| 102 |
+
if not self.env.episode_snapshot:
|
| 103 |
raise RuntimeError(
|
| 104 |
"Cannot score episode before reset. Call env.reset(...) and run at least one rollout."
|
| 105 |
)
|
| 106 |
|
| 107 |
def _compute_scores(self) -> dict:
|
| 108 |
+
"""
|
| 109 |
+
Compute bounded scores for the current episode.
|
| 110 |
+
|
| 111 |
+
Returns a dict with two scoring annotations:
|
| 112 |
+
- _scoring_mode: always "grader" for this method
|
| 113 |
+
- _score_formula: human-readable formula string
|
| 114 |
+
|
| 115 |
+
The actual final_score value is computed in "grader mode"
|
| 116 |
+
(oracle-normalized margin, with fraud-F1 composite for hard tasks).
|
| 117 |
+
This differs from the inference-mode score (mean of clamped step
|
| 118 |
+
rewards) declared in openenv.yaml and emitted by inference.py.
|
| 119 |
+
"""
|
| 120 |
if self.oracle is None:
|
| 121 |
raise RuntimeError("Oracle not initialized. Call grade() first.")
|
| 122 |
|
|
|
|
| 147 |
"f1": fraud.f1,
|
| 148 |
},
|
| 149 |
"final_score": final_score,
|
| 150 |
+
# Explicit scoring mode marker so consumers know which formula applies.
|
| 151 |
+
# Grader mode: oracle-normalized margin + optional fraud F1 composite.
|
| 152 |
+
"_scoring_mode": "grader",
|
| 153 |
+
"_score_formula": (
|
| 154 |
+
"0.6 * margin_score + 0.4 * fraud_f1"
|
| 155 |
+
if self.task == "hard"
|
| 156 |
+
else "margin_score"
|
| 157 |
+
),
|
| 158 |
}
|
| 159 |
|
| 160 |
def _compute_fraud_metrics(self) -> FraudMetrics:
|
|
|
|
| 171 |
|
| 172 |
snapshot_by_item = {
|
| 173 |
int(record.item_id): record.hidden_condition
|
| 174 |
+
for record in self.env.episode_snapshot
|
| 175 |
}
|
| 176 |
|
| 177 |
+
for resolution in self.env.full_resolution_history:
|
| 178 |
hidden = snapshot_by_item.get(int(resolution.item_id))
|
| 179 |
is_fraud = hidden == Condition.FRAUDULENT
|
| 180 |
flagged = resolution.action == DispositionAction.FLAG_FRAUD
|
RevLogiXenv_v0/oracle.py
CHANGED
|
@@ -34,7 +34,7 @@ class Oracle:
|
|
| 34 |
self._precompute()
|
| 35 |
|
| 36 |
def _build_snapshot(self) -> list[OracleRecord]:
|
| 37 |
-
raw =
|
| 38 |
if not raw:
|
| 39 |
raise ValueError(
|
| 40 |
"Environment snapshot is empty. Reset the environment before creating Oracle."
|
|
|
|
| 34 |
self._precompute()
|
| 35 |
|
| 36 |
def _build_snapshot(self) -> list[OracleRecord]:
|
| 37 |
+
raw = self._env.episode_snapshot
|
| 38 |
if not raw:
|
| 39 |
raise ValueError(
|
| 40 |
"Environment snapshot is empty. Reset the environment before creating Oracle."
|
RevLogiXenv_v0/prompting.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Shared LLM prompting utilities for RevLogiXenv.
|
| 3 |
+
|
| 4 |
+
Provides unified prompt construction and action parsing for both the
|
| 5 |
+
hackathon inference entrypoint (inference.py) and the baseline agent
|
| 6 |
+
(baseline.py).
|
| 7 |
+
|
| 8 |
+
Two system prompts are exported to allow callers to choose the level of
|
| 9 |
+
detail:
|
| 10 |
+
- MINIMAL_SYSTEM_PROMPT: concise, single-sentence instruction (inference.py style)
|
| 11 |
+
- FULL_SYSTEM_PROMPT: detailed domain briefing with noise model and rules (baseline.py style)
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import re
|
| 17 |
+
|
| 18 |
+
from .models import DispositionAction, ReturnsAction, ReturnsObservation
|
| 19 |
+
|
| 20 |
+
# Minimal prompt — used by inference.py for hackathon Phase 2 validation.
|
| 21 |
+
MINIMAL_SYSTEM_PROMPT = (
|
| 22 |
+
"You are a reverse-logistics triage agent. Output EXACTLY ONE WORD from this list: "
|
| 23 |
+
"RESELL_FULL, RESELL_DISCOUNT_15, RESELL_DISCOUNT_30, RESELL_DISCOUNT_50, REFURBISH, "
|
| 24 |
+
"DISPOSE, FLAG_FRAUD, INSPECT, WAIT. No explanation. No punctuation. Just the single word."
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
# Full prompt — used by baseline.py for richer LLM reasoning.
|
| 28 |
+
FULL_SYSTEM_PROMPT = """You are a reverse-logistics triage agent for AutonomousReturns-v0.
|
| 29 |
+
|
| 30 |
+
NOISE MODEL (CRITICAL):
|
| 31 |
+
- Items have HIDDEN TRUE CONDITION: perfect, lightly_used, damaged, fraudulent
|
| 32 |
+
- You observe NOISY condition scores (0-10), NOT ground truth
|
| 33 |
+
- Fraudulent items score HIGH (mean=7.8, std=0.70) to mimic quality items
|
| 34 |
+
- Perfect items score highest (mean=8.9), damaged score lowest (mean=3.0)
|
| 35 |
+
- High score + fraud history = SUSPICIOUS (may be fraudulent mimicking quality)
|
| 36 |
+
- High score + clean history = likely good
|
| 37 |
+
|
| 38 |
+
CUSTOMER PROFILE INTERPRETATION:
|
| 39 |
+
- RETURNS: number of previous returns (0-4+)
|
| 40 |
+
- HIST_FRAUD_FLAGS: past fraud indicators (0 = clean, 2+ = high risk)
|
| 41 |
+
- RECENT_ABUSE: True if customer shows abuse patterns
|
| 42 |
+
|
| 43 |
+
FRAUD INDICATORS (strongest to weakest):
|
| 44 |
+
1. 'wrong item' or 'missing parts' in return reason
|
| 45 |
+
2. high HIST_FRAUD_FLAGS (>0)
|
| 46 |
+
3. RECENT_ABUSE = True
|
| 47 |
+
4. 'mismatch'/'suspect'/'serial' in inspection note
|
| 48 |
+
5. 'missing' packaging
|
| 49 |
+
|
| 50 |
+
DISPOSITION VALUE GUIDE:
|
| 51 |
+
- RESELL_FULL: Best for PERFECT ($0.98x). Bad for damaged ($0.22x) or fraud ($0.10x)
|
| 52 |
+
- RESELL_DISCOUNT_15: Good for LIGHTLY_USED ($0.82x). Avoid on fraud ($0.22x)
|
| 53 |
+
- RESELL_DISCOUNT_30: General lightly-used fallback
|
| 54 |
+
- RESELL_DISCOUNT_50: Damaged items, low-value situations
|
| 55 |
+
- REFURBISH: High-price damaged items only ($18 fixed + 15% variable)
|
| 56 |
+
- DISPOSE: Low-price (<$90) damaged items
|
| 57 |
+
- FLAG_FRAUD: Only with 3+ fraud signals. Costs $6+$0.03x. Recovers $0.88x if correct
|
| 58 |
+
- INSPECT: Uncertain expensive items ($250+). Reduces noise 14x but costs $5
|
| 59 |
+
|
| 60 |
+
RULES:
|
| 61 |
+
- score > 8.0 AND no fraud history → likely PERFECT → RESELL_FULL or RESELL_DISCOUNT_15
|
| 62 |
+
- score 5.0-7.5 AND fraud signals → INSPECT before deciding
|
| 63 |
+
- score < 4.0 → likely damaged → REFURBISH (high$) or DISPOSE (low$)
|
| 64 |
+
- NEVER flag fraud with < 3 signals (causes expensive false positives)
|
| 65 |
+
|
| 66 |
+
Choose EXACTLY ONE action: RESELL_FULL, RESELL_DISCOUNT_15, RESELL_DISCOUNT_30, RESELL_DISCOUNT_50, REFURBISH, DISPOSE, FLAG_FRAUD, INSPECT, WAIT
|
| 67 |
+
Return ONLY the action name. No explanation. No punctuation."""
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def observation_to_prompt_inference(obs: ReturnsObservation) -> str:
|
| 71 |
+
"""
|
| 72 |
+
Build a concise LLM prompt from an observation.
|
| 73 |
+
Used by inference.py for hackathon Phase 2 validation.
|
| 74 |
+
"""
|
| 75 |
+
if obs.current_item is None:
|
| 76 |
+
return "No items left. Output exactly: wait"
|
| 77 |
+
|
| 78 |
+
item = obs.current_item
|
| 79 |
+
lines: list[str] = [
|
| 80 |
+
f"Product: {item.product_title}",
|
| 81 |
+
f"Price: ${item.price:.2f}",
|
| 82 |
+
f"Score: {item.noisy_condition_score:.1f}/10",
|
| 83 |
+
f"Reason: {item.stated_return_reason}",
|
| 84 |
+
f"Returns: {item.customer_profile.total_returns}",
|
| 85 |
+
f"Fraud flags: {item.customer_profile.historical_fraud_flags}",
|
| 86 |
+
f"Abuse: {item.customer_profile.recent_abuse_signals}",
|
| 87 |
+
f"Packaging: {item.packaging_condition}",
|
| 88 |
+
]
|
| 89 |
+
if item.damage_flags:
|
| 90 |
+
lines.append(f"Damage: {', '.join(item.damage_flags)}")
|
| 91 |
+
if item.inspection_note:
|
| 92 |
+
lines.append(f"Note: {item.inspection_note}")
|
| 93 |
+
|
| 94 |
+
lines.append(f"Remaining: {obs.remaining_items}, Pending: {obs.pending_resolution_count}")
|
| 95 |
+
lines.append(f"Ledger: ${obs.running_ledger:.2f}")
|
| 96 |
+
if obs.last_three_resolutions:
|
| 97 |
+
lines.append("Recent:")
|
| 98 |
+
for resolution in obs.last_three_resolutions:
|
| 99 |
+
lines.append(f"{resolution.action.value}={resolution.normalized_reward:.2f}")
|
| 100 |
+
lines.append("")
|
| 101 |
+
lines.append(
|
| 102 |
+
"Output ONE word: RESELL_FULL, RESELL_DISCOUNT_15, RESELL_DISCOUNT_30, "
|
| 103 |
+
"RESELL_DISCOUNT_50, REFURBISH, DISPOSE, FLAG_FRAUD, INSPECT, or WAIT"
|
| 104 |
+
)
|
| 105 |
+
return "\n".join(lines)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def observation_to_prompt_baseline(obs: ReturnsObservation) -> str:
|
| 109 |
+
"""
|
| 110 |
+
Build a detailed LLM prompt from an observation.
|
| 111 |
+
Used by baseline.py for richer LLM reasoning.
|
| 112 |
+
"""
|
| 113 |
+
if obs.current_item is None:
|
| 114 |
+
return (
|
| 115 |
+
"No current item is available. "
|
| 116 |
+
f"Pending resolutions: {obs.pending_resolution_count}. "
|
| 117 |
+
"Output exactly one action token: wait"
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
item = obs.current_item
|
| 121 |
+
lines: list[str] = [
|
| 122 |
+
f"Product: {item.product_title}",
|
| 123 |
+
f"Category: {item.category}",
|
| 124 |
+
f"Price: {item.price:.2f}",
|
| 125 |
+
f"Noisy condition score: {item.noisy_condition_score:.2f}/10",
|
| 126 |
+
f"Return reason: {item.stated_return_reason}",
|
| 127 |
+
f"Customer profile: RETURNS={item.customer_profile.total_returns}, "
|
| 128 |
+
f"HIST_FRAUD_FLAGS={item.customer_profile.historical_fraud_flags}, "
|
| 129 |
+
f"RECENT_ABUSE={item.customer_profile.recent_abuse_signals}",
|
| 130 |
+
f"Packaging: {item.packaging_condition}",
|
| 131 |
+
]
|
| 132 |
+
if item.damage_flags:
|
| 133 |
+
lines.append(f"Damage flags: {', '.join(item.damage_flags)}")
|
| 134 |
+
if item.inspection_note:
|
| 135 |
+
lines.append(f"Inspection note: {item.inspection_note}")
|
| 136 |
+
|
| 137 |
+
lines.append(f"Remaining items: {obs.remaining_items}")
|
| 138 |
+
lines.append(f"Pending resolutions: {obs.pending_resolution_count}")
|
| 139 |
+
lines.append(f"Running ledger: {obs.running_ledger:.4f}")
|
| 140 |
+
|
| 141 |
+
if obs.last_three_resolutions:
|
| 142 |
+
lines.append("Recent outcomes:")
|
| 143 |
+
for r in obs.last_three_resolutions:
|
| 144 |
+
lines.append(
|
| 145 |
+
f"- action={r.action.value} normalized_reward={r.normalized_reward:.4f}"
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
lines.append("")
|
| 149 |
+
lines.append("Choose one action token exactly from:")
|
| 150 |
+
action_strings = [a.value for a in DispositionAction]
|
| 151 |
+
lines.append(", ".join(action_strings))
|
| 152 |
+
return "\n".join(lines)
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def parse_action_inference(response_text: str, obs: ReturnsObservation) -> ReturnsAction:
|
| 156 |
+
"""
|
| 157 |
+
Parse an LLM response into a ReturnsAction using regex.
|
| 158 |
+
Searches all lines and returns the last match found.
|
| 159 |
+
Falls back to WAIT if no match.
|
| 160 |
+
Used by inference.py.
|
| 161 |
+
"""
|
| 162 |
+
if obs.current_item is None:
|
| 163 |
+
return ReturnsAction(action=DispositionAction.WAIT)
|
| 164 |
+
|
| 165 |
+
text = (response_text or "").lower().strip()
|
| 166 |
+
actions = [
|
| 167 |
+
"resell_full",
|
| 168 |
+
"resell_discount_15",
|
| 169 |
+
"resell_discount_30",
|
| 170 |
+
"resell_discount_50",
|
| 171 |
+
"refurbish",
|
| 172 |
+
"dispose",
|
| 173 |
+
"flag_fraud",
|
| 174 |
+
"inspect",
|
| 175 |
+
"wait",
|
| 176 |
+
]
|
| 177 |
+
pattern = r"\b(" + "|".join(sorted(actions, key=len, reverse=True)) + r")\b"
|
| 178 |
+
|
| 179 |
+
lines = [line.strip() for line in text.split("\n") if line.strip()]
|
| 180 |
+
for line in reversed(lines):
|
| 181 |
+
line_match = re.fullmatch(pattern, line)
|
| 182 |
+
if line_match:
|
| 183 |
+
matched = line_match.group(1)
|
| 184 |
+
for action in DispositionAction:
|
| 185 |
+
if action.value == matched:
|
| 186 |
+
return ReturnsAction(action=action)
|
| 187 |
+
|
| 188 |
+
matches = re.findall(pattern, text)
|
| 189 |
+
if matches:
|
| 190 |
+
matched = matches[-1]
|
| 191 |
+
for action in DispositionAction:
|
| 192 |
+
if action.value == matched:
|
| 193 |
+
return ReturnsAction(action=action)
|
| 194 |
+
|
| 195 |
+
return ReturnsAction(action=DispositionAction.WAIT)
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def parse_action_baseline(response_text: str, obs: ReturnsObservation) -> ReturnsAction:
|
| 199 |
+
"""
|
| 200 |
+
Parse an LLM response into a ReturnsAction using exact + substring matching.
|
| 201 |
+
Tries exact match first, then substring match, then falls back to default.
|
| 202 |
+
Used by baseline.py.
|
| 203 |
+
"""
|
| 204 |
+
text = (response_text or "").strip().lower()
|
| 205 |
+
|
| 206 |
+
if obs.current_item is None:
|
| 207 |
+
return ReturnsAction(action=DispositionAction.WAIT)
|
| 208 |
+
|
| 209 |
+
for act in DispositionAction:
|
| 210 |
+
if act.value == text:
|
| 211 |
+
return ReturnsAction(action=act)
|
| 212 |
+
|
| 213 |
+
for act in DispositionAction:
|
| 214 |
+
if act.value in text:
|
| 215 |
+
return ReturnsAction(action=act)
|
| 216 |
+
|
| 217 |
+
return ReturnsAction(action=DispositionAction.RESELL_DISCOUNT_30)
|
inference.py
CHANGED
|
@@ -24,6 +24,11 @@ from openai import OpenAI
|
|
| 24 |
|
| 25 |
from RevLogiXenv_v0 import AutonomousReturnsEnv
|
| 26 |
from RevLogiXenv_v0.models import DispositionAction, ReturnsAction, ReturnsObservation
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
load_dotenv()
|
| 29 |
|
|
@@ -48,12 +53,6 @@ MAX_TOKENS = 64
|
|
| 48 |
_REWARD_MIN = 0.01
|
| 49 |
_REWARD_MAX = 0.99
|
| 50 |
|
| 51 |
-
LLM_SYSTEM_PROMPT = (
|
| 52 |
-
"You are a reverse-logistics triage agent. Output EXACTLY ONE WORD from this list: "
|
| 53 |
-
"RESELL_FULL, RESELL_DISCOUNT_15, RESELL_DISCOUNT_30, RESELL_DISCOUNT_50, REFURBISH, "
|
| 54 |
-
"DISPOSE, FLAG_FRAUD, INSPECT, WAIT. No explanation. No punctuation. Just the single word."
|
| 55 |
-
)
|
| 56 |
-
|
| 57 |
|
| 58 |
def log_start(task: str, env: str, model: str) -> None:
|
| 59 |
print(f"[START] task={task} env={env} model={model}", flush=True)
|
|
@@ -88,83 +87,12 @@ def _clamp_reward(r: float) -> float:
|
|
| 88 |
return max(_REWARD_MIN, min(_REWARD_MAX, r))
|
| 89 |
|
| 90 |
|
| 91 |
-
def observation_to_prompt(obs: ReturnsObservation) -> str:
|
| 92 |
-
if obs.current_item is None:
|
| 93 |
-
return "No items left. Output exactly: wait"
|
| 94 |
-
|
| 95 |
-
item = obs.current_item
|
| 96 |
-
lines: list[str] = [
|
| 97 |
-
f"Product: {item.product_title}",
|
| 98 |
-
f"Price: ${item.price:.2f}",
|
| 99 |
-
f"Score: {item.noisy_condition_score:.1f}/10",
|
| 100 |
-
f"Reason: {item.stated_return_reason}",
|
| 101 |
-
f"Returns: {item.customer_profile.total_returns}",
|
| 102 |
-
f"Fraud flags: {item.customer_profile.historical_fraud_flags}",
|
| 103 |
-
f"Abuse: {item.customer_profile.recent_abuse_signals}",
|
| 104 |
-
f"Packaging: {item.packaging_condition}",
|
| 105 |
-
]
|
| 106 |
-
if item.damage_flags:
|
| 107 |
-
lines.append(f"Damage: {', '.join(item.damage_flags)}")
|
| 108 |
-
if item.inspection_note:
|
| 109 |
-
lines.append(f"Note: {item.inspection_note}")
|
| 110 |
-
|
| 111 |
-
lines.append(f"Remaining: {obs.remaining_items}, Pending: {obs.pending_resolution_count}")
|
| 112 |
-
lines.append(f"Ledger: ${obs.running_ledger:.2f}")
|
| 113 |
-
if obs.last_three_resolutions:
|
| 114 |
-
lines.append("Recent:")
|
| 115 |
-
for resolution in obs.last_three_resolutions:
|
| 116 |
-
lines.append(f"{resolution.action.value}={resolution.normalized_reward:.2f}")
|
| 117 |
-
lines.append("")
|
| 118 |
-
lines.append(
|
| 119 |
-
"Output ONE word: RESELL_FULL, RESELL_DISCOUNT_15, RESELL_DISCOUNT_30, "
|
| 120 |
-
"RESELL_DISCOUNT_50, REFURBISH, DISPOSE, FLAG_FRAUD, INSPECT, or WAIT"
|
| 121 |
-
)
|
| 122 |
-
return "\n".join(lines)
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
def parse_action(response_text: str, obs: ReturnsObservation) -> ReturnsAction:
|
| 126 |
-
if obs.current_item is None:
|
| 127 |
-
return ReturnsAction(action=DispositionAction.WAIT)
|
| 128 |
-
|
| 129 |
-
text = (response_text or "").lower().strip()
|
| 130 |
-
actions = [
|
| 131 |
-
"resell_full",
|
| 132 |
-
"resell_discount_15",
|
| 133 |
-
"resell_discount_30",
|
| 134 |
-
"resell_discount_50",
|
| 135 |
-
"refurbish",
|
| 136 |
-
"dispose",
|
| 137 |
-
"flag_fraud",
|
| 138 |
-
"inspect",
|
| 139 |
-
"wait",
|
| 140 |
-
]
|
| 141 |
-
pattern = r"\b(" + "|".join(sorted(actions, key=len, reverse=True)) + r")\b"
|
| 142 |
-
|
| 143 |
-
lines = [line.strip() for line in text.split("\n") if line.strip()]
|
| 144 |
-
for line in reversed(lines):
|
| 145 |
-
line_match = re.fullmatch(pattern, line)
|
| 146 |
-
if line_match:
|
| 147 |
-
matched = line_match.group(1)
|
| 148 |
-
for action in DispositionAction:
|
| 149 |
-
if action.value == matched:
|
| 150 |
-
return ReturnsAction(action=action)
|
| 151 |
-
|
| 152 |
-
matches = re.findall(pattern, text)
|
| 153 |
-
if matches:
|
| 154 |
-
matched = matches[-1]
|
| 155 |
-
for action in DispositionAction:
|
| 156 |
-
if action.value == matched:
|
| 157 |
-
return ReturnsAction(action=action)
|
| 158 |
-
|
| 159 |
-
return ReturnsAction(action=DispositionAction.WAIT)
|
| 160 |
-
|
| 161 |
-
|
| 162 |
def get_model_response(client: OpenAI, prompt: str) -> str:
|
| 163 |
try:
|
| 164 |
completion = client.chat.completions.create(
|
| 165 |
model=MODEL_NAME,
|
| 166 |
messages=[
|
| 167 |
-
{"role": "system", "content":
|
| 168 |
{"role": "user", "content": prompt},
|
| 169 |
],
|
| 170 |
temperature=TEMPERATURE,
|
|
|
|
| 24 |
|
| 25 |
from RevLogiXenv_v0 import AutonomousReturnsEnv
|
| 26 |
from RevLogiXenv_v0.models import DispositionAction, ReturnsAction, ReturnsObservation
|
| 27 |
+
from RevLogiXenv_v0.prompting import (
|
| 28 |
+
MINIMAL_SYSTEM_PROMPT,
|
| 29 |
+
observation_to_prompt_inference as observation_to_prompt,
|
| 30 |
+
parse_action_inference as parse_action,
|
| 31 |
+
)
|
| 32 |
|
| 33 |
load_dotenv()
|
| 34 |
|
|
|
|
| 53 |
_REWARD_MIN = 0.01
|
| 54 |
_REWARD_MAX = 0.99
|
| 55 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
|
| 57 |
def log_start(task: str, env: str, model: str) -> None:
|
| 58 |
print(f"[START] task={task} env={env} model={model}", flush=True)
|
|
|
|
| 87 |
return max(_REWARD_MIN, min(_REWARD_MAX, r))
|
| 88 |
|
| 89 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
def get_model_response(client: OpenAI, prompt: str) -> str:
|
| 91 |
try:
|
| 92 |
completion = client.chat.completions.create(
|
| 93 |
model=MODEL_NAME,
|
| 94 |
messages=[
|
| 95 |
+
{"role": "system", "content": MINIMAL_SYSTEM_PROMPT},
|
| 96 |
{"role": "user", "content": prompt},
|
| 97 |
],
|
| 98 |
temperature=TEMPERATURE,
|
openenv.yaml
CHANGED
|
@@ -38,11 +38,8 @@ tasks:
|
|
| 38 |
noise_level: hard
|
| 39 |
delay_type: variable
|
| 40 |
scoring:
|
| 41 |
-
type:
|
| 42 |
range: [0.01, 0.99]
|
| 43 |
-
weights:
|
| 44 |
-
margin: 0.6
|
| 45 |
-
fraud_f1: 0.4
|
| 46 |
|
| 47 |
hard:
|
| 48 |
description: "Extreme uncertainty + adversarial noise - new extra-hard difficulty"
|
|
@@ -125,4 +122,12 @@ reward:
|
|
| 125 |
scoring:
|
| 126 |
reward_range: [0.01, 0.99]
|
| 127 |
success_threshold: 0.5
|
|
|
|
|
|
|
|
|
|
| 128 |
score_formula: mean(step_rewards)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
noise_level: hard
|
| 39 |
delay_type: variable
|
| 40 |
scoring:
|
| 41 |
+
type: margin
|
| 42 |
range: [0.01, 0.99]
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
hard:
|
| 45 |
description: "Extreme uncertainty + adversarial noise - new extra-hard difficulty"
|
|
|
|
| 122 |
scoring:
|
| 123 |
reward_range: [0.01, 0.99]
|
| 124 |
success_threshold: 0.5
|
| 125 |
+
# Inference-mode score: mean of per-step shaped rewards, clamped to (0.01, 0.99).
|
| 126 |
+
# The hackathon Phase 2 validator derives the task score from the rewards list
|
| 127 |
+
# emitted by inference.py, so this must match what inference.py computes.
|
| 128 |
score_formula: mean(step_rewards)
|
| 129 |
+
# Grader-mode score (Python API via Grader class):
|
| 130 |
+
# easy/medium: final_score = oracle_margin_score (oracle-normalized profit)
|
| 131 |
+
# hard: final_score = 0.6 * oracle_margin_score + 0.4 * fraud_f1
|
| 132 |
+
# These two scoring modes target different use cases (hackathon validation vs
|
| 133 |
+
# local Python-based evaluation) and are intentionally kept separate.
|
tests/test_grader_edge_cases.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tests for Grader edge cases and fraud metric robustness.
|
| 3 |
+
|
| 4 |
+
Covers:
|
| 5 |
+
- No fraud items present, agent does not flag anything → f1 near perfect
|
| 6 |
+
- No fraud items present, agent does flag something → f1 low floor
|
| 7 |
+
- All fraud items correctly flagged → f1 perfect
|
| 8 |
+
- Legacy easy task alias still works
|
| 9 |
+
- extra_hard noise level is accessible
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import sys
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
sys.path.insert(0, str(Path(__file__).parent.parent / "RevLogiXenv_v0"))
|
| 18 |
+
|
| 19 |
+
from RevLogiXenv_v0 import AutonomousReturnsEnv, Grader
|
| 20 |
+
from RevLogiXenv_v0.models import DispositionAction, ReturnsAction
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _run_episode_with_action(task: str, seed: int, policy_fn) -> AutonomousReturnsEnv:
|
| 24 |
+
"""Helper: run a full episode with a given policy function."""
|
| 25 |
+
env = AutonomousReturnsEnv(task)
|
| 26 |
+
obs = env.reset(seed=seed)
|
| 27 |
+
while not obs.done:
|
| 28 |
+
action = policy_fn(obs)
|
| 29 |
+
obs = env.step(action)
|
| 30 |
+
return env
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _always_wait(obs):
|
| 34 |
+
"""Policy that always waits — useful for draining queues without penalty."""
|
| 35 |
+
return ReturnsAction(action=DispositionAction.WAIT)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _always_resell_full(obs):
|
| 39 |
+
"""Policy that always resells at full — used to probe fraud metrics."""
|
| 40 |
+
return ReturnsAction(action=DispositionAction.RESELL_FULL)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def test_no_fraud_items_no_flags_yields_high_f1():
|
| 44 |
+
"""
|
| 45 |
+
When no fraudulent items exist in the episode and the agent never flags
|
| 46 |
+
anything, the fraud F1 should be clamped to near-perfect (0.99).
|
| 47 |
+
"""
|
| 48 |
+
# easy task has fraud_enabled: false — guaranteed no fraud items
|
| 49 |
+
env = _run_episode_with_action("easy", seed=7, policy_fn=_always_wait)
|
| 50 |
+
grader = Grader("easy")
|
| 51 |
+
result = grader.score_completed_episode(env=env)
|
| 52 |
+
|
| 53 |
+
f1 = result["fraud_metrics"]["f1"]
|
| 54 |
+
assert f1 == 0.99, f"Expected f1=0.99 for no-fraud task, got {f1}"
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def test_easy_task_alias_legacy_easy_still_works():
|
| 58 |
+
"""
|
| 59 |
+
The _legacy_easy task alias is declared in openenv.yaml and must be
|
| 60 |
+
accepted by the runtime environment.
|
| 61 |
+
"""
|
| 62 |
+
env = AutonomousReturnsEnv("_legacy_easy")
|
| 63 |
+
obs = env.reset(seed=1)
|
| 64 |
+
assert obs is not None
|
| 65 |
+
assert env._config["num_items"] == 10
|
| 66 |
+
assert env._config["noise_level"] == "easy"
|
| 67 |
+
assert env._config["delay_type"] == "immediate"
|
| 68 |
+
|
| 69 |
+
# Backward compatibility alias should continue to work as before.
|
| 70 |
+
legacy_alias_env = AutonomousReturnsEnv("_old_easy")
|
| 71 |
+
alias_obs = legacy_alias_env.reset(seed=1)
|
| 72 |
+
assert alias_obs is not None
|
| 73 |
+
steps = 0
|
| 74 |
+
while not obs.done and steps < 50:
|
| 75 |
+
action = ReturnsAction(action=DispositionAction.WAIT)
|
| 76 |
+
obs = env.step(action)
|
| 77 |
+
steps += 1
|
| 78 |
+
assert steps > 0
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def test_extra_hard_noise_level_accessible():
|
| 82 |
+
"""
|
| 83 |
+
Verify that the 'extra_hard' noise level can be set via task configuration
|
| 84 |
+
and does not raise errors during item generation.
|
| 85 |
+
"""
|
| 86 |
+
# 'hard' task uses noise_level: extra_hard — verify this works end-to-end
|
| 87 |
+
env = AutonomousReturnsEnv("hard")
|
| 88 |
+
obs = env.reset(seed=999)
|
| 89 |
+
steps = 0
|
| 90 |
+
while not obs.done and steps < 100:
|
| 91 |
+
action = ReturnsAction(action=DispositionAction.RESELL_DISCOUNT_30)
|
| 92 |
+
obs = env.step(action)
|
| 93 |
+
steps += 1
|
| 94 |
+
assert obs.done, "hard task should complete within 100 steps"
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def test_grader_rejects_incomplete_episode():
|
| 98 |
+
"""Grader must raise RuntimeError when scoring an incomplete episode."""
|
| 99 |
+
env = AutonomousReturnsEnv("easy")
|
| 100 |
+
env.reset(seed=42)
|
| 101 |
+
# Don't run any steps — episode is incomplete
|
| 102 |
+
grader = Grader("easy")
|
| 103 |
+
try:
|
| 104 |
+
grader.score_completed_episode(env=env)
|
| 105 |
+
assert False, "Expected RuntimeError for incomplete episode"
|
| 106 |
+
except RuntimeError as e:
|
| 107 |
+
assert "incomplete" in str(e).lower()
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def test_grader_rejects_no_snapshot():
|
| 111 |
+
"""Grader must raise RuntimeError when environment has no episode snapshot."""
|
| 112 |
+
# Create env but don't call reset()
|
| 113 |
+
env = AutonomousReturnsEnv("easy")
|
| 114 |
+
grader = Grader("easy")
|
| 115 |
+
try:
|
| 116 |
+
grader.score_completed_episode(env=env)
|
| 117 |
+
assert False, "Expected RuntimeError for missing episode snapshot"
|
| 118 |
+
except RuntimeError as e:
|
| 119 |
+
assert "snapshot" in str(e).lower() or "reset" in str(e).lower()
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def test_reward_detail_components_populated():
|
| 123 |
+
"""
|
| 124 |
+
Verify that reward_detail.components is populated during normal episode execution.
|
| 125 |
+
At least one step should have non-empty components dict.
|
| 126 |
+
"""
|
| 127 |
+
env = AutonomousReturnsEnv("medium")
|
| 128 |
+
obs = env.reset(seed=55)
|
| 129 |
+
|
| 130 |
+
components_seen: list[dict] = []
|
| 131 |
+
while not obs.done:
|
| 132 |
+
action = ReturnsAction(action=DispositionAction.RESELL_DISCOUNT_30)
|
| 133 |
+
obs = env.step(action)
|
| 134 |
+
if obs.reward_detail and obs.reward_detail.components:
|
| 135 |
+
components_seen.append(obs.reward_detail.components)
|
| 136 |
+
|
| 137 |
+
assert len(components_seen) > 0, "Expected at least one step with populated components"
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def test_fraud_metrics_precision_recall_clamped():
|
| 141 |
+
"""All fraud precision/recall/f1 values must be within [0.01, 0.99] inclusive."""
|
| 142 |
+
env = _run_episode_with_action("medium", seed=21, policy_fn=_always_resell_full)
|
| 143 |
+
grader = Grader("medium")
|
| 144 |
+
result = grader.score_completed_episode(env=env)
|
| 145 |
+
|
| 146 |
+
fm = result["fraud_metrics"]
|
| 147 |
+
for key in ("precision", "recall", "f1"):
|
| 148 |
+
score = fm[key]
|
| 149 |
+
# Clamped scores are in the inclusive [0.01, 0.99] range per grader._clamp_score
|
| 150 |
+
assert 0.01 <= score <= 0.99, f"{key}={score} outside [0.01, 0.99]"
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
if __name__ == "__main__":
|
| 154 |
+
test_no_fraud_items_no_flags_yields_high_f1()
|
| 155 |
+
test_easy_task_alias_legacy_easy_still_works()
|
| 156 |
+
test_extra_hard_noise_level_accessible()
|
| 157 |
+
test_grader_rejects_incomplete_episode()
|
| 158 |
+
test_grader_rejects_no_snapshot()
|
| 159 |
+
test_reward_detail_components_populated()
|
| 160 |
+
test_fraud_metrics_precision_recall_clamped()
|
| 161 |
+
print("All grader edge-case tests passed.")
|
tests/test_policies_improvements.py
CHANGED
|
@@ -111,3 +111,39 @@ def test_policy_config_inspect_thresholds():
|
|
| 111 |
assert config.inspect_score_low == 5.0
|
| 112 |
assert config.inspect_score_high == 7.5
|
| 113 |
assert config.inspect_min_price == 250.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
assert config.inspect_score_low == 5.0
|
| 112 |
assert config.inspect_score_high == 7.5
|
| 113 |
assert config.inspect_min_price == 250.0
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def test_heuristic_does_not_repeat_inspect_after_note_update():
|
| 117 |
+
"""
|
| 118 |
+
After an INSPECT action updates the item's inspection note, the next policy
|
| 119 |
+
decision for the same item should not INSPECT again.
|
| 120 |
+
"""
|
| 121 |
+
policy = HeuristicPolicy()
|
| 122 |
+
env = AutonomousReturnsEnv("hard")
|
| 123 |
+
obs = env.reset(seed=42)
|
| 124 |
+
|
| 125 |
+
inspected_once = False
|
| 126 |
+
steps = 0
|
| 127 |
+
|
| 128 |
+
while not obs.done and steps < 200:
|
| 129 |
+
action = policy(obs)
|
| 130 |
+
|
| 131 |
+
if not inspected_once:
|
| 132 |
+
if action.action == DispositionAction.INSPECT and obs.current_item is not None:
|
| 133 |
+
inspected_once = True
|
| 134 |
+
item_id = obs.current_item.item_id
|
| 135 |
+
obs = env.step(action)
|
| 136 |
+
assert obs.current_item is not None
|
| 137 |
+
assert obs.current_item.item_id == item_id
|
| 138 |
+
assert obs.current_item.inspection_note is not None
|
| 139 |
+
|
| 140 |
+
next_action = policy(obs)
|
| 141 |
+
assert next_action.action != DispositionAction.INSPECT, (
|
| 142 |
+
"Policy repeated INSPECT on same item after inspection note update"
|
| 143 |
+
)
|
| 144 |
+
break
|
| 145 |
+
|
| 146 |
+
obs = env.step(action)
|
| 147 |
+
steps += 1
|
| 148 |
+
|
| 149 |
+
assert inspected_once, "Did not encounter an INSPECT scenario in hard task"
|
tests/test_scoring_consistency.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Tests for scoring consistency across inference-mode and grader-mode outputs.
|
| 3 |
+
|
| 4 |
+
Verifies that:
|
| 5 |
+
1. inference.py and grader.py both produce scores within (0.01, 0.99).
|
| 6 |
+
2. _scoring_mode and _score_formula markers are present in grader output.
|
| 7 |
+
3. The two scoring modes are intentionally different (documented in openenv.yaml).
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import sys
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
# Allow imports from RevLogiXenv_v0 in the worktree
|
| 16 |
+
sys.path.insert(0, str(Path(__file__).parent.parent / "RevLogiXenv_v0"))
|
| 17 |
+
|
| 18 |
+
from RevLogiXenv_v0 import AutonomousReturnsEnv, Grader
|
| 19 |
+
from RevLogiXenv_v0.policies import heuristic_action
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def test_grader_output_contains_scoring_markers():
|
| 23 |
+
"""Grader._compute_scores() must include _scoring_mode and _score_formula."""
|
| 24 |
+
env = AutonomousReturnsEnv("easy")
|
| 25 |
+
obs = env.reset(seed=42)
|
| 26 |
+
|
| 27 |
+
while not obs.done:
|
| 28 |
+
action = heuristic_action(obs)
|
| 29 |
+
obs = env.step(action)
|
| 30 |
+
|
| 31 |
+
grader = Grader("easy")
|
| 32 |
+
result = grader.score_completed_episode(env=env)
|
| 33 |
+
|
| 34 |
+
assert "_scoring_mode" in result, "Grader output missing _scoring_mode"
|
| 35 |
+
assert result["_scoring_mode"] == "grader"
|
| 36 |
+
assert "_score_formula" in result, "Grader output missing _score_formula"
|
| 37 |
+
assert result["_score_formula"] == "margin_score"
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_grader_hard_task_has_composite_formula():
|
| 41 |
+
"""Hard task grader must report the composite fraud formula."""
|
| 42 |
+
env = AutonomousReturnsEnv("hard")
|
| 43 |
+
obs = env.reset(seed=99)
|
| 44 |
+
|
| 45 |
+
while not obs.done:
|
| 46 |
+
action = heuristic_action(obs)
|
| 47 |
+
obs = env.step(action)
|
| 48 |
+
|
| 49 |
+
grader = Grader("hard")
|
| 50 |
+
result = grader.score_completed_episode(env=env)
|
| 51 |
+
|
| 52 |
+
assert result["_scoring_mode"] == "grader"
|
| 53 |
+
assert "0.6 * margin_score + 0.4 * fraud_f1" in result["_score_formula"]
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def test_grader_scores_bounded_to_open_interval():
|
| 57 |
+
"""All grader scores must be strictly within (0.01, 0.99)."""
|
| 58 |
+
env = AutonomousReturnsEnv("medium")
|
| 59 |
+
obs = env.reset(seed=7)
|
| 60 |
+
|
| 61 |
+
while not obs.done:
|
| 62 |
+
action = heuristic_action(obs)
|
| 63 |
+
obs = env.step(action)
|
| 64 |
+
|
| 65 |
+
grader = Grader("medium")
|
| 66 |
+
result = grader.score_completed_episode(env=env)
|
| 67 |
+
|
| 68 |
+
for key in ("final_score", "margin_score"):
|
| 69 |
+
score = result[key]
|
| 70 |
+
assert 0.01 < score < 0.99, f"{key}={score} outside (0.01, 0.99)"
|
| 71 |
+
|
| 72 |
+
f1 = result["fraud_metrics"]["f1"]
|
| 73 |
+
assert 0.01 < f1 < 0.99, f"fraud_f1={f1} outside (0.01, 0.99)"
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def test_grader_consistent_across_tasks():
|
| 77 |
+
"""Grader must produce valid output for easy, medium, and hard tasks."""
|
| 78 |
+
tasks = ["easy", "medium", "hard"]
|
| 79 |
+
for task in tasks:
|
| 80 |
+
env = AutonomousReturnsEnv(task)
|
| 81 |
+
obs = env.reset(seed=123)
|
| 82 |
+
|
| 83 |
+
while not obs.done:
|
| 84 |
+
action = heuristic_action(obs)
|
| 85 |
+
obs = env.step(action)
|
| 86 |
+
|
| 87 |
+
grader = Grader(task)
|
| 88 |
+
result = grader.score_completed_episode(env=env)
|
| 89 |
+
|
| 90 |
+
assert "final_score" in result
|
| 91 |
+
assert "margin_score" in result
|
| 92 |
+
assert "fraud_metrics" in result
|
| 93 |
+
assert "profit" in result
|
| 94 |
+
assert 0.01 < result["final_score"] < 0.99
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def test_inference_score_mean_of_step_rewards():
|
| 98 |
+
"""
|
| 99 |
+
The inference-mode score formula is mean(step_rewards), clamped to (0.01, 0.99).
|
| 100 |
+
This test verifies that the inference.py output format matches the documented formula.
|
| 101 |
+
"""
|
| 102 |
+
# We test the computation logic without an LLM by directly exercising
|
| 103 |
+
# the reward-collection path in an episode.
|
| 104 |
+
from RevLogiXenv_v0.environment import AutonomousReturnsEnv
|
| 105 |
+
from RevLogiXenv_v0.policies import heuristic_action
|
| 106 |
+
|
| 107 |
+
REWARD_MIN = 0.01
|
| 108 |
+
REWARD_MAX = 0.99
|
| 109 |
+
|
| 110 |
+
task = "easy"
|
| 111 |
+
env = AutonomousReturnsEnv(task)
|
| 112 |
+
obs = env.reset(seed=42)
|
| 113 |
+
|
| 114 |
+
step_rewards: list[float] = []
|
| 115 |
+
while not obs.done:
|
| 116 |
+
action = heuristic_action(obs)
|
| 117 |
+
obs = env.step(action)
|
| 118 |
+
if obs.reward is not None:
|
| 119 |
+
clamped = float(max(REWARD_MIN, min(REWARD_MAX, obs.reward)))
|
| 120 |
+
step_rewards.append(clamped)
|
| 121 |
+
|
| 122 |
+
inference_score = sum(step_rewards) / len(step_rewards) if step_rewards else 0.0
|
| 123 |
+
assert 0.01 < inference_score < 0.99
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
if __name__ == "__main__":
|
| 127 |
+
test_grader_output_contains_scoring_markers()
|
| 128 |
+
test_grader_hard_task_has_composite_formula()
|
| 129 |
+
test_grader_scores_bounded_to_open_interval()
|
| 130 |
+
test_grader_consistent_across_tasks()
|
| 131 |
+
test_inference_score_mean_of_step_rewards()
|
| 132 |
+
print("All scoring consistency tests passed.")
|