agentic-rl-main / tests /test_checkpoint_eval_paths.py
Jack04810's picture
Add files using upload-large-folder tool
aad9f16 verified
Raw History Blame Contribute Delete
17.4 kB
from __future__ import annotations
import json
import os
import shutil
from pathlib import Path
from typing import Any
import pytest
from opsd_utils.checkpoint_eval_paths import (
find_best_checkpoint_path,
find_checkpoint_evaluation_policy,
recover_interrupted_checkpoint_eval_save,
update_final_checkpoint_link,
validate_checkpoint_eval_output_dir,
)
from opsd_utils.checkpoint_eval import CheckpointEvaluationTriggerCallback
class _State:
best_model_checkpoint = None
class _Trainer:
state = _State()
def _checkpoint_eval_policy_state(
*,
step: int,
score: float,
evaluation_count: int,
lower_score_streak: int = 0,
stop_requested: bool = False,
) -> dict[str, Any]:
"""Build the callback payload HF serializes into trainer_state.json."""
return {
"version": 1,
"patience": 3,
"tie_policy": "reset",
"state": {
"best_score": score,
"best_step": step,
"lower_score_streak": lower_score_streak,
"evaluation_count": evaluation_count,
"stop_requested": stop_requested,
},
}
def _write_native_checkpoint_state(
checkpoint: Path,
*,
step: int,
policy: dict[str, Any],
best_global_step: int | None = None,
) -> None:
"""Write only the durable state needed to prove a native HF save."""
checkpoint.mkdir(exist_ok=True)
state = {
"global_step": step,
"best_global_step": step if best_global_step is None else best_global_step,
"stateful_callbacks": {
"CheckpointEvaluationTriggerCallback": {
"args": {"enabled": True, "patience": 3, "tie_policy": "reset"},
"attributes": {
"pending_checkpoint_evaluation": False,
"trigger_count": policy["state"]["evaluation_count"],
"checkpoint_eval_state": policy,
},
}
},
}
(checkpoint / "trainer_state.json").write_text(
json.dumps(state, indent=2, sort_keys=True), encoding="utf-8"
)
def _interrupted_native_save_layout(
tmp_path: Path,
) -> tuple[Path, Path, dict[str, Any], dict[str, Any]]:
"""Model HF's write-complete / rotation-not-yet-started crash window."""
old_checkpoint = tmp_path / "checkpoint-100"
new_checkpoint = tmp_path / "checkpoint-200"
old_policy = _checkpoint_eval_policy_state(
step=100,
score=0.60,
evaluation_count=1,
)
new_policy = _checkpoint_eval_policy_state(
step=200,
score=0.75,
evaluation_count=2,
)
_write_native_checkpoint_state(old_checkpoint, step=100, policy=old_policy)
# The callback's prior successful save is the durable pointer evidence
# that checkpoint-100 was the retained best before native rotation.
os.symlink(old_checkpoint.name, tmp_path / "final_checkpoint")
_write_native_checkpoint_state(new_checkpoint, step=200, policy=new_policy)
return old_checkpoint, new_checkpoint, old_policy, new_policy
def _rotated_before_callback_publication_layout(
tmp_path: Path,
*,
with_stale_sidecar: bool = True,
) -> tuple[Path, Path, dict[str, Any], dict[str, Any]]:
"""Model HF rotation completing immediately before callback ``on_save``.
This is the narrow single-directory crash state: HF has already deleted
the old best but the callback has not yet repointed ``final_checkpoint``
or published the new policy sidecar.
"""
old_checkpoint, new_checkpoint, old_policy, new_policy = _interrupted_native_save_layout(
tmp_path
)
if with_stale_sidecar:
(tmp_path / "checkpoint_eval_state.json").write_text(
json.dumps({"version": 1, "checkpoint_eval_state": old_policy}),
encoding="utf-8",
)
shutil.rmtree(old_checkpoint)
assert (tmp_path / "final_checkpoint").is_symlink()
assert not (tmp_path / "final_checkpoint").exists()
return old_checkpoint, new_checkpoint, old_policy, new_policy
def test_update_final_checkpoint_link_uses_relative_atomic_symlink(tmp_path: Path) -> None:
checkpoint = tmp_path / "checkpoint-12"
checkpoint.mkdir()
(checkpoint / "config.json").write_text("{}", encoding="utf-8")
final = update_final_checkpoint_link(tmp_path, checkpoint)
assert final.is_symlink()
assert not Path(os.readlink(final)).is_absolute()
assert final.resolve() == checkpoint.resolve()
assert list(tmp_path.glob(".final_checkpoint.tmp-*")) == []
def test_find_best_checkpoint_path_uses_trainer_state(tmp_path: Path) -> None:
checkpoint = tmp_path / "checkpoint-7"
checkpoint.mkdir()
trainer = _Trainer()
trainer.state.best_model_checkpoint = str(checkpoint)
assert find_best_checkpoint_path(trainer, tmp_path) == checkpoint.resolve()
def test_find_best_checkpoint_path_uses_resumed_callback_policy(tmp_path: Path) -> None:
checkpoint = tmp_path / "checkpoint-9"
checkpoint.mkdir()
trainer = _Trainer()
callback = CheckpointEvaluationTriggerCallback(output_dir=tmp_path)
callback.policy.observe(0.8, step=9)
class _Handler:
callbacks = [callback]
trainer.callback_handler = _Handler()
assert find_best_checkpoint_path(trainer, tmp_path) == checkpoint.resolve()
def test_existing_ambiguous_layout_is_rejected(tmp_path: Path) -> None:
(tmp_path / "checkpoint-1").mkdir()
(tmp_path / "checkpoint-2").mkdir()
with pytest.raises(ValueError, match="multiple checkpoint"):
validate_checkpoint_eval_output_dir(tmp_path)
(tmp_path / "checkpoint-2").rmdir()
(tmp_path / "final_checkpoint").mkdir()
with pytest.raises(ValueError, match="real final_checkpoint"):
validate_checkpoint_eval_output_dir(tmp_path)
def test_dangling_internal_best_link_can_be_replaced_after_checkpoint_rotation(tmp_path: Path) -> None:
old_checkpoint = tmp_path / "checkpoint-1"
old_checkpoint.mkdir()
update_final_checkpoint_link(tmp_path, old_checkpoint)
# With save_total_limit=1, native Trainer rotation can remove the old
# checkpoint before the callback's on_save gets a chance to point the
# compatibility link at the new one.
old_checkpoint.rmdir()
new_checkpoint = tmp_path / "checkpoint-2"
new_checkpoint.mkdir()
assert (tmp_path / "final_checkpoint").is_symlink()
assert not (tmp_path / "final_checkpoint").exists()
validate_checkpoint_eval_output_dir(tmp_path)
final = update_final_checkpoint_link(tmp_path, new_checkpoint)
assert final.resolve() == new_checkpoint.resolve()
def test_recover_post_rotation_dangling_link_uses_retained_native_policy(
tmp_path: Path,
) -> None:
old_checkpoint, new_checkpoint, old_policy, new_policy = (
_rotated_before_callback_publication_layout(tmp_path)
)
retained = recover_interrupted_checkpoint_eval_save(
tmp_path, patience=3, tie_policy="reset"
)
assert retained == new_checkpoint.resolve()
assert not old_checkpoint.exists()
final = tmp_path / "final_checkpoint"
assert final.is_symlink()
assert final.resolve() == new_checkpoint.resolve()
assert os.readlink(final) == new_checkpoint.name
sidecar = json.loads((tmp_path / "checkpoint_eval_state.json").read_text(encoding="utf-8"))
assert sidecar == {"version": 1, "checkpoint_eval_state": new_policy}
assert sidecar["checkpoint_eval_state"] != old_policy
validate_checkpoint_eval_output_dir(tmp_path)
def test_recover_post_rotation_rejects_missing_sidecar_without_old_policy_proof(
tmp_path: Path,
) -> None:
_, new_checkpoint, _, new_policy = _rotated_before_callback_publication_layout(
tmp_path, with_stale_sidecar=False
)
assert recover_interrupted_checkpoint_eval_save(tmp_path) is None
final = tmp_path / "final_checkpoint"
assert final.is_symlink()
assert not final.exists()
assert new_checkpoint.is_dir()
assert not (tmp_path / "checkpoint_eval_state.json").exists()
def test_post_rotation_recovery_rejects_unproven_or_dangerous_artifacts(tmp_path: Path) -> None:
old_checkpoint, new_checkpoint, _, new_policy = _rotated_before_callback_publication_layout(
tmp_path
)
# A retained directory name is never enough: its policy must nominate and
# prove this exact saved step.
new_policy["state"]["best_step"] = 100
_write_native_checkpoint_state(new_checkpoint, step=200, policy=new_policy)
assert recover_interrupted_checkpoint_eval_save(tmp_path) is None
final = tmp_path / "final_checkpoint"
assert final.is_symlink()
assert os.readlink(final) == old_checkpoint.name
assert not final.exists()
# Restore the valid native proof, then turn the sidecar into an external
# symlink. Recovery must neither follow nor replace it.
new_policy["state"]["best_step"] = 200
_write_native_checkpoint_state(new_checkpoint, step=200, policy=new_policy)
external_sidecar = tmp_path / "external-sidecar.json"
external_sidecar.write_text("do not replace", encoding="utf-8")
sidecar = tmp_path / "checkpoint_eval_state.json"
sidecar.unlink()
sidecar.symlink_to(external_sidecar)
assert recover_interrupted_checkpoint_eval_save(tmp_path) is None
assert sidecar.is_symlink()
assert external_sidecar.read_text(encoding="utf-8") == "do not replace"
assert os.readlink(final) == old_checkpoint.name
@pytest.mark.parametrize(
"target", ("../outside", "checkpoint-100/../checkpoint-100", "checkpoint-0100")
)
def test_post_rotation_recovery_never_replaces_noncanonical_final_link(
tmp_path: Path, target: str
) -> None:
_, new_checkpoint, _, _ = _rotated_before_callback_publication_layout(tmp_path)
final = tmp_path / "final_checkpoint"
final.unlink()
final.symlink_to(target)
assert recover_interrupted_checkpoint_eval_save(tmp_path) is None
assert final.is_symlink()
assert os.readlink(final) == target
assert new_checkpoint.is_dir()
def test_post_rotation_recovery_requires_second_evaluation(
tmp_path: Path,
) -> None:
_, new_checkpoint, _, new_policy = _rotated_before_callback_publication_layout(tmp_path)
new_policy["state"]["evaluation_count"] = 1
_write_native_checkpoint_state(new_checkpoint, step=200, policy=new_policy)
assert recover_interrupted_checkpoint_eval_save(tmp_path) is None
final = tmp_path / "final_checkpoint"
assert final.is_symlink()
assert not final.exists()
sidecar = json.loads((tmp_path / "checkpoint_eval_state.json").read_text(encoding="utf-8"))
assert sidecar["checkpoint_eval_state"]["state"]["best_step"] == 100
def test_recover_interrupted_native_save_uses_trainer_state_proof_before_pruning(
tmp_path: Path,
) -> None:
old_checkpoint, new_checkpoint, _, new_policy = _interrupted_native_save_layout(tmp_path)
retained = recover_interrupted_checkpoint_eval_save(tmp_path)
assert retained == new_checkpoint.resolve()
assert not old_checkpoint.exists()
assert new_checkpoint.is_dir()
final = tmp_path / "final_checkpoint"
assert final.is_symlink()
assert final.resolve() == new_checkpoint.resolve()
sidecar = json.loads((tmp_path / "checkpoint_eval_state.json").read_text(encoding="utf-8"))
assert sidecar == {"version": 1, "checkpoint_eval_state": new_policy}
# Validation is intentionally the recovery entrypoint used by main, and
# after the proof-driven repair it sees the normal one-checkpoint layout.
validate_checkpoint_eval_output_dir(tmp_path)
assert sorted(path.name for path in tmp_path.glob("checkpoint-*")) == ["checkpoint-200"]
def test_validation_is_read_only_until_rank_zero_recovery_has_proven_the_save(
tmp_path: Path,
) -> None:
old_checkpoint, new_checkpoint, _, _ = _interrupted_native_save_layout(tmp_path)
# main invokes recovery on rank zero and synchronizes before validation.
# Validation itself must remain non-destructive: it may reject the two-dir
# window, but it must never decide which model to delete on every rank.
with pytest.raises(ValueError, match="multiple checkpoint"):
validate_checkpoint_eval_output_dir(tmp_path)
assert old_checkpoint.is_dir()
assert new_checkpoint.is_dir()
assert (tmp_path / "final_checkpoint").resolve() == old_checkpoint.resolve()
assert recover_interrupted_checkpoint_eval_save(tmp_path) == new_checkpoint.resolve()
validate_checkpoint_eval_output_dir(tmp_path)
assert not old_checkpoint.exists()
assert new_checkpoint.is_dir()
def test_interrupted_save_with_mismatched_new_policy_is_rejected_without_deletion(
tmp_path: Path,
) -> None:
old_checkpoint, new_checkpoint, _, new_policy = _interrupted_native_save_layout(tmp_path)
# Directory names alone are never enough evidence: make the new callback
# policy nominate the old step despite global_step/best_global_step=200.
new_policy["state"]["best_step"] = 100
_write_native_checkpoint_state(new_checkpoint, step=200, policy=new_policy)
assert recover_interrupted_checkpoint_eval_save(tmp_path) is None
assert old_checkpoint.is_dir()
assert new_checkpoint.is_dir()
assert (tmp_path / "final_checkpoint").resolve() == old_checkpoint.resolve()
assert not (tmp_path / "checkpoint_eval_state.json").exists()
with pytest.raises(ValueError, match="multiple checkpoint"):
validate_checkpoint_eval_output_dir(tmp_path)
# validate must not turn ambiguous evidence into a destructive repair.
assert old_checkpoint.is_dir()
assert new_checkpoint.is_dir()
assert (tmp_path / "final_checkpoint").resolve() == old_checkpoint.resolve()
def test_interrupted_save_rejects_checkpoint_directory_symlinks(tmp_path: Path) -> None:
old_checkpoint, new_checkpoint, _, _ = _interrupted_native_save_layout(tmp_path)
external_root = tmp_path / "external"
external_root.mkdir()
external_new = external_root / "checkpoint-200"
new_checkpoint.rename(external_new)
new_checkpoint.symlink_to(external_new, target_is_directory=True)
# A directory symlink must not become an escape hatch for recovery's
# destructive prune or for a final_checkpoint link outside output_dir.
assert recover_interrupted_checkpoint_eval_save(tmp_path) is None
assert old_checkpoint.is_dir()
assert new_checkpoint.is_symlink()
assert external_new.is_dir()
assert (tmp_path / "final_checkpoint").resolve() == old_checkpoint.resolve()
def test_interrupted_save_rejects_incompatible_serialized_policy(tmp_path: Path) -> None:
old_checkpoint, new_checkpoint, _, new_policy = _interrupted_native_save_layout(tmp_path)
new_policy["patience"] = 0
_write_native_checkpoint_state(new_checkpoint, step=200, policy=new_policy)
assert recover_interrupted_checkpoint_eval_save(tmp_path) is None
assert old_checkpoint.is_dir()
assert new_checkpoint.is_dir()
assert (tmp_path / "final_checkpoint").resolve() == old_checkpoint.resolve()
@pytest.mark.parametrize(
("field", "value"),
[
("best_score", 0.60),
("best_score", 0.50),
("patience", 2),
("tie_policy", "ignore"),
],
)
def test_interrupted_save_requires_strictly_better_compatible_new_policy(
tmp_path: Path,
field: str,
value: Any,
) -> None:
old_checkpoint, new_checkpoint, _, new_policy = _interrupted_native_save_layout(tmp_path)
if field in {"best_score"}:
new_policy["state"][field] = value
else:
new_policy[field] = value
_write_native_checkpoint_state(new_checkpoint, step=200, policy=new_policy)
assert recover_interrupted_checkpoint_eval_save(
tmp_path, patience=3, tie_policy="reset"
) is None
assert old_checkpoint.is_dir()
assert new_checkpoint.is_dir()
assert (tmp_path / "final_checkpoint").resolve() == old_checkpoint.resolve()
def test_interrupted_save_prefers_newer_sidecar_baseline_before_pruning(tmp_path: Path) -> None:
old_checkpoint, new_checkpoint, _, _ = _interrupted_native_save_layout(tmp_path)
# A lower evaluation after checkpoint-100 has no native checkpoint, but
# its sidecar is the current committed policy baseline. The prospective
# score must still beat that score, not merely the old trainer-state score.
sidecar_policy = _checkpoint_eval_policy_state(
step=100,
score=0.80,
evaluation_count=2,
lower_score_streak=1,
)
(tmp_path / "checkpoint_eval_state.json").write_text(
json.dumps({"version": 1, "checkpoint_eval_state": sidecar_policy}),
encoding="utf-8",
)
assert recover_interrupted_checkpoint_eval_save(tmp_path) is None
assert old_checkpoint.is_dir()
assert new_checkpoint.is_dir()
assert (tmp_path / "final_checkpoint").resolve() == old_checkpoint.resolve()
def test_find_checkpoint_evaluation_policy_prefers_live_callback_after_restore() -> None:
trainer = _Trainer()
stale_policy = CheckpointEvaluationTriggerCallback().policy
active_callback = CheckpointEvaluationTriggerCallback()
active_callback.policy.observe(0.9, step=4)
trainer.checkpoint_eval_policy = stale_policy
class _Handler:
callbacks = [active_callback]
trainer.callback_handler = _Handler()
assert find_checkpoint_evaluation_policy(trainer) is active_callback.policy