Preserve continuous rollout hard scores

Rollout hard scores can be continuous when smoothed rewards are used. Converting the field through int() turned values like 0.75 into 0, which loses signal before scoring and serialization.

Constraint: Keep the existing RolloutResult dictionary shape unchanged.

Rejected: Clamp hard scores to 0 or 1 | contradicts existing continuous-score support in compute_score and sleep replay types.

Confidence: high

Scope-risk: narrow

Reversibility: clean

Directive: Treat hard as numeric reward data, not only a binary label.

Tested: uv run --with pytest pytest tests/test_types.py tests/test_scoring.py -q

Tested: uv run --with ruff ruff check skillopt/types.py tests/test_types.py

Not-tested: Full benchmark rollouts.
This commit is contained in:
Ziiii
2026-07-06 15:42:07 +08:00
parent e4ea6a6771
commit 0c30b778fb
2 changed files with 20 additions and 7 deletions
+5 -5
View File
@@ -11,12 +11,12 @@ BatchSpec — from skillopt.datasets.base
""" """
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field, fields as dc_fields from dataclasses import dataclass, field
from dataclasses import fields as dc_fields
from typing import Any, Literal from typing import Any, Literal
from skillopt.evaluation.gate import GateAction, GateResult # noqa: F401
from skillopt.datasets.base import BatchSpec # noqa: F401 from skillopt.datasets.base import BatchSpec # noqa: F401
from skillopt.evaluation.gate import GateAction, GateResult # noqa: F401
# ── Atomic types ───────────────────────────────────────────────────────── # ── Atomic types ─────────────────────────────────────────────────────────
@@ -109,7 +109,7 @@ class RolloutResult:
""" """
id: str id: str
hard: int hard: float
soft: float soft: float
n_turns: int = 0 n_turns: int = 0
fail_reason: str = "" fail_reason: str = ""
@@ -142,7 +142,7 @@ class RolloutResult:
extras = {k: v for k, v in d.items() if k not in known} extras = {k: v for k, v in d.items() if k not in known}
return cls( return cls(
id=str(d.get("id", "")), id=str(d.get("id", "")),
hard=int(d.get("hard", 0)), hard=float(d.get("hard", 0)),
soft=float(d.get("soft", 0.0)), soft=float(d.get("soft", 0.0)),
n_turns=int(d.get("n_turns", 0)), n_turns=int(d.get("n_turns", 0)),
fail_reason=str(d.get("fail_reason", "")), fail_reason=str(d.get("fail_reason", "")),
+15 -2
View File
@@ -3,8 +3,7 @@ from __future__ import annotations
import pytest import pytest
from skillopt.types import Edit, Patch from skillopt.types import Edit, Patch, RolloutResult
# ── Edit ──────────────────────────────────────────────────────────────────── # ── Edit ────────────────────────────────────────────────────────────────────
@@ -247,3 +246,17 @@ class TestPatchEdgeCases:
assert isinstance(p.edits[0], Edit) assert isinstance(p.edits[0], Edit)
assert p.edits[0].op == "append" assert p.edits[0].op == "append"
assert p.edits[0].content == "hello" assert p.edits[0].content == "hello"
# ── RolloutResult ────────────────────────────────────────────────────────────
class TestRolloutResultRoundTrip:
"""RolloutResult.to_dict() / RolloutResult.from_dict() round-trip."""
def test_preserves_fractional_hard_score(self) -> None:
"""Hard can be a continuous reward and must not be truncated."""
result = RolloutResult.from_dict({"id": "episode-1", "hard": 0.75, "soft": 0.5})
assert result.hard == pytest.approx(0.75)
assert result.to_dict()["hard"] == pytest.approx(0.75)