File size: 57,457 Bytes
a57d682
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4688533
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d78f879
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9033aad
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1ce8fac
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9033aad
 
c803cd5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a57d682
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4688533
d78f879
9033aad
a57d682
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f6d4692
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a57d682
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
# Edits log β€” Round-2 environment extension

This file tracks every change made on top of the Round-1 submission, in order.
Useful both as a journal and as a re-deploy checklist.

**Round-1 baseline:** commit `bf77949` ("Update readme") on `main`. Single
family (xlsx), 10 hand-curated Finch tasks, monolithic `graders.py`,
heuristic step-rewards.

**Round-2 target:** unified office-document RL environment β€” xlsx + docx + pptx,
real enterprise artifacts, gaming-resistant multi-layer grading, manifest-driven.

---

## State at Round-1 (baseline)

| Area | What was there |
|---|---|
| Task families | xlsx only |
| Number of tasks | 10 hand-curated, all from Finch |
| Task definitions | Hardcoded `TASKS = {...}` dict in [`tasks.py`](tasks.py) |
| Source data | `data/<orig_id>/{src,ref}_0.xlsx` β€” 10 dirs |
| Grading | One `graders.py` module, two functions: `grade_qa` (text) and `grade_xlsx` (cell-diff) |
| Step rewards | `_compute_code_reward` in [`server/financial_environment.py`](server/financial_environment.py): heuristics on code string (regex `save(`, count substantive lines, length of stdout). Cap 0.10/step. |
| Sandboxing | None β€” agent's subprocess has full filesystem access |
| Reward components | 4 signals, all heuristic, partly gameable |
| Train/eval split | None |
| Deps | `openpyxl` only |

### Known weaknesses identified before changes

1. `save(` string match misses `prs.save()`, `Document.save()` β€” wouldn't generalize past xlsx.
2. No measurement of whether file *actually* changed; just whether code mentioned save.
3. No "moving toward gold" signal.
4. Hardcoded task table β€” can't scale past ~30 tasks without bloat.
5. Gold files reachable from sandbox via `glob(data/**)` β†’ reward hacking.

---

## Phase 1 β€” Manifest loader + 50 stratified Finch tasks

**Goal:** scale beyond 10 hand-curated tasks; introduce a manifest the env
loads at startup so future task families (docx, pptx) plug in cleanly.

### New files
- [`data_pipeline/finch_pull.py`](data_pipeline/finch_pull.py) β€” stratified
  puller for the `FinWorkBench/Finch` HF dataset (172 tasks). Picks **50
  xlsx-only MODIFY tasks** across 7 tag buckets:

  | Tag | Picked | of total |
  |---|---|---|
  | Calculation | 16 | of 119 |
  | Structuring / Formatting | 11 | of 86 |
  | Data Entry / Import | 6 | of 44 |
  | Validation / Review | 5 | of 37 |
  | Cross-sheet/file Retrieval | 5 | of 36 |
  | Summary / Visualization | 4 | of 33 |
  | Financial Modeling | 3 | of 15 |

  Web Search dropped β€” all such tasks have non-xlsx sources. Slots reallocated
  to Calculation + Structuring.

- [`data/manifest.jsonl`](data/manifest.jsonl) β€” 50 rows, schema:
  ```json
  {"id": "finch_10", "family": "xlsx", "origin": "finch", "orig_id": "10",
   "split": "eval", "primary_tag": "Calculation",
   "all_tags": ["Calculation", "Financial Modeling"],
   "business_type": "Predictive Modeling",
   "instruction": "...", "constraints": "...",
   "source_file": "data/finch_50/10/10_src_0.xlsx",
   "reference_file": "data/finch_50/10/10_ref_0.xlsx",
   "task_type": "MODIFY", "max_steps": 15}
  ```

- [`data/finch_50/<id>/{src,ref}.xlsx`](data/finch_50/) β€” ~42 MB, 50 tasks Γ— 2 files.

### Train/eval split
- 40 train / 10 eval (stratified β€” at least 1 holdout per tag).
- Driven by per-tag `EVAL_HOLDOUT` budget in the puller.

### Modified files
- [`tasks.py`](tasks.py) β€” added `_load_manifest()` that reads
  `data/manifest.jsonl` and merges rows into `TASKS` (skipping any whose ID
  already exists, so the original 10 hand-curated tasks remain). Added
  `list_tasks(split=, family=)`, `split_ids()` filters.

### Resulting task counts
- 60 total (10 original + 50 Finch), 50 train / 10 eval.

---

## Phase 2 β€” Unified `RewardTracker`

**Goal:** replace heuristic code-string scoring with **real file-state**
signals, generalizable across xlsx/pptx/docx.

### New file
- [`rewards.py`](rewards.py) β€” `RewardTracker` class, one instance per episode.

### Reward components (all per-step, summed and clamped to 0.10)

| Component | Range | What it actually checks |
|---|---|---|
| `exec_health` | 0–0.020 | Subprocess return code; bonus if stdout non-empty |
| `lib_engagement` | 0–0.010 | Code matches `_LIB_PATTERNS[family]` regex (xlsx β†’ openpyxl/load_workbook/Workbook; pptx β†’ Presentation; docx β†’ Document) |
| `mutation` | 0–0.030 | SHA-256 of working file changed since last step |
| `validity` | 0–0.020 | Mutated file still parses with the family's loader |
| `progress` | 0–0.040 | Structural distance to gold *decreased* this step (gated by `enable_progress`) |

### Per-family structural distance (in `rewards.py`)
- `_xlsx_distance` β€” fraction of gold cells matched (mirrors final grader)
- `_pptx_distance` β€” fraction of gold (slide_idx, shape_idx) text-frames matched
- `_docx_distance` β€” fraction of gold paragraphs matched at same index

### Modified files
- [`server/financial_environment.py`](server/financial_environment.py):
  - Replaced `_compute_code_reward` with a delegate to `RewardTracker`
  - `_compute_code_reward` now returns `(total, breakdown_dict)` instead of just `float`
  - Per-episode tracker stood up in `reset()` after copying source to workdir
  - `FINANCIAL_ENV_PROGRESS=0` env var disables the progress signal (for clean eval)
  - Reward decomposition surfaced in feedback for debugging

### Smoke test results
- Read-only step: 0.030 (exec_health 0.020 + lib_engagement 0.010)
- Save+modify step: 0.080 (+ mutation 0.030 + validity 0.020)
- Failed code: 0.005 (exec_health_fail only)
- Decomposition logged in feedback, e.g.: `Reward: total=0.080 (exec_health=0.020, lib_engagement=0.010, mutation=0.030, validity=0.020, progress=0.000)`

---

## Phase 3 β€” DOCX family (OSWorld-Verified writer subset)

**Goal:** add Microsoft Word (.docx) tasks alongside xlsx, with real
property-checking evaluators ported from OSWorld.

### New files

- [`data_pipeline/osworld_writer_pull.py`](data_pipeline/osworld_writer_pull.py)
  β€” pulls 21 strict-docx tasks from `xlang-ai/OSWorld` (GitHub) and
  `xlangai/ubuntu_osworld_file_cache` (HF). Of the 23 published writer
  UUIDs, drops 2 (one `.odt`, one `.pdf` source) leaving **21 strict-docx**.

  Schema normalization: OSWorld evaluators come in two shapes (single-string
  `func` vs. compound `func: list[str]` with `conj: "or"|"and"` and parallel
  `expected`/`options` lists). The puller normalizes everything to
  `evaluator: {conj, checks: [{func, options, expected_files: [...]}, ...]}`.
  Multi-gold (`multi: true`) tasks have `expected_files` as a list per check.

