Files
Bogdan (Dan) Baciu da06b157cb fix(sleep): honor val_fraction and test_fraction in the nightly cycle (#235)
* fix(sleep): honor val_fraction and test_fraction in the nightly cycle

config.py documents val_fraction and test_fraction and assign_splits()
implements both, but the nightly path only ever forwarded the legacy
holdout_fraction alias: mine() could not carry the new knobs, so
test_fraction was dead config -- no untouched test split could exist and
no held-out test score was ever recorded.

- mine() now mirrors assign_splits(): val_fraction/test_fraction are the
  real controls, holdout_fraction stays a legacy alias with unchanged
  override semantics for existing callers.
- run_sleep_cycle() resolves the alias (documented value-based precedence,
  since the merged config has no key provenance) and passes both fractions
  through; both are now recorded in the evidence config row.
- Nights that produce test-split tasks score the night's FINAL documents
  on the untouched test split (same replay_batch + aggregate_scores path
  the experiment harness uses) and write a write-only
  test/held_out_score row to evidence.jsonl. The gate never reads it.
- Defaults are bit-for-bit unchanged: test_fraction=0.0 yields the legacy
  two-way split, no test tasks, no extra calls.

Tests: tests/test_split_wiring.py pins the wiring end to end (mine
forwarding, alias precedence, evidence row present/absent).

* fix(sleep): close PR235 review blockers on split hygiene (#235)

- Track config key provenance; preserve explicit val_fraction=0.0
- Make val/train top-ups hash-stable; drop order-dependent test carve
- Block recall of archived val/test tasks and tonight held-out ids
- Document split knobs; extend test_split_wiring regression suite

* test(sleep): 2x3 hardening for split/recall hygiene (#235)
2026-08-21 22:18:52 +08:00

202 lines
8.1 KiB
Python

"""val_fraction / test_fraction must actually flow from config to the splits.
The config documents three knobs (`holdout_fraction` as a legacy alias of
`val_fraction`, plus `test_fraction`), and ``assign_splits`` implements all
three -- but the nightly path only ever forwarded ``holdout_fraction``, so
``test_fraction`` was dead config: no untouched test split could exist and no
held-out test score was ever recorded. These tests pin the wiring end to end.
"""
from __future__ import annotations
import json
import os
import tempfile
import unittest
from skillopt_sleep.config import DEFAULTS, load_config
from skillopt_sleep.cycle import _resolve_split_fractions, run_sleep_cycle
from skillopt_sleep.mine import assign_splits, mine
from skillopt_sleep.types import TaskRecord
def _mk_tasks(n):
# Realistic mined tasks: exact-reference judged, unique stable ids.
return [
TaskRecord(
id=f"t{i:03d}", project="/repo/example",
intent=f"do the recurring thing number {i}",
reference_kind="exact", reference=f"answer {i}",
)
for i in range(n)
]
class TestMineForwardsFractions(unittest.TestCase):
def test_test_fraction_reaches_assign_splits(self):
tasks = mine(
[], llm_miner=lambda digests: _mk_tasks(60),
max_tasks=60, val_fraction=0.2, test_fraction=0.3, seed=7,
)
splits = {t.split for t in tasks}
self.assertIn("test", splits, "test_fraction did not reach assign_splits")
self.assertIn("val", splits)
self.assertIn("train", splits)
def test_default_call_is_two_way_like_before(self):
tasks = mine([], llm_miner=lambda digests: _mk_tasks(60), max_tasks=60)
self.assertEqual({t.split for t in tasks} - {"train", "val"}, set(),
"defaults must reproduce the legacy two-way split")
def test_legacy_holdout_alias_still_wins_when_passed(self):
a = mine([], llm_miner=lambda d: _mk_tasks(60), max_tasks=60,
holdout_fraction=0.6, seed=7)
b = mine([], llm_miner=lambda d: _mk_tasks(60), max_tasks=60,
val_fraction=0.6, seed=7)
self.assertEqual([t.split for t in a], [t.split for t in b])
class TestConfigAliasPrecedence(unittest.TestCase):
def _cfg(self, **over):
return load_config(invoked_project="/tmp/x", projects="invoked", **over)
def test_defaults_resolve_to_legacy_behavior(self):
val, test = _resolve_split_fractions(self._cfg())
self.assertEqual(val, DEFAULTS["val_fraction"])
self.assertEqual(test, DEFAULTS["test_fraction"])
def test_legacy_config_holdout_only_still_wins(self):
val, _ = _resolve_split_fractions(self._cfg(holdout_fraction=0.2))
self.assertEqual(val, 0.2)
def test_user_val_fraction_beats_stale_alias_default(self):
val, _ = _resolve_split_fractions(self._cfg(val_fraction=0.5))
self.assertEqual(val, 0.5)
def test_explicit_val_fraction_beats_explicit_alias(self):
val, _ = _resolve_split_fractions(
self._cfg(val_fraction=0.5, holdout_fraction=0.2))
self.assertEqual(val, 0.5)
def test_test_fraction_flows(self):
_, test = _resolve_split_fractions(self._cfg(test_fraction=0.25))
self.assertEqual(test, 0.25)
def test_explicit_zero_val_fraction_is_preserved(self):
val, _ = _resolve_split_fractions(self._cfg(val_fraction=0.0))
self.assertEqual(val, 0.0)
def test_explicit_val_at_default_beats_alias(self):
val, _ = _resolve_split_fractions(
self._cfg(val_fraction=0.34, holdout_fraction=0.5))
self.assertEqual(val, 0.34)
def test_invalid_fraction_sum_raises(self):
with self.assertRaises(ValueError):
_resolve_split_fractions(self._cfg(val_fraction=0.6, test_fraction=0.5))
class TestSplitStabilityAcrossNights(unittest.TestCase):
def test_appending_tasks_does_not_reassign_existing_test(self):
first = assign_splits(
_mk_tasks(4), val_fraction=0.34, test_fraction=0.10, seed=42,
)
splits_first = {t.id: t.split for t in first}
second = assign_splits(
_mk_tasks(5), val_fraction=0.34, test_fraction=0.10, seed=42,
)
splits_second = {t.id: t.split for t in second}
for task_id, split in splits_first.items():
self.assertEqual(
splits_second[task_id], split,
f"{task_id} changed split when a new task was appended",
)
def test_maintainer_repro_seed_42_t1_does_not_reassign_t4(self):
"""Hash splits are id-stable; appending t1 must not move t4's split."""
base = [TaskRecord(id=f"t{i}", project="/p", intent=f"task {i}") for i in (0, 3, 4)]
with_t1 = base + [TaskRecord(id="t1", project="/p", intent="task 1")]
first = assign_splits(list(base), val_fraction=0.34, test_fraction=0.10, seed=42)
second = assign_splits(list(with_t1), val_fraction=0.34, test_fraction=0.10, seed=42)
self.assertEqual(next(t for t in second if t.id == "t1").split, "test")
self.assertEqual(
next(t for t in first if t.id == "t4").split,
next(t for t in second if t.id == "t4").split,
)
class TestRecallSplitHygiene(unittest.TestCase):
def test_archived_test_tasks_are_never_recalled(self):
from skillopt_sleep.dream import recall_similar
new = TaskRecord(id="n1", project="/p", intent="validate login form")
archived_test = TaskRecord(
id="same-id", project="/p", intent="validate login form fields",
split="test",
)
recalled = recall_similar([new], [archived_test], k=1)
self.assertEqual(recalled, [])
def test_recall_honors_exclude_ids_for_tonights_held_out(self):
from skillopt_sleep.dream import recall_similar
held = TaskRecord(
id="held-out", project="/p", intent="validate login form fields",
split="train",
)
new_train = TaskRecord(
id="n1", project="/p", intent="validate login form", split="train",
)
recalled = recall_similar(
[new_train], [held], k=1, exclude_ids={"held-out"},
)
self.assertEqual(recalled, [])
class TestHeldOutScoreEvidence(unittest.TestCase):
"""A night with test-split tasks writes a write-only held-out score row."""
def _run_night(self, tasks):
with tempfile.TemporaryDirectory() as tmp:
cfg = load_config(
invoked_project=tmp, projects="invoked", backend="mock",
state_dir=os.path.join(tmp, "state"),
claude_home=os.path.join(tmp, ".claude"),
)
run_sleep_cycle(cfg, seed_tasks=tasks, dry_run=True)
ev_dir = os.path.join(cfg.state_dir, "evidence")
rows = []
for name in sorted(os.listdir(ev_dir)):
with open(os.path.join(ev_dir, name), encoding="utf-8") as fh:
rows += [json.loads(line) for line in fh if line.strip()]
return rows
def _seed(self, with_test):
tasks = _mk_tasks(6)
for i, t in enumerate(tasks):
t.split = "train" if i < 3 else ("val" if i < 5 else
("test" if with_test else "val"))
return tasks
def test_score_row_written_when_test_tasks_exist(self):
rows = self._run_night(self._seed(with_test=True))
score_rows = [r for r in rows
if r.get("stage") == "test"
and r.get("event") == "held_out_score"]
self.assertEqual(len(score_rows), 1)
row = score_rows[0]
self.assertEqual(row["n_test"], 1)
self.assertIn("hard", row)
self.assertIn("soft", row)
self.assertIn("accepted", row)
def test_no_score_row_without_test_tasks(self):
rows = self._run_night(self._seed(with_test=False))
self.assertEqual(
[r for r in rows if r.get("stage") == "test"], [],
"no test tasks => no held-out row => bit-for-bit legacy nights",
)
if __name__ == "__main__":
unittest.main()