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, "")