feat(trainer): final-skill val + best promotion; keep best unpolluted by slow_update

- slow_update force-inject now writes current_skill ONLY (best_skill stays a
  faithful val-best snapshot, never receives un-validated slow_update content)
- after training, run one val on the final skill; if its gate score beats the
  incumbent best, promote final to best (updates best_skill/best_step/best_origin)
- trainer now evaluates final skill on test itself (reuses best test result when
  final==best); records final_selection_* and final_test_* in summary.json
- spreadsheetbench: head+tail truncate the post-execution verification report at
  source to fix multi-MB conversation bloat

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Cuzyoung
2026-06-02 05:55:31 +00:00
parent 372fd56c1e
commit ffe581098b
2 changed files with 208 additions and 28 deletions
+171 -15
View File
@@ -1543,13 +1543,13 @@ class ReflACTTrainer:
elif action in {
"accept", "accept_new_best", "force_accept",
}:
# Force-accept mode: re-apply to both current & best.
# Force-accept mode: re-apply guidance to
# current_skill only. best_skill must remain a
# faithful snapshot of the val-best step and must
# NOT receive force-injected slow-update content.
current_skill = replace_slow_update_field(
current_skill, slow_saved["slow_update_content"],
)
best_skill = replace_slow_update_field(
best_skill, slow_saved["slow_update_content"],
)
elif epoch == 1:
# Epoch 1: inject empty placeholder
os.makedirs(slow_dir, exist_ok=True)
@@ -1557,7 +1557,7 @@ class ReflACTTrainer:
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:
f.write(best_skill if best_score > current_score else current_skill)
f.write(best_skill)
with open(slow_done_path, "w") as f:
json.dump({"action": "inject_placeholder", "epoch": epoch}, f, indent=2)
_persist_runtime_state(global_step)
@@ -1778,16 +1778,15 @@ class ReflACTTrainer:
else:
# ── Force-accept mode (default) ──────────────────
# The epoch-level longitudinal guidance is injected
# into both current_skill and best_skill
# unconditionally — it must not be gated by
# step-level selection scores.
# into current_skill ONLY, so training continues
# with the accumulated slow memory. best_skill is
# left untouched: it must remain a faithful snapshot
# of the val-best step (which may be a pre-slow step
# such as S_0 carrying no slow_update field at all).
slow_content = slow_result["slow_update_content"]
current_skill = replace_slow_update_field(
current_skill, slow_content,
)
best_skill = replace_slow_update_field(
best_skill, slow_content,
)
# Update caches so downstream steps use the
# slow-update-injected skill for hashing.
slow_candidate_hash = skill_hash(current_skill)
@@ -1798,7 +1797,7 @@ class ReflACTTrainer:
print(
f" [slow update] force-injected into "
f"current & best "
f"current only "
f"({len(slow_content)} chars), "
f"{slow_time}s"
)
@@ -1951,10 +1950,70 @@ class ReflACTTrainer:
baseline_test_soft = None
test_hard = None
test_soft = None
final_test_hard = None
final_test_soft = None
final_selection_hard = None
final_selection_soft = None
if cfg["eval_test"]:
task_types = adapter.get_task_types()
# ── Final skill validation (valid_seen) + best promotion ─────
# The final (last) skill may carry an epoch-end slow_update that
# was force-injected WITHOUT a val pass (use_gate=false or
# slow_update_gate_with_selection=false), so it never competed for
# best. Run one real val on the final skill; if its gate score
# beats the incumbent best, PROMOTE it to best so that best is the
# true val-argmax over all skills (including the final slow_update).
# When final == best, reuse the existing val score (no rollout).
try:
if skill_hash(current_skill) == skill_hash(best_skill):
final_selection_hard, final_selection_soft = best_score, None
print(
"\n [final skill == best skill] "
f"final_selection_hard={best_score:.4f} (reused)"
)
else:
fval_env, fval_n = _build_eval_env(
split="valid_seen",
env_num=cfg["sel_env_num"],
seed=seed,
)
fval_dir = os.path.join(out_root, "final_selection_eval")
fval_results = adapter.rollout(fval_env, current_skill, fval_dir)
final_selection_hard, final_selection_soft = compute_score(fval_results)
final_gate_score = select_gate_score(
final_selection_hard, final_selection_soft,
gate_metric, gate_mixed_weight,
)
print(
f"\n [final skill val] items={fval_n} "
f"final_selection_hard={final_selection_hard:.4f} "
f"gate={final_gate_score:.4f} "
f"(best={best_score:.4f})"
)
if final_gate_score > best_score:
# Promote: the final (slow-updated) skill is val-better
# than the incumbent best. Make it the new best so the
# subsequent BEST-skill test rollout evaluates it and
# best/final test scores coincide.
print(
f" [promote] final {final_gate_score:.4f} > "
f"best {best_score:.4f} → final becomes new best "
f"(step {global_step}, origin {current_origin})"
)
best_skill = current_skill
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:
f.write(best_skill)
_persist_runtime_state(global_step)
except Exception as _e: # noqa: BLE001
final_selection_hard = None
final_selection_soft = None
print(f"\n [final skill val FAILED: {_e!r}]")
# Baseline: S_0 on test set (valid_unseen)
print(f"\n{'='*60}")
print(" BASELINE TEST — evaluate initial skill on Test set (valid_unseen)")
@@ -2023,13 +2082,87 @@ class ReflACTTrainer:
f, indent=2, ensure_ascii=False,
)
# Final skill (last skill in trajectory) on test set.
# Distinct from best_skill: with use_gate=False every candidate is
# force-accepted so the final skill is whatever the last step
# produced; with use_gate=True it is the last accepted skill, which
# may differ from the best-on-val skill. We always evaluate it so
# every run reports baseline / best-on-val / final on test.
# Guarded so a failure here never prevents summary.json from being
# written (the orchestrator's post-hoc safety net fills it in).
try:
if skill_hash(current_skill) == skill_hash(best_skill):
# Final == best: reuse results, skip a redundant rollout.
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:
json.dump(
{
k: {
"total": b["total"],
"hard_acc": b["hard"] / max(b["total"], 1),
}
for k, b in best_buckets.items()
},
f, indent=2, ensure_ascii=False,
)
print(
"\n [final skill == best skill] "
f"final_test_hard={final_test_hard:.4f} (reused)"
)
else:
print(f"\n{'='*60}")
print(" FINAL SKILL TEST — evaluate last skill on Test set (valid_unseen)")
print(f"{'='*60}")
test_env3, test_n3 = _build_eval_env(
split="valid_unseen",
env_num=cfg["test_env_num"],
seed=seed,
)
print(f" Test items: {test_n3}")
final_test_dir = os.path.join(out_root, "test_eval_final")
final_test_results = adapter.rollout(test_env3, current_skill, final_test_dir)
final_test_hard, final_test_soft = compute_score(final_test_results)
final_buckets = _compute_task_type_buckets(final_test_results, task_types)
print("\n === Final Skill Test Results ===")
for task_type in task_types + ["overall"]:
b = final_buckets.get(task_type, {"total": 0, "hard": 0})
t = max(b["total"], 1)
print(
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:
json.dump(
{
k: {
"total": b["total"],
"hard_acc": b["hard"] / max(b["total"], 1),
}
for k, b in final_buckets.items()
},
f, indent=2, ensure_ascii=False,
)
except Exception as _e: # noqa: BLE001
final_test_hard = None
final_test_soft = None
print(f"\n [final skill test FAILED: {_e!r}] "
"— will be filled by post-hoc eval")
# Comparison
delta_hard = (test_hard or 0) - (baseline_test_hard or 0)
print(f"\n === Improvement (best vs baseline) ===")
print(f"\n === Improvement vs baseline (init S_0) ===")
print(
f" hard: {baseline_test_hard:.4f} -> {test_hard:.4f} "
f" [2] best-on-val hard: {baseline_test_hard:.4f} -> {test_hard:.4f} "
f"(delta={delta_hard:+.4f})"
)
if final_test_hard is not None:
final_delta_hard = (final_test_hard or 0) - (baseline_test_hard or 0)
print(
f" [3] final/last hard: {baseline_test_hard:.4f} -> {final_test_hard:.4f} "
f"(delta={final_delta_hard:+.4f})"
)
# ── Global summary ───────────────────────────────────────────────
total_wall = time.time() - t_loop_start
@@ -2061,6 +2194,8 @@ class ReflACTTrainer:
skill_hash(skill_init), (None, None),
)[0],
"best_selection_hard": best_score,
"final_selection_hard": final_selection_hard,
"final_selection_soft": final_selection_soft,
"best_step": best_step,
"current_origin": current_origin,
"best_origin": best_origin,
@@ -2073,11 +2208,18 @@ class ReflACTTrainer:
"baseline_test_soft": baseline_test_soft,
"test_hard": test_hard,
"test_soft": test_soft,
"final_test_hard": final_test_hard,
"final_test_soft": final_test_soft,
"test_delta_hard": (
(test_hard or 0) - (baseline_test_hard or 0)
if test_hard is not None
else None
),
"final_test_delta_hard": (
(final_test_hard or 0) - (baseline_test_hard or 0)
if final_test_hard is not None
else None
),
"total_wall_time_s": round(total_wall, 1),
"token_summary": token_summary,
}
@@ -2098,8 +2240,22 @@ class ReflACTTrainer:
f" epoch {es['epoch']}: accept={es['accepts']} reject={es['rejects']} "
f"best={es['best_score_at_epoch_end']:.4f}"
)
if baseline_test_hard is not None:
print("\n === TEST scores (3 skills, split=valid_unseen) ===")
print(
f" [1] init/baseline (S_0) : "
f"test_hard={baseline_test_hard:.4f}"
)
if test_hard is not None:
print(f" test_hard={test_hard:.4f} test_soft={test_soft:.4f}")
print(
f" [2] best-on-val (step {best_step})".ljust(37)
+ f": test_hard={test_hard:.4f} test_soft={test_soft:.4f}"
)
if final_test_hard is not None:
print(
f" [3] final/last skill : "
f"test_hard={final_test_hard:.4f} test_soft={final_test_soft:.4f}"
)
if token_summary.get("_total"):
t = token_summary["_total"]
print(