AdityaProCoder commited on
Commit
365bd0d
·
2 Parent(s): 3e67a004cce274

Merge branch 'fix/scoring-mismatch'

Browse files
.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-4o-mini
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-4o-mini
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-4o-mini
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-4o-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,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
- ACTION_STRINGS = [a.value for a in DispositionAction]
23
-
24
- LLM_SYSTEM_PROMPT = """You are a reverse-logistics triage agent for AutonomousReturns-v0.
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-4o-mini")
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
- 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
- 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
- text = (response_text or "").strip().lower()
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-4o-mini",
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: final_score = 0.6 * margin_score + 0.4 * fraud_f1
 
 
 
 
 
 
 
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 getattr(self.env, "_episode_snapshot", None):
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 getattr(self.env, "_episode_snapshot", [])
148
  }
149
 
150
- for resolution in self.env._full_resolution_history:
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 = getattr(self._env, "_episode_snapshot", None)
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": LLM_SYSTEM_PROMPT},
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: composite
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.")