fix(sleep): honor val_fraction and test_fraction in the nightly cycle - #235
Conversation
|
All good here? Tried a new (greatly expanded) approach anxiously waiting to see how it landed. |
|
Thanks — I retested the current head (
The branch also conflicts with current |
Yifan Yang (Yif-Yang)
left a comment
There was a problem hiding this comment.
Thanks for this, and sorry for the slow first response.
The core claim checks out: I confirmed on upstream/main that cycle.py forwarded only holdout_fraction and mine() dropped test_fraction entirely, so it really was dead config. I also confirmed held_out_score is written in exactly one place and read by no production code — the write-only property holds as documented. Suite on your branch: 1107 passed, 10 skipped, 130 subtests.
Two things to fix.
Blocker — archived test tasks can re-enter training via recall
cycle.py:585 archives every non-dream task, including the ones just tagged split='test'. On a later night with recall_k > 0, recall_similar (dream.py:83-94) pulls them out of the archive and re-emits them with split="train":
tags=list(h.tags) + ["recall"], split="train", origin="real",So a task held out as the untouched final measure becomes training material for a subsequent night, and — because splits are hash-stable over seed + t.id — it is still scored as "held-out" on that later night too. That silently converts the number this PR exists to produce back into a contaminated one.
It only triggers with recall_k > 0 and test_fraction > 0, so no current user is affected (recall_k defaults to 0). But that combination is precisely the setup this feature invites, so I'd like it closed before merge. recall_similar should skip tasks whose archived split is test, or the archive write should exclude them.
I'll note the surrounding design is careful about exactly this — consolidate.py:85-90 explicitly refuses to fall back to test as train or val, with a comment saying so. The recall path just wasn't covered by that reasoning.
Should fix — value-based alias resolution has an unreachable case
_resolve_split_fractions infers intent from values because the merged config has no key provenance, so the alias wins only when val == DEFAULTS["val_fraction"] (0.34). That can't distinguish "user left val_fraction alone" from "user explicitly wrote val_fraction: 0.34". Concretely:
_resolve_split_fractions(load_config(val_fraction=0.34, holdout_fraction=0.5))
# -> (0.5, 0.0)The explicit val_fraction is discarded — the opposite of your own docstring ("A user-changed val_fraction always beats the alias"). Your test test_explicit_val_fraction_beats_explicit_alias uses 0.5/0.2, which dodges the collision.
The trigger is narrow and the blast radius is one ratio, so this is not a blocker — but there's a clean fix available: load_config already does data = dict(DEFAULTS); data.update(...), so real provenance can be recorded there instead of guessed. Also note holdout_fraction ships with a default of 0.34 (config.py:44), so it is never None and the is not None check does nothing.
Minor
- Conflicts with main in
skillopt_sleep/cycle.py; needs a rebase. I merged it locally against current main and the result was clean — 1358 passed, 10 skipped, 269 subtests — so the conflict is mechanical. - Neither
val_fraction/test_fractionnor the alias precedence rule appears anywhere indocs/. Since the precedence is subtle, please document it.
On cost: I want to correct a concern I had initially. Enabling test_fraction does not add rollouts — mine() is capped by max_tasks_per_night and assign_splits partitions that same fixed pool, and test tasks are excluded from consolidation, so they stop costing the 2-4 rollouts per train/val task and cost exactly one each here. Net cost goes down. No gating needed.
Good, well-scoped fix. Close the recall path and I'll merge.
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).
- 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
427096a to
bbfd321
Compare
|
Coming with the required work. |
|
Thanks for the thorough review. Rebased onto current
Happy to adjust if you want content-hash disjointness in this PR rather than a follow-up. |
What Problem This Solves
Fixes an issue where operators who set
test_fraction(orval_fraction) in their Sleep config silently got neither: the nightly cycle only ever forwarded the legacyholdout_fractionalias, sotest_fractionwas dead config. No untouched test split could exist, which means every nightly lift number was a validation-split number, and validation is the same signal candidate skills are selected against.The first review round also surfaced three hygiene bugs that made the wiring unsafe to merge: archived test tasks could leak back into dream recall as train, split assignment could flip when task order changed, and explicit
val_fraction: 0.0was overwritten by the alias default.Why This Change Was Made
config.pydocumentsval_fractionandtest_fraction, andassign_splits()already implements both (withholdout_fractionas its alias), butmine()could not carry them andcycle.pypassed only the alias. This PR wires what the config promises and closes the review blockers:mine()mirrors theassign_splits()knobs;holdout_fractionstays a legacy alias.load_config()records_user_config_keysso alias precedence follows config key provenance, not value guessing (holdout_fractionwins only when the user set it and did not setval_fraction; explicitval_fraction: 0.0is preserved).recall_similar()acceptsexclude_idsso archived val/test tasks and tonight's held-out ids never re-enter dream recall as train whenrecall_k > 0.test/held_out_scorerow toevidence.jsonl. The gate never reads it.docs/sleep/README.mddocuments the split knobs and alias rules.Non-goals: no gate-semantics change, and no content-hash near-duplicate hardening (separate follow-up).
Rebased onto current upstream
mainat3c8873f(preserves OpenCode evidence config from #241).Project Fit
consolidate._split()already documents that test is held out entirely and scored by the caller; in the nightly, this PR is that caller.User Impact
Operators setting
test_fractionnow get what the config promises: a real untouched test split and a per-night held-out score row inevidence.jsonl. Operators who leave defaults alone see zero change: withtest_fraction: 0.0the split is bit-for-bit the legacy two-way split, no test tasks exist, and the scoring block never runs (no extra backend calls, no extra tokens).Proof
Before (stock
mainat3c8873f): the wiring tests cannot pass; recall can treat archived test tasks as train; split labels can change when task order changes;val_fraction: 0.0silently becomes0.34.After (this branch, head
bbfd321):Baseline at rebase parent
3c8873fbefore this diff: 1347 passed, 11 skipped. The delta is exactly the 17 new tests, with zero regressions.Pinned by tests: default two-way behavior unchanged; alias provenance (not value collision);
val_fraction=0.0honored; split stability under append order; recall excludes val/test archives; no score row without test tasks.Academic Support
The measurement-validity motivation, briefly:
Testing
New:
tests/test_split_wiring.py, 17 tests pinning mine/cycle wiring, alias provenance, zero val_fraction, split stability, recall exclusions, and held-out scoring. Full suite:python -m pytest -q.exc-skillopt-3os-receipton fork branch after push (prior baseline inresearch/2026-08-17/receipts/2026-08-17-baseline-3os.md; expect +17 wiring passes, zero new failures)Limitations & Negative Results
test_fractionreduces train and val mass;0.0stays the default for that reason.Reproduce It Yourself