- [`graders/docx_metrics.py`](graders/docx_metrics.py) β€” port of 16 evaluator
  functions from
  [OSWorld's `desktop_env/evaluators/metrics/docs.py`](https://github.com/xlang-ai/OSWorld/blob/main/desktop_env/evaluators/metrics/docs.py)
  (Apache-2.0). Heavy deps (`skimage`, `easyocr`) imported lazily; one
  function (`find_default_font`) stubbed because it operates on a LibreOffice
  config XML that doesn't exist in our headless sandbox.

  Added `infeasible` handler: passes iff agent didn't modify the source
  (the agent should refuse). The `bb8ccc78` task ("Share this document with
  my team and let us edit it together in real-time") uses this β€” it's
  genuinely impossible from a code-execution sandbox.

  Dispatcher: `run_evaluator(conj, checks, working_file, source_file)` β€”
  `and` = `min(scores)`, `or` = `max(scores)`.

  | Evaluator | Tasks | Style |
  |---|---|---|
  | `compare_docx_files` | 7Γ— | Content diff (with options: ignore_blanks, ignore_case, fuzzy_match, …) |
  | `compare_line_spacing` | 3Γ— | Property |
  | `compare_docx_tables` | 3Γ— | Structure |
  | `check_tabstops` | 1Γ— | Property + position-distance |
  | `compare_subscript_contains` | 1Γ— | Property |
  | `has_page_numbers_in_footers` | 1Γ— | Single-file property |
  | `compare_font_names` | 1Γ— | Single-file property |
  | `is_first_line_centered` | 1Γ— | Single-file property |
  | `compare_docx_images` | 1Γ— | Pixel-byte diff |
  | `compare_unique_train_records` | 1Γ— | Multi-file domain logic |
  | `evaluate_strike_through_last_paragraph` | 1Γ— | Property |
  | `evaluate_colored_words_in_tables` | 1Γ— | Skimage CIE delta-E |
  | `infeasible` | 1Γ— | Sentinel (file-unchanged check) |
  | `check_italic_font_size_14` | 1Γ— | Property |
  | `contains_page_break` | 1Γ— | Property |
  | `find_default_font` | 1Γ— | **Stubbed** (LO-config-dependent) |

### File reorganization (mid-phase)

- Renamed `graders.py` (root module) β†’ `graders/__init__.py` (package).
  Forced because `graders/` (new dir for `docx_metrics.py`) collides with
  `graders.py` (old root file) β€” Python won't accept both. Existing
  `from graders import grade_task` imports still work transparently.

### New 3-layer DOCX grader

In [`graders/__init__.py`](graders/__init__.py):

```python
def grade_docx(task, output_path):
    if not _docx_validity(output_path):     # layer 1 β€” validity gate
        return 0.001
    diff_score    = _docx_diff(output_path, task["reference_file"])    # layer 2
    primary_score = run_evaluator(...)                                 # layer 3
    return 0.4 * diff_score + 0.6 * primary_score
```

The dispatcher (`grade_task`) routes by `task["family"]` β€” xlsx still uses
the cell-diff path, docx uses the new 3-layer path.

### Modified files

- [`tasks.py`](tasks.py) β€” manifest loader now passes through `evaluator`,
  `primary_tag`, `all_tags`. Resolves evaluator's `expected_files` to absolute
  paths (matters for the gold-stash dedup in Phase 4).

- [`pyproject.toml`](pyproject.toml) + [`Dockerfile`](Dockerfile) β€” added
  `python-docx>=1.1.0`, `rapidfuzz>=3.0.0`, `Pillow>=10.0.0`.

### Resulting task counts
- 81 total (10 original + 50 Finch xlsx + 21 OSWorld docx).
- 17 docx train, 4 docx eval (stratified to cover 4 distinct evaluator funcs).

### Smoke test results
- Submit gold to compound `andΓ—2` task β†’ **0.999** βœ“
- Submit corrupted bytes β†’ **0.001** (validity gate rejects) βœ“
- Submit unmodified source β†’ **0.400** (diff layer says similar, per-task says no-edit)

### OSWorld quirk noted
- `osworld_0a0faba3` (`check_tabstops` task): the gold itself doesn't satisfy
  `word_number_split_by_tabstop=3` for paragraph [2] (`"Make payment\t..."` has
  only 2 words before the tab). This is a faithful port of OSWorld's
  behavior, not a bug in our code. May want to relax the rule for training
  or move that task to eval-only.

---

## Phase 4 β€” Reward-hacking defenses

**Goal:** plug the two biggest hacking surfaces identified in the Q2 audit.

### Defense 1 β€” Gold file moved out of sandbox at episode start

**Threat:** `glob('/app/env/data/**/*Gold*.docx')` or `glob('**/*_ref_*.xlsx')`
finds the gold; agent submits it for an instant 0.999.

**Fix:** at `reset()`:

1. Make a per-episode COPY of the global `TASKS[id]` dict (so episode-time
   path mutations don't pollute the global)
2. Create a tmpdir at `/tmp/oe_gold_<random>/`
3. **Move (rename)** every gold file from `data/...` into the tmpdir with a
   generic name (`gold_ref<ext>`, `check_<i>_<j>_<random><ext>`)
4. Track the moves in `self._gold_originals` so `close()` can restore
5. Rewrite the episode-task's `reference_file` and
   `evaluator.checks[*].expected_files` to point at the tmpdir paths

**De-dup**: when the same path appears as both `reference_file` and an
evaluator `expected_files` entry (common β€” the puller sets reference_file =
first check's first expected_file), the stasher uses a `path_map` to ensure
both new paths point to the same stashed location.

**Restore**: `close()` renames stashed files back to their original `data/`
locations. `reset()` calls `close()` at the start of each episode in case
the prior episode didn't end cleanly.

### Defense 2 β€” Per-task evaluator as 6th reward signal

**Threat:** the previous 5 components rewarded "moved closer to gold via
generic structural distance", which an agent could optimize without
satisfying the actual property check the task is testing.

**Fix:** new `eval_check` component (0–0.020). Computes the per-task
evaluator at episode start, then on each mutating step. Rewards
*increases* in spec-aligned score.

```python
# rewards.py
if self.task_evaluator is not None and file_valid:
    cur_eval = self._safe_task_eval()
    if self._prev_eval is not None and cur_eval > self._prev_eval:
        delta = cur_eval - self._prev_eval
        sig.eval_check = min(EVAL_CHECK_MAX, EVAL_CHECK_MAX * delta)
    self._prev_eval = cur_eval
```

For docx, the env passes `task_evaluator = run_evaluator(conj, checks, ...)`
into the tracker. xlsx/pptx pass `None`.

### Modified files

- [`rewards.py`](rewards.py):
  - Added `task_evaluator` param to `RewardTracker.__init__`
  - Added `eval_check` field to `StepSignals` + recomputed `total`
  - Added `EVAL_CHECK_MAX = 0.020` constant
  - Added `_safe_task_eval()` helper

- [`server/financial_environment.py`](server/financial_environment.py):
  - `__init__`: added `_gold_stash_dir`, `_gold_originals` fields
  - `reset()`: copies task dict, creates stash dir, calls `_stash_gold_files`,
    builds `task_evaluator` callable for docx, passes it to `RewardTracker`
  - New methods: `_stash_gold_files(task, stash_dir)`, `_make_task_evaluator()`
  - `close()`: restores moved gold files to data/, removes stash dir

### Smoke test results

| Scenario | Score | Expected | Result |
|---|---|---|---|
| Compound andΓ—2 docx, submit stashed gold | 0.999 | ~0.999 | βœ“ |
| Single-check docx, submit stashed gold | 0.999 | ~0.999 | βœ“ |
| Submit corrupted bytes | 0.001 | 0.001 | βœ“ (validity gate) |
| Submit source (unmodified) | 0.400 | partial | βœ“ (diff 1.0, per-task 0) |
| xlsx (no per-task evaluator), submit gold | 0.999 | ~0.999 | βœ“ |
| Code step copies stashed gold to working file | total=0.090 with `eval_check=0.020` | should fire | βœ“ |
| Original gold file present in data/ during episode | False on disk | False | βœ“ (moved out) |
| Original restored after `close()` | True on disk | True | βœ“ |

---

## Phase 5 β€” PPTX family (PPTArena ingest)

**Goal:** add Microsoft PowerPoint (.pptx) tasks. PPTArena chosen over TSBench
because PPTArena ships actual gold .pptx files; TSBench only has
`ideal_description` text and would need an LLM judge.

### Source

Local checkout of [PPTArena](https://github.com/michaelofengend/PPTArena)
unpacked at `~/Downloads/PPTArena-main`. The repo's
`src/evaluation_pairs_refined.json` has 100 well-curated task pairs:

```json
{
  "name": "Case 31: Fix Text Overflow",
  "prompt": "...",
  "style_target": "<detailed expected output spec>",
  "original": "Original/<file>.pptx",
  "ground_truth": "GroundTruth/<file>.pptx",
  "category": ["Content", "Layout"],
  "edit_type": "Text & Typography"
}
```

Distribution across the 100:

| edit_type | count |
|---|---|
| Text & Typography | 29 |
| Charts | 10 |
| Images & Pictures | 10 |
| Theme & Background | 9 |
| Alignment, Distribution & Z-order | 8 |
| Slide/Section Management & Footers | 8 |
| Tables | 8 |
| Shapes & Drawing | 4 |
| SmartArt & Diagrams | 4 |
| Slide Layout & Placeholders | 3 |
| Accessibility & Semantics | 2 |
| Long-tail singletons (Transitions, Hyperlinks, Master, Audio/Video, Animations) | 1 each |

### New file
- [`data_pipeline/pptarena_pull.py`](data_pipeline/pptarena_pull.py) β€” reads
  `evaluation_pairs_refined.json`, picks **38 tasks** stratified by
  `edit_type`. Sub-budget below; sum is 38 (close to the 40 target β€” the
  gap is from the long-tail edit_types having only 1 sample each).

  | edit_type | picked | of total |
  |---|---|---|
  | Text & Typography | 6 | of 29 |
  | Charts | 4 | of 10 |
  | Images & Pictures | 4 | of 10 |
  | Theme & Background | 3 | of 9 |
  | Alignment, Distribution & Z-order | 3 | of 8 |
  | Slide/Section Management & Footers | 3 | of 8 |
  | Tables | 3 | of 8 |
  | Shapes & Drawing | 2 | of 4 |
  | SmartArt & Diagrams | 2 | of 4 |
  | Slide Layout & Placeholders | 2 | of 3 |
  | Accessibility & Semantics | 1 | of 2 |
  | Long-tail singletons | 5 Γ— 1 | of 5 |

  Long-tail singletons all go to train (only 1 sample each β€” can't hold out).
  Eval holdout = 8: 2 from Text & Typography, 1 each from {Charts, Images,
  Theme, Alignment, Slide Mgmt, Tables}.

  The agent-facing instruction is `prompt + "\n\nDetails:\n" + style_target`
  β€” `style_target` carries the explicit spec PPTArena uses internally for
  evaluation, exposed to the agent as a "hidden but visible" constraint.

### Data layout

```
data/pptarena/<slug>/
   <slug>_src.pptx   # copied from PPTArena-main/Original/
   <slug>_ref.pptx   # copied from PPTArena-main/GroundTruth/
```

Total disk: ~244 MB for 38 tasks (pptx files are larger than docx/xlsx β€”
they contain embedded images and themes).

### Grader β€” `grade_pptx` (2-layer, no per-task evaluator)

In [`graders/__init__.py`](graders/__init__.py):

```python
def grade_pptx(task, output_path):
    if not _pptx_validity(output_path):  # layer 1
        return 0.001
    # layer 2: structural diff
    #   slide-count match (30%) + per-shape text-equality (70%, fuzzy 90%+ allowed)
    ...
```

Per-task evaluator is **intentionally not wired**. PPTArena's published
evaluator is a VLM-as-judge pipeline (instruction-following + visual quality)
which is expensive and non-deterministic. Skipping for v1; wiring it as an
optional `RENDER_FOR_VLM=1` flag is in the Open Issues list.

### Modified files

- [`graders/__init__.py`](graders/__init__.py): added `_pptx_validity`,
  `_pptx_load_shape_text`, `grade_pptx`. Dispatcher now routes pptx β†’ grade_pptx.
- [`pyproject.toml`](pyproject.toml) + [`Dockerfile`](Dockerfile): added
  `python-pptx>=1.0.0`.

### Resulting task counts (cumulative)

| Family | Origin | Train | Eval | Total |
|---|---|---|---|---|
| xlsx | hand-curated | 10 | 0 | 10 |
| xlsx | Finch | 40 | 10 | 50 |
| docx | OSWorld | 17 | 4 | 21 |
| pptx | PPTArena | 30 | 8 | 38 |
| **total** | | **97** | **22** | **119** |

### Smoke test results

| Scenario | Score | Expected | Result |
|---|---|---|---|
| Submit stashed gold (eval task) | 0.999 | ~0.999 | βœ“ |
| Submit corrupted .pptx bytes | 0.001 | 0.001 | βœ“ (validity gate) |
| Code step that mutates + saves (add blank slide) | total=0.080 | β‰₯0.06 | βœ“ (exec=0.020, lib=0.010, mutation=0.030, validity=0.020) |
| Gold-stash works for pptx (file moves out of `data/`) | True | True | βœ“ |
| `close()` restores gold to `data/` | True | True | βœ“ |

### Known limitation: text-only diff is weak for layout tasks

For an Alignment / Layout task (e.g. *Case 60: Fix Text Placement*), source
and ground-truth have near-identical text content β€” only shape positions
differ. Our diff layer scores 0.999 on the unmodified source for this case,
which is not what we want. Two paths to fix:

1. **Extend `grade_pptx` with position+size diff** (cheap; ~30 lines): for
   each (slide_idx, shape_idx) pair, compare `(left, top, width, height)`
   within tolerance. Recompose the score as `0.2 * slide_count + 0.8 * avg(
   0.5 * text_match + 0.25 * position_match + 0.25 * size_match)`.

2. **Wire VLM judge** behind `PPTX_VLM_JUDGE=1` env var β€” render slides via
   headless LibreOffice β†’ PNG, send (instruction, before, after, ref) to a
   VLM. Matches PPTArena's published methodology but is expensive.

Recommended: (1) before any RL training; (2) for the final eval scoreboard.

### Phase 5 follow-up: layout-aware diff (delivered)

Implemented option (1) above. The grader now loads every shape's
`(left, top, width, height)` (in EMU) and computes a per-shape composite
score:

- **Text** (50%) β€” exact match β†’ 1.0; rapidfuzz partial credit otherwise.
- **Position** (25%) β€” `_coord_match(left, denom=slide_w)` averaged with
  same for `top`. Tolerance: `delta ≀ 2%` of slide dim β†’ 1.0; `delta β‰₯ 20%`
  β†’ 0.0; linear in between. Both sides None (placeholder inheriting from
  layout) is treated as a match.
- **Size** (25%) β€” same `_coord_match` for width/height.

Final score reweighted: `0.2 * slide_count + 0.8 * avg(per-shape composite)`.

#### Smoke results on all 8 pptx eval tasks (source-vs-gold)

| Task | Before fix | After fix | Notes |
|---|---|---|---|
| `case_36_add_speaker_notes` | 0.999 | **0.683** | Big drop β€” entire shapes added in gold |
| `case_32_arrange_image_and_text` | 0.999 | **0.824** | Position diff captured |
| `case_7_update_quarter_two_data_b` | 0.999 | **0.948** | Chart text + size diff |
| `case_60_fix_text_placement` | 0.999 | **0.981** | Modest β€” positions in tolerance band |
| `case_35_structural_fix` | 0.999 | 0.971 | Modest |
| `case_49_normalize_thousand_separators` | 0.999 | 0.992 | Tiny text edit, no layout change |
| `case_40_hindu_center_titles` | 0.999 | 0.997 | Title-alignment only β€” small px shift |
| `case_26_match_slide_colors_to_theme` | 0.999 | 0.999 | Pure color/theme β€” geometry unchanged |

5 of 8 eval tasks now show meaningful drop. The remaining 3 (`case_40`,
`case_49`, `case_26`) still score ~0.99 because their edits are
**styling-only** β€” color, font, fill β€” which our geometry-only diff
doesn't see.

#### Remaining gap: styling-only tasks (29 of 100 PPTArena tasks)

Styling tasks edit shape `fill`, `line`, font `name/size/bold/italic/color`,
or theme β€” none of which are captured by text + geometry. Two ways to
close the gap, both filed as new follow-ups:

a. **Per-shape style diff**: for each shape, compare
   `fill.solid().fore_color.rgb`, `line.color.rgb`, and for the first run
   in each text frame: `font.name, font.size, font.bold, font.italic,
   font.color.rgb`. Add as a 4th component in `_shape_match_score`. ~50 lines.

b. **VLM judge** (option 2 above) β€” catches styling for free since it
   compares rendered images. Defer to eval-time only because of cost.

For training, (a) is sufficient. For the final scoreboard, (b) is nicer.

### Phase 5 follow-up #2: style-aware diff (delivered)

Implemented option (a) above. New `_shape_style()` extractor pulls 7
attributes per shape (all None-tolerant β€” failures during read become
`None`, which counts as a match against another `None`):

| Attribute | Weight | Source |
|---|---|---|
| `fill_rgb` | 0.30 | `shape.fill.fore_color.rgb` (solid fills only) |
| `font_rgb` | 0.20 | first-run `font.color.rgb` |
| `font_size_pt` | 0.15 | first-run `font.size.pt` |
| `font_name` | 0.10 | first-run `font.name` |
| `line_rgb` | 0.10 | `shape.line.color.rgb` |
| `font_bold` | 0.075 | first-run `font.bold` |
| `font_italic` | 0.075 | first-run `font.italic` |

Per-shape composite reweighted from `50% text + 25% pos + 25% size` to:

> **40% text + 20% style + 20% position + 20% size**

Why these weights? Text is still dominant because most edits affect text
content. Style gets equal weight to position/size, reflecting that styling
edits are common in PPTArena (~29 tasks).

#### Smoke results across all 8 pptx eval tasks (source-vs-gold)

| Task | Phase-5 layout-only | Phase-5+style | Discrimination (gold βˆ’ source) |
|---|---|---|---|
| `case_26_match_slide_colors_to_theme` | 0.999 | **0.971** | 0.000 β†’ **0.028** βœ“ unblocked |
| `case_36_add_speaker_notes` | 0.683 | 0.715 | 0.316 β†’ 0.284 |
| `case_32_arrange_image_and_text` | 0.824 | 0.855 | 0.175 β†’ 0.144 |
| `case_60_fix_text_placement` | 0.981 | 0.985 | 0.018 β†’ 0.014 |
| `case_35_structural_fix` | 0.971 | 0.975 | 0.028 β†’ 0.024 |
| `case_7_update_quarter_two_data_b` | 0.948 | 0.951 | 0.051 β†’ 0.048 |
| `case_40_hindu_center_titles` | 0.997 | 0.998 | tiny |
| `case_49_normalize_thousand_separators` | 0.992 | 0.994 | tiny |

Gold-vs-gold remained 0.999 on all 8 (no regression).

**Trade-off observed:** the styling task discrimination went from 0 β†’ 0.028,
but text/layout-heavy tasks lost a few percentage points of discrimination
because the text weight dropped from 50% β†’ 40%. Net positive but not
dramatic.

#### The dilution problem (now the binding limitation)

For tasks where only a few shapes out of many are edited (e.g.
`case_40_hindu_center_titles` edits 1 title shape per slide), the diff
averages across **all** shapes β€” the un-edited majority dominates and
the score barely moves between source and gold. This is structural to
average-based diff and not a bug.

Two follow-ups to consider:

a. **Edit-zone masking** β€” score only shapes whose attributes differ
   between source and gold (using `task.source_file` as the baseline).
   Changes scoring semantics: instead of "how close to gold", you measure
   "did the agent fix the parts that were supposed to change". ~30 lines,
   but more invasive than (b) below.

b. **VLM judge** β€” compares rendered images, naturally focuses on visible
   differences. The right long-term answer; expensive β€” defer to eval-time
   behind a flag.

---

## Phase 6 β€” Inference script v2 (manifest-aware benchmarking)

**Goal:** Round-1's [`inference.py`](inference.py) was hardcoded to 5 xlsx
tasks and produced stdout-only output. Round-2 needs a script that:

1. Selects tasks from the manifest (filterable by split/family/ids)
2. Picks the right system prompt per family (openpyxl / python-docx / python-pptx)
3. Persists results to disk so we can produce reward curves and before/after
   plots for the judging story

### CLI (new)

```
python inference.py [--split eval|train|all]
                    [--family xlsx|docx|pptx|all]
                    [--limit N]
                    [--task-ids id1,id2,…]
                    [--output-dir runs/<custom>]
                    [--model <name>]
                    [--api-base <url>] [--env-url <http://…>]
                    [--max-steps 15] [--task-timeout 360]
                    [--temperature 0.0] [--max-tokens 12000]
```

`--task-ids` overrides `--split`/`--family`. Selection is sorted
deterministically by (family, primary_tag, id).

### Output structure (new)

Each run writes a `runs/<timestamp>_<model_slug>/` directory:

```
results.json           # summary + per-task records
summary.csv            # flat table for plotting
trajectories/<id>.jsonl # full step trace per task (action, reward, feedback)
log.txt                # mirrors stdout
```

`results.json` shape:
```json
{
  "model": "...",
  "split": "eval", "family": "all",
  "n_tasks": 22, "avg_score": 0.456, "success_rate": 0.318,
  "total_elapsed_s": 1840.5,
  "by_family": {
    "xlsx": {"n": 10, "avg": 0.521},
    "docx": {"n": 4, "avg": 0.402},
    "pptx": {"n": 8, "avg": 0.388}
  },
  "results": [{ "task_id":..., "score":..., "step_rewards":[...], ...}]
}
```

`summary.csv` columns: `task_id, family, primary_tag, split, score, success,
steps, elapsed_s, error` β€” feeds straight into matplotlib/seaborn for the
hero plot in the README.

### Family-aware system prompts (new)

The single prompt mentioning `openpyxl` is replaced by three:

| Family | Prompt mentions |
|---|---|
| xlsx | `openpyxl.load_workbook`, `wb.save(path)` |
| docx | `from docx import Document`, `doc.save(path)`, common imports for shared/enum |
| pptx | `from pptx import Presentation`, `prs.save(path)`, color/util imports |

Selection is by `obs["family"]` (env-provided, with fallback to the
manifest's `family` field).

### Other changes

- `MAX_STEPS` default raised from 10 β†’ 15 to match the env's actual cap
  (was undercutting agents on hard tasks)
- `TASK_TIMEOUT` raised from 240s β†’ 360s β€” pptx tasks have larger files
  and need more inspection time
- Task selection auto-injects the 10 hand-curated `task_1..task_10` (which
  live in `tasks.py`, not the manifest) so they remain runnable via
  `--task-ids`
- Action extractor now also recognizes `docx`/`pptx` strings as code-block
  hints (was openpyxl-only)
- Trajectory persistence: every (action, reward, feedback) tuple is saved
  per task β€” useful as **input to SFT warm-start** in the eventual training
  loop

### Smoke validation

- `--help` prints clean usage
- Loads 119 tasks from manifest + injects 10 hand-curated; selects:
  - `--split eval` β†’ 22 tasks (10 xlsx + 4 docx + 8 pptx) βœ“
  - `--task-ids finch_10,osworld_0a0faba3,pptarena_case_60_fix_text_placement` β†’ 3 tasks βœ“
- Output writers (json/csv/jsonl) round-trip cleanly via synthetic test

A full live benchmark (with model API + env server) is the user's next
action β€” costs ~$0.50-2 in API tokens for a 22-task eval depending on model.

### Modified files

- [`inference.py`](inference.py) β€” full rewrite (~400 lines, was ~350)

### Files unchanged in Phase 6

- All env-server code, graders, manifest, data, deps

---

## Phase 7 β€” Live-discovered exploit + anti-exploit fix

**Trigger:** during Kimi-K2.5 eval (Apr 25, 2026), the model submitted the
**unmodified source file in step 1** for two tasks and scored very high:

| Task | Edit type | Score on src-unchanged submit | Why it worked |
|---|---|---|---|
| `pptarena_case_40_hindu_center_titles` | Title alignment | 0.998 | Paragraph-level `alignment` wasn't in `_shape_style`; everything else (text, position, size, font attrs) was identical between source and gold |
| `pptarena_case_26_match_slide_colors_to_theme` | Theme color | 0.971 | Gold uses theme-color references (None RGB); source uses explicit RGB. The mismatch dilutes across 30 shapes for only ~3% drop |

This is genuine reward hacking by an inference-time agent, exactly what the
"hard to game" criterion in the judging guide warns about. Two fixes
delivered:

### Fix 1: extended `_shape_style` (catches the per-attribute gaps)

Added two new attributes to the per-shape style extractor:

| Attribute | Source | Catches |
|---|---|---|
| `para_alignment` | `shape.text_frame.paragraphs[0].alignment` | "Center the title" / "right-align" tasks |
| `fill_theme` | `shape.fill.fore_color.theme_color` (when fill is solid but `.rgb` raises) | "Match colors to theme" tasks where gold uses theme refs and source uses explicit RGB |

Reweighted `_STYLE_WEIGHTS` from 7 attrs β†’ 9 attrs:

```
fill_rgb 0.22 | fill_theme 0.08 | font_rgb 0.17 | para_alignment 0.15
font_size_pt 0.12 | line_rgb 0.08 | font_name 0.08
font_bold 0.05 | font_italic 0.05
```

Status: improves shape-level discrimination, but the **dilution problem
still wins** when only 2 of 55 shapes change (case_40 src-vs-gold went
from 0.998 β†’ 0.997 β€” basically unchanged because of averaging). This is
why we need Fix 2.

### Fix 2: byte-equality anti-exploit at grade time (the actual fix)

Added in [`graders/__init__.py`](graders/__init__.py)'s `grade_task`:
**if the agent's submitted file is byte-identical to the source AND the
task isn't OSWorld's `infeasible` sentinel, return 0.001 immediately.**

```python
if src_file_exists and not is_infeasible_task:
    if same_bytes(output_path, source_file):
        return 0.001  # agent didn't actually do anything
```

This kills the entire class of "submit source unchanged" exploits across
all three families, regardless of which specific attribute the diff
misses. Validation:

| Test | Before fix | After fix |
|---|---|---|
| Submit unmodified source on `case_40` | 0.998 | **0.001** βœ“ |
| Submit unmodified source on `case_26` | 0.971 | **0.001** βœ“ |
| Submit gold on `case_40` | 0.999 | 0.999 βœ“ no regression |
| Submit gold on `case_26` | 0.999 | 0.999 βœ“ no regression |
| All 8 pptx eval tasks, gold-vs-gold | 0.999 | 0.999 βœ“ no regression |

The OSWorld `infeasible` task (where not modifying *is* the correct
answer) is correctly excluded β€” that path uses the existing `infeasible`
evaluator function which already does its own equality check and credits
the agent.

### Important implication for SFT corpus building

When we eventually filter trajectories for the SFT corpus, **drop any
trajectory where `n_steps == 1` and the only action was `submit_file`**
even after this fix. Reasons:
1. Defense in depth β€” if a future grader gap appears, we don't want the
   student model trained on "submit unchanged" wins
2. A real solve takes at least one code step; 1-step `submit_file` is
   structurally suspicious

This filter is documented as a TODO for the SFT collection script.

### Re-eval needed

The Kimi-K2.5 baseline numbers from `runs/baseline_kimi_k25_eval/` were
collected with the pre-fix grader. The two exploited tasks are now
correctly graded at 0.001 instead of 0.998/0.971, lowering the run's
average. Either re-run Kimi on those two tasks with `--resume`, or
recompute the average locally:

```bash
# Quick local recompute (no re-inference) β€” assumes you already pushed
# updated graders. The OLD numbers are inflated; the NEW numbers reflect
# what Kimi actually solved.
```

(Recommendation: re-run with `--resume --task-ids pptarena_case_40_hindu_center_titles,pptarena_case_26_match_slide_colors_to_theme`. Costs <$0.10.)

---

## Phase 8 β€” SFT corpus builder (trajectory β†’ messages-format JSONL)

**Goal:** turn teacher trajectories (collected on the train split via
`inference.py --split train`) into an SFT-ready corpus for warm-starting
a small student model (Qwen2.5-Coder-3B-Instruct) before GRPO.

### New file
- [`data_pipeline/build_sft_corpus.py`](data_pipeline/build_sft_corpus.py)
  β€” reads a `runs/<dir>/{summary.csv, trajectories/*.jsonl}` produced by
  `inference.py`, applies six filters, and emits a JSONL where each row
  is one accepted episode in the
  [TRL `SFTTrainer` `messages` format](https://huggingface.co/docs/trl/main/en/sft_trainer):

  ```jsonl
  {"task_id": "...", "family": "xlsx", "primary_tag": "Calculation",
   "split": "train", "score": 0.94, "n_steps": 6,
   "messages": [
     {"role": "system",    "content": <SYSTEM_PROMPTS[family]>},
     {"role": "user",      "content": <task instruction + source path + family>},
     {"role": "assistant", "content": "```python\n…\n```"},
     {"role": "user",      "content": "Code execution result (step 1/15):\n…"},
     {"role": "assistant", "content": "SUBMIT_FILE: /…"},
     ...
   ]}
  ```

### Filters (in order)

| # | Filter | What it drops | Why |
|---|---|---|---|
| 1 | `error` column non-empty | Failed runs (timeouts, model crashes) | No useful signal |
| 2 | `n_steps < --min-steps` (default 2) | Trivial 1-step runs | Real solves take β‰₯1 code step |
| 3 | **1-step `submit_file`** | Trajectories where the only action is `submit_file` | **Defense in depth against grader exploits** β€” Phase 7 proved a model can submit source unchanged and beat the diff threshold; even with the byte-equality check, future grader gaps could re-open this. A real solve takes β‰₯1 code step; we never want to teach the student "skip the work". Always dropped, regardless of score. |
| 4 | `final_score < --score-threshold` (default 0.4) | Low-quality solves | Don't train on partial-fail patterns |
| 5 | Malformed action types | Action types outside `{code, submit, submit_file}` | Schema enforcement |
| 6 | No real work | Trajectories with no successful code step (`reward > 0.005`) | Drops "model only made syntax errors" cases |

The `--min-steps 2` and the explicit 1-step-submit-file check are
**redundant by design** β€” both catch the same exploit class so a future
refactor that loosens one doesn't open the door.

### Message reconstruction details

- **System prompt:** imported verbatim from `inference.SYSTEM_PROMPTS[family]`
  so the SFT corpus matches what the model sees at deployment.
- **First user message:** task instruction + constraints + source-file
  path (extracted from the trajectory's first code action via regex,
  falls back to manifest's `source_file`) + family + task type. The
  env's xlsx-summary section is intentionally skipped to avoid re-opening
  files at corpus-build time.
- **Assistant turns:** action content wrapped in the format the
  `extract_action()` parser expects:
  - `code` β†’ ` ```python\n{content}\n``` `
  - `submit` β†’ `SUBMIT_ANSWER: {content}`
  - `submit_file` β†’ `SUBMIT_FILE: {content}`
- **User turns:** mirror inference.py's per-step feedback message:
  ```
  Code execution result (step {n}/{max_steps}):
  {feedback}

  Source file: {path}
  ```

### Smoke test (against the MiniMax-M2.1 eval run)

```
Input rows    : 22
Accepted      : 10
Drops:
  low_score                    12

Accepted breakdown:
  docx      2
  pptx      4
  xlsx      4
Avg steps   : 10.8
Avg score   : 0.794
```

For the actual SFT corpus we'll use **train-split teacher trajectories
from Kimi-K2.5**, not the eval baseline. With 97 train tasks at
~30–50% retention rate that's ~30–50 high-quality episodes β€” enough for
a meaningful SFT warm-start before GRPO.

### Modified files

- None (new file only)

### Files unchanged in Phase 8

- env server, graders, manifest, data, deps

---

## Phase 9 β€” Hard early-submit gate at the env layer

**Trigger:** during Phase-2 trajectory collection on the train split,
Kimi-K2.5 was *still* trying to submit the unmodified source file at
step 1 (e.g., `pptarena_case_91_add_qr_code`), even though the Phase-7
grader correctly scored it 0.001. Post-grading defense alone wasn't
enough β€” every wasted "submit at step 1" episode was lost training data
and burned API budget.

### Fix: refuse the action before grading

[`server/financial_environment.py`](server/financial_environment.py) now
tracks `_code_steps_taken` (incremented in `_handle_code` regardless of
success β€” even a failed code attempt counts). Both submit handlers
(`_handle_submit_file`, `_handle_submit_text`) check
`_code_steps_taken >= _min_code_steps_before_submit` (default 1) and
return early with explanatory feedback if not.

Crucially, **the rejection does NOT end the episode**:

- The agent gets back a feedback message: `❌ Submit rejected: you must
  execute at least 1 code step before submitting...`
- The reward for the rejected step is `0.001`
- `done=False` β€” the agent has its remaining steps (15 - n_used) to recover

This shape is exactly right for an RL agent: ending the episode would
make a single bad attempt catastrophic; keeping it open turns it into
a corrective signal.

The minimum is overridable via `FINANCIAL_ENV_MIN_CODE_STEPS` env var.
Set to `0` to disable the gate (useful only for debugging).

### Belt-and-suspenders: prompt also tells the model

[`inference.py`](inference.py)'s `_BASE_RULES` now includes:

> 6. **You MUST execute at least one code step before submitting.** The
>    environment will reject SUBMIT_ANSWER and SUBMIT_FILE on step 1 β€” you
>    need to read or modify the file with code first. Submitting the source
>    file unchanged is never a correct solve and will be rejected.

Defense in depth: the prompt prevents wasted retries on models that
follow instructions; the env layer enforces the rule on models that
don't.

### Smoke test results

```
Reset:          code_steps_taken = 0, min_required = 1

Step 1: submit_file (early)       β†’ reward=0.001, done=False  βœ“ rejected
Step 2: code (any code)           β†’ counter increments to 1    βœ“
Step 3: submit_file (after code)  β†’ reward=normal, done=True   βœ“ allowed
Step 1: submit (QA, early)        β†’ reward=0.001, done=False   βœ“ same gate
Disabled (env var=0)              β†’ submit goes through        βœ“
```

### Stack of defenses against the "submit unchanged" exploit class

This is now the third independent defense, all targeting the same
exploit class:

| Layer | Phase | What it does |
|---|---|---|
| **Env action gate** | **9** (this one) | **Refuse the submit action itself if no code step has been taken** |
| Grader byte-equality | 7 | If submit happens AND output is byte-identical to source β†’ 0.001 |
| SFT corpus filter | 8 | Drop trajectories with `n_steps==1` and `submit_file` even at high score |

Layer 9 prevents the trajectory from existing in the first place.
Layer 7 catches it if Layer 9 is somehow bypassed (e.g.,
`FINANCIAL_ENV_MIN_CODE_STEPS=0`).
Layer 8 prevents future grader gaps from leaking into SFT training data.

### Modified files

- [`server/financial_environment.py`](server/financial_environment.py) β€”
  added `_code_steps_taken`, `_min_code_steps_before_submit`,
  `_early_submit_rejected()`. Both submit handlers gated.
- [`inference.py`](inference.py) β€” added rule #6 to `_BASE_RULES`.

### Files unchanged in Phase 9

- graders, manifest, data, deps

### Phase 9.1 β€” `--skip-completed` for cheap re-runs

After Phase 9 landed, the natural question was: "do I just run with `--resume`
and the env will sort it out?"  Answer: no β€” `--resume` alone re-runs every
selected task and merges. To save API spend on already-good trajectories,
added a `--skip-completed` flag to [`inference.py`](inference.py).

When set with `--resume`, drops tasks whose prior result is **clean**:
- `error` column empty
- `score >= --skip-completed-threshold` (default `0.05`)
- `steps > 1` β€” single-step results are the Phase-7 exploit pattern; always retried regardless of score

Re-runs only tasks that errored, scored low, or were single-step.  Concretely
for the existing MiniMax baseline run: 13 skipped (clean), 9 retried (low
score). For a Kimi train-split run with 1-step submit_file exploits, those
all fall into the "steps ≀ 1" bucket and get correctly re-tried under the
new Phase-9 env gate.

Usage:
```bash
python3 inference.py \
  --split train \
  --resume --skip-completed \
  --output-dir runs/teacher_kimi_k25_train \
  --model moonshotai/Kimi-K2.5 ...
```

If everything's already clean, the script prints "Nothing to do" and exits
without spending a cent.

---

## Phase 10 β€” SFT training script

**Goal:** warm-start `Qwen2.5-Coder-3B-Instruct` on the SFT corpus built
in Phase 8, before GRPO. Per the $45 budget plan (1Γ— A100 80GB on HF Jobs
@ $2.50/hr), SFT runs ~6h β‰ˆ $15 leaving ~$30 for GRPO + eval.

### New file
- [`train_sft.py`](train_sft.py) β€” TRL `SFTTrainer` driver. Loads the
  `messages`-format JSONL, applies the model's chat template, masks loss
  on user/system tokens (assistant-only loss), trains a LoRA adapter,
  optionally pushes to HF Hub.

### Key choices

| Decision | Why |
|---|---|
| **`assistant_only_loss=True`** | Multi-turn agent SFT β€” we don't want to train on env-generated user feedback, only on assistant turns (the things the model produces) |
| **LoRA r=32, alpha=64, all-linear targets** | Sweet spot for 3B+ models; full-FT memory cost is unjustified for a $45 budget |
| **bf16 + gradient checkpointing + 8K seq len** | Fits a 3B model + 32-rank LoRA + 8K context comfortably on A100 80GB; can be dropped to 4K + r=16 for L40S 48GB |
| **`packing=False`** | Multi-turn examples are too varied to pack cleanly; each episode is its own sample |
| **CLI: `--push-to-hub`** | Optional push for the GRPO step to pull the SFT adapter from Hub instead of local disk |
| **CLI: `--use-qlora`** | 4-bit quantization fallback for tighter VRAM (e.g. consumer GPU dev) |

### Command (HF Jobs)

```bash
hf jobs run \
  --hardware "Nvidia A100 - large" \
  --timeout 8h \
  --image "huggingface/transformers-pytorch-gpu:latest" \
  --secrets HF_TOKEN \
  -- \
  bash -c "pip install -U 'trl>=0.11' peft accelerate bitsandbytes && \
           python train_sft.py \
             --dataset data/sft_kimi_k25.jsonl \
             --output-dir /tmp/qwen3b-sft \
             --push-to-hub bpHigh/qwen3b-office-sft"
```

### Local smoke test

The argparse layer imports cleanly without GPU. The full training requires
a GPU + the trl/peft/accelerate stack β€” not run locally as part of CI; the
real validation is the HF Jobs run.

### Modified files

- None (new file only)

### Files unchanged in Phase 10

- env server, graders, manifest, data, deps

---

## Current state (post-Phase 10)

### Repo layout

```
openenv_financial_task_env/
β”œβ”€β”€ data/
β”‚   β”œβ”€β”€ manifest.jsonl                # 109 rows: 50 Finch + 21 OSWorld + 38 PPTArena
β”‚   β”œβ”€β”€ 0/, 21/, 24/, …               # original 10 hand-curated task dirs (xlsx)
β”‚   β”œβ”€β”€ finch_50/<orig_id>/{src,ref}.xlsx
β”‚   β”œβ”€β”€ osworld_writer/<uuid>/<src + N gold files>.docx
β”‚   └── pptarena/<slug>/{<slug>_src,<slug>_ref}.pptx
β”œβ”€β”€ data_pipeline/
β”‚   β”œβ”€β”€ finch_pull.py                 # Phase 1
β”‚   β”œβ”€β”€ osworld_writer_pull.py        # Phase 3
β”‚   └── pptarena_pull.py              # Phase 5
β”œβ”€β”€ graders/
β”‚   β”œβ”€β”€ __init__.py                   # grade_xlsx + grade_docx + grade_pptx + dispatcher
β”‚   └── docx_metrics.py               # 16 OSWorld evaluator functions
β”œβ”€β”€ rewards.py                        # Phase 2; updated in Phase 4
β”œβ”€β”€ server/financial_environment.py   # gold stash + per-task eval signal wired in
β”œβ”€β”€ tasks.py                          # manifest loader; absolute-path resolution
β”œβ”€β”€ models.py                         # unchanged
β”œβ”€β”€ client.py                         # unchanged
β”œβ”€β”€ inference.py                      # unchanged
β”œβ”€β”€ pyproject.toml                    # +python-docx, +python-pptx, +rapidfuzz, +Pillow
β”œβ”€β”€ Dockerfile                        # +python-docx, +python-pptx, +rapidfuzz, +Pillow
β”œβ”€β”€ openenv.yaml                      # unchanged from Round 1
└── edits.md                          # this file
```

### Task inventory

| Family | Source | Train | Eval | Total |
|---|---|---|---|---|
| xlsx | hand-curated | 10 | 0 | 10 |
| xlsx | Finch | 40 | 10 | 50 |
| docx | OSWorld writer | 17 | 4 | 21 |
| pptx | PPTArena | 30 | 8 | 38 |
| **total** | | **97** | **22** | **119** |

### Reward signal stack

| Layer | Purpose | Mode |
|---|---|---|
| Per-step `RewardTracker` | Dense process reward (6 components) | Always on |
| `progress` | Structural distance to gold ↓ | On for training, off for eval (`FINANCIAL_ENV_PROGRESS=0`) |
| `eval_check` | Per-task evaluator score ↑ | Auto-enabled when task has an evaluator block (currently docx only) |
| Final grade β€” xlsx | 30% sheet-name + 70% cell-level diff | Submit-only |
| Final grade β€” docx | Validity gate + 40% diff + 60% per-task evaluator | Submit-only |
| Final grade β€” pptx | Validity gate + 20% slide-count + 80% avg(40% text + 20% style + 20% position + 20% size) | Submit-only |

### Defenses against reward hacking

| Vector | Status | Details |
|---|---|---|
| Persistent globals | βœ… Each step is fresh `subprocess.run` |
| Time runaway | βœ… 30s subprocess timeout |
| Memory runaway | ⚠️ No `ulimit` yet (TODO) |
| Glob the gold via `data/` | βœ… Gold moved out of `data/` for the episode |
| Read manifest.jsonl to find gold path | ⚠️ Still reachable; would need full sandbox isolation (TODO) |
| Generic-distance gaming | βœ… `eval_check` rewards spec-aligned progress |
| **Submit-source-unchanged** (Phase 7) | βœ… Byte-equality check at grade time β†’ 0.001 |
| **1-step-submit-file in SFT corpus** (Phase 8) | βœ… Builder drops these even at high score |
| **Early submit before any code step** (Phase 9) | βœ… Env refuses the action itself; episode stays open for recovery |
| `lib_engagement` regex gaming | 🟑 Trivial cap (0.010); AST-based check would harden (TODO) |
| `mutation` spam | 🟑 Capped per-step but could spam-save garbage; could couple to progress (TODO) |

---

## Open issues / next steps (not yet done)

1. ~~**Layout-aware pptx diff**~~ β€” **DONE** in Phase 5 follow-up. Position
   + size matching with tolerance now active. 5 of 8 eval tasks meaningfully
   degrade source-vs-gold; 3 styling-only tasks still don't (see #2).

2. ~~**Style-aware pptx diff**~~ β€” **DONE** (Phase 5 follow-up #2). 7-attribute
   style match (fill/line color, first-run font name/size/bold/italic/color).
   Unblocked the pure-styling task `case_26` (discrimination 0 β†’ 0.028).

3. **Edit-zone masking for pptx** β€” current diff averages over all shapes,
   so small targeted edits get diluted. Mask the score to shapes whose
   attributes differ between source and gold. Changes semantics: "did the
   agent fix the parts that were supposed to change" instead of "how close
   to gold overall". ~30 lines. **Priority:** medium β€” biggest improvement
   on tasks where edit surface is <5% of the deck.

4. **PPTX VLM judge** (optional, behind `PPTX_VLM_JUDGE=1`): render slides
   via headless LibreOffice β†’ PNG, send (instruction, before, after, ref)
   to a VLM. Matches PPTArena's published methodology. Expensive β€” defer
   to final eval-time only, not training inner loop.

3. **TSBench** β€” skipped this round because it ships only `ideal_description`
   text (no gold files). Could add later as an LLM-judge family. Would
   need a separate grader; structurally similar to a per-task evaluator
   that calls Claude/GPT-4o with `(diff_summary, ideal_description)`.

4. **Memory cgroup** on agent subprocess: prevent OOM-bomb step from killing
   the env server.

5. **AST-based library check** in rewards.py: replace regex with real call
   detection so `import openpyxl  # decoy` doesn't earn the bonus.

6. **Couple mutation reward to progress**: only credit `mutation` if
   `progress > 0` in the same step OR last N steps β€” kills the spam-save
   strategy while preserving exploration credit.

7. **Manifest hiding for full sandbox isolation**: at server startup, also
   move/redact `data/manifest.jsonl` so a determined agent can't read it
   from the subprocess. Better: deploy with the data tree mounted at a
   path the agent's cwd subtree can't reach (bwrap, or docker bind-mounts
   to e.g. `/var/lib/openenv_data`).

8. **Test on more docx evaluator types** end-to-end. Currently smoke-tested
   `compare_docx_files` (single + compound `and`) and `compare_docx_tables`.
   Should sweep all 16 evaluators with synthetic agent outputs.

9. **`osworld_0a0faba3` quirk** β€” gold doesn't self-pass `check_tabstops`
   constraint due to a 2-words-before-tab paragraph. Either move to eval-only
   or relax the constraint.

10. **Inference baseline** β€” re-run the Round-1 inference script across all
    119 tasks (or a stratified subset) to refresh the README scoreboard.

11. **README rewrite** β€” current README is Round-1. Needs the cross-format
    pitch (xlsx + docx + pptx), the multi-layer grader story, the
    gaming-resistance angle.

12. **Training script** β€” TRL/Unsloth GRPO with LoRA on Qwen2.5-Coder-3B,
    trajectory-collection from a teacher (Claude Haiku 4.5), + SFT warm-start.
    Per the earlier $100-budget plan.

---

## Phase 13 β€” GRPO rollout fix: custom `rollout_func` for markdown JSON tool calls

**Symptom:** First GRPO run started with `environment_factory=OfficeDocumentEnv`
showed reward stuck at 0.0 across every step in Trackio.  Captured a
completion sample mid-run and confirmed the model was emitting:

```
```json
{"name": "run_python_code", "arguments": {"code": "..."}}
```
```

…but TRL's `environment_factory` path runs `add_response_schema(tokenizer)` β†’
`qwen3_schema`, whose regex only matches `<tool_call>...</tool_call>` XML.
The parser found 0 tool calls per completion, the env never received a
step, reward stayed 0, advantage was 0, and gradient flow through the
model was effectively zero.  ~5 min of A100 time burned learning nothing.

**Root cause:** the SFT'd model (`bpHigh/qwen3b-office-sft-kimi`) was
trained on 53 Kimi-K2.5 trajectories where the assistant emits markdown
JSON blocks.  The SFT overwrote Qwen2.5-Coder's native `<tool_call>` XML
behavior.  TRL's tool-call parser is hardcoded to one of five known
schemas (glm4, gptoss, llama3, qwen3, qwen3_5) β€” none of which match
markdown blocks.

### Fix: bypass the parser by writing our own rollout

Switched `train_grpo.py` from `environment_factory=OfficeDocumentEnv` to
`rollout_func=rollout_func`.  TRL's two rollout paths:

| Mode | Who drives the loop | Tool-call format | Used here? |
|---|---|---|---|
| `environment_factory` | TRL's internal parser | `<tool_call>...</tool_call>` XML only | ❌ broken for our SFT model |
| `rollout_func` | User callback | Anything you want β€” you parse it | βœ… |

### New `rollout_func(prompts, trainer)` β€” ~150 LOC in [`train_grpo.py`](train_grpo.py)

For each `prompt Γ— num_generations`:

1. Spawn an `OfficeDocumentEnv` and reset it with the task's `task_id`
   (recovered from a `<task_id:...>` marker we now embed in the user
   prompt β€” TRL doesn't pass dataset columns to `rollout_func`).
2. Apply the chat template to the initial `[system, user]` messages,
   tokenize β†’ `prompt_ids`.
3. Loop up to 12 turns:
   a. Batch-call `trainer.vllm_generation.generate()` for every alive
      rollout in parallel (one generation per rollout per turn).
   b. Decode each completion β†’ text.
   c. Parse via `parse_tool_call(text)`:
      - First try ```` ```json {"name": ..., "arguments": ...} ``` ````
        (primary SFT format).
      - Fall back to ```` ```python ... ``` ```` β†’ `run_python_code`.
      - Fall back to Kimi K2.5 `<|tool_call_begin|>` markers.
   d. Dispatch to `env.run_python_code` / `env.submit_file` /
      `env.submit_text_answer`.
   e. Tokenize the env feedback as a user-message wire format
      (chat-template diff: `tok.apply_chat_template(after) βˆ’ before`),
      append to `completion_ids` with `logprob=0` and `env_mask=0`.
4. After loop, return per-rollout:
   - `prompt_ids`, `completion_ids`, `logprobs`, `env_mask`
   - `env_reward_value` (extra field) β€” TRL forwards this as a kwarg
     to the reward function

### Reward function update

Old: `def env_reward(environments, **kwargs)` β€” read from TRL-managed env
instances.

New: `def env_reward(prompts=None, completions=None, env_reward_value=None, **kwargs)`
β€” read directly from the extra field returned by `rollout_func`.

### Why `env_mask` matters

The `env_mask` field tells TRL "these tokens are NOT model-emitted, don't
flow loss through them."  Without it, GRPO would compute loss on env
feedback tokens too, which is meaningless (the model didn't pick those
tokens β€” the env did).

### Modified files

- [`train_grpo.py`](train_grpo.py):
  - SYSTEM_PROMPT rewritten to instruct the model in its native markdown
    JSON format (not XML).
  - User prompt now prefixes `<task_id:NAME>\n\n` so `rollout_func` can
    recover task identity.
  - Added `parse_tool_call(text) -> dict | None` β€” three-format parser.
  - Added `rollout_func(prompts, trainer) -> dict` β€” the new rollout.
  - Removed `tokenizer.response_schema = qwen3_schema` (no longer
    needed β€” we don't go through TRL's parser).
  - Removed `max_tool_calling_iterations` from `GRPOConfig` (we cap
    turns ourselves at 12).
  - GRPOTrainer constructor: `environment_factory=...` β†’ `rollout_func=...`.

### Files unchanged in Phase 13

- [`server/financial_environment.py`](server/financial_environment.py)
- [`server/app.py`](server/app.py)
- [`client.py`](client.py)
- All SFT artifacts and dashboard code

The env-side concurrent-session work from the prior commits
(`SUPPORTS_CONCURRENT_SESSIONS=True`, `max_concurrent_envs=16`,
`FINANCIAL_ENV_GOLD_STASH=copy`) is still required β€” `rollout_func`
opens batch_size Γ— num_generations env sessions in parallel within each
gradient step.

### Risks / things to watch

1. **Token alignment fragility**: tokenizing the env-feedback "wire
   format" via a chat-template diff assumes the template doesn't insert
   anything weird mid-conversation.  If Qwen2.5-Coder's template ever
   changes, the diff approach could mis-attribute boundary tokens.
   Mitigation: print sample completions from the first training step
   and verify env_mask boundaries by hand.

2. **Concurrency on the env Space**: with `num_generations=2` and
   `gradient_accumulation_steps=8`, each gradient step opens 16 env
   sessions in parallel β€” exactly at the Space's `max_concurrent_envs=16`
   limit.  If we bump `num_generations` to 4, also bump
   `max_concurrent_envs` to 32.

3. **Per-turn cap of 1024 tokens**: `_ROLLOUT_MAX_TOKENS_PER_TURN` was
   chosen for safety, but if the model wants to emit a long python block
   it gets truncated.  Tune up if we see long-code tasks failing.

### Trackio run hygiene

The first (failed) GRPO run logged `office-doc-grpo` to
`bpHigh/trackio-office-grpo`.  Renamed/archived rather than deleted β€”
it's evidence of the parser-format mismatch.  The post-fix run logs to
the same project name; the failed run is suffixed `-attempt1` for
provenance.

---

## Re-deploy checklist

If a fresh contributor wants to reproduce the current state from
commit `bf77949`:

1. `pip install -e ".[dev]"` (now pulls python-docx, python-pptx, rapidfuzz, Pillow)
2. `python data_pipeline/finch_pull.py` β€” ~3 min, downloads ~42 MB
3. `python data_pipeline/osworld_writer_pull.py` β€” ~30 s, downloads ~10 MB
4. Download/clone PPTArena to a local path (e.g. `~/Downloads/PPTArena-main`),
   then `python data_pipeline/pptarena_pull.py --root ~/Downloads/PPTArena-main`
   β€” copies ~244 MB
5. Check `data/manifest.jsonl` has 109 lines (50 + 21 + 38)
6. `python -c "from tasks import TASKS; print(len(TASKS))"` should print 119
7. Smoke test: `python -c "from server.financial_environment import FinancialEnvironment; e = FinancialEnvironment(); o = e.reset(task_id='finch_10'); print(o.task_id)"`
8. Docker build: `docker build -t financial-task-env:latest .` β€” should complete cleanly with the new deps

For training (RL):
- Set `FINANCIAL_ENV_PROGRESS=1` (default) for dense gradient
- Ensure each rollout worker uses its own `FinancialEnvironment` instance β€” gold-stash is single-tenant per task