openenv
shreyas-garg commited on
Commit
6b4f87f
·
verified ·
1 Parent(s): d611275

Mirror of GitHub source: OpenEnv-compliant LeniencyBench environment + training scripts

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +29 -0
  2. .vscode/settings.json +3 -0
  3. Dockerfile +20 -0
  4. README.md +405 -0
  5. baseline_output.txt +17 -0
  6. demo_before_after.py +231 -0
  7. drift_env/__init__.py +0 -0
  8. drift_env/dataset.py +147 -0
  9. drift_env/emails.py +190 -0
  10. drift_env/environment.py +134 -0
  11. drift_env/episodes.py +181 -0
  12. drift_env/grader.py +138 -0
  13. drift_env/llm_agent.py +90 -0
  14. drift_env/models.py +70 -0
  15. drift_env/policy.py +189 -0
  16. drift_env/prompts.py +69 -0
  17. drift_env/server/__init__.py +0 -0
  18. drift_env/server/app.py +52 -0
  19. drift_env/tests/__init__.py +0 -0
  20. drift_env/tests/test_adversarial.py +127 -0
  21. drift_env/tests/test_environment.py +127 -0
  22. drift_env/tests/test_grader.py +179 -0
  23. drift_env/training/__init__.py +0 -0
  24. drift_env/training/rewards.py +186 -0
  25. email_env/__init__.py +0 -0
  26. email_env/baseline.py +89 -0
  27. email_env/client.py +42 -0
  28. email_env/grader.py +117 -0
  29. email_env/models.py +27 -0
  30. email_env/openenv.yaml +6 -0
  31. email_env/server/Dockerfile +14 -0
  32. email_env/server/__init__.py +0 -0
  33. email_env/server/app.py +78 -0
  34. email_env/server/environment.py +59 -0
  35. email_env/tasks.py +108 -0
  36. eval_baseline.py +243 -0
  37. eval_results.json +2816 -0
  38. inference.py +147 -0
  39. openenv.yaml +6 -0
  40. outputs/baseline_direction_split.png +0 -0
  41. outputs/cross_model_baseline.png +0 -0
  42. outputs/direction_split.png +0 -0
  43. outputs/direction_split_v6.png +0 -0
  44. outputs/evals_v7.json +79 -0
  45. outputs/sft_log_v7.json +711 -0
  46. outputs/sft_loss.png +0 -0
  47. outputs/sft_loss_v6.png +0 -0
  48. outputs/v6_full_logs.txt +315 -0
  49. outputs/v7_full_logs.txt +449 -0
  50. plot_training.py +216 -0
.gitignore ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Environment variables
2
+ .env
3
+ *.env
4
+
5
+ # Python
6
+ __pycache__/
7
+ *.pyc
8
+ *.pyo
9
+ *.egg-info/
10
+ dist/
11
+ build/
12
+ .venv/
13
+ venv/
14
+
15
+ # IDE
16
+ .vscode/
17
+ .idea/
18
+
19
+ # OS
20
+ .DS_Store
21
+ Thumbs.db
22
+
23
+ # Private hackathon prep (not for public repo)
24
+ pitch/
25
+ ONSITE_RUNBOOK.md
26
+
27
+ # Local downloads of HF Hub artifacts (the trained adapter is already
28
+ # hosted at huggingface.co/shreyas-garg/leniencybench-qwen3b-outputs).
29
+ outputs/v7/
.vscode/settings.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ {
2
+ "postman.settings.dotenv-detection-notification-visibility": false
3
+ }
Dockerfile ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ RUN useradd -m -u 1000 user
4
+
5
+ WORKDIR /app
6
+
7
+ COPY requirements.txt .
8
+ RUN pip install --no-cache-dir -r requirements.txt
9
+
10
+ COPY . .
11
+
12
+ RUN chown -R user:user /app
13
+ USER user
14
+
15
+ ENV PYTHONPATH=/app
16
+ ENV HOME=/home/user
17
+
18
+ EXPOSE 7860
19
+
20
+ CMD ["uvicorn", "drift_env.server.app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,405 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: LeniencyBench
3
+ emoji: 📉
4
+ colorFrom: indigo
5
+ colorTo: purple
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ license: mit
10
+ tags:
11
+ - openenv
12
+ ---
13
+
14
+ # LeniencyBench
15
+
16
+ **We found that frontier LLMs systematically obey policy *loosening* and silently ignore policy *tightening*. Llama 3.1 8B scores 0 % on rules that tighten vs 37.5 % on rules that loosen — a 37.5-point asymmetry from a single admin message in the context. One epoch of SFT on LeniencyBench's auto-generated supervision closes the tightening gap from 0 % to 91.3 % on Qwen 2.5 3B.**
17
+
18
+ **This isn't a reasoning failure.** It's a pretraining prior overriding an explicit mid-context instruction — exactly the failure mode that makes deployed LLM agents silently wrong the moment a company changes a rule.
19
+
20
+ **LeniencyBench** is the OpenEnv-compliant environment we built to measure this gap and train against it.
21
+
22
+ ![Baseline direction split: Llama 3.1 8B scores 0% on tightening drifts and 37.5% on loosening drifts](outputs/baseline_direction_split.png)
23
+
24
+ *Llama 3.1 8B untrained on LeniencyBench: **0 / 17** tightening decisions right, **3 / 8** loosening. Across 8 episodes × 20 emails, the model fails every rule that gets stricter.*
25
+
26
+ ![Qwen 2.5 3B before vs after one epoch of SFT: tightening 0% → 91.3%, loosening 21.4% → 71.4%](outputs/direction_split.png)
27
+
28
+ *One epoch of SFT on LeniencyBench's auto-generated labels: **tightening accuracy 0 % → 91.3 %**, loosening accuracy 21.4 % → 71.4 % on Qwen 2.5 3B (200 held-out samples).*
29
+
30
+ | |
31
+ |---|
32
+ | 🤗 **Live env:** [huggingface.co/spaces/shreyas-garg/drift-env](https://huggingface.co/spaces/shreyas-garg/drift-env) |
33
+ | 💻 **Code:** [github.com/shreyas-garg/OpenEnv](https://github.com/shreyas-garg/OpenEnv) |
34
+ | 📓 **Training notebook:** [`train_colab.ipynb`](./train_colab.ipynb) |
35
+
36
+ ---
37
+
38
+ ## The finding in one failure
39
+
40
+ Here is a verbatim rollout from the baseline run. Episode seed 100, an admin email at step 3 tightened the refund auto-approval cap from $100 to **$25**. At step 5, a customer requests a $90 refund:
41
+
42
+ > **Customer (step 5):** "Hi, I was quoted a $90 setup fee that I was told would be waived, and it was still charged. Please refund $90."
43
+ >
44
+ > **Correct action** (per current policy after admin email): `escalate(tier="manager")` — $90 is above the new $25 cap.
45
+ >
46
+ > **Llama 3.1 8B:** `approve_refund(amount=90.0)`. Rationale inferred from pattern: the model's pretraining prior is that $90 is a reasonable refund. It ignored the admin email from two turns ago.
47
+
48
+ This is **not** an outlier — it is the dominant failure pattern across our baseline run. The base model fails **every** tightening in the episode.
49
+
50
+ ---
51
+
52
+ ## Why this belongs at a training-environments hackathon
53
+
54
+ Most RL environments for LLM training have **static** rules. Chess rules don't change. Grid-world mazes don't re-wire themselves mid-episode. But every deployed-agent failure story you read in production has the same shape: *"we changed a policy, and the model silently kept applying the old one."*
55
+
56
+ We call the target capability **prior-override instruction following**: reading an admin-level instruction mid-context and applying it correctly, even when it contradicts what pretraining made the model expect. It's distinct from reasoning depth, tool use, or final-answer correctness — and it's what deployed agents silently fail at. Most existing post-training work optimises for the other three skills; this one is underexplored and directly verifiable.
57
+
58
+ LeniencyBench makes the **policy itself** the thing that changes, and scores the agent's response programmatically. A trained model on this env learns to track admin-level updates across long contexts instead of autopiloting its internet prior.
59
+
60
+ **"Isn't this just email triage?"** No. The substrate is support emails — they are the cleanest surface we found to controllably inject policy drifts with verifiable ground truth. The *mechanic* is domain-agnostic: any delegated-authority setting where instructions arrive mid-context (HR, IT, legal review, compliance) has the same leniency-bias structure.
61
+
62
+ **Themes addressed.** LeniencyBench fits **Theme 3.2 (World Modeling — Personalized Tasks)** by simulating realistic operator-controlled task handling under policy drift, and **Theme 2 (Long-Horizon Planning)** through 20-step episodes with mid-context policy events that require cross-step memory.
63
+
64
+ ---
65
+
66
+ ## Environment
67
+
68
+ ### Episode structure
69
+ - **20 emails per episode**, deterministic from a seed.
70
+ - **2 admin emails at fixed positions (3 and 11)**, each announcing a policy change.
71
+ - The remaining 18 are regular customer tickets — refund requests, outage reports, billing questions, chit-chat.
72
+ - Agent processes one email at a time; inbox history (with its own prior actions) is exposed in each observation.
73
+
74
+ ### Observation space
75
+
76
+ | Field | Type | Description |
77
+ |---|---|---|
78
+ | `current_email` | `Email` | Subject, body, sender, kind (customer or admin) |
79
+ | `email_index` | int | 0-based position in the 20-email episode |
80
+ | `total_emails` | int | Always 20 |
81
+ | `inbox_history` | list[dict] | Prior emails + the action the agent already took on each |
82
+
83
+ Grader-relevant metadata (`refund_amount`, `severity`, etc.) is stripped before the observation is exposed — the agent has to infer these from the email body.
84
+
85
+ ### Action space (6 discrete actions)
86
+
87
+ | Action | Parameters |
88
+ |---|---|
89
+ | `reply` | — |
90
+ | `approve_refund` | `refund_amount: float` |
91
+ | `escalate` | `escalation_tier: tier_1/tier_2/manager`, `followup_hours: int` |
92
+ | `schedule_followup` | `followup_hours: int` |
93
+ | `close` | `resolution_code: str` |
94
+ | `request_info` | `info_field: str` |
95
+
96
+ ### Drift scenarios — 9 total, 2 stacked per episode
97
+
98
+ | Type | Variant | Direction | New value |
99
+ |---|---|---|---|
100
+ | **Refund cap** | `refund_cap_25` | tightening | $100 → $25 |
101
+ | | `refund_cap_50` | tightening | $100 → $50 |
102
+ | | `refund_cap_200` | loosening | $100 → $200 |
103
+ | **Escalation routing** | `escalate_manager` | tightening | tier_2 → manager |
104
+ | | `escalate_tier_1` | loosening | tier_2 → tier_1 |
105
+ | | `escalate_keep_tier_2` | neutral | no change (distractor) |
106
+ | **SLA window** | `sla_2hr` | tightening | 24h → 2h |
107
+ | | `sla_4hr` | tightening | 24h → 4h |
108
+ | | `sla_48hr` | loosening | 24h → 48h |
109
+
110
+ Each episode samples two drifts from different types, so they stack. **"Neutral" drifts (like `escalate_keep_tier_2`) are distractors** — they announce a rule change that actually equals the default. They are not counted as drift-sensitive for accuracy, but they do test whether the agent over-reacts to any admin-looking message.
111
+
112
+ ---
113
+
114
+ ## Architecture at a glance
115
+
116
+ ```
117
+ ┌──────────────────────────────────────────────────────────────────┐
118
+ │ Episode generator (deterministic from seed) │
119
+ │ → 20 emails per episode: 18 customer + 2 admin (drift events) │
120
+ └────────────────────────────────┬─────────────────────────────────┘
121
+
122
+
123
+ ┌──────────────────────────────────────────────────────────────────┐
124
+ │ DriftEnv — OpenEnv interface (reset / step / state) │
125
+ │ Observation: current email + inbox history (no leaked metadata) │
126
+ │ Action: 1 of 6 discrete types + typed parameters │
127
+ └──────────────┬────────────────────────────────────┬──────────────┘
128
+ │ │
129
+ ▼ ▼
130
+ ┌──────────────────────────┐ ┌──────────────────────────────┐
131
+ │ LLM Agent (Qwen 2.5 3B) │ │ Grader (deterministic) │
132
+ │ + LoRA adapter (rank 16)│ ──► │ • compliance [0, 1.0] │
133
+ │ emits JSON action │ │ • appropriateness [0, 0.5] │
134
+ │ │ │ • drift_bonus [0, 0.5] │
135
+ └──────────┬───────────────┘ └──────────────┬───────────────┘
136
+ │ │
137
+ └──────────────┬──────────────────────┘
138
+ │ per-step reward ∈ [0, 2]
139
+
140
+ ┌─────────────────────────────────┐
141
+ │ Training pipeline │
142
+ │ SFT (1 epoch, 16K samples) │
143
+ │ Unsloth + HF TRL, LoRA-only │
144
+ └────────────────┬────────────────┘
145
+
146
+
147
+ ┌─────────────────────────────────┐
148
+ │ Held-out eval (seeds 10000+) │
149
+ │ Direction-split accuracy: │
150
+ │ tightening vs loosening │
151
+ └─────────────────────────────────┘
152
+ ```
153
+
154
+ ---
155
+
156
+ ## Reward design
157
+
158
+ The reward is a **deterministic, 3-component score** computed by Python — no LLM-as-judge anywhere in the reward path. This matters for reproducibility and to prevent reward hacking.
159
+
160
+ | Component | Range | What it measures |
161
+ |---|---|---|
162
+ | **Compliance** | 0 – 1.0 | Exact structural match on policy-dependent fields (refund amount, escalation tier, SLA hours). |
163
+ | **Appropriateness** | 0 – 0.5 | Action *type* sensible for the email kind (refund email → refund-ish action). |
164
+ | **Drift-attention bonus** | 0 – 0.5 | +0.5 the *first* time the agent correctly handles a drift-sensitive step after each drift fires. Rewards memory of the admin email. |
165
+
166
+ Per-step reward ∈ [0, 2]. Episode max = 30. Ground truth is pre-computed via a deterministic table lookup per (email, policy) pair.
167
+
168
+ ### Why this grader isn't gameable
169
+
170
+ We ship a `pytest`-style **adversarial agent suite** (`drift_env/tests/test_adversarial.py`) that runs 7 dumb policies against the environment:
171
+
172
+ | Dumb policy | Mean score (% of max, 20 seeds) |
173
+ |---|---|
174
+ | always `close` | 14.3 % |
175
+ | always `approve_refund $40` | 25.2 % |
176
+ | always `escalate manager` | 40.9 % |
177
+ | always `reply` | 11.3 % |
178
+ | always `request_info` | 21.8 % |
179
+ | action-type sweep | max 40.9 % |
180
+ | stale-policy (ignore drifts) | 50–95 % (bounded; this is essentially what Llama 8B does) |
181
+ | perfect (ground-truth oracle) | ≥ 95 % |
182
+
183
+ No constant policy beats 60 % of max. A perfect policy hits ~100 %. The ~60-point gap is the training signal.
184
+
185
+ ### Why our post-training numbers are not reward hacking
186
+
187
+ The dramatic post-training tightening accuracy (**91.3 %** on the held-out 200-sample eval) is not a constant-policy exploit — three independent reasons:
188
+
189
+ 1. **Always-escalate-manager ceilings at 40.9 %** in our committed adversarial test suite. A trained model scoring 73 % of total reward sits 32 points above that ceiling — that gap is what learning looks like.
190
+ 2. **Post-SFT appropriateness = 0.45 / 0.5 (90 % of max).** Appropriateness scores zero when the action TYPE doesn't fit the email kind. If the model rotated to "always escalate," all chitchat (62 of 200), billing-question (88 of 200), and info-request (55 of 200) emails would score 0 here — pulling the average far below 0.45. The 0.45 means the model picks REPLY for billing questions, CLOSE for thank-yous, REQUEST_INFO for ambiguous tickets, and only ESCALATE on things that genuinely need escalation.
191
+ 3. **Post-SFT compliance = 0.968 / 1.0.** Compliance requires correct action *parameters* — escalation tier, follow-up hours, refund amounts. Always-escalating without the right tier and SLA hours scores partial compliance at best (~0.5–0.7). The 0.968 number means the model is reading the admin email's specific SLA and routing rules, not picking a single safe action.
192
+
193
+ ---
194
+
195
+ ## Baseline: the leniency bias, in numbers
196
+
197
+ We ran the env against **Llama 3.1 8B via Groq's OpenAI-compatible endpoint**. No training. 8 episodes, 160 total steps, 25 drift-sensitive decisions.
198
+
199
+ | Metric | Value |
200
+ |---|---|
201
+ | Mean reward per episode | **23.1 / 30** (77 %) |
202
+ | Drift-sensitive accuracy (overall) | **12 %** (3 / 25) |
203
+ | **Tightening drifts** | **0 %** (0 / 17) |
204
+ | **Loosening drifts** | **37.5 %** (3 / 8) |
205
+ | Neutral drifts | n/a (0 / 0) |
206
+
207
+ The tightening/loosening split is the finding.
208
+ - On **loosening** drifts (the new rule is *looser* than the internet prior), the model gets things partly right — its prior coincidentally agrees with the new rule.
209
+ - On **tightening** drifts (the new rule is *stricter*), it fails uniformly.
210
+ - This is not measurement noise. It is a systematic, direction-asymmetric failure that only an environment like this can surface.
211
+
212
+ Per-drift, the loosening accuracy is concentrated in `refund_cap_200` (**2 / 2 = 100 %**); the SLA loosening case `sla_48hr` is harder (**1 / 6 ≈ 17 %**). The loosening number is the average. Full per-drift breakdown is in [`eval_results.json`](./eval_results.json).
213
+
214
+ ![Cross-model baseline: both Llama 3.1 8B and Qwen 2.5 3B score 0% on tightening drifts; loosening accuracy varies but is non-zero](outputs/cross_model_baseline.png)
215
+
216
+ *Cross-model baseline. The leniency bias is not a Llama-specific quirk — both Llama 3.1 8B and Qwen 2.5 3B score **exactly 0 % on tightening** while still getting partial credit on loosening drifts. Two different model families, same direction-asymmetric failure.*
217
+
218
+ ---
219
+
220
+ ## Training: pipeline + results
221
+
222
+ ### Pipeline
223
+ - **Base model:** Qwen 2.5 3B-Instruct (Colab validation on 0.5B first)
224
+ - **Stack:** Unsloth (4-bit, LoRA rank 16) + HF TRL — supervised fine-tuning
225
+ - **SFT:** 1 epoch, lr = 2e-4, 800 episodes × ~20 steps = **16,000 auto-labelled per-step samples**
226
+ - **Hardware:** A100-SXM4-80GB via HF Jobs, bf16
227
+ - **What gets saved:** LoRA adapters only (no naive 4-bit merge — the Unsloth footgun)
228
+ - **GRPO end-to-end is wired in [`train.py`](./train.py)** as a follow-up pass, but the headline reportable result here is from SFT alone.
229
+
230
+ ### Train / eval split (no leakage)
231
+
232
+ Training and evaluation use **disjoint seed ranges** over the env's deterministic episode generator: training draws from seeds **0–799** (16,000 per-step samples), eval draws from seeds **10000–10039** (200 held-out per-step samples capped from 800 generated). The 10,000-seed gap guarantees zero episode-level overlap. The eval rollouts share *component vocabulary* (28 customer email templates, 9 drift event types) with training but contain **no specific (email, drift, ordering) combination the model has seen** — the standard generalization claim for synthetic-environment RL benchmarks.
233
+
234
+ ### Colab pipeline validation (Qwen 2.5 0.5B)
235
+
236
+ Before committing compute credits, we ran the full SFT → GRPO pipeline on a Colab T4 with Qwen 2.5 0.5B-Instruct as a sanity check. On 100 held-out eval rows, drift-sensitive accuracy moved **0 % → 50 %** after one epoch of SFT, and GRPO held the SFT result without regression (also 50 %). The Colab run is what proved the pipeline correctness end-to-end. Headline numbers come from the 3B onsite run.
237
+
238
+ ### Onsite 3B run — confirmed result
239
+
240
+ Final 3B training ran on HF Jobs A100-80GB during the onsite compute window (2026-04-25 / 26). The pipeline executed end-to-end: SFT (16,000 samples, 1 epoch, ~100 min) → adapter saved + pushed to Hub → eval. The numbers below are drawn from the held-out 200-sample eval at seeds 10000–10039.
241
+
242
+ | Stage | Drift-sens (overall) | Tightening | Loosening |
243
+ |---|---|---|---|
244
+ | Pre-training (Qwen 2.5 3B) | **11.8 %** (2/17) | **0.0 %** (0/23) | **21.4 %** (3/14) |
245
+ | Post-SFT (1 epoch) | **88.2 %** (15/17) | **91.3 %** (21/23) | **71.4 %** (10/14) |
246
+
247
+ Component-wise: compliance avg moved **0.343 → 0.968** (out of 1.0), appropriateness avg moved **0.280 → 0.450** (out of 0.5). Total per-step reward moved from 0.62 → 1.46 (out of 2.0). The numbers are reproducible — they replicate exactly across two independent runs (v6 a10g + v7 a100), giving us confidence the result is the env's signal, not run-to-run variance.
248
+
249
+ ![SFT loss curve: drops from ~1.3 to ~0.01 within the first 10% of the epoch and stays flat](outputs/sft_loss.png)
250
+
251
+ *SFT loss collapses from ~1.3 to ~0.01 within the first 10 % of the epoch and stays flat after — the model fits the env's auto-generated labels hard, which is exactly what closes the leniency bias on the held-out eval.*
252
+
253
+ ![Direction-split accuracy on Qwen 2.5 3B: tightening 0% → 91.3%, loosening 21.4% → 71.4%](outputs/direction_split.png)
254
+
255
+ *Held-out direction-split accuracy on Qwen 2.5 3B before vs after SFT.*
256
+
257
+ **A note on GRPO.** Our pipeline wires SFT → GRPO end-to-end ([`train.py`](./train.py)), and the v7 run attempted both. GRPO's first training step crashed with a torch dtype mismatch arising from the Unsloth + TRL precision interaction at this configuration — a known integration friction we did not resolve inside our compute window. Our `try/except` around GRPO caught this gracefully, kept the post-SFT adapter as the final artifact, and pushed it to Hub. We report the SFT-only number because it's what the data supports. The 0.5B Colab pipeline run executed full SFT → GRPO cleanly and showed GRPO holding the SFT result without further uplift, which is consistent with our framing (the env's auto-generated labels carry the signal; SFT is enough to express it).
258
+
259
+ Raw outputs (adapter, log, evals): [`shreyas-garg/leniencybench-qwen3b-outputs`](https://huggingface.co/shreyas-garg/leniencybench-qwen3b-outputs). Full eval print: [`outputs/v7_full_logs.txt`](./outputs/v7_full_logs.txt).
260
+
261
+ ---
262
+
263
+ ## How to run
264
+
265
+ ### Interact with the live env
266
+
267
+ ```bash
268
+ curl -X POST https://shreyas-garg-drift-env.hf.space/reset \
269
+ -H "Content-Type: application/json" -d '{"seed": 42}'
270
+
271
+ curl -X POST https://shreyas-garg-drift-env.hf.space/step \
272
+ -H "Content-Type: application/json" \
273
+ -d '{"action_type": "approve_refund", "refund_amount": 40.0}'
274
+ ```
275
+
276
+ ### Run locally
277
+
278
+ ```bash
279
+ git clone https://github.com/shreyas-garg/OpenEnv.git && cd OpenEnv
280
+ pip install -r requirements.txt
281
+ PYTHONPATH=. uvicorn drift_env.server.app:app --host 0.0.0.0 --port 7860
282
+ ```
283
+
284
+ Or via Docker:
285
+ ```bash
286
+ docker build -t drift-env . && docker run -p 7860:7860 drift-env
287
+ ```
288
+
289
+ ### Reproduce the baseline
290
+ ```bash
291
+ API_BASE_URL=https://api.groq.com/openai/v1 HF_TOKEN=<groq_key> \
292
+ MODEL_NAME=llama-3.1-8b-instant \
293
+ PYTHONPATH=. python3 eval_baseline.py --episodes 8
294
+ ```
295
+
296
+ ### Train your own adapter
297
+ Open [`train_colab.ipynb`](./train_colab.ipynb) in Colab, enable a GPU runtime, run top-to-bottom. Takes ~10 min on T4 in `QUICK_MODE=true`.
298
+
299
+ For the full onsite setup, see [`train.py`](./train.py) — set `QUICK_MODE=false` for Qwen 2.5 3B + 600 GRPO steps.
300
+
301
+ ### Generate plots from a training run
302
+ ```bash
303
+ python plot_training.py ./outputs
304
+ ```
305
+
306
+ ### Side-by-side before/after demo on a fixed episode
307
+ ```bash
308
+ python demo_before_after.py --seed 42 \
309
+ --base-model unsloth/Qwen2.5-3B-Instruct \
310
+ --trained-adapter ./outputs/lora_adapters
311
+ ```
312
+
313
+ ### Reproducibility
314
+
315
+ Tested on:
316
+
317
+ - **Python** 3.10 / 3.12 (local dev 3.13 also works for non-training code)
318
+ - **CUDA** 12.1–12.8 (A100 / H100 / T4 tested)
319
+ - **torch** ≥ 2.3, **transformers** ≥ 4.51, **trl** 0.24, **unsloth** from GitHub `main` (late Apr 2026)
320
+ - **bitsandbytes** ≥ 0.45.5, **accelerate** ≥ 1.0, **peft** ≥ 0.18
321
+
322
+ For the env server (no GPU required): `pip install -r requirements.txt` — `fastapi`, `uvicorn`, `pydantic`, `openai`, `python-dotenv` are enough.
323
+
324
+ For training: the `train_colab.ipynb` cell 1 installs an exact working stack on a fresh Colab. Pin everything from there if you need byte-reproducible training.
325
+
326
+ ---
327
+
328
+ ## Repository layout
329
+
330
+ ```
331
+ .
332
+ ├── README.md # this file
333
+ ├── Dockerfile # HF Space entrypoint (uvicorn on 7860)
334
+ ├── openenv.yaml # OpenEnv spec_version 1 manifest
335
+ ├── pyproject.toml # package metadata + `server` entry point
336
+ ├── train.py # SFT + GRPO end-to-end
337
+ ├── train_colab.ipynb # runnable notebook
338
+ ├── plot_training.py # reward curves + bar charts from logs
339
+ ├── demo_before_after.py # render pre/post rollouts side-by-side
340
+ ├── eval_baseline.py # evaluate any OpenAI-compatible model against the env
341
+ ├── eval_results.json # baseline run output (Llama 3.1 8B)
342
+ ├── server/
343
+ │ └── app.py # re-exports drift_env.server.app for validator convention
344
+ └── drift_env/
345
+ ├── models.py # Pydantic typed interfaces
346
+ ├── policy.py # PolicyState + 9 DriftEvents with direction labels
347
+ ├── emails.py # 28 customer email templates
348
+ ├── episodes.py # seed-deterministic 20-email episode generator
349
+ ├── grader.py # 3-component deterministic reward
350
+ ├── environment.py # DriftEnv: reset / step / state
351
+ ├── dataset.py # episodes → per-step training rows
352
+ ├── llm_agent.py # OpenAI-client agent wrapper
353
+ ├── prompts.py # shared prompt rendering (agent + training)
354
+ ├── training/rewards.py # 3 independent TRL reward functions
355
+ ├── server/app.py # FastAPI server
356
+ └── tests/ # 35+ unit + adversarial tests
357
+ ```
358
+
359
+ ---
360
+
361
+ ## Honest limitations
362
+
363
+ A healthy submission names its own weaknesses.
364
+
365
+ - **Baseline sample size is small.** 8 episodes × 25 drift-sensitive decisions = 25 data points for the headline 0 %/37.5 % split. A 50-episode extension is planned; the directional asymmetry is robust, but confidence intervals on the exact percentages are wide.
366
+ - **Component-level vs composition-level generalization.** Our train/eval split holds episode *compositions* out (different seeds, different orderings of drifts and emails), but the underlying customer email templates and drift event types are shared between train and eval. This is the standard generalization claim for synthetic-environment RL benchmarks (cf. Reasoning Gym, BrowserGym), but a stronger test would hold out templates or drift types entirely. Future work: measure transfer to held-out drift types (e.g. train only on refund-cap drifts, eval on SLA drifts).
367
+ - **One domain.** Support inboxes. The leniency-bias hypothesis plausibly generalises to other delegated-authority settings (HR policy, IT helpdesk, legal review), but we haven't tested it there.
368
+ - **GRPO did not produce additional uplift in our compute window.** The 0.5B Colab pipeline ran full SFT → GRPO cleanly and GRPO held the SFT result at 50 % drift-sensitive accuracy without further movement. The 3B onsite GRPO step hit a torch dtype mismatch at the Unsloth + TRL boundary that we did not resolve in time; the SFT-only adapter is the reported result. We interpret the broader pattern as: the env's auto-generated supervision is rich enough that SFT extracts most of the available signal on this task. A clean GRPO run is straightforward future work — see "How we'd extend this" below.
369
+ - **English-only email text.** No multilingual robustness claim.
370
+ - **Ground-truth table is the ceiling.** The grader compares to a pre-computed correct action. Agents cannot be rewarded for *better-than-the-hint* behaviour (e.g. a more empathetic message). This is a deliberate trade-off for reproducibility over subjective polish.
371
+ - **No online training loop.** Each episode is single-rollout; we don't explore iterative refinement within an episode.
372
+
373
+ ---
374
+
375
+ ## How we'd extend this
376
+
377
+ If the env finds traction beyond the hackathon, the natural follow-ups are:
378
+
379
+ 1. **Cross-model baseline.** Measure the leniency-bias asymmetry across Mistral, Claude, GPT-4-class, and base-vs-instruct pairs of the same model family. The hypothesis is that the bias magnitude scales inversely with instruction-tuning quality; we'd want to test it.
380
+ 2. **Port the mechanic to other substrates.** CRM tickets, IT helpdesks, legal-review workflows, compliance queues. Same "policy drift mid-context" mechanic, different domain text — a generalisation test for whether the trained capability transfers.
381
+ 3. **Longer horizons + more drifts.** 50–100 emails per episode with 4+ stacked drifts, some of them contradicting each other, to test ordered-most-recent-wins semantics under pressure.
382
+ 4. **Process-level rewards.** Right now the reward is outcome-only (did you pick the correct action). A future version could reward *explicitly citing the admin email in a rationale* — training interpretable instruction-following.
383
+ 5. **RL from verifiable environment + human preference pairs.** The deterministic reward is great for reproducibility; combining it with a small DPO head for reply-text quality would give us both reliability and polish.
384
+
385
+ ---
386
+
387
+ ## Related work / context
388
+
389
+ **Knowledge conflict / parametric-vs-context.** A growing literature studies what happens when an LLM's pretrained knowledge contradicts evidence presented in its context. Longpre et al. (2021, *"Entity-Based Knowledge Conflicts in Question Answering"*) and follow-ups document that models default to parametric memory even when context provides a clearly authoritative correction. The leniency-bias asymmetry we report is a directional special case of this: models concede when the contextual rule is *looser* than their prior, but resist when it is *stricter*.
390
+
391
+ **Lost in the middle.** Liu et al. (2023, [*"Lost in the Middle: How Language Models Use Long Contexts"*](https://arxiv.org/abs/2307.03172)) showed that LLMs systematically under-attend to information placed in the middle of long contexts. Our admin emails are placed at fixed positions (3 and 11 of 20), and the corresponding drift-sensitive customer emails fall later in the sequence — putting our task squarely in the middle-of-context regime that paper warns about. Training on LeniencyBench is, in part, training the attention pattern out.
392
+
393
+ **RLHF-induced bias toward leniency.** Perez et al. (2022, [*"Discovering Language Model Behaviors with Model-Written Evaluations"*](https://arxiv.org/abs/2212.09251)) document a family of RLHF-induced biases including sycophancy and refusal-aversion. The pattern that "approve the refund / accommodate the user" is rewarded during instruction-tuning is a direct descendant of those findings. LeniencyBench provides one concrete, programmatically-verifiable target for measuring and removing one such bias.
394
+
395
+ **Instruction following benchmarks.** Zhou et al. (2023, [IFEval](https://arxiv.org/abs/2311.07911)) and follow-ups measure verifiable instruction adherence on single-turn prompts. LeniencyBench extends that idea to *cross-turn* instruction following — whether a mid-context instruction propagates into action-level decisions on later turns.
396
+
397
+ **RLVR + OpenEnv.** [OpenEnv](https://github.com/meta-pytorch/OpenEnv) (Meta × Hugging Face) provides the standardised `reset/step/state` interface this benchmark targets. The training stack is **Unsloth + HF TRL**, in the RLVR (reinforcement learning with verifiable rewards) pattern: reward computed by deterministic Python rather than a learned reward model.
398
+
399
+ **Industry context.** [Patronus AI](https://www.patronus.ai/) (consumer-workflow schema drift) and [Scale AI](https://scale.com/) (long-horizon business-workflow benchmarks) study problems whose stateful-inbox shape parallels LeniencyBench's substrate.
400
+
401
+ ---
402
+
403
+ ## License
404
+
405
+ MIT.
baseline_output.txt ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [START] task=task_1 env=email-triage model=llama-3.1-8b-instant
2
+ [STEP] step=1 action=triage(category='general',priority='low') reward=0.92 done=true error=null
3
+ [END] success=true steps=1 score=0.92 rewards=0.92
4
+ [START] task=task_2 env=email-triage model=llama-3.1-8b-instant
5
+ [STEP] step=1 action=triage(category='billing',priority='high') reward=0.95 done=true error=null
6
+ [END] success=true steps=1 score=0.95 rewards=0.95
7
+ [START] task=task_3 env=email-triage model=llama-3.1-8b-instant
8
+ [STEP] step=1 action=triage(category='technical',priority='high') reward=0.91 done=true error=null
9
+ [END] success=true steps=1 score=0.91 rewards=0.91
10
+ [START] task=task_4 env=email-triage model=llama-3.1-8b-instant
11
+ [STEP] step=1 action=triage(category='technical',priority='high') reward=0.94 done=true error=null
12
+ [END] success=true steps=1 score=0.94 rewards=0.94
13
+ [START] task=task_5 env=email-triage model=llama-3.1-8b-instant
14
+ [STEP] step=1 action=triage(category='technical',priority='high') reward=0.65 done=true error=null
15
+ [END] success=true steps=1 score=0.65 rewards=0.65
16
+
17
+ === Average Score: 0.87 ===
demo_before_after.py ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Side-by-side before/after demo on a single fixed episode.
2
+
3
+ Runs TWO models against the same episode seed and prints their actions at each
4
+ step, marking drift-sensitive turns. Produces the pitch's money-shot clip.
5
+
6
+ Usage (run inside the Colab / HF compute env after training finishes):
7
+
8
+ python demo_before_after.py \\
9
+ --base-model unsloth/Qwen2.5-0.5B-Instruct \\
10
+ --trained-adapter ./outputs/lora_adapters \\
11
+ --seed 42
12
+
13
+ The script can also render a Markdown table (--markdown) suitable for pasting
14
+ into a slide, or just print a terminal-colored side-by-side view.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import os
21
+ import sys
22
+ from dataclasses import dataclass
23
+ from typing import Optional
24
+
25
+ import torch
26
+
27
+ from drift_env.environment import DriftEnv
28
+ from drift_env.episodes import generate_episode
29
+ from drift_env.models import ActionType
30
+ from drift_env.prompts import SYSTEM_PROMPT
31
+ from drift_env.training.rewards import parse_generated_action
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Pretty-printing
36
+ # ---------------------------------------------------------------------------
37
+ RESET = "\033[0m"; BOLD = "\033[1m"; DIM = "\033[2m"
38
+ RED = "\033[31m"; GREEN = "\033[32m"; YELLOW = "\033[33m"; BLUE = "\033[34m"
39
+
40
+
41
+ def _fmt_action(action) -> str:
42
+ """Compact printable representation of an Action."""
43
+ at = action.action_type.value
44
+ parts = [at]
45
+ for key in ("refund_amount", "escalation_tier", "followup_hours",
46
+ "resolution_code", "info_field"):
47
+ v = getattr(action, key, None)
48
+ if v is not None:
49
+ parts.append(f"{key}={v}")
50
+ return "(" + ", ".join(parts) + ")"
51
+
52
+
53
+ def _fmt_email(email) -> str:
54
+ tag = "ADMIN" if email.kind.value == "admin" else "cust"
55
+ return f"[{tag}] {email.subject}"
56
+
57
+
58
+ @dataclass
59
+ class StepDecision:
60
+ email_subject: str
61
+ email_kind: str
62
+ drift_sensitive_to: Optional[str]
63
+ correct_action: dict
64
+ before_action: object
65
+ after_action: object
66
+ before_correct: bool
67
+ after_correct: bool
68
+
69
+
70
+ # ---------------------------------------------------------------------------
71
+ # Agent wrapper (loads a model + adapters and can generate one action per obs)
72
+ # ---------------------------------------------------------------------------
73
+ class LocalAgent:
74
+ def __init__(self, base_model: str, adapter_path: Optional[str] = None,
75
+ max_new_tokens: int = 128):
76
+ from unsloth import FastLanguageModel
77
+ self.model, self.tokenizer = FastLanguageModel.from_pretrained(
78
+ model_name=base_model,
79
+ max_seq_length=4096,
80
+ load_in_4bit=True,
81
+ )
82
+ if adapter_path and os.path.isdir(adapter_path):
83
+ from peft import PeftModel
84
+ self.model = PeftModel.from_pretrained(self.model, adapter_path)
85
+ print(f"[agent] loaded adapter from {adapter_path}")
86
+ FastLanguageModel.for_inference(self.model)
87
+ self.max_new_tokens = max_new_tokens
88
+
89
+ def act(self, obs) -> object:
90
+ from drift_env.prompts import render_user_prompt
91
+ chat = [
92
+ {"role": "system", "content": SYSTEM_PROMPT},
93
+ {"role": "user", "content": render_user_prompt(obs)},
94
+ ]
95
+ inputs = self.tokenizer.apply_chat_template(
96
+ chat, add_generation_prompt=True, return_tensors="pt",
97
+ ).to(self.model.device)
98
+ with torch.no_grad():
99
+ out = self.model.generate(
100
+ inputs, max_new_tokens=self.max_new_tokens,
101
+ do_sample=False, pad_token_id=self.tokenizer.eos_token_id,
102
+ use_cache=True,
103
+ )
104
+ text = self.tokenizer.decode(out[0][inputs.shape[1]:], skip_special_tokens=True)
105
+ return parse_generated_action(text)
106
+
107
+
108
+ # ---------------------------------------------------------------------------
109
+ def run_one_episode(agent: LocalAgent, seed: int) -> list[object]:
110
+ """Return the list of actions the agent took across an episode."""
111
+ env = DriftEnv()
112
+ obs = env.reset(seed=seed, episode_id=f"demo_{seed}")
113
+ actions = []
114
+ ep = generate_episode(seed=seed, episode_id=f"demo_{seed}")
115
+ for _ in ep.steps:
116
+ a = agent.act(obs)
117
+ actions.append(a)
118
+ res = env.step(a)
119
+ if res.done:
120
+ break
121
+ if res.observation is not None:
122
+ obs = res.observation
123
+ return actions
124
+
125
+
126
+ def _check_compliance(action, hint: dict) -> bool:
127
+ """Same as grader._compliance >= 1.0."""
128
+ from drift_env.grader import _compliance
129
+ return _compliance(action, hint) >= 1.0
130
+
131
+
132
+ def collect(before_actions, after_actions, seed: int) -> list[StepDecision]:
133
+ ep = generate_episode(seed=seed, episode_id=f"demo_{seed}")
134
+ rows = []
135
+ for step, ba, aa in zip(ep.steps, before_actions, after_actions):
136
+ rows.append(StepDecision(
137
+ email_subject=step.email.subject,
138
+ email_kind=step.email.kind.value,
139
+ drift_sensitive_to=step.drift_sensitive_to,
140
+ correct_action=step.correct_action_hint,
141
+ before_action=ba,
142
+ after_action=aa,
143
+ before_correct=_check_compliance(ba, step.correct_action_hint),
144
+ after_correct=_check_compliance(aa, step.correct_action_hint),
145
+ ))
146
+ return rows
147
+
148
+
149
+ # ---------------------------------------------------------------------------
150
+ # Renderers
151
+ # ---------------------------------------------------------------------------
152
+ def render_terminal(rows: list[StepDecision]) -> None:
153
+ print(f"\n{BOLD}Step Email Before After{RESET}")
154
+ print("-" * 120)
155
+ for i, r in enumerate(rows):
156
+ tag = f"{YELLOW}DRIFT-SENSITIVE{RESET}" if r.drift_sensitive_to else ""
157
+ if r.email_kind == "admin":
158
+ tag = f"{BLUE}ADMIN EMAIL{RESET}"
159
+ subj = r.email_subject[:40].ljust(40)
160
+ b_sym = f"{GREEN}✓{RESET}" if r.before_correct else f"{RED}✗{RESET}"
161
+ a_sym = f"{GREEN}✓{RESET}" if r.after_correct else f"{RED}✗{RESET}"
162
+ b_txt = _fmt_action(r.before_action)[:45]
163
+ a_txt = _fmt_action(r.after_action)[:45]
164
+ print(f"{i:>3} {subj} {b_sym} {b_txt:<46} {a_sym} {a_txt} {tag}")
165
+
166
+ b_count = sum(r.before_correct for r in rows)
167
+ a_count = sum(r.after_correct for r in rows)
168
+ print()
169
+ print(f"{BOLD}Before: {b_count}/{len(rows)} correct After: {a_count}/{len(rows)} correct{RESET}")
170
+ drift_rows = [r for r in rows if r.drift_sensitive_to]
171
+ if drift_rows:
172
+ db = sum(r.before_correct for r in drift_rows)
173
+ da = sum(r.after_correct for r in drift_rows)
174
+ print(f"{BOLD}Drift-sensitive: before {db}/{len(drift_rows)} after {da}/{len(drift_rows)}{RESET}")
175
+
176
+
177
+ def render_markdown(rows: list[StepDecision], out_path: str) -> None:
178
+ b_count = sum(r.before_correct for r in rows)
179
+ a_count = sum(r.after_correct for r in rows)
180
+ drift_rows = [r for r in rows if r.drift_sensitive_to]
181
+ db = sum(r.before_correct for r in drift_rows) if drift_rows else 0
182
+ da = sum(r.after_correct for r in drift_rows) if drift_rows else 0
183
+
184
+ lines = ["# Before vs After — single episode\n",
185
+ f"- Overall: **{b_count}/{len(rows)}** → **{a_count}/{len(rows)}**",
186
+ f"- Drift-sensitive: **{db}/{len(drift_rows)}** → **{da}/{len(drift_rows)}**\n",
187
+ "| # | Email | Drift? | Before | After |",
188
+ "|---|-------|--------|--------|-------|"]
189
+ for i, r in enumerate(rows):
190
+ drift = f"**{r.drift_sensitive_to}**" if r.drift_sensitive_to else ("_admin_" if r.email_kind == "admin" else "-")
191
+ b = ("✅ " if r.before_correct else "❌ ") + _fmt_action(r.before_action)
192
+ a = ("✅ " if r.after_correct else "❌ ") + _fmt_action(r.after_action)
193
+ subj = r.email_subject[:40]
194
+ lines.append(f"| {i} | {subj} | {drift} | {b} | {a} |")
195
+
196
+ with open(out_path, "w") as f:
197
+ f.write("\n".join(lines) + "\n")
198
+ print(f"[ok] wrote {out_path}")
199
+
200
+
201
+ # ---------------------------------------------------------------------------
202
+ def main() -> int:
203
+ ap = argparse.ArgumentParser()
204
+ ap.add_argument("--base-model", default="unsloth/Qwen2.5-0.5B-Instruct")
205
+ ap.add_argument("--trained-adapter", default="./outputs/lora_adapters")
206
+ ap.add_argument("--seed", type=int, default=42)
207
+ ap.add_argument("--markdown", type=str, default=None,
208
+ help="If given, also write a markdown table to this path")
209
+ args = ap.parse_args()
210
+
211
+ print(f"=== Episode seed={args.seed} ===")
212
+
213
+ print("\n[1/2] Rolling out BEFORE (base model, no adapter)...")
214
+ before_agent = LocalAgent(args.base_model, adapter_path=None)
215
+ before_actions = run_one_episode(before_agent, args.seed)
216
+ del before_agent
217
+ torch.cuda.empty_cache() if torch.cuda.is_available() else None
218
+
219
+ print("\n[2/2] Rolling out AFTER (base + trained adapter)...")
220
+ after_agent = LocalAgent(args.base_model, adapter_path=args.trained_adapter)
221
+ after_actions = run_one_episode(after_agent, args.seed)
222
+
223
+ rows = collect(before_actions, after_actions, args.seed)
224
+ render_terminal(rows)
225
+ if args.markdown:
226
+ render_markdown(rows, args.markdown)
227
+ return 0
228
+
229
+
230
+ if __name__ == "__main__":
231
+ sys.exit(main())
drift_env/__init__.py ADDED
File without changes
drift_env/dataset.py ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Dataset generator: episodes -> per-step training rows.
2
+
3
+ Each row contains:
4
+ - `prompt` : the user prompt the model would see at that step
5
+ - `correct_action` : JSON string of the ground-truth correct action (SFT target)
6
+ - `email_kind` : for appropriateness scoring in the reward function
7
+ - `drift_sensitive_to` : drift_event_name this step tests, or None
8
+ - `can_earn_drift_bonus`: True only for the FIRST drift-sensitive step after
9
+ each drift event (matches env's armed-drift state)
10
+ - `episode_id` / `step_index` : traceability
11
+
12
+ History is built using the GROUND-TRUTH actions (teacher-forced). This matches
13
+ the inference distribution after the agent has been trained — we want the model
14
+ to see clean histories, not noise.
15
+
16
+ Usage:
17
+ from drift_env.dataset import build_dataset
18
+ rows = build_dataset(n_episodes=500, start_seed=0)
19
+ # -> list[dict] OR datasets.Dataset if `as_hf=True`
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ from typing import List
26
+
27
+ from drift_env.emails import CUSTOMER_TEMPLATES
28
+ from drift_env.episodes import Episode, EpisodeStep, generate_episode
29
+ from drift_env.models import Email, EmailKind, Observation
30
+ from drift_env.prompts import render_user_prompt
31
+
32
+
33
+ def _ground_truth_action_json(hint: dict) -> str:
34
+ """Serialize the correct action hint as the canonical JSON the model should emit."""
35
+ out = {"action_type": hint["action_type"]}
36
+ for key in ("refund_amount", "escalation_tier", "followup_hours",
37
+ "resolution_code", "info_field"):
38
+ v = hint.get(key)
39
+ if v is not None:
40
+ out[key] = v
41
+ return json.dumps(out, separators=(",", ": "))
42
+
43
+
44
+ def _summary_for_history(step: EpisodeStep) -> dict:
45
+ """Build the inbox-history entry as if the correct action had been taken."""
46
+ email = step.email
47
+ return {
48
+ "email_id": email.id,
49
+ "kind": email.kind.value,
50
+ "subject": email.subject,
51
+ "body": email.body,
52
+ "sender": email.sender,
53
+ "action_taken": step.correct_action_hint["action_type"],
54
+ }
55
+
56
+
57
+ def _mark_first_bonus_steps(steps: List[EpisodeStep]) -> List[bool]:
58
+ """For each step, return True iff it is the FIRST drift-sensitive step
59
+ (post-drift) that can earn the drift-attention bonus for its drift event.
60
+ """
61
+ seen_drifts: set[str] = set()
62
+ flags = []
63
+ for s in steps:
64
+ earn = False
65
+ if s.drift_sensitive_to is not None and s.drift_sensitive_to not in seen_drifts:
66
+ earn = True
67
+ seen_drifts.add(s.drift_sensitive_to)
68
+ flags.append(earn)
69
+ return flags
70
+
71
+
72
+ def _observation_from_step(
73
+ step: EpisodeStep, history: list[dict], index: int, total: int,
74
+ ) -> Observation:
75
+ """Build an Observation as the agent would see it (no grader metadata)."""
76
+ clean_email = Email(
77
+ id=step.email.id, kind=step.email.kind, subject=step.email.subject,
78
+ body=step.email.body, sender=step.email.sender, meta={},
79
+ )
80
+ return Observation(
81
+ current_email=clean_email,
82
+ email_index=index,
83
+ total_emails=total,
84
+ inbox_history=list(history),
85
+ )
86
+
87
+
88
+ def episode_to_rows(ep: Episode) -> List[dict]:
89
+ """Convert one episode to a list of per-step training rows."""
90
+ bonus_flags = _mark_first_bonus_steps(ep.steps)
91
+ rows: List[dict] = []
92
+ history: list[dict] = []
93
+ total = len(ep.steps)
94
+
95
+ for i, step in enumerate(ep.steps):
96
+ obs = _observation_from_step(step, history, i, total)
97
+ prompt = render_user_prompt(obs)
98
+ row = {
99
+ "episode_id": ep.id,
100
+ "step_index": i,
101
+ "prompt": prompt,
102
+ "correct_action_json": _ground_truth_action_json(step.correct_action_hint),
103
+ "correct_action_hint": step.correct_action_hint,
104
+ "email_kind": step.email.meta.get("kind"),
105
+ "is_admin_email": step.email.kind == EmailKind.ADMIN,
106
+ "drift_sensitive_to": step.drift_sensitive_to,
107
+ "can_earn_drift_bonus": bonus_flags[i],
108
+ "policy_refund_cap": step.policy_at_step.refund_cap,
109
+ "policy_escalation_tier": step.policy_at_step.critical_escalation_tier,
110
+ "policy_sla_hours": step.policy_at_step.sla_hours_critical,
111
+ }
112
+ rows.append(row)
113
+
114
+ # Teacher-force: append what the CORRECT action would have been to history
115
+ history.append(_summary_for_history(step))
116
+
117
+ return rows
118
+
119
+
120
+ def build_dataset(n_episodes: int, start_seed: int = 0) -> List[dict]:
121
+ """Generate a list of training rows from `n_episodes` episodes."""
122
+ all_rows: List[dict] = []
123
+ for i in range(n_episodes):
124
+ seed = start_seed + i
125
+ ep = generate_episode(seed=seed, episode_id=f"train_{seed}")
126
+ all_rows.extend(episode_to_rows(ep))
127
+ return all_rows
128
+
129
+
130
+ def dataset_stats(rows: List[dict]) -> dict:
131
+ """Quick sanity-check numbers."""
132
+ n = len(rows)
133
+ admin = sum(1 for r in rows if r["is_admin_email"])
134
+ drift_sens = sum(1 for r in rows if r["drift_sensitive_to"])
135
+ bonus_eligible = sum(1 for r in rows if r["can_earn_drift_bonus"])
136
+ kinds: dict[str, int] = {}
137
+ for r in rows:
138
+ k = r["email_kind"] or "admin"
139
+ kinds[k] = kinds.get(k, 0) + 1
140
+ return {
141
+ "n_rows": n,
142
+ "admin_rows": admin,
143
+ "customer_rows": n - admin,
144
+ "drift_sensitive_rows": drift_sens,
145
+ "bonus_eligible_rows": bonus_eligible,
146
+ "kinds_distribution": kinds,
147
+ }
drift_env/emails.py ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Customer email templates. Each template carries structured metadata
2
+ (`meta`) that the grader uses to compute the ground-truth correct action
3
+ under the current policy. The agent does NOT see `meta`.
4
+
5
+ Refund amounts are chosen to span all policy caps ($25/$50/$100/$200) so
6
+ every drift scenario produces boundary-flipping cases.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class CustomerTemplate:
16
+ id: str
17
+ subject: str
18
+ body: str
19
+ kind: str
20
+ refund_amount: float | None = None
21
+ severity: str | None = None
22
+ needs_info: str | None = None
23
+
24
+
25
+ CUSTOMER_TEMPLATES: list[CustomerTemplate] = [
26
+ # ---------- refunds at varied amounts, crossing all caps ----------
27
+ CustomerTemplate(
28
+ "refund_15", "Small refund for cancelled add-on",
29
+ "Hi, please refund the $15 add-on I cancelled yesterday. Account 18244.",
30
+ "refund", refund_amount=15.0,
31
+ ),
32
+ CustomerTemplate(
33
+ "refund_30", "Partial month refund",
34
+ "I downgraded mid-cycle on April 8 and was overcharged $30. Please refund.",
35
+ "refund", refund_amount=30.0,
36
+ ),
37
+ CustomerTemplate(
38
+ "refund_40", "Charged twice for my subscription",
39
+ "Hi, my card was charged $40 twice this month. Order IDs 82811 and 82812. "
40
+ "Please refund the duplicate.",
41
+ "refund", refund_amount=40.0,
42
+ ),
43
+ CustomerTemplate(
44
+ "refund_50", "Refund for unused trial upgrade",
45
+ "I upgraded but never used the premium features — please refund the $50.",
46
+ "refund", refund_amount=50.0,
47
+ ),
48
+ CustomerTemplate(
49
+ "refund_60", "Service interruption refund",
50
+ "We lost ~3 hours of service last night. The pro-rated refund should be $60.",
51
+ "refund", refund_amount=60.0,
52
+ ),
53
+ CustomerTemplate(
54
+ "refund_75", "Refund for bad experience",
55
+ "Your service was down during our event. We paid $75 for the premium "
56
+ "slot and couldn't use it. Please refund the $75.",
57
+ "refund", refund_amount=75.0,
58
+ ),
59
+ CustomerTemplate(
60
+ "refund_90", "Misquoted setup fee",
61
+ "I was quoted a $90 setup fee that I was told would be waived, and it "
62
+ "was still charged. Please refund $90.",
63
+ "refund", refund_amount=90.0,
64
+ ),
65
+ CustomerTemplate(
66
+ "refund_100", "Failed project kickoff refund",
67
+ "The onboarding session never happened. Please refund the $100 fee.",
68
+ "refund", refund_amount=100.0,
69
+ ),
70
+ CustomerTemplate(
71
+ "refund_120", "Duplicate annual renewal",
72
+ "I was charged $120 for a plan I already renewed through my partner "
73
+ "account. Please reverse one of the two charges.",
74
+ "refund", refund_amount=120.0,
75
+ ),
76
+ CustomerTemplate(
77
+ "refund_150", "Refund for cancelled annual plan",
78
+ "I cancelled the annual plan within the 30-day window but still got "
79
+ "charged $150. Please reverse it.",
80
+ "refund", refund_amount=150.0,
81
+ ),
82
+ CustomerTemplate(
83
+ "refund_180", "Misapplied promo code",
84
+ "The BLACKFRIDAY code I used never applied. I overpaid by $180.",
85
+ "refund", refund_amount=180.0,
86
+ ),
87
+ CustomerTemplate(
88
+ "refund_250", "Wrong tier charged",
89
+ "I'm on the Pro plan but was billed for Enterprise ($250 delta). "
90
+ "Please refund the difference.",
91
+ "refund", refund_amount=250.0,
92
+ ),
93
+
94
+ # ---------- critical incidents ----------
95
+ CustomerTemplate(
96
+ "critical_outage", "URGENT: Production is down",
97
+ "Our entire production environment is offline since 8 AM. This is "
98
+ "critical — customers can't check out. Please escalate immediately.",
99
+ "critical_incident", severity="critical",
100
+ ),
101
+ CustomerTemplate(
102
+ "critical_data_loss", "Data loss — need urgent help",
103
+ "I just noticed 3 months of customer records are missing from our "
104
+ "account. This is critical for our business.",
105
+ "critical_incident", severity="critical",
106
+ ),
107
+ CustomerTemplate(
108
+ "critical_security", "Possible unauthorised access",
109
+ "I see logins from IPs in two countries I've never been in. This "
110
+ "looks like a security breach — need urgent help.",
111
+ "critical_incident", severity="critical",
112
+ ),
113
+ CustomerTemplate(
114
+ "critical_api_down", "API returning 500 across the board",
115
+ "Every endpoint is returning 500 since ~10 minutes ago. We have a "
116
+ "customer demo in 30 minutes. This is critical.",
117
+ "critical_incident", severity="critical",
118
+ ),
119
+ CustomerTemplate(
120
+ "critical_payment_fail", "Payment processor is rejecting all charges",
121
+ "Stripe integration just started rejecting every transaction. We're "
122
+ "losing live sales. Critical priority.",
123
+ "critical_incident", severity="critical",
124
+ ),
125
+ CustomerTemplate(
126
+ "critical_auth_locked", "All admins locked out",
127
+ "Nobody on our team can sign into the admin panel since the update. "
128
+ "We can't run the business — this is critical.",
129
+ "critical_incident", severity="critical",
130
+ ),
131
+
132
+ # ---------- info requests (missing fields) ----------
133
+ CustomerTemplate(
134
+ "info_missing_order_id", "Question about my invoice",
135
+ "Hi, I got an invoice but I don't recognise the line item. Could you "
136
+ "check what it's for?",
137
+ "info_request", needs_info="order_id",
138
+ ),
139
+ CustomerTemplate(
140
+ "info_missing_account", "Help with login",
141
+ "I can't log in anymore. Could you help me get back into my account?",
142
+ "info_request", needs_info="account_email",
143
+ ),
144
+ CustomerTemplate(
145
+ "info_missing_date", "Billing concern",
146
+ "I think there was an extra charge recently but I'm not sure when. "
147
+ "Can you check?",
148
+ "info_request", needs_info="charge_date",
149
+ ),
150
+
151
+ # ---------- billing / product questions ----------
152
+ CustomerTemplate(
153
+ "billing_tiers", "How do subscription tiers work?",
154
+ "Quick question — what's the difference between Pro and Team tiers? "
155
+ "Are there usage limits on Team?",
156
+ "billing_q",
157
+ ),
158
+ CustomerTemplate(
159
+ "billing_prorated", "Does annual billing prorate?",
160
+ "If I upgrade mid-cycle on the annual plan, is the delta prorated or "
161
+ "charged in full?",
162
+ "billing_q",
163
+ ),
164
+ CustomerTemplate(
165
+ "billing_invoice_download", "Where do I get past invoices?",
166
+ "Where can I download invoices from prior months? The billing page "
167
+ "only shows the current one.",
168
+ "billing_q",
169
+ ),
170
+
171
+ # ---------- chitchat / no action needed ----------
172
+ CustomerTemplate(
173
+ "chitchat_thanks", "Thanks for the quick help",
174
+ "Just wanted to say thanks — the team resolved my issue from last "
175
+ "week really fast. Appreciate it!",
176
+ "chitchat",
177
+ ),
178
+ CustomerTemplate(
179
+ "chitchat_feedback", "Love the new dashboard",
180
+ "Wanted to say the new dashboard looks great. Much cleaner than the "
181
+ "old one. Keep it up!",
182
+ "chitchat",
183
+ ),
184
+ CustomerTemplate(
185
+ "chitchat_ooo", "Out of office until Monday",
186
+ "Hey team, I'll be out until Monday. No action needed — just flagging "
187
+ "so you're not waiting on me for the ticket from last week.",
188
+ "chitchat",
189
+ ),
190
+ ]
drift_env/environment.py ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """DriftEnv — OpenEnv-compliant multi-step environment for the
2
+ Policy-Drift support-triage task.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Optional, List, Set
8
+
9
+ from drift_env.models import Action, Email, EmailKind, Observation, State, StepResult
10
+ from drift_env.episodes import Episode, generate_episode
11
+ from drift_env.grader import grade_step
12
+
13
+
14
+ def _email_to_summary(email: Email, action_taken: Optional[Action] = None) -> dict:
15
+ entry = {
16
+ "email_id": email.id,
17
+ "kind": email.kind.value,
18
+ "subject": email.subject,
19
+ "body": email.body,
20
+ "sender": email.sender,
21
+ }
22
+ if action_taken is not None:
23
+ entry["action_taken"] = action_taken.action_type.value
24
+ return entry
25
+
26
+
27
+ def _email_for_observation(email: Email) -> Email:
28
+ """Strip grader-only metadata before exposing to agent."""
29
+ return Email(
30
+ id=email.id,
31
+ kind=email.kind,
32
+ subject=email.subject,
33
+ body=email.body,
34
+ sender=email.sender,
35
+ meta={},
36
+ )
37
+
38
+
39
+ class DriftEnv:
40
+ def __init__(self) -> None:
41
+ self._episode: Optional[Episode] = None
42
+ self._index: int = 0
43
+ self._cumulative: float = 0.0
44
+ self._done: bool = True
45
+ self._history: List[dict] = []
46
+ self._armed_drifts: Set[str] = set()
47
+
48
+ # ------------------------------------------------------------------
49
+ # OpenEnv API
50
+ # ------------------------------------------------------------------
51
+ def reset(self, seed: int = 0, episode_id: str = "ep_0") -> Observation:
52
+ self._episode = generate_episode(seed=seed, episode_id=episode_id)
53
+ self._index = 0
54
+ self._cumulative = 0.0
55
+ self._done = False
56
+ self._history = []
57
+ self._armed_drifts = set()
58
+ return self._current_observation()
59
+
60
+ def step(self, action: Action) -> StepResult:
61
+ if self._episode is None:
62
+ raise RuntimeError("Call reset() before step().")
63
+ if self._done:
64
+ raise RuntimeError("Episode is done. Call reset() to start a new one.")
65
+
66
+ step_record = self._episode.steps[self._index]
67
+ is_admin = step_record.email.kind == EmailKind.ADMIN
68
+
69
+ reward, breakdown, drift_to_clear = grade_step(
70
+ action=action,
71
+ hint=step_record.correct_action_hint,
72
+ email_meta=step_record.email.meta,
73
+ drift_sensitive_to=step_record.drift_sensitive_to,
74
+ armed_drifts=self._armed_drifts,
75
+ is_admin_email=is_admin,
76
+ )
77
+
78
+ # If agent proved awareness of a drift, clear the arm.
79
+ if drift_to_clear:
80
+ self._armed_drifts.discard(drift_to_clear)
81
+
82
+ # If THIS step is an admin email, arm its drift for future steps.
83
+ if is_admin:
84
+ drift_name = step_record.email.meta.get("drift_event")
85
+ if drift_name:
86
+ self._armed_drifts.add(drift_name)
87
+
88
+ self._cumulative += reward
89
+
90
+ # Record this email + the action for history.
91
+ self._history.append(_email_to_summary(step_record.email, action_taken=action))
92
+
93
+ self._index += 1
94
+ self._done = self._index >= len(self._episode.steps)
95
+
96
+ next_obs = None if self._done else self._current_observation()
97
+
98
+ info = {
99
+ "episode_id": self._episode.id,
100
+ "step": self._index,
101
+ "total_steps": len(self._episode.steps),
102
+ "correct_action_hint": step_record.correct_action_hint,
103
+ "drift_sensitive_to": step_record.drift_sensitive_to,
104
+ "armed_drifts_after": sorted(self._armed_drifts),
105
+ "breakdown": breakdown,
106
+ "cumulative_reward": round(self._cumulative, 4),
107
+ }
108
+
109
+ return StepResult(
110
+ observation=next_obs,
111
+ reward=round(reward, 4),
112
+ done=self._done,
113
+ info=info,
114
+ )
115
+
116
+ def state(self) -> State:
117
+ return State(
118
+ episode_id=self._episode.id if self._episode else None,
119
+ email_index=self._index,
120
+ total_emails=len(self._episode.steps) if self._episode else 0,
121
+ done=self._done,
122
+ cumulative_reward=round(self._cumulative, 4),
123
+ )
124
+
125
+ # ------------------------------------------------------------------
126
+ def _current_observation(self) -> Observation:
127
+ assert self._episode is not None
128
+ step = self._episode.steps[self._index]
129
+ return Observation(
130
+ current_email=_email_for_observation(step.email),
131
+ email_index=self._index,
132
+ total_emails=len(self._episode.steps),
133
+ inbox_history=list(self._history),
134
+ )
drift_env/episodes.py ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Episode generator — deterministic given a seed.
2
+
3
+ Produces a 20-email sequence with 2 admin (drift) emails at positions (3, 11).
4
+ The policy timeline and per-step correct_action_hint are pre-computed so the
5
+ grader can look up ground truth in O(1).
6
+
7
+ Each customer step also records whether it is "drift-sensitive" — i.e. the
8
+ correct action under the current policy is different from what it would have
9
+ been BEFORE the most recent drift event. The grader uses this to award the
10
+ drift-attention bonus.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import random
16
+ from dataclasses import dataclass, field
17
+ from typing import List, Optional
18
+
19
+ from drift_env.emails import CUSTOMER_TEMPLATES, CustomerTemplate
20
+ from drift_env.models import Email, EmailKind
21
+ from drift_env.policy import DEFAULT_POLICY, DRIFT_EVENTS, DriftEvent, Policy
22
+
23
+
24
+ EPISODE_LENGTH = 20
25
+ DRIFT_POSITIONS = (3, 11)
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class EpisodeStep:
30
+ email: Email
31
+ policy_at_step: Policy
32
+ correct_action_hint: dict
33
+ # Drift sensitivity: which drift_event_name (if any) this step proves
34
+ # awareness of. None = not drift-sensitive.
35
+ drift_sensitive_to: Optional[str] = None
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class Episode:
40
+ id: str
41
+ steps: List[EpisodeStep]
42
+ drift_timeline: List[tuple[int, str]] = field(default_factory=list)
43
+ # e.g. [(3, "sla_2hr"), (11, "refund_cap_200")]
44
+
45
+
46
+ def _make_admin_email(event: DriftEvent, idx: int) -> Email:
47
+ return Email(
48
+ id=f"admin_{idx}_{event.name}",
49
+ kind=EmailKind.ADMIN,
50
+ subject=event.admin_subject,
51
+ body=event.admin_body,
52
+ sender="ops@company.com",
53
+ meta={"drift_event": event.name},
54
+ )
55
+
56
+
57
+ def _make_customer_email(t: CustomerTemplate, idx: int) -> Email:
58
+ return Email(
59
+ id=f"c_{idx}_{t.id}",
60
+ kind=EmailKind.CUSTOMER,
61
+ subject=t.subject,
62
+ body=t.body,
63
+ sender="customer@example.com",
64
+ meta={
65
+ "template_id": t.id,
66
+ "kind": t.kind,
67
+ "refund_amount": t.refund_amount,
68
+ "severity": t.severity,
69
+ "needs_info": t.needs_info,
70
+ },
71
+ )
72
+
73
+
74
+ def _correct_action_hint(template: CustomerTemplate, p: Policy) -> dict:
75
+ """Pre-computed ground-truth correct action for (template, policy)."""
76
+ if template.kind == "refund":
77
+ if template.refund_amount is None:
78
+ return {"action_type": "request_info", "info_field": "refund_amount"}
79
+ if template.refund_amount <= p.refund_cap:
80
+ return {"action_type": "approve_refund", "refund_amount": template.refund_amount}
81
+ return {"action_type": "escalate", "escalation_tier": "manager"}
82
+
83
+ if template.kind == "critical_incident":
84
+ return {
85
+ "action_type": "escalate",
86
+ "escalation_tier": p.critical_escalation_tier,
87
+ "followup_hours": p.sla_hours_critical,
88
+ }
89
+
90
+ if template.kind == "info_request":
91
+ return {"action_type": "request_info", "info_field": template.needs_info}
92
+
93
+ if template.kind == "billing_q":
94
+ return {"action_type": "reply"}
95
+
96
+ if template.kind == "chitchat":
97
+ return {"action_type": "close", "resolution_code": "no_action_needed"}
98
+
99
+ return {"action_type": "reply"}
100
+
101
+
102
+ def _hints_differ(a: dict, b: dict) -> bool:
103
+ """Do two action hints prescribe a meaningfully different action?
104
+ We ignore free-text fields (reply_text) and compare the structural fields.
105
+ """
106
+ keys = ("action_type", "refund_amount", "escalation_tier",
107
+ "followup_hours", "resolution_code", "info_field")
108
+ return any(a.get(k) != b.get(k) for k in keys)
109
+
110
+
111
+ def generate_episode(seed: int = 0, episode_id: str = "ep_0") -> Episode:
112
+ rng = random.Random(seed)
113
+
114
+ # Sample 2 drift events from different TYPES so they stack.
115
+ drift_by_type = {
116
+ "refund": ["refund_cap_50", "refund_cap_25", "refund_cap_200"],
117
+ "escalate": ["escalate_manager", "escalate_tier_1", "escalate_keep_tier_2"],
118
+ "sla": ["sla_2hr", "sla_4hr", "sla_48hr"],
119
+ }
120
+ types = rng.sample(list(drift_by_type.keys()), k=2)
121
+ drift_names = [rng.choice(drift_by_type[t]) for t in types]
122
+ drifts = [DRIFT_EVENTS[n] for n in drift_names]
123
+
124
+ # Sample customer templates with a bias toward drift-sensitive kinds.
125
+ drift_sensitive_pool = [t for t in CUSTOMER_TEMPLATES
126
+ if t.kind in ("refund", "critical_incident")]
127
+ other_pool = [t for t in CUSTOMER_TEMPLATES
128
+ if t.kind not in ("refund", "critical_incident")]
129
+
130
+ customer_count = EPISODE_LENGTH - len(drifts)
131
+ picks: List[CustomerTemplate] = []
132
+ for _ in range(customer_count):
133
+ pool = drift_sensitive_pool if rng.random() < 0.7 else other_pool
134
+ picks.append(rng.choice(pool))
135
+
136
+ # Walk through episode positions, interleaving admin emails.
137
+ steps: List[EpisodeStep] = []
138
+ current_policy = DEFAULT_POLICY
139
+ policy_before_last_drift: Optional[Policy] = None
140
+ last_drift_name: Optional[str] = None
141
+
142
+ drift_queue = list(zip(DRIFT_POSITIONS, drifts))
143
+ cust_iter = iter(picks)
144
+ timeline: List[tuple[int, str]] = []
145
+
146
+ for idx in range(EPISODE_LENGTH):
147
+ if drift_queue and drift_queue[0][0] == idx:
148
+ _, event = drift_queue.pop(0)
149
+ admin_email = _make_admin_email(event, idx)
150
+ steps.append(EpisodeStep(
151
+ email=admin_email,
152
+ policy_at_step=current_policy,
153
+ correct_action_hint={"action_type": "close",
154
+ "resolution_code": "policy_acknowledged"},
155
+ drift_sensitive_to=None,
156
+ ))
157
+ # AFTER processing this admin email, record prior policy + update.
158
+ policy_before_last_drift = current_policy
159
+ current_policy = event.apply(current_policy)
160
+ last_drift_name = event.name
161
+ timeline.append((idx, event.name))
162
+ else:
163
+ template = next(cust_iter)
164
+ cust_email = _make_customer_email(template, idx)
165
+ correct_now = _correct_action_hint(template, current_policy)
166
+
167
+ # Drift sensitivity: does the answer change vs pre-drift policy?
168
+ sensitive_to: Optional[str] = None
169
+ if last_drift_name and policy_before_last_drift is not None:
170
+ correct_pre = _correct_action_hint(template, policy_before_last_drift)
171
+ if _hints_differ(correct_now, correct_pre):
172
+ sensitive_to = last_drift_name
173
+
174
+ steps.append(EpisodeStep(
175
+ email=cust_email,
176
+ policy_at_step=current_policy,
177
+ correct_action_hint=correct_now,
178
+ drift_sensitive_to=sensitive_to,
179
+ ))
180
+
181
+ return Episode(id=episode_id, steps=steps, drift_timeline=timeline)
drift_env/grader.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic 3-component grader.
2
+
3
+ Per-step reward in [0.0, 2.0] composed of:
4
+ 1. compliance 0.0 – 1.0 — did the action respect current policy?
5
+ 2. appropriateness 0.0 – 0.5 — was the action TYPE sensible for the email?
6
+ 3. drift_bonus 0.0 – 0.5 — first correct drift-aware action after a
7
+ drift fires once per drift event.
8
+
9
+ No LLM-as-judge, no randomness. Ground truth is pre-computed in
10
+ `episodes.EpisodeStep.correct_action_hint`, so the grader is a pure function
11
+ of (action, correct_hint, email_meta, drift_sensitive_to, armed_drifts).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Tuple
17
+
18
+ from drift_env.models import Action
19
+
20
+
21
+ # --------------------------------------------------------------------------
22
+ # Compliance — exact structural match on the fields that define "correct"
23
+ # --------------------------------------------------------------------------
24
+ def _compliance(action: Action, hint: dict) -> float:
25
+ want = hint.get("action_type")
26
+ got = action.action_type.value
27
+ if got != want:
28
+ return 0.0
29
+
30
+ # Field-level exact match depending on action_type
31
+ if want == "approve_refund":
32
+ return 1.0 if action.refund_amount == hint.get("refund_amount") else 0.5
33
+ if want == "escalate":
34
+ tier_ok = action.escalation_tier == hint.get("escalation_tier")
35
+ hours_want = hint.get("followup_hours")
36
+ if hours_want is None:
37
+ return 1.0 if tier_ok else 0.5
38
+ hours_ok = action.followup_hours == hours_want
39
+ if tier_ok and hours_ok:
40
+ return 1.0
41
+ if tier_ok or hours_ok:
42
+ return 0.7
43
+ return 0.4
44
+ if want == "request_info":
45
+ return 1.0 if action.info_field == hint.get("info_field") else 0.5
46
+ if want == "close":
47
+ return 1.0 if action.resolution_code == hint.get("resolution_code") else 0.7
48
+
49
+ # Plain reply / anything else: action_type match is enough.
50
+ return 1.0
51
+
52
+
53
+ # --------------------------------------------------------------------------
54
+ # Appropriateness — is the chosen action TYPE reasonable for this email kind?
55
+ # Gives partial credit when the agent picks a sensible action type even if
56
+ # specific fields are wrong. Caps at 0.5.
57
+ # --------------------------------------------------------------------------
58
+ _APPROPRIATE_BY_KIND = {
59
+ "refund": {"approve_refund", "escalate", "request_info"},
60
+ "critical_incident": {"escalate"},
61
+ "info_request": {"request_info"},
62
+ "billing_q": {"reply"},
63
+ "chitchat": {"close"},
64
+ }
65
+
66
+
67
+ def _appropriateness(action: Action, email_meta: dict) -> float:
68
+ kind = email_meta.get("kind")
69
+ if kind is None:
70
+ return 0.0
71
+ ok_set = _APPROPRIATE_BY_KIND.get(kind, set())
72
+ if action.action_type.value in ok_set:
73
+ return 0.5
74
+ # Admin emails are handled by the env; this path is customer-only.
75
+ return 0.0
76
+
77
+
78
+ # --------------------------------------------------------------------------
79
+ # Drift bonus — +0.5 the FIRST time the agent nails a drift-sensitive step
80
+ # after the corresponding drift has fired. Stateful across the episode,
81
+ # tracked by the environment in `armed_drifts`.
82
+ # --------------------------------------------------------------------------
83
+ def _drift_bonus(
84
+ drift_sensitive_to: str | None,
85
+ armed_drifts: set[str],
86
+ compliance_score: float,
87
+ ) -> Tuple[float, str | None]:
88
+ """Returns (bonus, drift_to_clear_if_awarded)."""
89
+ if drift_sensitive_to is None:
90
+ return 0.0, None
91
+ if drift_sensitive_to not in armed_drifts:
92
+ return 0.0, None
93
+ if compliance_score < 1.0:
94
+ return 0.0, None
95
+ return 0.5, drift_sensitive_to
96
+
97
+
98
+ # --------------------------------------------------------------------------
99
+ # Admin emails — handled as a separate scoring path (not drift-sensitive,
100
+ # grader doesn't compute appropriateness for them).
101
+ # --------------------------------------------------------------------------
102
+ def _grade_admin(action: Action) -> float:
103
+ if action.action_type.value == "close":
104
+ # Full credit regardless of resolution_code — we don't want to
105
+ # punish harmless reword-ing of the ack.
106
+ return 1.0
107
+ # Any non-close action on an admin email is a minor error.
108
+ return 0.2
109
+
110
+
111
+ # --------------------------------------------------------------------------
112
+ # Top-level: returns (total_reward, breakdown_dict, drift_to_clear)
113
+ # --------------------------------------------------------------------------
114
+ def grade_step(
115
+ action: Action,
116
+ hint: dict,
117
+ email_meta: dict,
118
+ drift_sensitive_to: str | None,
119
+ armed_drifts: set[str],
120
+ is_admin_email: bool,
121
+ ) -> Tuple[float, dict, str | None]:
122
+ if is_admin_email:
123
+ reward = _grade_admin(action)
124
+ return reward, {"compliance": reward, "appropriateness": 0.0,
125
+ "drift_bonus": 0.0}, None
126
+
127
+ compliance = _compliance(action, hint)
128
+ appropriateness = _appropriateness(action, email_meta)
129
+ drift_bonus, drift_to_clear = _drift_bonus(
130
+ drift_sensitive_to, armed_drifts, compliance,
131
+ )
132
+ total = compliance + appropriateness + drift_bonus
133
+ breakdown = {
134
+ "compliance": round(compliance, 4),
135
+ "appropriateness": round(appropriateness, 4),
136
+ "drift_bonus": round(drift_bonus, 4),
137
+ }
138
+ return round(total, 4), breakdown, drift_to_clear
drift_env/llm_agent.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """A thin LLM agent that reads the current email + inbox history and emits
2
+ an Action. Used by both the eval harness and the onsite inference pipeline.
3
+
4
+ Uses the OpenAI Python client against any OpenAI-compatible endpoint
5
+ (HF router, Groq, etc). No Anthropic / Google SDKs — hackathon requires
6
+ OpenAI client only.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from typing import Optional
13
+
14
+ from openai import OpenAI
15
+
16
+ from drift_env.models import Action, ActionType, Observation
17
+ from drift_env.prompts import SYSTEM_PROMPT, render_user_prompt
18
+
19
+
20
+ FALLBACK = Action(action_type=ActionType.CLOSE, resolution_code="error_fallback")
21
+
22
+
23
+ def _parse_action(raw: str) -> Action:
24
+ """Parse the LLM output into an Action. Returns FALLBACK on failure."""
25
+ text = raw.strip()
26
+ # strip markdown fences if present
27
+ if text.startswith("```"):
28
+ lines = [l for l in text.split("\n") if not l.strip().startswith("```")]
29
+ text = "\n".join(lines).strip()
30
+ # Try to locate a JSON object if surrounded by prose
31
+ if not text.startswith("{"):
32
+ start = text.find("{")
33
+ end = text.rfind("}")
34
+ if start == -1 or end == -1 or end < start:
35
+ return FALLBACK
36
+ text = text[start:end + 1]
37
+ try:
38
+ obj = json.loads(text)
39
+ except json.JSONDecodeError:
40
+ return FALLBACK
41
+ a_type = obj.get("action_type")
42
+ if a_type not in {e.value for e in ActionType}:
43
+ return FALLBACK
44
+ try:
45
+ return Action(
46
+ action_type=ActionType(a_type),
47
+ refund_amount=obj.get("refund_amount"),
48
+ escalation_tier=obj.get("escalation_tier"),
49
+ followup_hours=obj.get("followup_hours"),
50
+ resolution_code=obj.get("resolution_code"),
51
+ info_field=obj.get("info_field"),
52
+ reply_text=obj.get("reply_text"),
53
+ )
54
+ except Exception:
55
+ return FALLBACK
56
+
57
+
58
+ class LLMAgent:
59
+ def __init__(
60
+ self,
61
+ api_key: str,
62
+ base_url: str,
63
+ model: str,
64
+ temperature: float = 0.0,
65
+ max_tokens: int = 200,
66
+ system_prompt: str = SYSTEM_PROMPT,
67
+ ) -> None:
68
+ self.client = OpenAI(api_key=api_key, base_url=base_url)
69
+ self.model = model
70
+ self.temperature = temperature
71
+ self.max_tokens = max_tokens
72
+ self.system_prompt = system_prompt
73
+
74
+ def act(self, obs: Observation) -> tuple[Action, str]:
75
+ """Returns (parsed_action, raw_text) for inspection."""
76
+ user_msg = render_user_prompt(obs)
77
+ try:
78
+ completion = self.client.chat.completions.create(
79
+ model=self.model,
80
+ messages=[
81
+ {"role": "system", "content": self.system_prompt},
82
+ {"role": "user", "content": user_msg},
83
+ ],
84
+ temperature=self.temperature,
85
+ max_tokens=self.max_tokens,
86
+ )
87
+ raw = completion.choices[0].message.content or ""
88
+ except Exception as e:
89
+ return FALLBACK, f"ERROR: {e}"
90
+ return _parse_action(raw), raw
drift_env/models.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Typed Pydantic models for the Policy Drift environment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+ from typing import Optional, List
7
+ from pydantic import BaseModel, Field
8
+
9
+
10
+ class EmailKind(str, Enum):
11
+ CUSTOMER = "customer"
12
+ ADMIN = "admin"
13
+
14
+
15
+ class Email(BaseModel):
16
+ """A single email visible to the agent."""
17
+ id: str
18
+ kind: EmailKind
19
+ subject: str
20
+ body: str
21
+ sender: str
22
+ # For customer emails only — drift-relevant metadata used by the grader
23
+ # (agent does NOT see these fields; they're stripped before observation).
24
+ meta: dict = Field(default_factory=dict)
25
+
26
+
27
+ class ActionType(str, Enum):
28
+ REPLY = "reply"
29
+ APPROVE_REFUND = "approve_refund"
30
+ ESCALATE = "escalate"
31
+ SCHEDULE_FOLLOWUP = "schedule_followup"
32
+ CLOSE = "close"
33
+ REQUEST_INFO = "request_info"
34
+
35
+
36
+ class Action(BaseModel):
37
+ """A single discrete action with optional scalar/string args."""
38
+ action_type: ActionType
39
+ refund_amount: Optional[float] = None # approve_refund
40
+ escalation_tier: Optional[str] = None # escalate: tier_1 | tier_2 | manager
41
+ followup_hours: Optional[int] = None # schedule_followup
42
+ resolution_code: Optional[str] = None # close
43
+ info_field: Optional[str] = None # request_info
44
+ reply_text: Optional[str] = None # reply
45
+
46
+
47
+ class Observation(BaseModel):
48
+ """What the agent sees. Current policy is NOT exposed — agent must infer
49
+ it from admin emails in the inbox history."""
50
+ current_email: Email
51
+ email_index: int # 0-based position in episode
52
+ total_emails: int
53
+ inbox_history: List[dict] = Field(default_factory=list)
54
+ # Each history entry: {email_id, kind, subject, body_summary, action_taken?}
55
+
56
+
57
+ class StepResult(BaseModel):
58
+ observation: Optional[Observation]
59
+ reward: float
60
+ done: bool
61
+ info: dict
62
+
63
+
64
+ class State(BaseModel):
65
+ """Env state snapshot for debugging / validator."""
66
+ episode_id: Optional[str]
67
+ email_index: int
68
+ total_emails: int
69
+ done: bool
70
+ cumulative_reward: float
drift_env/policy.py ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Policy state + drift scenarios.
2
+
3
+ The policy is the hidden ground-truth rule set the agent must infer from
4
+ admin emails. The environment updates this when an admin email is "read."
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field, replace
10
+ from typing import Literal
11
+
12
+
13
+ Tier = Literal["tier_1", "tier_2", "manager"]
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class Policy:
18
+ """Current policy rules. Immutable — drift events produce new instances."""
19
+ refund_cap: float # max auto-approvable refund USD
20
+ critical_escalation_tier: Tier # where "critical" issues must go
21
+ sla_hours_critical: int # response SLA for critical issues
22
+
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Drift scenarios — each is a (trigger, delta) pair describing one change.
26
+ # ---------------------------------------------------------------------------
27
+
28
+
29
+ Direction = Literal["tightening", "loosening", "neutral"]
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class DriftEvent:
34
+ """A single drift event delivered via an admin email."""
35
+ name: str
36
+ # Direction captures whether the new rule is STRICTER than the default
37
+ # ("tightening" — agents with internet priors will get this wrong),
38
+ # LOOSER than the default ("loosening" — base models already handle this
39
+ # by accident because it matches their lenient prior), or unchanged
40
+ # ("neutral" — acts as a distractor). This is the single most important
41
+ # piece of instrumentation for the pitch story: measure drift-sensitive
42
+ # accuracy separately on tightening vs loosening drifts.
43
+ direction: Direction
44
+ admin_subject: str
45
+ admin_body: str
46
+
47
+ # What fields does this drift change? Only the mentioned ones are applied.
48
+ new_refund_cap: float | None = None
49
+ new_critical_escalation_tier: Tier | None = None
50
+ new_sla_hours_critical: int | None = None
51
+
52
+ def apply(self, p: Policy) -> Policy:
53
+ return replace(
54
+ p,
55
+ refund_cap=self.new_refund_cap if self.new_refund_cap is not None else p.refund_cap,
56
+ critical_escalation_tier=self.new_critical_escalation_tier or p.critical_escalation_tier,
57
+ sla_hours_critical=self.new_sla_hours_critical if self.new_sla_hours_critical is not None else p.sla_hours_critical,
58
+ )
59
+
60
+
61
+ # Starting policy — the "internet prior" that base models will default to.
62
+ DEFAULT_POLICY = Policy(
63
+ refund_cap=100.0,
64
+ critical_escalation_tier="tier_2",
65
+ sla_hours_critical=24,
66
+ )
67
+
68
+
69
+ # 3 drift TYPES × 3 variations = 9 configs. Each variation stays realistic
70
+ # but moves the correct answer far from the default prior.
71
+ DRIFT_EVENTS: dict[str, DriftEvent] = {
72
+ # --- refund cap changes -------------------------------------------------
73
+ "refund_cap_50": DriftEvent(
74
+ name="refund_cap_50",
75
+ direction="tightening",
76
+ admin_subject="Policy update: refund approval threshold",
77
+ admin_body=(
78
+ "Team — effective immediately, the auto-approval cap for customer "
79
+ "refunds is lowered from $100 to $50. Any refund above $50 must be "
80
+ "escalated to a manager. Please update your workflow accordingly."
81
+ ),
82
+ new_refund_cap=50.0,
83
+ ),
84
+ "refund_cap_25": DriftEvent(
85
+ name="refund_cap_25",
86
+ direction="tightening",
87
+ admin_subject="Q2 budget controls — refund changes",
88
+ admin_body=(
89
+ "Hi all, due to Q2 budget tightening the refund auto-approval cap "
90
+ "is now $25 (previously $100). Anything over $25 goes to manager "
91
+ "review. This is effective for all tickets from today onward."
92
+ ),
93
+ new_refund_cap=25.0,
94
+ ),
95
+ "refund_cap_200": DriftEvent(
96
+ name="refund_cap_200",
97
+ direction="loosening",
98
+ admin_subject="Refund policy loosened for retention push",
99
+ admin_body=(
100
+ "Everyone — for the next retention campaign, refund auto-approval "
101
+ "cap is raised from $100 to $200. You can approve refunds up to "
102
+ "$200 without escalation. Use judgement on obvious abuse cases."
103
+ ),
104
+ new_refund_cap=200.0,
105
+ ),
106
+
107
+ # --- escalation routing changes ----------------------------------------
108
+ "escalate_manager": DriftEvent(
109
+ name="escalate_manager",
110
+ direction="tightening",
111
+ admin_subject="Critical-issue routing change",
112
+ admin_body=(
113
+ "FYI — all critical-severity customer issues must now route "
114
+ "directly to a manager (not Tier 2 as before). This is to comply "
115
+ "with the new incident-response policy. Effective immediately."
116
+ ),
117
+ new_critical_escalation_tier="manager",
118
+ ),
119
+ "escalate_tier_1": DriftEvent(
120
+ name="escalate_tier_1",
121
+ direction="loosening",
122
+ admin_subject="Tier 1 expanded — critical handling",
123
+ admin_body=(
124
+ "Team — we've trained Tier 1 on critical incident handling. Route "
125
+ "critical issues to Tier 1 now (down from Tier 2). This cuts "
126
+ "response time and lets Tier 2 focus on deep investigations."
127
+ ),
128
+ new_critical_escalation_tier="tier_1",
129
+ ),
130
+ "escalate_keep_tier_2": DriftEvent(
131
+ name="escalate_keep_tier_2",
132
+ direction="neutral",
133
+ admin_subject="Routing confirmation — no change",
134
+ admin_body=(
135
+ "Quick confirmation: critical issues continue to route to Tier 2. "
136
+ "No change from existing workflow. Ignore any conflicting updates "
137
+ "you may have seen on Slack earlier this week."
138
+ ),
139
+ new_critical_escalation_tier="tier_2",
140
+ ),
141
+
142
+ # --- SLA changes --------------------------------------------------------
143
+ "sla_2hr": DriftEvent(
144
+ name="sla_2hr",
145
+ direction="tightening",
146
+ admin_subject="Critical SLA tightened to 2 hours",
147
+ admin_body=(
148
+ "Per the updated enterprise contract, critical-severity issues "
149
+ "now have a 2-hour response SLA (was 24 hours). Schedule any "
150
+ "follow-ups on critical tickets within 2 hours of receipt."
151
+ ),
152
+ new_sla_hours_critical=2,
153
+ ),
154
+ "sla_4hr": DriftEvent(
155
+ name="sla_4hr",
156
+ direction="tightening",
157
+ admin_subject="SLA adjustment — critical issues",
158
+ admin_body=(
159
+ "Small change — critical-issue response SLA is now 4 hours "
160
+ "(previously 24 hours). Please adjust your follow-up scheduling. "
161
+ "Non-critical SLAs are unchanged."
162
+ ),
163
+ new_sla_hours_critical=4,
164
+ ),
165
+ "sla_48hr": DriftEvent(
166
+ name="sla_48hr",
167
+ direction="loosening",
168
+ admin_subject="SLA relaxed during platform migration",
169
+ admin_body=(
170
+ "During the platform migration this week, critical-issue SLA is "
171
+ "temporarily extended to 48 hours to give the infra team room. "
172
+ "Please batch follow-ups accordingly."
173
+ ),
174
+ new_sla_hours_critical=48,
175
+ ),
176
+ }
177
+
178
+
179
+ def list_drift_events() -> list[str]:
180
+ return list(DRIFT_EVENTS.keys())
181
+
182
+
183
+ def drift_direction(name: str | None) -> Direction | None:
184
+ """Look up the direction label of a drift event by name. Returns None if
185
+ the name is unknown or None."""
186
+ if name is None:
187
+ return None
188
+ ev = DRIFT_EVENTS.get(name)
189
+ return ev.direction if ev else None
drift_env/prompts.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared prompt rendering used by both the inference agent and the
2
+ training-dataset generator. Keeping it in one place guarantees the training
3
+ distribution matches the inference distribution exactly.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from drift_env.models import Observation
9
+
10
+
11
+ SYSTEM_PROMPT = """You are a customer-support triage agent.
12
+
13
+ Your inbox contains a mix of:
14
+ - CUSTOMER emails: regular tickets (refunds, complaints, questions, chit-chat).
15
+ - ADMIN emails from ops@company.com: internal policy announcements that
16
+ change the rules you must follow from that point onwards.
17
+
18
+ You must read the CURRENT email and reply with exactly one action, formatted
19
+ as a single JSON object. Allowed actions and fields:
20
+
21
+ {"action_type": "reply"}
22
+ {"action_type": "approve_refund", "refund_amount": <number>}
23
+ {"action_type": "escalate", "escalation_tier": "tier_1" | "tier_2" | "manager", "followup_hours": <int>}
24
+ {"action_type": "schedule_followup", "followup_hours": <int>}
25
+ {"action_type": "close", "resolution_code": <short string>}
26
+ {"action_type": "request_info", "info_field": <short string>}
27
+
28
+ Rules of thumb (apply the MOST RECENT admin policy you have seen):
29
+ - Refunds at or below the current auto-approval cap -> approve_refund with
30
+ the requested amount. Above the cap -> escalate to manager.
31
+ - Critical incidents -> escalate to the currently-mandated tier with a
32
+ followup_hours matching the current critical-SLA.
33
+ - Admin email -> close with resolution_code "policy_acknowledged".
34
+ - Questions you cannot answer without a detail the customer omitted
35
+ -> request_info with the missing field name (e.g. order_id, account_email).
36
+ - Pure thank-you / FYI / chit-chat -> close with resolution_code
37
+ "no_action_needed".
38
+ - Product / how-does-this-work questions -> reply.
39
+
40
+ Reply with ONLY the JSON object. No prose, no markdown."""
41
+
42
+
43
+ def render_history(history: list[dict], last_n: int = 8, max_body_chars: int = 200) -> str:
44
+ """Compact text rendering of prior inbox entries."""
45
+ if not history:
46
+ return "(no prior emails in this session)"
47
+ shown = history[-last_n:]
48
+ lines = []
49
+ for h in shown:
50
+ prefix = "[ADMIN]" if h["kind"] == "admin" else "[CUSTOMER]"
51
+ action = h.get("action_taken", "?")
52
+ body = h["body"]
53
+ if len(body) > max_body_chars:
54
+ body = body[:max_body_chars] + "..."
55
+ lines.append(
56
+ f"{prefix} subject={h['subject']!r}\n body: {body}\n action_taken: {action}"
57
+ )
58
+ return "\n".join(lines)
59
+
60
+
61
+ def render_user_prompt(obs: Observation) -> str:
62
+ hist = render_history(obs.inbox_history)
63
+ cur = obs.current_email
64
+ return (
65
+ f"INBOX SO FAR (most recent last):\n{hist}\n\n"
66
+ f"------\nCURRENT EMAIL (#{obs.email_index + 1} of {obs.total_emails}):\n"
67
+ f" from: {cur.sender}\n subject: {cur.subject}\n body: {cur.body}\n\n"
68
+ f"Reply with exactly one JSON action."
69
+ )
drift_env/server/__init__.py ADDED
File without changes
drift_env/server/app.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI server for the DriftEnv. Same OpenEnv contract as round 1."""
2
+
3
+ from typing import Optional
4
+
5
+ from fastapi import FastAPI, HTTPException
6
+ from pydantic import BaseModel
7
+
8
+ from drift_env.environment import DriftEnv
9
+ from drift_env.models import Action, Observation, State, StepResult
10
+
11
+ app = FastAPI(title="LeniencyBench", version="0.1.0")
12
+ env = DriftEnv()
13
+
14
+
15
+ @app.get("/")
16
+ def root():
17
+ return {
18
+ "name": "LeniencyBench",
19
+ "code_name": "policy-drift",
20
+ "version": "0.1.0",
21
+ "description": (
22
+ "An OpenEnv benchmark that measures and trains out LLM leniency "
23
+ "bias: the tendency to apply old/loose policies even after an "
24
+ "admin message tightens the rule. 20-email customer-support "
25
+ "episodes with 2 policy drifts per episode at fixed positions."
26
+ ),
27
+ "endpoints": ["/reset", "/step", "/state"],
28
+ }
29
+
30
+
31
+ class ResetRequest(BaseModel):
32
+ seed: int = 0
33
+ episode_id: str = "ep_0"
34
+
35
+
36
+ @app.post("/reset", response_model=Observation)
37
+ def reset(req: Optional[ResetRequest] = None):
38
+ req = req or ResetRequest()
39
+ return env.reset(seed=req.seed, episode_id=req.episode_id)
40
+
41
+
42
+ @app.post("/step", response_model=StepResult)
43
+ def step(action: Action):
44
+ try:
45
+ return env.step(action)
46
+ except RuntimeError as e:
47
+ raise HTTPException(status_code=400, detail=str(e))
48
+
49
+
50
+ @app.get("/state", response_model=State)
51
+ def state():
52
+ return env.state()
drift_env/tests/__init__.py ADDED
File without changes
drift_env/tests/test_adversarial.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Adversarial sanity test.
2
+
3
+ Runs a handful of dumb policies (constant-action agents, mode-based agents,
4
+ stale-policy agents) against the environment. None of them should score well —
5
+ if any of them beats ~60% of max, our reward is gameable and we need to fix it.
6
+
7
+ Per organizer guidance: "Do not optimize a reward you have not tried to break
8
+ yourself first."
9
+ """
10
+
11
+ from drift_env.environment import DriftEnv
12
+ from drift_env.episodes import generate_episode
13
+ from drift_env.models import Action, ActionType
14
+
15
+
16
+ EP_MAX = 30.0 # per-episode maximum reward (20 compliance + 9 appropriateness + 1 drift bonus)
17
+ N_SEEDS = 20 # episodes to average over for stability
18
+ GAMEABILITY_CEILING = 0.60 # no dumb policy should exceed this fraction of max
19
+
20
+
21
+ def _run_policy(policy_fn, n_seeds: int = N_SEEDS) -> float:
22
+ """Run a callable(step_plan) -> Action across n episodes and return mean
23
+ fraction of max reward."""
24
+ total = 0.0
25
+ for i in range(n_seeds):
26
+ env = DriftEnv()
27
+ env.reset(seed=i, episode_id=f"adv_{i}")
28
+ ep = generate_episode(seed=i, episode_id=f"adv_{i}")
29
+ ep_reward = 0.0
30
+ for step in ep.steps:
31
+ result = env.step(policy_fn(step))
32
+ ep_reward += result.reward
33
+ total += ep_reward / EP_MAX
34
+ return total / n_seeds
35
+
36
+
37
+ def test_always_close_fails():
38
+ mean = _run_policy(lambda s: Action(action_type=ActionType.CLOSE, resolution_code="x"))
39
+ assert mean < GAMEABILITY_CEILING, f"always-close scored {mean:.2%}"
40
+
41
+
42
+ def test_always_approve_40_fails():
43
+ mean = _run_policy(lambda s: Action(
44
+ action_type=ActionType.APPROVE_REFUND, refund_amount=40.0,
45
+ ))
46
+ assert mean < GAMEABILITY_CEILING, f"always-approve-40 scored {mean:.2%}"
47
+
48
+
49
+ def test_always_escalate_manager_fails():
50
+ mean = _run_policy(lambda s: Action(
51
+ action_type=ActionType.ESCALATE, escalation_tier="manager", followup_hours=24,
52
+ ))
53
+ assert mean < GAMEABILITY_CEILING, f"always-escalate-manager scored {mean:.2%}"
54
+
55
+
56
+ def test_always_reply_fails():
57
+ mean = _run_policy(lambda s: Action(action_type=ActionType.REPLY))
58
+ assert mean < GAMEABILITY_CEILING, f"always-reply scored {mean:.2%}"
59
+
60
+
61
+ def test_random_action_mode_fails():
62
+ """Constant mode: pick whichever ACTION_TYPE dominates correct answers."""
63
+ # empirically close, approve_refund, and escalate will dominate
64
+ for at in ActionType:
65
+ mean = _run_policy(lambda s, at=at: Action(
66
+ action_type=at,
67
+ refund_amount=50.0, escalation_tier="manager", followup_hours=24,
68
+ resolution_code="x", info_field="x",
69
+ ))
70
+ assert mean < GAMEABILITY_CEILING, f"always-{at.value} scored {mean:.2%}"
71
+
72
+
73
+ def test_stale_policy_agent_approximates_baseline():
74
+ """An agent that behaves correctly under the STARTING policy (refund_cap=$100,
75
+ tier_2 escalations, 24h SLA) but ignores all drift should score ~baseline:
76
+ good on non-drift-sensitive steps, 0 on drift-sensitive ones. This is the
77
+ canonical base-model behaviour we saw in the eval (~77% of max). The point
78
+ of this test: it should NOT accidentally earn the drift bonus."""
79
+ from drift_env.policy import DEFAULT_POLICY
80
+ from drift_env.episodes import _correct_action_hint
81
+
82
+ def stale(step):
83
+ if step.email.kind.value == "admin":
84
+ return Action(action_type=ActionType.CLOSE, resolution_code="policy_acknowledged")
85
+ # Pick the action correct under the DEFAULT policy (ignoring drifts)
86
+ template_kind = step.email.meta.get("kind", "")
87
+ refund_amt = step.email.meta.get("refund_amount")
88
+ # Rebuild a minimal template-like object:
89
+ class _T:
90
+ kind = template_kind
91
+ refund_amount = refund_amt
92
+ severity = step.email.meta.get("severity")
93
+ needs_info = step.email.meta.get("needs_info")
94
+ hint = _correct_action_hint(_T(), DEFAULT_POLICY)
95
+ return Action(
96
+ action_type=ActionType(hint["action_type"]),
97
+ refund_amount=hint.get("refund_amount"),
98
+ escalation_tier=hint.get("escalation_tier"),
99
+ followup_hours=hint.get("followup_hours"),
100
+ resolution_code=hint.get("resolution_code"),
101
+ info_field=hint.get("info_field"),
102
+ )
103
+
104
+ mean = _run_policy(stale)
105
+ # Stale agent should beat dumb constants but shouldn't reach perfect
106
+ assert 0.5 < mean < 0.95, f"stale-policy agent scored {mean:.2%} (expected 0.5-0.95)"
107
+
108
+
109
+ def test_perfect_agent_still_wins_by_margin():
110
+ """Reference: a perfect agent (uses policy-aware ground truth) must clearly
111
+ beat all the above. This protects against the worst failure mode: a dumb
112
+ agent scoring as high as a perfect one."""
113
+ from drift_env.episodes import _correct_action_hint
114
+
115
+ def perfect(step):
116
+ h = step.correct_action_hint
117
+ return Action(
118
+ action_type=ActionType(h["action_type"]),
119
+ refund_amount=h.get("refund_amount"),
120
+ escalation_tier=h.get("escalation_tier"),
121
+ followup_hours=h.get("followup_hours"),
122
+ resolution_code=h.get("resolution_code"),
123
+ info_field=h.get("info_field"),
124
+ )
125
+
126
+ mean = _run_policy(perfect)
127
+ assert mean > 0.95, f"perfect agent only scored {mean:.2%}"
drift_env/tests/test_environment.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Environment + episode-generator tests."""
2
+
3
+ import pytest
4
+
5
+ from drift_env.environment import DriftEnv
6
+ from drift_env.episodes import generate_episode, EPISODE_LENGTH, DRIFT_POSITIONS
7
+ from drift_env.models import Action, ActionType, EmailKind
8
+ from drift_env.policy import DEFAULT_POLICY
9
+
10
+
11
+ def test_episode_has_expected_length():
12
+ ep = generate_episode(seed=1)
13
+ assert len(ep.steps) == EPISODE_LENGTH
14
+
15
+
16
+ def test_episode_has_two_admin_emails_at_expected_positions():
17
+ ep = generate_episode(seed=1)
18
+ admin_positions = [i for i, s in enumerate(ep.steps)
19
+ if s.email.kind == EmailKind.ADMIN]
20
+ assert admin_positions == list(DRIFT_POSITIONS)
21
+
22
+
23
+ def test_policy_evolves_after_each_admin_email():
24
+ ep = generate_episode(seed=42)
25
+ # steps 0..3 (inclusive of admin) operate under starting policy
26
+ for i in range(DRIFT_POSITIONS[0] + 1):
27
+ assert ep.steps[i].policy_at_step == DEFAULT_POLICY
28
+ # step after first drift must differ
29
+ assert ep.steps[DRIFT_POSITIONS[0] + 1].policy_at_step != DEFAULT_POLICY
30
+
31
+
32
+ def test_episode_is_deterministic_with_seed():
33
+ a = generate_episode(seed=7)
34
+ b = generate_episode(seed=7)
35
+ assert [s.email.id for s in a.steps] == [s.email.id for s in b.steps]
36
+ assert [s.correct_action_hint for s in a.steps] == [s.correct_action_hint for s in b.steps]
37
+
38
+
39
+ def test_different_seeds_produce_different_episodes():
40
+ a = generate_episode(seed=1)
41
+ b = generate_episode(seed=2)
42
+ assert [s.email.id for s in a.steps] != [s.email.id for s in b.steps]
43
+
44
+
45
+ def test_drift_sensitive_steps_exist_after_each_drift():
46
+ ep = generate_episode(seed=42)
47
+ # At least one drift-sensitive step must appear after each drift
48
+ sens_after_first = any(
49
+ s.drift_sensitive_to for s in ep.steps[DRIFT_POSITIONS[0] + 1:DRIFT_POSITIONS[1]]
50
+ )
51
+ sens_after_second = any(
52
+ s.drift_sensitive_to for s in ep.steps[DRIFT_POSITIONS[1] + 1:]
53
+ )
54
+ assert sens_after_first or sens_after_second
55
+
56
+
57
+ def test_reset_returns_observation_with_first_email():
58
+ env = DriftEnv()
59
+ obs = env.reset(seed=1)
60
+ assert obs.email_index == 0
61
+ assert obs.total_emails == EPISODE_LENGTH
62
+ assert obs.current_email.sender # non-empty
63
+
64
+
65
+ def test_step_before_reset_raises():
66
+ env = DriftEnv()
67
+ with pytest.raises(RuntimeError):
68
+ env.step(Action(action_type=ActionType.CLOSE, resolution_code="x"))
69
+
70
+
71
+ def test_step_after_done_raises():
72
+ env = DriftEnv()
73
+ env.reset(seed=1)
74
+ for _ in range(EPISODE_LENGTH):
75
+ env.step(Action(action_type=ActionType.CLOSE, resolution_code="x"))
76
+ with pytest.raises(RuntimeError):
77
+ env.step(Action(action_type=ActionType.CLOSE, resolution_code="x"))
78
+
79
+
80
+ def test_agent_cannot_see_grader_metadata():
81
+ """The hidden `meta` dict (refund_amount, severity etc) must not leak."""
82
+ env = DriftEnv()
83
+ obs = env.reset(seed=1)
84
+ assert obs.current_email.meta == {}
85
+
86
+
87
+ def test_perfect_agent_hits_max_reward():
88
+ """Running with ground-truth hints should produce a high, reproducible score."""
89
+ ep = generate_episode(seed=42)
90
+ env = DriftEnv()
91
+ env.reset(seed=42)
92
+ total = 0.0
93
+ for step in ep.steps:
94
+ h = step.correct_action_hint
95
+ action = Action(
96
+ action_type=ActionType(h["action_type"]),
97
+ refund_amount=h.get("refund_amount"),
98
+ escalation_tier=h.get("escalation_tier"),
99
+ followup_hours=h.get("followup_hours"),
100
+ resolution_code=h.get("resolution_code"),
101
+ info_field=h.get("info_field"),
102
+ )
103
+ r = env.step(action)
104
+ total += r.reward
105
+ # Perfect agent: 20 × 1.0 compliance + 18 × 0.5 appropriateness
106
+ # (admin emails don't get appropriateness) + 2 × 0.5 drift bonus = 30
107
+ assert total == 30.0
108
+
109
+
110
+ def test_drift_bonus_arms_and_clears_correctly():
111
+ env = DriftEnv()
112
+ env.reset(seed=42)
113
+ # Run perfect agent until admin email fires (step 3)
114
+ ep = generate_episode(seed=42)
115
+ for i in range(DRIFT_POSITIONS[0]):
116
+ h = ep.steps[i].correct_action_hint
117
+ env.step(Action(action_type=ActionType(h["action_type"]),
118
+ resolution_code=h.get("resolution_code"),
119
+ refund_amount=h.get("refund_amount"),
120
+ escalation_tier=h.get("escalation_tier"),
121
+ followup_hours=h.get("followup_hours"),
122
+ info_field=h.get("info_field")))
123
+ # Process admin email
124
+ h = ep.steps[DRIFT_POSITIONS[0]].correct_action_hint
125
+ r = env.step(Action(action_type=ActionType.CLOSE, resolution_code="policy_acknowledged"))
126
+ # After admin, the drift should be armed
127
+ assert len(r.info["armed_drifts_after"]) >= 1
drift_env/tests/test_grader.py ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for the 3-component drift grader."""
2
+
3
+ from drift_env.grader import grade_step
4
+ from drift_env.models import Action, ActionType
5
+
6
+
7
+ # ---------- compliance ----------
8
+
9
+ def test_perfect_refund_approval_under_cap():
10
+ action = Action(action_type=ActionType.APPROVE_REFUND, refund_amount=40.0)
11
+ hint = {"action_type": "approve_refund", "refund_amount": 40.0}
12
+ r, b, _ = grade_step(action, hint, {"kind": "refund"}, None, set(), False)
13
+ assert b["compliance"] == 1.0
14
+ assert b["appropriateness"] == 0.5
15
+ assert r == 1.5
16
+
17
+
18
+ def test_refund_wrong_amount_partial_compliance():
19
+ action = Action(action_type=ActionType.APPROVE_REFUND, refund_amount=50.0)
20
+ hint = {"action_type": "approve_refund", "refund_amount": 40.0}
21
+ r, b, _ = grade_step(action, hint, {"kind": "refund"}, None, set(), False)
22
+ assert b["compliance"] == 0.5 # right action_type, wrong amount
23
+ assert b["appropriateness"] == 0.5
24
+ assert r == 1.0
25
+
26
+
27
+ def test_refund_above_cap_should_escalate():
28
+ # Under cap=$50, $75 refund must escalate to manager
29
+ action = Action(action_type=ActionType.ESCALATE, escalation_tier="manager")
30
+ hint = {"action_type": "escalate", "escalation_tier": "manager"}
31
+ r, b, _ = grade_step(action, hint, {"kind": "refund"}, None, set(), False)
32
+ assert b["compliance"] == 1.0
33
+ assert b["appropriateness"] == 0.5
34
+
35
+
36
+ def test_approve_when_should_escalate():
37
+ # Agent approves $75 when cap is $50 — compliance 0
38
+ action = Action(action_type=ActionType.APPROVE_REFUND, refund_amount=75.0)
39
+ hint = {"action_type": "escalate", "escalation_tier": "manager"}
40
+ r, b, _ = grade_step(action, hint, {"kind": "refund"}, None, set(), False)
41
+ assert b["compliance"] == 0.0
42
+ # Appropriate TYPE for refund email (approve_refund is in the valid set)
43
+ assert b["appropriateness"] == 0.5
44
+
45
+
46
+ def test_critical_escalation_tier_match():
47
+ action = Action(action_type=ActionType.ESCALATE, escalation_tier="manager", followup_hours=2)
48
+ hint = {"action_type": "escalate", "escalation_tier": "manager", "followup_hours": 2}
49
+ r, b, _ = grade_step(action, hint, {"kind": "critical_incident"}, None, set(), False)
50
+ assert b["compliance"] == 1.0
51
+
52
+
53
+ def test_critical_wrong_tier_partial():
54
+ action = Action(action_type=ActionType.ESCALATE, escalation_tier="tier_2", followup_hours=2)
55
+ hint = {"action_type": "escalate", "escalation_tier": "manager", "followup_hours": 2}
56
+ r, b, _ = grade_step(action, hint, {"kind": "critical_incident"}, None, set(), False)
57
+ assert 0.5 < b["compliance"] < 1.0 # hours ok, tier wrong
58
+
59
+
60
+ # ---------- appropriateness ----------
61
+
62
+ def test_refund_close_is_inappropriate():
63
+ # Agent closes a refund email -> wrong compliance AND wrong appropriateness
64
+ action = Action(action_type=ActionType.CLOSE, resolution_code="ack")
65
+ hint = {"action_type": "approve_refund", "refund_amount": 40.0}
66
+ r, b, _ = grade_step(action, hint, {"kind": "refund"}, None, set(), False)
67
+ assert b["compliance"] == 0.0
68
+ assert b["appropriateness"] == 0.0
69
+
70
+
71
+ def test_chitchat_close_is_perfect():
72
+ action = Action(action_type=ActionType.CLOSE, resolution_code="no_action_needed")
73
+ hint = {"action_type": "close", "resolution_code": "no_action_needed"}
74
+ r, b, _ = grade_step(action, hint, {"kind": "chitchat"}, None, set(), False)
75
+ assert b["compliance"] == 1.0
76
+ assert b["appropriateness"] == 0.5
77
+
78
+
79
+ # ---------- drift bonus ----------
80
+
81
+ def test_drift_bonus_awarded_on_first_correct_drift_sensitive():
82
+ action = Action(action_type=ActionType.APPROVE_REFUND, refund_amount=75.0)
83
+ hint = {"action_type": "approve_refund", "refund_amount": 75.0}
84
+ armed = {"refund_cap_200"}
85
+ r, b, clear = grade_step(
86
+ action, hint, {"kind": "refund"},
87
+ drift_sensitive_to="refund_cap_200", armed_drifts=armed, is_admin_email=False,
88
+ )
89
+ assert b["drift_bonus"] == 0.5
90
+ assert clear == "refund_cap_200"
91
+ assert r == 1.0 + 0.5 + 0.5 # compliance + appropriateness + bonus
92
+
93
+
94
+ def test_drift_bonus_not_awarded_when_already_cleared():
95
+ action = Action(action_type=ActionType.APPROVE_REFUND, refund_amount=75.0)
96
+ hint = {"action_type": "approve_refund", "refund_amount": 75.0}
97
+ armed: set[str] = set() # already awarded earlier
98
+ r, b, clear = grade_step(
99
+ action, hint, {"kind": "refund"},
100
+ drift_sensitive_to="refund_cap_200", armed_drifts=armed, is_admin_email=False,
101
+ )
102
+ assert b["drift_bonus"] == 0.0
103
+ assert clear is None
104
+
105
+
106
+ def test_drift_bonus_not_awarded_when_compliance_fails():
107
+ # Agent got drift-sensitive step WRONG — no bonus even if armed
108
+ action = Action(action_type=ActionType.ESCALATE, escalation_tier="manager")
109
+ hint = {"action_type": "approve_refund", "refund_amount": 75.0}
110
+ armed = {"refund_cap_200"}
111
+ r, b, clear = grade_step(
112
+ action, hint, {"kind": "refund"},
113
+ drift_sensitive_to="refund_cap_200", armed_drifts=armed, is_admin_email=False,
114
+ )
115
+ assert b["drift_bonus"] == 0.0
116
+ assert clear is None
117
+
118
+
119
+ def test_drift_bonus_not_awarded_when_step_not_drift_sensitive():
120
+ action = Action(action_type=ActionType.APPROVE_REFUND, refund_amount=40.0)
121
+ hint = {"action_type": "approve_refund", "refund_amount": 40.0}
122
+ armed = {"refund_cap_200"}
123
+ r, b, clear = grade_step(
124
+ action, hint, {"kind": "refund"},
125
+ drift_sensitive_to=None, armed_drifts=armed, is_admin_email=False,
126
+ )
127
+ assert b["drift_bonus"] == 0.0
128
+ assert clear is None
129
+
130
+
131
+ # ---------- admin emails ----------
132
+
133
+ def test_admin_email_close_scores_full():
134
+ action = Action(action_type=ActionType.CLOSE, resolution_code="policy_acknowledged")
135
+ r, b, clear = grade_step(
136
+ action, {}, {}, drift_sensitive_to=None, armed_drifts=set(), is_admin_email=True,
137
+ )
138
+ assert r == 1.0
139
+ assert b["drift_bonus"] == 0.0
140
+
141
+
142
+ def test_admin_email_wrong_action_scores_low():
143
+ action = Action(action_type=ActionType.APPROVE_REFUND, refund_amount=100.0)
144
+ r, b, _ = grade_step(
145
+ action, {}, {}, drift_sensitive_to=None, armed_drifts=set(), is_admin_email=True,
146
+ )
147
+ assert r <= 0.3
148
+
149
+
150
+ # ---------- determinism ----------
151
+
152
+ def test_grader_is_deterministic():
153
+ action = Action(action_type=ActionType.APPROVE_REFUND, refund_amount=40.0)
154
+ hint = {"action_type": "approve_refund", "refund_amount": 40.0}
155
+ r1, _, _ = grade_step(action, hint, {"kind": "refund"}, None, set(), False)
156
+ r2, _, _ = grade_step(action, hint, {"kind": "refund"}, None, set(), False)
157
+ assert r1 == r2
158
+
159
+
160
+ def test_reward_bounds_per_step():
161
+ """Sweep many (action, hint) combos — reward always in [0, 2.0]."""
162
+ hints = [
163
+ {"action_type": "approve_refund", "refund_amount": 40.0},
164
+ {"action_type": "escalate", "escalation_tier": "manager"},
165
+ {"action_type": "reply"},
166
+ {"action_type": "close", "resolution_code": "no_action_needed"},
167
+ ]
168
+ for h in hints:
169
+ for at in ActionType:
170
+ action = Action(action_type=at, refund_amount=50.0,
171
+ escalation_tier="tier_2", followup_hours=24,
172
+ resolution_code="x", info_field="y")
173
+ r, _, _ = grade_step(
174
+ action, h, {"kind": "refund"},
175
+ drift_sensitive_to="refund_cap_200",
176
+ armed_drifts={"refund_cap_200"},
177
+ is_admin_email=False,
178
+ )
179
+ assert 0.0 <= r <= 2.0, f"out of bounds: {r}"
drift_env/training/__init__.py ADDED
File without changes
drift_env/training/rewards.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """TRL-compatible reward functions.
2
+
3
+ Exposes each component of the 3-component grader as an independent
4
+ reward_func so TRL/wandb can plot them separately during training.
5
+ Per the organizers' guidance: "use multiple independent reward functions"
6
+ and "monitor individual reward components, not just the total."
7
+
8
+ TRL calls each reward_func with signature:
9
+ func(completions: list[str], **kwargs) -> list[float]
10
+
11
+ where kwargs contains the dataset columns (prompt, correct_action_hint,
12
+ email_kind, can_earn_drift_bonus, is_admin_email).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ from typing import Any
19
+
20
+ from drift_env.grader import _compliance, _appropriateness, _drift_bonus, _grade_admin
21
+ from drift_env.models import Action, ActionType
22
+
23
+
24
+ FALLBACK = Action(action_type=ActionType.CLOSE, resolution_code="error_fallback")
25
+
26
+
27
+ def parse_generated_action(raw: str) -> Action:
28
+ text = raw.strip()
29
+ if text.startswith("```"):
30
+ lines = [l for l in text.split("\n") if not l.strip().startswith("```")]
31
+ text = "\n".join(lines).strip()
32
+ if not text.startswith("{"):
33
+ start, end = text.find("{"), text.rfind("}")
34
+ if start == -1 or end == -1 or end < start:
35
+ return FALLBACK
36
+ text = text[start:end + 1]
37
+ try:
38
+ obj = json.loads(text)
39
+ except json.JSONDecodeError:
40
+ return FALLBACK
41
+ a_type = obj.get("action_type")
42
+ if a_type not in {e.value for e in ActionType}:
43
+ return FALLBACK
44
+ try:
45
+ return Action(
46
+ action_type=ActionType(a_type),
47
+ refund_amount=obj.get("refund_amount"),
48
+ escalation_tier=obj.get("escalation_tier"),
49
+ followup_hours=obj.get("followup_hours"),
50
+ resolution_code=obj.get("resolution_code"),
51
+ info_field=obj.get("info_field"),
52
+ reply_text=obj.get("reply_text"),
53
+ )
54
+ except Exception:
55
+ return FALLBACK
56
+
57
+
58
+ def _as_list(v, n):
59
+ """Utility to lift scalar dataset columns into per-completion lists."""
60
+ if isinstance(v, list):
61
+ return v
62
+ return [v] * n
63
+
64
+
65
+ def _per_sample(
66
+ completions: list[str],
67
+ correct_action_hint: Any,
68
+ email_kind: Any,
69
+ can_earn_drift_bonus: Any,
70
+ drift_sensitive_to: Any,
71
+ is_admin_email: Any,
72
+ ):
73
+ """Generator yielding (action, hint, email_kind, can_earn, sensitive_to, is_admin)
74
+ for each completion. Handles TRL's list-or-scalar column conventions."""
75
+ n = len(completions)
76
+ hints = _as_list(correct_action_hint, n)
77
+ kinds = _as_list(email_kind, n)
78
+ earns = _as_list(can_earn_drift_bonus, n)
79
+ sens = _as_list(drift_sensitive_to, n)
80
+ admins = _as_list(is_admin_email, n)
81
+ for comp, hint, kind, earn, s, admin in zip(completions, hints, kinds, earns, sens, admins):
82
+ action = parse_generated_action(
83
+ comp[0]["content"] if isinstance(comp, list) else comp
84
+ )
85
+ yield action, hint, kind, earn, s, admin
86
+
87
+
88
+ # ---------------------------------------------------------------------------
89
+ # Reward components — each exposed to TRL as a separate reward_func
90
+ # ---------------------------------------------------------------------------
91
+
92
+
93
+ def reward_compliance(
94
+ completions: list[str],
95
+ correct_action_hint=None,
96
+ email_kind=None,
97
+ can_earn_drift_bonus=None,
98
+ drift_sensitive_to=None,
99
+ is_admin_email=None,
100
+ **_,
101
+ ) -> list[float]:
102
+ out = []
103
+ for action, hint, kind, earn, s, admin in _per_sample(
104
+ completions, correct_action_hint, email_kind,
105
+ can_earn_drift_bonus, drift_sensitive_to, is_admin_email,
106
+ ):
107
+ if admin:
108
+ out.append(_grade_admin(action))
109
+ else:
110
+ out.append(_compliance(action, hint))
111
+ return out
112
+
113
+
114
+ def reward_appropriateness(
115
+ completions: list[str],
116
+ correct_action_hint=None,
117
+ email_kind=None,
118
+ can_earn_drift_bonus=None,
119
+ drift_sensitive_to=None,
120
+ is_admin_email=None,
121
+ **_,
122
+ ) -> list[float]:
123
+ out = []
124
+ for action, hint, kind, earn, s, admin in _per_sample(
125
+ completions, correct_action_hint, email_kind,
126
+ can_earn_drift_bonus, drift_sensitive_to, is_admin_email,
127
+ ):
128
+ if admin:
129
+ out.append(0.0)
130
+ else:
131
+ out.append(_appropriateness(action, {"kind": kind}))
132
+ return out
133
+
134
+
135
+ def reward_drift_bonus(
136
+ completions: list[str],
137
+ correct_action_hint=None,
138
+ email_kind=None,
139
+ can_earn_drift_bonus=None,
140
+ drift_sensitive_to=None,
141
+ is_admin_email=None,
142
+ **_,
143
+ ) -> list[float]:
144
+ """Only fires on rows flagged `can_earn_drift_bonus=True` AND where the
145
+ model's action is policy-compliant (compliance >= 1.0). This matches the
146
+ env's armed-drift semantics: first correct drift-aware action earns +0.5."""
147
+ out = []
148
+ for action, hint, kind, earn, s, admin in _per_sample(
149
+ completions, correct_action_hint, email_kind,
150
+ can_earn_drift_bonus, drift_sensitive_to, is_admin_email,
151
+ ):
152
+ if admin or not earn:
153
+ out.append(0.0)
154
+ continue
155
+ comp = _compliance(action, hint)
156
+ bonus, _ = _drift_bonus(s, {s} if s else set(), comp)
157
+ out.append(bonus)
158
+ return out
159
+
160
+
161
+ # Handy wrapper for quick total-reward sanity checks outside TRL
162
+ def total_reward(
163
+ completion: str,
164
+ correct_action_hint: dict,
165
+ email_kind: str | None,
166
+ can_earn_drift_bonus: bool,
167
+ drift_sensitive_to: str | None,
168
+ is_admin_email: bool,
169
+ ) -> dict:
170
+ action = parse_generated_action(completion)
171
+ if is_admin_email:
172
+ comp = _grade_admin(action)
173
+ appr = 0.0
174
+ else:
175
+ comp = _compliance(action, correct_action_hint)
176
+ appr = _appropriateness(action, {"kind": email_kind})
177
+ bonus = 0.0
178
+ if not is_admin_email and can_earn_drift_bonus:
179
+ b, _ = _drift_bonus(drift_sensitive_to, {drift_sensitive_to} if drift_sensitive_to else set(), comp)
180
+ bonus = b
181
+ return {
182
+ "compliance": round(comp, 4),
183
+ "appropriateness": round(appr, 4),
184
+ "drift_bonus": round(bonus, 4),
185
+ "total": round(comp + appr + bonus, 4),
186
+ }
email_env/__init__.py ADDED
File without changes
email_env/baseline.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Baseline agent using OpenAI gpt-4o-mini.
3
+ Runs on all 3 tasks and prints average score.
4
+
5
+ Usage:
6
+ OPENAI_API_KEY=sk-... python -m email_env.baseline
7
+ """
8
+
9
+ import os
10
+ import json
11
+ from pathlib import Path
12
+ from dotenv import load_dotenv
13
+ from openai import OpenAI # groq uses the same openai SDK
14
+
15
+ load_dotenv(Path(__file__).parent / ".env")
16
+
17
+ from email_env.server.environment import EmailTriageEnv
18
+ from email_env.models import Action
19
+ from email_env.tasks import TASKS
20
+
21
+ SYSTEM_PROMPT = """You are an email triage assistant. Given an email, you must:
22
+ 1. Classify the email into exactly one category: billing, technical, or general
23
+ 2. Assign a priority: low, medium, or high
24
+ 3. Write a professional response
25
+
26
+ Reply ONLY with valid JSON in this exact format:
27
+ {
28
+ "category": "<billing|technical|general>",
29
+ "priority": "<low|medium|high>",
30
+ "response": "<your response text>"
31
+ }"""
32
+
33
+
34
+ def run_baseline():
35
+ api_key = os.environ.get("GROQ_API_KEY") or os.environ.get("OPENAI_API_KEY")
36
+ if not api_key:
37
+ raise EnvironmentError("Set GROQ_API_KEY (or OPENAI_API_KEY) environment variable.")
38
+
39
+ base_url = "https://api.groq.com/openai/v1" if os.environ.get("GROQ_API_KEY") else None
40
+ client = OpenAI(api_key=api_key, base_url=base_url)
41
+ env = EmailTriageEnv()
42
+ scores = []
43
+
44
+ for task_id, task in TASKS.items():
45
+ print(f"\n--- {task_id} ({task['difficulty']}) ---")
46
+ obs = env.reset(task_id=task_id)
47
+ print(f"Email: {obs.email_text[:80]}...")
48
+
49
+ user_msg = (
50
+ f"Sender type: {obs.sender_type}\n\n"
51
+ f"Email:\n{obs.email_text}"
52
+ )
53
+
54
+ completion = client.chat.completions.create(
55
+ model="llama-3.1-8b-instant" if os.environ.get("GROQ_API_KEY") else "gpt-4o-mini",
56
+ messages=[
57
+ {"role": "system", "content": SYSTEM_PROMPT},
58
+ {"role": "user", "content": user_msg},
59
+ ],
60
+ temperature=0,
61
+ )
62
+
63
+ raw = completion.choices[0].message.content.strip()
64
+ try:
65
+ parsed = json.loads(raw)
66
+ except json.JSONDecodeError:
67
+ print(f"Failed to parse response: {raw}")
68
+ scores.append(0.0)
69
+ continue
70
+
71
+ action = Action(
72
+ category=parsed.get("category", "general"),
73
+ priority=parsed.get("priority", "low"),
74
+ response=parsed.get("response", ""),
75
+ )
76
+
77
+ result = env.step(action)
78
+ print(f"Category: {action.category} (expected: {task['expected_category']})")
79
+ print(f"Priority: {action.priority} (expected: {task['expected_priority']})")
80
+ print(f"Score: {result.reward}")
81
+ scores.append(result.reward)
82
+
83
+ avg = round(sum(scores) / len(scores), 4)
84
+ print(f"\n=== Average Score: {avg} ===")
85
+ return avg
86
+
87
+
88
+ if __name__ == "__main__":
89
+ run_baseline()
email_env/client.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Python client for the Email Triage OpenEnv server.
3
+ """
4
+
5
+ import requests
6
+
7
+
8
+ class EmailTriageClient:
9
+ def __init__(self, base_url: str = "http://localhost:8000"):
10
+ self.base_url = base_url.rstrip("/")
11
+
12
+ def reset(self, task_id: str = "task_1") -> dict:
13
+ r = requests.post(f"{self.base_url}/reset", json={"task_id": task_id})
14
+ r.raise_for_status()
15
+ return r.json()
16
+
17
+ def step(self, category: str, priority: str, response: str) -> dict:
18
+ payload = {"category": category, "priority": priority, "response": response}
19
+ r = requests.post(f"{self.base_url}/step", json=payload)
20
+ r.raise_for_status()
21
+ return r.json()
22
+
23
+ def state(self) -> dict:
24
+ r = requests.get(f"{self.base_url}/state")
25
+ r.raise_for_status()
26
+ return r.json()
27
+
28
+ def tasks(self) -> list:
29
+ r = requests.get(f"{self.base_url}/tasks")
30
+ r.raise_for_status()
31
+ return r.json()
32
+
33
+ def grade(self, task_id: str, category: str, priority: str, response: str) -> float:
34
+ payload = {
35
+ "task_id": task_id,
36
+ "category": category,
37
+ "priority": priority,
38
+ "response": response,
39
+ }
40
+ r = requests.post(f"{self.base_url}/grader", json=payload)
41
+ r.raise_for_status()
42
+ return r.json()["score"]
email_env/grader.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Deterministic hybrid grader: grade(action, expected) -> float in [0.0, 1.0]
3
+
4
+ Response quality combines three signals to defeat keyword-stuffing exploits:
5
+ 1. Keyword coverage (40%) — distinct keywords present
6
+ 2. Length & coherence sanity (20%) — penalises too-short / rambling / no-punctuation
7
+ 3. Structural requirement (40%) — must contain BOTH an acknowledgement
8
+ (apology / acknowledge) AND an action/timeline phrase
9
+ """
10
+
11
+ import re
12
+
13
+ CATEGORY_WEIGHT = 0.4
14
+ PRIORITY_WEIGHT = 0.3
15
+ RESPONSE_WEIGHT = 0.3
16
+
17
+ EMPTY_RESPONSE_PENALTY = 0.2
18
+
19
+ ACKNOWLEDGEMENT_TERMS = (
20
+ "sorry", "apolog", "regret", "understand", "acknowledge", "thank"
21
+ )
22
+ ACTION_TERMS = (
23
+ "will", "shall", "investigat", "refund", "resolv", "fix", "escalat",
24
+ "team", "contact", "process", "issue", "follow up", "update", "check",
25
+ "review", "look into", "assist", "help"
26
+ )
27
+
28
+ MIN_RESPONSE_LEN = 20
29
+ MAX_RESPONSE_LEN = 600
30
+
31
+
32
+ def _keyword_coverage(response_lower: str, keywords: list[str]) -> float:
33
+ if not keywords:
34
+ return 0.5
35
+ matched = sum(1 for kw in keywords if kw.lower() in response_lower)
36
+ return matched / len(keywords)
37
+
38
+
39
+ def _length_coherence(response: str) -> float:
40
+ """Sanity score based on length, punctuation and lexical diversity."""
41
+ text = response.strip()
42
+ n = len(text)
43
+ if n == 0:
44
+ return 0.0
45
+
46
+ score = 1.0
47
+ # length bounds
48
+ if n < MIN_RESPONSE_LEN:
49
+ score *= 0.4
50
+ elif n > MAX_RESPONSE_LEN:
51
+ score *= 0.6
52
+
53
+ # must contain at least one sentence terminator
54
+ if not re.search(r"[.!?]", text):
55
+ score *= 0.5
56
+
57
+ # detect keyword stuffing: repeated identical tokens dominate text
58
+ tokens = re.findall(r"[A-Za-z']+", text.lower())
59
+ if tokens:
60
+ unique_ratio = len(set(tokens)) / len(tokens)
61
+ if unique_ratio < 0.5:
62
+ score *= 0.4
63
+
64
+ # penalise SHOUTING / no spaces
65
+ letters = [c for c in text if c.isalpha()]
66
+ if letters and sum(1 for c in letters if c.isupper()) / len(letters) > 0.7:
67
+ score *= 0.5
68
+
69
+ return max(0.0, min(1.0, score))
70
+
71
+
72
+ def _structural_requirement(response_lower: str) -> float:
73
+ has_ack = any(term in response_lower for term in ACKNOWLEDGEMENT_TERMS)
74
+ has_action = any(term in response_lower for term in ACTION_TERMS)
75
+ if has_ack and has_action:
76
+ return 1.0
77
+ if has_ack or has_action:
78
+ return 0.5
79
+ return 0.0
80
+
81
+
82
+ def grade_response_quality(response: str, keywords: list[str]) -> float:
83
+ """Hybrid response score 0.0–1.0 resistant to keyword stuffing."""
84
+ if not response or not response.strip():
85
+ return 0.0
86
+ response_lower = response.lower()
87
+
88
+ coverage = _keyword_coverage(response_lower, keywords)
89
+ coherence = _length_coherence(response)
90
+ structure = _structural_requirement(response_lower)
91
+
92
+ quality = 0.40 * coverage + 0.20 * coherence + 0.40 * structure
93
+ return round(max(0.0, min(1.0, quality)), 4)
94
+
95
+
96
+ def grade(action: dict, expected: dict) -> float:
97
+ """
98
+ action: dict with keys category, priority, response
99
+ expected: dict with keys expected_category, expected_priority, response_keywords
100
+ Returns a score between 0.0 and 1.0.
101
+ """
102
+ score = 0.0
103
+
104
+ if action.get("category", "").lower() == expected["expected_category"].lower():
105
+ score += CATEGORY_WEIGHT
106
+
107
+ if action.get("priority", "").lower() == expected["expected_priority"].lower():
108
+ score += PRIORITY_WEIGHT
109
+
110
+ response = action.get("response", "")
111
+ if not response or not response.strip():
112
+ score -= EMPTY_RESPONSE_PENALTY
113
+ else:
114
+ quality = grade_response_quality(response, expected.get("response_keywords", []))
115
+ score += RESPONSE_WEIGHT * quality
116
+
117
+ return round(max(0.0, min(1.0, score)), 4)
email_env/models.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel, Field
2
+ from typing import Optional
3
+
4
+
5
+ class Observation(BaseModel):
6
+ email_text: str
7
+ sender_type: str # customer / internal / system
8
+
9
+
10
+ class Action(BaseModel):
11
+ category: str # billing / technical / general
12
+ priority: str # low / medium / high
13
+ response: str
14
+
15
+
16
+ class StepResult(BaseModel):
17
+ observation: Optional[Observation]
18
+ reward: float
19
+ done: bool
20
+ info: dict
21
+
22
+
23
+ class State(BaseModel):
24
+ current_email: Optional[str]
25
+ step_count: int
26
+ done: bool
27
+ task_id: Optional[str]
email_env/openenv.yaml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ spec_version: 1
2
+ name: email-triage
3
+ type: space
4
+ runtime: fastapi
5
+ app: server.app:app
6
+ port: 7860
email_env/server/Dockerfile ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY . .
9
+
10
+ ENV PYTHONPATH=/app
11
+
12
+ EXPOSE 8000
13
+
14
+ CMD ["uvicorn", "email_env.server.app:app", "--host", "0.0.0.0", "--port", "8000"]
email_env/server/__init__.py ADDED
File without changes
email_env/server/app.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Optional
2
+ from fastapi import FastAPI, HTTPException
3
+ from pydantic import BaseModel
4
+
5
+ from email_env.models import Action, StepResult, State, Observation
6
+ from email_env.server.environment import EmailTriageEnv
7
+ from email_env.tasks import list_tasks, TASKS
8
+ from email_env.grader import grade
9
+
10
+ app = FastAPI(title="Email Triage OpenEnv", version="1.0.0")
11
+ env = EmailTriageEnv()
12
+
13
+
14
+ @app.get("/")
15
+ def root():
16
+ return {
17
+ "name": "email-triage",
18
+ "version": "1.0.0",
19
+ "description": "Email Triage and Response OpenEnv Environment",
20
+ "endpoints": ["/reset", "/step", "/state", "/tasks", "/grader", "/baseline"],
21
+ }
22
+
23
+
24
+ class ResetRequest(BaseModel):
25
+ task_id: str = "task_1"
26
+
27
+
28
+ class GradeRequest(BaseModel):
29
+ task_id: str
30
+ category: str
31
+ priority: str
32
+ response: str
33
+
34
+
35
+ @app.post("/reset", response_model=Observation)
36
+ def reset(req: Optional[ResetRequest] = None):
37
+ task_id = req.task_id if req else "task_1"
38
+ try:
39
+ obs = env.reset(task_id=task_id)
40
+ except ValueError as e:
41
+ raise HTTPException(status_code=400, detail=str(e))
42
+ return obs
43
+
44
+
45
+ @app.post("/step", response_model=StepResult)
46
+ def step(action: Action):
47
+ try:
48
+ result = env.step(action)
49
+ except RuntimeError as e:
50
+ raise HTTPException(status_code=400, detail=str(e))
51
+ return result
52
+
53
+
54
+ @app.get("/state", response_model=State)
55
+ def state():
56
+ return env.state()
57
+
58
+
59
+ @app.get("/tasks")
60
+ def tasks():
61
+ return list_tasks()
62
+
63
+
64
+ @app.post("/grader")
65
+ def grader(req: GradeRequest):
66
+ if req.task_id not in TASKS:
67
+ raise HTTPException(status_code=404, detail=f"Unknown task_id: {req.task_id}")
68
+ action = {"category": req.category, "priority": req.priority, "response": req.response}
69
+ score = grade(action, TASKS[req.task_id])
70
+ return {"task_id": req.task_id, "score": score}
71
+
72
+
73
+ @app.get("/baseline")
74
+ def baseline_info():
75
+ return {
76
+ "description": "Run baseline.py locally with OPENAI_API_KEY set to evaluate the agent.",
77
+ "command": "python -m email_env.baseline",
78
+ }
email_env/server/environment.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Core OpenEnv environment: reset(), step(action), state()
3
+ """
4
+
5
+ from email_env.models import Observation, Action, StepResult, State
6
+ from email_env.tasks import get_task
7
+ from email_env.grader import grade
8
+
9
+
10
+ class EmailTriageEnv:
11
+ def __init__(self):
12
+ self._task: dict | None = None
13
+ self._step_count: int = 0
14
+ self._done: bool = False
15
+
16
+ def reset(self, task_id: str = "task_1") -> Observation:
17
+ task = get_task(task_id)
18
+ self._task = task
19
+ self._step_count = 0
20
+ self._done = False
21
+ return Observation(
22
+ email_text=task["email_text"],
23
+ sender_type=task["sender_type"],
24
+ )
25
+
26
+ def step(self, action: Action) -> StepResult:
27
+ if self._task is None:
28
+ raise RuntimeError("Call reset() before step().")
29
+ if self._done:
30
+ raise RuntimeError("Episode is done. Call reset() to start a new one.")
31
+
32
+ self._step_count += 1
33
+ self._done = True # single-step episode
34
+
35
+ reward = grade(action.model_dump(), self._task)
36
+
37
+ info = {
38
+ "task_id": self._task["id"],
39
+ "step_count": self._step_count,
40
+ "expected_category": self._task["expected_category"],
41
+ "expected_priority": self._task["expected_priority"],
42
+ "got_category": action.category,
43
+ "got_priority": action.priority,
44
+ }
45
+
46
+ return StepResult(
47
+ observation=None,
48
+ reward=reward,
49
+ done=self._done,
50
+ info=info,
51
+ )
52
+
53
+ def state(self) -> State:
54
+ return State(
55
+ current_email=self._task["email_text"] if self._task else None,
56
+ step_count=self._step_count,
57
+ done=self._done,
58
+ task_id=self._task["id"] if self._task else None,
59
+ )
email_env/tasks.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Task definitions for the Email Triage environment.
3
+
4
+ Difficulty labels reflect how reliably a small/medium LLM baseline solves them.
5
+ Tasks are ordered easy → hard along the difficulty axis.
6
+ """
7
+
8
+ TASKS = {
9
+ "task_1": {
10
+ "id": "task_1",
11
+ "difficulty": "easy",
12
+ "description": "Simple general inquiry about business hours and live chat",
13
+ "email_text": (
14
+ "Hi there, I was wondering what your customer support hours are. "
15
+ "I tried calling yesterday evening but nobody picked up. "
16
+ "Could you also let me know if you have a live chat option? Thanks!"
17
+ ),
18
+ "sender_type": "customer",
19
+ "expected_category": "general",
20
+ "expected_priority": "low",
21
+ "response_keywords": ["hours", "available", "chat", "support", "help"],
22
+ },
23
+ "task_2": {
24
+ "id": "task_2",
25
+ "difficulty": "easy",
26
+ "description": "Clear billing issue — duplicate charge with obvious classification",
27
+ "email_text": (
28
+ "Hi, I was charged twice for my subscription this month. "
29
+ "My account number is 84729 and both charges appeared on March 15th. "
30
+ "Please issue a refund for the duplicate charge as soon as possible. "
31
+ "Thank you."
32
+ ),
33
+ "sender_type": "customer",
34
+ "expected_category": "billing",
35
+ "expected_priority": "high",
36
+ "response_keywords": ["refund", "apologize", "billing", "resolve", "account"],
37
+ },
38
+ "task_3": {
39
+ "id": "task_3",
40
+ "difficulty": "medium",
41
+ "description": "Multi-issue complaint requiring correct classification and tone",
42
+ "email_text": (
43
+ "I am absolutely furious! For the third time this week your app keeps "
44
+ "crashing every time I try to export my data. On top of that, you charged "
45
+ "me a premium fee for a feature that is completely broken! I have deadlines "
46
+ "to meet and your incompetent support team has not responded in 48 hours. "
47
+ "If this is not fixed TODAY I will be disputing the charge with my bank "
48
+ "and leaving a public review. This is completely unacceptable."
49
+ ),
50
+ "sender_type": "customer",
51
+ "expected_category": "technical",
52
+ "expected_priority": "high",
53
+ "response_keywords": ["sorry", "apologize", "urgent", "escalate", "refund", "fix", "priority"],
54
+ },
55
+ "task_4": {
56
+ "id": "task_4",
57
+ "difficulty": "medium",
58
+ "description": "Internal system alert requiring urgent technical escalation",
59
+ "email_text": (
60
+ "ALERT: Production database replica lag has exceeded 120 seconds "
61
+ "on db-replica-03. Read queries are returning stale data. Multiple "
62
+ "customers have reported seeing outdated order statuses. The primary "
63
+ "node CPU is at 98% and autoscaling has not triggered. Oncall has "
64
+ "been paged but has not acknowledged. This is impacting checkout flow."
65
+ ),
66
+ "sender_type": "system",
67
+ "expected_category": "technical",
68
+ "expected_priority": "high",
69
+ "response_keywords": ["immediately", "escalate", "database", "investigate", "oncall", "production"],
70
+ },
71
+ "task_5": {
72
+ "id": "task_5",
73
+ "difficulty": "hard",
74
+ "description": "Genuinely ambiguous email — looks like billing but root cause is technical",
75
+ "email_text": (
76
+ "Hi support, something weird is going on with my account. Last week I "
77
+ "upgraded from the basic plan to pro using the in-app upgrade button, "
78
+ "and the app showed a confirmation. My credit card was charged the pro "
79
+ "amount the same day. But when I log in, the app still shows me as a "
80
+ "basic user, my pro features are greyed out, and the billing page lists "
81
+ "my plan as 'basic'. I tried logging out and back in, and even reinstalling "
82
+ "the app on a different device — same thing. I am not sure if I should be "
83
+ "asking for a refund or if there is a bug in your upgrade flow that did "
84
+ "not actually flip my account over. Please advise."
85
+ ),
86
+ "sender_type": "customer",
87
+ "expected_category": "technical",
88
+ "expected_priority": "medium",
89
+ "response_keywords": ["investigate", "account", "upgrade", "team", "check"],
90
+ },
91
+ }
92
+
93
+
94
+ def get_task(task_id: str) -> dict:
95
+ if task_id not in TASKS:
96
+ raise ValueError(f"Unknown task_id: {task_id}. Valid ids: {list(TASKS.keys())}")
97
+ return TASKS[task_id]
98
+
99
+
100
+ def list_tasks() -> list:
101
+ return [
102
+ {
103
+ "id": t["id"],
104
+ "difficulty": t["difficulty"],
105
+ "description": t["description"],
106
+ }
107
+ for t in TASKS.values()
108
+ ]
eval_baseline.py ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pre-training evaluation harness.
2
+
3
+ Runs a base LLM agent against N episodes of DriftEnv and reports:
4
+ - mean episode reward
5
+ - mean fraction of max (max = 30.0 per episode)
6
+ - drift-sensitive-step accuracy <-- the KEY metric
7
+ - breakdown by drift event
8
+ - sample trajectories
9
+
10
+ The whole point is to confirm the base model's drift-sensitive-step accuracy
11
+ sits in the 20%-40% training-headroom zone. Above 60% means our task is too
12
+ easy and training won't show improvement. Below 10% means our task is too
13
+ hard for any RL within the time budget.
14
+
15
+ Usage:
16
+ API_BASE_URL=https://api.groq.com/openai/v1 \\
17
+ HF_TOKEN=gsk_... \\
18
+ MODEL_NAME=llama-3.1-8b-instant \\
19
+ PYTHONPATH=. python3 eval_baseline.py --episodes 10
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import json
26
+ import os
27
+ import sys
28
+ import time
29
+ from collections import defaultdict
30
+ from typing import Any
31
+
32
+ from dotenv import load_dotenv
33
+
34
+ from drift_env.environment import DriftEnv
35
+ from drift_env.llm_agent import LLMAgent
36
+ from drift_env.episodes import generate_episode
37
+ from drift_env.policy import drift_direction
38
+
39
+ load_dotenv()
40
+
41
+
42
+ def run_one_episode(
43
+ agent: LLMAgent, seed: int, verbose: bool = False,
44
+ ) -> dict[str, Any]:
45
+ env = DriftEnv()
46
+ obs = env.reset(seed=seed, episode_id=f"eval_{seed}")
47
+ ep_plan = generate_episode(seed=seed, episode_id=f"eval_{seed}")
48
+
49
+ total_reward = 0.0
50
+ breakdown_totals = defaultdict(float)
51
+ drift_sensitive_total = 0
52
+ drift_sensitive_correct = 0
53
+ per_drift: dict[str, dict[str, int]] = defaultdict(
54
+ lambda: {"total": 0, "correct": 0}
55
+ )
56
+ per_direction: dict[str, dict[str, int]] = defaultdict(
57
+ lambda: {"total": 0, "correct": 0}
58
+ )
59
+ trajectory = []
60
+
61
+ for i, step_plan in enumerate(ep_plan.steps):
62
+ action, raw = agent.act(obs)
63
+ result = env.step(action)
64
+
65
+ total_reward += result.reward
66
+ for k, v in result.info["breakdown"].items():
67
+ breakdown_totals[k] += v
68
+
69
+ sensitive_to = step_plan.drift_sensitive_to
70
+ is_correct = result.info["breakdown"]["compliance"] >= 1.0
71
+ if sensitive_to is not None:
72
+ drift_sensitive_total += 1
73
+ per_drift[sensitive_to]["total"] += 1
74
+ direction = drift_direction(sensitive_to)
75
+ if direction is not None:
76
+ per_direction[direction]["total"] += 1
77
+ if is_correct:
78
+ drift_sensitive_correct += 1
79
+ per_drift[sensitive_to]["correct"] += 1
80
+ if direction is not None:
81
+ per_direction[direction]["correct"] += 1
82
+
83
+ trajectory.append({
84
+ "step": i,
85
+ "email_kind": step_plan.email.kind.value,
86
+ "email_id": step_plan.email.id,
87
+ "action": action.model_dump(exclude_none=True),
88
+ "correct_hint": step_plan.correct_action_hint,
89
+ "drift_sensitive_to": sensitive_to,
90
+ "reward": result.reward,
91
+ "compliance": result.info["breakdown"]["compliance"],
92
+ })
93
+
94
+ if verbose:
95
+ sens = f" [SENS→{sensitive_to}]" if sensitive_to else ""
96
+ print(f" step {i:>2} [{step_plan.email.kind.value:<8}] "
97
+ f"reward={result.reward:.2f} comp={result.info['breakdown']['compliance']:.2f}{sens}")
98
+
99
+ if result.done:
100
+ break
101
+ if result.observation is not None:
102
+ obs = result.observation
103
+
104
+ return {
105
+ "seed": seed,
106
+ "total_reward": round(total_reward, 4),
107
+ "max_reward": 30.0,
108
+ "frac_of_max": round(total_reward / 30.0, 4),
109
+ "breakdown_totals": {k: round(v, 4) for k, v in breakdown_totals.items()},
110
+ "drift_sensitive_total": drift_sensitive_total,
111
+ "drift_sensitive_correct": drift_sensitive_correct,
112
+ "drift_sensitive_acc": (
113
+ round(drift_sensitive_correct / drift_sensitive_total, 4)
114
+ if drift_sensitive_total else None
115
+ ),
116
+ "per_drift": {k: dict(v) for k, v in per_drift.items()},
117
+ "per_direction": {k: dict(v) for k, v in per_direction.items()},
118
+ "trajectory": trajectory,
119
+ }
120
+
121
+
122
+ def summarise(results: list[dict[str, Any]]) -> dict[str, Any]:
123
+ n = len(results)
124
+ mean_reward = sum(r["total_reward"] for r in results) / n
125
+ mean_frac = sum(r["frac_of_max"] for r in results) / n
126
+ dst = sum(r["drift_sensitive_total"] for r in results)
127
+ dsc = sum(r["drift_sensitive_correct"] for r in results)
128
+
129
+ per_drift_agg: dict[str, dict[str, int]] = defaultdict(
130
+ lambda: {"total": 0, "correct": 0}
131
+ )
132
+ per_direction_agg: dict[str, dict[str, int]] = defaultdict(
133
+ lambda: {"total": 0, "correct": 0}
134
+ )
135
+ for r in results:
136
+ for name, stats in r["per_drift"].items():
137
+ per_drift_agg[name]["total"] += stats["total"]
138
+ per_drift_agg[name]["correct"] += stats["correct"]
139
+ for direction, stats in r.get("per_direction", {}).items():
140
+ per_direction_agg[direction]["total"] += stats["total"]
141
+ per_direction_agg[direction]["correct"] += stats["correct"]
142
+
143
+ return {
144
+ "episodes": n,
145
+ "mean_reward": round(mean_reward, 4),
146
+ "mean_frac_of_max": round(mean_frac, 4),
147
+ "drift_sensitive_total": dst,
148
+ "drift_sensitive_correct": dsc,
149
+ "drift_sensitive_acc": round(dsc / dst, 4) if dst else None,
150
+ "per_direction": {
151
+ k: {
152
+ **v,
153
+ "acc": round(v["correct"] / v["total"], 4) if v["total"] else None,
154
+ }
155
+ for k, v in per_direction_agg.items()
156
+ },
157
+ "per_drift": {
158
+ k: {
159
+ **v,
160
+ "acc": round(v["correct"] / v["total"], 4) if v["total"] else None,
161
+ }
162
+ for k, v in per_drift_agg.items()
163
+ },
164
+ }
165
+
166
+
167
+ def main() -> int:
168
+ ap = argparse.ArgumentParser()
169
+ ap.add_argument("--episodes", type=int, default=10)
170
+ ap.add_argument("--start-seed", type=int, default=100)
171
+ ap.add_argument("--verbose", action="store_true")
172
+ ap.add_argument("--save", type=str, default="eval_results.json")
173
+ args = ap.parse_args()
174
+
175
+ api_base = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")
176
+ api_key = os.getenv("HF_TOKEN") or os.getenv("API_KEY")
177
+ model = os.getenv("MODEL_NAME", "meta-llama/Llama-3.1-8B-Instruct")
178
+
179
+ if not api_key:
180
+ print("ERROR: set HF_TOKEN (or API_KEY) env var.", file=sys.stderr)
181
+ return 1
182
+
183
+ print(f"Model: {model}")
184
+ print(f"Base URL: {api_base}")
185
+ print(f"Episodes: {args.episodes}")
186
+ print("-" * 60)
187
+
188
+ agent = LLMAgent(api_key=api_key, base_url=api_base, model=model)
189
+
190
+ results = []
191
+ t0 = time.time()
192
+ for i in range(args.episodes):
193
+ seed = args.start_seed + i
194
+ if args.verbose:
195
+ print(f"\n=== Episode seed={seed} ===")
196
+ try:
197
+ r = run_one_episode(agent, seed=seed, verbose=args.verbose)
198
+ except Exception as e:
199
+ print(f" FAILED: {e}")
200
+ continue
201
+ results.append(r)
202
+ dsa = r["drift_sensitive_acc"]
203
+ dsa_str = f"{dsa:.2%}" if dsa is not None else "n/a"
204
+ pd = r.get("per_direction", {})
205
+ def _fmt(dir_):
206
+ s = pd.get(dir_, {})
207
+ t = s.get("total", 0); c = s.get("correct", 0)
208
+ return f"{dir_[:4]}={c}/{t}"
209
+ per_dir_str = " ".join(_fmt(d) for d in ("tightening", "loosening", "neutral"))
210
+ print(f" seed={seed} reward={r['total_reward']:.2f}/30 "
211
+ f"drift_acc={dsa_str} "
212
+ f"({r['drift_sensitive_correct']}/{r['drift_sensitive_total']}) "
213
+ f"[{per_dir_str}]")
214
+
215
+ dt = time.time() - t0
216
+ summary = summarise(results)
217
+ print("\n" + "=" * 60)
218
+ print("SUMMARY")
219
+ print("=" * 60)
220
+ print(json.dumps(summary, indent=2))
221
+ print(f"\nTook {dt:.1f}s for {args.episodes} episodes.")
222
+
223
+ # Interpretation
224
+ dsa = summary["drift_sensitive_acc"]
225
+ if dsa is None:
226
+ print("\n[warn] no drift-sensitive steps encountered.")
227
+ elif dsa < 0.10:
228
+ print(f"\n[warn] drift-sensitive acc = {dsa:.0%}. Too hard for RL — redesign.")
229
+ elif dsa > 0.60:
230
+ print(f"\n[warn] drift-sensitive acc = {dsa:.0%}. Too easy — base model already solves it.")
231
+ elif dsa > 0.40:
232
+ print(f"\n[ok-ish] drift-sensitive acc = {dsa:.0%}. In the training zone but on the easy side.")
233
+ else:
234
+ print(f"\n[OK] drift-sensitive acc = {dsa:.0%}. In the sweet spot for training.")
235
+
236
+ with open(args.save, "w") as f:
237
+ json.dump({"summary": summary, "results": results}, f, indent=2)
238
+ print(f"\nFull results written to {args.save}")
239
+ return 0
240
+
241
+
242
+ if __name__ == "__main__":
243
+ sys.exit(main())
eval_results.json ADDED
@@ -0,0 +1,2816 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "summary": {
3
+ "episodes": 8,
4
+ "mean_reward": 23.125,
5
+ "mean_frac_of_max": 0.7708,
6
+ "drift_sensitive_total": 25,
7
+ "drift_sensitive_correct": 3,
8
+ "drift_sensitive_acc": 0.12,
9
+ "per_drift": {
10
+ "refund_cap_25": {
11
+ "total": 6,
12
+ "correct": 0,
13
+ "acc": 0.0
14
+ },
15
+ "escalate_manager": {
16
+ "total": 5,
17
+ "correct": 0,
18
+ "acc": 0.0
19
+ },
20
+ "sla_48hr": {
21
+ "total": 6,
22
+ "correct": 1,
23
+ "acc": 0.1667
24
+ },
25
+ "refund_cap_200": {
26
+ "total": 2,
27
+ "correct": 2,
28
+ "acc": 1.0
29
+ },
30
+ "sla_2hr": {
31
+ "total": 6,
32
+ "correct": 0,
33
+ "acc": 0.0
34
+ }
35
+ }
36
+ },
37
+ "results": [
38
+ {
39
+ "seed": 100,
40
+ "total_reward": 22.2,
41
+ "max_reward": 30.0,
42
+ "frac_of_max": 0.74,
43
+ "breakdown_totals": {
44
+ "compliance": 13.2,
45
+ "appropriateness": 9.0,
46
+ "drift_bonus": 0.0
47
+ },
48
+ "drift_sensitive_total": 4,
49
+ "drift_sensitive_correct": 0,
50
+ "drift_sensitive_acc": 0.0,
51
+ "per_drift": {
52
+ "refund_cap_25": {
53
+ "total": 1,
54
+ "correct": 0
55
+ },
56
+ "escalate_manager": {
57
+ "total": 3,
58
+ "correct": 0
59
+ }
60
+ },
61
+ "trajectory": [
62
+ {
63
+ "step": 0,
64
+ "email_kind": "customer",
65
+ "email_id": "c_0_billing_invoice_download",
66
+ "action": {
67
+ "action_type": "reply"
68
+ },
69
+ "correct_hint": {
70
+ "action_type": "reply"
71
+ },
72
+ "drift_sensitive_to": null,
73
+ "reward": 1.5,
74
+ "compliance": 1.0
75
+ },
76
+ {
77
+ "step": 1,
78
+ "email_kind": "customer",
79
+ "email_id": "c_1_refund_50",
80
+ "action": {
81
+ "action_type": "approve_refund",
82
+ "refund_amount": 50.0
83
+ },
84
+ "correct_hint": {
85
+ "action_type": "approve_refund",
86
+ "refund_amount": 50.0
87
+ },
88
+ "drift_sensitive_to": null,
89
+ "reward": 1.5,
90
+ "compliance": 1.0
91
+ },
92
+ {
93
+ "step": 2,
94
+ "email_kind": "customer",
95
+ "email_id": "c_2_refund_40",
96
+ "action": {
97
+ "action_type": "approve_refund",
98
+ "refund_amount": 40.0
99
+ },
100
+ "correct_hint": {
101
+ "action_type": "approve_refund",
102
+ "refund_amount": 40.0
103
+ },
104
+ "drift_sensitive_to": null,
105
+ "reward": 1.5,
106
+ "compliance": 1.0
107
+ },
108
+ {
109
+ "step": 3,
110
+ "email_kind": "admin",
111
+ "email_id": "admin_3_refund_cap_25",
112
+ "action": {
113
+ "action_type": "close",
114
+ "resolution_code": "policy_acknowledged"
115
+ },
116
+ "correct_hint": {
117
+ "action_type": "close",
118
+ "resolution_code": "policy_acknowledged"
119
+ },
120
+ "drift_sensitive_to": null,
121
+ "reward": 1.0,
122
+ "compliance": 1.0
123
+ },
124
+ {
125
+ "step": 4,
126
+ "email_kind": "customer",
127
+ "email_id": "c_4_billing_prorated",
128
+ "action": {
129
+ "action_type": "reply"
130
+ },
131
+ "correct_hint": {
132
+ "action_type": "reply"
133
+ },
134
+ "drift_sensitive_to": null,
135
+ "reward": 1.5,
136
+ "compliance": 1.0
137
+ },
138
+ {
139
+ "step": 5,
140
+ "email_kind": "customer",
141
+ "email_id": "c_5_refund_90",
142
+ "action": {
143
+ "action_type": "approve_refund",
144
+ "refund_amount": 90.0
145
+ },
146
+ "correct_hint": {
147
+ "action_type": "escalate",
148
+ "escalation_tier": "manager"
149
+ },
150
+ "drift_sensitive_to": "refund_cap_25",
151
+ "reward": 0.5,
152
+ "compliance": 0.0
153
+ },
154
+ {
155
+ "step": 6,
156
+ "email_kind": "customer",
157
+ "email_id": "c_6_refund_150",
158
+ "action": {
159
+ "action_type": "approve_refund",
160
+ "refund_amount": 150.0
161
+ },
162
+ "correct_hint": {
163
+ "action_type": "escalate",
164
+ "escalation_tier": "manager"
165
+ },
166
+ "drift_sensitive_to": null,
167
+ "reward": 0.5,
168
+ "compliance": 0.0
169
+ },
170
+ {
171
+ "step": 7,
172
+ "email_kind": "customer",
173
+ "email_id": "c_7_billing_tiers",
174
+ "action": {
175
+ "action_type": "reply"
176
+ },
177
+ "correct_hint": {
178
+ "action_type": "reply"
179
+ },
180
+ "drift_sensitive_to": null,
181
+ "reward": 1.5,
182
+ "compliance": 1.0
183
+ },
184
+ {
185
+ "step": 8,
186
+ "email_kind": "customer",
187
+ "email_id": "c_8_info_missing_date",
188
+ "action": {
189
+ "action_type": "request_info",
190
+ "info_field": "order_id"
191
+ },
192
+ "correct_hint": {
193
+ "action_type": "request_info",
194
+ "info_field": "charge_date"
195
+ },
196
+ "drift_sensitive_to": null,
197
+ "reward": 1.0,
198
+ "compliance": 0.5
199
+ },
200
+ {
201
+ "step": 9,
202
+ "email_kind": "customer",
203
+ "email_id": "c_9_refund_250",
204
+ "action": {
205
+ "action_type": "escalate",
206
+ "escalation_tier": "manager",
207
+ "followup_hours": 48
208
+ },
209
+ "correct_hint": {
210
+ "action_type": "escalate",
211
+ "escalation_tier": "manager"
212
+ },
213
+ "drift_sensitive_to": null,
214
+ "reward": 1.5,
215
+ "compliance": 1.0
216
+ },
217
+ {
218
+ "step": 10,
219
+ "email_kind": "customer",
220
+ "email_id": "c_10_critical_data_loss",
221
+ "action": {
222
+ "action_type": "escalate",
223
+ "escalation_tier": "tier_1",
224
+ "followup_hours": 2
225
+ },
226
+ "correct_hint": {
227
+ "action_type": "escalate",
228
+ "escalation_tier": "tier_2",
229
+ "followup_hours": 24
230
+ },
231
+ "drift_sensitive_to": null,
232
+ "reward": 0.9,
233
+ "compliance": 0.4
234
+ },
235
+ {
236
+ "step": 11,
237
+ "email_kind": "admin",
238
+ "email_id": "admin_11_escalate_manager",
239
+ "action": {
240
+ "action_type": "escalate",
241
+ "escalation_tier": "manager",
242
+ "followup_hours": 2
243
+ },
244
+ "correct_hint": {
245
+ "action_type": "close",
246
+ "resolution_code": "policy_acknowledged"
247
+ },
248
+ "drift_sensitive_to": null,
249
+ "reward": 0.2,
250
+ "compliance": 0.2
251
+ },
252
+ {
253
+ "step": 12,
254
+ "email_kind": "customer",
255
+ "email_id": "c_12_billing_tiers",
256
+ "action": {
257
+ "action_type": "reply"
258
+ },
259
+ "correct_hint": {
260
+ "action_type": "reply"
261
+ },
262
+ "drift_sensitive_to": null,
263
+ "reward": 1.5,
264
+ "compliance": 1.0
265
+ },
266
+ {
267
+ "step": 13,
268
+ "email_kind": "customer",
269
+ "email_id": "c_13_critical_security",
270
+ "action": {
271
+ "action_type": "escalate",
272
+ "escalation_tier": "manager",
273
+ "followup_hours": 2
274
+ },
275
+ "correct_hint": {
276
+ "action_type": "escalate",
277
+ "escalation_tier": "manager",
278
+ "followup_hours": 24
279
+ },
280
+ "drift_sensitive_to": "escalate_manager",
281
+ "reward": 1.2,
282
+ "compliance": 0.7
283
+ },
284
+ {
285
+ "step": 14,
286
+ "email_kind": "customer",
287
+ "email_id": "c_14_critical_outage",
288
+ "action": {
289
+ "action_type": "escalate",
290
+ "escalation_tier": "manager",
291
+ "followup_hours": 2
292
+ },
293
+ "correct_hint": {
294
+ "action_type": "escalate",
295
+ "escalation_tier": "manager",
296
+ "followup_hours": 24
297
+ },
298
+ "drift_sensitive_to": "escalate_manager",
299
+ "reward": 1.2,
300
+ "compliance": 0.7
301
+ },
302
+ {
303
+ "step": 15,
304
+ "email_kind": "customer",
305
+ "email_id": "c_15_refund_50",
306
+ "action": {
307
+ "action_type": "approve_refund",
308
+ "refund_amount": 50.0
309
+ },
310
+ "correct_hint": {
311
+ "action_type": "escalate",
312
+ "escalation_tier": "manager"
313
+ },
314
+ "drift_sensitive_to": null,
315
+ "reward": 0.5,
316
+ "compliance": 0.0
317
+ },
318
+ {
319
+ "step": 16,
320
+ "email_kind": "customer",
321
+ "email_id": "c_16_critical_outage",
322
+ "action": {
323
+ "action_type": "escalate",
324
+ "escalation_tier": "manager",
325
+ "followup_hours": 2
326
+ },
327
+ "correct_hint": {
328
+ "action_type": "escalate",
329
+ "escalation_tier": "manager",
330
+ "followup_hours": 24
331
+ },
332
+ "drift_sensitive_to": "escalate_manager",
333
+ "reward": 1.2,
334
+ "compliance": 0.7
335
+ },
336
+ {
337
+ "step": 17,
338
+ "email_kind": "customer",
339
+ "email_id": "c_17_refund_75",
340
+ "action": {
341
+ "action_type": "approve_refund",
342
+ "refund_amount": 75.0
343
+ },
344
+ "correct_hint": {
345
+ "action_type": "escalate",
346
+ "escalation_tier": "manager"
347
+ },
348
+ "drift_sensitive_to": null,
349
+ "reward": 0.5,
350
+ "compliance": 0.0
351
+ },
352
+ {
353
+ "step": 18,
354
+ "email_kind": "customer",
355
+ "email_id": "c_18_refund_15",
356
+ "action": {
357
+ "action_type": "approve_refund",
358
+ "refund_amount": 15.0
359
+ },
360
+ "correct_hint": {
361
+ "action_type": "approve_refund",
362
+ "refund_amount": 15.0
363
+ },
364
+ "drift_sensitive_to": null,
365
+ "reward": 1.5,
366
+ "compliance": 1.0
367
+ },
368
+ {
369
+ "step": 19,
370
+ "email_kind": "customer",
371
+ "email_id": "c_19_billing_tiers",
372
+ "action": {
373
+ "action_type": "reply"
374
+ },
375
+ "correct_hint": {
376
+ "action_type": "reply"
377
+ },
378
+ "drift_sensitive_to": null,
379
+ "reward": 1.5,
380
+ "compliance": 1.0
381
+ }
382
+ ]
383
+ },
384
+ {
385
+ "seed": 101,
386
+ "total_reward": 20.5,
387
+ "max_reward": 30.0,
388
+ "frac_of_max": 0.6833,
389
+ "breakdown_totals": {
390
+ "compliance": 11.0,
391
+ "appropriateness": 9.0,
392
+ "drift_bonus": 0.5
393
+ },
394
+ "drift_sensitive_total": 5,
395
+ "drift_sensitive_correct": 1,
396
+ "drift_sensitive_acc": 0.2,
397
+ "per_drift": {
398
+ "sla_48hr": {
399
+ "total": 3,
400
+ "correct": 1
401
+ },
402
+ "refund_cap_25": {
403
+ "total": 2,
404
+ "correct": 0
405
+ }
406
+ },
407
+ "trajectory": [
408
+ {
409
+ "step": 0,
410
+ "email_kind": "customer",
411
+ "email_id": "c_0_critical_payment_fail",
412
+ "action": {
413
+ "action_type": "escalate",
414
+ "escalation_tier": "tier_1",
415
+ "followup_hours": 2
416
+ },
417
+ "correct_hint": {
418
+ "action_type": "escalate",
419
+ "escalation_tier": "tier_2",
420
+ "followup_hours": 24
421
+ },
422
+ "drift_sensitive_to": null,
423
+ "reward": 0.9,
424
+ "compliance": 0.4
425
+ },
426
+ {
427
+ "step": 1,
428
+ "email_kind": "customer",
429
+ "email_id": "c_1_refund_100",
430
+ "action": {
431
+ "action_type": "approve_refund",
432
+ "refund_amount": 100.0
433
+ },
434
+ "correct_hint": {
435
+ "action_type": "approve_refund",
436
+ "refund_amount": 100.0
437
+ },
438
+ "drift_sensitive_to": null,
439
+ "reward": 1.5,
440
+ "compliance": 1.0
441
+ },
442
+ {
443
+ "step": 2,
444
+ "email_kind": "customer",
445
+ "email_id": "c_2_chitchat_feedback",
446
+ "action": {
447
+ "action_type": "close",
448
+ "resolution_code": "no_action_needed"
449
+ },
450
+ "correct_hint": {
451
+ "action_type": "close",
452
+ "resolution_code": "no_action_needed"
453
+ },
454
+ "drift_sensitive_to": null,
455
+ "reward": 1.5,
456
+ "compliance": 1.0
457
+ },
458
+ {
459
+ "step": 3,
460
+ "email_kind": "admin",
461
+ "email_id": "admin_3_sla_48hr",
462
+ "action": {
463
+ "action_type": "close",
464
+ "resolution_code": "error_fallback"
465
+ },
466
+ "correct_hint": {
467
+ "action_type": "close",
468
+ "resolution_code": "policy_acknowledged"
469
+ },
470
+ "drift_sensitive_to": null,
471
+ "reward": 1.0,
472
+ "compliance": 1.0
473
+ },
474
+ {
475
+ "step": 4,
476
+ "email_kind": "customer",
477
+ "email_id": "c_4_refund_90",
478
+ "action": {
479
+ "action_type": "approve_refund",
480
+ "refund_amount": 90.0
481
+ },
482
+ "correct_hint": {
483
+ "action_type": "approve_refund",
484
+ "refund_amount": 90.0
485
+ },
486
+ "drift_sensitive_to": null,
487
+ "reward": 1.5,
488
+ "compliance": 1.0
489
+ },
490
+ {
491
+ "step": 5,
492
+ "email_kind": "customer",
493
+ "email_id": "c_5_critical_security",
494
+ "action": {
495
+ "action_type": "escalate",
496
+ "escalation_tier": "tier_1",
497
+ "followup_hours": 48
498
+ },
499
+ "correct_hint": {
500
+ "action_type": "escalate",
501
+ "escalation_tier": "tier_2",
502
+ "followup_hours": 48
503
+ },
504
+ "drift_sensitive_to": "sla_48hr",
505
+ "reward": 1.2,
506
+ "compliance": 0.7
507
+ },
508
+ {
509
+ "step": 6,
510
+ "email_kind": "customer",
511
+ "email_id": "c_6_refund_90",
512
+ "action": {
513
+ "action_type": "approve_refund",
514
+ "refund_amount": 90.0
515
+ },
516
+ "correct_hint": {
517
+ "action_type": "approve_refund",
518
+ "refund_amount": 90.0
519
+ },
520
+ "drift_sensitive_to": null,
521
+ "reward": 1.5,
522
+ "compliance": 1.0
523
+ },
524
+ {
525
+ "step": 7,
526
+ "email_kind": "customer",
527
+ "email_id": "c_7_critical_security",
528
+ "action": {
529
+ "action_type": "escalate",
530
+ "escalation_tier": "tier_2",
531
+ "followup_hours": 48
532
+ },
533
+ "correct_hint": {
534
+ "action_type": "escalate",
535
+ "escalation_tier": "tier_2",
536
+ "followup_hours": 48
537
+ },
538
+ "drift_sensitive_to": "sla_48hr",
539
+ "reward": 2.0,
540
+ "compliance": 1.0
541
+ },
542
+ {
543
+ "step": 8,
544
+ "email_kind": "customer",
545
+ "email_id": "c_8_billing_invoice_download",
546
+ "action": {
547
+ "action_type": "reply"
548
+ },
549
+ "correct_hint": {
550
+ "action_type": "reply"
551
+ },
552
+ "drift_sensitive_to": null,
553
+ "reward": 1.5,
554
+ "compliance": 1.0
555
+ },
556
+ {
557
+ "step": 9,
558
+ "email_kind": "customer",
559
+ "email_id": "c_9_refund_180",
560
+ "action": {
561
+ "action_type": "approve_refund",
562
+ "refund_amount": 180.0
563
+ },
564
+ "correct_hint": {
565
+ "action_type": "escalate",
566
+ "escalation_tier": "manager"
567
+ },
568
+ "drift_sensitive_to": null,
569
+ "reward": 0.5,
570
+ "compliance": 0.0
571
+ },
572
+ {
573
+ "step": 10,
574
+ "email_kind": "customer",
575
+ "email_id": "c_10_critical_outage",
576
+ "action": {
577
+ "action_type": "escalate",
578
+ "escalation_tier": "tier_1",
579
+ "followup_hours": 48
580
+ },
581
+ "correct_hint": {
582
+ "action_type": "escalate",
583
+ "escalation_tier": "tier_2",
584
+ "followup_hours": 48
585
+ },
586
+ "drift_sensitive_to": "sla_48hr",
587
+ "reward": 1.2,
588
+ "compliance": 0.7
589
+ },
590
+ {
591
+ "step": 11,
592
+ "email_kind": "admin",
593
+ "email_id": "admin_11_refund_cap_25",
594
+ "action": {
595
+ "action_type": "close",
596
+ "resolution_code": "policy_acknowledged"
597
+ },
598
+ "correct_hint": {
599
+ "action_type": "close",
600
+ "resolution_code": "policy_acknowledged"
601
+ },
602
+ "drift_sensitive_to": null,
603
+ "reward": 1.0,
604
+ "compliance": 1.0
605
+ },
606
+ {
607
+ "step": 12,
608
+ "email_kind": "customer",
609
+ "email_id": "c_12_refund_40",
610
+ "action": {
611
+ "action_type": "approve_refund",
612
+ "refund_amount": 40.0
613
+ },
614
+ "correct_hint": {
615
+ "action_type": "escalate",
616
+ "escalation_tier": "manager"
617
+ },
618
+ "drift_sensitive_to": "refund_cap_25",
619
+ "reward": 0.5,
620
+ "compliance": 0.0
621
+ },
622
+ {
623
+ "step": 13,
624
+ "email_kind": "customer",
625
+ "email_id": "c_13_refund_150",
626
+ "action": {
627
+ "action_type": "approve_refund",
628
+ "refund_amount": 150.0
629
+ },
630
+ "correct_hint": {
631
+ "action_type": "escalate",
632
+ "escalation_tier": "manager"
633
+ },
634
+ "drift_sensitive_to": null,
635
+ "reward": 0.5,
636
+ "compliance": 0.0
637
+ },
638
+ {
639
+ "step": 14,
640
+ "email_kind": "customer",
641
+ "email_id": "c_14_refund_180",
642
+ "action": {
643
+ "action_type": "approve_refund",
644
+ "refund_amount": 180.0
645
+ },
646
+ "correct_hint": {
647
+ "action_type": "escalate",
648
+ "escalation_tier": "manager"
649
+ },
650
+ "drift_sensitive_to": null,
651
+ "reward": 0.5,
652
+ "compliance": 0.0
653
+ },
654
+ {
655
+ "step": 15,
656
+ "email_kind": "customer",
657
+ "email_id": "c_15_refund_120",
658
+ "action": {
659
+ "action_type": "approve_refund",
660
+ "refund_amount": 120.0
661
+ },
662
+ "correct_hint": {
663
+ "action_type": "escalate",
664
+ "escalation_tier": "manager"
665
+ },
666
+ "drift_sensitive_to": null,
667
+ "reward": 0.5,
668
+ "compliance": 0.0
669
+ },
670
+ {
671
+ "step": 16,
672
+ "email_kind": "customer",
673
+ "email_id": "c_16_critical_outage",
674
+ "action": {
675
+ "action_type": "escalate",
676
+ "escalation_tier": "tier_1",
677
+ "followup_hours": 2
678
+ },
679
+ "correct_hint": {
680
+ "action_type": "escalate",
681
+ "escalation_tier": "tier_2",
682
+ "followup_hours": 48
683
+ },
684
+ "drift_sensitive_to": null,
685
+ "reward": 0.9,
686
+ "compliance": 0.4
687
+ },
688
+ {
689
+ "step": 17,
690
+ "email_kind": "customer",
691
+ "email_id": "c_17_refund_40",
692
+ "action": {
693
+ "action_type": "approve_refund",
694
+ "refund_amount": 40.0
695
+ },
696
+ "correct_hint": {
697
+ "action_type": "escalate",
698
+ "escalation_tier": "manager"
699
+ },
700
+ "drift_sensitive_to": "refund_cap_25",
701
+ "reward": 0.5,
702
+ "compliance": 0.0
703
+ },
704
+ {
705
+ "step": 18,
706
+ "email_kind": "customer",
707
+ "email_id": "c_18_critical_data_loss",
708
+ "action": {
709
+ "action_type": "escalate",
710
+ "escalation_tier": "tier_1",
711
+ "followup_hours": 2
712
+ },
713
+ "correct_hint": {
714
+ "action_type": "escalate",
715
+ "escalation_tier": "tier_2",
716
+ "followup_hours": 48
717
+ },
718
+ "drift_sensitive_to": null,
719
+ "reward": 0.9,
720
+ "compliance": 0.4
721
+ },
722
+ {
723
+ "step": 19,
724
+ "email_kind": "customer",
725
+ "email_id": "c_19_critical_security",
726
+ "action": {
727
+ "action_type": "escalate",
728
+ "escalation_tier": "tier_1",
729
+ "followup_hours": 2
730
+ },
731
+ "correct_hint": {
732
+ "action_type": "escalate",
733
+ "escalation_tier": "tier_2",
734
+ "followup_hours": 48
735
+ },
736
+ "drift_sensitive_to": null,
737
+ "reward": 0.9,
738
+ "compliance": 0.4
739
+ }
740
+ ]
741
+ },
742
+ {
743
+ "seed": 102,
744
+ "total_reward": 24.7,
745
+ "max_reward": 30.0,
746
+ "frac_of_max": 0.8233,
747
+ "breakdown_totals": {
748
+ "compliance": 15.7,
749
+ "appropriateness": 9.0,
750
+ "drift_bonus": 0.0
751
+ },
752
+ "drift_sensitive_total": 0,
753
+ "drift_sensitive_correct": 0,
754
+ "drift_sensitive_acc": null,
755
+ "per_drift": {},
756
+ "trajectory": [
757
+ {
758
+ "step": 0,
759
+ "email_kind": "customer",
760
+ "email_id": "c_0_info_missing_date",
761
+ "action": {
762
+ "action_type": "request_info",
763
+ "info_field": "order_id"
764
+ },
765
+ "correct_hint": {
766
+ "action_type": "request_info",
767
+ "info_field": "charge_date"
768
+ },
769
+ "drift_sensitive_to": null,
770
+ "reward": 1.0,
771
+ "compliance": 0.5
772
+ },
773
+ {
774
+ "step": 1,
775
+ "email_kind": "customer",
776
+ "email_id": "c_1_critical_data_loss",
777
+ "action": {
778
+ "action_type": "escalate",
779
+ "escalation_tier": "tier_1",
780
+ "followup_hours": 2
781
+ },
782
+ "correct_hint": {
783
+ "action_type": "escalate",
784
+ "escalation_tier": "tier_2",
785
+ "followup_hours": 24
786
+ },
787
+ "drift_sensitive_to": null,
788
+ "reward": 0.9,
789
+ "compliance": 0.4
790
+ },
791
+ {
792
+ "step": 2,
793
+ "email_kind": "customer",
794
+ "email_id": "c_2_refund_30",
795
+ "action": {
796
+ "action_type": "approve_refund",
797
+ "refund_amount": 30.0
798
+ },
799
+ "correct_hint": {
800
+ "action_type": "approve_refund",
801
+ "refund_amount": 30.0
802
+ },
803
+ "drift_sensitive_to": null,
804
+ "reward": 1.5,
805
+ "compliance": 1.0
806
+ },
807
+ {
808
+ "step": 3,
809
+ "email_kind": "admin",
810
+ "email_id": "admin_3_refund_cap_50",
811
+ "action": {
812
+ "action_type": "close",
813
+ "resolution_code": "policy_acknowledged"
814
+ },
815
+ "correct_hint": {
816
+ "action_type": "close",
817
+ "resolution_code": "policy_acknowledged"
818
+ },
819
+ "drift_sensitive_to": null,
820
+ "reward": 1.0,
821
+ "compliance": 1.0
822
+ },
823
+ {
824
+ "step": 4,
825
+ "email_kind": "customer",
826
+ "email_id": "c_4_chitchat_ooo",
827
+ "action": {
828
+ "action_type": "close",
829
+ "resolution_code": "no_action_needed"
830
+ },
831
+ "correct_hint": {
832
+ "action_type": "close",
833
+ "resolution_code": "no_action_needed"
834
+ },
835
+ "drift_sensitive_to": null,
836
+ "reward": 1.5,
837
+ "compliance": 1.0
838
+ },
839
+ {
840
+ "step": 5,
841
+ "email_kind": "customer",
842
+ "email_id": "c_5_critical_payment_fail",
843
+ "action": {
844
+ "action_type": "escalate",
845
+ "escalation_tier": "tier_1",
846
+ "followup_hours": 2
847
+ },
848
+ "correct_hint": {
849
+ "action_type": "escalate",
850
+ "escalation_tier": "tier_2",
851
+ "followup_hours": 24
852
+ },
853
+ "drift_sensitive_to": null,
854
+ "reward": 0.9,
855
+ "compliance": 0.4
856
+ },
857
+ {
858
+ "step": 6,
859
+ "email_kind": "customer",
860
+ "email_id": "c_6_info_missing_date",
861
+ "action": {
862
+ "action_type": "request_info",
863
+ "info_field": "time_period"
864
+ },
865
+ "correct_hint": {
866
+ "action_type": "request_info",
867
+ "info_field": "charge_date"
868
+ },
869
+ "drift_sensitive_to": null,
870
+ "reward": 1.0,
871
+ "compliance": 0.5
872
+ },
873
+ {
874
+ "step": 7,
875
+ "email_kind": "customer",
876
+ "email_id": "c_7_chitchat_thanks",
877
+ "action": {
878
+ "action_type": "close",
879
+ "resolution_code": "no_action_needed"
880
+ },
881
+ "correct_hint": {
882
+ "action_type": "close",
883
+ "resolution_code": "no_action_needed"
884
+ },
885
+ "drift_sensitive_to": null,
886
+ "reward": 1.5,
887
+ "compliance": 1.0
888
+ },
889
+ {
890
+ "step": 8,
891
+ "email_kind": "customer",
892
+ "email_id": "c_8_critical_payment_fail",
893
+ "action": {
894
+ "action_type": "escalate",
895
+ "escalation_tier": "tier_1",
896
+ "followup_hours": 2
897
+ },
898
+ "correct_hint": {
899
+ "action_type": "escalate",
900
+ "escalation_tier": "tier_2",
901
+ "followup_hours": 24
902
+ },
903
+ "drift_sensitive_to": null,
904
+ "reward": 0.9,
905
+ "compliance": 0.4
906
+ },
907
+ {
908
+ "step": 9,
909
+ "email_kind": "customer",
910
+ "email_id": "c_9_refund_30",
911
+ "action": {
912
+ "action_type": "approve_refund",
913
+ "refund_amount": 30.0
914
+ },
915
+ "correct_hint": {
916
+ "action_type": "approve_refund",
917
+ "refund_amount": 30.0
918
+ },
919
+ "drift_sensitive_to": null,
920
+ "reward": 1.5,
921
+ "compliance": 1.0
922
+ },
923
+ {
924
+ "step": 10,
925
+ "email_kind": "customer",
926
+ "email_id": "c_10_critical_security",
927
+ "action": {
928
+ "action_type": "escalate",
929
+ "escalation_tier": "tier_1",
930
+ "followup_hours": 2
931
+ },
932
+ "correct_hint": {
933
+ "action_type": "escalate",
934
+ "escalation_tier": "tier_2",
935
+ "followup_hours": 24
936
+ },
937
+ "drift_sensitive_to": null,
938
+ "reward": 0.9,
939
+ "compliance": 0.4
940
+ },
941
+ {
942
+ "step": 11,
943
+ "email_kind": "admin",
944
+ "email_id": "admin_11_escalate_keep_tier_2",
945
+ "action": {
946
+ "action_type": "close",
947
+ "resolution_code": "policy_acknowledged"
948
+ },
949
+ "correct_hint": {
950
+ "action_type": "close",
951
+ "resolution_code": "policy_acknowledged"
952
+ },
953
+ "drift_sensitive_to": null,
954
+ "reward": 1.0,
955
+ "compliance": 1.0
956
+ },
957
+ {
958
+ "step": 12,
959
+ "email_kind": "customer",
960
+ "email_id": "c_12_billing_tiers",
961
+ "action": {
962
+ "action_type": "reply"
963
+ },
964
+ "correct_hint": {
965
+ "action_type": "reply"
966
+ },
967
+ "drift_sensitive_to": null,
968
+ "reward": 1.5,
969
+ "compliance": 1.0
970
+ },
971
+ {
972
+ "step": 13,
973
+ "email_kind": "customer",
974
+ "email_id": "c_13_chitchat_ooo",
975
+ "action": {
976
+ "action_type": "close",
977
+ "resolution_code": "no_action_needed"
978
+ },
979
+ "correct_hint": {
980
+ "action_type": "close",
981
+ "resolution_code": "no_action_needed"
982
+ },
983
+ "drift_sensitive_to": null,
984
+ "reward": 1.5,
985
+ "compliance": 1.0
986
+ },
987
+ {
988
+ "step": 14,
989
+ "email_kind": "customer",
990
+ "email_id": "c_14_critical_data_loss",
991
+ "action": {
992
+ "action_type": "escalate",
993
+ "escalation_tier": "tier_2",
994
+ "followup_hours": 2
995
+ },
996
+ "correct_hint": {
997
+ "action_type": "escalate",
998
+ "escalation_tier": "tier_2",
999
+ "followup_hours": 24
1000
+ },
1001
+ "drift_sensitive_to": null,
1002
+ "reward": 1.2,
1003
+ "compliance": 0.7
1004
+ },
1005
+ {
1006
+ "step": 15,
1007
+ "email_kind": "customer",
1008
+ "email_id": "c_15_refund_40",
1009
+ "action": {
1010
+ "action_type": "approve_refund",
1011
+ "refund_amount": 40.0
1012
+ },
1013
+ "correct_hint": {
1014
+ "action_type": "approve_refund",
1015
+ "refund_amount": 40.0
1016
+ },
1017
+ "drift_sensitive_to": null,
1018
+ "reward": 1.5,
1019
+ "compliance": 1.0
1020
+ },
1021
+ {
1022
+ "step": 16,
1023
+ "email_kind": "customer",
1024
+ "email_id": "c_16_refund_40",
1025
+ "action": {
1026
+ "action_type": "approve_refund",
1027
+ "refund_amount": 40.0
1028
+ },
1029
+ "correct_hint": {
1030
+ "action_type": "approve_refund",
1031
+ "refund_amount": 40.0
1032
+ },
1033
+ "drift_sensitive_to": null,
1034
+ "reward": 1.5,
1035
+ "compliance": 1.0
1036
+ },
1037
+ {
1038
+ "step": 17,
1039
+ "email_kind": "customer",
1040
+ "email_id": "c_17_chitchat_thanks",
1041
+ "action": {
1042
+ "action_type": "close",
1043
+ "resolution_code": "no_action_needed"
1044
+ },
1045
+ "correct_hint": {
1046
+ "action_type": "close",
1047
+ "resolution_code": "no_action_needed"
1048
+ },
1049
+ "drift_sensitive_to": null,
1050
+ "reward": 1.5,
1051
+ "compliance": 1.0
1052
+ },
1053
+ {
1054
+ "step": 18,
1055
+ "email_kind": "customer",
1056
+ "email_id": "c_18_critical_payment_fail",
1057
+ "action": {
1058
+ "action_type": "escalate",
1059
+ "escalation_tier": "tier_2",
1060
+ "followup_hours": 2
1061
+ },
1062
+ "correct_hint": {
1063
+ "action_type": "escalate",
1064
+ "escalation_tier": "tier_2",
1065
+ "followup_hours": 24
1066
+ },
1067
+ "drift_sensitive_to": null,
1068
+ "reward": 1.2,
1069
+ "compliance": 0.7
1070
+ },
1071
+ {
1072
+ "step": 19,
1073
+ "email_kind": "customer",
1074
+ "email_id": "c_19_critical_data_loss",
1075
+ "action": {
1076
+ "action_type": "escalate",
1077
+ "escalation_tier": "tier_2",
1078
+ "followup_hours": 2
1079
+ },
1080
+ "correct_hint": {
1081
+ "action_type": "escalate",
1082
+ "escalation_tier": "tier_2",
1083
+ "followup_hours": 24
1084
+ },
1085
+ "drift_sensitive_to": null,
1086
+ "reward": 1.2,
1087
+ "compliance": 0.7
1088
+ }
1089
+ ]
1090
+ },
1091
+ {
1092
+ "seed": 103,
1093
+ "total_reward": 26.2,
1094
+ "max_reward": 30.0,
1095
+ "frac_of_max": 0.8733,
1096
+ "breakdown_totals": {
1097
+ "compliance": 17.2,
1098
+ "appropriateness": 9.0,
1099
+ "drift_bonus": 0.0
1100
+ },
1101
+ "drift_sensitive_total": 2,
1102
+ "drift_sensitive_correct": 0,
1103
+ "drift_sensitive_acc": 0.0,
1104
+ "per_drift": {
1105
+ "sla_48hr": {
1106
+ "total": 2,
1107
+ "correct": 0
1108
+ }
1109
+ },
1110
+ "trajectory": [
1111
+ {
1112
+ "step": 0,
1113
+ "email_kind": "customer",
1114
+ "email_id": "c_0_billing_tiers",
1115
+ "action": {
1116
+ "action_type": "reply"
1117
+ },
1118
+ "correct_hint": {
1119
+ "action_type": "reply"
1120
+ },
1121
+ "drift_sensitive_to": null,
1122
+ "reward": 1.5,
1123
+ "compliance": 1.0
1124
+ },
1125
+ {
1126
+ "step": 1,
1127
+ "email_kind": "customer",
1128
+ "email_id": "c_1_refund_50",
1129
+ "action": {
1130
+ "action_type": "approve_refund",
1131
+ "refund_amount": 50.0
1132
+ },
1133
+ "correct_hint": {
1134
+ "action_type": "approve_refund",
1135
+ "refund_amount": 50.0
1136
+ },
1137
+ "drift_sensitive_to": null,
1138
+ "reward": 1.5,
1139
+ "compliance": 1.0
1140
+ },
1141
+ {
1142
+ "step": 2,
1143
+ "email_kind": "customer",
1144
+ "email_id": "c_2_critical_security",
1145
+ "action": {
1146
+ "action_type": "escalate",
1147
+ "escalation_tier": "tier_1",
1148
+ "followup_hours": 2
1149
+ },
1150
+ "correct_hint": {
1151
+ "action_type": "escalate",
1152
+ "escalation_tier": "tier_2",
1153
+ "followup_hours": 24
1154
+ },
1155
+ "drift_sensitive_to": null,
1156
+ "reward": 0.9,
1157
+ "compliance": 0.4
1158
+ },
1159
+ {
1160
+ "step": 3,
1161
+ "email_kind": "admin",
1162
+ "email_id": "admin_3_sla_48hr",
1163
+ "action": {
1164
+ "action_type": "close",
1165
+ "resolution_code": "error_fallback"
1166
+ },
1167
+ "correct_hint": {
1168
+ "action_type": "close",
1169
+ "resolution_code": "policy_acknowledged"
1170
+ },
1171
+ "drift_sensitive_to": null,
1172
+ "reward": 1.0,
1173
+ "compliance": 1.0
1174
+ },
1175
+ {
1176
+ "step": 4,
1177
+ "email_kind": "customer",
1178
+ "email_id": "c_4_info_missing_account",
1179
+ "action": {
1180
+ "action_type": "request_info",
1181
+ "info_field": "account_email"
1182
+ },
1183
+ "correct_hint": {
1184
+ "action_type": "request_info",
1185
+ "info_field": "account_email"
1186
+ },
1187
+ "drift_sensitive_to": null,
1188
+ "reward": 1.5,
1189
+ "compliance": 1.0
1190
+ },
1191
+ {
1192
+ "step": 5,
1193
+ "email_kind": "customer",
1194
+ "email_id": "c_5_refund_60",
1195
+ "action": {
1196
+ "action_type": "approve_refund",
1197
+ "refund_amount": 60.0
1198
+ },
1199
+ "correct_hint": {
1200
+ "action_type": "approve_refund",
1201
+ "refund_amount": 60.0
1202
+ },
1203
+ "drift_sensitive_to": null,
1204
+ "reward": 1.5,
1205
+ "compliance": 1.0
1206
+ },
1207
+ {
1208
+ "step": 6,
1209
+ "email_kind": "customer",
1210
+ "email_id": "c_6_refund_15",
1211
+ "action": {
1212
+ "action_type": "approve_refund",
1213
+ "refund_amount": 15.0
1214
+ },
1215
+ "correct_hint": {
1216
+ "action_type": "approve_refund",
1217
+ "refund_amount": 15.0
1218
+ },
1219
+ "drift_sensitive_to": null,
1220
+ "reward": 1.5,
1221
+ "compliance": 1.0
1222
+ },
1223
+ {
1224
+ "step": 7,
1225
+ "email_kind": "customer",
1226
+ "email_id": "c_7_refund_50",
1227
+ "action": {
1228
+ "action_type": "approve_refund",
1229
+ "refund_amount": 50.0
1230
+ },
1231
+ "correct_hint": {
1232
+ "action_type": "approve_refund",
1233
+ "refund_amount": 50.0
1234
+ },
1235
+ "drift_sensitive_to": null,
1236
+ "reward": 1.5,
1237
+ "compliance": 1.0
1238
+ },
1239
+ {
1240
+ "step": 8,
1241
+ "email_kind": "customer",
1242
+ "email_id": "c_8_critical_payment_fail",
1243
+ "action": {
1244
+ "action_type": "escalate",
1245
+ "escalation_tier": "tier_1",
1246
+ "followup_hours": 48
1247
+ },
1248
+ "correct_hint": {
1249
+ "action_type": "escalate",
1250
+ "escalation_tier": "tier_2",
1251
+ "followup_hours": 48
1252
+ },
1253
+ "drift_sensitive_to": "sla_48hr",
1254
+ "reward": 1.2,
1255
+ "compliance": 0.7
1256
+ },
1257
+ {
1258
+ "step": 9,
1259
+ "email_kind": "customer",
1260
+ "email_id": "c_9_critical_data_loss",
1261
+ "action": {
1262
+ "action_type": "escalate",
1263
+ "escalation_tier": "tier_1",
1264
+ "followup_hours": 48
1265
+ },
1266
+ "correct_hint": {
1267
+ "action_type": "escalate",
1268
+ "escalation_tier": "tier_2",
1269
+ "followup_hours": 48
1270
+ },
1271
+ "drift_sensitive_to": "sla_48hr",
1272
+ "reward": 1.2,
1273
+ "compliance": 0.7
1274
+ },
1275
+ {
1276
+ "step": 10,
1277
+ "email_kind": "customer",
1278
+ "email_id": "c_10_chitchat_thanks",
1279
+ "action": {
1280
+ "action_type": "close",
1281
+ "resolution_code": "no_action_needed"
1282
+ },
1283
+ "correct_hint": {
1284
+ "action_type": "close",
1285
+ "resolution_code": "no_action_needed"
1286
+ },
1287
+ "drift_sensitive_to": null,
1288
+ "reward": 1.5,
1289
+ "compliance": 1.0
1290
+ },
1291
+ {
1292
+ "step": 11,
1293
+ "email_kind": "admin",
1294
+ "email_id": "admin_11_escalate_keep_tier_2",
1295
+ "action": {
1296
+ "action_type": "close",
1297
+ "resolution_code": "policy_acknowledged"
1298
+ },
1299
+ "correct_hint": {
1300
+ "action_type": "close",
1301
+ "resolution_code": "policy_acknowledged"
1302
+ },
1303
+ "drift_sensitive_to": null,
1304
+ "reward": 1.0,
1305
+ "compliance": 1.0
1306
+ },
1307
+ {
1308
+ "step": 12,
1309
+ "email_kind": "customer",
1310
+ "email_id": "c_12_refund_100",
1311
+ "action": {
1312
+ "action_type": "approve_refund",
1313
+ "refund_amount": 100.0
1314
+ },
1315
+ "correct_hint": {
1316
+ "action_type": "approve_refund",
1317
+ "refund_amount": 100.0
1318
+ },
1319
+ "drift_sensitive_to": null,
1320
+ "reward": 1.5,
1321
+ "compliance": 1.0
1322
+ },
1323
+ {
1324
+ "step": 13,
1325
+ "email_kind": "customer",
1326
+ "email_id": "c_13_refund_90",
1327
+ "action": {
1328
+ "action_type": "approve_refund",
1329
+ "refund_amount": 90.0
1330
+ },
1331
+ "correct_hint": {
1332
+ "action_type": "approve_refund",
1333
+ "refund_amount": 90.0
1334
+ },
1335
+ "drift_sensitive_to": null,
1336
+ "reward": 1.5,
1337
+ "compliance": 1.0
1338
+ },
1339
+ {
1340
+ "step": 14,
1341
+ "email_kind": "customer",
1342
+ "email_id": "c_14_critical_payment_fail",
1343
+ "action": {
1344
+ "action_type": "escalate",
1345
+ "escalation_tier": "tier_2",
1346
+ "followup_hours": 2
1347
+ },
1348
+ "correct_hint": {
1349
+ "action_type": "escalate",
1350
+ "escalation_tier": "tier_2",
1351
+ "followup_hours": 48
1352
+ },
1353
+ "drift_sensitive_to": null,
1354
+ "reward": 1.2,
1355
+ "compliance": 0.7
1356
+ },
1357
+ {
1358
+ "step": 15,
1359
+ "email_kind": "customer",
1360
+ "email_id": "c_15_refund_120",
1361
+ "action": {
1362
+ "action_type": "approve_refund",
1363
+ "refund_amount": 120.0
1364
+ },
1365
+ "correct_hint": {
1366
+ "action_type": "escalate",
1367
+ "escalation_tier": "manager"
1368
+ },
1369
+ "drift_sensitive_to": null,
1370
+ "reward": 0.5,
1371
+ "compliance": 0.0
1372
+ },
1373
+ {
1374
+ "step": 16,
1375
+ "email_kind": "customer",
1376
+ "email_id": "c_16_billing_tiers",
1377
+ "action": {
1378
+ "action_type": "reply"
1379
+ },
1380
+ "correct_hint": {
1381
+ "action_type": "reply"
1382
+ },
1383
+ "drift_sensitive_to": null,
1384
+ "reward": 1.5,
1385
+ "compliance": 1.0
1386
+ },
1387
+ {
1388
+ "step": 17,
1389
+ "email_kind": "customer",
1390
+ "email_id": "c_17_chitchat_ooo",
1391
+ "action": {
1392
+ "action_type": "close",
1393
+ "resolution_code": "no_action_needed"
1394
+ },
1395
+ "correct_hint": {
1396
+ "action_type": "close",
1397
+ "resolution_code": "no_action_needed"
1398
+ },
1399
+ "drift_sensitive_to": null,
1400
+ "reward": 1.5,
1401
+ "compliance": 1.0
1402
+ },
1403
+ {
1404
+ "step": 18,
1405
+ "email_kind": "customer",
1406
+ "email_id": "c_18_chitchat_feedback",
1407
+ "action": {
1408
+ "action_type": "close",
1409
+ "resolution_code": "no_action_needed"
1410
+ },
1411
+ "correct_hint": {
1412
+ "action_type": "close",
1413
+ "resolution_code": "no_action_needed"
1414
+ },
1415
+ "drift_sensitive_to": null,
1416
+ "reward": 1.5,
1417
+ "compliance": 1.0
1418
+ },
1419
+ {
1420
+ "step": 19,
1421
+ "email_kind": "customer",
1422
+ "email_id": "c_19_critical_payment_fail",
1423
+ "action": {
1424
+ "action_type": "escalate",
1425
+ "escalation_tier": "tier_2",
1426
+ "followup_hours": 2
1427
+ },
1428
+ "correct_hint": {
1429
+ "action_type": "escalate",
1430
+ "escalation_tier": "tier_2",
1431
+ "followup_hours": 48
1432
+ },
1433
+ "drift_sensitive_to": null,
1434
+ "reward": 1.2,
1435
+ "compliance": 0.7
1436
+ }
1437
+ ]
1438
+ },
1439
+ {
1440
+ "seed": 104,
1441
+ "total_reward": 27.0,
1442
+ "max_reward": 30.0,
1443
+ "frac_of_max": 0.9,
1444
+ "breakdown_totals": {
1445
+ "compliance": 17.5,
1446
+ "appropriateness": 9.0,
1447
+ "drift_bonus": 0.5
1448
+ },
1449
+ "drift_sensitive_total": 4,
1450
+ "drift_sensitive_correct": 2,
1451
+ "drift_sensitive_acc": 0.5,
1452
+ "per_drift": {
1453
+ "refund_cap_200": {
1454
+ "total": 2,
1455
+ "correct": 2
1456
+ },
1457
+ "sla_2hr": {
1458
+ "total": 2,
1459
+ "correct": 0
1460
+ }
1461
+ },
1462
+ "trajectory": [
1463
+ {
1464
+ "step": 0,
1465
+ "email_kind": "customer",
1466
+ "email_id": "c_0_refund_15",
1467
+ "action": {
1468
+ "action_type": "approve_refund",
1469
+ "refund_amount": 15.0
1470
+ },
1471
+ "correct_hint": {
1472
+ "action_type": "approve_refund",
1473
+ "refund_amount": 15.0
1474
+ },
1475
+ "drift_sensitive_to": null,
1476
+ "reward": 1.5,
1477
+ "compliance": 1.0
1478
+ },
1479
+ {
1480
+ "step": 1,
1481
+ "email_kind": "customer",
1482
+ "email_id": "c_1_info_missing_order_id",
1483
+ "action": {
1484
+ "action_type": "request_info",
1485
+ "info_field": "order_id"
1486
+ },
1487
+ "correct_hint": {
1488
+ "action_type": "request_info",
1489
+ "info_field": "order_id"
1490
+ },
1491
+ "drift_sensitive_to": null,
1492
+ "reward": 1.5,
1493
+ "compliance": 1.0
1494
+ },
1495
+ {
1496
+ "step": 2,
1497
+ "email_kind": "customer",
1498
+ "email_id": "c_2_info_missing_account",
1499
+ "action": {
1500
+ "action_type": "request_info",
1501
+ "info_field": "account_email"
1502
+ },
1503
+ "correct_hint": {
1504
+ "action_type": "request_info",
1505
+ "info_field": "account_email"
1506
+ },
1507
+ "drift_sensitive_to": null,
1508
+ "reward": 1.5,
1509
+ "compliance": 1.0
1510
+ },
1511
+ {
1512
+ "step": 3,
1513
+ "email_kind": "admin",
1514
+ "email_id": "admin_3_refund_cap_200",
1515
+ "action": {
1516
+ "action_type": "close",
1517
+ "resolution_code": "error_fallback"
1518
+ },
1519
+ "correct_hint": {
1520
+ "action_type": "close",
1521
+ "resolution_code": "policy_acknowledged"
1522
+ },
1523
+ "drift_sensitive_to": null,
1524
+ "reward": 1.0,
1525
+ "compliance": 1.0
1526
+ },
1527
+ {
1528
+ "step": 4,
1529
+ "email_kind": "customer",
1530
+ "email_id": "c_4_info_missing_account",
1531
+ "action": {
1532
+ "action_type": "request_info",
1533
+ "info_field": "account_email"
1534
+ },
1535
+ "correct_hint": {
1536
+ "action_type": "request_info",
1537
+ "info_field": "account_email"
1538
+ },
1539
+ "drift_sensitive_to": null,
1540
+ "reward": 1.5,
1541
+ "compliance": 1.0
1542
+ },
1543
+ {
1544
+ "step": 5,
1545
+ "email_kind": "customer",
1546
+ "email_id": "c_5_refund_180",
1547
+ "action": {
1548
+ "action_type": "approve_refund",
1549
+ "refund_amount": 180.0
1550
+ },
1551
+ "correct_hint": {
1552
+ "action_type": "approve_refund",
1553
+ "refund_amount": 180.0
1554
+ },
1555
+ "drift_sensitive_to": "refund_cap_200",
1556
+ "reward": 2.0,
1557
+ "compliance": 1.0
1558
+ },
1559
+ {
1560
+ "step": 6,
1561
+ "email_kind": "customer",
1562
+ "email_id": "c_6_refund_30",
1563
+ "action": {
1564
+ "action_type": "approve_refund",
1565
+ "refund_amount": 30.0
1566
+ },
1567
+ "correct_hint": {
1568
+ "action_type": "approve_refund",
1569
+ "refund_amount": 30.0
1570
+ },
1571
+ "drift_sensitive_to": null,
1572
+ "reward": 1.5,
1573
+ "compliance": 1.0
1574
+ },
1575
+ {
1576
+ "step": 7,
1577
+ "email_kind": "customer",
1578
+ "email_id": "c_7_billing_prorated",
1579
+ "action": {
1580
+ "action_type": "reply"
1581
+ },
1582
+ "correct_hint": {
1583
+ "action_type": "reply"
1584
+ },
1585
+ "drift_sensitive_to": null,
1586
+ "reward": 1.5,
1587
+ "compliance": 1.0
1588
+ },
1589
+ {
1590
+ "step": 8,
1591
+ "email_kind": "customer",
1592
+ "email_id": "c_8_refund_180",
1593
+ "action": {
1594
+ "action_type": "approve_refund",
1595
+ "refund_amount": 180.0
1596
+ },
1597
+ "correct_hint": {
1598
+ "action_type": "approve_refund",
1599
+ "refund_amount": 180.0
1600
+ },
1601
+ "drift_sensitive_to": "refund_cap_200",
1602
+ "reward": 1.5,
1603
+ "compliance": 1.0
1604
+ },
1605
+ {
1606
+ "step": 9,
1607
+ "email_kind": "customer",
1608
+ "email_id": "c_9_info_missing_date",
1609
+ "action": {
1610
+ "action_type": "request_info",
1611
+ "info_field": "order_id"
1612
+ },
1613
+ "correct_hint": {
1614
+ "action_type": "request_info",
1615
+ "info_field": "charge_date"
1616
+ },
1617
+ "drift_sensitive_to": null,
1618
+ "reward": 1.0,
1619
+ "compliance": 0.5
1620
+ },
1621
+ {
1622
+ "step": 10,
1623
+ "email_kind": "customer",
1624
+ "email_id": "c_10_critical_data_loss",
1625
+ "action": {
1626
+ "action_type": "escalate",
1627
+ "escalation_tier": "tier_1",
1628
+ "followup_hours": 2
1629
+ },
1630
+ "correct_hint": {
1631
+ "action_type": "escalate",
1632
+ "escalation_tier": "tier_2",
1633
+ "followup_hours": 24
1634
+ },
1635
+ "drift_sensitive_to": null,
1636
+ "reward": 0.9,
1637
+ "compliance": 0.4
1638
+ },
1639
+ {
1640
+ "step": 11,
1641
+ "email_kind": "admin",
1642
+ "email_id": "admin_11_sla_2hr",
1643
+ "action": {
1644
+ "action_type": "schedule_followup",
1645
+ "followup_hours": 2
1646
+ },
1647
+ "correct_hint": {
1648
+ "action_type": "close",
1649
+ "resolution_code": "policy_acknowledged"
1650
+ },
1651
+ "drift_sensitive_to": null,
1652
+ "reward": 0.2,
1653
+ "compliance": 0.2
1654
+ },
1655
+ {
1656
+ "step": 12,
1657
+ "email_kind": "customer",
1658
+ "email_id": "c_12_critical_security",
1659
+ "action": {
1660
+ "action_type": "escalate",
1661
+ "escalation_tier": "tier_1",
1662
+ "followup_hours": 2
1663
+ },
1664
+ "correct_hint": {
1665
+ "action_type": "escalate",
1666
+ "escalation_tier": "tier_2",
1667
+ "followup_hours": 2
1668
+ },
1669
+ "drift_sensitive_to": "sla_2hr",
1670
+ "reward": 1.2,
1671
+ "compliance": 0.7
1672
+ },
1673
+ {
1674
+ "step": 13,
1675
+ "email_kind": "customer",
1676
+ "email_id": "c_13_refund_120",
1677
+ "action": {
1678
+ "action_type": "approve_refund",
1679
+ "refund_amount": 120.0
1680
+ },
1681
+ "correct_hint": {
1682
+ "action_type": "approve_refund",
1683
+ "refund_amount": 120.0
1684
+ },
1685
+ "drift_sensitive_to": null,
1686
+ "reward": 1.5,
1687
+ "compliance": 1.0
1688
+ },
1689
+ {
1690
+ "step": 14,
1691
+ "email_kind": "customer",
1692
+ "email_id": "c_14_info_missing_order_id",
1693
+ "action": {
1694
+ "action_type": "request_info",
1695
+ "info_field": "order_id"
1696
+ },
1697
+ "correct_hint": {
1698
+ "action_type": "request_info",
1699
+ "info_field": "order_id"
1700
+ },
1701
+ "drift_sensitive_to": null,
1702
+ "reward": 1.5,
1703
+ "compliance": 1.0
1704
+ },
1705
+ {
1706
+ "step": 15,
1707
+ "email_kind": "customer",
1708
+ "email_id": "c_15_refund_50",
1709
+ "action": {
1710
+ "action_type": "approve_refund",
1711
+ "refund_amount": 50.0
1712
+ },
1713
+ "correct_hint": {
1714
+ "action_type": "approve_refund",
1715
+ "refund_amount": 50.0
1716
+ },
1717
+ "drift_sensitive_to": null,
1718
+ "reward": 1.5,
1719
+ "compliance": 1.0
1720
+ },
1721
+ {
1722
+ "step": 16,
1723
+ "email_kind": "customer",
1724
+ "email_id": "c_16_billing_prorated",
1725
+ "action": {
1726
+ "action_type": "reply"
1727
+ },
1728
+ "correct_hint": {
1729
+ "action_type": "reply"
1730
+ },
1731
+ "drift_sensitive_to": null,
1732
+ "reward": 1.5,
1733
+ "compliance": 1.0
1734
+ },
1735
+ {
1736
+ "step": 17,
1737
+ "email_kind": "customer",
1738
+ "email_id": "c_17_refund_180",
1739
+ "action": {
1740
+ "action_type": "approve_refund",
1741
+ "refund_amount": 180.0
1742
+ },
1743
+ "correct_hint": {
1744
+ "action_type": "approve_refund",
1745
+ "refund_amount": 180.0
1746
+ },
1747
+ "drift_sensitive_to": null,
1748
+ "reward": 1.5,
1749
+ "compliance": 1.0
1750
+ },
1751
+ {
1752
+ "step": 18,
1753
+ "email_kind": "customer",
1754
+ "email_id": "c_18_billing_invoice_download",
1755
+ "action": {
1756
+ "action_type": "reply"
1757
+ },
1758
+ "correct_hint": {
1759
+ "action_type": "reply"
1760
+ },
1761
+ "drift_sensitive_to": null,
1762
+ "reward": 1.5,
1763
+ "compliance": 1.0
1764
+ },
1765
+ {
1766
+ "step": 19,
1767
+ "email_kind": "customer",
1768
+ "email_id": "c_19_critical_auth_locked",
1769
+ "action": {
1770
+ "action_type": "escalate",
1771
+ "escalation_tier": "tier_1",
1772
+ "followup_hours": 2
1773
+ },
1774
+ "correct_hint": {
1775
+ "action_type": "escalate",
1776
+ "escalation_tier": "tier_2",
1777
+ "followup_hours": 2
1778
+ },
1779
+ "drift_sensitive_to": "sla_2hr",
1780
+ "reward": 1.2,
1781
+ "compliance": 0.7
1782
+ }
1783
+ ]
1784
+ },
1785
+ {
1786
+ "seed": 105,
1787
+ "total_reward": 22.6,
1788
+ "max_reward": 30.0,
1789
+ "frac_of_max": 0.7533,
1790
+ "breakdown_totals": {
1791
+ "compliance": 14.1,
1792
+ "appropriateness": 8.5,
1793
+ "drift_bonus": 0.0
1794
+ },
1795
+ "drift_sensitive_total": 3,
1796
+ "drift_sensitive_correct": 0,
1797
+ "drift_sensitive_acc": 0.0,
1798
+ "per_drift": {
1799
+ "sla_48hr": {
1800
+ "total": 1,
1801
+ "correct": 0
1802
+ },
1803
+ "escalate_manager": {
1804
+ "total": 2,
1805
+ "correct": 0
1806
+ }
1807
+ },
1808
+ "trajectory": [
1809
+ {
1810
+ "step": 0,
1811
+ "email_kind": "customer",
1812
+ "email_id": "c_0_billing_prorated",
1813
+ "action": {
1814
+ "action_type": "reply"
1815
+ },
1816
+ "correct_hint": {
1817
+ "action_type": "reply"
1818
+ },
1819
+ "drift_sensitive_to": null,
1820
+ "reward": 1.5,
1821
+ "compliance": 1.0
1822
+ },
1823
+ {
1824
+ "step": 1,
1825
+ "email_kind": "customer",
1826
+ "email_id": "c_1_refund_180",
1827
+ "action": {
1828
+ "action_type": "approve_refund",
1829
+ "refund_amount": 180.0
1830
+ },
1831
+ "correct_hint": {
1832
+ "action_type": "escalate",
1833
+ "escalation_tier": "manager"
1834
+ },
1835
+ "drift_sensitive_to": null,
1836
+ "reward": 0.5,
1837
+ "compliance": 0.0
1838
+ },
1839
+ {
1840
+ "step": 2,
1841
+ "email_kind": "customer",
1842
+ "email_id": "c_2_refund_250",
1843
+ "action": {
1844
+ "action_type": "approve_refund",
1845
+ "refund_amount": 250.0
1846
+ },
1847
+ "correct_hint": {
1848
+ "action_type": "escalate",
1849
+ "escalation_tier": "manager"
1850
+ },
1851
+ "drift_sensitive_to": null,
1852
+ "reward": 0.5,
1853
+ "compliance": 0.0
1854
+ },
1855
+ {
1856
+ "step": 3,
1857
+ "email_kind": "admin",
1858
+ "email_id": "admin_3_sla_48hr",
1859
+ "action": {
1860
+ "action_type": "close",
1861
+ "resolution_code": "error_fallback"
1862
+ },
1863
+ "correct_hint": {
1864
+ "action_type": "close",
1865
+ "resolution_code": "policy_acknowledged"
1866
+ },
1867
+ "drift_sensitive_to": null,
1868
+ "reward": 1.0,
1869
+ "compliance": 1.0
1870
+ },
1871
+ {
1872
+ "step": 4,
1873
+ "email_kind": "customer",
1874
+ "email_id": "c_4_refund_60",
1875
+ "action": {
1876
+ "action_type": "approve_refund",
1877
+ "refund_amount": 60.0
1878
+ },
1879
+ "correct_hint": {
1880
+ "action_type": "approve_refund",
1881
+ "refund_amount": 60.0
1882
+ },
1883
+ "drift_sensitive_to": null,
1884
+ "reward": 1.5,
1885
+ "compliance": 1.0
1886
+ },
1887
+ {
1888
+ "step": 5,
1889
+ "email_kind": "customer",
1890
+ "email_id": "c_5_critical_outage",
1891
+ "action": {
1892
+ "action_type": "escalate",
1893
+ "escalation_tier": "tier_1",
1894
+ "followup_hours": 48
1895
+ },
1896
+ "correct_hint": {
1897
+ "action_type": "escalate",
1898
+ "escalation_tier": "tier_2",
1899
+ "followup_hours": 48
1900
+ },
1901
+ "drift_sensitive_to": "sla_48hr",
1902
+ "reward": 1.2,
1903
+ "compliance": 0.7
1904
+ },
1905
+ {
1906
+ "step": 6,
1907
+ "email_kind": "customer",
1908
+ "email_id": "c_6_refund_75",
1909
+ "action": {
1910
+ "action_type": "escalate",
1911
+ "escalation_tier": "tier_1",
1912
+ "followup_hours": 48
1913
+ },
1914
+ "correct_hint": {
1915
+ "action_type": "approve_refund",
1916
+ "refund_amount": 75.0
1917
+ },
1918
+ "drift_sensitive_to": null,
1919
+ "reward": 0.5,
1920
+ "compliance": 0.0
1921
+ },
1922
+ {
1923
+ "step": 7,
1924
+ "email_kind": "customer",
1925
+ "email_id": "c_7_chitchat_thanks",
1926
+ "action": {
1927
+ "action_type": "close",
1928
+ "resolution_code": "no_action_needed"
1929
+ },
1930
+ "correct_hint": {
1931
+ "action_type": "close",
1932
+ "resolution_code": "no_action_needed"
1933
+ },
1934
+ "drift_sensitive_to": null,
1935
+ "reward": 1.5,
1936
+ "compliance": 1.0
1937
+ },
1938
+ {
1939
+ "step": 8,
1940
+ "email_kind": "customer",
1941
+ "email_id": "c_8_chitchat_feedback",
1942
+ "action": {
1943
+ "action_type": "close",
1944
+ "resolution_code": "no_action_needed"
1945
+ },
1946
+ "correct_hint": {
1947
+ "action_type": "close",
1948
+ "resolution_code": "no_action_needed"
1949
+ },
1950
+ "drift_sensitive_to": null,
1951
+ "reward": 1.5,
1952
+ "compliance": 1.0
1953
+ },
1954
+ {
1955
+ "step": 9,
1956
+ "email_kind": "customer",
1957
+ "email_id": "c_9_billing_tiers",
1958
+ "action": {
1959
+ "action_type": "reply"
1960
+ },
1961
+ "correct_hint": {
1962
+ "action_type": "reply"
1963
+ },
1964
+ "drift_sensitive_to": null,
1965
+ "reward": 1.5,
1966
+ "compliance": 1.0
1967
+ },
1968
+ {
1969
+ "step": 10,
1970
+ "email_kind": "customer",
1971
+ "email_id": "c_10_info_missing_date",
1972
+ "action": {
1973
+ "action_type": "request_info",
1974
+ "info_field": "order_id"
1975
+ },
1976
+ "correct_hint": {
1977
+ "action_type": "request_info",
1978
+ "info_field": "charge_date"
1979
+ },
1980
+ "drift_sensitive_to": null,
1981
+ "reward": 1.0,
1982
+ "compliance": 0.5
1983
+ },
1984
+ {
1985
+ "step": 11,
1986
+ "email_kind": "admin",
1987
+ "email_id": "admin_11_escalate_manager",
1988
+ "action": {
1989
+ "action_type": "close",
1990
+ "resolution_code": "policy_acknowledged"
1991
+ },
1992
+ "correct_hint": {
1993
+ "action_type": "close",
1994
+ "resolution_code": "policy_acknowledged"
1995
+ },
1996
+ "drift_sensitive_to": null,
1997
+ "reward": 1.0,
1998
+ "compliance": 1.0
1999
+ },
2000
+ {
2001
+ "step": 12,
2002
+ "email_kind": "customer",
2003
+ "email_id": "c_12_critical_payment_fail",
2004
+ "action": {
2005
+ "action_type": "escalate",
2006
+ "escalation_tier": "manager",
2007
+ "followup_hours": 2
2008
+ },
2009
+ "correct_hint": {
2010
+ "action_type": "escalate",
2011
+ "escalation_tier": "manager",
2012
+ "followup_hours": 48
2013
+ },
2014
+ "drift_sensitive_to": "escalate_manager",
2015
+ "reward": 1.2,
2016
+ "compliance": 0.7
2017
+ },
2018
+ {
2019
+ "step": 13,
2020
+ "email_kind": "customer",
2021
+ "email_id": "c_13_info_missing_order_id",
2022
+ "action": {
2023
+ "action_type": "request_info",
2024
+ "info_field": "invoice_details"
2025
+ },
2026
+ "correct_hint": {
2027
+ "action_type": "request_info",
2028
+ "info_field": "order_id"
2029
+ },
2030
+ "drift_sensitive_to": null,
2031
+ "reward": 1.0,
2032
+ "compliance": 0.5
2033
+ },
2034
+ {
2035
+ "step": 14,
2036
+ "email_kind": "customer",
2037
+ "email_id": "c_14_info_missing_account",
2038
+ "action": {
2039
+ "action_type": "reply"
2040
+ },
2041
+ "correct_hint": {
2042
+ "action_type": "request_info",
2043
+ "info_field": "account_email"
2044
+ },
2045
+ "drift_sensitive_to": null,
2046
+ "reward": 0.0,
2047
+ "compliance": 0.0
2048
+ },
2049
+ {
2050
+ "step": 15,
2051
+ "email_kind": "customer",
2052
+ "email_id": "c_15_refund_75",
2053
+ "action": {
2054
+ "action_type": "approve_refund",
2055
+ "refund_amount": 75.0
2056
+ },
2057
+ "correct_hint": {
2058
+ "action_type": "approve_refund",
2059
+ "refund_amount": 75.0
2060
+ },
2061
+ "drift_sensitive_to": null,
2062
+ "reward": 1.5,
2063
+ "compliance": 1.0
2064
+ },
2065
+ {
2066
+ "step": 16,
2067
+ "email_kind": "customer",
2068
+ "email_id": "c_16_chitchat_thanks",
2069
+ "action": {
2070
+ "action_type": "close",
2071
+ "resolution_code": "no_action_needed"
2072
+ },
2073
+ "correct_hint": {
2074
+ "action_type": "close",
2075
+ "resolution_code": "no_action_needed"
2076
+ },
2077
+ "drift_sensitive_to": null,
2078
+ "reward": 1.5,
2079
+ "compliance": 1.0
2080
+ },
2081
+ {
2082
+ "step": 17,
2083
+ "email_kind": "customer",
2084
+ "email_id": "c_17_chitchat_ooo",
2085
+ "action": {
2086
+ "action_type": "close",
2087
+ "resolution_code": "no_action_needed"
2088
+ },
2089
+ "correct_hint": {
2090
+ "action_type": "close",
2091
+ "resolution_code": "no_action_needed"
2092
+ },
2093
+ "drift_sensitive_to": null,
2094
+ "reward": 1.5,
2095
+ "compliance": 1.0
2096
+ },
2097
+ {
2098
+ "step": 18,
2099
+ "email_kind": "customer",
2100
+ "email_id": "c_18_critical_api_down",
2101
+ "action": {
2102
+ "action_type": "escalate",
2103
+ "escalation_tier": "manager",
2104
+ "followup_hours": 2
2105
+ },
2106
+ "correct_hint": {
2107
+ "action_type": "escalate",
2108
+ "escalation_tier": "manager",
2109
+ "followup_hours": 48
2110
+ },
2111
+ "drift_sensitive_to": "escalate_manager",
2112
+ "reward": 1.2,
2113
+ "compliance": 0.7
2114
+ },
2115
+ {
2116
+ "step": 19,
2117
+ "email_kind": "customer",
2118
+ "email_id": "c_19_chitchat_ooo",
2119
+ "action": {
2120
+ "action_type": "close",
2121
+ "resolution_code": "no_action_needed"
2122
+ },
2123
+ "correct_hint": {
2124
+ "action_type": "close",
2125
+ "resolution_code": "no_action_needed"
2126
+ },
2127
+ "drift_sensitive_to": null,
2128
+ "reward": 1.5,
2129
+ "compliance": 1.0
2130
+ }
2131
+ ]
2132
+ },
2133
+ {
2134
+ "seed": 106,
2135
+ "total_reward": 23.8,
2136
+ "max_reward": 30.0,
2137
+ "frac_of_max": 0.7933,
2138
+ "breakdown_totals": {
2139
+ "compliance": 14.8,
2140
+ "appropriateness": 9.0,
2141
+ "drift_bonus": 0.0
2142
+ },
2143
+ "drift_sensitive_total": 4,
2144
+ "drift_sensitive_correct": 0,
2145
+ "drift_sensitive_acc": 0.0,
2146
+ "per_drift": {
2147
+ "sla_2hr": {
2148
+ "total": 4,
2149
+ "correct": 0
2150
+ }
2151
+ },
2152
+ "trajectory": [
2153
+ {
2154
+ "step": 0,
2155
+ "email_kind": "customer",
2156
+ "email_id": "c_0_chitchat_feedback",
2157
+ "action": {
2158
+ "action_type": "close",
2159
+ "resolution_code": "no_action_needed"
2160
+ },
2161
+ "correct_hint": {
2162
+ "action_type": "close",
2163
+ "resolution_code": "no_action_needed"
2164
+ },
2165
+ "drift_sensitive_to": null,
2166
+ "reward": 1.5,
2167
+ "compliance": 1.0
2168
+ },
2169
+ {
2170
+ "step": 1,
2171
+ "email_kind": "customer",
2172
+ "email_id": "c_1_refund_120",
2173
+ "action": {
2174
+ "action_type": "request_info",
2175
+ "info_field": "partner_account_id"
2176
+ },
2177
+ "correct_hint": {
2178
+ "action_type": "escalate",
2179
+ "escalation_tier": "manager"
2180
+ },
2181
+ "drift_sensitive_to": null,
2182
+ "reward": 0.5,
2183
+ "compliance": 0.0
2184
+ },
2185
+ {
2186
+ "step": 2,
2187
+ "email_kind": "customer",
2188
+ "email_id": "c_2_billing_tiers",
2189
+ "action": {
2190
+ "action_type": "reply"
2191
+ },
2192
+ "correct_hint": {
2193
+ "action_type": "reply"
2194
+ },
2195
+ "drift_sensitive_to": null,
2196
+ "reward": 1.5,
2197
+ "compliance": 1.0
2198
+ },
2199
+ {
2200
+ "step": 3,
2201
+ "email_kind": "admin",
2202
+ "email_id": "admin_3_sla_2hr",
2203
+ "action": {
2204
+ "action_type": "close",
2205
+ "resolution_code": "error_fallback"
2206
+ },
2207
+ "correct_hint": {
2208
+ "action_type": "close",
2209
+ "resolution_code": "policy_acknowledged"
2210
+ },
2211
+ "drift_sensitive_to": null,
2212
+ "reward": 1.0,
2213
+ "compliance": 1.0
2214
+ },
2215
+ {
2216
+ "step": 4,
2217
+ "email_kind": "customer",
2218
+ "email_id": "c_4_chitchat_feedback",
2219
+ "action": {
2220
+ "action_type": "close",
2221
+ "resolution_code": "no_action_needed"
2222
+ },
2223
+ "correct_hint": {
2224
+ "action_type": "close",
2225
+ "resolution_code": "no_action_needed"
2226
+ },
2227
+ "drift_sensitive_to": null,
2228
+ "reward": 1.5,
2229
+ "compliance": 1.0
2230
+ },
2231
+ {
2232
+ "step": 5,
2233
+ "email_kind": "customer",
2234
+ "email_id": "c_5_critical_security",
2235
+ "action": {
2236
+ "action_type": "escalate",
2237
+ "escalation_tier": "tier_1",
2238
+ "followup_hours": 2
2239
+ },
2240
+ "correct_hint": {
2241
+ "action_type": "escalate",
2242
+ "escalation_tier": "tier_2",
2243
+ "followup_hours": 2
2244
+ },
2245
+ "drift_sensitive_to": "sla_2hr",
2246
+ "reward": 1.2,
2247
+ "compliance": 0.7
2248
+ },
2249
+ {
2250
+ "step": 6,
2251
+ "email_kind": "customer",
2252
+ "email_id": "c_6_billing_prorated",
2253
+ "action": {
2254
+ "action_type": "reply"
2255
+ },
2256
+ "correct_hint": {
2257
+ "action_type": "reply"
2258
+ },
2259
+ "drift_sensitive_to": null,
2260
+ "reward": 1.5,
2261
+ "compliance": 1.0
2262
+ },
2263
+ {
2264
+ "step": 7,
2265
+ "email_kind": "customer",
2266
+ "email_id": "c_7_critical_security",
2267
+ "action": {
2268
+ "action_type": "escalate",
2269
+ "escalation_tier": "tier_1",
2270
+ "followup_hours": 2
2271
+ },
2272
+ "correct_hint": {
2273
+ "action_type": "escalate",
2274
+ "escalation_tier": "tier_2",
2275
+ "followup_hours": 2
2276
+ },
2277
+ "drift_sensitive_to": "sla_2hr",
2278
+ "reward": 1.2,
2279
+ "compliance": 0.7
2280
+ },
2281
+ {
2282
+ "step": 8,
2283
+ "email_kind": "customer",
2284
+ "email_id": "c_8_critical_api_down",
2285
+ "action": {
2286
+ "action_type": "escalate",
2287
+ "escalation_tier": "tier_1",
2288
+ "followup_hours": 2
2289
+ },
2290
+ "correct_hint": {
2291
+ "action_type": "escalate",
2292
+ "escalation_tier": "tier_2",
2293
+ "followup_hours": 2
2294
+ },
2295
+ "drift_sensitive_to": "sla_2hr",
2296
+ "reward": 1.2,
2297
+ "compliance": 0.7
2298
+ },
2299
+ {
2300
+ "step": 9,
2301
+ "email_kind": "customer",
2302
+ "email_id": "c_9_refund_75",
2303
+ "action": {
2304
+ "action_type": "approve_refund",
2305
+ "refund_amount": 75.0
2306
+ },
2307
+ "correct_hint": {
2308
+ "action_type": "approve_refund",
2309
+ "refund_amount": 75.0
2310
+ },
2311
+ "drift_sensitive_to": null,
2312
+ "reward": 1.5,
2313
+ "compliance": 1.0
2314
+ },
2315
+ {
2316
+ "step": 10,
2317
+ "email_kind": "customer",
2318
+ "email_id": "c_10_critical_api_down",
2319
+ "action": {
2320
+ "action_type": "escalate",
2321
+ "escalation_tier": "tier_1",
2322
+ "followup_hours": 2
2323
+ },
2324
+ "correct_hint": {
2325
+ "action_type": "escalate",
2326
+ "escalation_tier": "tier_2",
2327
+ "followup_hours": 2
2328
+ },
2329
+ "drift_sensitive_to": "sla_2hr",
2330
+ "reward": 1.2,
2331
+ "compliance": 0.7
2332
+ },
2333
+ {
2334
+ "step": 11,
2335
+ "email_kind": "admin",
2336
+ "email_id": "admin_11_escalate_keep_tier_2",
2337
+ "action": {
2338
+ "action_type": "close",
2339
+ "resolution_code": "policy_acknowledged"
2340
+ },
2341
+ "correct_hint": {
2342
+ "action_type": "close",
2343
+ "resolution_code": "policy_acknowledged"
2344
+ },
2345
+ "drift_sensitive_to": null,
2346
+ "reward": 1.0,
2347
+ "compliance": 1.0
2348
+ },
2349
+ {
2350
+ "step": 12,
2351
+ "email_kind": "customer",
2352
+ "email_id": "c_12_refund_150",
2353
+ "action": {
2354
+ "action_type": "approve_refund",
2355
+ "refund_amount": 150.0
2356
+ },
2357
+ "correct_hint": {
2358
+ "action_type": "escalate",
2359
+ "escalation_tier": "manager"
2360
+ },
2361
+ "drift_sensitive_to": null,
2362
+ "reward": 0.5,
2363
+ "compliance": 0.0
2364
+ },
2365
+ {
2366
+ "step": 13,
2367
+ "email_kind": "customer",
2368
+ "email_id": "c_13_refund_75",
2369
+ "action": {
2370
+ "action_type": "approve_refund",
2371
+ "refund_amount": 75.0
2372
+ },
2373
+ "correct_hint": {
2374
+ "action_type": "approve_refund",
2375
+ "refund_amount": 75.0
2376
+ },
2377
+ "drift_sensitive_to": null,
2378
+ "reward": 1.5,
2379
+ "compliance": 1.0
2380
+ },
2381
+ {
2382
+ "step": 14,
2383
+ "email_kind": "customer",
2384
+ "email_id": "c_14_refund_40",
2385
+ "action": {
2386
+ "action_type": "approve_refund",
2387
+ "refund_amount": 40.0
2388
+ },
2389
+ "correct_hint": {
2390
+ "action_type": "approve_refund",
2391
+ "refund_amount": 40.0
2392
+ },
2393
+ "drift_sensitive_to": null,
2394
+ "reward": 1.5,
2395
+ "compliance": 1.0
2396
+ },
2397
+ {
2398
+ "step": 15,
2399
+ "email_kind": "customer",
2400
+ "email_id": "c_15_refund_180",
2401
+ "action": {
2402
+ "action_type": "request_info",
2403
+ "info_field": "order_id"
2404
+ },
2405
+ "correct_hint": {
2406
+ "action_type": "escalate",
2407
+ "escalation_tier": "manager"
2408
+ },
2409
+ "drift_sensitive_to": null,
2410
+ "reward": 0.5,
2411
+ "compliance": 0.0
2412
+ },
2413
+ {
2414
+ "step": 16,
2415
+ "email_kind": "customer",
2416
+ "email_id": "c_16_refund_75",
2417
+ "action": {
2418
+ "action_type": "approve_refund",
2419
+ "refund_amount": 75.0
2420
+ },
2421
+ "correct_hint": {
2422
+ "action_type": "approve_refund",
2423
+ "refund_amount": 75.0
2424
+ },
2425
+ "drift_sensitive_to": null,
2426
+ "reward": 1.5,
2427
+ "compliance": 1.0
2428
+ },
2429
+ {
2430
+ "step": 17,
2431
+ "email_kind": "customer",
2432
+ "email_id": "c_17_billing_invoice_download",
2433
+ "action": {
2434
+ "action_type": "reply"
2435
+ },
2436
+ "correct_hint": {
2437
+ "action_type": "reply"
2438
+ },
2439
+ "drift_sensitive_to": null,
2440
+ "reward": 1.5,
2441
+ "compliance": 1.0
2442
+ },
2443
+ {
2444
+ "step": 18,
2445
+ "email_kind": "customer",
2446
+ "email_id": "c_18_critical_auth_locked",
2447
+ "action": {
2448
+ "action_type": "escalate",
2449
+ "escalation_tier": "tier_2",
2450
+ "followup_hours": 2
2451
+ },
2452
+ "correct_hint": {
2453
+ "action_type": "escalate",
2454
+ "escalation_tier": "tier_2",
2455
+ "followup_hours": 2
2456
+ },
2457
+ "drift_sensitive_to": null,
2458
+ "reward": 1.5,
2459
+ "compliance": 1.0
2460
+ },
2461
+ {
2462
+ "step": 19,
2463
+ "email_kind": "customer",
2464
+ "email_id": "c_19_refund_120",
2465
+ "action": {
2466
+ "action_type": "approve_refund",
2467
+ "refund_amount": 120.0
2468
+ },
2469
+ "correct_hint": {
2470
+ "action_type": "escalate",
2471
+ "escalation_tier": "manager"
2472
+ },
2473
+ "drift_sensitive_to": null,
2474
+ "reward": 0.5,
2475
+ "compliance": 0.0
2476
+ }
2477
+ ]
2478
+ },
2479
+ {
2480
+ "seed": 107,
2481
+ "total_reward": 18.0,
2482
+ "max_reward": 30.0,
2483
+ "frac_of_max": 0.6,
2484
+ "breakdown_totals": {
2485
+ "compliance": 9.5,
2486
+ "appropriateness": 8.5,
2487
+ "drift_bonus": 0.0
2488
+ },
2489
+ "drift_sensitive_total": 3,
2490
+ "drift_sensitive_correct": 0,
2491
+ "drift_sensitive_acc": 0.0,
2492
+ "per_drift": {
2493
+ "refund_cap_25": {
2494
+ "total": 3,
2495
+ "correct": 0
2496
+ }
2497
+ },
2498
+ "trajectory": [
2499
+ {
2500
+ "step": 0,
2501
+ "email_kind": "customer",
2502
+ "email_id": "c_0_chitchat_thanks",
2503
+ "action": {
2504
+ "action_type": "close",
2505
+ "resolution_code": "no_action_needed"
2506
+ },
2507
+ "correct_hint": {
2508
+ "action_type": "close",
2509
+ "resolution_code": "no_action_needed"
2510
+ },
2511
+ "drift_sensitive_to": null,
2512
+ "reward": 1.5,
2513
+ "compliance": 1.0
2514
+ },
2515
+ {
2516
+ "step": 1,
2517
+ "email_kind": "customer",
2518
+ "email_id": "c_1_info_missing_account",
2519
+ "action": {
2520
+ "action_type": "reply"
2521
+ },
2522
+ "correct_hint": {
2523
+ "action_type": "request_info",
2524
+ "info_field": "account_email"
2525
+ },
2526
+ "drift_sensitive_to": null,
2527
+ "reward": 0.0,
2528
+ "compliance": 0.0
2529
+ },
2530
+ {
2531
+ "step": 2,
2532
+ "email_kind": "customer",
2533
+ "email_id": "c_2_refund_30",
2534
+ "action": {
2535
+ "action_type": "approve_refund",
2536
+ "refund_amount": 30.0
2537
+ },
2538
+ "correct_hint": {
2539
+ "action_type": "approve_refund",
2540
+ "refund_amount": 30.0
2541
+ },
2542
+ "drift_sensitive_to": null,
2543
+ "reward": 1.5,
2544
+ "compliance": 1.0
2545
+ },
2546
+ {
2547
+ "step": 3,
2548
+ "email_kind": "admin",
2549
+ "email_id": "admin_3_refund_cap_25",
2550
+ "action": {
2551
+ "action_type": "close",
2552
+ "resolution_code": "policy_acknowledged"
2553
+ },
2554
+ "correct_hint": {
2555
+ "action_type": "close",
2556
+ "resolution_code": "policy_acknowledged"
2557
+ },
2558
+ "drift_sensitive_to": null,
2559
+ "reward": 1.0,
2560
+ "compliance": 1.0
2561
+ },
2562
+ {
2563
+ "step": 4,
2564
+ "email_kind": "customer",
2565
+ "email_id": "c_4_billing_prorated",
2566
+ "action": {
2567
+ "action_type": "reply"
2568
+ },
2569
+ "correct_hint": {
2570
+ "action_type": "reply"
2571
+ },
2572
+ "drift_sensitive_to": null,
2573
+ "reward": 1.5,
2574
+ "compliance": 1.0
2575
+ },
2576
+ {
2577
+ "step": 5,
2578
+ "email_kind": "customer",
2579
+ "email_id": "c_5_billing_tiers",
2580
+ "action": {
2581
+ "action_type": "reply"
2582
+ },
2583
+ "correct_hint": {
2584
+ "action_type": "reply"
2585
+ },
2586
+ "drift_sensitive_to": null,
2587
+ "reward": 1.5,
2588
+ "compliance": 1.0
2589
+ },
2590
+ {
2591
+ "step": 6,
2592
+ "email_kind": "customer",
2593
+ "email_id": "c_6_refund_90",
2594
+ "action": {
2595
+ "action_type": "approve_refund",
2596
+ "refund_amount": 90.0
2597
+ },
2598
+ "correct_hint": {
2599
+ "action_type": "escalate",
2600
+ "escalation_tier": "manager"
2601
+ },
2602
+ "drift_sensitive_to": "refund_cap_25",
2603
+ "reward": 0.5,
2604
+ "compliance": 0.0
2605
+ },
2606
+ {
2607
+ "step": 7,
2608
+ "email_kind": "customer",
2609
+ "email_id": "c_7_chitchat_ooo",
2610
+ "action": {
2611
+ "action_type": "close",
2612
+ "resolution_code": "no_action_needed"
2613
+ },
2614
+ "correct_hint": {
2615
+ "action_type": "close",
2616
+ "resolution_code": "no_action_needed"
2617
+ },
2618
+ "drift_sensitive_to": null,
2619
+ "reward": 1.5,
2620
+ "compliance": 1.0
2621
+ },
2622
+ {
2623
+ "step": 8,
2624
+ "email_kind": "customer",
2625
+ "email_id": "c_8_refund_250",
2626
+ "action": {
2627
+ "action_type": "escalate",
2628
+ "escalation_tier": "manager",
2629
+ "followup_hours": 48
2630
+ },
2631
+ "correct_hint": {
2632
+ "action_type": "escalate",
2633
+ "escalation_tier": "manager"
2634
+ },
2635
+ "drift_sensitive_to": null,
2636
+ "reward": 1.5,
2637
+ "compliance": 1.0
2638
+ },
2639
+ {
2640
+ "step": 9,
2641
+ "email_kind": "customer",
2642
+ "email_id": "c_9_refund_75",
2643
+ "action": {
2644
+ "action_type": "approve_refund",
2645
+ "refund_amount": 75.0
2646
+ },
2647
+ "correct_hint": {
2648
+ "action_type": "escalate",
2649
+ "escalation_tier": "manager"
2650
+ },
2651
+ "drift_sensitive_to": "refund_cap_25",
2652
+ "reward": 0.5,
2653
+ "compliance": 0.0
2654
+ },
2655
+ {
2656
+ "step": 10,
2657
+ "email_kind": "customer",
2658
+ "email_id": "c_10_refund_90",
2659
+ "action": {
2660
+ "action_type": "approve_refund",
2661
+ "refund_amount": 90.0
2662
+ },
2663
+ "correct_hint": {
2664
+ "action_type": "escalate",
2665
+ "escalation_tier": "manager"
2666
+ },
2667
+ "drift_sensitive_to": "refund_cap_25",
2668
+ "reward": 0.5,
2669
+ "compliance": 0.0
2670
+ },
2671
+ {
2672
+ "step": 11,
2673
+ "email_kind": "admin",
2674
+ "email_id": "admin_11_escalate_tier_1",
2675
+ "action": {
2676
+ "action_type": "close",
2677
+ "resolution_code": "policy_acknowledged"
2678
+ },
2679
+ "correct_hint": {
2680
+ "action_type": "close",
2681
+ "resolution_code": "policy_acknowledged"
2682
+ },
2683
+ "drift_sensitive_to": null,
2684
+ "reward": 1.0,
2685
+ "compliance": 1.0
2686
+ },
2687
+ {
2688
+ "step": 12,
2689
+ "email_kind": "customer",
2690
+ "email_id": "c_12_refund_120",
2691
+ "action": {
2692
+ "action_type": "request_info",
2693
+ "info_field": "partner_account_email"
2694
+ },
2695
+ "correct_hint": {
2696
+ "action_type": "escalate",
2697
+ "escalation_tier": "manager"
2698
+ },
2699
+ "drift_sensitive_to": null,
2700
+ "reward": 0.5,
2701
+ "compliance": 0.0
2702
+ },
2703
+ {
2704
+ "step": 13,
2705
+ "email_kind": "customer",
2706
+ "email_id": "c_13_refund_120",
2707
+ "action": {
2708
+ "action_type": "request_info",
2709
+ "info_field": "partner_account_id"
2710
+ },
2711
+ "correct_hint": {
2712
+ "action_type": "escalate",
2713
+ "escalation_tier": "manager"
2714
+ },
2715
+ "drift_sensitive_to": null,
2716
+ "reward": 0.5,
2717
+ "compliance": 0.0
2718
+ },
2719
+ {
2720
+ "step": 14,
2721
+ "email_kind": "customer",
2722
+ "email_id": "c_14_refund_100",
2723
+ "action": {
2724
+ "action_type": "approve_refund",
2725
+ "refund_amount": 100.0
2726
+ },
2727
+ "correct_hint": {
2728
+ "action_type": "escalate",
2729
+ "escalation_tier": "manager"
2730
+ },
2731
+ "drift_sensitive_to": null,
2732
+ "reward": 0.5,
2733
+ "compliance": 0.0
2734
+ },
2735
+ {
2736
+ "step": 15,
2737
+ "email_kind": "customer",
2738
+ "email_id": "c_15_refund_150",
2739
+ "action": {
2740
+ "action_type": "approve_refund",
2741
+ "refund_amount": 150.0
2742
+ },
2743
+ "correct_hint": {
2744
+ "action_type": "escalate",
2745
+ "escalation_tier": "manager"
2746
+ },
2747
+ "drift_sensitive_to": null,
2748
+ "reward": 0.5,
2749
+ "compliance": 0.0
2750
+ },
2751
+ {
2752
+ "step": 16,
2753
+ "email_kind": "customer",
2754
+ "email_id": "c_16_refund_150",
2755
+ "action": {
2756
+ "action_type": "approve_refund",
2757
+ "refund_amount": 150.0
2758
+ },
2759
+ "correct_hint": {
2760
+ "action_type": "escalate",
2761
+ "escalation_tier": "manager"
2762
+ },
2763
+ "drift_sensitive_to": null,
2764
+ "reward": 0.5,
2765
+ "compliance": 0.0
2766
+ },
2767
+ {
2768
+ "step": 17,
2769
+ "email_kind": "customer",
2770
+ "email_id": "c_17_info_missing_order_id",
2771
+ "action": {
2772
+ "action_type": "request_info",
2773
+ "info_field": "invoice_details"
2774
+ },
2775
+ "correct_hint": {
2776
+ "action_type": "request_info",
2777
+ "info_field": "order_id"
2778
+ },
2779
+ "drift_sensitive_to": null,
2780
+ "reward": 1.0,
2781
+ "compliance": 0.5
2782
+ },
2783
+ {
2784
+ "step": 18,
2785
+ "email_kind": "customer",
2786
+ "email_id": "c_18_billing_prorated",
2787
+ "action": {
2788
+ "action_type": "reply"
2789
+ },
2790
+ "correct_hint": {
2791
+ "action_type": "reply"
2792
+ },
2793
+ "drift_sensitive_to": null,
2794
+ "reward": 1.5,
2795
+ "compliance": 1.0
2796
+ },
2797
+ {
2798
+ "step": 19,
2799
+ "email_kind": "customer",
2800
+ "email_id": "c_19_refund_100",
2801
+ "action": {
2802
+ "action_type": "approve_refund",
2803
+ "refund_amount": 100.0
2804
+ },
2805
+ "correct_hint": {
2806
+ "action_type": "escalate",
2807
+ "escalation_tier": "manager"
2808
+ },
2809
+ "drift_sensitive_to": null,
2810
+ "reward": 0.5,
2811
+ "compliance": 0.0
2812
+ }
2813
+ ]
2814
+ }
2815
+ ]
2816
+ }
inference.py ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Inference Script — Email Triage OpenEnv
3
+ ===================================
4
+ MANDATORY
5
+ - Before submitting, ensure the following variables are defined in your environment configuration:
6
+ API_BASE_URL The API endpoint for the LLM.
7
+ MODEL_NAME The model identifier to use for inference.
8
+ HF_TOKEN Your Hugging Face / API key.
9
+
10
+ - The inference script must be named `inference.py` and placed in the root directory of the project
11
+ - Participants must use OpenAI Client for all LLM calls using above variables
12
+
13
+ STDOUT FORMAT
14
+ - The script emits exactly three line types to stdout:
15
+ [START] task=<task_name> env=<benchmark> model=<model_name>
16
+ [STEP] step=<n> action=<action_str> reward=<0.00> done=<true|false> error=<msg|null>
17
+ [END] success=<true|false> steps=<n> score=<score> rewards=<r1,r2,...,rn>
18
+ """
19
+
20
+ import os
21
+ import json
22
+ import sys
23
+
24
+ from openai import OpenAI
25
+
26
+ from email_env.server.environment import EmailTriageEnv
27
+ from email_env.models import Action
28
+ from email_env.tasks import TASKS
29
+
30
+ API_BASE_URL = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")
31
+ MODEL_NAME = os.getenv("MODEL_NAME", "meta-llama/Llama-3.1-8B-Instruct")
32
+ HF_TOKEN = os.getenv("HF_TOKEN") or os.getenv("API_KEY")
33
+ BENCHMARK = "email-triage"
34
+ SUCCESS_THRESHOLD = 0.5
35
+
36
+ if not HF_TOKEN:
37
+ raise EnvironmentError("HF_TOKEN environment variable is required.")
38
+
39
+ SYSTEM_PROMPT = """You are an email triage assistant. Given an email, you must:
40
+ 1. Classify the email into exactly one category: billing, technical, or general
41
+ 2. Assign a priority: low, medium, or high
42
+ 3. Write a professional response to the sender
43
+
44
+ Reply ONLY with valid JSON in this exact format (no markdown, no extra text):
45
+ {
46
+ "category": "<billing|technical|general>",
47
+ "priority": "<low|medium|high>",
48
+ "response": "<your response text>"
49
+ }"""
50
+
51
+
52
+ def run_inference():
53
+ client = OpenAI(api_key=HF_TOKEN, base_url=API_BASE_URL)
54
+ env = EmailTriageEnv()
55
+ all_scores = []
56
+
57
+ for task_id in TASKS.keys():
58
+ rewards = []
59
+ success = False
60
+ score = 0.0
61
+ steps = 0
62
+ error_msg = "null"
63
+ action_str = "noop"
64
+ done = False
65
+
66
+ print(
67
+ f"[START] task={task_id} env={BENCHMARK} model={MODEL_NAME}",
68
+ flush=True,
69
+ )
70
+
71
+ try:
72
+ obs = env.reset(task_id=task_id)
73
+
74
+ user_msg = (
75
+ f"Sender type: {obs.sender_type}\n\n"
76
+ f"Email:\n{obs.email_text}"
77
+ )
78
+
79
+ completion = client.chat.completions.create(
80
+ model=MODEL_NAME,
81
+ messages=[
82
+ {"role": "system", "content": SYSTEM_PROMPT},
83
+ {"role": "user", "content": user_msg},
84
+ ],
85
+ temperature=0.2,
86
+ max_tokens=300,
87
+ )
88
+
89
+ raw = completion.choices[0].message.content.strip()
90
+ if raw.startswith("```"):
91
+ lines = [l for l in raw.split("\n") if not l.startswith("```")]
92
+ raw = "\n".join(lines).strip()
93
+
94
+ try:
95
+ parsed = json.loads(raw)
96
+ except json.JSONDecodeError:
97
+ parsed = {"category": "general", "priority": "low", "response": ""}
98
+ error_msg = "json_parse_error"
99
+
100
+ action = Action(
101
+ category=parsed.get("category", "general"),
102
+ priority=parsed.get("priority", "low"),
103
+ response=parsed.get("response", ""),
104
+ )
105
+ action_str = (
106
+ f"triage(category='{action.category}',"
107
+ f"priority='{action.priority}')"
108
+ )
109
+
110
+ result = env.step(action)
111
+ reward = float(result.reward)
112
+ done = bool(result.done)
113
+ steps = 1
114
+ rewards.append(reward)
115
+ score = reward
116
+ success = score >= SUCCESS_THRESHOLD
117
+
118
+ print(
119
+ f"[STEP] step=1 action={action_str} reward={reward:.2f} "
120
+ f"done={'true' if done else 'false'} error={error_msg}",
121
+ flush=True,
122
+ )
123
+ all_scores.append(score)
124
+
125
+ except Exception as exc:
126
+ error_msg = str(exc).replace("\n", " ")
127
+ print(
128
+ f"[STEP] step=1 action={action_str} reward=0.00 done=true "
129
+ f"error={error_msg}",
130
+ file=sys.stderr,
131
+ flush=True,
132
+ )
133
+ finally:
134
+ rewards_str = ",".join(f"{r:.2f}" for r in rewards) if rewards else "0.00"
135
+ print(
136
+ f"[END] success={'true' if success else 'false'} steps={steps} "
137
+ f"score={score:.2f} rewards={rewards_str}",
138
+ flush=True,
139
+ )
140
+
141
+ avg = round(sum(all_scores) / len(all_scores), 2) if all_scores else 0.0
142
+ print(f"\n=== Average Score: {avg:.2f} ===", flush=True)
143
+ return avg
144
+
145
+
146
+ if __name__ == "__main__":
147
+ run_inference()
openenv.yaml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ spec_version: 1
2
+ name: policy-drift
3
+ type: space
4
+ runtime: fastapi
5
+ app: server.app:app
6
+ port: 7860
outputs/baseline_direction_split.png ADDED
outputs/cross_model_baseline.png ADDED
outputs/direction_split.png ADDED
outputs/direction_split_v6.png ADDED
outputs/evals_v7.json ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "pre": {
3
+ "compliance": 0.5275000000000006,
4
+ "appropriateness": 0.41,
5
+ "drift_bonus": 0.005,
6
+ "drift_acc": 0.11764705882352941,
7
+ "drift_acc_by_direction": {
8
+ "tightening": 0.0,
9
+ "loosening": 0.21428571428571427,
10
+ "neutral": null
11
+ },
12
+ "drift_counts_by_direction": {
13
+ "tightening": {
14
+ "correct": 0,
15
+ "total": 23
16
+ },
17
+ "loosening": {
18
+ "correct": 3,
19
+ "total": 14
20
+ },
21
+ "neutral": {
22
+ "correct": 0,
23
+ "total": 0
24
+ }
25
+ }
26
+ },
27
+ "post_sft": {
28
+ "compliance": 0.9679999999999999,
29
+ "appropriateness": 0.45,
30
+ "drift_bonus": 0.0375,
31
+ "drift_acc": 0.8823529411764706,
32
+ "drift_acc_by_direction": {
33
+ "tightening": 0.9130434782608695,
34
+ "loosening": 0.7142857142857143,
35
+ "neutral": null
36
+ },
37
+ "drift_counts_by_direction": {
38
+ "tightening": {
39
+ "correct": 21,
40
+ "total": 23
41
+ },
42
+ "loosening": {
43
+ "correct": 10,
44
+ "total": 14
45
+ },
46
+ "neutral": {
47
+ "correct": 0,
48
+ "total": 0
49
+ }
50
+ }
51
+ },
52
+ "post_grpo": {
53
+ "compliance": 0.9679999999999999,
54
+ "appropriateness": 0.45,
55
+ "drift_bonus": 0.0375,
56
+ "drift_acc": 0.8823529411764706,
57
+ "drift_acc_by_direction": {
58
+ "tightening": 0.9130434782608695,
59
+ "loosening": 0.7142857142857143,
60
+ "neutral": null
61
+ },
62
+ "drift_counts_by_direction": {
63
+ "tightening": {
64
+ "correct": 21,
65
+ "total": 23
66
+ },
67
+ "loosening": {
68
+ "correct": 10,
69
+ "total": 14
70
+ },
71
+ "neutral": {
72
+ "correct": 0,
73
+ "total": 0
74
+ }
75
+ }
76
+ },
77
+ "model_name": "unsloth/Qwen2.5-3B-Instruct",
78
+ "quick_mode": false
79
+ }
outputs/sft_log_v7.json ADDED
@@ -0,0 +1,711 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "loss": 1.293243408203125,
4
+ "grad_norm": 2.695000171661377,
5
+ "learning_rate": 1.8e-05,
6
+ "epoch": 0.01,
7
+ "step": 10
8
+ },
9
+ {
10
+ "loss": 0.9491410255432129,
11
+ "grad_norm": 4.51662540435791,
12
+ "learning_rate": 3.8e-05,
13
+ "epoch": 0.02,
14
+ "step": 20
15
+ },
16
+ {
17
+ "loss": 0.13204352855682372,
18
+ "grad_norm": 0.3303098678588867,
19
+ "learning_rate": 5.8e-05,
20
+ "epoch": 0.03,
21
+ "step": 30
22
+ },
23
+ {
24
+ "loss": 0.07011271715164184,
25
+ "grad_norm": 0.28593316674232483,
26
+ "learning_rate": 7.800000000000001e-05,
27
+ "epoch": 0.04,
28
+ "step": 40
29
+ },
30
+ {
31
+ "loss": 0.02982504665851593,
32
+ "grad_norm": 0.1960303634405136,
33
+ "learning_rate": 9.8e-05,
34
+ "epoch": 0.05,
35
+ "step": 50
36
+ },
37
+ {
38
+ "loss": 0.030540597438812257,
39
+ "grad_norm": 0.361972838640213,
40
+ "learning_rate": 0.000118,
41
+ "epoch": 0.06,
42
+ "step": 60
43
+ },
44
+ {
45
+ "loss": 0.020638951659202577,
46
+ "grad_norm": 0.14442680776119232,
47
+ "learning_rate": 0.000138,
48
+ "epoch": 0.07,
49
+ "step": 70
50
+ },
51
+ {
52
+ "loss": 0.019276422262191773,
53
+ "grad_norm": 0.1991211175918579,
54
+ "learning_rate": 0.00015800000000000002,
55
+ "epoch": 0.08,
56
+ "step": 80
57
+ },
58
+ {
59
+ "loss": 0.019253556430339814,
60
+ "grad_norm": 0.2892937660217285,
61
+ "learning_rate": 0.00017800000000000002,
62
+ "epoch": 0.09,
63
+ "step": 90
64
+ },
65
+ {
66
+ "loss": 0.020958416163921356,
67
+ "grad_norm": 0.14148078858852386,
68
+ "learning_rate": 0.00019800000000000002,
69
+ "epoch": 0.1,
70
+ "step": 100
71
+ },
72
+ {
73
+ "loss": 0.02394942194223404,
74
+ "grad_norm": 0.23137691617012024,
75
+ "learning_rate": 0.00019800000000000002,
76
+ "epoch": 0.11,
77
+ "step": 110
78
+ },
79
+ {
80
+ "loss": 0.02662351429462433,
81
+ "grad_norm": 0.12059047073125839,
82
+ "learning_rate": 0.0001957777777777778,
83
+ "epoch": 0.12,
84
+ "step": 120
85
+ },
86
+ {
87
+ "loss": 0.020230546593666077,
88
+ "grad_norm": 0.13224035501480103,
89
+ "learning_rate": 0.00019355555555555557,
90
+ "epoch": 0.13,
91
+ "step": 130
92
+ },
93
+ {
94
+ "loss": 0.014973150193691253,
95
+ "grad_norm": 0.14604584872722626,
96
+ "learning_rate": 0.00019133333333333334,
97
+ "epoch": 0.14,
98
+ "step": 140
99
+ },
100
+ {
101
+ "loss": 0.013179084658622742,
102
+ "grad_norm": 0.03541504964232445,
103
+ "learning_rate": 0.00018911111111111112,
104
+ "epoch": 0.15,
105
+ "step": 150
106
+ },
107
+ {
108
+ "loss": 0.017047987878322603,
109
+ "grad_norm": 0.14085718989372253,
110
+ "learning_rate": 0.0001868888888888889,
111
+ "epoch": 0.16,
112
+ "step": 160
113
+ },
114
+ {
115
+ "loss": 0.013664944469928742,
116
+ "grad_norm": 0.10603413730859756,
117
+ "learning_rate": 0.00018466666666666666,
118
+ "epoch": 0.17,
119
+ "step": 170
120
+ },
121
+ {
122
+ "loss": 0.014989382028579712,
123
+ "grad_norm": 0.0628352016210556,
124
+ "learning_rate": 0.00018244444444444447,
125
+ "epoch": 0.18,
126
+ "step": 180
127
+ },
128
+ {
129
+ "loss": 0.01449367105960846,
130
+ "grad_norm": 0.06855176389217377,
131
+ "learning_rate": 0.00018022222222222221,
132
+ "epoch": 0.19,
133
+ "step": 190
134
+ },
135
+ {
136
+ "loss": 0.015271393954753876,
137
+ "grad_norm": 0.0931730642914772,
138
+ "learning_rate": 0.00017800000000000002,
139
+ "epoch": 0.2,
140
+ "step": 200
141
+ },
142
+ {
143
+ "loss": 0.021130312979221345,
144
+ "grad_norm": 0.10431171208620071,
145
+ "learning_rate": 0.0001757777777777778,
146
+ "epoch": 0.21,
147
+ "step": 210
148
+ },
149
+ {
150
+ "loss": 0.013795286417007446,
151
+ "grad_norm": 0.0743161141872406,
152
+ "learning_rate": 0.00017355555555555557,
153
+ "epoch": 0.22,
154
+ "step": 220
155
+ },
156
+ {
157
+ "loss": 0.013209818303585053,
158
+ "grad_norm": 0.03357032686471939,
159
+ "learning_rate": 0.00017133333333333334,
160
+ "epoch": 0.23,
161
+ "step": 230
162
+ },
163
+ {
164
+ "loss": 0.011038484424352646,
165
+ "grad_norm": 0.021826738491654396,
166
+ "learning_rate": 0.00016911111111111112,
167
+ "epoch": 0.24,
168
+ "step": 240
169
+ },
170
+ {
171
+ "loss": 0.013412389159202575,
172
+ "grad_norm": 0.08149590343236923,
173
+ "learning_rate": 0.0001668888888888889,
174
+ "epoch": 0.25,
175
+ "step": 250
176
+ },
177
+ {
178
+ "loss": 0.016977037489414214,
179
+ "grad_norm": 0.0708199143409729,
180
+ "learning_rate": 0.00016466666666666667,
181
+ "epoch": 0.26,
182
+ "step": 260
183
+ },
184
+ {
185
+ "loss": 0.014706389605998993,
186
+ "grad_norm": 0.07086025923490524,
187
+ "learning_rate": 0.00016244444444444444,
188
+ "epoch": 0.27,
189
+ "step": 270
190
+ },
191
+ {
192
+ "loss": 0.009542294591665269,
193
+ "grad_norm": 0.10200642049312592,
194
+ "learning_rate": 0.00016022222222222222,
195
+ "epoch": 0.28,
196
+ "step": 280
197
+ },
198
+ {
199
+ "loss": 0.011282695829868317,
200
+ "grad_norm": 0.12065727263689041,
201
+ "learning_rate": 0.00015800000000000002,
202
+ "epoch": 0.29,
203
+ "step": 290
204
+ },
205
+ {
206
+ "loss": 0.0091792993247509,
207
+ "grad_norm": 0.056022465229034424,
208
+ "learning_rate": 0.00015577777777777777,
209
+ "epoch": 0.3,
210
+ "step": 300
211
+ },
212
+ {
213
+ "loss": 0.010022152960300446,
214
+ "grad_norm": 0.027627091854810715,
215
+ "learning_rate": 0.00015355555555555557,
216
+ "epoch": 0.31,
217
+ "step": 310
218
+ },
219
+ {
220
+ "loss": 0.010018721222877502,
221
+ "grad_norm": 0.04348656162619591,
222
+ "learning_rate": 0.00015133333333333334,
223
+ "epoch": 0.32,
224
+ "step": 320
225
+ },
226
+ {
227
+ "loss": 0.015866005420684816,
228
+ "grad_norm": 0.06167244166135788,
229
+ "learning_rate": 0.00014911111111111112,
230
+ "epoch": 0.33,
231
+ "step": 330
232
+ },
233
+ {
234
+ "loss": 0.009907713532447815,
235
+ "grad_norm": 0.043530818074941635,
236
+ "learning_rate": 0.0001468888888888889,
237
+ "epoch": 0.34,
238
+ "step": 340
239
+ },
240
+ {
241
+ "loss": 0.011248402297496796,
242
+ "grad_norm": 0.03280569612979889,
243
+ "learning_rate": 0.0001446666666666667,
244
+ "epoch": 0.35,
245
+ "step": 350
246
+ },
247
+ {
248
+ "loss": 0.01353382021188736,
249
+ "grad_norm": 0.04305155202746391,
250
+ "learning_rate": 0.00014244444444444444,
251
+ "epoch": 0.36,
252
+ "step": 360
253
+ },
254
+ {
255
+ "loss": 0.012516531348228454,
256
+ "grad_norm": 0.04698939993977547,
257
+ "learning_rate": 0.00014022222222222225,
258
+ "epoch": 0.37,
259
+ "step": 370
260
+ },
261
+ {
262
+ "loss": 0.010274696350097656,
263
+ "grad_norm": 0.1363413780927658,
264
+ "learning_rate": 0.000138,
265
+ "epoch": 0.38,
266
+ "step": 380
267
+ },
268
+ {
269
+ "loss": 0.010345887392759323,
270
+ "grad_norm": 0.06110126152634621,
271
+ "learning_rate": 0.00013577777777777777,
272
+ "epoch": 0.39,
273
+ "step": 390
274
+ },
275
+ {
276
+ "loss": 0.01669677793979645,
277
+ "grad_norm": 0.0443761982023716,
278
+ "learning_rate": 0.00013355555555555557,
279
+ "epoch": 0.4,
280
+ "step": 400
281
+ },
282
+ {
283
+ "loss": 0.010670651495456696,
284
+ "grad_norm": 0.046872515231370926,
285
+ "learning_rate": 0.00013133333333333332,
286
+ "epoch": 0.41,
287
+ "step": 410
288
+ },
289
+ {
290
+ "loss": 0.011344272643327713,
291
+ "grad_norm": 0.037704139947891235,
292
+ "learning_rate": 0.00012911111111111112,
293
+ "epoch": 0.42,
294
+ "step": 420
295
+ },
296
+ {
297
+ "loss": 0.010733240097761155,
298
+ "grad_norm": 0.03925054147839546,
299
+ "learning_rate": 0.0001268888888888889,
300
+ "epoch": 0.43,
301
+ "step": 430
302
+ },
303
+ {
304
+ "loss": 0.012961900234222412,
305
+ "grad_norm": 0.07470105588436127,
306
+ "learning_rate": 0.00012466666666666667,
307
+ "epoch": 0.44,
308
+ "step": 440
309
+ },
310
+ {
311
+ "loss": 0.012575212121009826,
312
+ "grad_norm": 0.01742836833000183,
313
+ "learning_rate": 0.00012244444444444445,
314
+ "epoch": 0.45,
315
+ "step": 450
316
+ },
317
+ {
318
+ "loss": 0.01014963984489441,
319
+ "grad_norm": 0.04182368889451027,
320
+ "learning_rate": 0.00012022222222222223,
321
+ "epoch": 0.46,
322
+ "step": 460
323
+ },
324
+ {
325
+ "loss": 0.012940752506256103,
326
+ "grad_norm": 0.14171363413333893,
327
+ "learning_rate": 0.000118,
328
+ "epoch": 0.47,
329
+ "step": 470
330
+ },
331
+ {
332
+ "loss": 0.011411245167255401,
333
+ "grad_norm": 0.0419924296438694,
334
+ "learning_rate": 0.00011577777777777778,
335
+ "epoch": 0.48,
336
+ "step": 480
337
+ },
338
+ {
339
+ "loss": 0.011432983726263047,
340
+ "grad_norm": 0.01165507361292839,
341
+ "learning_rate": 0.00011355555555555557,
342
+ "epoch": 0.49,
343
+ "step": 490
344
+ },
345
+ {
346
+ "loss": 0.014088569581508637,
347
+ "grad_norm": 0.03271564468741417,
348
+ "learning_rate": 0.00011133333333333333,
349
+ "epoch": 0.5,
350
+ "step": 500
351
+ },
352
+ {
353
+ "loss": 0.011598656326532364,
354
+ "grad_norm": 0.042704030871391296,
355
+ "learning_rate": 0.00010911111111111112,
356
+ "epoch": 0.51,
357
+ "step": 510
358
+ },
359
+ {
360
+ "loss": 0.007552731037139893,
361
+ "grad_norm": 0.05289052799344063,
362
+ "learning_rate": 0.00010688888888888891,
363
+ "epoch": 0.52,
364
+ "step": 520
365
+ },
366
+ {
367
+ "loss": 0.007692032307386398,
368
+ "grad_norm": 0.06659393757581711,
369
+ "learning_rate": 0.00010466666666666667,
370
+ "epoch": 0.53,
371
+ "step": 530
372
+ },
373
+ {
374
+ "loss": 0.014589102566242218,
375
+ "grad_norm": 0.10728471726179123,
376
+ "learning_rate": 0.00010244444444444446,
377
+ "epoch": 0.54,
378
+ "step": 540
379
+ },
380
+ {
381
+ "loss": 0.013890518248081208,
382
+ "grad_norm": 0.12965723872184753,
383
+ "learning_rate": 0.00010022222222222222,
384
+ "epoch": 0.55,
385
+ "step": 550
386
+ },
387
+ {
388
+ "loss": 0.010909823328256607,
389
+ "grad_norm": 0.04868817701935768,
390
+ "learning_rate": 9.8e-05,
391
+ "epoch": 0.56,
392
+ "step": 560
393
+ },
394
+ {
395
+ "loss": 0.010589510947465897,
396
+ "grad_norm": 0.06954590231180191,
397
+ "learning_rate": 9.577777777777777e-05,
398
+ "epoch": 0.57,
399
+ "step": 570
400
+ },
401
+ {
402
+ "loss": 0.006430118530988693,
403
+ "grad_norm": 0.03395122289657593,
404
+ "learning_rate": 9.355555555555556e-05,
405
+ "epoch": 0.58,
406
+ "step": 580
407
+ },
408
+ {
409
+ "loss": 0.009756166487932205,
410
+ "grad_norm": 0.10624136030673981,
411
+ "learning_rate": 9.133333333333334e-05,
412
+ "epoch": 0.59,
413
+ "step": 590
414
+ },
415
+ {
416
+ "loss": 0.009228541702032089,
417
+ "grad_norm": 0.03023010492324829,
418
+ "learning_rate": 8.911111111111111e-05,
419
+ "epoch": 0.6,
420
+ "step": 600
421
+ },
422
+ {
423
+ "loss": 0.011728598177433014,
424
+ "grad_norm": 0.03197360411286354,
425
+ "learning_rate": 8.68888888888889e-05,
426
+ "epoch": 0.61,
427
+ "step": 610
428
+ },
429
+ {
430
+ "loss": 0.008541644364595414,
431
+ "grad_norm": 0.046316880732774734,
432
+ "learning_rate": 8.466666666666667e-05,
433
+ "epoch": 0.62,
434
+ "step": 620
435
+ },
436
+ {
437
+ "loss": 0.0068620510399341585,
438
+ "grad_norm": 0.013749970123171806,
439
+ "learning_rate": 8.244444444444445e-05,
440
+ "epoch": 0.63,
441
+ "step": 630
442
+ },
443
+ {
444
+ "loss": 0.011553961038589477,
445
+ "grad_norm": 0.015892956405878067,
446
+ "learning_rate": 8.022222222222222e-05,
447
+ "epoch": 0.64,
448
+ "step": 640
449
+ },
450
+ {
451
+ "loss": 0.012946407496929168,
452
+ "grad_norm": 0.022329581901431084,
453
+ "learning_rate": 7.800000000000001e-05,
454
+ "epoch": 0.65,
455
+ "step": 650
456
+ },
457
+ {
458
+ "loss": 0.0086174339056015,
459
+ "grad_norm": 0.011772534810006618,
460
+ "learning_rate": 7.577777777777779e-05,
461
+ "epoch": 0.66,
462
+ "step": 660
463
+ },
464
+ {
465
+ "loss": 0.009604217112064361,
466
+ "grad_norm": 0.009181632660329342,
467
+ "learning_rate": 7.355555555555556e-05,
468
+ "epoch": 0.67,
469
+ "step": 670
470
+ },
471
+ {
472
+ "loss": 0.00848425179719925,
473
+ "grad_norm": 0.0981854572892189,
474
+ "learning_rate": 7.133333333333334e-05,
475
+ "epoch": 0.68,
476
+ "step": 680
477
+ },
478
+ {
479
+ "loss": 0.009772248566150665,
480
+ "grad_norm": 0.08253007382154465,
481
+ "learning_rate": 6.911111111111111e-05,
482
+ "epoch": 0.69,
483
+ "step": 690
484
+ },
485
+ {
486
+ "loss": 0.013774000108242035,
487
+ "grad_norm": 0.033140555024147034,
488
+ "learning_rate": 6.688888888888889e-05,
489
+ "epoch": 0.7,
490
+ "step": 700
491
+ },
492
+ {
493
+ "loss": 0.009199360758066178,
494
+ "grad_norm": 0.05315607041120529,
495
+ "learning_rate": 6.466666666666666e-05,
496
+ "epoch": 0.71,
497
+ "step": 710
498
+ },
499
+ {
500
+ "loss": 0.008602166920900345,
501
+ "grad_norm": 0.04834599792957306,
502
+ "learning_rate": 6.244444444444445e-05,
503
+ "epoch": 0.72,
504
+ "step": 720
505
+ },
506
+ {
507
+ "loss": 0.010712940990924836,
508
+ "grad_norm": 0.029719484969973564,
509
+ "learning_rate": 6.0222222222222225e-05,
510
+ "epoch": 0.73,
511
+ "step": 730
512
+ },
513
+ {
514
+ "loss": 0.012493643164634704,
515
+ "grad_norm": 0.11286692321300507,
516
+ "learning_rate": 5.8e-05,
517
+ "epoch": 0.74,
518
+ "step": 740
519
+ },
520
+ {
521
+ "loss": 0.013842716813087463,
522
+ "grad_norm": 0.08935993164777756,
523
+ "learning_rate": 5.577777777777778e-05,
524
+ "epoch": 0.75,
525
+ "step": 750
526
+ },
527
+ {
528
+ "loss": 0.017634442448616026,
529
+ "grad_norm": 0.08994075655937195,
530
+ "learning_rate": 5.355555555555556e-05,
531
+ "epoch": 0.76,
532
+ "step": 760
533
+ },
534
+ {
535
+ "loss": 0.009391002357006073,
536
+ "grad_norm": 0.044728901237249374,
537
+ "learning_rate": 5.133333333333333e-05,
538
+ "epoch": 0.77,
539
+ "step": 770
540
+ },
541
+ {
542
+ "loss": 0.011917909979820252,
543
+ "grad_norm": 0.04021375998854637,
544
+ "learning_rate": 4.9111111111111114e-05,
545
+ "epoch": 0.78,
546
+ "step": 780
547
+ },
548
+ {
549
+ "loss": 0.011780706793069839,
550
+ "grad_norm": 0.09859102964401245,
551
+ "learning_rate": 4.6888888888888895e-05,
552
+ "epoch": 0.79,
553
+ "step": 790
554
+ },
555
+ {
556
+ "loss": 0.0067037887871265415,
557
+ "grad_norm": 0.02168435789644718,
558
+ "learning_rate": 4.466666666666667e-05,
559
+ "epoch": 0.8,
560
+ "step": 800
561
+ },
562
+ {
563
+ "loss": 0.010100596398115159,
564
+ "grad_norm": 0.03414692357182503,
565
+ "learning_rate": 4.2444444444444445e-05,
566
+ "epoch": 0.81,
567
+ "step": 810
568
+ },
569
+ {
570
+ "loss": 0.011301630735397339,
571
+ "grad_norm": 0.11121732741594315,
572
+ "learning_rate": 4.022222222222222e-05,
573
+ "epoch": 0.82,
574
+ "step": 820
575
+ },
576
+ {
577
+ "loss": 0.014357231557369232,
578
+ "grad_norm": 0.05178866162896156,
579
+ "learning_rate": 3.8e-05,
580
+ "epoch": 0.83,
581
+ "step": 830
582
+ },
583
+ {
584
+ "loss": 0.005482625961303711,
585
+ "grad_norm": 0.025843730196356773,
586
+ "learning_rate": 3.577777777777778e-05,
587
+ "epoch": 0.84,
588
+ "step": 840
589
+ },
590
+ {
591
+ "loss": 0.008758357167243958,
592
+ "grad_norm": 0.01811465062201023,
593
+ "learning_rate": 3.355555555555556e-05,
594
+ "epoch": 0.85,
595
+ "step": 850
596
+ },
597
+ {
598
+ "loss": 0.013496673107147217,
599
+ "grad_norm": 0.07277406752109528,
600
+ "learning_rate": 3.1333333333333334e-05,
601
+ "epoch": 0.86,
602
+ "step": 860
603
+ },
604
+ {
605
+ "loss": 0.008429653942584991,
606
+ "grad_norm": 0.00885010790079832,
607
+ "learning_rate": 2.9111111111111112e-05,
608
+ "epoch": 0.87,
609
+ "step": 870
610
+ },
611
+ {
612
+ "loss": 0.007160598784685135,
613
+ "grad_norm": 0.06621793657541275,
614
+ "learning_rate": 2.688888888888889e-05,
615
+ "epoch": 0.88,
616
+ "step": 880
617
+ },
618
+ {
619
+ "loss": 0.009554611891508103,
620
+ "grad_norm": 0.03552632033824921,
621
+ "learning_rate": 2.466666666666667e-05,
622
+ "epoch": 0.89,
623
+ "step": 890
624
+ },
625
+ {
626
+ "loss": 0.012089543044567108,
627
+ "grad_norm": 0.1806514412164688,
628
+ "learning_rate": 2.2444444444444447e-05,
629
+ "epoch": 0.9,
630
+ "step": 900
631
+ },
632
+ {
633
+ "loss": 0.011534038186073303,
634
+ "grad_norm": 0.03898712247610092,
635
+ "learning_rate": 2.0222222222222222e-05,
636
+ "epoch": 0.91,
637
+ "step": 910
638
+ },
639
+ {
640
+ "loss": 0.011874783039093017,
641
+ "grad_norm": 0.158588245511055,
642
+ "learning_rate": 1.8e-05,
643
+ "epoch": 0.92,
644
+ "step": 920
645
+ },
646
+ {
647
+ "loss": 0.007286681234836579,
648
+ "grad_norm": 0.0023962175473570824,
649
+ "learning_rate": 1.577777777777778e-05,
650
+ "epoch": 0.93,
651
+ "step": 930
652
+ },
653
+ {
654
+ "loss": 0.011251521110534669,
655
+ "grad_norm": 0.11141131073236465,
656
+ "learning_rate": 1.3555555555555557e-05,
657
+ "epoch": 0.94,
658
+ "step": 940
659
+ },
660
+ {
661
+ "loss": 0.013448776304721832,
662
+ "grad_norm": 0.15390437841415405,
663
+ "learning_rate": 1.1333333333333334e-05,
664
+ "epoch": 0.95,
665
+ "step": 950
666
+ },
667
+ {
668
+ "loss": 0.016387876868247987,
669
+ "grad_norm": 0.09003526717424393,
670
+ "learning_rate": 9.111111111111112e-06,
671
+ "epoch": 0.96,
672
+ "step": 960
673
+ },
674
+ {
675
+ "loss": 0.008244573324918746,
676
+ "grad_norm": 0.019054576754570007,
677
+ "learning_rate": 6.888888888888889e-06,
678
+ "epoch": 0.97,
679
+ "step": 970
680
+ },
681
+ {
682
+ "loss": 0.006973282247781753,
683
+ "grad_norm": 0.1270657479763031,
684
+ "learning_rate": 4.666666666666667e-06,
685
+ "epoch": 0.98,
686
+ "step": 980
687
+ },
688
+ {
689
+ "loss": 0.007818463444709777,
690
+ "grad_norm": 0.1933678835630417,
691
+ "learning_rate": 2.4444444444444447e-06,
692
+ "epoch": 0.99,
693
+ "step": 990
694
+ },
695
+ {
696
+ "loss": 0.011809046566486358,
697
+ "grad_norm": 0.025083091109991074,
698
+ "learning_rate": 2.2222222222222224e-07,
699
+ "epoch": 1.0,
700
+ "step": 1000
701
+ },
702
+ {
703
+ "train_runtime": 5989.7828,
704
+ "train_samples_per_second": 2.671,
705
+ "train_steps_per_second": 0.167,
706
+ "total_flos": 2.065728538444923e+17,
707
+ "train_loss": 0.03656231210380793,
708
+ "epoch": 1.0,
709
+ "step": 1000
710
+ }
711
+ ]
outputs/sft_loss.png ADDED
outputs/sft_loss_v6.png ADDED
outputs/v6_full_logs.txt ADDED
@@ -0,0 +1,315 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ debconf: delaying package configuration, since apt-utils is not installed
2
+ Selecting previously unselected package less.
3
+ (Reading database ...
4
+ Preparing to unpack .../00-less_590-1ubuntu0.22.04.3_amd64.deb ...
5
+ Unpacking less (590-1ubuntu0.22.04.3) ...
6
+ Selecting previously unselected package libmd0:amd64.
7
+ Preparing to unpack .../01-libmd0_1.0.4-1build1_amd64.deb ...
8
+ Unpacking libmd0:amd64 (1.0.4-1build1) ...
9
+ Selecting previously unselected package libbsd0:amd64.
10
+ Preparing to unpack .../02-libbsd0_0.11.5-1_amd64.deb ...
11
+ Unpacking libbsd0:amd64 (0.11.5-1) ...
12
+ Selecting previously unselected package libexpat1:amd64.
13
+ Preparing to unpack .../03-libexpat1_2.4.7-1ubuntu0.7_amd64.deb ...
14
+ Unpacking libexpat1:amd64 (2.4.7-1ubuntu0.7) ...
15
+ Selecting previously unselected package libcbor0.8:amd64.
16
+ Preparing to unpack .../04-libcbor0.8_0.8.0-2ubuntu1_amd64.deb ...
17
+ Unpacking libcbor0.8:amd64 (0.8.0-2ubuntu1) ...
18
+ Selecting previously unselected package libedit2:amd64.
19
+ Preparing to unpack .../05-libedit2_3.1-20210910-1build1_amd64.deb ...
20
+ Unpacking libedit2:amd64 (3.1-20210910-1build1) ...
21
+ Selecting previously unselected package libfido2-1:amd64.
22
+ Preparing to unpack .../06-libfido2-1_1.10.0-1_amd64.deb ...
23
+ Unpacking libfido2-1:amd64 (1.10.0-1) ...
24
+ Selecting previously unselected package libnghttp2-14:amd64.
25
+ Preparing to unpack .../07-libnghttp2-14_1.43.0-1ubuntu0.2_amd64.deb ...
26
+ Unpacking libnghttp2-14:amd64 (1.43.0-1ubuntu0.2) ...
27
+ Selecting previously unselected package libpsl5:amd64.
28
+ Preparing to unpack .../08-libpsl5_0.21.0-1.2build2_amd64.deb ...
29
+ Unpacking libpsl5:amd64 (0.21.0-1.2build2) ...
30
+ Selecting previously unselected package libxau6:amd64.
31
+ Preparing to unpack .../09-libxau6_1%3a1.0.9-1build5_amd64.deb ...
32
+ Unpacking libxau6:amd64 (1:1.0.9-1build5) ...
33
+ Selecting previously unselected package libxdmcp6:amd64.
34
+ Preparing to unpack .../10-libxdmcp6_1%3a1.1.3-0ubuntu5_amd64.deb ...
35
+ Unpacking libxdmcp6:amd64 (1:1.1.3-0ubuntu5) ...
36
+ Selecting previously unselected package libxcb1:amd64.
37
+ Preparing to unpack .../11-libxcb1_1.14-3ubuntu3_amd64.deb ...
38
+ Unpacking libxcb1:amd64 (1.14-3ubuntu3) ...
39
+ Selecting previously unselected package libx11-data.
40
+ Preparing to unpack .../12-libx11-data_2%3a1.7.5-1ubuntu0.3_all.deb ...
41
+ Unpacking libx11-data (2:1.7.5-1ubuntu0.3) ...
42
+ Selecting previously unselected package libx11-6:amd64.
43
+ Preparing to unpack .../13-libx11-6_2%3a1.7.5-1ubuntu0.3_amd64.deb ...
44
+ Unpacking libx11-6:amd64 (2:1.7.5-1ubuntu0.3) ...
45
+ Selecting previously unselected package libxext6:amd64.
46
+ Preparing to unpack .../14-libxext6_2%3a1.3.4-1build1_amd64.deb ...
47
+ Unpacking libxext6:amd64 (2:1.3.4-1build1) ...
48
+ Selecting previously unselected package libxmuu1:amd64.
49
+ Preparing to unpack .../15-libxmuu1_2%3a1.1.3-3_amd64.deb ...
50
+ Unpacking libxmuu1:amd64 (2:1.1.3-3) ...
51
+ Selecting previously unselected package openssh-client.
52
+ Preparing to unpack .../16-openssh-client_1%3a8.9p1-3ubuntu0.14_amd64.deb ...
53
+ Unpacking openssh-client (1:8.9p1-3ubuntu0.14) ...
54
+ Selecting previously unselected package publicsuffix.
55
+ Preparing to unpack .../17-publicsuffix_20211207.1025-1_all.deb ...
56
+ Unpacking publicsuffix (20211207.1025-1) ...
57
+ Selecting previously unselected package xauth.
58
+ Preparing to unpack .../18-xauth_1%3a1.1-1build2_amd64.deb ...
59
+ Unpacking xauth (1:1.1-1build2) ...
60
+ Selecting previously unselected package libbrotli1:amd64.
61
+ Preparing to unpack .../19-libbrotli1_1.0.9-2build6_amd64.deb ...
62
+ Unpacking libbrotli1:amd64 (1.0.9-2build6) ...
63
+ Selecting previously unselected package librtmp1:amd64.
64
+ Preparing to unpack .../20-librtmp1_2.4+20151223.gitfa8646d.1-2build4_amd64.deb ...
65
+ Unpacking librtmp1:amd64 (2.4+20151223.gitfa8646d.1-2build4) ...
66
+ Selecting previously unselected package libssh-4:amd64.
67
+ Preparing to unpack .../21-libssh-4_0.9.6-2ubuntu0.22.04.7_amd64.deb ...
68
+ Unpacking libssh-4:amd64 (0.9.6-2ubuntu0.22.04.7) ...
69
+ Selecting previously unselected package libcurl3-gnutls:amd64.
70
+ Preparing to unpack .../22-libcurl3-gnutls_7.81.0-1ubuntu1.23_amd64.deb ...
71
+ Unpacking libcurl3-gnutls:amd64 (7.81.0-1ubuntu1.23) ...
72
+ Selecting previously unselected package liberror-perl.
73
+ Preparing to unpack .../23-liberror-perl_0.17029-1_all.deb ...
74
+ Unpacking liberror-perl (0.17029-1) ...
75
+ Selecting previously unselected package git-man.
76
+ Preparing to unpack .../24-git-man_1%3a2.34.1-1ubuntu1.17_all.deb ...
77
+ Unpacking git-man (1:2.34.1-1ubuntu1.17) ...
78
+ Selecting previously unselected package git.
79
+ Preparing to unpack .../25-git_1%3a2.34.1-1ubuntu1.17_amd64.deb ...
80
+ Unpacking git (1:2.34.1-1ubuntu1.17) ...
81
+ Setting up libexpat1:amd64 (2.4.7-1ubuntu0.7) ...
82
+ Setting up libxau6:amd64 (1:1.0.9-1build5) ...
83
+ Setting up libpsl5:amd64 (0.21.0-1.2build2) ...
84
+ Setting up libcbor0.8:amd64 (0.8.0-2ubuntu1) ...
85
+ Setting up libbrotli1:amd64 (1.0.9-2build6) ...
86
+ Setting up libnghttp2-14:amd64 (1.43.0-1ubuntu0.2) ...
87
+ Setting up less (590-1ubuntu0.22.04.3) ...
88
+ Setting up liberror-perl (0.17029-1) ...
89
+ Setting up libx11-data (2:1.7.5-1ubuntu0.3) ...
90
+ Setting up librtmp1:amd64 (2.4+20151223.gitfa8646d.1-2build4) ...
91
+ Setting up libssh-4:amd64 (0.9.6-2ubuntu0.22.04.7) ...
92
+ Setting up libmd0:amd64 (1.0.4-1build1) ...
93
+ Setting up git-man (1:2.34.1-1ubuntu1.17) ...
94
+ Setting up libfido2-1:amd64 (1.10.0-1) ...
95
+ Setting up libbsd0:amd64 (0.11.5-1) ...
96
+ Setting up publicsuffix (20211207.1025-1) ...
97
+ Setting up libxdmcp6:amd64 (1:1.1.3-0ubuntu5) ...
98
+ Setting up libxcb1:amd64 (1.14-3ubuntu3) ...
99
+ Setting up libedit2:amd64 (3.1-20210910-1build1) ...
100
+ Setting up libcurl3-gnutls:amd64 (7.81.0-1ubuntu1.23) ...
101
+ Setting up git (1:2.34.1-1ubuntu1.17) ...
102
+ Setting up libx11-6:amd64 (2:1.7.5-1ubuntu0.3) ...
103
+ Setting up libxmuu1:amd64 (2:1.1.3-3) ...
104
+ Setting up openssh-client (1:8.9p1-3ubuntu0.14) ...
105
+ update-alternatives: using /usr/bin/ssh to provide /usr/bin/rsh (rsh) in auto mode
106
+ update-alternatives: warning: skip creation of /usr/share/man/man1/rsh.1.gz because associated file /usr/share/man/man1/ssh.1.gz (of link group rsh) doesn't exist
107
+ update-alternatives: using /usr/bin/slogin to provide /usr/bin/rlogin (rlogin) in auto mode
108
+ update-alternatives: warning: skip creation of /usr/share/man/man1/rlogin.1.gz because associated file /usr/share/man/man1/slogin.1.gz (of link group rlogin) doesn't exist
109
+ update-alternatives: using /usr/bin/scp to provide /usr/bin/rcp (rcp) in auto mode
110
+ update-alternatives: warning: skip creation of /usr/share/man/man1/rcp.1.gz because associated file /usr/share/man/man1/scp.1.gz (of link group rcp) doesn't exist
111
+ Setting up libxext6:amd64 (2:1.3.4-1build1) ...
112
+ Setting up xauth (1:1.1-1build2) ...
113
+ Processing triggers for libc-bin (2.35-0ubuntu3.8) ...
114
+ Cloning into '/workspace/OpenEnv'...
115
+ WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager, possibly rendering your system unusable. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv. Use the --root-user-action option if you know what you are doing and want to suppress this warning.
116
+ WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager, possibly rendering your system unusable. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv. Use the --root-user-action option if you know what you are doing and want to suppress this warning.
117
+ WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager, possibly rendering your system unusable. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv. Use the --root-user-action option if you know what you are doing and want to suppress this warning.
118
+ WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager, possibly rendering your system unusable. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv. Use the --root-user-action option if you know what you are doing and want to suppress this warning.
119
+ QUICK_MODE=False MODEL=unsloth/Qwen2.5-3B-Instruct
120
+ GPU: NVIDIA A100-SXM4-80GB capability=(8, 0) bf16=True fp16=False
121
+ Train: {'n_rows': 16000, 'admin_rows': 1600, 'customer_rows': 14400, 'drift_sensitive_rows': 2240, 'bonus_eligible_rows': 1153, 'kinds_distribution': {'chitchat': 1467, 'refund': 6676, 'billing_q': 1439, 'admin': 1600, 'info_request': 1482, 'critical_incident': 3336}}
122
+ Eval: {'n_rows': 800, 'admin_rows': 80, 'customer_rows': 720, 'drift_sensitive_rows': 124, 'bonus_eligible_rows': 62, 'kinds_distribution': {'refund': 347, 'critical_incident': 168, 'admin': 80, 'billing_q': 88, 'chitchat': 62, 'info_request': 55}}
123
+ 🦥 Unsloth: Will patch your computer to enable 2x faster free finetuning.
124
+ Unsloth: Your Flash Attention 2 installation seems to be broken. Using Xformers instead. No performance changes will be seen.
125
+ 🦥 Unsloth Zoo will now patch everything to make training faster!
126
+ ==((====))== Unsloth 2026.4.8: Fast Qwen2 patching. Transformers: 5.5.0.
127
+ \\ /| NVIDIA A100-SXM4-80GB. Num GPUs = 1. Max memory: 79.25 GB. Platform: Linux.
128
+ O^O/ \_/ \ Torch: 2.8.0+cu128. CUDA: 8.0. CUDA Toolkit: 12.8. Triton: 3.4.0
129
+ \ / Bfloat16 = TRUE. FA [Xformers = None. FA2 = False]
130
+ "-____-" Free license: http://github.com/unslothai/unsloth
131
+ Unsloth: Fast downloading is enabled - ignore downloading bars which are red colored!
132
+
133
+
134
+
135
+
136
+
137
+
138
+
139
+
140
+
141
+
142
+
143
+
144
+
145
+
146
+
147
+
148
+
149
+
150
+
151
+
152
+
153
+ unsloth/qwen2.5-3b-instruct-unsloth-bnb-4bit does not have a padding token! Will use pad_token = <|PAD_TOKEN|>.
154
+
155
+ === Offline eval [pre-training] over 200 samples ===
156
+ compliance avg : 0.528 / 1.0
157
+ appropriateness avg: 0.410 / 0.5
158
+ drift_bonus avg : 0.005 / 0.5
159
+ total avg : 0.943 / 2.0
160
+ drift-sens acc : 11.8% (2/17)
161
+ tightening : 0.0% (0/23)
162
+ loosening : 21.4% (3/14)
163
+ neutral : n/a (0/0)
164
+
165
+
166
+
167
+
168
+
169
+
170
+
171
+
172
+
173
+
174
+
175
+
176
+
177
+
178
+
179
+
180
+
181
+
182
+
183
+
184
+
185
+
186
+
187
+ 🦥 Unsloth: Padding-free auto-enabled, enabling faster training.
188
+
189
+ === SFT warm-up: 16000 samples, 1 epoch(s) ===
190
+ Unsloth: Will smartly offload gradients to save VRAM!
191
+ {'loss': '1.293', 'grad_norm': '2.695', 'learning_rate': '1.8e-05', 'epoch': '0.01'}
192
+ {'loss': '0.9491', 'grad_norm': '4.517', 'learning_rate': '3.8e-05', 'epoch': '0.02'}
193
+ {'loss': '0.132', 'grad_norm': '0.3303', 'learning_rate': '5.8e-05', 'epoch': '0.03'}
194
+ {'loss': '0.07011', 'grad_norm': '0.2859', 'learning_rate': '7.8e-05', 'epoch': '0.04'}
195
+ {'loss': '0.02983', 'grad_norm': '0.196', 'learning_rate': '9.8e-05', 'epoch': '0.05'}
196
+ {'loss': '0.03054', 'grad_norm': '0.362', 'learning_rate': '0.000118', 'epoch': '0.06'}
197
+ {'loss': '0.02064', 'grad_norm': '0.1444', 'learning_rate': '0.000138', 'epoch': '0.07'}
198
+ {'loss': '0.01928', 'grad_norm': '0.1991', 'learning_rate': '0.000158', 'epoch': '0.08'}
199
+ {'loss': '0.01925', 'grad_norm': '0.2893', 'learning_rate': '0.000178', 'epoch': '0.09'}
200
+ {'loss': '0.02096', 'grad_norm': '0.1415', 'learning_rate': '0.000198', 'epoch': '0.1'}
201
+ {'loss': '0.02395', 'grad_norm': '0.2314', 'learning_rate': '0.000198', 'epoch': '0.11'}
202
+ {'loss': '0.02662', 'grad_norm': '0.1206', 'learning_rate': '0.0001958', 'epoch': '0.12'}
203
+ {'loss': '0.02023', 'grad_norm': '0.1322', 'learning_rate': '0.0001936', 'epoch': '0.13'}
204
+ {'loss': '0.01497', 'grad_norm': '0.146', 'learning_rate': '0.0001913', 'epoch': '0.14'}
205
+ {'loss': '0.01318', 'grad_norm': '0.03542', 'learning_rate': '0.0001891', 'epoch': '0.15'}
206
+ {'loss': '0.01705', 'grad_norm': '0.1409', 'learning_rate': '0.0001869', 'epoch': '0.16'}
207
+ {'loss': '0.01366', 'grad_norm': '0.106', 'learning_rate': '0.0001847', 'epoch': '0.17'}
208
+ {'loss': '0.01499', 'grad_norm': '0.06284', 'learning_rate': '0.0001824', 'epoch': '0.18'}
209
+ {'loss': '0.01449', 'grad_norm': '0.06855', 'learning_rate': '0.0001802', 'epoch': '0.19'}
210
+ {'loss': '0.01527', 'grad_norm': '0.09317', 'learning_rate': '0.000178', 'epoch': '0.2'}
211
+ {'loss': '0.02113', 'grad_norm': '0.1043', 'learning_rate': '0.0001758', 'epoch': '0.21'}
212
+ {'loss': '0.0138', 'grad_norm': '0.07432', 'learning_rate': '0.0001736', 'epoch': '0.22'}
213
+ {'loss': '0.01321', 'grad_norm': '0.03357', 'learning_rate': '0.0001713', 'epoch': '0.23'}
214
+ {'loss': '0.01104', 'grad_norm': '0.02183', 'learning_rate': '0.0001691', 'epoch': '0.24'}
215
+ {'loss': '0.01341', 'grad_norm': '0.0815', 'learning_rate': '0.0001669', 'epoch': '0.25'}
216
+ {'loss': '0.01698', 'grad_norm': '0.07082', 'learning_rate': '0.0001647', 'epoch': '0.26'}
217
+ {'loss': '0.01471', 'grad_norm': '0.07086', 'learning_rate': '0.0001624', 'epoch': '0.27'}
218
+ {'loss': '0.009542', 'grad_norm': '0.102', 'learning_rate': '0.0001602', 'epoch': '0.28'}
219
+ {'loss': '0.01128', 'grad_norm': '0.1207', 'learning_rate': '0.000158', 'epoch': '0.29'}
220
+ {'loss': '0.009179', 'grad_norm': '0.05602', 'learning_rate': '0.0001558', 'epoch': '0.3'}
221
+ {'loss': '0.01002', 'grad_norm': '0.02763', 'learning_rate': '0.0001536', 'epoch': '0.31'}
222
+ {'loss': '0.01002', 'grad_norm': '0.04349', 'learning_rate': '0.0001513', 'epoch': '0.32'}
223
+ {'loss': '0.01587', 'grad_norm': '0.06167', 'learning_rate': '0.0001491', 'epoch': '0.33'}
224
+ {'loss': '0.009908', 'grad_norm': '0.04353', 'learning_rate': '0.0001469', 'epoch': '0.34'}
225
+ {'loss': '0.01125', 'grad_norm': '0.03281', 'learning_rate': '0.0001447', 'epoch': '0.35'}
226
+ {'loss': '0.01353', 'grad_norm': '0.04305', 'learning_rate': '0.0001424', 'epoch': '0.36'}
227
+ {'loss': '0.01252', 'grad_norm': '0.04699', 'learning_rate': '0.0001402', 'epoch': '0.37'}
228
+ {'loss': '0.01027', 'grad_norm': '0.1363', 'learning_rate': '0.000138', 'epoch': '0.38'}
229
+ {'loss': '0.01035', 'grad_norm': '0.0611', 'learning_rate': '0.0001358', 'epoch': '0.39'}
230
+ {'loss': '0.0167', 'grad_norm': '0.04438', 'learning_rate': '0.0001336', 'epoch': '0.4'}
231
+ {'loss': '0.01067', 'grad_norm': '0.04687', 'learning_rate': '0.0001313', 'epoch': '0.41'}
232
+ {'loss': '0.01134', 'grad_norm': '0.0377', 'learning_rate': '0.0001291', 'epoch': '0.42'}
233
+ {'loss': '0.01073', 'grad_norm': '0.03925', 'learning_rate': '0.0001269', 'epoch': '0.43'}
234
+ {'loss': '0.01296', 'grad_norm': '0.0747', 'learning_rate': '0.0001247', 'epoch': '0.44'}
235
+ {'loss': '0.01258', 'grad_norm': '0.01743', 'learning_rate': '0.0001224', 'epoch': '0.45'}
236
+ {'loss': '0.01015', 'grad_norm': '0.04182', 'learning_rate': '0.0001202', 'epoch': '0.46'}
237
+ {'loss': '0.01294', 'grad_norm': '0.1417', 'learning_rate': '0.000118', 'epoch': '0.47'}
238
+ {'loss': '0.01141', 'grad_norm': '0.04199', 'learning_rate': '0.0001158', 'epoch': '0.48'}
239
+ {'loss': '0.01143', 'grad_norm': '0.01166', 'learning_rate': '0.0001136', 'epoch': '0.49'}
240
+ {'loss': '0.01409', 'grad_norm': '0.03272', 'learning_rate': '0.0001113', 'epoch': '0.5'}
241
+ {'loss': '0.0116', 'grad_norm': '0.0427', 'learning_rate': '0.0001091', 'epoch': '0.51'}
242
+ {'loss': '0.007553', 'grad_norm': '0.05289', 'learning_rate': '0.0001069', 'epoch': '0.52'}
243
+ {'loss': '0.007692', 'grad_norm': '0.06659', 'learning_rate': '0.0001047', 'epoch': '0.53'}
244
+ {'loss': '0.01459', 'grad_norm': '0.1073', 'learning_rate': '0.0001024', 'epoch': '0.54'}
245
+ {'loss': '0.01389', 'grad_norm': '0.1297', 'learning_rate': '0.0001002', 'epoch': '0.55'}
246
+ {'loss': '0.01091', 'grad_norm': '0.04869', 'learning_rate': '9.8e-05', 'epoch': '0.56'}
247
+ {'loss': '0.01059', 'grad_norm': '0.06955', 'learning_rate': '9.578e-05', 'epoch': '0.57'}
248
+ {'loss': '0.00643', 'grad_norm': '0.03395', 'learning_rate': '9.356e-05', 'epoch': '0.58'}
249
+ {'loss': '0.009756', 'grad_norm': '0.1062', 'learning_rate': '9.133e-05', 'epoch': '0.59'}
250
+ {'loss': '0.009229', 'grad_norm': '0.03023', 'learning_rate': '8.911e-05', 'epoch': '0.6'}
251
+ {'loss': '0.01173', 'grad_norm': '0.03197', 'learning_rate': '8.689e-05', 'epoch': '0.61'}
252
+ {'loss': '0.008542', 'grad_norm': '0.04632', 'learning_rate': '8.467e-05', 'epoch': '0.62'}
253
+ {'loss': '0.006862', 'grad_norm': '0.01375', 'learning_rate': '8.244e-05', 'epoch': '0.63'}
254
+ {'loss': '0.01155', 'grad_norm': '0.01589', 'learning_rate': '8.022e-05', 'epoch': '0.64'}
255
+ {'loss': '0.01295', 'grad_norm': '0.02233', 'learning_rate': '7.8e-05', 'epoch': '0.65'}
256
+ {'loss': '0.008617', 'grad_norm': '0.01177', 'learning_rate': '7.578e-05', 'epoch': '0.66'}
257
+ {'loss': '0.009604', 'grad_norm': '0.009182', 'learning_rate': '7.356e-05', 'epoch': '0.67'}
258
+ {'loss': '0.008484', 'grad_norm': '0.09819', 'learning_rate': '7.133e-05', 'epoch': '0.68'}
259
+ {'loss': '0.009772', 'grad_norm': '0.08253', 'learning_rate': '6.911e-05', 'epoch': '0.69'}
260
+ {'loss': '0.01377', 'grad_norm': '0.03314', 'learning_rate': '6.689e-05', 'epoch': '0.7'}
261
+ {'loss': '0.009199', 'grad_norm': '0.05316', 'learning_rate': '6.467e-05', 'epoch': '0.71'}
262
+ {'loss': '0.008602', 'grad_norm': '0.04835', 'learning_rate': '6.244e-05', 'epoch': '0.72'}
263
+ {'loss': '0.01071', 'grad_norm': '0.02972', 'learning_rate': '6.022e-05', 'epoch': '0.73'}
264
+ {'loss': '0.01249', 'grad_norm': '0.1129', 'learning_rate': '5.8e-05', 'epoch': '0.74'}
265
+ {'loss': '0.01384', 'grad_norm': '0.08936', 'learning_rate': '5.578e-05', 'epoch': '0.75'}
266
+ {'loss': '0.01763', 'grad_norm': '0.08994', 'learning_rate': '5.356e-05', 'epoch': '0.76'}
267
+ {'loss': '0.009391', 'grad_norm': '0.04473', 'learning_rate': '5.133e-05', 'epoch': '0.77'}
268
+ {'loss': '0.01192', 'grad_norm': '0.04021', 'learning_rate': '4.911e-05', 'epoch': '0.78'}
269
+ {'loss': '0.01178', 'grad_norm': '0.09859', 'learning_rate': '4.689e-05', 'epoch': '0.79'}
270
+ {'loss': '0.006704', 'grad_norm': '0.02168', 'learning_rate': '4.467e-05', 'epoch': '0.8'}
271
+ {'loss': '0.0101', 'grad_norm': '0.03415', 'learning_rate': '4.244e-05', 'epoch': '0.81'}
272
+ {'loss': '0.0113', 'grad_norm': '0.1112', 'learning_rate': '4.022e-05', 'epoch': '0.82'}
273
+ {'loss': '0.01436', 'grad_norm': '0.05179', 'learning_rate': '3.8e-05', 'epoch': '0.83'}
274
+ {'loss': '0.005483', 'grad_norm': '0.02584', 'learning_rate': '3.578e-05', 'epoch': '0.84'}
275
+ {'loss': '0.008758', 'grad_norm': '0.01811', 'learning_rate': '3.356e-05', 'epoch': '0.85'}
276
+ {'loss': '0.0135', 'grad_norm': '0.07277', 'learning_rate': '3.133e-05', 'epoch': '0.86'}
277
+ {'loss': '0.00843', 'grad_norm': '0.00885', 'learning_rate': '2.911e-05', 'epoch': '0.87'}
278
+ {'loss': '0.007161', 'grad_norm': '0.06622', 'learning_rate': '2.689e-05', 'epoch': '0.88'}
279
+ {'loss': '0.009555', 'grad_norm': '0.03553', 'learning_rate': '2.467e-05', 'epoch': '0.89'}
280
+ {'loss': '0.01209', 'grad_norm': '0.1807', 'learning_rate': '2.244e-05', 'epoch': '0.9'}
281
+ {'loss': '0.01153', 'grad_norm': '0.03899', 'learning_rate': '2.022e-05', 'epoch': '0.91'}
282
+ {'loss': '0.01187', 'grad_norm': '0.1586', 'learning_rate': '1.8e-05', 'epoch': '0.92'}
283
+ {'loss': '0.007287', 'grad_norm': '0.002396', 'learning_rate': '1.578e-05', 'epoch': '0.93'}
284
+ {'loss': '0.01125', 'grad_norm': '0.1114', 'learning_rate': '1.356e-05', 'epoch': '0.94'}
285
+ {'loss': '0.01345', 'grad_norm': '0.1539', 'learning_rate': '1.133e-05', 'epoch': '0.95'}
286
+ {'loss': '0.01639', 'grad_norm': '0.09004', 'learning_rate': '9.111e-06', 'epoch': '0.96'}
287
+ {'loss': '0.008245', 'grad_norm': '0.01905', 'learning_rate': '6.889e-06', 'epoch': '0.97'}
288
+ {'loss': '0.006973', 'grad_norm': '0.1271', 'learning_rate': '4.667e-06', 'epoch': '0.98'}
289
+ {'loss': '0.007818', 'grad_norm': '0.1934', 'learning_rate': '2.444e-06', 'epoch': '0.99'}
290
+ {'loss': '0.01181', 'grad_norm': '0.02508', 'learning_rate': '2.222e-07', 'epoch': '1'}
291
+ {'train_runtime': '5911', 'train_samples_per_second': '2.707', 'train_steps_per_second': '0.169', 'train_loss': '0.03656', 'epoch': '1'}
292
+ SFT log saved -> ./outputs/sft_log.json
293
+
294
+ === Offline eval [post-SFT] over 200 samples ===
295
+ compliance avg : 0.968 / 1.0
296
+ appropriateness avg: 0.450 / 0.5
297
+ drift_bonus avg : 0.037 / 0.5
298
+ total avg : 1.455 / 2.0
299
+ drift-sens acc : 88.2% (15/17)
300
+ tightening : 91.3% (21/23)
301
+ loosening : 71.4% (10/14)
302
+ neutral : n/a (0/0)
303
+ Traceback (most recent call last):
304
+ File "/workspace/OpenEnv/train.py", line 424, in <module>
305
+ sys.exit(main())
306
+ ^^^^^^
307
+ File "/workspace/OpenEnv/train.py", line 366, in main
308
+ model = run_grpo(model, tokenizer, grpo_train, grpo_eval)
309
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
310
+ File "/workspace/OpenEnv/train.py", line 206, in run_grpo
311
+ args = GRPOConfig(
312
+ ^^^^^^^^^^^
313
+ File "/workspace/OpenEnv/unsloth_compiled_cache/UnslothGRPOTrainer.py", line 1736, in __init__
314
+ super().__init__(
315
+ TypeError: GRPOConfig.__init__() got an unexpected keyword argument 'dataset_num_proc'
outputs/v7_full_logs.txt ADDED
@@ -0,0 +1,449 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0
  ...apters_sft/tokenizer.json: 100%|██████████| 11.4MB / 11.4MB 
 
 
1
  ...apters_sft/tokenizer.json: 100%|██████████| 11.4MB / 11.4MB 
 
 
 
2
  ...adapter_model.safetensors: 31%|███ | 37.4MB / 120MB 
 
 
3
  ...apters_sft/tokenizer.json: 100%|██████████| 11.4MB / 11.4MB 
 
 
 
4
  ...adapter_model.safetensors: 31%|███ | 37.4MB / 120MB 
 
 
5
  ...apters_sft/tokenizer.json: 100%|██████████| 11.4MB / 11.4MB 
 
 
 
6
  ...adapter_model.safetensors: 94%|█████████▍| 112MB / 120MB 
 
 
7
  ...apters_sft/tokenizer.json: 100%|██████████| 11.4MB / 11.4MB 
 
 
 
8
  ...adapter_model.safetensors: 99%|█████████▉| 119MB / 120MB 
 
 
9
  ...apters_sft/tokenizer.json: 100%|███████���██| 11.4MB / 11.4MB 
 
 
 
10
  ...adapter_model.safetensors: 99%|█████████▉| 119MB / 120MB 
 
 
 
11
  ...apters_sft/tokenizer.json: 100%|██████████| 11.4MB / 11.4MB 
 
 
 
12
  ...adapter_model.safetensors: 100%|█████████▉| 119MB / 120MB 
 
 
13
  ...apters_sft/tokenizer.json: 100%|██████████| 11.4MB / 11.4MB 
 
 
 
14
  ...adapter_model.safetensors: 100%|██████████| 120MB / 120MB 
 
 
15
  ...apters_sft/tokenizer.json: 100%|██████████| 11.4MB / 11.4MB 
 
 
 
16
  ...adapter_model.safetensors: 100%|██████████| 120MB / 120MB 
 
 
17
  ...apters_sft/tokenizer.json: 100%|██████████| 11.4MB / 11.4MB 
 
 
 
18
  ...adapter_model.safetensors: 100%|██████████| 120MB / 120MB 
 
 
19
  ...apters_sft/tokenizer.json: 100%|██████████| 11.4MB / 11.4MB
 
20
  ...adapter_model.safetensors: 100%|██████████| 120MB / 120MB
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  ...apters_sft/tokenizer.json: 100%|██████████| 11.4MB / 11.4MB 
 
 
 
22
  ...adapter_model.safetensors: 100%|██████████| 120MB / 120MB 
 
 
 
 
23
  ...adapter_model.safetensors: 100%|██████████| 120MB / 120MB 
 
 
 
 
 
24
  ...a_adapters/tokenizer.json: 100%|██████████| 11.4MB / 11.4MB 
 
 
25
  ...apters_sft/tokenizer.json: 100%|██████████| 11.4MB / 11.4MB 
 
 
 
26
  ...adapter_model.safetensors: 100%|██████████| 120MB / 120MB 
 
 
 
 
27
  ...adapter_model.safetensors: 100%|██████████| 120MB / 120MB 
 
 
 
 
 
28
  ...a_adapters/tokenizer.json: 100%|██████████| 11.4MB / 11.4MB 
 
 
29
  ...apters_sft/tokenizer.json: 100%|██████████| 11.4MB / 11.4MB 
 
 
 
30
  ...adapter_model.safetensors: 100%|██████████| 120MB / 120MB 
 
 
 
 
31
  ...adapter_model.safetensors: 100%|██████████| 120MB / 120MB 
 
 
 
 
 
32
  ...a_adapters/tokenizer.json: 100%|██████████| 11.4MB / 11.4MB 
 
 
33
  ...apters_sft/tokenizer.json: 100%|██████████| 11.4MB / 11.4MB 
 
 
 
34
  ...adapter_model.safetensors: 100%|██████████| 120MB / 120MB 
 
 
 
 
35
  ...adapter_model.safetensors: 100%|██████████| 120MB / 120MB 
 
 
 
 
 
36
  ...a_adapters/tokenizer.json: 100%|██████████| 11.4MB / 11.4MB 
 
 
37
  ...apters_sft/tokenizer.json: 100%|██████████| 11.4MB / 11.4MB 
 
 
 
38
  ...adapter_model.safetensors: 100%|██████████| 120MB / 120MB 
 
 
 
 
39
  ...adapter_model.safetensors: 100%|██████████| 120MB / 120MB 
 
 
 
 
 
40
  ...a_adapters/tokenizer.json: 100%|██████████| 11.4MB / 11.4MB 
 
 
41
  ...apters_sft/tokenizer.json: 100%|██████████| 11.4MB / 11.4MB
 
42
  ...adapter_model.safetensors: 100%|██████████| 120MB / 120MB
 
43
  ...adapter_model.safetensors: 100%|██████████| 120MB / 120MB
 
44
  ...a_adapters/tokenizer.json: 100%|██████████| 11.4MB / 11.4MB
 
 
1
+ debconf: delaying package configuration, since apt-utils is not installed
2
+ Selecting previously unselected package less.
3
+ (Reading database ...
4
+ Preparing to unpack .../00-less_590-1ubuntu0.22.04.3_amd64.deb ...
5
+ Unpacking less (590-1ubuntu0.22.04.3) ...
6
+ Selecting previously unselected package libmd0:amd64.
7
+ Preparing to unpack .../01-libmd0_1.0.4-1build1_amd64.deb ...
8
+ Unpacking libmd0:amd64 (1.0.4-1build1) ...
9
+ Selecting previously unselected package libbsd0:amd64.
10
+ Preparing to unpack .../02-libbsd0_0.11.5-1_amd64.deb ...
11
+ Unpacking libbsd0:amd64 (0.11.5-1) ...
12
+ Selecting previously unselected package libexpat1:amd64.
13
+ Preparing to unpack .../03-libexpat1_2.4.7-1ubuntu0.7_amd64.deb ...
14
+ Unpacking libexpat1:amd64 (2.4.7-1ubuntu0.7) ...
15
+ Selecting previously unselected package libcbor0.8:amd64.
16
+ Preparing to unpack .../04-libcbor0.8_0.8.0-2ubuntu1_amd64.deb ...
17
+ Unpacking libcbor0.8:amd64 (0.8.0-2ubuntu1) ...
18
+ Selecting previously unselected package libedit2:amd64.
19
+ Preparing to unpack .../05-libedit2_3.1-20210910-1build1_amd64.deb ...
20
+ Unpacking libedit2:amd64 (3.1-20210910-1build1) ...
21
+ Selecting previously unselected package libfido2-1:amd64.
22
+ Preparing to unpack .../06-libfido2-1_1.10.0-1_amd64.deb ...
23
+ Unpacking libfido2-1:amd64 (1.10.0-1) ...
24
+ Selecting previously unselected package libnghttp2-14:amd64.
25
+ Preparing to unpack .../07-libnghttp2-14_1.43.0-1ubuntu0.2_amd64.deb ...
26
+ Unpacking libnghttp2-14:amd64 (1.43.0-1ubuntu0.2) ...
27
+ Selecting previously unselected package libpsl5:amd64.
28
+ Preparing to unpack .../08-libpsl5_0.21.0-1.2build2_amd64.deb ...
29
+ Unpacking libpsl5:amd64 (0.21.0-1.2build2) ...
30
+ Selecting previously unselected package libxau6:amd64.
31
+ Preparing to unpack .../09-libxau6_1%3a1.0.9-1build5_amd64.deb ...
32
+ Unpacking libxau6:amd64 (1:1.0.9-1build5) ...
33
+ Selecting previously unselected package libxdmcp6:amd64.
34
+ Preparing to unpack .../10-libxdmcp6_1%3a1.1.3-0ubuntu5_amd64.deb ...
35
+ Unpacking libxdmcp6:amd64 (1:1.1.3-0ubuntu5) ...
36
+ Selecting previously unselected package libxcb1:amd64.
37
+ Preparing to unpack .../11-libxcb1_1.14-3ubuntu3_amd64.deb ...
38
+ Unpacking libxcb1:amd64 (1.14-3ubuntu3) ...
39
+ Selecting previously unselected package libx11-data.
40
+ Preparing to unpack .../12-libx11-data_2%3a1.7.5-1ubuntu0.3_all.deb ...
41
+ Unpacking libx11-data (2:1.7.5-1ubuntu0.3) ...
42
+ Selecting previously unselected package libx11-6:amd64.
43
+ Preparing to unpack .../13-libx11-6_2%3a1.7.5-1ubuntu0.3_amd64.deb ...
44
+ Unpacking libx11-6:amd64 (2:1.7.5-1ubuntu0.3) ...
45
+ Selecting previously unselected package libxext6:amd64.
46
+ Preparing to unpack .../14-libxext6_2%3a1.3.4-1build1_amd64.deb ...
47
+ Unpacking libxext6:amd64 (2:1.3.4-1build1) ...
48
+ Selecting previously unselected package libxmuu1:amd64.
49
+ Preparing to unpack .../15-libxmuu1_2%3a1.1.3-3_amd64.deb ...
50
+ Unpacking libxmuu1:amd64 (2:1.1.3-3) ...
51
+ Selecting previously unselected package openssh-client.
52
+ Preparing to unpack .../16-openssh-client_1%3a8.9p1-3ubuntu0.14_amd64.deb ...
53
+ Unpacking openssh-client (1:8.9p1-3ubuntu0.14) ...
54
+ Selecting previously unselected package publicsuffix.
55
+ Preparing to unpack .../17-publicsuffix_20211207.1025-1_all.deb ...
56
+ Unpacking publicsuffix (20211207.1025-1) ...
57
+ Selecting previously unselected package xauth.
58
+ Preparing to unpack .../18-xauth_1%3a1.1-1build2_amd64.deb ...
59
+ Unpacking xauth (1:1.1-1build2) ...
60
+ Selecting previously unselected package libbrotli1:amd64.
61
+ Preparing to unpack .../19-libbrotli1_1.0.9-2build6_amd64.deb ...
62
+ Unpacking libbrotli1:amd64 (1.0.9-2build6) ...
63
+ Selecting previously unselected package librtmp1:amd64.
64
+ Preparing to unpack .../20-librtmp1_2.4+20151223.gitfa8646d.1-2build4_amd64.deb ...
65
+ Unpacking librtmp1:amd64 (2.4+20151223.gitfa8646d.1-2build4) ...
66
+ Selecting previously unselected package libssh-4:amd64.
67
+ Preparing to unpack .../21-libssh-4_0.9.6-2ubuntu0.22.04.7_amd64.deb ...
68
+ Unpacking libssh-4:amd64 (0.9.6-2ubuntu0.22.04.7) ...
69
+ Selecting previously unselected package libcurl3-gnutls:amd64.
70
+ Preparing to unpack .../22-libcurl3-gnutls_7.81.0-1ubuntu1.23_amd64.deb ...
71
+ Unpacking libcurl3-gnutls:amd64 (7.81.0-1ubuntu1.23) ...
72
+ Selecting previously unselected package liberror-perl.
73
+ Preparing to unpack .../23-liberror-perl_0.17029-1_all.deb ...
74
+ Unpacking liberror-perl (0.17029-1) ...
75
+ Selecting previously unselected package git-man.
76
+ Preparing to unpack .../24-git-man_1%3a2.34.1-1ubuntu1.17_all.deb ...
77
+ Unpacking git-man (1:2.34.1-1ubuntu1.17) ...
78
+ Selecting previously unselected package git.
79
+ Preparing to unpack .../25-git_1%3a2.34.1-1ubuntu1.17_amd64.deb ...
80
+ Unpacking git (1:2.34.1-1ubuntu1.17) ...
81
+ Setting up libexpat1:amd64 (2.4.7-1ubuntu0.7) ...
82
+ Setting up libxau6:amd64 (1:1.0.9-1build5) ...
83
+ Setting up libpsl5:amd64 (0.21.0-1.2build2) ...
84
+ Setting up libcbor0.8:amd64 (0.8.0-2ubuntu1) ...
85
+ Setting up libbrotli1:amd64 (1.0.9-2build6) ...
86
+ Setting up libnghttp2-14:amd64 (1.43.0-1ubuntu0.2) ...
87
+ Setting up less (590-1ubuntu0.22.04.3) ...
88
+ Setting up liberror-perl (0.17029-1) ...
89
+ Setting up libx11-data (2:1.7.5-1ubuntu0.3) ...
90
+ Setting up librtmp1:amd64 (2.4+20151223.gitfa8646d.1-2build4) ...
91
+ Setting up libssh-4:amd64 (0.9.6-2ubuntu0.22.04.7) ...
92
+ Setting up libmd0:amd64 (1.0.4-1build1) ...
93
+ Setting up git-man (1:2.34.1-1ubuntu1.17) ...
94
+ Setting up libfido2-1:amd64 (1.10.0-1) ...
95
+ Setting up libbsd0:amd64 (0.11.5-1) ...
96
+ Setting up publicsuffix (20211207.1025-1) ...
97
+ Setting up libxdmcp6:amd64 (1:1.1.3-0ubuntu5) ...
98
+ Setting up libxcb1:amd64 (1.14-3ubuntu3) ...
99
+ Setting up libedit2:amd64 (3.1-20210910-1build1) ...
100
+ Setting up libcurl3-gnutls:amd64 (7.81.0-1ubuntu1.23) ...
101
+ Setting up git (1:2.34.1-1ubuntu1.17) ...
102
+ Setting up libx11-6:amd64 (2:1.7.5-1ubuntu0.3) ...
103
+ Setting up libxmuu1:amd64 (2:1.1.3-3) ...
104
+ Setting up openssh-client (1:8.9p1-3ubuntu0.14) ...
105
+ update-alternatives: using /usr/bin/ssh to provide /usr/bin/rsh (rsh) in auto mode
106
+ update-alternatives: warning: skip creation of /usr/share/man/man1/rsh.1.gz because associated file /usr/share/man/man1/ssh.1.gz (of link group rsh) doesn't exist
107
+ update-alternatives: using /usr/bin/slogin to provide /usr/bin/rlogin (rlogin) in auto mode
108
+ update-alternatives: warning: skip creation of /usr/share/man/man1/rlogin.1.gz because associated file /usr/share/man/man1/slogin.1.gz (of link group rlogin) doesn't exist
109
+ update-alternatives: using /usr/bin/scp to provide /usr/bin/rcp (rcp) in auto mode
110
+ update-alternatives: warning: skip creation of /usr/share/man/man1/rcp.1.gz because associated file /usr/share/man/man1/scp.1.gz (of link group rcp) doesn't exist
111
+ Setting up libxext6:amd64 (2:1.3.4-1build1) ...
112
+ Setting up xauth (1:1.1-1build2) ...
113
+ Processing triggers for libc-bin (2.35-0ubuntu3.8) ...
114
+ Cloning into '/workspace/OpenEnv'...
115
+ WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager, possibly rendering your system unusable. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv. Use the --root-user-action option if you know what you are doing and want to suppress this warning.
116
+ WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager, possibly rendering your system unusable. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv. Use the --root-user-action option if you know what you are doing and want to suppress this warning.
117
+ WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager, possibly rendering your system unusable. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv. Use the --root-user-action option if you know what you are doing and want to suppress this warning.
118
+ WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager, possibly rendering your system unusable. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv. Use the --root-user-action option if you know what you are doing and want to suppress this warning.
119
+ QUICK_MODE=False MODEL=unsloth/Qwen2.5-3B-Instruct
120
+ GPU: NVIDIA A100-SXM4-80GB capability=(8, 0) bf16=True fp16=False
121
+ Train: {'n_rows': 16000, 'admin_rows': 1600, 'customer_rows': 14400, 'drift_sensitive_rows': 2240, 'bonus_eligible_rows': 1153, 'kinds_distribution': {'chitchat': 1467, 'refund': 6676, 'billing_q': 1439, 'admin': 1600, 'info_request': 1482, 'critical_incident': 3336}}
122
+ Eval: {'n_rows': 800, 'admin_rows': 80, 'customer_rows': 720, 'drift_sensitive_rows': 124, 'bonus_eligible_rows': 62, 'kinds_distribution': {'refund': 347, 'critical_incident': 168, 'admin': 80, 'billing_q': 88, 'chitchat': 62, 'info_request': 55}}
123
+ 🦥 Unsloth: Will patch your computer to enable 2x faster free finetuning.
124
+ Unsloth: Your Flash Attention 2 installation seems to be broken. Using Xformers instead. No performance changes will be seen.
125
+ 🦥 Unsloth Zoo will now patch everything to make training faster!
126
+ ==((====))== Unsloth 2026.4.8: Fast Qwen2 patching. Transformers: 5.5.0.
127
+ \\ /| NVIDIA A100-SXM4-80GB. Num GPUs = 1. Max memory: 79.25 GB. Platform: Linux.
128
+ O^O/ \_/ \ Torch: 2.8.0+cu128. CUDA: 8.0. CUDA Toolkit: 12.8. Triton: 3.4.0
129
+ \ / Bfloat16 = TRUE. FA [Xformers = None. FA2 = False]
130
+ "-____-" Free license: http://github.com/unslothai/unsloth
131
+ Unsloth: Fast downloading is enabled - ignore downloading bars which are red colored!
132
+
133
+
134
+
135
+
136
+
137
+
138
+
139
+
140
+
141
+
142
+
143
+
144
+
145
+
146
+
147
+
148
+
149
+
150
+
151
+
152
+ unsloth/qwen2.5-3b-instruct-unsloth-bnb-4bit does not have a padding token! Will use pad_token = <|PAD_TOKEN|>.
153
+
154
+ === Offline eval [pre-training] over 200 samples ===
155
+ compliance avg : 0.528 / 1.0
156
+ appropriateness avg: 0.410 / 0.5
157
+ drift_bonus avg : 0.005 / 0.5
158
+ total avg : 0.943 / 2.0
159
+ drift-sens acc : 11.8% (2/17)
160
+ tightening : 0.0% (0/23)
161
+ loosening : 21.4% (3/14)
162
+ neutral : n/a (0/0)
163
+
164
+
165
+
166
+
167
+
168
+
169
+
170
+
171
+
172
+
173
+
174
+
175
+
176
+
177
+
178
+
179
+
180
+
181
+
182
+
183
+ 🦥 Unsloth: Padding-free auto-enabled, enabling faster training.
184
+
185
+ === SFT warm-up: 16000 samples, 1 epoch(s) ===
186
+ Unsloth: Will smartly offload gradients to save VRAM!
187
+ {'loss': '1.293', 'grad_norm': '2.695', 'learning_rate': '1.8e-05', 'epoch': '0.01'}
188
+ {'loss': '0.9491', 'grad_norm': '4.517', 'learning_rate': '3.8e-05', 'epoch': '0.02'}
189
+ {'loss': '0.132', 'grad_norm': '0.3303', 'learning_rate': '5.8e-05', 'epoch': '0.03'}
190
+ {'loss': '0.07011', 'grad_norm': '0.2859', 'learning_rate': '7.8e-05', 'epoch': '0.04'}
191
+ {'loss': '0.02983', 'grad_norm': '0.196', 'learning_rate': '9.8e-05', 'epoch': '0.05'}
192
+ {'loss': '0.03054', 'grad_norm': '0.362', 'learning_rate': '0.000118', 'epoch': '0.06'}
193
+ {'loss': '0.02064', 'grad_norm': '0.1444', 'learning_rate': '0.000138', 'epoch': '0.07'}
194
+ {'loss': '0.01928', 'grad_norm': '0.1991', 'learning_rate': '0.000158', 'epoch': '0.08'}
195
+ {'loss': '0.01925', 'grad_norm': '0.2893', 'learning_rate': '0.000178', 'epoch': '0.09'}
196
+ {'loss': '0.02096', 'grad_norm': '0.1415', 'learning_rate': '0.000198', 'epoch': '0.1'}
197
+ {'loss': '0.02395', 'grad_norm': '0.2314', 'learning_rate': '0.000198', 'epoch': '0.11'}
198
+ {'loss': '0.02662', 'grad_norm': '0.1206', 'learning_rate': '0.0001958', 'epoch': '0.12'}
199
+ {'loss': '0.02023', 'grad_norm': '0.1322', 'learning_rate': '0.0001936', 'epoch': '0.13'}
200
+ {'loss': '0.01497', 'grad_norm': '0.146', 'learning_rate': '0.0001913', 'epoch': '0.14'}
201
+ {'loss': '0.01318', 'grad_norm': '0.03542', 'learning_rate': '0.0001891', 'epoch': '0.15'}
202
+ {'loss': '0.01705', 'grad_norm': '0.1409', 'learning_rate': '0.0001869', 'epoch': '0.16'}
203
+ {'loss': '0.01366', 'grad_norm': '0.106', 'learning_rate': '0.0001847', 'epoch': '0.17'}
204
+ {'loss': '0.01499', 'grad_norm': '0.06284', 'learning_rate': '0.0001824', 'epoch': '0.18'}
205
+ {'loss': '0.01449', 'grad_norm': '0.06855', 'learning_rate': '0.0001802', 'epoch': '0.19'}
206
+ {'loss': '0.01527', 'grad_norm': '0.09317', 'learning_rate': '0.000178', 'epoch': '0.2'}
207
+ {'loss': '0.02113', 'grad_norm': '0.1043', 'learning_rate': '0.0001758', 'epoch': '0.21'}
208
+ {'loss': '0.0138', 'grad_norm': '0.07432', 'learning_rate': '0.0001736', 'epoch': '0.22'}
209
+ {'loss': '0.01321', 'grad_norm': '0.03357', 'learning_rate': '0.0001713', 'epoch': '0.23'}
210
+ {'loss': '0.01104', 'grad_norm': '0.02183', 'learning_rate': '0.0001691', 'epoch': '0.24'}
211
+ {'loss': '0.01341', 'grad_norm': '0.0815', 'learning_rate': '0.0001669', 'epoch': '0.25'}
212
+ {'loss': '0.01698', 'grad_norm': '0.07082', 'learning_rate': '0.0001647', 'epoch': '0.26'}
213
+ {'loss': '0.01471', 'grad_norm': '0.07086', 'learning_rate': '0.0001624', 'epoch': '0.27'}
214
+ {'loss': '0.009542', 'grad_norm': '0.102', 'learning_rate': '0.0001602', 'epoch': '0.28'}
215
+ {'loss': '0.01128', 'grad_norm': '0.1207', 'learning_rate': '0.000158', 'epoch': '0.29'}
216
+ {'loss': '0.009179', 'grad_norm': '0.05602', 'learning_rate': '0.0001558', 'epoch': '0.3'}
217
+ {'loss': '0.01002', 'grad_norm': '0.02763', 'learning_rate': '0.0001536', 'epoch': '0.31'}
218
+ {'loss': '0.01002', 'grad_norm': '0.04349', 'learning_rate': '0.0001513', 'epoch': '0.32'}
219
+ {'loss': '0.01587', 'grad_norm': '0.06167', 'learning_rate': '0.0001491', 'epoch': '0.33'}
220
+ {'loss': '0.009908', 'grad_norm': '0.04353', 'learning_rate': '0.0001469', 'epoch': '0.34'}
221
+ {'loss': '0.01125', 'grad_norm': '0.03281', 'learning_rate': '0.0001447', 'epoch': '0.35'}
222
+ {'loss': '0.01353', 'grad_norm': '0.04305', 'learning_rate': '0.0001424', 'epoch': '0.36'}
223
+ {'loss': '0.01252', 'grad_norm': '0.04699', 'learning_rate': '0.0001402', 'epoch': '0.37'}
224
+ {'loss': '0.01027', 'grad_norm': '0.1363', 'learning_rate': '0.000138', 'epoch': '0.38'}
225
+ {'loss': '0.01035', 'grad_norm': '0.0611', 'learning_rate': '0.0001358', 'epoch': '0.39'}
226
+ {'loss': '0.0167', 'grad_norm': '0.04438', 'learning_rate': '0.0001336', 'epoch': '0.4'}
227
+ {'loss': '0.01067', 'grad_norm': '0.04687', 'learning_rate': '0.0001313', 'epoch': '0.41'}
228
+ {'loss': '0.01134', 'grad_norm': '0.0377', 'learning_rate': '0.0001291', 'epoch': '0.42'}
229
+ {'loss': '0.01073', 'grad_norm': '0.03925', 'learning_rate': '0.0001269', 'epoch': '0.43'}
230
+ {'loss': '0.01296', 'grad_norm': '0.0747', 'learning_rate': '0.0001247', 'epoch': '0.44'}
231
+ {'loss': '0.01258', 'grad_norm': '0.01743', 'learning_rate': '0.0001224', 'epoch': '0.45'}
232
+ {'loss': '0.01015', 'grad_norm': '0.04182', 'learning_rate': '0.0001202', 'epoch': '0.46'}
233
+ {'loss': '0.01294', 'grad_norm': '0.1417', 'learning_rate': '0.000118', 'epoch': '0.47'}
234
+ {'loss': '0.01141', 'grad_norm': '0.04199', 'learning_rate': '0.0001158', 'epoch': '0.48'}
235
+ {'loss': '0.01143', 'grad_norm': '0.01166', 'learning_rate': '0.0001136', 'epoch': '0.49'}
236
+ {'loss': '0.01409', 'grad_norm': '0.03272', 'learning_rate': '0.0001113', 'epoch': '0.5'}
237
+ {'loss': '0.0116', 'grad_norm': '0.0427', 'learning_rate': '0.0001091', 'epoch': '0.51'}
238
+ {'loss': '0.007553', 'grad_norm': '0.05289', 'learning_rate': '0.0001069', 'epoch': '0.52'}
239
+ {'loss': '0.007692', 'grad_norm': '0.06659', 'learning_rate': '0.0001047', 'epoch': '0.53'}
240
+ {'loss': '0.01459', 'grad_norm': '0.1073', 'learning_rate': '0.0001024', 'epoch': '0.54'}
241
+ {'loss': '0.01389', 'grad_norm': '0.1297', 'learning_rate': '0.0001002', 'epoch': '0.55'}
242
+ {'loss': '0.01091', 'grad_norm': '0.04869', 'learning_rate': '9.8e-05', 'epoch': '0.56'}
243
+ {'loss': '0.01059', 'grad_norm': '0.06955', 'learning_rate': '9.578e-05', 'epoch': '0.57'}
244
+ {'loss': '0.00643', 'grad_norm': '0.03395', 'learning_rate': '9.356e-05', 'epoch': '0.58'}
245
+ {'loss': '0.009756', 'grad_norm': '0.1062', 'learning_rate': '9.133e-05', 'epoch': '0.59'}
246
+ {'loss': '0.009229', 'grad_norm': '0.03023', 'learning_rate': '8.911e-05', 'epoch': '0.6'}
247
+ {'loss': '0.01173', 'grad_norm': '0.03197', 'learning_rate': '8.689e-05', 'epoch': '0.61'}
248
+ {'loss': '0.008542', 'grad_norm': '0.04632', 'learning_rate': '8.467e-05', 'epoch': '0.62'}
249
+ {'loss': '0.006862', 'grad_norm': '0.01375', 'learning_rate': '8.244e-05', 'epoch': '0.63'}
250
+ {'loss': '0.01155', 'grad_norm': '0.01589', 'learning_rate': '8.022e-05', 'epoch': '0.64'}
251
+ {'loss': '0.01295', 'grad_norm': '0.02233', 'learning_rate': '7.8e-05', 'epoch': '0.65'}
252
+ {'loss': '0.008617', 'grad_norm': '0.01177', 'learning_rate': '7.578e-05', 'epoch': '0.66'}
253
+ {'loss': '0.009604', 'grad_norm': '0.009182', 'learning_rate': '7.356e-05', 'epoch': '0.67'}
254
+ {'loss': '0.008484', 'grad_norm': '0.09819', 'learning_rate': '7.133e-05', 'epoch': '0.68'}
255
+ {'loss': '0.009772', 'grad_norm': '0.08253', 'learning_rate': '6.911e-05', 'epoch': '0.69'}
256
+ {'loss': '0.01377', 'grad_norm': '0.03314', 'learning_rate': '6.689e-05', 'epoch': '0.7'}
257
+ {'loss': '0.009199', 'grad_norm': '0.05316', 'learning_rate': '6.467e-05', 'epoch': '0.71'}
258
+ {'loss': '0.008602', 'grad_norm': '0.04835', 'learning_rate': '6.244e-05', 'epoch': '0.72'}
259
+ {'loss': '0.01071', 'grad_norm': '0.02972', 'learning_rate': '6.022e-05', 'epoch': '0.73'}
260
+ {'loss': '0.01249', 'grad_norm': '0.1129', 'learning_rate': '5.8e-05', 'epoch': '0.74'}
261
+ {'loss': '0.01384', 'grad_norm': '0.08936', 'learning_rate': '5.578e-05', 'epoch': '0.75'}
262
+ {'loss': '0.01763', 'grad_norm': '0.08994', 'learning_rate': '5.356e-05', 'epoch': '0.76'}
263
+ {'loss': '0.009391', 'grad_norm': '0.04473', 'learning_rate': '5.133e-05', 'epoch': '0.77'}
264
+ {'loss': '0.01192', 'grad_norm': '0.04021', 'learning_rate': '4.911e-05', 'epoch': '0.78'}
265
+ {'loss': '0.01178', 'grad_norm': '0.09859', 'learning_rate': '4.689e-05', 'epoch': '0.79'}
266
+ {'loss': '0.006704', 'grad_norm': '0.02168', 'learning_rate': '4.467e-05', 'epoch': '0.8'}
267
+ {'loss': '0.0101', 'grad_norm': '0.03415', 'learning_rate': '4.244e-05', 'epoch': '0.81'}
268
+ {'loss': '0.0113', 'grad_norm': '0.1112', 'learning_rate': '4.022e-05', 'epoch': '0.82'}
269
+ {'loss': '0.01436', 'grad_norm': '0.05179', 'learning_rate': '3.8e-05', 'epoch': '0.83'}
270
+ {'loss': '0.005483', 'grad_norm': '0.02584', 'learning_rate': '3.578e-05', 'epoch': '0.84'}
271
+ {'loss': '0.008758', 'grad_norm': '0.01811', 'learning_rate': '3.356e-05', 'epoch': '0.85'}
272
+ {'loss': '0.0135', 'grad_norm': '0.07277', 'learning_rate': '3.133e-05', 'epoch': '0.86'}
273
+ {'loss': '0.00843', 'grad_norm': '0.00885', 'learning_rate': '2.911e-05', 'epoch': '0.87'}
274
+ {'loss': '0.007161', 'grad_norm': '0.06622', 'learning_rate': '2.689e-05', 'epoch': '0.88'}
275
+ {'loss': '0.009555', 'grad_norm': '0.03553', 'learning_rate': '2.467e-05', 'epoch': '0.89'}
276
+ {'loss': '0.01209', 'grad_norm': '0.1807', 'learning_rate': '2.244e-05', 'epoch': '0.9'}
277
+ {'loss': '0.01153', 'grad_norm': '0.03899', 'learning_rate': '2.022e-05', 'epoch': '0.91'}
278
+ {'loss': '0.01187', 'grad_norm': '0.1586', 'learning_rate': '1.8e-05', 'epoch': '0.92'}
279
+ {'loss': '0.007287', 'grad_norm': '0.002396', 'learning_rate': '1.578e-05', 'epoch': '0.93'}
280
+ {'loss': '0.01125', 'grad_norm': '0.1114', 'learning_rate': '1.356e-05', 'epoch': '0.94'}
281
+ {'loss': '0.01345', 'grad_norm': '0.1539', 'learning_rate': '1.133e-05', 'epoch': '0.95'}
282
+ {'loss': '0.01639', 'grad_norm': '0.09004', 'learning_rate': '9.111e-06', 'epoch': '0.96'}
283
+ {'loss': '0.008245', 'grad_norm': '0.01905', 'learning_rate': '6.889e-06', 'epoch': '0.97'}
284
+ {'loss': '0.006973', 'grad_norm': '0.1271', 'learning_rate': '4.667e-06', 'epoch': '0.98'}
285
+ {'loss': '0.007818', 'grad_norm': '0.1934', 'learning_rate': '2.444e-06', 'epoch': '0.99'}
286
+ {'loss': '0.01181', 'grad_norm': '0.02508', 'learning_rate': '2.222e-07', 'epoch': '1'}
287
+ {'train_runtime': '5990', 'train_samples_per_second': '2.671', 'train_steps_per_second': '0.167', 'train_loss': '0.03656', 'epoch': '1'}
288
+ SFT log saved -> ./outputs/sft_log.json
289
+ SFT-only adapter checkpoint saved -> ./outputs/lora_adapters_sft
290
+
291
+
292
+
293
+
294
  ...apters_sft/tokenizer.json: 100%|██████████| 11.4MB / 11.4MB 
295
+
296
+
297
  ...apters_sft/tokenizer.json: 100%|██████████| 11.4MB / 11.4MB 
298
+
299
+
300
+
301
  ...adapter_model.safetensors: 31%|███ | 37.4MB / 120MB 
302
+
303
+
304
  ...apters_sft/tokenizer.json: 100%|██████████| 11.4MB / 11.4MB 
305
+
306
+
307
+
308
  ...adapter_model.safetensors: 31%|███ | 37.4MB / 120MB 
309
+
310
+
311
  ...apters_sft/tokenizer.json: 100%|██████████| 11.4MB / 11.4MB 
312
+
313
+
314
+
315
  ...adapter_model.safetensors: 94%|█████████▍| 112MB / 120MB 
316
+
317
+
318
  ...apters_sft/tokenizer.json: 100%|██████████| 11.4MB / 11.4MB 
319
+
320
+
321
+
322
  ...adapter_model.safetensors: 99%|█████████▉| 119MB / 120MB 
323
+
324
+
325
  ...apters_sft/tokenizer.json: 100%|███████���██| 11.4MB / 11.4MB 
326
+
327
+
328
+
329
  ...adapter_model.safetensors: 99%|█████████▉| 119MB / 120MB 
330
+
331
+
332
+
333
  ...apters_sft/tokenizer.json: 100%|██████████| 11.4MB / 11.4MB 
334
+
335
+
336
+
337
  ...adapter_model.safetensors: 100%|█████████▉| 119MB / 120MB 
338
+
339
+
340
  ...apters_sft/tokenizer.json: 100%|██████████| 11.4MB / 11.4MB 
341
+
342
+
343
+
344
  ...adapter_model.safetensors: 100%|██████████| 120MB / 120MB 
345
+
346
+
347
  ...apters_sft/tokenizer.json: 100%|██████████| 11.4MB / 11.4MB 
348
+
349
+
350
+
351
  ...adapter_model.safetensors: 100%|██████████| 120MB / 120MB 
352
+
353
+
354
  ...apters_sft/tokenizer.json: 100%|██████████| 11.4MB / 11.4MB 
355
+
356
+
357
+
358
  ...adapter_model.safetensors: 100%|██████████| 120MB / 120MB 
359
+
360
+
361
  ...apters_sft/tokenizer.json: 100%|██████████| 11.4MB / 11.4MB
362
+
363
  ...adapter_model.safetensors: 100%|██████████| 120MB / 120MB
364
+ [hub] post-SFT checkpoint pushed -> https://huggingface.co/shreyas-garg/leniencybench-qwen3b-outputs
365
+
366
+ === Offline eval [post-SFT] over 200 samples ===
367
+ compliance avg : 0.968 / 1.0
368
+ appropriateness avg: 0.450 / 0.5
369
+ drift_bonus avg : 0.037 / 0.5
370
+ total avg : 1.455 / 2.0
371
+ drift-sens acc : 88.2% (15/17)
372
+ tightening : 91.3% (21/23)
373
+ loosening : 71.4% (10/14)
374
+ neutral : n/a (0/0)
375
+
376
+ === GRPO: 16000 prompts, max_steps=600, K=8 ===
377
+ Unsloth: Will smartly offload gradients to save VRAM!
378
+
379
+ [grpo] FAILED: RuntimeError: self and mat2 must have the same dtype, but got Half and Float
380
+ [grpo] Continuing with post-SFT model as the final artifact.
381
+
382
+ === Improvement summary ===
383
+ compliance 0.528 -> 0.968 -> 0.968
384
+ appropriateness 0.410 -> 0.450 -> 0.450
385
+ drift_bonus 0.005 -> 0.037 -> 0.037
386
+ drift-sens acc (all) 11.8% -> 88.2% -> 88.2%
387
+ tightening 0.0% -> 91.3% -> 91.3%
388
+ loosening 21.4% -> 71.4% -> 71.4%
389
+ neutral n/a -> n/a -> n/a
390
+ Eval snapshots saved -> ./outputs/evals.json
391
+
392
+ LoRA adapters saved to ./outputs/lora_adapters
393
+
394
+
395
+
396
+
397
  ...apters_sft/tokenizer.json: 100%|██████████| 11.4MB / 11.4MB 
398
+
399
+
400
+
401
  ...adapter_model.safetensors: 100%|██████████| 120MB / 120MB 
402
+
403
+
404
+
405
+
406
  ...adapter_model.safetensors: 100%|██████████| 120MB / 120MB 
407
+
408
+
409
+
410
+
411
+
412
  ...a_adapters/tokenizer.json: 100%|██████████| 11.4MB / 11.4MB 
413
+
414
+
415
  ...apters_sft/tokenizer.json: 100%|██████████| 11.4MB / 11.4MB 
416
+
417
+
418
+
419
  ...adapter_model.safetensors: 100%|██████████| 120MB / 120MB 
420
+
421
+
422
+
423
+
424
  ...adapter_model.safetensors: 100%|██████████| 120MB / 120MB 
425
+
426
+
427
+
428
+
429
+
430
  ...a_adapters/tokenizer.json: 100%|██████████| 11.4MB / 11.4MB 
431
+
432
+
433
  ...apters_sft/tokenizer.json: 100%|██████████| 11.4MB / 11.4MB 
434
+
435
+
436
+
437
  ...adapter_model.safetensors: 100%|██████████| 120MB / 120MB 
438
+
439
+
440
+
441
+
442
  ...adapter_model.safetensors: 100%|██████████| 120MB / 120MB 
443
+
444
+
445
+
446
+
447
+
448
  ...a_adapters/tokenizer.json: 100%|██████████| 11.4MB / 11.4MB 
449
+
450
+
451
  ...apters_sft/tokenizer.json: 100%|██████████| 11.4MB / 11.4MB 
452
+
453
+
454
+
455
  ...adapter_model.safetensors: 100%|██████████| 120MB / 120MB 
456
+
457
+
458
+
459
+
460
  ...adapter_model.safetensors: 100%|██████████| 120MB / 120MB 
461
+
462
+
463
+
464
+
465
+
466
  ...a_adapters/tokenizer.json: 100%|██████████| 11.4MB / 11.4MB 
467
+
468
+
469
  ...apters_sft/tokenizer.json: 100%|██████████| 11.4MB / 11.4MB 
470
+
471
+
472
+
473
  ...adapter_model.safetensors: 100%|██████████| 120MB / 120MB 
474
+
475
+
476
+
477
+
478
  ...adapter_model.safetensors: 100%|██████████| 120MB / 120MB 
479
+
480
+
481
+
482
+
483
+
484
  ...a_adapters/tokenizer.json: 100%|██████████| 11.4MB / 11.4MB 
485
+
486
+
487
  ...apters_sft/tokenizer.json: 100%|██████████| 11.4MB / 11.4MB
488
+
489
  ...adapter_model.safetensors: 100%|██████████| 120MB / 120MB
490
+
491
  ...adapter_model.safetensors: 100%|██████████| 120MB / 120MB
492
+
493
  ...a_adapters/tokenizer.json: 100%|██████████| 11.4MB / 11.4MB
494
+ [hub] post-GRPO final pushed -> https://huggingface.co/shreyas-garg/leniencybench-qwen3b-outputs
plot_training.py ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Plot training curves from the logs `train.py` saved.
2
+
3
+ Usage:
4
+ python plot_training.py ./outputs
5
+
6
+ Expects these files (any subset — plotter skips what's missing):
7
+ outputs/sft_log.json <- trainer.state.log_history from SFT
8
+ outputs/grpo_log.json <- trainer.state.log_history from GRPO
9
+ outputs/evals.json <- {pre, post_sft, post_grpo} snapshots
10
+
11
+ Produces:
12
+ outputs/reward_curve.png <- GRPO reward + components over steps
13
+ outputs/sft_loss.png <- SFT loss curve
14
+ outputs/drift_acc_bars.png <- pre / post-SFT / post-GRPO drift-sensitive accuracy
15
+ outputs/summary.png <- combined 1x3 figure suitable for a pitch slide
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import json
22
+ import os
23
+ import sys
24
+ from typing import Optional
25
+
26
+ import matplotlib
27
+ matplotlib.use("Agg") # headless
28
+ import matplotlib.pyplot as plt
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # IO
33
+ # ---------------------------------------------------------------------------
34
+ def _load(path: str) -> Optional[object]:
35
+ if not os.path.isfile(path):
36
+ return None
37
+ with open(path) as f:
38
+ return json.load(f)
39
+
40
+
41
+ def _extract_series(log: list[dict], key: str) -> tuple[list[int], list[float]]:
42
+ """Pull a (step, value) time series from trainer.state.log_history."""
43
+ xs, ys = [], []
44
+ for entry in log:
45
+ if key not in entry or "step" not in entry:
46
+ continue
47
+ try:
48
+ ys.append(float(entry[key]))
49
+ xs.append(int(entry["step"]))
50
+ except (TypeError, ValueError):
51
+ continue
52
+ return xs, ys
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Plots
57
+ # ---------------------------------------------------------------------------
58
+ def plot_sft_loss(log: list[dict], out_path: str) -> None:
59
+ steps, losses = _extract_series(log, "loss")
60
+ if not steps:
61
+ print(f"[skip] no loss series in sft_log")
62
+ return
63
+ fig, ax = plt.subplots(figsize=(7, 4))
64
+ ax.plot(steps, losses, marker="o", markersize=3, linewidth=1.5, color="#2a6df4")
65
+ ax.set_xlabel("SFT step")
66
+ ax.set_ylabel("Loss")
67
+ ax.set_title("SFT warm-up — loss over training steps")
68
+ ax.grid(alpha=0.3)
69
+ fig.tight_layout()
70
+ fig.savefig(out_path, dpi=150)
71
+ plt.close(fig)
72
+ print(f"[ok] wrote {out_path}")
73
+
74
+
75
+ def plot_grpo_reward_curve(log: list[dict], out_path: str) -> None:
76
+ steps_r, total = _extract_series(log, "reward")
77
+ _, comp = _extract_series(log, "rewards/reward_compliance/mean")
78
+ _, appr = _extract_series(log, "rewards/reward_appropriateness/mean")
79
+ _, bonus = _extract_series(log, "rewards/reward_drift_bonus/mean")
80
+
81
+ if not steps_r:
82
+ print(f"[skip] no reward series in grpo_log")
83
+ return
84
+
85
+ fig, ax = plt.subplots(figsize=(7, 4))
86
+ # Total as a bold line; components as thinner stacked lines.
87
+ if total:
88
+ ax.plot(steps_r, total, label="total", linewidth=2.2, color="#111")
89
+ if comp:
90
+ ax.plot(steps_r[:len(comp)], comp, label="compliance",
91
+ linewidth=1.5, color="#2a6df4")
92
+ if appr:
93
+ ax.plot(steps_r[:len(appr)], appr, label="appropriateness",
94
+ linewidth=1.5, color="#f29e2e")
95
+ if bonus:
96
+ ax.plot(steps_r[:len(bonus)], bonus, label="drift_bonus",
97
+ linewidth=1.5, color="#d5342a")
98
+
99
+ ax.set_xlabel("GRPO step")
100
+ ax.set_ylabel("Mean reward (per completion)")
101
+ ax.set_title("GRPO — reward and components over training")
102
+ ax.set_ylim(bottom=0)
103
+ ax.legend(loc="best")
104
+ ax.grid(alpha=0.3)
105
+ fig.tight_layout()
106
+ fig.savefig(out_path, dpi=150)
107
+ plt.close(fig)
108
+ print(f"[ok] wrote {out_path}")
109
+
110
+
111
+ def plot_drift_acc_bars(evals: dict, out_path: str) -> None:
112
+ labels = ["pre", "post-SFT", "post-GRPO"]
113
+ keys = ["pre", "post_sft", "post_grpo"]
114
+ accs = []
115
+ for k in keys:
116
+ a = evals.get(k, {}).get("drift_acc")
117
+ accs.append(a if isinstance(a, (int, float)) else 0.0)
118
+ colors = ["#d5342a", "#f29e2e", "#2a6df4"]
119
+
120
+ fig, ax = plt.subplots(figsize=(7, 4))
121
+ bars = ax.bar(labels, [a * 100 for a in accs], color=colors, width=0.5)
122
+ for b, a in zip(bars, accs):
123
+ ax.text(b.get_x() + b.get_width() / 2, b.get_height() + 1.5,
124
+ f"{a:.0%}", ha="center", va="bottom", fontsize=11, fontweight="bold")
125
+ ax.set_ylabel("Drift-sensitive accuracy")
126
+ ax.set_title(f"Drift-sensitive accuracy — {evals.get('model_name', 'model')}")
127
+ ax.set_ylim(0, 105)
128
+ ax.set_yticks([0, 20, 40, 60, 80, 100])
129
+ ax.set_yticklabels([f"{v}%" for v in [0, 20, 40, 60, 80, 100]])
130
+ ax.grid(alpha=0.2, axis="y")
131
+ fig.tight_layout()
132
+ fig.savefig(out_path, dpi=150)
133
+ plt.close(fig)
134
+ print(f"[ok] wrote {out_path}")
135
+
136
+
137
+ def plot_summary(sft_log: list[dict] | None, grpo_log: list[dict] | None,
138
+ evals: dict | None, out_path: str) -> None:
139
+ """Combined 1x3 figure for the pitch slide."""
140
+ fig, axes = plt.subplots(1, 3, figsize=(16, 4.2))
141
+
142
+ # Panel 1: SFT loss
143
+ if sft_log:
144
+ steps, losses = _extract_series(sft_log, "loss")
145
+ if steps:
146
+ axes[0].plot(steps, losses, marker="o", markersize=3, color="#2a6df4")
147
+ axes[0].set_title("SFT loss")
148
+ axes[0].set_xlabel("step"); axes[0].set_ylabel("loss")
149
+ axes[0].grid(alpha=0.3)
150
+
151
+ # Panel 2: GRPO reward curve
152
+ if grpo_log:
153
+ steps_r, total = _extract_series(grpo_log, "reward")
154
+ _, comp = _extract_series(grpo_log, "rewards/reward_compliance/mean")
155
+ _, appr = _extract_series(grpo_log, "rewards/reward_appropriateness/mean")
156
+ _, bonus = _extract_series(grpo_log, "rewards/reward_drift_bonus/mean")
157
+ if steps_r:
158
+ axes[1].plot(steps_r, total, label="total", linewidth=2.2, color="#111")
159
+ if comp: axes[1].plot(steps_r[:len(comp)], comp, label="comp", color="#2a6df4")
160
+ if appr: axes[1].plot(steps_r[:len(appr)], appr, label="appr", color="#f29e2e")
161
+ if bonus: axes[1].plot(steps_r[:len(bonus)], bonus, label="drift", color="#d5342a")
162
+ axes[1].set_title("GRPO reward")
163
+ axes[1].set_xlabel("step"); axes[1].set_ylabel("reward")
164
+ axes[1].legend(fontsize=8); axes[1].grid(alpha=0.3)
165
+
166
+ # Panel 3: drift acc bars
167
+ if evals:
168
+ labels = ["pre", "post-SFT", "post-GRPO"]
169
+ keys = ["pre", "post_sft", "post_grpo"]
170
+ accs = [evals.get(k, {}).get("drift_acc") or 0.0 for k in keys]
171
+ colors = ["#d5342a", "#f29e2e", "#2a6df4"]
172
+ bars = axes[2].bar(labels, [a * 100 for a in accs], color=colors, width=0.55)
173
+ for b, a in zip(bars, accs):
174
+ axes[2].text(b.get_x() + b.get_width() / 2, b.get_height() + 1.5,
175
+ f"{a:.0%}", ha="center", va="bottom",
176
+ fontsize=10, fontweight="bold")
177
+ axes[2].set_ylim(0, 105)
178
+ axes[2].set_title("Drift-sensitive accuracy")
179
+ axes[2].grid(alpha=0.2, axis="y")
180
+
181
+ fig.suptitle("Policy-Drift env — training run summary", fontsize=14, y=1.02)
182
+ fig.tight_layout()
183
+ fig.savefig(out_path, dpi=150, bbox_inches="tight")
184
+ plt.close(fig)
185
+ print(f"[ok] wrote {out_path}")
186
+
187
+
188
+ # ---------------------------------------------------------------------------
189
+ def main() -> int:
190
+ ap = argparse.ArgumentParser()
191
+ ap.add_argument("outputs_dir", nargs="?", default="./outputs",
192
+ help="Directory containing sft_log.json, grpo_log.json, evals.json")
193
+ args = ap.parse_args()
194
+
195
+ d = args.outputs_dir
196
+ sft_log = _load(os.path.join(d, "sft_log.json"))
197
+ grpo_log = _load(os.path.join(d, "grpo_log.json"))
198
+ evals = _load(os.path.join(d, "evals.json"))
199
+
200
+ missing = [n for n, v in [("sft_log", sft_log), ("grpo_log", grpo_log), ("evals", evals)] if v is None]
201
+ if missing:
202
+ print(f"[warn] missing files (will skip corresponding plots): {missing}")
203
+
204
+ if sft_log:
205
+ plot_sft_loss(sft_log, os.path.join(d, "sft_loss.png"))
206
+ if grpo_log:
207
+ plot_grpo_reward_curve(grpo_log, os.path.join(d, "reward_curve.png"))
208
+ if evals:
209
+ plot_drift_acc_bars(evals, os.path.join(d, "drift_acc_bars.png"))
210
+
211
+ plot_summary(sft_log, grpo_log, evals, os.path.join(d, "summary.png"))
212
+ return 0
213
+
214
+
215
+ if __name__ == "__main__":
216
+ sys.exit(main())