diff --git a/scripts/eval_only.py b/scripts/eval_only.py
index e92c05c4b..a8d1620d7 100644
--- a/scripts/eval_only.py
+++ b/scripts/eval_only.py
@@ -48,6 +48,7 @@
)
from skillopt.model.common import default_model_for_backend, normalize_backend_name
from skillopt.utils import compute_score
+from skillopt.utils.text_io import read_text
_OPENAI_DEFAULT_MODEL_SENTINELS = {"gpt-5.4", "gpt-5.5"}
_ROLE_BACKEND_DEFAULTS = (None, "", "openai_chat")
@@ -608,8 +609,7 @@ def main() -> None:
# Load skill
skill_path = os.path.abspath(args.skill)
- with open(skill_path) as f:
- skill_content = f.read()
+ skill_content = read_text(skill_path)
print(f" [skill] {skill_path} ({len(skill_content)} chars)")
# Configure models
@@ -766,7 +766,7 @@ def main() -> None:
"hard": hard,
"soft": soft,
}
- with open(os.path.join(out_root, "eval_summary.json"), "w") as f:
+ with open(os.path.join(out_root, "eval_summary.json"), "w", encoding="utf-8") as f:
json.dump(summary, f, indent=2, ensure_ascii=False)
print(f" Saved to: {out_root}")
diff --git a/skillopt/engine/trainer.py b/skillopt/engine/trainer.py
index 144e37dc2..7b9c7eb2c 100644
--- a/skillopt/engine/trainer.py
+++ b/skillopt/engine/trainer.py
@@ -83,6 +83,7 @@
short_item_summary,
)
from skillopt.utils import compute_score, skill_hash
+from skillopt.utils.text_io import read_text
# ── Skill-aware reflection: appendix flush ───────────────────────────────────
@@ -116,7 +117,7 @@ def _flush_skill_aware_appendix(
step_rec["n_execution_lapse_notes"] = len(step_appendix_notes)
step_rec["n_appendix_notes_added"] = n_added
step_rec["n_appendix_notes_total"] = len(after_notes)
- with open(os.path.join(step_dir, "appendix_notes.json"), "w") as f:
+ with open(os.path.join(step_dir, "appendix_notes.json"), "w", encoding="utf-8") as f:
json.dump(
{
"step_notes": step_appendix_notes,
@@ -357,28 +358,26 @@ def _redact_cfg(cfg: dict) -> dict:
def _load_history(out_root: str) -> list[dict]:
path = os.path.join(out_root, "history.json")
if os.path.exists(path):
- with open(path) as f:
- return json.load(f)
+ return json.loads(read_text(path))
return []
def _save_history(out_root: str, history: list[dict]) -> None:
path = os.path.join(out_root, "history.json")
- with open(path, "w") as f:
+ with open(path, "w", encoding="utf-8") as f:
json.dump(history, f, ensure_ascii=False, indent=2)
def _save_skill(out_root: str, step: int, content: str) -> None:
skills_dir = os.path.join(out_root, "skills")
os.makedirs(skills_dir, exist_ok=True)
- with open(os.path.join(skills_dir, f"skill_v{step:04d}.md"), "w") as f:
+ with open(os.path.join(skills_dir, f"skill_v{step:04d}.md"), "w", encoding="utf-8") as f:
f.write(content)
def _load_skill(out_root: str, step: int) -> str:
path = os.path.join(out_root, "skills", f"skill_v{step:04d}.md")
- with open(path) as f:
- return f.read()
+ return read_text(path)
def _load_meta_skill_content(out_root: str, epoch: int) -> str:
@@ -390,8 +389,7 @@ def _load_meta_skill_content(out_root: str, epoch: int) -> str:
if not os.path.exists(path):
return ""
try:
- with open(path) as f:
- result = json.load(f)
+ result = json.loads(read_text(path))
return str(result.get("meta_skill_content", "")).strip()
except Exception:
return ""
@@ -402,8 +400,7 @@ def _load_runtime_state(out_root: str) -> dict | None:
if not os.path.exists(path):
return None
try:
- with open(path) as f:
- state = json.load(f)
+ state = json.loads(read_text(path))
return state if isinstance(state, dict) else None
except Exception:
return None
@@ -411,7 +408,7 @@ def _load_runtime_state(out_root: str) -> dict | None:
def _save_runtime_state(out_root: str, state: dict) -> None:
path = os.path.join(out_root, "runtime_state.json")
- with open(path, "w") as f:
+ with open(path, "w", encoding="utf-8") as f:
json.dump(state, f, ensure_ascii=False, indent=2)
@@ -598,8 +595,7 @@ def _extract_failure_patterns(
continue
seen_patch_files.add(fname)
try:
- with open(fname) as f:
- patch = json.load(f)
+ patch = json.loads(read_text(fname))
for fs in patch.get("failure_summary", []):
ft = fs.get("failure_type", "")
sd = fs.get("description", "")
@@ -893,8 +889,7 @@ def _build_eval_env(split: str, env_num: int, seed: int):
# ── Load initial skill ───────────────────────────────────────────
skill_init_path = os.path.abspath(cfg["skill_init"])
if os.path.exists(skill_init_path):
- with open(skill_init_path) as f:
- skill_init = f.read()
+ skill_init = read_text(skill_init_path)
print(f" [initial skill] {skill_init_path} ({len(skill_init)} chars)")
else:
skill_init = ""
@@ -943,7 +938,7 @@ def _build_eval_env(split: str, env_num: int, seed: int):
cfg["resolved_qwen_thinking_modes"] = get_qwen_thinking_modes()
# Save config after deriving runtime values.
- with open(os.path.join(out_root, "config.json"), "w") as f:
+ with open(os.path.join(out_root, "config.json"), "w", encoding="utf-8") as f:
json.dump(_redact_cfg(cfg), f, indent=2, ensure_ascii=False)
train_pool_size = train_size
@@ -989,14 +984,12 @@ def _build_eval_env(split: str, env_num: int, seed: int):
current_skill_path = runtime_state.get("current_skill_path") or os.path.join(
out_root, "skills", f"skill_v{last_step:04d}.md",
)
- with open(current_skill_path) as f:
- current_skill = f.read()
+ current_skill = read_text(current_skill_path)
best_skill_path = runtime_state.get("best_skill_path") or os.path.join(
out_root, "best_skill.md",
)
if os.path.exists(best_skill_path):
- with open(best_skill_path) as f:
- best_skill = f.read()
+ best_skill = read_text(best_skill_path)
else:
best_skill = current_skill
current_score = float(runtime_state.get("current_score", -1.0) or -1.0)
@@ -1022,8 +1015,7 @@ def _build_eval_env(split: str, env_num: int, seed: int):
best_step = best_rec["best_step"]
best_skill_path = os.path.join(out_root, "best_skill.md")
if os.path.exists(best_skill_path):
- with open(best_skill_path) as f:
- best_skill = f.read()
+ best_skill = read_text(best_skill_path)
else:
best_skill = _load_skill(out_root, best_step)
current_score = history[-1].get("current_score", best_score)
@@ -1354,7 +1346,7 @@ def _persist_runtime_state(last_completed_step: int) -> None:
_save_history(out_root, history)
_save_skill(out_root, global_step, current_skill)
_persist_runtime_state(global_step)
- with open(os.path.join(step_dir, "step_record.json"), "w") as f:
+ with open(os.path.join(step_dir, "step_record.json"), "w", encoding="utf-8") as f:
json.dump(step_rec, f, indent=2, ensure_ascii=False)
print(" [skip] no usable patches — skill unchanged")
continue
@@ -1368,7 +1360,7 @@ def _persist_runtime_state(last_completed_step: int) -> None:
update_mode=update_mode,
meta_skill_context=active_meta_skill,
)
- with open(os.path.join(step_dir, "merged_patch.json"), "w") as f:
+ with open(os.path.join(step_dir, "merged_patch.json"), "w", encoding="utf-8") as f:
json.dump(merged_patch, f, ensure_ascii=False, indent=2)
merged_items = get_payload_items(merged_patch, update_mode)
@@ -1388,7 +1380,7 @@ def _persist_runtime_state(last_completed_step: int) -> None:
step_rec["n_edits_ranked"] = n_edits_ranked
step_rec["edit_budget"] = None
step_rec["lr_control_mode"] = "none"
- with open(os.path.join(step_dir, "ranked_edits.json"), "w") as f:
+ with open(os.path.join(step_dir, "ranked_edits.json"), "w", encoding="utf-8") as f:
json.dump(ranked_patch, f, ensure_ascii=False, indent=2)
else:
if lr_control_mode == "autonomous":
@@ -1403,9 +1395,9 @@ def _persist_runtime_state(last_completed_step: int) -> None:
meta_skill_context=active_meta_skill,
)
edit_budget = int(lr_decision["learning_rate"])
- with open(os.path.join(step_dir, "lr_decision.json"), "w") as f:
+ with open(os.path.join(step_dir, "lr_decision.json"), "w", encoding="utf-8") as f:
json.dump(lr_decision, f, ensure_ascii=False, indent=2)
- with open(os.path.join(out_root, "lr_history.jsonl"), "a") as f:
+ with open(os.path.join(out_root, "lr_history.jsonl"), "a", encoding="utf-8") as f:
f.write(json.dumps({
"step": global_step,
"epoch": epoch,
@@ -1419,7 +1411,7 @@ def _persist_runtime_state(last_completed_step: int) -> None:
update_mode=update_mode,
meta_skill_context=active_meta_skill,
)
- with open(os.path.join(step_dir, "ranked_edits.json"), "w") as f:
+ with open(os.path.join(step_dir, "ranked_edits.json"), "w", encoding="utf-8") as f:
json.dump(ranked_patch, f, ensure_ascii=False, indent=2)
ranked_items = get_payload_items(ranked_patch, update_mode)
@@ -1462,7 +1454,7 @@ def _persist_runtime_state(last_completed_step: int) -> None:
if rewrite_result and rewrite_result.get("new_skill"):
candidate_skill = rewrite_result["new_skill"]
apply_report = []
- with open(os.path.join(step_dir, "rewrite_result.json"), "w") as f:
+ with open(os.path.join(step_dir, "rewrite_result.json"), "w", encoding="utf-8") as f:
json.dump(rewrite_result, f, ensure_ascii=False, indent=2)
else:
candidate_skill = current_skill
@@ -1485,7 +1477,7 @@ def _persist_runtime_state(last_completed_step: int) -> None:
"title": selected_candidate.get("title", ""),
"source_type": selected_candidate.get("source_type", ""),
}
- with open(os.path.join(step_dir, "full_rewrite_result.json"), "w") as f:
+ with open(os.path.join(step_dir, "full_rewrite_result.json"), "w", encoding="utf-8") as f:
json.dump(
{
"selected_candidate": selected_candidate,
@@ -1500,10 +1492,10 @@ def _persist_runtime_state(last_completed_step: int) -> None:
apply_report = []
else:
candidate_skill, apply_report = apply_patch_with_report(current_skill, ranked_patch)
- with open(os.path.join(step_dir, "candidate_skill.md"), "w") as f:
+ with open(os.path.join(step_dir, "candidate_skill.md"), "w", encoding="utf-8") as f:
f.write(candidate_skill)
if apply_report:
- with open(os.path.join(step_dir, "edit_apply_report.json"), "w") as f:
+ with open(os.path.join(step_dir, "edit_apply_report.json"), "w", encoding="utf-8") as f:
json.dump(apply_report, f, indent=2, ensure_ascii=False)
cand_hash = skill_hash(candidate_skill)
@@ -1548,7 +1540,7 @@ def _persist_runtime_state(last_completed_step: int) -> None:
_save_history(out_root, history)
_save_skill(out_root, global_step, current_skill)
_persist_runtime_state(global_step)
- with open(os.path.join(step_dir, "step_record.json"), "w") as f:
+ with open(os.path.join(step_dir, "step_record.json"), "w", encoding="utf-8") as f:
json.dump(step_rec, f, indent=2, ensure_ascii=False)
print(" [skip] no usable rewrite generated — skill unchanged")
continue
@@ -1709,7 +1701,7 @@ def _persist_runtime_state(last_completed_step: int) -> None:
# Persist step digest for step buffer context
digest_path = os.path.join(step_dir, "trajectory_digest.json")
- with open(digest_path, "w") as f:
+ with open(digest_path, "w", encoding="utf-8") as f:
json.dump(buf_entry, f, indent=2, ensure_ascii=False)
# ── Token snapshot ───────────────────────────────────────
@@ -1739,12 +1731,12 @@ def _persist_runtime_state(last_completed_step: int) -> None:
step_rec["wall_time_s"] = round(time.time() - step_t0, 1)
_save_skill(out_root, global_step, current_skill)
- with open(os.path.join(out_root, "best_skill.md"), "w") as f:
+ with open(os.path.join(out_root, "best_skill.md"), "w", encoding="utf-8") as f:
f.write(best_skill)
history.append(step_rec)
_save_history(out_root, history)
_persist_runtime_state(global_step)
- with open(os.path.join(step_dir, "step_record.json"), "w") as f:
+ with open(os.path.join(step_dir, "step_record.json"), "w", encoding="utf-8") as f:
json.dump(step_rec, f, indent=2, ensure_ascii=False)
timing = step_rec["timing"]
@@ -1775,13 +1767,11 @@ def _persist_runtime_state(last_completed_step: int) -> None:
f"\n [SLOW UPDATE epoch {epoch}] "
f"resumed — already done"
)
- with open(slow_done_path) as f:
- slow_saved = json.load(f)
+ slow_saved = json.loads(read_text(slow_done_path))
comparison_path = os.path.join(slow_dir, "comparison_pairs.json")
if os.path.exists(comparison_path):
try:
- with open(comparison_path) as f:
- epoch_comparison_pairs = json.load(f)
+ epoch_comparison_pairs = json.loads(read_text(comparison_path))
except Exception:
epoch_comparison_pairs = None
if (
@@ -1813,9 +1803,9 @@ def _persist_runtime_state(last_completed_step: int) -> None:
current_skill = inject_empty_slow_update_field(current_skill)
current_origin = f"slow_update_placeholder_epoch_{epoch:02d}"
_save_skill(out_root, global_step, current_skill)
- with open(os.path.join(out_root, "best_skill.md"), "w") as f:
+ with open(os.path.join(out_root, "best_skill.md"), "w", encoding="utf-8") as f:
f.write(best_skill)
- with open(slow_done_path, "w") as f:
+ with open(slow_done_path, "w", encoding="utf-8") as f:
json.dump({"action": "inject_placeholder", "epoch": epoch}, f, indent=2)
_persist_runtime_state(global_step)
print(
@@ -1934,7 +1924,7 @@ def _persist_runtime_state(last_completed_step: int) -> None:
current_skill, slow_result["slow_update_content"],
)
slow_candidate_hash = skill_hash(slow_candidate)
- with open(os.path.join(slow_dir, "candidate_skill.md"), "w") as f:
+ with open(os.path.join(slow_dir, "candidate_skill.md"), "w", encoding="utf-8") as f:
f.write(slow_candidate)
slow_result["time_s"] = slow_time
slow_result["prev_hard"] = prev_hard
@@ -2071,10 +2061,10 @@ def _persist_runtime_state(last_completed_step: int) -> None:
)
# 5. Save
- with open(slow_done_path, "w") as f:
+ with open(slow_done_path, "w", encoding="utf-8") as f:
json.dump(slow_result, f, indent=2, ensure_ascii=False)
_save_skill(out_root, global_step, current_skill)
- with open(os.path.join(out_root, "best_skill.md"), "w") as f:
+ with open(os.path.join(out_root, "best_skill.md"), "w", encoding="utf-8") as f:
f.write(best_skill)
_persist_runtime_state(global_step)
@@ -2093,7 +2083,7 @@ def _persist_runtime_state(last_completed_step: int) -> None:
if os.path.exists(meta_skill_done_path):
print(f"\n [META SKILL epoch {epoch}] resumed — already done")
elif epoch == 1:
- with open(meta_skill_done_path, "w") as f:
+ with open(meta_skill_done_path, "w", encoding="utf-8") as f:
json.dump(
{"action": "skip_first_epoch", "epoch": epoch},
f, indent=2, ensure_ascii=False,
@@ -2193,11 +2183,11 @@ def _persist_runtime_state(last_completed_step: int) -> None:
meta_skill_result["action"] = "no_content"
print(f" [meta skill] no memory produced, {meta_skill_time}s")
- with open(meta_skill_done_path, "w") as f:
+ with open(meta_skill_done_path, "w", encoding="utf-8") as f:
json.dump(meta_skill_result, f, indent=2, ensure_ascii=False)
# ── Save best skill ──────────────────────────────────────────────
- with open(os.path.join(out_root, "best_skill.md"), "w") as f:
+ with open(os.path.join(out_root, "best_skill.md"), "w", encoding="utf-8") as f:
f.write(best_skill)
_persist_runtime_state(global_step)
print(
@@ -2270,7 +2260,7 @@ def _persist_runtime_state(last_completed_step: int) -> None:
best_score = final_gate_score
best_step = global_step
best_origin = current_origin
- with open(os.path.join(out_root, "best_skill.md"), "w") as f:
+ with open(os.path.join(out_root, "best_skill.md"), "w", encoding="utf-8") as f:
f.write(best_skill)
_persist_runtime_state(global_step)
except Exception as _e: # noqa: BLE001
@@ -2301,7 +2291,7 @@ def _persist_runtime_state(last_completed_step: int) -> None:
f" {task_type:<40s}: "
f"hard={b['hard']}/{b['total']}={b['hard']/t:.4f}"
)
- with open(os.path.join(baseline_test_dir, "summary.json"), "w") as f:
+ with open(os.path.join(baseline_test_dir, "summary.json"), "w", encoding="utf-8") as f:
json.dump(
{
k: {
@@ -2336,7 +2326,7 @@ def _persist_runtime_state(last_completed_step: int) -> None:
f" {task_type:<40s}: "
f"hard={b['hard']}/{b['total']}={b['hard']/t:.4f}"
)
- with open(os.path.join(test_dir, "summary.json"), "w") as f:
+ with open(os.path.join(test_dir, "summary.json"), "w", encoding="utf-8") as f:
json.dump(
{
k: {
@@ -2362,7 +2352,7 @@ def _persist_runtime_state(last_completed_step: int) -> None:
final_test_hard, final_test_soft = test_hard, test_soft
final_test_dir = os.path.join(out_root, "test_eval_final")
os.makedirs(final_test_dir, exist_ok=True)
- with open(os.path.join(final_test_dir, "summary.json"), "w") as f:
+ with open(os.path.join(final_test_dir, "summary.json"), "w", encoding="utf-8") as f:
json.dump(
{
k: {
@@ -2400,7 +2390,7 @@ def _persist_runtime_state(last_completed_step: int) -> None:
f" {task_type:<40s}: "
f"hard={b['hard']}/{b['total']}={b['hard']/t:.4f}"
)
- with open(os.path.join(final_test_dir, "summary.json"), "w") as f:
+ with open(os.path.join(final_test_dir, "summary.json"), "w", encoding="utf-8") as f:
json.dump(
{
k: {
@@ -2490,7 +2480,7 @@ def _persist_runtime_state(last_completed_step: int) -> None:
"total_wall_time_s": round(total_wall, 1),
"token_summary": token_summary,
}
- with open(os.path.join(out_root, "summary.json"), "w") as f:
+ with open(os.path.join(out_root, "summary.json"), "w", encoding="utf-8") as f:
json.dump(summary, f, indent=2, ensure_ascii=False)
print(f"\n{'='*60}")
diff --git a/skillopt/envs/alfworld/adapter.py b/skillopt/envs/alfworld/adapter.py
index 18db01b04..f3e2af558 100644
--- a/skillopt/envs/alfworld/adapter.py
+++ b/skillopt/envs/alfworld/adapter.py
@@ -18,6 +18,7 @@
TASKS,
)
from skillopt.utils import compute_score
+from skillopt.utils.text_io import read_lines
@dataclass(frozen=True)
@@ -329,12 +330,11 @@ def rollout(
# Resume support
if os.path.exists(results_path):
existing: list[dict] = []
- with open(results_path) as f:
- for line in f:
- try:
- existing.append(json.loads(line))
- except Exception:
- pass
+ for line in read_lines(results_path):
+ try:
+ existing.append(json.loads(line))
+ except Exception:
+ pass
if existing:
return existing
@@ -355,7 +355,7 @@ def rollout(
result_ids=getattr(env_manager, "_skillopt_result_ids", None),
)
- with open(results_path, "w") as f:
+ with open(results_path, "w", encoding="utf-8") as f:
for r in results:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
diff --git a/skillopt/envs/livemathematicianbench/dataloader.py b/skillopt/envs/livemathematicianbench/dataloader.py
index 3ab53f583..9536c7f0e 100644
--- a/skillopt/envs/livemathematicianbench/dataloader.py
+++ b/skillopt/envs/livemathematicianbench/dataloader.py
@@ -17,7 +17,7 @@
def _load_json(path: str) -> Any:
- with open(path) as f:
+ with open(path, encoding="utf-8") as f:
return json.load(f)
diff --git a/skillopt/envs/livemathematicianbench/rollout.py b/skillopt/envs/livemathematicianbench/rollout.py
index 50a9b15a9..59e1a37ff 100644
--- a/skillopt/envs/livemathematicianbench/rollout.py
+++ b/skillopt/envs/livemathematicianbench/rollout.py
@@ -11,6 +11,7 @@
from skillopt.model import chat_target, get_target_backend, is_target_exec_backend
from skillopt.model.codex_harness import prepare_workspace, render_skill_md, run_target_exec
from skillopt.prompts import load_prompt
+from skillopt.utils.text_io import read_lines
def _build_system(skill_content: str) -> str:
@@ -202,7 +203,7 @@ def process_one(
f"Exact Match: {eval_result['em']}"
)
conversation.append({"role": "system", "content": eval_detail})
- with open(os.path.join(pred_dir, "conversation.json"), "w") as f:
+ with open(os.path.join(pred_dir, "conversation.json"), "w", encoding="utf-8") as f:
json.dump(conversation, f, ensure_ascii=False, indent=2)
return result
@@ -280,7 +281,7 @@ def process_one(
)
conversation.append({"role": "system", "content": eval_detail})
- with open(os.path.join(pred_dir, "conversation.json"), "w") as f:
+ with open(os.path.join(pred_dir, "conversation.json"), "w", encoding="utf-8") as f:
json.dump(conversation, f, ensure_ascii=False, indent=2)
except Exception as e: # noqa: BLE001
@@ -317,14 +318,13 @@ def run_batch(
done_ids: set[str] = set()
existing: list[dict] = []
if os.path.exists(results_path):
- with open(results_path) as f:
- for line in f:
- try:
- r = json.loads(line)
- done_ids.add(str(r["id"]))
- existing.append(r)
- except Exception:
- pass
+ for line in read_lines(results_path):
+ try:
+ r = json.loads(line)
+ done_ids.add(str(r["id"]))
+ existing.append(r)
+ except Exception:
+ pass
pending = [it for it in items if str(it["id"]) not in done_ids]
if not pending:
@@ -380,7 +380,7 @@ def _error_result(it: dict, exc: Exception) -> dict:
res["fail_reason"] = f"error: {type(exc).__name__}: {exc}"
return res
- with open(results_path, "a") as outf:
+ with open(results_path, "a", encoding="utf-8") as outf:
ex = ThreadPoolExecutor(max_workers=workers)
try:
futs = {
diff --git a/skillopt/envs/searchqa/dataloader.py b/skillopt/envs/searchqa/dataloader.py
index 2dc1c1e0f..d618b7054 100644
--- a/skillopt/envs/searchqa/dataloader.py
+++ b/skillopt/envs/searchqa/dataloader.py
@@ -10,7 +10,7 @@
def _load_items(path: str) -> list[dict]:
"""Load items from JSON or JSONL file."""
- with open(path) as f:
+ with open(path, encoding="utf-8") as f:
content = f.read().strip()
try:
data = json.loads(content)
diff --git a/skillopt/envs/searchqa/rollout.py b/skillopt/envs/searchqa/rollout.py
index 7f96393d0..cb6cb8972 100644
--- a/skillopt/envs/searchqa/rollout.py
+++ b/skillopt/envs/searchqa/rollout.py
@@ -21,6 +21,7 @@
from skillopt.model import chat_target, is_target_exec_backend
from skillopt.model.codex_harness import prepare_workspace, render_skill_md, run_target_exec
from skillopt.prompts import load_prompt
+from skillopt.utils.text_io import read_lines
# ── Prompt templates ─────────────────────────────────────────────────────────
@@ -233,11 +234,11 @@ def process_one(
result["agent_ok"] = True
result["n_turns"] = len(conversation)
- with open(os.path.join(pred_dir, "target_system_prompt.txt"), "w") as f:
+ with open(os.path.join(pred_dir, "target_system_prompt.txt"), "w", encoding="utf-8") as f:
f.write(system)
- with open(os.path.join(pred_dir, "target_user_prompt.txt"), "w") as f:
+ with open(os.path.join(pred_dir, "target_user_prompt.txt"), "w", encoding="utf-8") as f:
f.write(user)
- with open(os.path.join(pred_dir, "conversation.json"), "w") as f:
+ with open(os.path.join(pred_dir, "conversation.json"), "w", encoding="utf-8") as f:
json.dump(conversation, f, ensure_ascii=False, indent=2)
eval_result = evaluate(response, gold_answers)
@@ -261,7 +262,7 @@ def process_one(
f"F1: {eval_result['f1']:.4f}"
)
conversation.append({"role": "system", "content": eval_detail})
- with open(os.path.join(pred_dir, "conversation.json"), "w") as f:
+ with open(os.path.join(pred_dir, "conversation.json"), "w", encoding="utf-8") as f:
json.dump(conversation, f, ensure_ascii=False, indent=2)
return result
@@ -310,11 +311,11 @@ def process_one(
result["n_turns"] = len(conversation)
# Save conversation
- with open(os.path.join(pred_dir, "target_system_prompt.txt"), "w") as f:
+ with open(os.path.join(pred_dir, "target_system_prompt.txt"), "w", encoding="utf-8") as f:
f.write(system)
- with open(os.path.join(pred_dir, "target_user_prompt.txt"), "w") as f:
+ with open(os.path.join(pred_dir, "target_user_prompt.txt"), "w", encoding="utf-8") as f:
f.write(user)
- with open(os.path.join(pred_dir, "conversation.json"), "w") as f:
+ with open(os.path.join(pred_dir, "conversation.json"), "w", encoding="utf-8") as f:
json.dump(conversation, f, ensure_ascii=False, indent=2)
# Evaluate
@@ -346,7 +347,7 @@ def process_one(
"content": eval_detail,
})
# Re-save enriched conversation
- with open(os.path.join(pred_dir, "conversation.json"), "w") as f:
+ with open(os.path.join(pred_dir, "conversation.json"), "w", encoding="utf-8") as f:
json.dump(conversation, f, ensure_ascii=False, indent=2)
except Exception as e: # noqa: BLE001
@@ -380,14 +381,13 @@ def run_batch(
done_ids: set[str] = set()
existing: list[dict] = []
if os.path.exists(results_path):
- with open(results_path) as f:
- for line in f:
- try:
- r = json.loads(line)
- done_ids.add(str(r["id"]))
- existing.append(r)
- except Exception:
- pass
+ for line in read_lines(results_path):
+ try:
+ r = json.loads(line)
+ done_ids.add(str(r["id"]))
+ existing.append(r)
+ except Exception:
+ pass
pending = [it for it in items if str(it["id"]) not in done_ids]
if not pending:
@@ -441,7 +441,7 @@ def _run_one(item: dict) -> dict:
max_completion_tokens,
)
- with open(results_path, "a") as outf:
+ with open(results_path, "a", encoding="utf-8") as outf:
ex = ThreadPoolExecutor(max_workers=workers)
try:
futs = {ex.submit(_run_one, it): it for it in pending}
diff --git a/skillopt/envs/spreadsheetbench/adapter.py b/skillopt/envs/spreadsheetbench/adapter.py
index cb1e36d96..9ead61a8b 100644
--- a/skillopt/envs/spreadsheetbench/adapter.py
+++ b/skillopt/envs/spreadsheetbench/adapter.py
@@ -17,6 +17,7 @@
run_spreadsheet_batch_codegen,
)
from skillopt.model import get_target_backend, is_target_exec_backend
+from skillopt.utils.text_io import read_lines
# Task types used for per-category breakdowns
@@ -109,12 +110,11 @@ def rollout(
# Resume support
if os.path.exists(results_path):
existing: list[dict] = []
- with open(results_path) as f:
- for line in f:
- try:
- existing.append(json.loads(line))
- except (ValueError, RecursionError, TypeError):
- pass
+ for line in read_lines(results_path):
+ try:
+ existing.append(json.loads(line))
+ except (ValueError, RecursionError, TypeError):
+ pass
if existing:
return existing
@@ -149,7 +149,7 @@ def rollout(
diagnostic_trace_context_by_id=kwargs.get("diagnostic_trace_context_by_id"),
)
- with open(results_path, "w") as f:
+ with open(results_path, "w", encoding="utf-8") as f:
for r in results:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
diff --git a/skillopt/envs/spreadsheetbench/react_agent.py b/skillopt/envs/spreadsheetbench/react_agent.py
index 61faa1d65..8f62d0bd6 100644
--- a/skillopt/envs/spreadsheetbench/react_agent.py
+++ b/skillopt/envs/spreadsheetbench/react_agent.py
@@ -160,7 +160,7 @@ def _write_file(path: str, content: str, work_dir: str) -> str:
parent = os.path.dirname(full_path)
if parent:
os.makedirs(parent, exist_ok=True)
- with open(full_path, "w") as f:
+ with open(full_path, "w", encoding="utf-8") as f:
f.write(content)
return f"File written: {full_path} ({len(content)} chars)"
except Exception as e: # noqa: BLE001
@@ -176,7 +176,7 @@ def _auto_verify(work_dir: str) -> str:
sol_path = os.path.join(work_dir, "solution.py")
output_path = None
if os.path.exists(sol_path):
- with open(sol_path) as f:
+ with open(sol_path, encoding="utf-8") as f:
for line in f:
stripped = line.strip()
if stripped.startswith("OUTPUT_PATH"):
diff --git a/skillopt/envs/spreadsheetbench/rollout.py b/skillopt/envs/spreadsheetbench/rollout.py
index bfdc85813..15ff2e991 100644
--- a/skillopt/envs/spreadsheetbench/rollout.py
+++ b/skillopt/envs/spreadsheetbench/rollout.py
@@ -34,6 +34,7 @@
confined_task_output_dir as _confined_task_out_dir,
is_safe_task_id as _is_safe_task_id,
)
+from skillopt.utils.text_io import read_lines
# ── Data loading ─────────────────────────────────────────────────────────────
@@ -42,13 +43,13 @@
def load_items(path: str) -> list[dict]:
"""Load a benchmark file. Supports both .jsonl and .json (list of dicts)."""
if path.endswith(".json"):
- with open(path) as f:
+ with open(path, encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict):
data = data.get("data") or list(data.values())
return list(data)
items = []
- with open(path) as f:
+ with open(path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
@@ -324,10 +325,10 @@ def process_one(
except Exception:
target_system_prompt = ""
if target_system_prompt:
- with open(os.path.join(task_out_dir, "target_system_prompt.txt"), "w") as f:
+ with open(os.path.join(task_out_dir, "target_system_prompt.txt"), "w", encoding="utf-8") as f:
f.write(target_system_prompt)
result["target_system_prompt"] = target_system_prompt
- with open(os.path.join(task_out_dir, "target_user_prompt.txt"), "w") as f:
+ with open(os.path.join(task_out_dir, "target_user_prompt.txt"), "w", encoding="utf-8") as f:
f.write(target_user_prompt)
result["target_user_prompt"] = target_user_prompt
@@ -359,16 +360,16 @@ def process_one(
)
result["n_turns"] = agent_result.get("n_turns", 0)
if agent_result.get("target_system_prompt"):
- with open(os.path.join(task_out_dir, "target_system_prompt.txt"), "w") as f:
+ with open(os.path.join(task_out_dir, "target_system_prompt.txt"), "w", encoding="utf-8") as f:
f.write(agent_result["target_system_prompt"])
result["target_system_prompt"] = agent_result["target_system_prompt"]
if agent_result.get("target_user_prompt"):
- with open(os.path.join(task_out_dir, "target_user_prompt.txt"), "w") as f:
+ with open(os.path.join(task_out_dir, "target_user_prompt.txt"), "w", encoding="utf-8") as f:
f.write(agent_result["target_user_prompt"])
result["target_user_prompt"] = agent_result["target_user_prompt"]
# Save conversation log
- with open(os.path.join(task_out_dir, "conversation.json"), "w") as f:
+ with open(os.path.join(task_out_dir, "conversation.json"), "w", encoding="utf-8") as f:
json.dump(
agent_result.get("conversation", []),
f, ensure_ascii=False, indent=2,
@@ -408,7 +409,7 @@ def process_one(
result["fail_reason"] = "no-solution-py-for-other-cases"
continue
- with open(solution_path) as f:
+ with open(solution_path, encoding="utf-8") as f:
code = f.read()
# Prepend new INPUT_PATH / OUTPUT_PATH
@@ -497,14 +498,13 @@ def run_spreadsheet_batch(
done_ids: set[str] = set()
existing: list[dict] = []
if os.path.exists(results_path):
- with open(results_path) as f:
- for line in f:
- try:
- r = json.loads(line)
- done_ids.add(str(r["id"]))
- existing.append(r)
- except (ValueError, RecursionError, KeyError, TypeError):
- pass
+ for line in read_lines(results_path):
+ try:
+ r = json.loads(line)
+ done_ids.add(str(r["id"]))
+ existing.append(r)
+ except (ValueError, RecursionError, KeyError, TypeError):
+ pass
pending = [it for it in items if str(it["id"]) not in done_ids]
print(
@@ -713,11 +713,11 @@ def process_one_codegen(
diagnostic_trace_context=diagnostic_trace_context,
)
- with open(os.path.join(task_out_dir, "spreadsheet_preview.txt"), "w") as f:
+ with open(os.path.join(task_out_dir, "spreadsheet_preview.txt"), "w", encoding="utf-8") as f:
f.write(preview_text)
- with open(os.path.join(task_out_dir, "target_system_prompt.txt"), "w") as f:
+ with open(os.path.join(task_out_dir, "target_system_prompt.txt"), "w", encoding="utf-8") as f:
f.write(target_system)
- with open(os.path.join(task_out_dir, "target_user_prompt.txt"), "w") as f:
+ with open(os.path.join(task_out_dir, "target_user_prompt.txt"), "w", encoding="utf-8") as f:
f.write(target_user)
result["spreadsheet_preview"] = preview_text
@@ -772,12 +772,12 @@ def process_one_codegen(
raw = agent_result.get("raw", "")
# Save artifacts
- with open(os.path.join(task_out_dir, "code.py"), "w") as f:
+ with open(os.path.join(task_out_dir, "code.py"), "w", encoding="utf-8") as f:
f.write(code)
- with open(os.path.join(task_out_dir, "raw.txt"), "w") as f:
+ with open(os.path.join(task_out_dir, "raw.txt"), "w", encoding="utf-8") as f:
f.write(raw)
if agent_result.get("conversation"):
- with open(os.path.join(task_out_dir, "conversation.json"), "w") as f:
+ with open(os.path.join(task_out_dir, "conversation.json"), "w", encoding="utf-8") as f:
json.dump(agent_result["conversation"], f, ensure_ascii=False, indent=2)
if not code.strip():
@@ -854,7 +854,7 @@ def process_one_codegen(
"content": f"[POST-EXECUTION VERIFICATION]\n\n{enrichment_msg}",
})
# Re-save the enriched conversation
- with open(os.path.join(task_out_dir, "conversation.json"), "w") as f:
+ with open(os.path.join(task_out_dir, "conversation.json"), "w", encoding="utf-8") as f:
json.dump(conversation, f, ensure_ascii=False, indent=2)
n_cases = result["n_cases"]
n_pass = result["n_pass"]
@@ -905,14 +905,13 @@ def run_spreadsheet_batch_codegen(
done_ids: set[str] = set()
existing: list[dict] = []
if os.path.exists(results_path):
- with open(results_path) as f:
- for line in f:
- try:
- r = json.loads(line)
- done_ids.add(str(r["id"]))
- existing.append(r)
- except (ValueError, RecursionError, KeyError, TypeError):
- pass
+ for line in read_lines(results_path):
+ try:
+ r = json.loads(line)
+ done_ids.add(str(r["id"]))
+ existing.append(r)
+ except (ValueError, RecursionError, KeyError, TypeError):
+ pass
pending = [it for it in items if str(it["id"]) not in done_ids]
print(
diff --git a/skillopt/gradient/reflect.py b/skillopt/gradient/reflect.py
index df68c9b76..e19a24f1c 100644
--- a/skillopt/gradient/reflect.py
+++ b/skillopt/gradient/reflect.py
@@ -46,6 +46,7 @@
)
from skillopt.prompts import load_prompt
from skillopt.utils import extract_json
+from skillopt.utils.text_io import read_text
# ── Trajectory formatting ────────────────────────────────────────────────────
@@ -141,8 +142,7 @@ def fmt_minibatch_trajectories(
conv_path = os.path.join(prediction_dir, tid, "conversation.json")
if not os.path.exists(conv_path):
continue
- with open(conv_path) as f:
- conversation = json.load(f)
+ conversation = json.loads(read_text(conv_path))
if not conversation:
continue
@@ -169,8 +169,7 @@ def fmt_minibatch_trajectories(
if not target_prompt:
prompt_path = os.path.join(prediction_dir, tid, "target_system_prompt.txt")
if os.path.exists(prompt_path):
- with open(prompt_path) as f:
- target_prompt = f.read()
+ target_prompt = read_text(prompt_path)
if target_prompt:
header += (
f"\n#### Target System Prompt\n"
@@ -181,8 +180,7 @@ def fmt_minibatch_trajectories(
if not user_prompt:
user_prompt_path = os.path.join(prediction_dir, tid, "target_user_prompt.txt")
if os.path.exists(user_prompt_path):
- with open(user_prompt_path) as f:
- user_prompt = f.read()
+ user_prompt = read_text(user_prompt_path)
if user_prompt:
header += (
f"\n#### Target User Prompt\n"
@@ -194,7 +192,7 @@ def fmt_minibatch_trajectories(
if not codex_trace_summary:
codex_trace_summary_path = os.path.join(prediction_dir, tid, "codex_trace_summary.txt")
if os.path.exists(codex_trace_summary_path):
- with open(codex_trace_summary_path) as f:
+ with open(codex_trace_summary_path, encoding="utf-8") as f:
codex_trace_summary = f.read()
if codex_trace_summary:
header += (
@@ -226,8 +224,7 @@ def fmt_minibatch_trajectories(
if not preview:
preview_path = os.path.join(prediction_dir, tid, "spreadsheet_preview.txt")
if os.path.exists(preview_path):
- with open(preview_path) as f:
- preview = f.read()
+ preview = read_text(preview_path)
if preview:
header += (
f"\n#### Spreadsheet Preview\n"
@@ -573,8 +570,7 @@ def run_minibatch_reflect(
for idx, batch in enumerate(fail_batches):
path = os.path.join(patches_dir, f"minibatch_fail_{idx:03d}.json")
if os.path.exists(path):
- with open(path) as f:
- raw_patches.append(json.load(f))
+ raw_patches.append(json.loads(read_text(path)))
else:
pending_fail.append((idx, batch))
@@ -582,8 +578,7 @@ def run_minibatch_reflect(
for idx, batch in enumerate(succ_batches):
path = os.path.join(patches_dir, f"minibatch_succ_{idx:03d}.json")
if os.path.exists(path):
- with open(path) as f:
- raw_patches.append(json.load(f))
+ raw_patches.append(json.loads(read_text(path)))
else:
pending_succ.append((idx, batch))
@@ -636,7 +631,7 @@ def _do_succ(idx: int, batch: list[dict]) -> tuple[str, dict | None]:
tag, patch = fut.result()
if patch:
path = os.path.join(patches_dir, f"{tag}.json")
- with open(path, "w") as f:
+ with open(path, "w", encoding="utf-8") as f:
json.dump(patch, f, ensure_ascii=False, indent=2)
raw_patches.append(patch)
n_edits = len(get_payload_items(patch.get("patch", {}) if patch else {}, update_mode))
diff --git a/skillopt/optimizer/slow_update.py b/skillopt/optimizer/slow_update.py
index ba05b7507..90e013aee 100644
--- a/skillopt/optimizer/slow_update.py
+++ b/skillopt/optimizer/slow_update.py
@@ -30,6 +30,7 @@
from skillopt.model import chat_optimizer
from skillopt.prompts import load_prompt
from skillopt.utils import extract_json
+from skillopt.utils.text_io import read_text
# ── Protected field markers ─────────────────────────────────────────────────
@@ -129,8 +130,7 @@ def _read_trajectory(rollout_dir: str, task_id: str) -> str:
if not os.path.exists(conv_path):
return "(trajectory not available)"
try:
- with open(conv_path) as f:
- conversation = json.load(f)
+ conversation = json.loads(read_text(conv_path))
except Exception:
return "(trajectory read error)"
if not conversation:
@@ -280,7 +280,7 @@ def save_comparison_pairs(pairs: list[dict], out_path: str) -> None:
"prev": p["prev"],
"curr": p["curr"],
})
- with open(out_path, "w") as f:
+ with open(out_path, "w", encoding="utf-8") as f:
json.dump(slim, f, ensure_ascii=False, indent=2)
diff --git a/skillopt/utils/text_io.py b/skillopt/utils/text_io.py
new file mode 100644
index 000000000..e03b77f47
--- /dev/null
+++ b/skillopt/utils/text_io.py
@@ -0,0 +1,48 @@
+"""Read run files, including ones older releases wrote with the locale codec.
+
+Skills, histories, patches, trajectories and rollout records are written as
+UTF-8. Earlier releases left the codec to ``open()``, so a run started on a
+Windows machine holds cp1252 (or GBK, ...) bytes. These readers try UTF-8 and
+fall back to that codec, so such a run can still be resumed and its skills
+evaluated.
+"""
+from __future__ import annotations
+
+import locale
+
+
+def locale_codec() -> str:
+ """The codec ``open()`` picks when no encoding is given.
+
+ Kept behind a function so tests can stand in a non-UTF-8 locale on a
+ runner whose locale is UTF-8.
+ """
+ return locale.getpreferredencoding(False)
+
+
+def read_text(path: str) -> str:
+ """Return the text of ``path``: UTF-8, or the locale codec for older files."""
+ try:
+ with open(path, encoding="utf-8") as f:
+ return f.read()
+ except UnicodeDecodeError:
+ with open(path, encoding=locale_codec()) as f:
+ return f.read()
+
+
+def read_lines(path: str) -> list[str]:
+ """Return the lines of a JSONL file, each decoded like :func:`read_text`.
+
+ Lines are decoded one by one because a resumed rollout appends UTF-8
+ records to a file an earlier release may have started. A line valid in
+ neither codec, such as a record cut off mid-write, is decoded with
+ replacement characters instead of failing the whole read.
+ """
+ lines: list[str] = []
+ with open(path, "rb") as f:
+ for raw in f:
+ try:
+ lines.append(raw.decode("utf-8"))
+ except UnicodeDecodeError:
+ lines.append(raw.decode(locale_codec(), errors="replace"))
+ return lines
diff --git a/tests/test_run_file_encoding.py b/tests/test_run_file_encoding.py
new file mode 100644
index 000000000..9f3001b28
--- /dev/null
+++ b/tests/test_run_file_encoding.py
@@ -0,0 +1,398 @@
+"""Run files are read and written as UTF-8, whatever the locale codec is.
+
+``open()`` without an encoding uses the locale codec, which on Windows is the
+ANSI code page (cp1252, GBK, ...). Released skills, retrieved passages, model
+replies and optimizer edits routinely hold characters outside it, so a run
+crashed loading a skill, scored a correct answer 0 when writing its trajectory
+failed inside a broad ``except``, or showed the target a garbled skill.
+
+On a UTF-8 runner the locale codec is UTF-8, so behaviour alone cannot show the
+defect there. The pipeline tests therefore run in a subprocess under
+``-X warn_default_encoding`` with ``EncodingWarning`` raised as an error for
+SkillOpt's own modules: every ``open()`` on the exercised path that leaves the
+codec to the locale fails, on every platform.
+"""
+from __future__ import annotations
+
+import json
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+
+ROOT = Path(__file__).resolve().parent.parent
+RELEASED_SKILL = ROOT / "ckpt" / "searchqa" / "gpt5.5_skill.md"
+
+ARROW = "\N{RIGHTWARDS ARROW}"
+OMEGA = "\N{GREEK CAPITAL LETTER OMEGA}"
+QUOTED = "\N{LEFT DOUBLE QUOTATION MARK}Paris\N{RIGHT DOUBLE QUOTATION MARK}"
+PRODUCT = "\N{N-ARY PRODUCT}"
+
+
+# Scoped to SkillOpt's modules so that a dependency leaving the codec to the
+# locale cannot fail these tests.
+_STRICT = (
+ "import warnings\n"
+ "warnings.filterwarnings('error', category=EncodingWarning, module='skillopt|scripts')\n"
+)
+
+
+def _run_strict(script: str, *args: str) -> str:
+ """Run ``script`` in a subprocess where a locale-codec ``open()`` raises."""
+ proc = subprocess.run(
+ [sys.executable, "-X", "warn_default_encoding", "-c", _STRICT + script, *args],
+ cwd=ROOT,
+ env={**os.environ, "PYTHONIOENCODING": "utf-8"},
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ timeout=300,
+ )
+ assert proc.returncode == 0, f"{proc.stdout[-2000:]}\n{proc.stderr[-4000:]}"
+ return proc.stdout
+
+
+def _searchqa_split(root: Path) -> Path:
+ for split, size in (("train", 2), ("val", 2), ("test", 1)):
+ items = [
+ {
+ "id": f"{split}_{i}",
+ "question": "Which city is the capital of France?",
+ "context": "[DOC] Paris is the capital of France.",
+ "answers": ["Paris"],
+ }
+ for i in range(size)
+ ]
+ (root / split).mkdir(parents=True)
+ (root / split / "items.json").write_text(json.dumps(items), encoding="utf-8")
+ return root
+
+
+SEARCHQA_ROLLOUT = """
+import json, sys
+import skillopt.envs.searchqa.rollout as rollout
+
+reply = "The passage names the city " + chr(0x2192) + " Paris"
+rollout.chat_target = lambda **kwargs: (reply, {})
+item = {
+ "id": "q1",
+ "question": "Which city is the capital of France?",
+ "context": "[DOC] Paris (" + chr(0x3A9) + ") is the capital of France.",
+ "answers": ["Paris"],
+}
+skill = "Quote the passage " + chr(0x2192) + " then answer."
+first = rollout.run_batch([item], sys.argv[1], skill, workers=1)
+
+def finished_items_are_not_rerun(**kwargs):
+ raise AssertionError("resume rolled out a finished item again")
+
+rollout.chat_target = finished_items_are_not_rerun
+resumed = rollout.run_batch([item], sys.argv[1], skill, workers=1)
+print(json.dumps({"first": first, "resumed": resumed}))
+"""
+
+
+def test_searchqa_rollout_scores_and_keeps_non_ascii_text(tmp_path):
+ """A correct reply scores 1 whatever characters the context, skill or reply hold.
+
+ The prompts and the trajectory are written before scoring, inside a broad
+ ``except``: on cp1252 an ``Omega`` in a retrieved passage made the write
+ raise, so the correct answer scored 0 while ``agent_ok`` stayed true and
+ the systemic-failure check never fired. An arrow in the reply then crashed
+ the run writing ``results.jsonl``.
+ """
+ out = json.loads(_run_strict(SEARCHQA_ROLLOUT, str(tmp_path)).splitlines()[-1])
+
+ (row,) = out["first"]
+ assert (row["hard"], row["fail_reason"]) == (1, "")
+ assert out["resumed"] == out["first"]
+ pred = tmp_path / "predictions" / "q1"
+ assert ARROW in (pred / "target_system_prompt.txt").read_text(encoding="utf-8")
+ assert OMEGA in (pred / "target_user_prompt.txt").read_text(encoding="utf-8")
+ conversation = json.loads((pred / "conversation.json").read_text(encoding="utf-8"))
+ assert ARROW in conversation[0]["content"]
+ (record,) = (tmp_path / "results.jsonl").read_text(encoding="utf-8").splitlines()
+ assert ARROW in json.loads(record)["response"]
+
+
+EVAL_ONLY = """
+import sys
+import skillopt.envs.searchqa.rollout as rollout
+
+rollout.chat_target = lambda **kwargs: ("Paris", {})
+from scripts import eval_only
+
+sys.argv = ["eval_only.py", *sys.argv[1:]]
+eval_only.main()
+"""
+
+
+def test_eval_only_loads_a_released_skill(tmp_path):
+ """``ckpt/README.md`` documents ``eval_only.py --skill ckpt//gpt5.5_skill.md``.
+
+ Five of the six released skills hold a right double quotation mark, whose
+ UTF-8 bytes end in 0x9D, a byte cp1252 does not define, so on Windows the
+ documented command crashed before any model call.
+ """
+ out = tmp_path / "eval"
+ _run_strict(
+ EVAL_ONLY,
+ "--config", "configs/searchqa/default.yaml",
+ "--skill", str(RELEASED_SKILL),
+ "--split", "valid_unseen",
+ "--split_dir", str(_searchqa_split(tmp_path / "split")),
+ "--backend", "openai_compatible",
+ "--target_model", "fake",
+ "--out_root", str(out),
+ )
+
+ assert json.loads((out / "eval_summary.json").read_text(encoding="utf-8"))["hard"] == 1.0
+ prompt = (out / "predictions" / "test_0" / "target_system_prompt.txt").read_text(encoding="utf-8")
+ assert RELEASED_SKILL.read_text(encoding="utf-8").strip() in prompt
+
+
+TRAINING_RUN = """
+import json, sys
+import skillopt.model.openai_compatible_backend as backend
+
+edit = {"op": "append", "content": "- Copy the entity name " + chr(0x2192) + " never paraphrase it."}
+patch = json.dumps({"reasoning": "exact names", "patch": {"edits": [edit]}, "edits": [edit]})
+backend.chat_target = lambda **kwargs: ("Paris", {})
+backend.chat_optimizer = lambda **kwargs: (patch, {})
+from scripts import train
+
+sys.argv = ["train.py", *sys.argv[1:]]
+train.main()
+"""
+
+
+def test_training_step_keeps_a_released_skill_and_non_ascii_edits(tmp_path):
+ """A released skill as ``skill_init`` and an optimizer edit with an arrow.
+
+ On Windows, reading the skill crashed before the first rollout, and an
+ arrow in an edit crashed the step when the analyst's patch was saved. The
+ trainer writes the same edit into the merged patch, the ranked edits, the
+ edit-apply report and the candidate skill.
+ """
+ out = tmp_path / "run"
+ _run_strict(
+ TRAINING_RUN,
+ "--config", "configs/searchqa/default.yaml",
+ "--skill_init", str(RELEASED_SKILL),
+ "--split_dir", str(_searchqa_split(tmp_path / "split")),
+ "--backend", "openai_compatible",
+ "--optimizer_model", "fake",
+ "--target_model", "fake",
+ "--train_size", "2",
+ "--batch_size", "2",
+ "--num_epochs", "1",
+ "--workers", "1",
+ "--analyst_workers", "1",
+ "--use_slow_update", "false",
+ "--use_meta_skill", "false",
+ "--out_root", str(out),
+ )
+
+ released = RELEASED_SKILL.read_text(encoding="utf-8")
+ assert (out / "skills" / "skill_v0000.md").read_text(encoding="utf-8") == released
+ step = out / "steps" / "step_0001"
+ patch = json.loads((step / "patches" / "minibatch_succ_000.json").read_text(encoding="utf-8"))
+ assert ARROW in patch["edits"][0]["content"]
+ assert f"- Copy the entity name {ARROW} never paraphrase it." in (
+ step / "candidate_skill.md"
+ ).read_text(encoding="utf-8")
+ assert json.loads((out / "summary.json").read_text(encoding="utf-8"))["test_hard"] == 1.0
+
+
+REFLECT_TRAJECTORIES = """
+import sys
+from skillopt.gradient.reflect import fmt_minibatch_trajectories
+
+print(fmt_minibatch_trajectories([{"id": "t1", "hard": 0}], sys.argv[1]))
+"""
+
+
+def test_reflection_reads_utf8_trajectories(tmp_path):
+ """DocVQA and OfficeQA already write trajectories as UTF-8.
+
+ Reflection read them with the locale codec, before the analyst's ``try``,
+ so a closing curly quote in one crashed the reflect stage on Windows.
+ """
+ task = tmp_path / "t1"
+ task.mkdir()
+ conversation = [{"type": "message", "turn": 1, "content": f"The passage says {QUOTED}."}]
+ (task / "conversation.json").write_text(json.dumps(conversation, ensure_ascii=False), encoding="utf-8")
+ (task / "target_system_prompt.txt").write_text(f"Quote the passage {ARROW} then answer.", encoding="utf-8")
+ (task / "target_user_prompt.txt").write_text(f"[DOC] Paris ({OMEGA})", encoding="utf-8")
+
+ text = _run_strict(REFLECT_TRAJECTORIES, str(tmp_path))
+
+ assert QUOTED in text
+ assert ARROW in text
+ assert OMEGA in text
+
+
+DATASET_LOADERS = """
+import json, sys
+from skillopt.envs.livemathematicianbench.dataloader import load_items as load_livemath
+from skillopt.envs.searchqa.dataloader import SearchQADataLoader
+from skillopt.envs.spreadsheetbench.rollout import load_items as load_spreadsheetbench
+
+root = sys.argv[1]
+print(json.dumps({
+ "livemath": load_livemath(root + "/livemath")[0]["question"],
+ "searchqa": SearchQADataLoader().load_raw_items(root + "/searchqa.json")[0]["question"],
+ "spreadsheetbench": load_spreadsheetbench(root + "/spreadsheetbench.json")[0]["instruction"],
+}))
+"""
+
+
+def test_benchmark_data_files_are_read_as_utf8(tmp_path):
+ """Raw LiveMathematicianBench files failed to load on Windows (#117)."""
+ question = f"Evaluate {PRODUCT} (1 + 1/k^2) over k >= 1."
+ (tmp_path / "livemath").mkdir()
+ (tmp_path / "livemath" / "qa_smoke_final.json").write_text(
+ json.dumps(
+ [{"no": 1, "question": question, "choices": {"A": "2", "B": "e"}, "correct_choice": "A"}],
+ ensure_ascii=False,
+ ),
+ encoding="utf-8",
+ )
+ (tmp_path / "searchqa.json").write_text(
+ json.dumps([{"id": "q1", "question": question, "answers": ["2"]}], ensure_ascii=False), encoding="utf-8",
+ )
+ (tmp_path / "spreadsheetbench.json").write_text(
+ json.dumps([{"id": "1-1", "instruction": question}], ensure_ascii=False), encoding="utf-8",
+ )
+
+ loaded = json.loads(_run_strict(DATASET_LOADERS, str(tmp_path)).splitlines()[-1])
+
+ assert loaded == {"livemath": question, "searchqa": question, "spreadsheetbench": question}
+
+
+SPREADSHEETBENCH_CODEGEN = """
+import json, sys
+import skillopt.envs.spreadsheetbench.codegen_agent as codegen_agent
+from skillopt.envs.spreadsheetbench import rollout
+
+data_root, out_root, skill_path = sys.argv[1:4]
+with open(skill_path, encoding="utf-8") as f:
+ skill = f.read()
+codegen_agent.run_single = lambda **kwargs: {"code": "", "raw": "No code " + chr(0x2192), "n_turns": 1}
+item = {
+ "id": "1-1",
+ "instruction": "put 1 in A1",
+ "instruction_type": "cell",
+ "answer_position": "A1",
+ "answer_sheet": "Sheet1",
+ "spreadsheet_path": "1-1",
+}
+print(json.dumps(rollout.process_one_codegen(item, data_root, out_root, skill)))
+"""
+
+
+def test_spreadsheetbench_prompts_keep_the_seed_skill(tmp_path):
+ """The SpreadsheetBench seed skill holds an em dash and an arrow.
+
+ Read with cp1252 they reached every prompt as mojibake. Read correctly,
+ they made the rollout's own prompt writes fail inside its broad
+ ``except``, so the item scored 0 before the model was called; the skill
+ read and the rollout writes have to change together.
+ """
+ seed = ROOT / "skillopt" / "envs" / "spreadsheetbench" / "skills" / "initial.md"
+ case = tmp_path / "data" / "1-1"
+ case.mkdir(parents=True)
+ (case / "1_input.xlsx").write_bytes(b"")
+ (case / "1_answer.xlsx").write_bytes(b"")
+ out = tmp_path / "out"
+
+ result = json.loads(
+ _run_strict(SPREADSHEETBENCH_CODEGEN, str(tmp_path / "data"), str(out), str(seed)).splitlines()[-1]
+ )
+
+ assert result["fail_reason"] == "empty-code-block", result
+ system_prompt = (out / "predictions" / "1-1" / "target_system_prompt.txt").read_text(encoding="utf-8")
+ assert "\N{EM DASH}" in system_prompt
+ assert ARROW in system_prompt
+ assert ARROW in (out / "predictions" / "1-1" / "raw.txt").read_text(encoding="utf-8")
+
+
+def test_resume_reads_files_an_earlier_release_wrote_with_the_locale_codec(tmp_path, monkeypatch):
+ """Runs started before this change hold locale-codec bytes and still resume.
+
+ Reading them only as UTF-8 would fail, and for ``runtime_state.json`` the
+ failure is swallowed, so the resume falls back to ``history.json``, or
+ starts over when no step has finished yet. A rollout
+ resumed after the upgrade appends UTF-8 records to such a ``results.jsonl``,
+ so its lines are decoded one by one. The locale codec is forced to cp1252
+ so the fallback is exercised on a UTF-8 runner as well.
+ """
+ from skillopt.engine import trainer
+ from skillopt.envs.searchqa import rollout
+ from skillopt.utils import text_io
+
+ monkeypatch.setattr(text_io, "locale_codec", lambda: "cp1252")
+ run = tmp_path / "run"
+ skill_path = run / "skills" / "skill_v0003.md"
+ skill_path.parent.mkdir(parents=True)
+ skill = f"# Skill\n- Quote the passage \N{EM DASH} word for word: {QUOTED}.\n"
+ resume = "r\N{LATIN SMALL LETTER E WITH ACUTE}sum\N{LATIN SMALL LETTER E WITH ACUTE}"
+ history = [{"step": 3, "best_step": 3, "best_score": 0.5, "note": f"Keep the {resume} short"}]
+ state = {
+ "last_completed_step": 3,
+ "current_skill_path": str(skill_path),
+ "current_origin": "step_0003 \N{EM DASH} accepted",
+ }
+ skill_path.write_bytes(skill.encode("cp1252"))
+ (run / "history.json").write_bytes(json.dumps(history, ensure_ascii=False).encode("cp1252"))
+ (run / "runtime_state.json").write_bytes(json.dumps(state, ensure_ascii=False).encode("cp1252"))
+ for path in (skill_path, run / "history.json", run / "runtime_state.json"):
+ with pytest.raises(UnicodeDecodeError): # the fixtures must not be valid UTF-8
+ path.read_bytes().decode("utf-8")
+
+ assert trainer._load_runtime_state(str(run)) == state
+ assert trainer._load_history(str(run)) == history
+ assert trainer._load_skill(str(run), 3) == skill
+
+ legacy = {"id": "q1", "hard": 1, "response": "Paris \N{EM DASH} Paris"}
+ upgraded = {"id": "q2", "hard": 1, "response": f"{QUOTED} {ARROW} Paris"}
+ rollout_dir = tmp_path / "rollout"
+ rollout_dir.mkdir()
+ (rollout_dir / "results.jsonl").write_bytes(
+ (json.dumps(legacy, ensure_ascii=False) + "\n").encode("cp1252")
+ + (json.dumps(upgraded, ensure_ascii=False) + "\n").encode("utf-8")
+ )
+
+ def no_model_call(**kwargs):
+ raise AssertionError("resume rolled out a finished item again")
+
+ monkeypatch.setattr(rollout, "chat_target", no_model_call)
+ items = [{"id": item_id, "question": "", "answers": ["Paris"]} for item_id in ("q1", "q2")]
+ assert rollout.run_batch(items, str(rollout_dir), "", workers=1) == [legacy, upgraded]
+
+
+def test_resume_rolls_out_a_record_cut_off_mid_write_again(tmp_path, monkeypatch):
+ """A crash can leave the last ``results.jsonl`` record cut off mid-character.
+
+ That line is valid in neither codec. It is treated like any other line that
+ does not parse, so its item is rolled out again instead of the resume
+ failing on a decode error.
+ """
+ from skillopt.envs.searchqa import rollout
+ from skillopt.utils import text_io
+
+ monkeypatch.setattr(text_io, "locale_codec", lambda: "cp1252")
+ done = {"id": "q1", "hard": 1, "response": "Paris"}
+ cut_off = json.dumps({"id": "q2", "hard": 1, "response": f"{QUOTED} {ARROW}"}, ensure_ascii=False)
+ (tmp_path / "results.jsonl").write_bytes(
+ (json.dumps(done) + "\n").encode("utf-8") + cut_off.encode("utf-8")[:-4]
+ )
+ monkeypatch.setattr(rollout, "chat_target", lambda **kwargs: ("Paris", {}))
+
+ items = [{"id": item_id, "question": "", "answers": ["Paris"]} for item_id in ("q1", "q2")]
+ rows = rollout.run_batch(items, str(tmp_path), "", workers=1)
+
+ assert rows[0] == done
+ assert (rows[1]["id"], rows[1]["hard"], rows[1]["fail_reason"]) == ("q2", 1, "")